1use super::api::{is_stale_revision, SlidesApi};
9use super::client::SlidesClient;
10use super::read::{flatten, selected_slide_ids};
11use super::write_types::SlidesRequest;
12use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
13use crate::drive::client::DriveClient;
14use crate::drive::files_api::FilesApi;
15use crate::drive::folder_ancestry;
16use crate::drive::lease::check::{
17 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
18 LeasedWrite,
19};
20use crate::drive::types::{GOOGLE_SHORTCUT_MIME_TYPE, GOOGLE_SLIDES_MIME_TYPE};
21use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
22use crate::request_log::{self, DriveMutationOutcome};
23use serde::Serialize;
24use std::path::PathBuf;
25use std::time::{Duration, Instant};
26
27#[derive(Debug, Clone)]
29pub struct WriteOptions {
30 pub presentation_id: String,
32 pub search: String,
34 pub replace: String,
36 pub match_case: bool,
38 pub slides: Vec<String>,
40 pub dry_run: bool,
42 pub lease_token: Option<String>,
44 pub ledger_path: PathBuf,
46}
47impl WriteOptions {
48 fn validate(&self) -> Result<(), String> {
49 if self.search.is_empty() {
50 return Err("--search cannot be empty".into());
51 }
52 if self.slides.iter().any(String::is_empty) {
53 return Err("--slide cannot be empty".into());
54 }
55 Ok(())
56 }
57}
58
59#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
61#[serde(tag = "status", rename_all = "kebab-case")]
62pub enum WriteResult {
63 WouldReplace {
65 occurrences: usize,
67 },
68 Replaced {
70 occurrences_changed: i64,
72 },
73 AppliedResponseUnreadable {
75 detail: String,
77 },
78 RefusedNotAPresentation {
80 mime_type: String,
82 },
83 RefusedShortcut,
85 RefusedNoVisibleParents,
87 RefusedNoRevisionId,
89 Blocked {
91 decided_by: Option<DecidingRule>,
93 },
94 RefusedNoLease,
96 RefusedLeaseExpired,
98 RefusedLeaseWrongFile,
100 RefusedLeaseStale,
102 StaleRevision {
104 required_revision_id: String,
106 detail: String,
108 },
109 Failed {
111 detail: String,
113 },
114}
115impl FromLeaseRefusal for WriteResult {
116 fn from_no_lease() -> Self {
117 Self::RefusedNoLease
118 }
119 fn from_lease_expired() -> Self {
120 Self::RefusedLeaseExpired
121 }
122 fn from_lease_wrong_file() -> Self {
123 Self::RefusedLeaseWrongFile
124 }
125 fn from_lease_stale() -> Self {
126 Self::RefusedLeaseStale
127 }
128 fn from_lease_failed(detail: String) -> Self {
129 Self::Failed { detail }
130 }
131}
132impl WriteResult {
133 fn log_status(&self) -> &'static str {
134 match self {
135 Self::WouldReplace { .. } => "would-replace",
136 Self::Replaced { .. } => "replaced",
137 Self::AppliedResponseUnreadable { .. } => "applied-response-unreadable",
138 Self::RefusedNotAPresentation { .. } => "refused-not-a-presentation",
139 Self::RefusedShortcut => "refused-shortcut",
140 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
141 Self::RefusedNoRevisionId => "refused-no-revision-id",
142 Self::Blocked { .. } => "blocked",
143 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
144 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
145 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
146 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
147 Self::StaleRevision { .. } => "stale-revision",
148 Self::Failed { .. } => "failed",
149 }
150 }
151}
152
153#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
155pub struct WriteOutcome {
156 pub presentation_id: String,
158 pub file_name: Option<String>,
160 pub resolved_folder_id: Option<String>,
162 pub required_revision_id: Option<String>,
164 pub result: WriteResult,
166}
167impl JsonlSerialize for WriteOutcome {
168 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
169 write_scalar_jsonl(self, out)
170 }
171}
172
173pub fn count_occurrences(text: &str, search: &str, match_case: bool) -> usize {
176 if search.is_empty() {
177 return 0;
178 }
179 if match_case {
180 text.matches(search).count()
181 } else {
182 text.to_lowercase().matches(&search.to_lowercase()).count()
183 }
184}
185
186pub async fn write(
188 drive: &DriveClient,
189 slides: &SlidesClient,
190 opts: &WriteOptions,
191 rules: &[FolderPermissionRule],
192) -> WriteOutcome {
193 let started = Instant::now();
194 let outcome = write_inner(drive, slides, opts, rules).await;
195 if !opts.dry_run {
196 record_attempt(&outcome, started.elapsed());
197 }
198 outcome
199}
200async fn write_inner(
201 drive: &DriveClient,
202 slides: &SlidesClient,
203 opts: &WriteOptions,
204 rules: &[FolderPermissionRule],
205) -> WriteOutcome {
206 let bare = |result| WriteOutcome {
207 presentation_id: opts.presentation_id.clone(),
208 file_name: None,
209 resolved_folder_id: None,
210 required_revision_id: None,
211 result,
212 };
213
214 if let Err(detail) = opts.validate() {
215 return bare(WriteResult::Failed { detail });
216 }
217
218 let files_api = FilesApi::new(drive);
219 let target = match files_api.get_metadata(&opts.presentation_id).await {
220 Ok(target) => target,
221 Err(err) => {
222 return bare(WriteResult::Failed {
223 detail: err.to_string(),
224 })
225 }
226 };
227
228 let with_target = |result| WriteOutcome {
229 presentation_id: opts.presentation_id.clone(),
230 file_name: Some(target.name.clone()),
231 resolved_folder_id: None,
232 required_revision_id: None,
233 result,
234 };
235
236 if target.mime_type == GOOGLE_SHORTCUT_MIME_TYPE {
237 return with_target(WriteResult::RefusedShortcut);
238 }
239 if target.mime_type != GOOGLE_SLIDES_MIME_TYPE {
240 return with_target(WriteResult::RefusedNotAPresentation {
241 mime_type: target.mime_type.clone(),
242 });
243 }
244
245 let evaluated = match folder_ancestry::resolve_decision_for_file_target(
246 &files_api,
247 &target,
248 DriveOperation::SlidesWrite,
249 rules,
250 )
251 .await
252 {
253 Ok(evaluated) => evaluated,
254 Err(err) => {
255 return with_target(WriteResult::Failed {
256 detail: err.to_string(),
257 })
258 }
259 };
260
261 if evaluated.source == folder_ancestry::DecisionSource::NoVisibleParents {
262 return with_target(WriteResult::RefusedNoVisibleParents);
263 }
264
265 let folder_ancestry::FileTargetDecision {
266 decision,
267 resolved_folder_id,
268 requires_lease,
269 ..
270 } = evaluated;
271
272 let gated = |result, revision: Option<String>| WriteOutcome {
273 presentation_id: opts.presentation_id.clone(),
274 file_name: Some(target.name.clone()),
275 resolved_folder_id: resolved_folder_id.clone(),
276 required_revision_id: revision,
277 result,
278 };
279
280 if decision.verdict == write_gate::Verdict::Deny {
281 return gated(
282 WriteResult::Blocked {
283 decided_by: decision.decided_by,
284 },
285 None,
286 );
287 }
288
289 let api = SlidesApi::new(slides);
290 let presentation = match api.get_presentation(&opts.presentation_id).await {
291 Ok(presentation) => presentation,
292 Err(err) => {
293 return gated(
294 WriteResult::Failed {
295 detail: err.to_string(),
296 },
297 None,
298 )
299 }
300 };
301
302 let Some(revision_id) = presentation.revision_id.clone().filter(|id| !id.is_empty()) else {
303 return gated(WriteResult::RefusedNoRevisionId, None);
304 };
305
306 let pages = match selected_slide_ids(&presentation, &opts.slides) {
307 Ok(pages) if !pages.is_empty() => pages,
308 Ok(_) => {
309 return gated(
310 WriteResult::Failed {
311 detail: "No ordinary slides to replace text on".into(),
312 },
313 Some(revision_id),
314 )
315 }
316 Err(err) => {
317 return gated(
318 WriteResult::Failed {
319 detail: err.to_string(),
320 },
321 Some(revision_id),
322 )
323 }
324 };
325 let preview = WriteResult::WouldReplace {
326 occurrences: flatten(&presentation, &pages, false)
327 .iter()
328 .map(|element| count_occurrences(&element.text, &opts.search, opts.match_case))
329 .sum(),
330 };
331
332 if opts.dry_run {
333 return gated(preview, Some(revision_id));
334 }
335
336 let leased = LeasedWrite {
337 log_prefix: "drive slides write",
338 operation: "slides-replace",
339 ledger_path: &opts.ledger_path,
340 file_id: &opts.presentation_id,
341 };
342 let lease_grant = match gate_optional_leased_write(
343 leased,
344 &files_api,
345 requires_lease,
346 opts.lease_token.as_deref(),
347 )
348 .await
349 {
350 Ok(grant) => grant,
351 Err(err) => return gated(err.into_result(), Some(revision_id)),
352 };
353
354 let result = match conclude_native_leased_write(
355 leased,
356 &lease_grant,
357 &files_api,
358 api.batch_update(
359 &opts.presentation_id,
360 SlidesRequest::replace_all_text(&opts.search, &opts.replace, opts.match_case, &pages),
361 &revision_id,
362 )
363 .await,
364 |_| "Slides batchUpdate failed; inspect the command outcome for details".to_string(),
365 )
366 .await
367 {
368 Ok(Ok(response)) => WriteResult::Replaced {
369 occurrences_changed: response.occurrences_changed_for_replace(),
370 },
371 Ok(Err(detail)) => WriteResult::AppliedResponseUnreadable { detail },
372 Err(err) => {
373 let detail = err.to_string();
374 if is_stale_revision(&err) {
375 WriteResult::StaleRevision {
376 required_revision_id: revision_id.clone(),
377 detail,
378 }
379 } else {
380 WriteResult::Failed { detail }
381 }
382 }
383 };
384 drop(lease_grant);
385 gated(result, Some(revision_id))
386}
387
388fn record_attempt(outcome: &WriteOutcome, duration: Duration) {
389 let decided_by = match &outcome.result {
390 WriteResult::Blocked { decided_by } => decided_by.as_ref(),
391 _ => None,
392 };
393 let fields = write_gate::decided_by_log_fields(decided_by);
394 let occurrences_changed = match outcome.result {
395 WriteResult::Replaced {
396 occurrences_changed,
397 } => Some(occurrences_changed),
398 _ => None,
399 };
400 request_log::record_drive_mutation(DriveMutationOutcome {
403 operation: "slides-replace",
404 file_id: outcome.presentation_id.clone(),
405 file_name: outcome.file_name.clone().unwrap_or_default(),
406 status: outcome.result.log_status().into(),
407 resolved_folder_id: outcome.resolved_folder_id.clone(),
408 decided_by_folder_id: fields.folder_id,
409 decided_by_depth: fields.depth,
410 decided_by_file_id: fields.file_id,
411 occurrences_changed,
412 required_revision_id: outcome.required_revision_id.clone(),
413 duration,
414 ..Default::default()
415 });
416}
417
418pub fn describe(outcome: &WriteOutcome) -> String {
420 let name = outcome
421 .file_name
422 .as_deref()
423 .unwrap_or(&outcome.presentation_id);
424 match &outcome.result {
425 WriteResult::WouldReplace { occurrences } => format!("Would replace: {occurrences} estimated occurrence(s) in '{name}' (ordinary slides only)"),
426 WriteResult::Replaced { occurrences_changed } => format!("Replaced: {occurrences_changed} occurrence(s) in '{name}'"),
427 WriteResult::AppliedResponseUnreadable { detail } => format!("Applied: '{name}', but the response was unreadable; inspect the presentation before retrying: {detail}"),
428 WriteResult::RefusedNotAPresentation { mime_type } => format!("Refused: '{name}' is not a Google Slides presentation (mimeType: {mime_type})"),
429 WriteResult::RefusedShortcut => format!("Refused: '{name}' is a shortcut; use the target presentation id instead"),
430 WriteResult::RefusedNoVisibleParents => format!("Refused: '{name}' has no visible parent; grant its file_id the slides-write operation in write_permissions.rules"),
431 WriteResult::RefusedNoRevisionId => format!("Refused: '{name}' returned no revision id; request edit access or check the account in use"),
432 WriteResult::Blocked { decided_by } => match decided_by {
433 Some(rule) => format!("Blocked: '{name}' — refused by rule on {} {}{}", rule.kind_label(), rule.id(), rule.depth_suffix()),
434 None => format!("Blocked: '{name}' — refused by default policy (no matching rule)"),
435 },
436 WriteResult::RefusedNoLease => LeaseGateRefusal::NoLease.describe_line(&outcome.presentation_id, &format!("'{name}'")).unwrap_or_default(),
437 WriteResult::RefusedLeaseExpired => LeaseGateRefusal::Expired.describe_line(&outcome.presentation_id, &format!("'{name}'")).unwrap_or_default(),
438 WriteResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.describe_line(&outcome.presentation_id, &format!("'{name}'")).unwrap_or_default(),
439 WriteResult::RefusedLeaseStale => LeaseGateRefusal::Stale.describe_line(&outcome.presentation_id, &format!("'{name}'")).unwrap_or_default(),
440 WriteResult::StaleRevision { .. } => format!("Refused: '{name}' changed since it was read; nothing was written. Re-run against the current revision."),
441 WriteResult::Failed { detail } => format!("Failed: '{name}': {detail}"),
442 }
443}
444
445#[cfg(test)]
446#[allow(clippy::unwrap_used, clippy::expect_used)]
447mod tests {
448 use super::*;
449 use crate::drive::slides::client::test_support::mock_clients as clients;
450 use crate::drive::test_support::seed_lease;
451 use crate::drive::types::GOOGLE_DOC_MIME_TYPE;
452 use wiremock::matchers::{body_json, method, path};
453 use wiremock::{Mock, MockServer, ResponseTemplate};
454
455 fn opts() -> WriteOptions {
456 WriteOptions {
457 presentation_id: "p1".into(),
458 search: "Q3".into(),
459 replace: "Q4".into(),
460 match_case: true,
461 slides: vec![],
462 dry_run: false,
463 lease_token: None,
464 ledger_path: PathBuf::new(),
465 }
466 }
467 fn rule(require_lease: bool) -> FolderPermissionRule {
468 FolderPermissionRule {
469 file_id: Some("p1".into()),
470 folder_id: None,
471 recursive: false,
472 allow: std::iter::once(DriveOperation::SlidesWrite).collect(),
473 deny: std::collections::HashSet::default(),
474 require_lease,
475 }
476 }
477 async fn mount_file(server: &MockServer, mime: &str) {
478 Mock::given(method("GET"))
479 .and(path("/drive/v3/files/p1"))
480 .respond_with(ResponseTemplate::new(200).set_body_json(
481 serde_json::json!({"id":"p1","name":"Deck","mimeType":mime,"version":"1"}),
482 ))
483 .mount(server)
484 .await;
485 }
486 fn text_element(id: &str, text: &str) -> serde_json::Value {
487 serde_json::json!({"objectId":id,"shape":{"text":{"textElements":[{"textRun":{"content":text}}]}}})
488 }
489 fn presentation(revision: Option<&str>) -> serde_json::Value {
490 let mut p = serde_json::json!({"presentationId":"p1", "slides":[
491 {"objectId":"s1","pageElements":[text_element("e1","Q3 and q3")],"slideProperties":{"notesPage":{"objectId":"n1","pageElements":[text_element("note","Q3")]}}},
492 {"objectId":"s2","pageElements":[text_element("e2","Q3")]}
493 ], "masters":[{"objectId":"master","pageElements":[text_element("m","Q3")]}], "layouts":[{"objectId":"layout","pageElements":[text_element("l","Q3")]}]});
494 if let Some(revision) = revision {
495 p["revisionId"] = revision.into();
496 }
497 p
498 }
499 async fn mount_presentation(server: &MockServer, p: serde_json::Value) {
500 Mock::given(method("GET"))
501 .and(path("/v1/presentations/p1"))
502 .respond_with(ResponseTemplate::new(200).set_body_json(p))
503 .expect(1)
504 .mount(server)
505 .await;
506 }
507 fn batch() -> wiremock::MockBuilder {
508 Mock::given(method("POST")).and(path("/v1/presentations/p1:batchUpdate"))
509 }
510 async fn forbid_batch(server: &MockServer) {
511 batch()
512 .respond_with(ResponseTemplate::new(500))
513 .expect(0)
514 .mount(server)
515 .await;
516 }
517 fn expected_body(pages: &[&str], case: bool) -> serde_json::Value {
518 serde_json::json!({"requests":[{"replaceAllText":{"containsText":{"text":"Q3","matchCase":case},"replaceText":"Q4","pageObjectIds":pages}}],"writeControl":{"requiredRevisionId":"fresh-revision"}})
519 }
520 async fn ready(server: &MockServer) -> (DriveClient, SlidesClient) {
521 let clients = clients(server).await;
522 mount_file(server, GOOGLE_SLIDES_MIME_TYPE).await;
523 mount_presentation(server, presentation(Some("fresh-revision"))).await;
524 clients
525 }
526
527 #[tokio::test]
528 async fn mime_and_shortcut_refusals_precede_gate_and_slides_requests() {
529 for (mime, result) in [
530 (
531 GOOGLE_DOC_MIME_TYPE,
532 WriteResult::RefusedNotAPresentation {
533 mime_type: GOOGLE_DOC_MIME_TYPE.into(),
534 },
535 ),
536 (GOOGLE_SHORTCUT_MIME_TYPE, WriteResult::RefusedShortcut),
537 ] {
538 let server = MockServer::start().await;
539 let (drive, slides) = clients(&server).await;
540 mount_file(&server, mime).await;
541 Mock::given(path("/v1/presentations/p1"))
542 .respond_with(ResponseTemplate::new(500))
543 .expect(0)
544 .mount(&server)
545 .await;
546 forbid_batch(&server).await;
547 assert_eq!(
548 write_inner(&drive, &slides, &opts(), &[rule(false)])
549 .await
550 .result,
551 result
552 );
553 }
554 }
555 #[tokio::test]
556 async fn denied_target_makes_zero_slides_calls() {
557 let server = MockServer::start().await;
558 let (drive, slides) = clients(&server).await;
559 mount_file(&server, GOOGLE_SLIDES_MIME_TYPE).await;
560 Mock::given(path("/v1/presentations/p1"))
561 .respond_with(ResponseTemplate::new(500))
562 .expect(0)
563 .mount(&server)
564 .await;
565 forbid_batch(&server).await;
566 let mut deny = rule(false);
567 deny.allow.clear();
568 deny.deny.insert(DriveOperation::SlidesWrite);
569 assert!(matches!(
570 write_inner(&drive, &slides, &opts(), &[deny]).await.result,
571 WriteResult::Blocked { .. }
572 ));
573 }
574 #[tokio::test]
575 async fn validation_precedes_every_network_call() {
576 let server = MockServer::start().await;
577 let (drive, slides) = clients(&server).await;
578 for invalid in [
579 WriteOptions {
580 search: String::new(),
581 ..opts()
582 },
583 WriteOptions {
584 slides: vec![String::new()],
585 ..opts()
586 },
587 ] {
588 assert!(matches!(
589 write_inner(&drive, &slides, &invalid, &[rule(false)])
590 .await
591 .result,
592 WriteResult::Failed { .. }
593 ));
594 }
595 assert!(server.received_requests().await.unwrap().is_empty());
596 }
597 #[tokio::test]
598 async fn missing_or_empty_revision_is_refused() {
599 for revision in [None, Some("")] {
600 let server = MockServer::start().await;
601 let (drive, slides) = clients(&server).await;
602 mount_file(&server, GOOGLE_SLIDES_MIME_TYPE).await;
603 mount_presentation(&server, presentation(revision)).await;
604 forbid_batch(&server).await;
605 assert_eq!(
606 write_inner(&drive, &slides, &opts(), &[rule(false)])
607 .await
608 .result,
609 WriteResult::RefusedNoRevisionId
610 );
611 }
612 }
613 #[tokio::test]
614 async fn one_request_asserts_fresh_revision_and_explicit_ordinary_slide_ids() {
615 let server = MockServer::start().await;
616 let (drive, slides) = ready(&server).await;
617 batch()
618 .and(body_json(expected_body(&["s1", "s2"], true)))
619 .respond_with(ResponseTemplate::new(200).set_body_json(
620 serde_json::json!({"replies":[{"replaceAllText":{"occurrencesChanged":2}}]}),
621 ))
622 .expect(1)
623 .mount(&server)
624 .await;
625 let result = write_inner(&drive, &slides, &opts(), &[rule(false)]).await;
626 assert_eq!(
627 result.result,
628 WriteResult::Replaced {
629 occurrences_changed: 2
630 }
631 );
632 assert_eq!(
633 result.required_revision_id.as_deref(),
634 Some("fresh-revision")
635 );
636 }
637 #[tokio::test]
638 async fn slide_filter_and_case_option_reach_the_wire() {
639 let server = MockServer::start().await;
640 let (drive, slides) = ready(&server).await;
641 batch()
642 .and(body_json(expected_body(&["s2"], false)))
643 .respond_with(
644 ResponseTemplate::new(200)
645 .set_body_json(serde_json::json!({"replies":[{"replaceAllText":{}}]})),
646 )
647 .expect(1)
648 .mount(&server)
649 .await;
650 let opts = WriteOptions {
651 slides: vec!["s2".into(), "s2".into()],
652 match_case: false,
653 ..opts()
654 };
655 assert_eq!(
656 write_inner(&drive, &slides, &opts, &[rule(false)])
657 .await
658 .result,
659 WriteResult::Replaced {
660 occurrences_changed: 0
661 }
662 );
663 }
664 #[tokio::test]
665 async fn dry_run_counts_only_selected_slide_text_and_never_requires_lease() {
666 for (filter, case, count) in [
667 (vec![], true, 2),
668 (vec![], false, 3),
669 (vec!["s2".into()], true, 1),
670 ] {
671 let server = MockServer::start().await;
672 let (drive, slides) = ready(&server).await;
673 forbid_batch(&server).await;
674 let opts = WriteOptions {
675 dry_run: true,
676 slides: filter,
677 match_case: case,
678 ..opts()
679 };
680 assert_eq!(
681 write_inner(&drive, &slides, &opts, &[rule(true)])
682 .await
683 .result,
684 WriteResult::WouldReplace { occurrences: count }
685 );
686 }
687 }
688 #[tokio::test]
689 async fn preview_joins_runs_but_never_matches_across_objects_or_cells() {
690 let server = MockServer::start().await;
691 let (drive, slides) = clients(&server).await;
692 mount_file(&server, GOOGLE_SLIDES_MIME_TYPE).await;
693 mount_presentation(&server, serde_json::json!({"revisionId":"r","slides":[{"objectId":"s","pageElements":[text_element("a","Q"),text_element("b","3"),
694 {"objectId":"c","shape":{"text":{"textElements":[{"textRun":{"content":"Q"}},{"textRun":{"content":"3"}}]}}},
695 {"objectId":"t","table":{"tableRows":[{"tableCells":[{"text":{"textElements":[{"textRun":{"content":"Q"}}]}},{"text":{"textElements":[{"textRun":{"content":"3"}}]}}]}]}}
696 ]}]})).await;
697 forbid_batch(&server).await;
698 assert_eq!(
699 write_inner(
700 &drive,
701 &slides,
702 &WriteOptions {
703 dry_run: true,
704 ..opts()
705 },
706 &[rule(true)]
707 )
708 .await
709 .result,
710 WriteResult::WouldReplace { occurrences: 1 }
711 );
712 }
713 #[tokio::test]
714 async fn a_zero_estimate_still_sends_the_mutation() {
715 let server = MockServer::start().await;
716 let (drive, slides) = clients(&server).await;
717 mount_file(&server, GOOGLE_SLIDES_MIME_TYPE).await;
718 mount_presentation(&server, serde_json::json!({"revisionId":"fresh-revision","slides":[{"objectId":"s1","pageElements":[]}]})).await;
719 batch()
720 .and(body_json(expected_body(&["s1"], true)))
721 .respond_with(
722 ResponseTemplate::new(200).set_body_json(serde_json::json!({"replies":[{}]})),
723 )
724 .expect(1)
725 .mount(&server)
726 .await;
727 assert_eq!(
728 write_inner(&drive, &slides, &opts(), &[rule(false)])
729 .await
730 .result,
731 WriteResult::Replaced {
732 occurrences_changed: 0
733 }
734 );
735 }
736 #[tokio::test]
737 async fn invalid_filters_and_empty_decks_never_mutate() {
738 for filter in ["notes", "master", "unknown", "empty-deck"] {
739 let server = MockServer::start().await;
740 let (drive, slides) = clients(&server).await;
741 mount_file(&server, GOOGLE_SLIDES_MIME_TYPE).await;
742 let mut p = presentation(Some("r"));
743 let slides_filter = if filter == "empty-deck" {
744 p["slides"] = serde_json::json!([]);
745 vec![]
746 } else {
747 vec![filter.into()]
748 };
749 mount_presentation(&server, p).await;
750 forbid_batch(&server).await;
751 assert!(matches!(
752 write_inner(
753 &drive,
754 &slides,
755 &WriteOptions {
756 slides: slides_filter,
757 ..opts()
758 },
759 &[rule(false)]
760 )
761 .await
762 .result,
763 WriteResult::Failed { .. }
764 ));
765 }
766 }
767 #[tokio::test]
768 async fn stale_revision_is_distinct_from_unrelated_bad_request() {
769 for (message, stale) in [
770 (
771 "The required revision ID 'r' does not match the latest revision.",
772 true,
773 ),
774 ("Unknown page object ID", false),
775 ] {
776 let server = MockServer::start().await;
777 let (drive, slides) = ready(&server).await;
778 batch().respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({"error":{"code":400,"status":"INVALID_ARGUMENT","message":message}}))).expect(1).mount(&server).await;
779 let result = write_inner(&drive, &slides, &opts(), &[rule(false)])
780 .await
781 .result;
782 assert_eq!(matches!(result, WriteResult::StaleRevision { .. }), stale);
783 if !stale {
784 assert!(matches!(result, WriteResult::Failed { .. }));
785 }
786 }
787 }
788 #[tokio::test]
789 async fn unreadable_success_is_applied_and_concludes_the_lease_audit() {
790 let server = MockServer::start().await;
791 let (drive, slides) = ready(&server).await;
792 batch()
793 .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
794 .expect(1)
795 .mount(&server)
796 .await;
797 let dir = tempfile::tempdir().unwrap();
798 let ledger_path = dir.path().join("ledger.jsonl");
799 let lease_token = Some(seed_lease(&ledger_path, "p1", "1"));
800 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
801 let outcome = write_inner(
802 &drive,
803 &slides,
804 &WriteOptions {
805 lease_token,
806 ledger_path,
807 ..opts()
808 },
809 &[rule(true)],
810 )
811 .await;
812 assert!(matches!(
813 outcome.result,
814 WriteResult::AppliedResponseUnreadable { .. }
815 ));
816 assert_eq!(audit.verdicts(), ["pending", "allowed"]);
817 }
818 #[tokio::test]
819 async fn drive_lease_refusals_never_mutate_even_when_optional() {
820 for (mode, expected) in [
821 ("missing", WriteResult::RefusedNoLease),
822 ("expired", WriteResult::RefusedLeaseExpired),
823 ("wrong-file", WriteResult::RefusedLeaseWrongFile),
824 ("stale", WriteResult::RefusedLeaseStale),
825 ("optional-stale", WriteResult::RefusedLeaseStale),
826 ] {
827 let server = MockServer::start().await;
828 let (drive, slides) = ready(&server).await;
829 forbid_batch(&server).await;
830 let dir = tempfile::tempdir().unwrap();
831 let ledger_path = dir.path().join("ledger.jsonl");
832 let lease_token = match mode {
833 "missing" => None,
834 "expired" => Some("unknown-token".into()),
835 "wrong-file" => Some(seed_lease(&ledger_path, "other", "1")),
836 _ => Some(seed_lease(&ledger_path, "p1", "0")),
837 };
838 let opts = WriteOptions {
839 lease_token,
840 ledger_path,
841 ..opts()
842 };
843 assert_eq!(
844 write_inner(&drive, &slides, &opts, &[rule(mode != "optional-stale")])
845 .await
846 .result,
847 expected
848 );
849 }
850 }
851 #[tokio::test]
852 async fn successful_leased_write_concludes_audit_and_does_not_log_prose() {
853 let server = MockServer::start().await;
854 let (drive, slides) = ready(&server).await;
855 batch()
856 .respond_with(ResponseTemplate::new(200).set_body_json(
857 serde_json::json!({"replies":[{"replaceAllText":{"occurrencesChanged":1}}]}),
858 ))
859 .expect(1)
860 .mount(&server)
861 .await;
862 let dir = tempfile::tempdir().unwrap();
863 let ledger_path = dir.path().join("ledger.jsonl");
864 let lease_token = Some(seed_lease(&ledger_path, "p1", "1"));
865 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
866 let outcome = write_inner(
867 &drive,
868 &slides,
869 &WriteOptions {
870 lease_token,
871 ledger_path,
872 ..opts()
873 },
874 &[rule(true)],
875 )
876 .await;
877 assert_eq!(
878 outcome.result,
879 WriteResult::Replaced {
880 occurrences_changed: 1
881 }
882 );
883 assert_eq!(audit.verdicts(), ["pending", "allowed"]);
884 let records = format!("{:?}", audit.records());
885 assert!(records.contains("slides-replace"));
886 assert!(!records.contains("Q3"));
887 assert!(!records.contains("Q4"));
888 }
889 #[tokio::test]
890 async fn folder_grants_and_default_deny_use_the_same_gate_before_slides_reads() {
891 for allow in [false, true] {
892 let server = MockServer::start().await;
893 let (drive, slides) = clients(&server).await;
894 Mock::given(method("GET")).and(path("/drive/v3/files/p1"))
895 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id":"p1", "name":"Deck", "mimeType":GOOGLE_SLIDES_MIME_TYPE,"parents":["folder"],"version":"1"})))
896 .mount(&server).await;
897 Mock::given(method("GET")).and(path("/drive/v3/files/folder"))
898 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id":"folder","name":"Folder","mimeType":"application/vnd.google-apps.folder"})))
899 .mount(&server).await;
900 forbid_batch(&server).await;
901 let rules = if allow {
902 mount_presentation(&server, presentation(Some("r"))).await;
903 vec![FolderPermissionRule {
904 folder_id: Some("folder".into()),
905 file_id: None,
906 recursive: true,
907 ..rule(true)
908 }]
909 } else {
910 Mock::given(path("/v1/presentations/p1"))
911 .respond_with(ResponseTemplate::new(500))
912 .expect(0)
913 .mount(&server)
914 .await;
915 vec![]
916 };
917 let outcome = write_inner(
918 &drive,
919 &slides,
920 &WriteOptions {
921 dry_run: true,
922 ..opts()
923 },
924 &rules,
925 )
926 .await;
927 assert_eq!(outcome.resolved_folder_id.as_deref(), Some("folder"));
928 if allow {
929 assert_eq!(outcome.result, WriteResult::WouldReplace { occurrences: 2 });
930 } else {
931 assert!(matches!(
932 outcome.result,
933 WriteResult::Blocked { decided_by: None }
934 ));
935 }
936 }
937 }
938
939 #[tokio::test]
940 async fn failed_lease_audit_does_not_record_server_echoes_of_prose() {
941 let server = MockServer::start().await;
942 let (drive, slides) = ready(&server).await;
943 batch()
944 .respond_with(ResponseTemplate::new(400).set_body_json(
945 serde_json::json!({"error":{"code":400,"message":"Invalid replacement Q3 to Q4"}}),
946 ))
947 .expect(1)
948 .mount(&server)
949 .await;
950 let dir = tempfile::tempdir().unwrap();
951 let ledger_path = dir.path().join("ledger.jsonl");
952 let lease_token = Some(seed_lease(&ledger_path, "p1", "1"));
953 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
954 let outcome = write_inner(
955 &drive,
956 &slides,
957 &WriteOptions {
958 lease_token,
959 ledger_path,
960 ..opts()
961 },
962 &[rule(true)],
963 )
964 .await;
965 assert!(
966 matches!(outcome.result, WriteResult::Failed { ref detail } if detail.contains("Q3"))
967 );
968 assert_eq!(audit.verdicts(), ["pending", "failed"]);
969 let records = format!("{:?}", audit.records());
970 assert!(!records.contains("Q3"));
971 assert!(!records.contains("Q4"));
972 }
973
974 #[test]
975 fn case_and_multibyte_counts_are_display_only_and_nonoverlapping() {
976 assert_eq!(count_occurrences("aaaa", "aa", true), 2);
977 assert_eq!(count_occurrences("ÉCOLE école", "école", false), 2);
978 assert_eq!(count_occurrences("😀 😀", "😀", true), 2);
979 assert_eq!(count_occurrences("x", "", false), 0);
980 }
981
982 #[tokio::test]
983 async fn a_metadata_failure_reports_no_file_name_and_touches_no_slides_endpoint() {
984 let server = MockServer::start().await;
985 let (drive, slides) = clients(&server).await;
986 Mock::given(method("GET"))
987 .and(path("/drive/v3/files/p1"))
988 .respond_with(ResponseTemplate::new(404))
989 .mount(&server)
990 .await;
991 Mock::given(path("/v1/presentations/p1"))
992 .respond_with(ResponseTemplate::new(500))
993 .expect(0)
994 .mount(&server)
995 .await;
996 forbid_batch(&server).await;
997 let outcome = write_inner(&drive, &slides, &opts(), &[rule(false)]).await;
998 assert!(matches!(outcome.result, WriteResult::Failed { .. }));
999 assert_eq!(outcome.file_name, None);
1000 }
1001 #[tokio::test]
1002 async fn an_unresolvable_ancestor_chain_fails_rather_than_allowing() {
1003 let server = MockServer::start().await;
1004 let (drive, slides) = clients(&server).await;
1005 Mock::given(method("GET")).and(path("/drive/v3/files/p1"))
1006 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id":"p1", "name":"Deck", "mimeType":GOOGLE_SLIDES_MIME_TYPE,"parents":["folder"],"version":"1"})))
1007 .mount(&server).await;
1008 Mock::given(method("GET"))
1009 .and(path("/drive/v3/files/folder"))
1010 .respond_with(ResponseTemplate::new(500))
1011 .mount(&server)
1012 .await;
1013 Mock::given(path("/v1/presentations/p1"))
1014 .respond_with(ResponseTemplate::new(500))
1015 .expect(0)
1016 .mount(&server)
1017 .await;
1018 forbid_batch(&server).await;
1019 let rules = [FolderPermissionRule {
1020 folder_id: Some("folder".into()),
1021 file_id: None,
1022 recursive: true,
1023 ..rule(false)
1024 }];
1025 let outcome = write_inner(&drive, &slides, &opts(), &rules).await;
1026 assert!(
1027 matches!(outcome.result, WriteResult::Failed { .. }),
1028 "a chain that could not be resolved must never become an allow: {:?}",
1029 outcome.result
1030 );
1031 assert_eq!(outcome.file_name.as_deref(), Some("Deck"));
1032 }
1033 #[tokio::test]
1034 async fn a_parentless_presentation_without_a_file_rule_is_refused_distinctly() {
1035 let server = MockServer::start().await;
1036 let (drive, slides) = clients(&server).await;
1037 mount_file(&server, GOOGLE_SLIDES_MIME_TYPE).await;
1038 Mock::given(path("/v1/presentations/p1"))
1039 .respond_with(ResponseTemplate::new(500))
1040 .expect(0)
1041 .mount(&server)
1042 .await;
1043 forbid_batch(&server).await;
1044 let outcome = write_inner(&drive, &slides, &opts(), &[]).await;
1045 assert_eq!(outcome.result, WriteResult::RefusedNoVisibleParents);
1046 assert!(describe(&outcome).contains("file_id"));
1047 }
1048 #[tokio::test]
1049 async fn a_presentation_fetch_failure_keeps_the_gate_context_and_never_mutates() {
1050 let server = MockServer::start().await;
1051 let (drive, slides) = clients(&server).await;
1052 mount_file(&server, GOOGLE_SLIDES_MIME_TYPE).await;
1053 Mock::given(method("GET")).and(path("/v1/presentations/p1"))
1054 .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({"error":{"code":403,"status":"PERMISSION_DENIED","message":"nope"}})))
1055 .expect(1)
1056 .mount(&server)
1057 .await;
1058 forbid_batch(&server).await;
1059 let outcome = write_inner(&drive, &slides, &opts(), &[rule(false)]).await;
1060 assert!(
1061 matches!(outcome.result, WriteResult::Failed { ref detail } if detail.contains("Slides API request failed")),
1062 "{:?}",
1063 outcome.result
1064 );
1065 assert_eq!(outcome.file_name.as_deref(), Some("Deck"));
1066 assert_eq!(outcome.required_revision_id, None);
1067 }
1068 #[tokio::test]
1069 async fn the_public_entry_point_returns_the_engine_outcome_for_real_runs_and_dry_runs() {
1070 for dry_run in [false, true] {
1071 let server = MockServer::start().await;
1072 let (drive, slides) = ready(&server).await;
1073 let expected = if dry_run {
1074 forbid_batch(&server).await;
1075 WriteResult::WouldReplace { occurrences: 2 }
1076 } else {
1077 batch()
1078 .respond_with(ResponseTemplate::new(200).set_body_json(
1079 serde_json::json!({"replies":[{"replaceAllText":{"occurrencesChanged":2}}]}),
1080 ))
1081 .expect(1)
1082 .mount(&server)
1083 .await;
1084 WriteResult::Replaced {
1085 occurrences_changed: 2,
1086 }
1087 };
1088 let opts = WriteOptions { dry_run, ..opts() };
1089 assert_eq!(
1090 write(&drive, &slides, &opts, &[rule(false)]).await.result,
1091 expected
1092 );
1093 }
1094 }
1095 #[tokio::test]
1096 async fn the_public_entry_point_reports_a_denied_attempt_with_its_deciding_rule() {
1097 let server = MockServer::start().await;
1098 let (drive, slides) = clients(&server).await;
1099 mount_file(&server, GOOGLE_SLIDES_MIME_TYPE).await;
1100 forbid_batch(&server).await;
1101 let mut deny = rule(false);
1102 deny.allow.clear();
1103 deny.deny.insert(DriveOperation::SlidesWrite);
1104 let outcome = write(&drive, &slides, &opts(), &[deny]).await;
1105 assert_eq!(
1106 outcome.result,
1107 WriteResult::Blocked {
1108 decided_by: Some(DecidingRule::File {
1109 file_id: "p1".into()
1110 })
1111 }
1112 );
1113 }
1114
1115 fn outcome_of(result: WriteResult) -> WriteOutcome {
1116 WriteOutcome {
1117 presentation_id: "p1".into(),
1118 file_name: Some("Deck".into()),
1119 resolved_folder_id: None,
1120 required_revision_id: None,
1121 result,
1122 }
1123 }
1124 #[test]
1125 fn describe_names_the_deck_for_every_outcome_and_the_remedy_where_one_exists() {
1126 for (result, expected) in [
1127 (
1128 WriteResult::WouldReplace { occurrences: 3 },
1129 "Would replace: 3 estimated occurrence(s) in 'Deck'",
1130 ),
1131 (
1132 WriteResult::Replaced {
1133 occurrences_changed: 2,
1134 },
1135 "Replaced: 2 occurrence(s) in 'Deck'",
1136 ),
1137 (
1138 WriteResult::AppliedResponseUnreadable {
1139 detail: "bad json".into(),
1140 },
1141 "Applied: 'Deck', but the response was unreadable; inspect the presentation before retrying: bad json",
1142 ),
1143 (
1144 WriteResult::RefusedNotAPresentation {
1145 mime_type: "text/plain".into(),
1146 },
1147 "'Deck' is not a Google Slides presentation (mimeType: text/plain)",
1148 ),
1149 (WriteResult::RefusedShortcut, "'Deck' is a shortcut"),
1150 (
1151 WriteResult::RefusedNoVisibleParents,
1152 "grant its file_id the slides-write operation",
1153 ),
1154 (
1155 WriteResult::RefusedNoRevisionId,
1156 "'Deck' returned no revision id",
1157 ),
1158 (
1159 WriteResult::Blocked { decided_by: None },
1160 "Blocked: 'Deck' — refused by default policy (no matching rule)",
1161 ),
1162 (
1163 WriteResult::Blocked {
1164 decided_by: Some(DecidingRule::Folder {
1165 folder_id: "f".into(),
1166 depth: 2,
1167 }),
1168 },
1169 "Blocked: 'Deck' — refused by rule on folder f (depth 2)",
1170 ),
1171 (
1172 WriteResult::RefusedNoLease,
1173 "'Deck' requires a Drive write lease",
1174 ),
1175 (WriteResult::RefusedLeaseExpired, "lease is expired"),
1176 (
1177 WriteResult::RefusedLeaseWrongFile,
1178 "acquired for a different file",
1179 ),
1180 (WriteResult::RefusedLeaseStale, "'Deck' changed since"),
1181 (
1182 WriteResult::StaleRevision {
1183 required_revision_id: "r".into(),
1184 detail: "server echo".into(),
1185 },
1186 "'Deck' changed since it was read; nothing was written",
1187 ),
1188 (
1189 WriteResult::Failed {
1190 detail: "boom".into(),
1191 },
1192 "Failed: 'Deck': boom",
1193 ),
1194 ] {
1195 let text = describe(&outcome_of(result.clone()));
1196 assert!(text.contains(expected), "{result:?} rendered as {text:?}");
1197 }
1198 }
1199 #[test]
1200 fn describe_falls_back_to_the_presentation_id_and_omits_server_echoes_on_a_stale_revision() {
1201 let mut outcome = outcome_of(WriteResult::StaleRevision {
1202 required_revision_id: "r".into(),
1203 detail: "echoed Q3 prose".into(),
1204 });
1205 outcome.file_name = None;
1206 let text = describe(&outcome);
1207 assert!(text.contains("'p1'"), "{text}");
1208 assert!(!text.contains("Q3"), "{text}");
1209 }
1210 #[test]
1211 fn log_status_is_a_stable_label_per_outcome() {
1212 for (result, status) in [
1213 (
1214 WriteResult::WouldReplace { occurrences: 1 },
1215 "would-replace",
1216 ),
1217 (
1218 WriteResult::Replaced {
1219 occurrences_changed: 1,
1220 },
1221 "replaced",
1222 ),
1223 (
1224 WriteResult::AppliedResponseUnreadable { detail: "x".into() },
1225 "applied-response-unreadable",
1226 ),
1227 (
1228 WriteResult::RefusedNotAPresentation {
1229 mime_type: "x".into(),
1230 },
1231 "refused-not-a-presentation",
1232 ),
1233 (WriteResult::RefusedShortcut, "refused-shortcut"),
1234 (
1235 WriteResult::RefusedNoVisibleParents,
1236 "refused-no-visible-parents",
1237 ),
1238 (WriteResult::RefusedNoRevisionId, "refused-no-revision-id"),
1239 (WriteResult::Blocked { decided_by: None }, "blocked"),
1240 (
1241 WriteResult::RefusedNoLease,
1242 LeaseGateRefusal::NoLease.log_status(),
1243 ),
1244 (
1245 WriteResult::RefusedLeaseExpired,
1246 LeaseGateRefusal::Expired.log_status(),
1247 ),
1248 (
1249 WriteResult::RefusedLeaseWrongFile,
1250 LeaseGateRefusal::WrongFile.log_status(),
1251 ),
1252 (
1253 WriteResult::RefusedLeaseStale,
1254 LeaseGateRefusal::Stale.log_status(),
1255 ),
1256 (
1257 WriteResult::StaleRevision {
1258 required_revision_id: "r".into(),
1259 detail: "x".into(),
1260 },
1261 "stale-revision",
1262 ),
1263 (WriteResult::Failed { detail: "x".into() }, "failed"),
1264 ] {
1265 assert_eq!(result.log_status(), status, "{result:?}");
1266 }
1267 }
1268 #[test]
1269 fn a_failed_lease_check_becomes_a_failed_outcome_carrying_the_detail() {
1270 assert_eq!(
1271 WriteResult::from_lease_failed("ledger unreadable".into()),
1272 WriteResult::Failed {
1273 detail: "ledger unreadable".into()
1274 }
1275 );
1276 }
1277}