1use 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
43const SEED_RANGE: &str = "'Sheet1'!A1";
49
50#[derive(Debug, Clone)]
52pub struct CreateOptions {
53 pub name: String,
55 pub parent_folder_id: String,
57 pub values: Vec<Vec<String>>,
59 pub input: ValueInputOption,
61 pub dry_run: bool,
63}
64
65#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
67#[serde(tag = "status", rename_all = "kebab-case")]
68pub enum CreateResult {
69 WouldCreate {
71 rows: usize,
74 columns: usize,
76 },
77 Blocked {
79 decided_by: Option<DecidingRule>,
81 },
82 Created {
84 file_id: String,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 seeded_cells: Option<i64>,
89 },
90 CreatedValuesFailed {
97 file_id: String,
99 detail: String,
101 },
102 Failed {
104 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 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#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
134pub struct CreateOutcome {
135 pub name: String,
137 pub parent_folder_id: String,
139 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
149pub 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 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 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 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#[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 #[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 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 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 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 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 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 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 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 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 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}