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    /// Carries the ID because automatic rollback is not authorised by a
93    /// create grant. The operator may explicitly trash the empty spreadsheet
94    /// with a separate `trash` permission; plain failure would hide its ID.
95    CreatedValuesFailed {
96        /// The new spreadsheet's id — it exists and is empty.
97        file_id: String,
98        /// Why seeding failed.
99        detail: String,
100    },
101    /// Creation itself failed; nothing was created.
102    Failed {
103        /// A human-readable summary.
104        detail: String,
105    },
106}
107
108impl CreateResult {
109    fn log_status(&self) -> &'static str {
110        match self {
111            Self::WouldCreate { .. } => "would-create",
112            Self::Blocked { .. } => "blocked",
113            Self::Created { .. } => "created",
114            Self::CreatedValuesFailed { .. } => "created-values-failed",
115            Self::Failed { .. } => "failed",
116        }
117    }
118
119    /// The new file's id, when one exists — including the partial-failure
120    /// case, which is the whole reason that variant carries it.
121    fn file_id(&self) -> Option<&str> {
122        match self {
123            Self::Created { file_id, .. } | Self::CreatedValuesFailed { file_id, .. } => {
124                Some(file_id)
125            }
126            _ => None,
127        }
128    }
129}
130
131/// The full outcome of one attempt.
132#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
133pub struct CreateOutcome {
134    /// The requested title.
135    pub name: String,
136    /// The folder it was to be created in.
137    pub parent_folder_id: String,
138    /// What happened.
139    pub result: CreateResult,
140}
141
142impl JsonlSerialize for CreateOutcome {
143    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
144        write_scalar_jsonl(self, out)
145    }
146}
147
148/// Creates a spreadsheet, optionally seeding its cells.
149///
150/// Never returns `Err`; every failure is a [`CreateResult`] variant, and the
151/// process exits 0 regardless (ADR-0070 §10, ADR-0071 §12).
152pub async fn create(
153    drive: &DriveClient,
154    sheets: &SheetsClient,
155    opts: &CreateOptions,
156    rules: &[FolderPermissionRule],
157) -> CreateOutcome {
158    let started = Instant::now();
159    let outcome = create_inner(drive, sheets, opts, rules).await;
160    if !opts.dry_run {
161        record_attempt(&outcome, started.elapsed());
162    }
163    outcome
164}
165
166async fn create_inner(
167    drive: &DriveClient,
168    sheets: &SheetsClient,
169    opts: &CreateOptions,
170    rules: &[FolderPermissionRule],
171) -> CreateOutcome {
172    let finish = |result| CreateOutcome {
173        name: opts.name.clone(),
174        parent_folder_id: opts.parent_folder_id.clone(),
175        result,
176    };
177
178    let files_api = FilesApi::new(drive);
179    let decision = match folder_ancestry::resolve_decision(
180        &files_api,
181        &opts.parent_folder_id,
182        DriveOperation::Create,
183        rules,
184    )
185    .await
186    {
187        Ok(decision) => decision,
188        // An unresolvable chain is a refusal, never a silent allow.
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: ["sheets-write"]` on this folder still refuses the
203    // seed, even though `Create` alone authorises it otherwise — see this
204    // module's header. `decided_by.is_some()` is what distinguishes that
205    // deliberate signal from the bare default policy (no rule mentions
206    // `sheets-write` at all), which must **not** block a folder that only
207    // grants `create`.
208    if !opts.values.is_empty() {
209        let sheets_write_decision = match folder_ancestry::resolve_decision(
210            &files_api,
211            &opts.parent_folder_id,
212            DriveOperation::SheetsWrite,
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 sheets_write_decision.verdict == write_gate::Verdict::Deny
225            && sheets_write_decision.decided_by.is_some()
226        {
227            return finish(CreateResult::Blocked {
228                decided_by: sheets_write_decision.decided_by,
229            });
230        }
231    }
232
233    if opts.dry_run {
234        return finish(CreateResult::WouldCreate {
235            rows: opts.values.len(),
236            columns: opts.values.iter().map(Vec::len).max().unwrap_or(0),
237        });
238    }
239
240    let created = match files_api
241        .create(&opts.name, &opts.parent_folder_id, GOOGLE_SHEET_MIME_TYPE)
242        .await
243    {
244        Ok(file) => file,
245        Err(err) => {
246            return finish(CreateResult::Failed {
247                detail: format!("{err:#}"),
248            })
249        }
250    };
251
252    if opts.values.is_empty() {
253        return finish(CreateResult::Created {
254            file_id: created.id,
255            seeded_cells: None,
256        });
257    }
258
259    // The seed itself. `created.id` is the id `files.create` just returned,
260    // never anything the caller supplied — see this module's header.
261    match SheetsApi::new(sheets)
262        .values_update(&created.id, SEED_RANGE, &opts.values, opts.input)
263        .await
264    {
265        Ok(response) => finish(CreateResult::Created {
266            file_id: created.id,
267            seeded_cells: response.updated_cells,
268        }),
269        Err(err) => finish(CreateResult::CreatedValuesFailed {
270            file_id: created.id,
271            detail: format!("{err:#}"),
272        }),
273    }
274}
275
276fn record_attempt(outcome: &CreateOutcome, duration: Duration) {
277    let error = match &outcome.result {
278        CreateResult::Failed { detail } | CreateResult::CreatedValuesFailed { detail, .. } => {
279            Some(detail.clone())
280        }
281        _ => None,
282    };
283    let decided_by = match &outcome.result {
284        CreateResult::Blocked { decided_by } => decided_by.as_ref(),
285        _ => None,
286    };
287    let decided_by = write_gate::decided_by_log_fields(decided_by);
288    let updated_cells = match &outcome.result {
289        CreateResult::Created { seeded_cells, .. } => *seeded_cells,
290        _ => None,
291    };
292
293    request_log::record_drive_mutation(DriveMutationOutcome {
294        operation: "sheets-create",
295        file_id: outcome.result.file_id().unwrap_or_default().to_string(),
296        file_name: outcome.name.clone(),
297        status: outcome.result.log_status().to_string(),
298        resolved_folder_id: Some(outcome.parent_folder_id.clone()),
299        decided_by_folder_id: decided_by.folder_id,
300        decided_by_depth: decided_by.depth,
301        decided_by_file_id: decided_by.file_id,
302        updated_cells,
303        error,
304        duration,
305        ..Default::default()
306    });
307}
308
309/// Renders an outcome as a single human-readable line.
310#[must_use]
311pub fn describe(outcome: &CreateOutcome) -> String {
312    let name = &outcome.name;
313    let parent = &outcome.parent_folder_id;
314    match &outcome.result {
315        CreateResult::WouldCreate { rows, columns } if *rows == 0 => {
316            format!("Would create: spreadsheet '{name}' in {parent}")
317        }
318        CreateResult::WouldCreate { rows, columns } => format!(
319            "Would create: spreadsheet '{name}' in {parent}, seeded with {rows} row(s) x \
320             {columns} column(s)"
321        ),
322        CreateResult::Blocked { decided_by } => match decided_by {
323            Some(rule) => format!(
324                "Blocked: '{name}' in {parent} — refused by rule on {} {}{}",
325                rule.kind_label(),
326                rule.id(),
327                rule.depth_suffix()
328            ),
329            None => format!(
330                "Blocked: '{name}' in {parent} — refused by default policy (no matching rule)"
331            ),
332        },
333        CreateResult::Created {
334            file_id,
335            seeded_cells,
336        } => match seeded_cells {
337            Some(cells) => {
338                format!("Created: '{name}' ({file_id}) in {parent}, seeded {cells} cell(s)")
339            }
340            None => format!("Created: '{name}' ({file_id}) in {parent}"),
341        },
342        CreateResult::CreatedValuesFailed { file_id, detail } => format!(
343            "Partially failed: created '{name}' ({file_id}) in {parent}, but writing its \
344             values failed: {detail}. The spreadsheet exists and is empty — it cannot be \
345             rolled back automatically. Clean up explicitly with `omni-dev drive trash {file_id}` \
346             (requires trash permission on the file or its folder)."
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        assert!(text.contains("omni-dev drive trash new-sheet"), "{text}");
672        assert!(text.contains("requires trash permission"), "{text}");
673    }
674
675    #[tokio::test]
676    async fn a_denied_parent_creates_nothing() {
677        let server = wiremock::MockServer::start().await;
678        let (drive, sheets) = clients(&server).await;
679        mount_folder("parent-1").mount(&server).await;
680        // No create mock and no Sheets mock.
681        let outcome = create(&drive, &sheets, &opts(Vec::new(), false), &[]).await;
682        assert!(matches!(
683            outcome.result,
684            CreateResult::Blocked { decided_by: None }
685        ));
686        assert_eq!(
687            describe(&outcome),
688            "Blocked: 'Budget' in parent-1 — refused by default policy (no matching rule)"
689        );
690    }
691
692    #[tokio::test]
693    async fn a_blocked_by_rule_names_the_deciding_folder_in_the_message() {
694        let deny_rule = FolderPermissionRule {
695            folder_id: Some("parent-1".to_string()),
696            file_id: None,
697            recursive: true,
698            allow: HashSet::default(),
699            deny: std::iter::once(DriveOperation::Create).collect(),
700            require_lease: true,
701        };
702        let server = wiremock::MockServer::start().await;
703        let (drive, sheets) = clients(&server).await;
704        mount_folder("parent-1").mount(&server).await;
705        let outcome = create(&drive, &sheets, &opts(Vec::new(), false), &[deny_rule]).await;
706        let text = describe(&outcome);
707        assert!(
708            text.contains("refused by rule on folder parent-1"),
709            "{text}"
710        );
711    }
712
713    #[tokio::test]
714    async fn a_failed_files_create_call_is_reported_as_failed() {
715        let server = wiremock::MockServer::start().await;
716        let (drive, sheets) = clients(&server).await;
717        mount_folder("parent-1").mount(&server).await;
718        wiremock::Mock::given(wiremock::matchers::method("POST"))
719            .and(wiremock::matchers::path("/drive/v3/files"))
720            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
721            .mount(&server)
722            .await;
723
724        let outcome = create(&drive, &sheets, &opts(Vec::new(), false), &[allow_rule()]).await;
725        assert!(matches!(outcome.result, CreateResult::Failed { .. }));
726        let text = describe(&outcome);
727        assert!(text.starts_with("Failed: 'Budget' in parent-1"), "{text}");
728    }
729
730    #[tokio::test]
731    async fn dry_run_reports_seed_dimensions_and_creates_nothing() {
732        let server = wiremock::MockServer::start().await;
733        let (drive, sheets) = clients(&server).await;
734        mount_folder("parent-1").mount(&server).await;
735        // No create mock: a dry run must not create.
736        let values = vec![vec!["a".to_string(), "b".to_string(), "c".to_string()]];
737        let outcome = create(&drive, &sheets, &opts(values, true), &[allow_rule()]).await;
738        assert_eq!(
739            outcome.result,
740            CreateResult::WouldCreate {
741                rows: 1,
742                columns: 3
743            }
744        );
745        assert!(describe(&outcome).contains("1 row(s) x 3 column(s)"));
746    }
747
748    #[tokio::test]
749    async fn dry_run_with_no_values_omits_the_seed_dimensions() {
750        let server = wiremock::MockServer::start().await;
751        let (drive, sheets) = clients(&server).await;
752        mount_folder("parent-1").mount(&server).await;
753
754        let outcome = create(&drive, &sheets, &opts(Vec::new(), true), &[allow_rule()]).await;
755        assert_eq!(
756            outcome.result,
757            CreateResult::WouldCreate {
758                rows: 0,
759                columns: 0
760            }
761        );
762        assert_eq!(
763            describe(&outcome),
764            "Would create: spreadsheet 'Budget' in parent-1"
765        );
766    }
767
768    #[tokio::test]
769    async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
770        let server = wiremock::MockServer::start().await;
771        let (drive, sheets) = clients(&server).await;
772        wiremock::Mock::given(wiremock::matchers::method("GET"))
773            .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
774            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
775            .mount(&server)
776            .await;
777
778        let outcome = create(&drive, &sheets, &opts(Vec::new(), false), &[allow_rule()]).await;
779        assert!(matches!(outcome.result, CreateResult::Failed { .. }));
780        assert!(describe(&outcome).starts_with("Failed: 'Budget' in parent-1"));
781    }
782
783    #[test]
784    fn log_status_covers_every_variant() {
785        assert_eq!(
786            CreateResult::WouldCreate {
787                rows: 0,
788                columns: 0
789            }
790            .log_status(),
791            "would-create"
792        );
793        assert_eq!(
794            CreateResult::Blocked { decided_by: None }.log_status(),
795            "blocked"
796        );
797        assert_eq!(
798            CreateResult::Created {
799                file_id: "a".to_string(),
800                seeded_cells: None
801            }
802            .log_status(),
803            "created"
804        );
805        assert_eq!(
806            CreateResult::CreatedValuesFailed {
807                file_id: "a".to_string(),
808                detail: String::new()
809            }
810            .log_status(),
811            "created-values-failed"
812        );
813        assert_eq!(
814            CreateResult::Failed {
815                detail: String::new()
816            }
817            .log_status(),
818            "failed"
819        );
820    }
821
822    #[test]
823    fn write_jsonl_emits_one_line_of_json() {
824        let outcome = CreateOutcome {
825            name: "Budget".to_string(),
826            parent_folder_id: "parent-1".to_string(),
827            result: CreateResult::Created {
828                file_id: "new-sheet".to_string(),
829                seeded_cells: None,
830            },
831        };
832        let mut buf = Vec::new();
833        outcome.write_jsonl(&mut buf).unwrap();
834        let text = String::from_utf8(buf).unwrap();
835        assert_eq!(text.matches('\n').count(), 1);
836        let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
837        assert_eq!(parsed["result"]["status"], "created");
838    }
839
840    #[test]
841    fn file_id_is_exposed_for_both_created_variants() {
842        assert_eq!(
843            CreateResult::Created {
844                file_id: "a".to_string(),
845                seeded_cells: None
846            }
847            .file_id(),
848            Some("a")
849        );
850        assert_eq!(
851            CreateResult::CreatedValuesFailed {
852                file_id: "b".to_string(),
853                detail: String::new()
854            }
855            .file_id(),
856            Some("b")
857        );
858        assert_eq!(
859            CreateResult::Failed {
860                detail: String::new()
861            }
862            .file_id(),
863            None
864        );
865    }
866}