Skip to main content

omni_dev/drive/sheets/
create.rs

1//! `drive sheets create` engine — creates a spreadsheet and optionally
2//! seeds its cells (issue #1589,
3//! [ADR-0073](../../../docs/adrs/adr-0073.md) §11).
4//!
5//! Mirrors `create.rs` with a fixed spreadsheet MIME type rather than
6//! composing it, deliberately. `drive::create::create` logs internally and
7//! unconditionally — that non-bypassability is the point of it — so
8//! composing it would emit a phantom `["drive", "create"]` record for a
9//! command the user never typed, alongside this verb's own. One user-visible
10//! verb, one gate check, one log record.
11//!
12//! **The seeding write only checks for an explicit `SheetsWrite` deny**,
13//! never the bare default policy, and that is a decision rather than an
14//! oversight. Routing it through the ordinary `sheets write` engine (which
15//! honors default-deny like every other write) would re-run the gate under
16//! `SheetsWrite` against the *new* file's parents, which defaults to deny
17//! when no rule mentions `sheets-write` at all — so `sheets create --values`
18//! would create an empty spreadsheet and then report itself blocked on every
19//! folder that only grants `create`. The `Create` verdict alone authorises
20//! the pair, *unless* an operator has gone out of their way to add an
21//! explicit `deny: ["sheets-write"]` rule on this folder — that is a
22//! deliberate signal this module still honors, distinguished from "no rule
23//! at all" via [`write_gate::Decision::decided_by`]. This is only defensible
24//! because the target id is always the one `files.create` just returned
25//! inside an already-cleared folder, never a caller-supplied id; `drive
26//! upload` sets the precedent of writing content under `Upload` rather than
27//! `Edit`.
28
29use std::time::{Duration, Instant};
30
31use serde::Serialize;
32
33use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
34use crate::drive::client::DriveClient;
35use crate::drive::files_api::FilesApi;
36use crate::drive::folder_ancestry;
37use crate::drive::sheets::api::{SheetsApi, ValueInputOption};
38use crate::drive::sheets::client::SheetsClient;
39use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
40use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
41use crate::request_log::{self, DriveMutationOutcome};
42
43/// Where seeded values land.
44///
45/// Google names the first tab of a new spreadsheet `Sheet1`, so this is the
46/// anchor for an unqualified seed. Quoted, like every range this crate
47/// builds.
48const SEED_RANGE: &str = "'Sheet1'!A1";
49
50/// Per-call options.
51#[derive(Debug, Clone)]
52pub struct CreateOptions {
53    /// The new spreadsheet's title.
54    pub name: String,
55    /// The folder to create it in — what the gate is evaluated against.
56    pub parent_folder_id: String,
57    /// Optional initial cell values, written to `Sheet1!A1` onwards.
58    pub values: Vec<Vec<String>>,
59    /// How the API should interpret those values.
60    pub input: ValueInputOption,
61    /// Classify only; never create or write.
62    pub dry_run: bool,
63}
64
65/// What happened (or, under `--dry-run`, would happen).
66#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
67#[serde(tag = "status", rename_all = "kebab-case")]
68pub enum CreateResult {
69    /// `--dry-run`, and the gate would allow it.
70    WouldCreate {
71        /// Rows of seed input parsed, so a transposed input is visible
72        /// before it lands.
73        rows: usize,
74        /// Widest seed row.
75        columns: usize,
76    },
77    /// The folder write-permission gate refused it.
78    Blocked {
79        /// The rule that decided the refusal, if any.
80        decided_by: Option<DecidingRule>,
81    },
82    /// The spreadsheet was created (and seeded, if `--values` was given).
83    Created {
84        /// The new spreadsheet's id.
85        file_id: String,
86        /// Cells seeded, when seeding happened.
87        #[serde(skip_serializing_if = "Option::is_none")]
88        seeded_cells: Option<i64>,
89    },
90    /// The spreadsheet was created but seeding it failed.
91    ///
92    /// Its own variant, carrying the id, because there is **no**
93    /// `files.delete` anywhere in this integration — the empty spreadsheet
94    /// cannot be rolled back, so reporting this as a plain `Failed` would
95    /// leave an orphan the user has no way to find.
96    CreatedValuesFailed {
97        /// The new spreadsheet's id — it exists and is empty.
98        file_id: String,
99        /// Why seeding failed.
100        detail: String,
101    },
102    /// Creation itself failed; nothing was created.
103    Failed {
104        /// A human-readable summary.
105        detail: String,
106    },
107}
108
109impl CreateResult {
110    fn log_status(&self) -> &'static str {
111        match self {
112            Self::WouldCreate { .. } => "would-create",
113            Self::Blocked { .. } => "blocked",
114            Self::Created { .. } => "created",
115            Self::CreatedValuesFailed { .. } => "created-values-failed",
116            Self::Failed { .. } => "failed",
117        }
118    }
119
120    /// The new file's id, when one exists — including the partial-failure
121    /// case, which is the whole reason that variant carries it.
122    fn file_id(&self) -> Option<&str> {
123        match self {
124            Self::Created { file_id, .. } | Self::CreatedValuesFailed { file_id, .. } => {
125                Some(file_id)
126            }
127            _ => None,
128        }
129    }
130}
131
132/// The full outcome of one attempt.
133#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
134pub struct CreateOutcome {
135    /// The requested title.
136    pub name: String,
137    /// The folder it was to be created in.
138    pub parent_folder_id: String,
139    /// What happened.
140    pub result: CreateResult,
141}
142
143impl JsonlSerialize for CreateOutcome {
144    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
145        write_scalar_jsonl(self, out)
146    }
147}
148
149/// Creates a spreadsheet, optionally seeding its cells.
150///
151/// Never returns `Err`; every failure is a [`CreateResult`] variant, and the
152/// process exits 0 regardless (ADR-0070 §10, ADR-0071 §12).
153pub async fn create(
154    drive: &DriveClient,
155    sheets: &SheetsClient,
156    opts: &CreateOptions,
157    rules: &[FolderPermissionRule],
158) -> CreateOutcome {
159    let started = Instant::now();
160    let outcome = create_inner(drive, sheets, 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    sheets: &SheetsClient,
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        // An unresolvable chain is a refusal, never a silent allow.
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: ["sheets-write"]` on this folder still refuses the
204    // seed, even though `Create` alone authorises it otherwise — see this
205    // module's header. `decided_by.is_some()` is what distinguishes that
206    // deliberate signal from the bare default policy (no rule mentions
207    // `sheets-write` at all), which must **not** block a folder that only
208    // grants `create`.
209    if !opts.values.is_empty() {
210        let sheets_write_decision = match folder_ancestry::resolve_decision(
211            &files_api,
212            &opts.parent_folder_id,
213            DriveOperation::SheetsWrite,
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 sheets_write_decision.verdict == write_gate::Verdict::Deny
226            && sheets_write_decision.decided_by.is_some()
227        {
228            return finish(CreateResult::Blocked {
229                decided_by: sheets_write_decision.decided_by,
230            });
231        }
232    }
233
234    if opts.dry_run {
235        return finish(CreateResult::WouldCreate {
236            rows: opts.values.len(),
237            columns: opts.values.iter().map(Vec::len).max().unwrap_or(0),
238        });
239    }
240
241    let created = match files_api
242        .create(&opts.name, &opts.parent_folder_id, GOOGLE_SHEET_MIME_TYPE)
243        .await
244    {
245        Ok(file) => file,
246        Err(err) => {
247            return finish(CreateResult::Failed {
248                detail: format!("{err:#}"),
249            })
250        }
251    };
252
253    if opts.values.is_empty() {
254        return finish(CreateResult::Created {
255            file_id: created.id,
256            seeded_cells: None,
257        });
258    }
259
260    // The seed itself. `created.id` is the id `files.create` just returned,
261    // never anything the caller supplied — see this module's header.
262    match SheetsApi::new(sheets)
263        .values_update(&created.id, SEED_RANGE, &opts.values, opts.input)
264        .await
265    {
266        Ok(response) => finish(CreateResult::Created {
267            file_id: created.id,
268            seeded_cells: response.updated_cells,
269        }),
270        Err(err) => finish(CreateResult::CreatedValuesFailed {
271            file_id: created.id,
272            detail: format!("{err:#}"),
273        }),
274    }
275}
276
277fn record_attempt(outcome: &CreateOutcome, duration: Duration) {
278    let error = match &outcome.result {
279        CreateResult::Failed { detail } | CreateResult::CreatedValuesFailed { detail, .. } => {
280            Some(detail.clone())
281        }
282        _ => None,
283    };
284    let decided_by = match &outcome.result {
285        CreateResult::Blocked { decided_by } => decided_by.as_ref(),
286        _ => None,
287    };
288    let decided_by = write_gate::decided_by_log_fields(decided_by);
289    let updated_cells = match &outcome.result {
290        CreateResult::Created { seeded_cells, .. } => *seeded_cells,
291        _ => None,
292    };
293
294    request_log::record_drive_mutation(DriveMutationOutcome {
295        operation: "sheets-create",
296        file_id: outcome.result.file_id().unwrap_or_default().to_string(),
297        file_name: outcome.name.clone(),
298        status: outcome.result.log_status().to_string(),
299        resolved_folder_id: Some(outcome.parent_folder_id.clone()),
300        decided_by_folder_id: decided_by.folder_id,
301        decided_by_depth: decided_by.depth,
302        decided_by_file_id: decided_by.file_id,
303        updated_cells,
304        error,
305        duration,
306        ..Default::default()
307    });
308}
309
310/// Renders an outcome as a single human-readable line.
311#[must_use]
312pub fn describe(outcome: &CreateOutcome) -> String {
313    let name = &outcome.name;
314    let parent = &outcome.parent_folder_id;
315    match &outcome.result {
316        CreateResult::WouldCreate { rows, columns } if *rows == 0 => {
317            format!("Would create: spreadsheet '{name}' in {parent}")
318        }
319        CreateResult::WouldCreate { rows, columns } => format!(
320            "Would create: spreadsheet '{name}' in {parent}, seeded with {rows} row(s) x \
321             {columns} column(s)"
322        ),
323        CreateResult::Blocked { decided_by } => match decided_by {
324            Some(rule) => format!(
325                "Blocked: '{name}' in {parent} — refused by rule on {} {}{}",
326                rule.kind_label(),
327                rule.id(),
328                rule.depth_suffix()
329            ),
330            None => format!(
331                "Blocked: '{name}' in {parent} — refused by default policy (no matching rule)"
332            ),
333        },
334        CreateResult::Created {
335            file_id,
336            seeded_cells,
337        } => match seeded_cells {
338            Some(cells) => {
339                format!("Created: '{name}' ({file_id}) in {parent}, seeded {cells} cell(s)")
340            }
341            None => format!("Created: '{name}' ({file_id}) in {parent}"),
342        },
343        CreateResult::CreatedValuesFailed { file_id, detail } => format!(
344            "Partially failed: created '{name}' ({file_id}) in {parent}, but writing its \
345             values failed: {detail}. The spreadsheet exists and is empty — it cannot be \
346             rolled back automatically."
347        ),
348        CreateResult::Failed { detail } => format!("Failed: '{name}' in {parent}: {detail}"),
349    }
350}
351
352#[cfg(test)]
353#[allow(clippy::unwrap_used, clippy::expect_used)]
354mod tests {
355    use super::*;
356
357    /// Every `describe` arm renders to exactly one line.
358    ///
359    /// The `sheets write` twin of this test carries the full reasoning: the
360    /// CLI caller sanitizes the whole rendered line rather than each
361    /// interpolation, so an arm that emitted a newline would be silently
362    /// flattened by `sanitize_for_terminal` instead of failing anything.
363    #[test]
364    fn every_describe_arm_renders_a_single_line() {
365        for result in every_create_result() {
366            let outcome = CreateOutcome {
367                name: "Quarterly Plan".to_string(),
368                parent_folder_id: "folder-1".to_string(),
369                result,
370            };
371            let rendered = describe(&outcome);
372            assert_eq!(
373                rendered.lines().count(),
374                1,
375                "describe emitted {} lines for {:?}: {rendered:?}",
376                rendered.lines().count(),
377                outcome.result
378            );
379            assert!(
380                !rendered.chars().any(char::is_control),
381                "describe emitted a control character for {:?}: {rendered:?}",
382                outcome.result
383            );
384        }
385    }
386
387    /// One of every `CreateResult` variant.
388    ///
389    /// Exhaustive and wildcard-free, so a new variant breaks the build until
390    /// it is covered by `every_describe_arm_renders_a_single_line`.
391    fn every_create_result() -> Vec<CreateResult> {
392        let all = vec![
393            CreateResult::WouldCreate {
394                rows: 0,
395                columns: 0,
396            },
397            CreateResult::WouldCreate {
398                rows: 2,
399                columns: 3,
400            },
401            CreateResult::Blocked { decided_by: None },
402            CreateResult::Blocked {
403                decided_by: Some(DecidingRule::Folder {
404                    folder_id: "folder-1".to_string(),
405                    depth: 0,
406                }),
407            },
408            CreateResult::Blocked {
409                decided_by: Some(DecidingRule::File {
410                    file_id: "sheet-1".to_string(),
411                }),
412            },
413            CreateResult::Created {
414                file_id: "sheet-1".to_string(),
415                seeded_cells: Some(6),
416            },
417            CreateResult::Created {
418                file_id: "sheet-1".to_string(),
419                seeded_cells: None,
420            },
421            CreateResult::CreatedValuesFailed {
422                file_id: "sheet-1".to_string(),
423                detail: "the API said no".to_string(),
424            },
425            CreateResult::Failed {
426                detail: "the API said no".to_string(),
427            },
428        ];
429        for result in &all {
430            match result {
431                CreateResult::WouldCreate { .. }
432                | CreateResult::Blocked { .. }
433                | CreateResult::Created { .. }
434                | CreateResult::CreatedValuesFailed { .. }
435                | CreateResult::Failed { .. } => (),
436            }
437        }
438        all
439    }
440
441    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
442    use crate::drive::sheets::client::SHEETS_API_URL;
443    use crate::test_support::env::MapEnv;
444    use crate::utils::secret::Secret;
445    use std::collections::HashSet;
446
447    fn test_credentials() -> DriveCredentials {
448        DriveCredentials {
449            client_id: "client-1".to_string(),
450            client_secret: Secret::new("secret-1"),
451            refresh_token: Secret::new("refresh-1"),
452            scope: DriveGrantedScopes::READONLY,
453        }
454    }
455
456    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
457        wiremock::Mock::given(wiremock::matchers::method("POST"))
458            .and(wiremock::matchers::path("/token"))
459            .respond_with(
460                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
461                    "access_token": "test-token", "expires_in": 3600,
462                })),
463            )
464            .mount(server)
465            .await;
466        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
467        crate::drive::client::test_support::replace_session(
468            &mut drive,
469            &test_credentials(),
470            &format!("{}/token", server.uri()),
471        );
472        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
473        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
474        (drive, sheets)
475    }
476
477    fn mount_folder(id: &str) -> wiremock::Mock {
478        wiremock::Mock::given(wiremock::matchers::method("GET"))
479            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
480            .respond_with(
481                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
482                    "id": id, "name": id,
483                    "mimeType": "application/vnd.google-apps.folder", "parents": [],
484                })),
485            )
486    }
487
488    fn mount_create() -> wiremock::Mock {
489        wiremock::Mock::given(wiremock::matchers::method("POST"))
490            .and(wiremock::matchers::path("/drive/v3/files"))
491            .respond_with(
492                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
493                    "id": "new-sheet", "name": "Budget",
494                    "mimeType": "application/vnd.google-apps.spreadsheet",
495                })),
496            )
497    }
498
499    fn allow_rule() -> FolderPermissionRule {
500        FolderPermissionRule {
501            folder_id: Some("parent-1".to_string()),
502            file_id: None,
503            recursive: true,
504            allow: std::iter::once(DriveOperation::Create).collect(),
505            deny: HashSet::default(),
506            require_lease: true,
507        }
508    }
509
510    fn opts(values: Vec<Vec<String>>, dry_run: bool) -> CreateOptions {
511        CreateOptions {
512            name: "Budget".to_string(),
513            parent_folder_id: "parent-1".to_string(),
514            values,
515            input: ValueInputOption::UserEntered,
516            dry_run,
517        }
518    }
519
520    #[tokio::test]
521    async fn creates_a_spreadsheet_mime_type_without_values() {
522        let server = wiremock::MockServer::start().await;
523        let (drive, sheets) = clients(&server).await;
524        mount_folder("parent-1").mount(&server).await;
525        wiremock::Mock::given(wiremock::matchers::method("POST"))
526            .and(wiremock::matchers::path("/drive/v3/files"))
527            .and(wiremock::matchers::body_partial_json(serde_json::json!({
528                "mimeType": "application/vnd.google-apps.spreadsheet",
529            })))
530            .respond_with(
531                wiremock::ResponseTemplate::new(200)
532                    .set_body_json(serde_json::json!({"id": "new-sheet", "name": "Budget"})),
533            )
534            .expect(1)
535            .mount(&server)
536            .await;
537        // No Sheets mock: with no --values there must be no seeding call.
538
539        let outcome = create(&drive, &sheets, &opts(Vec::new(), false), &[allow_rule()]).await;
540        assert_eq!(
541            outcome.result,
542            CreateResult::Created {
543                file_id: "new-sheet".to_string(),
544                seeded_cells: None,
545            }
546        );
547        assert_eq!(
548            describe(&outcome),
549            "Created: 'Budget' (new-sheet) in parent-1"
550        );
551    }
552
553    #[tokio::test]
554    async fn seeds_values_into_sheet1_a1_checking_both_create_and_sheets_write_gates() {
555        let server = wiremock::MockServer::start().await;
556        let (drive, sheets) = clients(&server).await;
557        // `parent-1` is fetched twice: once resolving the `Create` decision,
558        // once resolving the `SheetsWrite` one for the seed — see this
559        // module's header for why the seed still checks for an *explicit*
560        // deny.
561        mount_folder("parent-1").expect(2).mount(&server).await;
562        mount_create().mount(&server).await;
563        wiremock::Mock::given(wiremock::matchers::method("PUT"))
564            .and(wiremock::matchers::path(
565                "/v4/spreadsheets/new-sheet/values/'Sheet1'!A1",
566            ))
567            .respond_with(
568                wiremock::ResponseTemplate::new(200)
569                    .set_body_json(serde_json::json!({"updatedCells": 4})),
570            )
571            .expect(1)
572            .mount(&server)
573            .await;
574
575        let values = vec![
576            vec!["a".to_string(), "b".to_string()],
577            vec!["c".to_string(), "d".to_string()],
578        ];
579        let outcome = create(&drive, &sheets, &opts(values, false), &[allow_rule()]).await;
580        assert_eq!(
581            outcome.result,
582            CreateResult::Created {
583                file_id: "new-sheet".to_string(),
584                seeded_cells: Some(4),
585            }
586        );
587        assert_eq!(
588            describe(&outcome),
589            "Created: 'Budget' (new-sheet) in parent-1, seeded 4 cell(s)"
590        );
591    }
592
593    #[tokio::test]
594    async fn a_create_only_rule_is_enough_to_seed() {
595        // A folder granting `create` but with no `sheets-write` rule at all
596        // must still allow `--values`: the seed's second check only refuses
597        // an *explicit* `sheets-write` deny, never the bare default policy.
598        let server = wiremock::MockServer::start().await;
599        let (drive, sheets) = clients(&server).await;
600        mount_folder("parent-1").mount(&server).await;
601        mount_create().mount(&server).await;
602        wiremock::Mock::given(wiremock::matchers::method("PUT"))
603            .respond_with(
604                wiremock::ResponseTemplate::new(200)
605                    .set_body_json(serde_json::json!({"updatedCells": 1})),
606            )
607            .mount(&server)
608            .await;
609
610        let values = vec![vec!["x".to_string()]];
611        let outcome = create(&drive, &sheets, &opts(values, false), &[allow_rule()]).await;
612        assert!(matches!(outcome.result, CreateResult::Created { .. }));
613    }
614
615    #[tokio::test]
616    async fn an_explicit_sheets_write_deny_blocks_the_seed_even_with_a_create_allow() {
617        // The gap this test closes: a folder that allows `create` but
618        // explicitly denies `sheets-write` must not let `--values` seed the
619        // new file anyway.
620        let server = wiremock::MockServer::start().await;
621        let (drive, sheets) = clients(&server).await;
622        mount_folder("parent-1").mount(&server).await;
623        mount_create().mount(&server).await;
624        // No PUT mock: a seed attempt would surface as Failed, not Blocked.
625
626        let rule = FolderPermissionRule {
627            folder_id: Some("parent-1".to_string()),
628            file_id: None,
629            recursive: true,
630            allow: std::iter::once(DriveOperation::Create).collect(),
631            deny: std::iter::once(DriveOperation::SheetsWrite).collect(),
632            require_lease: true,
633        };
634        let values = vec![vec!["x".to_string()]];
635        let outcome = create(&drive, &sheets, &opts(values, false), &[rule]).await;
636        assert!(
637            matches!(
638                outcome.result,
639                CreateResult::Blocked {
640                    decided_by: Some(_)
641                }
642            ),
643            "{:?}",
644            outcome.result
645        );
646    }
647
648    #[tokio::test]
649    async fn a_failed_seed_surfaces_the_orphaned_file_id() {
650        let server = wiremock::MockServer::start().await;
651        let (drive, sheets) = clients(&server).await;
652        mount_folder("parent-1").mount(&server).await;
653        mount_create().mount(&server).await;
654        wiremock::Mock::given(wiremock::matchers::method("PUT"))
655            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
656            .mount(&server)
657            .await;
658
659        let values = vec![vec!["x".to_string()]];
660        let outcome = create(&drive, &sheets, &opts(values, false), &[allow_rule()]).await;
661        let CreateResult::CreatedValuesFailed { file_id, .. } = &outcome.result else {
662            panic!("expected CreatedValuesFailed, got {:?}", outcome.result);
663        };
664        assert_eq!(file_id, "new-sheet");
665        // The message must not read as a clean failure: the spreadsheet
666        // exists and there is no delete API to undo it.
667        let text = describe(&outcome);
668        assert!(text.contains("Partially failed"), "{text}");
669        assert!(text.contains("new-sheet"), "{text}");
670        assert!(text.contains("cannot be rolled back"), "{text}");
671    }
672
673    #[tokio::test]
674    async fn a_denied_parent_creates_nothing() {
675        let server = wiremock::MockServer::start().await;
676        let (drive, sheets) = clients(&server).await;
677        mount_folder("parent-1").mount(&server).await;
678        // No create mock and no Sheets mock.
679        let outcome = create(&drive, &sheets, &opts(Vec::new(), false), &[]).await;
680        assert!(matches!(
681            outcome.result,
682            CreateResult::Blocked { decided_by: None }
683        ));
684        assert_eq!(
685            describe(&outcome),
686            "Blocked: 'Budget' in parent-1 — refused by default policy (no matching rule)"
687        );
688    }
689
690    #[tokio::test]
691    async fn a_blocked_by_rule_names_the_deciding_folder_in_the_message() {
692        let deny_rule = FolderPermissionRule {
693            folder_id: Some("parent-1".to_string()),
694            file_id: None,
695            recursive: true,
696            allow: HashSet::default(),
697            deny: std::iter::once(DriveOperation::Create).collect(),
698            require_lease: true,
699        };
700        let server = wiremock::MockServer::start().await;
701        let (drive, sheets) = clients(&server).await;
702        mount_folder("parent-1").mount(&server).await;
703        let outcome = create(&drive, &sheets, &opts(Vec::new(), false), &[deny_rule]).await;
704        let text = describe(&outcome);
705        assert!(
706            text.contains("refused by rule on folder parent-1"),
707            "{text}"
708        );
709    }
710
711    #[tokio::test]
712    async fn a_failed_files_create_call_is_reported_as_failed() {
713        let server = wiremock::MockServer::start().await;
714        let (drive, sheets) = clients(&server).await;
715        mount_folder("parent-1").mount(&server).await;
716        wiremock::Mock::given(wiremock::matchers::method("POST"))
717            .and(wiremock::matchers::path("/drive/v3/files"))
718            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
719            .mount(&server)
720            .await;
721
722        let outcome = create(&drive, &sheets, &opts(Vec::new(), false), &[allow_rule()]).await;
723        assert!(matches!(outcome.result, CreateResult::Failed { .. }));
724        let text = describe(&outcome);
725        assert!(text.starts_with("Failed: 'Budget' in parent-1"), "{text}");
726    }
727
728    #[tokio::test]
729    async fn dry_run_reports_seed_dimensions_and_creates_nothing() {
730        let server = wiremock::MockServer::start().await;
731        let (drive, sheets) = clients(&server).await;
732        mount_folder("parent-1").mount(&server).await;
733        // No create mock: a dry run must not create.
734        let values = vec![vec!["a".to_string(), "b".to_string(), "c".to_string()]];
735        let outcome = create(&drive, &sheets, &opts(values, true), &[allow_rule()]).await;
736        assert_eq!(
737            outcome.result,
738            CreateResult::WouldCreate {
739                rows: 1,
740                columns: 3
741            }
742        );
743        assert!(describe(&outcome).contains("1 row(s) x 3 column(s)"));
744    }
745
746    #[tokio::test]
747    async fn dry_run_with_no_values_omits_the_seed_dimensions() {
748        let server = wiremock::MockServer::start().await;
749        let (drive, sheets) = clients(&server).await;
750        mount_folder("parent-1").mount(&server).await;
751
752        let outcome = create(&drive, &sheets, &opts(Vec::new(), true), &[allow_rule()]).await;
753        assert_eq!(
754            outcome.result,
755            CreateResult::WouldCreate {
756                rows: 0,
757                columns: 0
758            }
759        );
760        assert_eq!(
761            describe(&outcome),
762            "Would create: spreadsheet 'Budget' in parent-1"
763        );
764    }
765
766    #[tokio::test]
767    async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
768        let server = wiremock::MockServer::start().await;
769        let (drive, sheets) = clients(&server).await;
770        wiremock::Mock::given(wiremock::matchers::method("GET"))
771            .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
772            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
773            .mount(&server)
774            .await;
775
776        let outcome = create(&drive, &sheets, &opts(Vec::new(), false), &[allow_rule()]).await;
777        assert!(matches!(outcome.result, CreateResult::Failed { .. }));
778        assert!(describe(&outcome).starts_with("Failed: 'Budget' in parent-1"));
779    }
780
781    #[test]
782    fn log_status_covers_every_variant() {
783        assert_eq!(
784            CreateResult::WouldCreate {
785                rows: 0,
786                columns: 0
787            }
788            .log_status(),
789            "would-create"
790        );
791        assert_eq!(
792            CreateResult::Blocked { decided_by: None }.log_status(),
793            "blocked"
794        );
795        assert_eq!(
796            CreateResult::Created {
797                file_id: "a".to_string(),
798                seeded_cells: None
799            }
800            .log_status(),
801            "created"
802        );
803        assert_eq!(
804            CreateResult::CreatedValuesFailed {
805                file_id: "a".to_string(),
806                detail: String::new()
807            }
808            .log_status(),
809            "created-values-failed"
810        );
811        assert_eq!(
812            CreateResult::Failed {
813                detail: String::new()
814            }
815            .log_status(),
816            "failed"
817        );
818    }
819
820    #[test]
821    fn write_jsonl_emits_one_line_of_json() {
822        let outcome = CreateOutcome {
823            name: "Budget".to_string(),
824            parent_folder_id: "parent-1".to_string(),
825            result: CreateResult::Created {
826                file_id: "new-sheet".to_string(),
827                seeded_cells: None,
828            },
829        };
830        let mut buf = Vec::new();
831        outcome.write_jsonl(&mut buf).unwrap();
832        let text = String::from_utf8(buf).unwrap();
833        assert_eq!(text.matches('\n').count(), 1);
834        let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
835        assert_eq!(parsed["result"]["status"], "created");
836    }
837
838    #[test]
839    fn file_id_is_exposed_for_both_created_variants() {
840        assert_eq!(
841            CreateResult::Created {
842                file_id: "a".to_string(),
843                seeded_cells: None
844            }
845            .file_id(),
846            Some("a")
847        );
848        assert_eq!(
849            CreateResult::CreatedValuesFailed {
850                file_id: "b".to_string(),
851                detail: String::new()
852            }
853            .file_id(),
854            Some("b")
855        );
856        assert_eq!(
857            CreateResult::Failed {
858                detail: String::new()
859            }
860            .file_id(),
861            None
862        );
863    }
864}