Skip to main content

omni_dev/drive/docs/
create.rs

1//! `drive docs create` engine — creates a Google Doc, optionally seeded with
2//! text (issue #1615, [ADR-0076](../../../docs/adrs/adr-0076.md) §9).
3//!
4//! Owns its verb rather than composing `drive create`'s public engine, which
5//! logs internally and unconditionally — composing it would emit a phantom
6//! `["drive","create"]` record for a command the user never typed, alongside
7//! the `docs-create` one. Same reasoning as `sheets/create.rs`.
8//!
9//! One of ADR-0073 §11's arguments is *stronger* here, and it is worth
10//! stating plainly: `documents.create` accepts only a title and drops the
11//! new document in the caller's My Drive root — it **cannot set a parent
12//! folder**. Since the folder is the write gate's entire input, going
13//! through `FilesApi::create` is not merely the tidier option, it is the
14//! only shape the gate can evaluate at all.
15//!
16//! The optional `--text` seed costs a second `documents.get`, purely to mint
17//! a lease for a document created milliseconds earlier. That round-trip is
18//! paid deliberately rather than adding a "we just made it, nobody can have
19//! touched it" unleased write path — which is exactly the escape hatch the
20//! next caller reaches for, and whose existence would void both the type
21//! fence and the grep guard in `write_types.rs`/`api.rs`.
22
23use std::time::{Duration, Instant};
24
25use serde::Serialize;
26
27use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
28use crate::drive::client::DriveClient;
29use crate::drive::docs::api::{DocsApi, SuggestionsViewMode};
30use crate::drive::docs::client::DocsClient;
31use crate::drive::docs::write_types::DocsRequest;
32use crate::drive::files_api::FilesApi;
33use crate::drive::folder_ancestry;
34use crate::drive::types::GOOGLE_DOC_MIME_TYPE;
35use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
36use crate::request_log::{self, DriveMutationOutcome};
37
38/// Per-call options.
39#[derive(Debug, Clone)]
40pub struct CreateOptions {
41    /// The new document's title.
42    pub name: String,
43    /// The folder to create it in — also the gate's input.
44    pub parent_folder_id: String,
45    /// Optional initial body text.
46    pub text: Option<String>,
47    /// Classify and preview, but create nothing.
48    pub dry_run: bool,
49}
50
51/// What happened (or, under `--dry-run`, would happen).
52#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
53#[serde(tag = "status", rename_all = "kebab-case")]
54pub enum CreateResult {
55    /// A dry run.
56    WouldCreate {
57        /// Unicode scalar values that would be seeded.
58        chars: usize,
59        /// UTF-8 bytes that would be seeded.
60        bytes: usize,
61    },
62    /// The gate refused it.
63    Blocked {
64        /// The rule that decided, when one did.
65        decided_by: Option<DecidingRule>,
66    },
67    /// Created, and seeded if `--text` was given.
68    Created {
69        /// The new document's id.
70        file_id: String,
71        /// Characters seeded, when `--text` was given.
72        #[serde(skip_serializing_if = "Option::is_none")]
73        seeded_chars: Option<usize>,
74    },
75    /// Created, but the seeding write failed.
76    ///
77    /// Carries the ID because automatic rollback is not authorised by a
78    /// create grant. The operator may explicitly trash the empty document
79    /// with a separate `trash` permission; plain failure would hide its ID.
80    CreatedTextFailed {
81        /// The new document's id, so it is findable.
82        file_id: String,
83        /// Why the seed failed.
84        detail: String,
85    },
86    /// Anything else.
87    Failed {
88        /// The error, verbatim.
89        detail: String,
90    },
91}
92
93impl CreateResult {
94    /// The kebab-case status for the request log.
95    const fn log_status(&self) -> &'static str {
96        match self {
97            Self::WouldCreate { .. } => "would-create",
98            Self::Blocked { .. } => "blocked",
99            Self::Created { .. } => "created",
100            Self::CreatedTextFailed { .. } => "created-text-failed",
101            Self::Failed { .. } => "failed",
102        }
103    }
104}
105
106/// The full outcome of one attempt.
107///
108/// Serialised by hand (below) so the JSON carries `parent_folder_id`, the
109/// name `drive create` and `sheets create` use for the same field, while
110/// still emitting the `resolved_folder_id` this verb shipped with, so
111/// existing consumers keep working (#1941).
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct CreateOutcome {
114    /// The requested title.
115    pub name: String,
116    /// The folder the document is created in, which the gate evaluated
117    /// against.
118    pub parent_folder_id: String,
119    /// What happened.
120    pub result: CreateResult,
121}
122
123impl Serialize for CreateOutcome {
124    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
125        #[derive(Serialize)]
126        struct Wire<'a> {
127            name: &'a str,
128            parent_folder_id: &'a str,
129            /// The original name for `parent_folder_id`, kept for
130            /// compatibility.
131            resolved_folder_id: &'a str,
132            result: &'a CreateResult,
133        }
134        Wire {
135            name: &self.name,
136            parent_folder_id: &self.parent_folder_id,
137            resolved_folder_id: &self.parent_folder_id,
138            result: &self.result,
139        }
140        .serialize(serializer)
141    }
142}
143
144impl JsonlSerialize for CreateOutcome {
145    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
146        write_scalar_jsonl(self, out)
147    }
148}
149
150/// Creates a document, optionally seeding it, logging every non-dry attempt.
151///
152/// Never returns `Err`: every failure is a [`CreateResult`] variant.
153pub async fn create(
154    drive: &DriveClient,
155    docs: &DocsClient,
156    opts: &CreateOptions,
157    rules: &[FolderPermissionRule],
158) -> CreateOutcome {
159    let started = Instant::now();
160    let outcome = create_inner(drive, docs, opts, rules).await;
161    if !opts.dry_run {
162        record_attempt(&outcome, started.elapsed());
163    }
164    outcome
165}
166
167async fn create_inner(
168    drive: &DriveClient,
169    docs: &DocsClient,
170    opts: &CreateOptions,
171    rules: &[FolderPermissionRule],
172) -> CreateOutcome {
173    let finish = |result| CreateOutcome {
174        name: opts.name.clone(),
175        parent_folder_id: opts.parent_folder_id.clone(),
176        result,
177    };
178
179    let files_api = FilesApi::new(drive);
180    let decision = match folder_ancestry::resolve_decision(
181        &files_api,
182        &opts.parent_folder_id,
183        DriveOperation::Create,
184        rules,
185    )
186    .await
187    {
188        Ok(decision) => decision,
189        Err(err) => {
190            return finish(CreateResult::Failed {
191                detail: err.to_string(),
192            })
193        }
194    };
195
196    if decision.verdict == write_gate::Verdict::Deny {
197        return finish(CreateResult::Blocked {
198            decided_by: decision.decided_by,
199        });
200    }
201
202    // An explicit `deny: ["docs-write"]` on this folder still refuses the
203    // seed, even though `Create` alone authorises it otherwise — see this
204    // module's header and ADR-0076 §9. `decided_by.is_some()` is what
205    // distinguishes that deliberate signal from the bare default policy (no
206    // rule mentions `docs-write` at all), which must **not** block a folder
207    // that only grants `create`.
208    if opts.text.is_some() {
209        let docs_write_decision = match folder_ancestry::resolve_decision(
210            &files_api,
211            &opts.parent_folder_id,
212            DriveOperation::DocsWrite,
213            rules,
214        )
215        .await
216        {
217            Ok(decision) => decision,
218            Err(err) => {
219                return finish(CreateResult::Failed {
220                    detail: err.to_string(),
221                })
222            }
223        };
224        if docs_write_decision.verdict == write_gate::Verdict::Deny
225            && docs_write_decision.decided_by.is_some()
226        {
227            return finish(CreateResult::Blocked {
228                decided_by: docs_write_decision.decided_by,
229            });
230        }
231    }
232
233    let (chars, bytes) = opts
234        .text
235        .as_ref()
236        .map_or((0, 0), |text| (text.chars().count(), text.len()));
237
238    if opts.dry_run {
239        return finish(CreateResult::WouldCreate { chars, bytes });
240    }
241
242    let created = match files_api
243        .create(&opts.name, &opts.parent_folder_id, GOOGLE_DOC_MIME_TYPE)
244        .await
245    {
246        Ok(created) => created,
247        Err(err) => {
248            return finish(CreateResult::Failed {
249                detail: err.to_string(),
250            })
251        }
252    };
253
254    let Some(text) = opts.text.as_ref() else {
255        return finish(CreateResult::Created {
256            file_id: created.id,
257            seeded_chars: None,
258        });
259    };
260
261    // The seed pays for its own lease rather than taking an unleased path.
262    let api = DocsApi::new(docs);
263    let document = match api
264        .get_document(&created.id, SuggestionsViewMode::default())
265        .await
266    {
267        Ok(document) => document,
268        Err(err) => {
269            return finish(CreateResult::CreatedTextFailed {
270                file_id: created.id,
271                detail: err.to_string(),
272            })
273        }
274    };
275    // A document this invocation just created should always carry a
276    // revision id — the account plainly has edit access to it — so this is
277    // folded into the partial-failure variant rather than given a variant
278    // that could never fire.
279    let Some(revision_id) = document.revision_id else {
280        return finish(CreateResult::CreatedTextFailed {
281            file_id: created.id,
282            detail: "the new document returned no revision id, so the seed could not be leased"
283                .to_string(),
284        });
285    };
286
287    match api
288        .batch_update(
289            &created.id,
290            DocsRequest::insert_text_at_end(text),
291            &revision_id,
292        )
293        .await
294    {
295        Ok(_) => finish(CreateResult::Created {
296            file_id: created.id,
297            seeded_chars: Some(chars),
298        }),
299        // A stale lease is not realistically reachable on a document created
300        // milliseconds earlier, so it is absorbed here rather than given its
301        // own variant; the detail carries the server's message either way.
302        Err(err) => finish(CreateResult::CreatedTextFailed {
303            file_id: created.id,
304            detail: err.to_string(),
305        }),
306    }
307}
308
309/// Emits the `kind: "drivemutation"` record.
310///
311/// The seed **text is never recorded**, only its size — same reasoning as
312/// `write.rs::record_attempt`.
313fn record_attempt(outcome: &CreateOutcome, duration: Duration) {
314    let error = match &outcome.result {
315        CreateResult::Failed { detail } | CreateResult::CreatedTextFailed { detail, .. } => {
316            Some(detail.clone())
317        }
318        _ => None,
319    };
320    let decided_by = match &outcome.result {
321        CreateResult::Blocked { decided_by } => decided_by.as_ref(),
322        _ => None,
323    };
324    let decided_by = write_gate::decided_by_log_fields(decided_by);
325    let file_id = match &outcome.result {
326        CreateResult::Created { file_id, .. } | CreateResult::CreatedTextFailed { file_id, .. } => {
327            file_id.clone()
328        }
329        _ => String::new(),
330    };
331    let inserted_chars = match &outcome.result {
332        CreateResult::Created {
333            seeded_chars: Some(chars),
334            ..
335        } => Some(*chars as i64),
336        _ => None,
337    };
338
339    request_log::record_drive_mutation(DriveMutationOutcome {
340        operation: "docs-create",
341        file_id,
342        file_name: outcome.name.clone(),
343        status: outcome.result.log_status().to_string(),
344        resolved_folder_id: Some(outcome.parent_folder_id.clone()),
345        decided_by_folder_id: decided_by.folder_id,
346        decided_by_depth: decided_by.depth,
347        decided_by_file_id: decided_by.file_id,
348        inserted_chars,
349        error,
350        duration,
351        ..Default::default()
352    });
353}
354
355/// Renders an outcome as a single human-readable line.
356#[must_use]
357pub fn describe(outcome: &CreateOutcome) -> String {
358    let name = &outcome.name;
359    let folder = &outcome.parent_folder_id;
360    match &outcome.result {
361        CreateResult::WouldCreate { chars, bytes } if *chars > 0 => format!(
362            "Would create: document '{name}' in {folder}, seeded with {chars} char(s) / \
363             {bytes} byte(s)"
364        ),
365        CreateResult::WouldCreate { .. } => {
366            format!("Would create: document '{name}' in {folder}")
367        }
368        CreateResult::Blocked { decided_by } => match decided_by {
369            Some(rule) => format!(
370                "Blocked: '{name}' in {folder} — refused by rule on {} {}{}",
371                rule.kind_label(),
372                rule.id(),
373                rule.depth_suffix()
374            ),
375            None => format!(
376                "Blocked: '{name}' in {folder} — refused by default policy (no matching rule)"
377            ),
378        },
379        CreateResult::Created {
380            file_id,
381            seeded_chars,
382        } => match seeded_chars {
383            Some(chars) => {
384                format!("Created: '{name}' ({file_id}) in {folder}, seeded with {chars} char(s)")
385            }
386            None => format!("Created: '{name}' ({file_id}) in {folder}"),
387        },
388        CreateResult::CreatedTextFailed { file_id, detail } => format!(
389            "Partially failed: created '{name}' ({file_id}) in {folder}, but seeding its text \
390             failed: {detail} The document exists and is empty — it cannot be rolled back \
391             automatically. Clean up explicitly with `omni-dev drive trash {file_id}` \
392             (requires trash permission on the file or its folder)."
393        ),
394        CreateResult::Failed { detail } => format!("Failed: '{name}' in {folder}: {detail}"),
395    }
396}
397
398#[cfg(test)]
399#[allow(clippy::unwrap_used, clippy::expect_used)]
400mod tests {
401    use super::*;
402    use std::collections::HashSet;
403
404    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
405    use crate::drive::docs::client::DOCS_API_URL;
406    use crate::test_support::env::MapEnv;
407    use crate::utils::secret::Secret;
408    use wiremock::matchers::{method, path};
409    use wiremock::{Mock, MockServer, ResponseTemplate};
410
411    fn test_credentials() -> DriveCredentials {
412        DriveCredentials {
413            client_id: "client-1".to_string(),
414            client_secret: Secret::new("secret-1"),
415            refresh_token: Secret::new("refresh-1"),
416            scope: DriveGrantedScopes::READONLY,
417        }
418    }
419
420    async fn clients(server: &MockServer) -> (DriveClient, DocsClient) {
421        Mock::given(method("POST"))
422            .and(path("/token"))
423            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
424                "access_token": "test-token", "expires_in": 3600,
425            })))
426            .mount(server)
427            .await;
428        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
429        crate::drive::client::test_support::replace_session(
430            &mut drive,
431            &test_credentials(),
432            &format!("{}/token", server.uri()),
433        );
434        let env = MapEnv::new().with(DOCS_API_URL, &server.uri());
435        let docs = DocsClient::from_drive_client_with(&env, &drive).unwrap();
436        (drive, docs)
437    }
438
439    fn mount_folder(id: &str) -> Mock {
440        Mock::given(method("GET"))
441            .and(path(format!("/drive/v3/files/{id}")))
442            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
443                "id": id, "name": id,
444                "mimeType": "application/vnd.google-apps.folder", "parents": [],
445            })))
446    }
447
448    fn mount_files_create() -> Mock {
449        Mock::given(method("POST"))
450            .and(path("/drive/v3/files"))
451            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
452                "id": "new-1", "name": "New Doc", "mimeType": GOOGLE_DOC_MIME_TYPE,
453            })))
454    }
455
456    fn mount_new_document(revision: Option<&str>) -> Mock {
457        let mut body = serde_json::json!({"documentId": "new-1", "body": {"content": []}});
458        if let Some(revision) = revision {
459            body["revisionId"] = serde_json::json!(revision);
460        }
461        Mock::given(method("GET"))
462            .and(path("/v1/documents/new-1"))
463            .respond_with(ResponseTemplate::new(200).set_body_json(body))
464    }
465
466    fn rule(ops: &[DriveOperation], denies: &[DriveOperation]) -> FolderPermissionRule {
467        FolderPermissionRule {
468            folder_id: Some("folder-1".to_string()),
469            file_id: None,
470            recursive: true,
471            allow: ops.iter().copied().collect(),
472            deny: denies.iter().copied().collect::<HashSet<_>>(),
473            require_lease: true,
474        }
475    }
476
477    fn opts(text: Option<&str>, dry_run: bool) -> CreateOptions {
478        CreateOptions {
479            name: "New Doc".to_string(),
480            parent_folder_id: "folder-1".to_string(),
481            text: text.map(str::to_string),
482            dry_run,
483        }
484    }
485
486    /// The JSON names the folder `parent_folder_id`, as `drive create` and
487    /// `sheets create` do, and keeps the original `resolved_folder_id`.
488    #[test]
489    fn json_carries_parent_folder_id_and_the_legacy_resolved_folder_id() {
490        let outcome = CreateOutcome {
491            name: "Notes".to_string(),
492            parent_folder_id: "folder-1".to_string(),
493            result: CreateResult::Created {
494                file_id: "doc-1".to_string(),
495                seeded_chars: None,
496            },
497        };
498        let json = serde_json::to_value(&outcome).unwrap();
499        assert_eq!(json["parent_folder_id"], "folder-1");
500        assert_eq!(json["resolved_folder_id"], "folder-1");
501        assert_eq!(json["name"], "Notes");
502        assert_eq!(json["result"]["file_id"], "doc-1");
503    }
504    #[tokio::test]
505    async fn create_without_text_makes_one_files_create_and_zero_docs_calls() {
506        let server = MockServer::start().await;
507        let (drive, docs) = clients(&server).await;
508        mount_folder("folder-1").mount(&server).await;
509        mount_files_create().expect(1).mount(&server).await;
510        // No Docs mocks: an unseeded create must not touch the Docs API.
511
512        let outcome = create(
513            &drive,
514            &docs,
515            &opts(None, false),
516            &[rule(&[DriveOperation::Create], &[])],
517        )
518        .await;
519        assert_eq!(
520            outcome.result,
521            CreateResult::Created {
522                file_id: "new-1".to_string(),
523                seeded_chars: None,
524            }
525        );
526    }
527
528    /// ADR-0076 §9: the seed mints its own lease rather than writing
529    /// unleased, so a `documents.get` on the *new* id must precede the
530    /// batchUpdate and its revision must be presented.
531    #[tokio::test]
532    async fn create_with_text_reads_the_new_document_for_a_lease_before_seeding() {
533        let server = MockServer::start().await;
534        let (drive, docs) = clients(&server).await;
535        mount_folder("folder-1").mount(&server).await;
536        mount_files_create().mount(&server).await;
537        mount_new_document(Some("rev-new"))
538            .expect(1)
539            .mount(&server)
540            .await;
541        Mock::given(method("POST"))
542            .and(path("/v1/documents/new-1:batchUpdate"))
543            .and(wiremock::matchers::body_partial_json(serde_json::json!({
544                "writeControl": {"requiredRevisionId": "rev-new"},
545            })))
546            .respond_with(
547                ResponseTemplate::new(200)
548                    .set_body_json(serde_json::json!({"documentId": "new-1", "replies": [{}]})),
549            )
550            .expect(1)
551            .mount(&server)
552            .await;
553
554        let outcome = create(
555            &drive,
556            &docs,
557            &opts(Some("hello"), false),
558            &[rule(&[DriveOperation::Create], &[])],
559        )
560        .await;
561        assert_eq!(
562            outcome.result,
563            CreateResult::Created {
564                file_id: "new-1".to_string(),
565                seeded_chars: Some(5),
566            }
567        );
568    }
569
570    /// There is no `files.delete` in this integration, so a failed seed must
571    /// name the orphan rather than report a clean failure.
572    #[tokio::test]
573    async fn a_seed_failure_reports_created_text_failed_carrying_the_id() {
574        let server = MockServer::start().await;
575        let (drive, docs) = clients(&server).await;
576        mount_folder("folder-1").mount(&server).await;
577        mount_files_create().mount(&server).await;
578        mount_new_document(Some("rev-new")).mount(&server).await;
579        Mock::given(method("POST"))
580            .and(path("/v1/documents/new-1:batchUpdate"))
581            .respond_with(ResponseTemplate::new(500))
582            .mount(&server)
583            .await;
584
585        let outcome = create(
586            &drive,
587            &docs,
588            &opts(Some("hello"), false),
589            &[rule(&[DriveOperation::Create], &[])],
590        )
591        .await;
592        match &outcome.result {
593            CreateResult::CreatedTextFailed { file_id, .. } => assert_eq!(file_id, "new-1"),
594            other => panic!("expected CreatedTextFailed, got {other:?}"),
595        }
596        let text = describe(&outcome);
597        assert!(
598            text.contains("new-1"),
599            "the orphan must be findable: {text}"
600        );
601        assert!(text.contains("cannot be rolled back"), "{text}");
602        assert!(text.contains("omni-dev drive trash new-1"), "{text}");
603        assert!(text.contains("requires trash permission"), "{text}");
604    }
605
606    /// The ADR-0073 §11 / ADR-0076 §9 distinction: a bare default deny must
607    /// **not** block the seed, or `--text` would be refused on every folder
608    /// that grants only `create`.
609    #[tokio::test]
610    async fn a_bare_default_deny_does_not_block_the_seed() {
611        let server = MockServer::start().await;
612        let (drive, docs) = clients(&server).await;
613        mount_folder("folder-1").mount(&server).await;
614        mount_files_create().mount(&server).await;
615        mount_new_document(Some("rev-new")).mount(&server).await;
616        Mock::given(method("POST"))
617            .and(path("/v1/documents/new-1:batchUpdate"))
618            .respond_with(
619                ResponseTemplate::new(200)
620                    .set_body_json(serde_json::json!({"documentId": "new-1", "replies": [{}]})),
621            )
622            .mount(&server)
623            .await;
624
625        // Grants `create` only — no rule mentions `docs-write` at all.
626        let outcome = create(
627            &drive,
628            &docs,
629            &opts(Some("hello"), false),
630            &[rule(&[DriveOperation::Create], &[])],
631        )
632        .await;
633        assert!(matches!(outcome.result, CreateResult::Created { .. }));
634    }
635
636    /// …but an *explicit* deny is a deliberate operator signal and is
637    /// honoured, before anything is created.
638    #[tokio::test]
639    async fn an_explicit_docs_write_deny_blocks_the_seed_and_creates_nothing() {
640        let server = MockServer::start().await;
641        let (drive, docs) = clients(&server).await;
642        mount_folder("folder-1").mount(&server).await;
643        // No `files.create` mock: nothing may be created.
644
645        let outcome = create(
646            &drive,
647            &docs,
648            &opts(Some("hello"), false),
649            &[rule(
650                &[DriveOperation::Create],
651                &[DriveOperation::DocsWrite],
652            )],
653        )
654        .await;
655        assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
656    }
657
658    /// The same explicit deny must not block a create with *no* seed, since
659    /// no text is written.
660    #[tokio::test]
661    async fn an_explicit_docs_write_deny_does_not_block_an_unseeded_create() {
662        let server = MockServer::start().await;
663        let (drive, docs) = clients(&server).await;
664        mount_folder("folder-1").mount(&server).await;
665        mount_files_create().mount(&server).await;
666
667        let outcome = create(
668            &drive,
669            &docs,
670            &opts(None, false),
671            &[rule(
672                &[DriveOperation::Create],
673                &[DriveOperation::DocsWrite],
674            )],
675        )
676        .await;
677        assert!(matches!(outcome.result, CreateResult::Created { .. }));
678    }
679
680    #[tokio::test]
681    async fn a_denied_create_is_blocked_and_creates_nothing() {
682        let server = MockServer::start().await;
683        let (drive, docs) = clients(&server).await;
684        mount_folder("folder-1").mount(&server).await;
685
686        let outcome = create(&drive, &docs, &opts(None, false), &[]).await;
687        assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
688    }
689
690    #[tokio::test]
691    async fn dry_run_creates_nothing() {
692        let server = MockServer::start().await;
693        let (drive, docs) = clients(&server).await;
694        mount_folder("folder-1").mount(&server).await;
695        // No POST mocks at all.
696
697        let outcome = create(
698            &drive,
699            &docs,
700            &opts(Some("hello"), true),
701            &[rule(&[DriveOperation::Create], &[])],
702        )
703        .await;
704        assert_eq!(
705            outcome.result,
706            CreateResult::WouldCreate { chars: 5, bytes: 5 }
707        );
708        assert!(describe(&outcome).contains("seeded with 5 char(s)"));
709    }
710}