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 {
96 file_id: String,
98 detail: String,
100 },
101 Failed {
103 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 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#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
133pub struct CreateOutcome {
134 pub name: String,
136 pub parent_folder_id: String,
138 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
148pub 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 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 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 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#[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 #[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 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 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 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}