Skip to main content

omni_dev/drive/slides/
write.rs

1//! Guarded Slides replacement (ADR-0093).
2//!
3//! Validate -> metadata/MIME -> permission gate -> snapshot/revision/filter ->
4//! preview/dry run -> optional Drive lease -> one revision-controlled mutation.
5//! Preview counts never suppress writes; notes/templates are excluded by explicit
6//! ordinary-slide page IDs. Prose is never included in mutation log records.
7
8use 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/// One literal text replacement attempt.
28#[derive(Debug, Clone)]
29pub struct WriteOptions {
30    /// Presentation identity.
31    pub presentation_id: String,
32    /// Literal text to find.
33    pub search: String,
34    /// Replacement text (may be empty).
35    pub replace: String,
36    /// Whether to match case.
37    pub match_case: bool,
38    /// Ordinary slide IDs; empty selects all ordinary slides.
39    pub slides: Vec<String>,
40    /// Preview without mutation or ledger consumption.
41    pub dry_run: bool,
42    /// Drive lease token when required by the deciding rule.
43    pub lease_token: Option<String>,
44    /// Lease ledger path.
45    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/// Result of a replacement or preview.
60#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
61#[serde(tag = "status", rename_all = "kebab-case")]
62pub enum WriteResult {
63    /// Snapshot estimate, display-only.
64    WouldReplace {
65        /// Non-overlapping matches per text object.
66        occurrences: usize,
67    },
68    /// Successful mutation with authoritative server count.
69    Replaced {
70        /// Server-reported count, including proto3 omitted zero.
71        occurrences_changed: i64,
72    },
73    /// HTTP success means applied, even if its response cannot be decoded.
74    AppliedResponseUnreadable {
75        /// Parsing failure; do not retry automatically.
76        detail: String,
77    },
78    /// Metadata is not a presentation.
79    RefusedNotAPresentation {
80        /// Actual MIME type.
81        mime_type: String,
82    },
83    /// Shortcuts are never followed.
84    RefusedShortcut,
85    /// No visible parent and no file-specific grant.
86    RefusedNoVisibleParents,
87    /// No nonempty editor revision token to assert.
88    RefusedNoRevisionId,
89    /// Permission gate refusal, before any Slides read.
90    Blocked {
91        /// Rule that refused, if any.
92        decided_by: Option<DecidingRule>,
93    },
94    /// Required Drive lease absent.
95    RefusedNoLease,
96    /// Lease expired or unknown.
97    RefusedLeaseExpired,
98    /// Lease belongs to another file.
99    RefusedLeaseWrongFile,
100    /// Drive version changed since lease acquisition.
101    RefusedLeaseStale,
102    /// Revision changed after the snapshot read; mutation refused atomically.
103    StaleRevision {
104        /// Revision asserted.
105        required_revision_id: String,
106        /// Server diagnostic.
107        detail: String,
108    },
109    /// Other validation or API failure.
110    Failed {
111        /// Diagnostic.
112        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/// Serializable outcome of one invocation.
154#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
155pub struct WriteOutcome {
156    /// Presentation acted on.
157    pub presentation_id: String,
158    /// Name from Drive metadata.
159    pub file_name: Option<String>,
160    /// Folder evaluated by the gate.
161    pub resolved_folder_id: Option<String>,
162    /// Revision asserted by the mutation.
163    pub required_revision_id: Option<String>,
164    /// Applied, previewed or refused result.
165    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
173/// Counts non-overlapping literal matches, for display only.
174/// Unicode case folding may differ from Google's server implementation.
175pub 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
186/// Runs the guarded engine and logs every non-dry-run attempt.
187pub 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    // Server diagnostics may echo request prose. Keep them out of logs; the
401    // interactive/structured outcome still carries the diagnostic for support.
402    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
418/// Describes one attempt without exposing searched/replacement prose.
419pub 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}