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    /// Its own variant carrying the id, because there is no `files.delete`
78    /// anywhere in this integration — the empty document cannot be rolled
79    /// back, and reporting it as a plain failure would leave an orphan the
80    /// user has no way to find.
81    CreatedTextFailed {
82        /// The new document's id, so it is findable.
83        file_id: String,
84        /// Why the seed failed.
85        detail: String,
86    },
87    /// Anything else.
88    Failed {
89        /// The error, verbatim.
90        detail: String,
91    },
92}
93
94impl CreateResult {
95    /// The kebab-case status for the request log.
96    const fn log_status(&self) -> &'static str {
97        match self {
98            Self::WouldCreate { .. } => "would-create",
99            Self::Blocked { .. } => "blocked",
100            Self::Created { .. } => "created",
101            Self::CreatedTextFailed { .. } => "created-text-failed",
102            Self::Failed { .. } => "failed",
103        }
104    }
105}
106
107/// The full outcome of one attempt.
108///
109/// Serialised by hand (below) so the JSON carries `parent_folder_id`, the
110/// name `drive create` and `sheets create` use for the same field, while
111/// still emitting the `resolved_folder_id` this verb shipped with, so
112/// existing consumers keep working (#1941).
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct CreateOutcome {
115    /// The requested title.
116    pub name: String,
117    /// The folder the document is created in, which the gate evaluated
118    /// against.
119    pub parent_folder_id: String,
120    /// What happened.
121    pub result: CreateResult,
122}
123
124impl Serialize for CreateOutcome {
125    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
126        #[derive(Serialize)]
127        struct Wire<'a> {
128            name: &'a str,
129            parent_folder_id: &'a str,
130            /// The original name for `parent_folder_id`, kept for
131            /// compatibility.
132            resolved_folder_id: &'a str,
133            result: &'a CreateResult,
134        }
135        Wire {
136            name: &self.name,
137            parent_folder_id: &self.parent_folder_id,
138            resolved_folder_id: &self.parent_folder_id,
139            result: &self.result,
140        }
141        .serialize(serializer)
142    }
143}
144
145impl JsonlSerialize for CreateOutcome {
146    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
147        write_scalar_jsonl(self, out)
148    }
149}
150
151/// Creates a document, optionally seeding it, logging every non-dry attempt.
152///
153/// Never returns `Err`: every failure is a [`CreateResult`] variant.
154pub async fn create(
155    drive: &DriveClient,
156    docs: &DocsClient,
157    opts: &CreateOptions,
158    rules: &[FolderPermissionRule],
159) -> CreateOutcome {
160    let started = Instant::now();
161    let outcome = create_inner(drive, docs, opts, rules).await;
162    if !opts.dry_run {
163        record_attempt(&outcome, started.elapsed());
164    }
165    outcome
166}
167
168async fn create_inner(
169    drive: &DriveClient,
170    docs: &DocsClient,
171    opts: &CreateOptions,
172    rules: &[FolderPermissionRule],
173) -> CreateOutcome {
174    let finish = |result| CreateOutcome {
175        name: opts.name.clone(),
176        parent_folder_id: opts.parent_folder_id.clone(),
177        result,
178    };
179
180    let files_api = FilesApi::new(drive);
181    let decision = match folder_ancestry::resolve_decision(
182        &files_api,
183        &opts.parent_folder_id,
184        DriveOperation::Create,
185        rules,
186    )
187    .await
188    {
189        Ok(decision) => decision,
190        Err(err) => {
191            return finish(CreateResult::Failed {
192                detail: err.to_string(),
193            })
194        }
195    };
196
197    if decision.verdict == write_gate::Verdict::Deny {
198        return finish(CreateResult::Blocked {
199            decided_by: decision.decided_by,
200        });
201    }
202
203    // An explicit `deny: ["docs-write"]` on this folder still refuses the
204    // seed, even though `Create` alone authorises it otherwise — see this
205    // module's header and ADR-0076 §9. `decided_by.is_some()` is what
206    // distinguishes that deliberate signal from the bare default policy (no
207    // rule mentions `docs-write` at all), which must **not** block a folder
208    // that only grants `create`.
209    if opts.text.is_some() {
210        let docs_write_decision = match folder_ancestry::resolve_decision(
211            &files_api,
212            &opts.parent_folder_id,
213            DriveOperation::DocsWrite,
214            rules,
215        )
216        .await
217        {
218            Ok(decision) => decision,
219            Err(err) => {
220                return finish(CreateResult::Failed {
221                    detail: err.to_string(),
222                })
223            }
224        };
225        if docs_write_decision.verdict == write_gate::Verdict::Deny
226            && docs_write_decision.decided_by.is_some()
227        {
228            return finish(CreateResult::Blocked {
229                decided_by: docs_write_decision.decided_by,
230            });
231        }
232    }
233
234    let (chars, bytes) = opts
235        .text
236        .as_ref()
237        .map_or((0, 0), |text| (text.chars().count(), text.len()));
238
239    if opts.dry_run {
240        return finish(CreateResult::WouldCreate { chars, bytes });
241    }
242
243    let created = match files_api
244        .create(&opts.name, &opts.parent_folder_id, GOOGLE_DOC_MIME_TYPE)
245        .await
246    {
247        Ok(created) => created,
248        Err(err) => {
249            return finish(CreateResult::Failed {
250                detail: err.to_string(),
251            })
252        }
253    };
254
255    let Some(text) = opts.text.as_ref() else {
256        return finish(CreateResult::Created {
257            file_id: created.id,
258            seeded_chars: None,
259        });
260    };
261
262    // The seed pays for its own lease rather than taking an unleased path.
263    let api = DocsApi::new(docs);
264    let document = match api
265        .get_document(&created.id, SuggestionsViewMode::default())
266        .await
267    {
268        Ok(document) => document,
269        Err(err) => {
270            return finish(CreateResult::CreatedTextFailed {
271                file_id: created.id,
272                detail: err.to_string(),
273            })
274        }
275    };
276    // A document this invocation just created should always carry a
277    // revision id — the account plainly has edit access to it — so this is
278    // folded into the partial-failure variant rather than given a variant
279    // that could never fire.
280    let Some(revision_id) = document.revision_id else {
281        return finish(CreateResult::CreatedTextFailed {
282            file_id: created.id,
283            detail: "the new document returned no revision id, so the seed could not be leased"
284                .to_string(),
285        });
286    };
287
288    match api
289        .batch_update(
290            &created.id,
291            DocsRequest::insert_text_at_end(text),
292            &revision_id,
293        )
294        .await
295    {
296        Ok(_) => finish(CreateResult::Created {
297            file_id: created.id,
298            seeded_chars: Some(chars),
299        }),
300        // A stale lease is not realistically reachable on a document created
301        // milliseconds earlier, so it is absorbed here rather than given its
302        // own variant; the detail carries the server's message either way.
303        Err(err) => finish(CreateResult::CreatedTextFailed {
304            file_id: created.id,
305            detail: err.to_string(),
306        }),
307    }
308}
309
310/// Emits the `kind: "drivemutation"` record.
311///
312/// The seed **text is never recorded**, only its size — same reasoning as
313/// `write.rs::record_attempt`.
314fn record_attempt(outcome: &CreateOutcome, duration: Duration) {
315    let error = match &outcome.result {
316        CreateResult::Failed { detail } | CreateResult::CreatedTextFailed { detail, .. } => {
317            Some(detail.clone())
318        }
319        _ => None,
320    };
321    let decided_by = match &outcome.result {
322        CreateResult::Blocked { decided_by } => decided_by.as_ref(),
323        _ => None,
324    };
325    let decided_by = write_gate::decided_by_log_fields(decided_by);
326    let file_id = match &outcome.result {
327        CreateResult::Created { file_id, .. } | CreateResult::CreatedTextFailed { file_id, .. } => {
328            file_id.clone()
329        }
330        _ => String::new(),
331    };
332    let inserted_chars = match &outcome.result {
333        CreateResult::Created {
334            seeded_chars: Some(chars),
335            ..
336        } => Some(*chars as i64),
337        _ => None,
338    };
339
340    request_log::record_drive_mutation(DriveMutationOutcome {
341        operation: "docs-create",
342        file_id,
343        file_name: outcome.name.clone(),
344        status: outcome.result.log_status().to_string(),
345        resolved_folder_id: Some(outcome.parent_folder_id.clone()),
346        decided_by_folder_id: decided_by.folder_id,
347        decided_by_depth: decided_by.depth,
348        decided_by_file_id: decided_by.file_id,
349        inserted_chars,
350        error,
351        duration,
352        ..Default::default()
353    });
354}
355
356/// Renders an outcome as a single human-readable line.
357#[must_use]
358pub fn describe(outcome: &CreateOutcome) -> String {
359    let name = &outcome.name;
360    let folder = &outcome.parent_folder_id;
361    match &outcome.result {
362        CreateResult::WouldCreate { chars, bytes } if *chars > 0 => format!(
363            "Would create: document '{name}' in {folder}, seeded with {chars} char(s) / \
364             {bytes} byte(s)"
365        ),
366        CreateResult::WouldCreate { .. } => {
367            format!("Would create: document '{name}' in {folder}")
368        }
369        CreateResult::Blocked { decided_by } => match decided_by {
370            Some(rule) => format!(
371                "Blocked: '{name}' in {folder} — refused by rule on {} {}{}",
372                rule.kind_label(),
373                rule.id(),
374                rule.depth_suffix()
375            ),
376            None => format!(
377                "Blocked: '{name}' in {folder} — refused by default policy (no matching rule)"
378            ),
379        },
380        CreateResult::Created {
381            file_id,
382            seeded_chars,
383        } => match seeded_chars {
384            Some(chars) => {
385                format!("Created: '{name}' ({file_id}) in {folder}, seeded with {chars} char(s)")
386            }
387            None => format!("Created: '{name}' ({file_id}) in {folder}"),
388        },
389        CreateResult::CreatedTextFailed { file_id, detail } => format!(
390            "Partially failed: created '{name}' ({file_id}) in {folder}, but seeding its text \
391             failed: {detail} The document exists and is empty — it cannot be rolled back \
392             automatically."
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    }
603
604    /// The ADR-0073 §11 / ADR-0076 §9 distinction: a bare default deny must
605    /// **not** block the seed, or `--text` would be refused on every folder
606    /// that grants only `create`.
607    #[tokio::test]
608    async fn a_bare_default_deny_does_not_block_the_seed() {
609        let server = MockServer::start().await;
610        let (drive, docs) = clients(&server).await;
611        mount_folder("folder-1").mount(&server).await;
612        mount_files_create().mount(&server).await;
613        mount_new_document(Some("rev-new")).mount(&server).await;
614        Mock::given(method("POST"))
615            .and(path("/v1/documents/new-1:batchUpdate"))
616            .respond_with(
617                ResponseTemplate::new(200)
618                    .set_body_json(serde_json::json!({"documentId": "new-1", "replies": [{}]})),
619            )
620            .mount(&server)
621            .await;
622
623        // Grants `create` only — no rule mentions `docs-write` at all.
624        let outcome = create(
625            &drive,
626            &docs,
627            &opts(Some("hello"), false),
628            &[rule(&[DriveOperation::Create], &[])],
629        )
630        .await;
631        assert!(matches!(outcome.result, CreateResult::Created { .. }));
632    }
633
634    /// …but an *explicit* deny is a deliberate operator signal and is
635    /// honoured, before anything is created.
636    #[tokio::test]
637    async fn an_explicit_docs_write_deny_blocks_the_seed_and_creates_nothing() {
638        let server = MockServer::start().await;
639        let (drive, docs) = clients(&server).await;
640        mount_folder("folder-1").mount(&server).await;
641        // No `files.create` mock: nothing may be created.
642
643        let outcome = create(
644            &drive,
645            &docs,
646            &opts(Some("hello"), false),
647            &[rule(
648                &[DriveOperation::Create],
649                &[DriveOperation::DocsWrite],
650            )],
651        )
652        .await;
653        assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
654    }
655
656    /// The same explicit deny must not block a create with *no* seed, since
657    /// no text is written.
658    #[tokio::test]
659    async fn an_explicit_docs_write_deny_does_not_block_an_unseeded_create() {
660        let server = MockServer::start().await;
661        let (drive, docs) = clients(&server).await;
662        mount_folder("folder-1").mount(&server).await;
663        mount_files_create().mount(&server).await;
664
665        let outcome = create(
666            &drive,
667            &docs,
668            &opts(None, false),
669            &[rule(
670                &[DriveOperation::Create],
671                &[DriveOperation::DocsWrite],
672            )],
673        )
674        .await;
675        assert!(matches!(outcome.result, CreateResult::Created { .. }));
676    }
677
678    #[tokio::test]
679    async fn a_denied_create_is_blocked_and_creates_nothing() {
680        let server = MockServer::start().await;
681        let (drive, docs) = clients(&server).await;
682        mount_folder("folder-1").mount(&server).await;
683
684        let outcome = create(&drive, &docs, &opts(None, false), &[]).await;
685        assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
686    }
687
688    #[tokio::test]
689    async fn dry_run_creates_nothing() {
690        let server = MockServer::start().await;
691        let (drive, docs) = clients(&server).await;
692        mount_folder("folder-1").mount(&server).await;
693        // No POST mocks at all.
694
695        let outcome = create(
696            &drive,
697            &docs,
698            &opts(Some("hello"), true),
699            &[rule(&[DriveOperation::Create], &[])],
700        )
701        .await;
702        assert_eq!(
703            outcome.result,
704            CreateResult::WouldCreate { chars: 5, bytes: 5 }
705        );
706        assert!(describe(&outcome).contains("seeded with 5 char(s)"));
707    }
708}