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 {
82 file_id: String,
84 detail: String,
86 },
87 Failed {
89 detail: String,
91 },
92}
93
94impl CreateResult {
95 const fn log_status(&self) -> &'static str {
97 match self {
98 Self::WouldCreate { .. } => "would-create",
99 Self::Blocked { .. } => "blocked",
100 Self::Created { .. } => "created",
101 Self::CreatedTextFailed { .. } => "created-text-failed",
102 Self::Failed { .. } => "failed",
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct CreateOutcome {
115 pub name: String,
117 pub parent_folder_id: String,
120 pub result: CreateResult,
122}
123
124impl Serialize for CreateOutcome {
125 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
126 #[derive(Serialize)]
127 struct Wire<'a> {
128 name: &'a str,
129 parent_folder_id: &'a str,
130 resolved_folder_id: &'a str,
133 result: &'a CreateResult,
134 }
135 Wire {
136 name: &self.name,
137 parent_folder_id: &self.parent_folder_id,
138 resolved_folder_id: &self.parent_folder_id,
139 result: &self.result,
140 }
141 .serialize(serializer)
142 }
143}
144
145impl JsonlSerialize for CreateOutcome {
146 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
147 write_scalar_jsonl(self, out)
148 }
149}
150
151pub async fn create(
155 drive: &DriveClient,
156 docs: &DocsClient,
157 opts: &CreateOptions,
158 rules: &[FolderPermissionRule],
159) -> CreateOutcome {
160 let started = Instant::now();
161 let outcome = create_inner(drive, docs, opts, rules).await;
162 if !opts.dry_run {
163 record_attempt(&outcome, started.elapsed());
164 }
165 outcome
166}
167
168async fn create_inner(
169 drive: &DriveClient,
170 docs: &DocsClient,
171 opts: &CreateOptions,
172 rules: &[FolderPermissionRule],
173) -> CreateOutcome {
174 let finish = |result| CreateOutcome {
175 name: opts.name.clone(),
176 parent_folder_id: opts.parent_folder_id.clone(),
177 result,
178 };
179
180 let files_api = FilesApi::new(drive);
181 let decision = match folder_ancestry::resolve_decision(
182 &files_api,
183 &opts.parent_folder_id,
184 DriveOperation::Create,
185 rules,
186 )
187 .await
188 {
189 Ok(decision) => decision,
190 Err(err) => {
191 return finish(CreateResult::Failed {
192 detail: err.to_string(),
193 })
194 }
195 };
196
197 if decision.verdict == write_gate::Verdict::Deny {
198 return finish(CreateResult::Blocked {
199 decided_by: decision.decided_by,
200 });
201 }
202
203 if opts.text.is_some() {
210 let docs_write_decision = match folder_ancestry::resolve_decision(
211 &files_api,
212 &opts.parent_folder_id,
213 DriveOperation::DocsWrite,
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 docs_write_decision.verdict == write_gate::Verdict::Deny
226 && docs_write_decision.decided_by.is_some()
227 {
228 return finish(CreateResult::Blocked {
229 decided_by: docs_write_decision.decided_by,
230 });
231 }
232 }
233
234 let (chars, bytes) = opts
235 .text
236 .as_ref()
237 .map_or((0, 0), |text| (text.chars().count(), text.len()));
238
239 if opts.dry_run {
240 return finish(CreateResult::WouldCreate { chars, bytes });
241 }
242
243 let created = match files_api
244 .create(&opts.name, &opts.parent_folder_id, GOOGLE_DOC_MIME_TYPE)
245 .await
246 {
247 Ok(created) => created,
248 Err(err) => {
249 return finish(CreateResult::Failed {
250 detail: err.to_string(),
251 })
252 }
253 };
254
255 let Some(text) = opts.text.as_ref() else {
256 return finish(CreateResult::Created {
257 file_id: created.id,
258 seeded_chars: None,
259 });
260 };
261
262 let api = DocsApi::new(docs);
264 let document = match api
265 .get_document(&created.id, SuggestionsViewMode::default())
266 .await
267 {
268 Ok(document) => document,
269 Err(err) => {
270 return finish(CreateResult::CreatedTextFailed {
271 file_id: created.id,
272 detail: err.to_string(),
273 })
274 }
275 };
276 let Some(revision_id) = document.revision_id else {
281 return finish(CreateResult::CreatedTextFailed {
282 file_id: created.id,
283 detail: "the new document returned no revision id, so the seed could not be leased"
284 .to_string(),
285 });
286 };
287
288 match api
289 .batch_update(
290 &created.id,
291 DocsRequest::insert_text_at_end(text),
292 &revision_id,
293 )
294 .await
295 {
296 Ok(_) => finish(CreateResult::Created {
297 file_id: created.id,
298 seeded_chars: Some(chars),
299 }),
300 Err(err) => finish(CreateResult::CreatedTextFailed {
304 file_id: created.id,
305 detail: err.to_string(),
306 }),
307 }
308}
309
310fn record_attempt(outcome: &CreateOutcome, duration: Duration) {
315 let error = match &outcome.result {
316 CreateResult::Failed { detail } | CreateResult::CreatedTextFailed { detail, .. } => {
317 Some(detail.clone())
318 }
319 _ => None,
320 };
321 let decided_by = match &outcome.result {
322 CreateResult::Blocked { decided_by } => decided_by.as_ref(),
323 _ => None,
324 };
325 let decided_by = write_gate::decided_by_log_fields(decided_by);
326 let file_id = match &outcome.result {
327 CreateResult::Created { file_id, .. } | CreateResult::CreatedTextFailed { file_id, .. } => {
328 file_id.clone()
329 }
330 _ => String::new(),
331 };
332 let inserted_chars = match &outcome.result {
333 CreateResult::Created {
334 seeded_chars: Some(chars),
335 ..
336 } => Some(*chars as i64),
337 _ => None,
338 };
339
340 request_log::record_drive_mutation(DriveMutationOutcome {
341 operation: "docs-create",
342 file_id,
343 file_name: outcome.name.clone(),
344 status: outcome.result.log_status().to_string(),
345 resolved_folder_id: Some(outcome.parent_folder_id.clone()),
346 decided_by_folder_id: decided_by.folder_id,
347 decided_by_depth: decided_by.depth,
348 decided_by_file_id: decided_by.file_id,
349 inserted_chars,
350 error,
351 duration,
352 ..Default::default()
353 });
354}
355
356#[must_use]
358pub fn describe(outcome: &CreateOutcome) -> String {
359 let name = &outcome.name;
360 let folder = &outcome.parent_folder_id;
361 match &outcome.result {
362 CreateResult::WouldCreate { chars, bytes } if *chars > 0 => format!(
363 "Would create: document '{name}' in {folder}, seeded with {chars} char(s) / \
364 {bytes} byte(s)"
365 ),
366 CreateResult::WouldCreate { .. } => {
367 format!("Would create: document '{name}' in {folder}")
368 }
369 CreateResult::Blocked { decided_by } => match decided_by {
370 Some(rule) => format!(
371 "Blocked: '{name}' in {folder} — refused by rule on {} {}{}",
372 rule.kind_label(),
373 rule.id(),
374 rule.depth_suffix()
375 ),
376 None => format!(
377 "Blocked: '{name}' in {folder} — refused by default policy (no matching rule)"
378 ),
379 },
380 CreateResult::Created {
381 file_id,
382 seeded_chars,
383 } => match seeded_chars {
384 Some(chars) => {
385 format!("Created: '{name}' ({file_id}) in {folder}, seeded with {chars} char(s)")
386 }
387 None => format!("Created: '{name}' ({file_id}) in {folder}"),
388 },
389 CreateResult::CreatedTextFailed { file_id, detail } => format!(
390 "Partially failed: created '{name}' ({file_id}) in {folder}, but seeding its text \
391 failed: {detail} The document exists and is empty — it cannot be rolled back \
392 automatically."
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 }
603
604 #[tokio::test]
608 async fn a_bare_default_deny_does_not_block_the_seed() {
609 let server = MockServer::start().await;
610 let (drive, docs) = clients(&server).await;
611 mount_folder("folder-1").mount(&server).await;
612 mount_files_create().mount(&server).await;
613 mount_new_document(Some("rev-new")).mount(&server).await;
614 Mock::given(method("POST"))
615 .and(path("/v1/documents/new-1:batchUpdate"))
616 .respond_with(
617 ResponseTemplate::new(200)
618 .set_body_json(serde_json::json!({"documentId": "new-1", "replies": [{}]})),
619 )
620 .mount(&server)
621 .await;
622
623 let outcome = create(
625 &drive,
626 &docs,
627 &opts(Some("hello"), false),
628 &[rule(&[DriveOperation::Create], &[])],
629 )
630 .await;
631 assert!(matches!(outcome.result, CreateResult::Created { .. }));
632 }
633
634 #[tokio::test]
637 async fn an_explicit_docs_write_deny_blocks_the_seed_and_creates_nothing() {
638 let server = MockServer::start().await;
639 let (drive, docs) = clients(&server).await;
640 mount_folder("folder-1").mount(&server).await;
641 let outcome = create(
644 &drive,
645 &docs,
646 &opts(Some("hello"), false),
647 &[rule(
648 &[DriveOperation::Create],
649 &[DriveOperation::DocsWrite],
650 )],
651 )
652 .await;
653 assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
654 }
655
656 #[tokio::test]
659 async fn an_explicit_docs_write_deny_does_not_block_an_unseeded_create() {
660 let server = MockServer::start().await;
661 let (drive, docs) = clients(&server).await;
662 mount_folder("folder-1").mount(&server).await;
663 mount_files_create().mount(&server).await;
664
665 let outcome = create(
666 &drive,
667 &docs,
668 &opts(None, false),
669 &[rule(
670 &[DriveOperation::Create],
671 &[DriveOperation::DocsWrite],
672 )],
673 )
674 .await;
675 assert!(matches!(outcome.result, CreateResult::Created { .. }));
676 }
677
678 #[tokio::test]
679 async fn a_denied_create_is_blocked_and_creates_nothing() {
680 let server = MockServer::start().await;
681 let (drive, docs) = clients(&server).await;
682 mount_folder("folder-1").mount(&server).await;
683
684 let outcome = create(&drive, &docs, &opts(None, false), &[]).await;
685 assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
686 }
687
688 #[tokio::test]
689 async fn dry_run_creates_nothing() {
690 let server = MockServer::start().await;
691 let (drive, docs) = clients(&server).await;
692 mount_folder("folder-1").mount(&server).await;
693 let outcome = create(
696 &drive,
697 &docs,
698 &opts(Some("hello"), true),
699 &[rule(&[DriveOperation::Create], &[])],
700 )
701 .await;
702 assert_eq!(
703 outcome.result,
704 CreateResult::WouldCreate { chars: 5, bytes: 5 }
705 );
706 assert!(describe(&outcome).contains("seeded with 5 char(s)"));
707 }
708}