1use std::time::{Duration, Instant};
24
25use serde::Serialize;
26
27use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
28use crate::drive::client::DriveClient;
29use crate::drive::docs::api::{DocsApi, SuggestionsViewMode};
30use crate::drive::docs::client::DocsClient;
31use crate::drive::docs::write_types::DocsRequest;
32use crate::drive::files_api::FilesApi;
33use crate::drive::folder_ancestry;
34use crate::drive::types::GOOGLE_DOC_MIME_TYPE;
35use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
36use crate::request_log::{self, DriveMutationOutcome};
37
38#[derive(Debug, Clone)]
40pub struct CreateOptions {
41 pub name: String,
43 pub parent_folder_id: String,
45 pub text: Option<String>,
47 pub dry_run: bool,
49}
50
51#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
53#[serde(tag = "status", rename_all = "kebab-case")]
54pub enum CreateResult {
55 WouldCreate {
57 chars: usize,
59 bytes: usize,
61 },
62 Blocked {
64 decided_by: Option<DecidingRule>,
66 },
67 Created {
69 file_id: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 seeded_chars: Option<usize>,
74 },
75 CreatedTextFailed {
81 file_id: String,
83 detail: String,
85 },
86 Failed {
88 detail: String,
90 },
91}
92
93impl CreateResult {
94 const fn log_status(&self) -> &'static str {
96 match self {
97 Self::WouldCreate { .. } => "would-create",
98 Self::Blocked { .. } => "blocked",
99 Self::Created { .. } => "created",
100 Self::CreatedTextFailed { .. } => "created-text-failed",
101 Self::Failed { .. } => "failed",
102 }
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct CreateOutcome {
114 pub name: String,
116 pub parent_folder_id: String,
119 pub result: CreateResult,
121}
122
123impl Serialize for CreateOutcome {
124 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
125 #[derive(Serialize)]
126 struct Wire<'a> {
127 name: &'a str,
128 parent_folder_id: &'a str,
129 resolved_folder_id: &'a str,
132 result: &'a CreateResult,
133 }
134 Wire {
135 name: &self.name,
136 parent_folder_id: &self.parent_folder_id,
137 resolved_folder_id: &self.parent_folder_id,
138 result: &self.result,
139 }
140 .serialize(serializer)
141 }
142}
143
144impl JsonlSerialize for CreateOutcome {
145 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
146 write_scalar_jsonl(self, out)
147 }
148}
149
150pub async fn create(
154 drive: &DriveClient,
155 docs: &DocsClient,
156 opts: &CreateOptions,
157 rules: &[FolderPermissionRule],
158) -> CreateOutcome {
159 let started = Instant::now();
160 let outcome = create_inner(drive, docs, 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 docs: &DocsClient,
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) => {
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.text.is_some() {
209 let docs_write_decision = match folder_ancestry::resolve_decision(
210 &files_api,
211 &opts.parent_folder_id,
212 DriveOperation::DocsWrite,
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 docs_write_decision.verdict == write_gate::Verdict::Deny
225 && docs_write_decision.decided_by.is_some()
226 {
227 return finish(CreateResult::Blocked {
228 decided_by: docs_write_decision.decided_by,
229 });
230 }
231 }
232
233 let (chars, bytes) = opts
234 .text
235 .as_ref()
236 .map_or((0, 0), |text| (text.chars().count(), text.len()));
237
238 if opts.dry_run {
239 return finish(CreateResult::WouldCreate { chars, bytes });
240 }
241
242 let created = match files_api
243 .create(&opts.name, &opts.parent_folder_id, GOOGLE_DOC_MIME_TYPE)
244 .await
245 {
246 Ok(created) => created,
247 Err(err) => {
248 return finish(CreateResult::Failed {
249 detail: err.to_string(),
250 })
251 }
252 };
253
254 let Some(text) = opts.text.as_ref() else {
255 return finish(CreateResult::Created {
256 file_id: created.id,
257 seeded_chars: None,
258 });
259 };
260
261 let api = DocsApi::new(docs);
263 let document = match api
264 .get_document(&created.id, SuggestionsViewMode::default())
265 .await
266 {
267 Ok(document) => document,
268 Err(err) => {
269 return finish(CreateResult::CreatedTextFailed {
270 file_id: created.id,
271 detail: err.to_string(),
272 })
273 }
274 };
275 let Some(revision_id) = document.revision_id else {
280 return finish(CreateResult::CreatedTextFailed {
281 file_id: created.id,
282 detail: "the new document returned no revision id, so the seed could not be leased"
283 .to_string(),
284 });
285 };
286
287 match api
288 .batch_update(
289 &created.id,
290 DocsRequest::insert_text_at_end(text),
291 &revision_id,
292 )
293 .await
294 {
295 Ok(_) => finish(CreateResult::Created {
296 file_id: created.id,
297 seeded_chars: Some(chars),
298 }),
299 Err(err) => finish(CreateResult::CreatedTextFailed {
303 file_id: created.id,
304 detail: err.to_string(),
305 }),
306 }
307}
308
309fn record_attempt(outcome: &CreateOutcome, duration: Duration) {
314 let error = match &outcome.result {
315 CreateResult::Failed { detail } | CreateResult::CreatedTextFailed { detail, .. } => {
316 Some(detail.clone())
317 }
318 _ => None,
319 };
320 let decided_by = match &outcome.result {
321 CreateResult::Blocked { decided_by } => decided_by.as_ref(),
322 _ => None,
323 };
324 let decided_by = write_gate::decided_by_log_fields(decided_by);
325 let file_id = match &outcome.result {
326 CreateResult::Created { file_id, .. } | CreateResult::CreatedTextFailed { file_id, .. } => {
327 file_id.clone()
328 }
329 _ => String::new(),
330 };
331 let inserted_chars = match &outcome.result {
332 CreateResult::Created {
333 seeded_chars: Some(chars),
334 ..
335 } => Some(*chars as i64),
336 _ => None,
337 };
338
339 request_log::record_drive_mutation(DriveMutationOutcome {
340 operation: "docs-create",
341 file_id,
342 file_name: outcome.name.clone(),
343 status: outcome.result.log_status().to_string(),
344 resolved_folder_id: Some(outcome.parent_folder_id.clone()),
345 decided_by_folder_id: decided_by.folder_id,
346 decided_by_depth: decided_by.depth,
347 decided_by_file_id: decided_by.file_id,
348 inserted_chars,
349 error,
350 duration,
351 ..Default::default()
352 });
353}
354
355#[must_use]
357pub fn describe(outcome: &CreateOutcome) -> String {
358 let name = &outcome.name;
359 let folder = &outcome.parent_folder_id;
360 match &outcome.result {
361 CreateResult::WouldCreate { chars, bytes } if *chars > 0 => format!(
362 "Would create: document '{name}' in {folder}, seeded with {chars} char(s) / \
363 {bytes} byte(s)"
364 ),
365 CreateResult::WouldCreate { .. } => {
366 format!("Would create: document '{name}' in {folder}")
367 }
368 CreateResult::Blocked { decided_by } => match decided_by {
369 Some(rule) => format!(
370 "Blocked: '{name}' in {folder} — refused by rule on {} {}{}",
371 rule.kind_label(),
372 rule.id(),
373 rule.depth_suffix()
374 ),
375 None => format!(
376 "Blocked: '{name}' in {folder} — refused by default policy (no matching rule)"
377 ),
378 },
379 CreateResult::Created {
380 file_id,
381 seeded_chars,
382 } => match seeded_chars {
383 Some(chars) => {
384 format!("Created: '{name}' ({file_id}) in {folder}, seeded with {chars} char(s)")
385 }
386 None => format!("Created: '{name}' ({file_id}) in {folder}"),
387 },
388 CreateResult::CreatedTextFailed { file_id, detail } => format!(
389 "Partially failed: created '{name}' ({file_id}) in {folder}, but seeding its text \
390 failed: {detail} The document exists and is empty — it cannot be rolled back \
391 automatically. Clean up explicitly with `omni-dev drive trash {file_id}` \
392 (requires trash permission on the file or its folder)."
393 ),
394 CreateResult::Failed { detail } => format!("Failed: '{name}' in {folder}: {detail}"),
395 }
396}
397
398#[cfg(test)]
399#[allow(clippy::unwrap_used, clippy::expect_used)]
400mod tests {
401 use super::*;
402 use std::collections::HashSet;
403
404 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
405 use crate::drive::docs::client::DOCS_API_URL;
406 use crate::test_support::env::MapEnv;
407 use crate::utils::secret::Secret;
408 use wiremock::matchers::{method, path};
409 use wiremock::{Mock, MockServer, ResponseTemplate};
410
411 fn test_credentials() -> DriveCredentials {
412 DriveCredentials {
413 client_id: "client-1".to_string(),
414 client_secret: Secret::new("secret-1"),
415 refresh_token: Secret::new("refresh-1"),
416 scope: DriveGrantedScopes::READONLY,
417 }
418 }
419
420 async fn clients(server: &MockServer) -> (DriveClient, DocsClient) {
421 Mock::given(method("POST"))
422 .and(path("/token"))
423 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
424 "access_token": "test-token", "expires_in": 3600,
425 })))
426 .mount(server)
427 .await;
428 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
429 crate::drive::client::test_support::replace_session(
430 &mut drive,
431 &test_credentials(),
432 &format!("{}/token", server.uri()),
433 );
434 let env = MapEnv::new().with(DOCS_API_URL, &server.uri());
435 let docs = DocsClient::from_drive_client_with(&env, &drive).unwrap();
436 (drive, docs)
437 }
438
439 fn mount_folder(id: &str) -> Mock {
440 Mock::given(method("GET"))
441 .and(path(format!("/drive/v3/files/{id}")))
442 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
443 "id": id, "name": id,
444 "mimeType": "application/vnd.google-apps.folder", "parents": [],
445 })))
446 }
447
448 fn mount_files_create() -> Mock {
449 Mock::given(method("POST"))
450 .and(path("/drive/v3/files"))
451 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
452 "id": "new-1", "name": "New Doc", "mimeType": GOOGLE_DOC_MIME_TYPE,
453 })))
454 }
455
456 fn mount_new_document(revision: Option<&str>) -> Mock {
457 let mut body = serde_json::json!({"documentId": "new-1", "body": {"content": []}});
458 if let Some(revision) = revision {
459 body["revisionId"] = serde_json::json!(revision);
460 }
461 Mock::given(method("GET"))
462 .and(path("/v1/documents/new-1"))
463 .respond_with(ResponseTemplate::new(200).set_body_json(body))
464 }
465
466 fn rule(ops: &[DriveOperation], denies: &[DriveOperation]) -> FolderPermissionRule {
467 FolderPermissionRule {
468 folder_id: Some("folder-1".to_string()),
469 file_id: None,
470 recursive: true,
471 allow: ops.iter().copied().collect(),
472 deny: denies.iter().copied().collect::<HashSet<_>>(),
473 require_lease: true,
474 }
475 }
476
477 fn opts(text: Option<&str>, dry_run: bool) -> CreateOptions {
478 CreateOptions {
479 name: "New Doc".to_string(),
480 parent_folder_id: "folder-1".to_string(),
481 text: text.map(str::to_string),
482 dry_run,
483 }
484 }
485
486 #[test]
489 fn json_carries_parent_folder_id_and_the_legacy_resolved_folder_id() {
490 let outcome = CreateOutcome {
491 name: "Notes".to_string(),
492 parent_folder_id: "folder-1".to_string(),
493 result: CreateResult::Created {
494 file_id: "doc-1".to_string(),
495 seeded_chars: None,
496 },
497 };
498 let json = serde_json::to_value(&outcome).unwrap();
499 assert_eq!(json["parent_folder_id"], "folder-1");
500 assert_eq!(json["resolved_folder_id"], "folder-1");
501 assert_eq!(json["name"], "Notes");
502 assert_eq!(json["result"]["file_id"], "doc-1");
503 }
504 #[tokio::test]
505 async fn create_without_text_makes_one_files_create_and_zero_docs_calls() {
506 let server = MockServer::start().await;
507 let (drive, docs) = clients(&server).await;
508 mount_folder("folder-1").mount(&server).await;
509 mount_files_create().expect(1).mount(&server).await;
510 let outcome = create(
513 &drive,
514 &docs,
515 &opts(None, false),
516 &[rule(&[DriveOperation::Create], &[])],
517 )
518 .await;
519 assert_eq!(
520 outcome.result,
521 CreateResult::Created {
522 file_id: "new-1".to_string(),
523 seeded_chars: None,
524 }
525 );
526 }
527
528 #[tokio::test]
532 async fn create_with_text_reads_the_new_document_for_a_lease_before_seeding() {
533 let server = MockServer::start().await;
534 let (drive, docs) = clients(&server).await;
535 mount_folder("folder-1").mount(&server).await;
536 mount_files_create().mount(&server).await;
537 mount_new_document(Some("rev-new"))
538 .expect(1)
539 .mount(&server)
540 .await;
541 Mock::given(method("POST"))
542 .and(path("/v1/documents/new-1:batchUpdate"))
543 .and(wiremock::matchers::body_partial_json(serde_json::json!({
544 "writeControl": {"requiredRevisionId": "rev-new"},
545 })))
546 .respond_with(
547 ResponseTemplate::new(200)
548 .set_body_json(serde_json::json!({"documentId": "new-1", "replies": [{}]})),
549 )
550 .expect(1)
551 .mount(&server)
552 .await;
553
554 let outcome = create(
555 &drive,
556 &docs,
557 &opts(Some("hello"), false),
558 &[rule(&[DriveOperation::Create], &[])],
559 )
560 .await;
561 assert_eq!(
562 outcome.result,
563 CreateResult::Created {
564 file_id: "new-1".to_string(),
565 seeded_chars: Some(5),
566 }
567 );
568 }
569
570 #[tokio::test]
573 async fn a_seed_failure_reports_created_text_failed_carrying_the_id() {
574 let server = MockServer::start().await;
575 let (drive, docs) = clients(&server).await;
576 mount_folder("folder-1").mount(&server).await;
577 mount_files_create().mount(&server).await;
578 mount_new_document(Some("rev-new")).mount(&server).await;
579 Mock::given(method("POST"))
580 .and(path("/v1/documents/new-1:batchUpdate"))
581 .respond_with(ResponseTemplate::new(500))
582 .mount(&server)
583 .await;
584
585 let outcome = create(
586 &drive,
587 &docs,
588 &opts(Some("hello"), false),
589 &[rule(&[DriveOperation::Create], &[])],
590 )
591 .await;
592 match &outcome.result {
593 CreateResult::CreatedTextFailed { file_id, .. } => assert_eq!(file_id, "new-1"),
594 other => panic!("expected CreatedTextFailed, got {other:?}"),
595 }
596 let text = describe(&outcome);
597 assert!(
598 text.contains("new-1"),
599 "the orphan must be findable: {text}"
600 );
601 assert!(text.contains("cannot be rolled back"), "{text}");
602 assert!(text.contains("omni-dev drive trash new-1"), "{text}");
603 assert!(text.contains("requires trash permission"), "{text}");
604 }
605
606 #[tokio::test]
610 async fn a_bare_default_deny_does_not_block_the_seed() {
611 let server = MockServer::start().await;
612 let (drive, docs) = clients(&server).await;
613 mount_folder("folder-1").mount(&server).await;
614 mount_files_create().mount(&server).await;
615 mount_new_document(Some("rev-new")).mount(&server).await;
616 Mock::given(method("POST"))
617 .and(path("/v1/documents/new-1:batchUpdate"))
618 .respond_with(
619 ResponseTemplate::new(200)
620 .set_body_json(serde_json::json!({"documentId": "new-1", "replies": [{}]})),
621 )
622 .mount(&server)
623 .await;
624
625 let outcome = create(
627 &drive,
628 &docs,
629 &opts(Some("hello"), false),
630 &[rule(&[DriveOperation::Create], &[])],
631 )
632 .await;
633 assert!(matches!(outcome.result, CreateResult::Created { .. }));
634 }
635
636 #[tokio::test]
639 async fn an_explicit_docs_write_deny_blocks_the_seed_and_creates_nothing() {
640 let server = MockServer::start().await;
641 let (drive, docs) = clients(&server).await;
642 mount_folder("folder-1").mount(&server).await;
643 let outcome = create(
646 &drive,
647 &docs,
648 &opts(Some("hello"), false),
649 &[rule(
650 &[DriveOperation::Create],
651 &[DriveOperation::DocsWrite],
652 )],
653 )
654 .await;
655 assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
656 }
657
658 #[tokio::test]
661 async fn an_explicit_docs_write_deny_does_not_block_an_unseeded_create() {
662 let server = MockServer::start().await;
663 let (drive, docs) = clients(&server).await;
664 mount_folder("folder-1").mount(&server).await;
665 mount_files_create().mount(&server).await;
666
667 let outcome = create(
668 &drive,
669 &docs,
670 &opts(None, false),
671 &[rule(
672 &[DriveOperation::Create],
673 &[DriveOperation::DocsWrite],
674 )],
675 )
676 .await;
677 assert!(matches!(outcome.result, CreateResult::Created { .. }));
678 }
679
680 #[tokio::test]
681 async fn a_denied_create_is_blocked_and_creates_nothing() {
682 let server = MockServer::start().await;
683 let (drive, docs) = clients(&server).await;
684 mount_folder("folder-1").mount(&server).await;
685
686 let outcome = create(&drive, &docs, &opts(None, false), &[]).await;
687 assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
688 }
689
690 #[tokio::test]
691 async fn dry_run_creates_nothing() {
692 let server = MockServer::start().await;
693 let (drive, docs) = clients(&server).await;
694 mount_folder("folder-1").mount(&server).await;
695 let outcome = create(
698 &drive,
699 &docs,
700 &opts(Some("hello"), true),
701 &[rule(&[DriveOperation::Create], &[])],
702 )
703 .await;
704 assert_eq!(
705 outcome.result,
706 CreateResult::WouldCreate { chars: 5, bytes: 5 }
707 );
708 assert!(describe(&outcome).contains("seeded with 5 char(s)"));
709 }
710}