use std::time::{Duration, Instant};
use serde::Serialize;
use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
use crate::drive::client::DriveClient;
use crate::drive::docs::api::{DocsApi, SuggestionsViewMode};
use crate::drive::docs::client::DocsClient;
use crate::drive::docs::write_types::DocsRequest;
use crate::drive::files_api::FilesApi;
use crate::drive::folder_ancestry;
use crate::drive::types::GOOGLE_DOC_MIME_TYPE;
use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
use crate::request_log::{self, DriveMutationOutcome};
#[derive(Debug, Clone)]
pub struct CreateOptions {
pub name: String,
pub parent_folder_id: String,
pub text: Option<String>,
pub dry_run: bool,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum CreateResult {
WouldCreate {
chars: usize,
bytes: usize,
},
Blocked {
decided_by: Option<DecidingRule>,
},
Created {
file_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
seeded_chars: Option<usize>,
},
CreatedTextFailed {
file_id: String,
detail: String,
},
Failed {
detail: String,
},
}
impl CreateResult {
const fn log_status(&self) -> &'static str {
match self {
Self::WouldCreate { .. } => "would-create",
Self::Blocked { .. } => "blocked",
Self::Created { .. } => "created",
Self::CreatedTextFailed { .. } => "created-text-failed",
Self::Failed { .. } => "failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateOutcome {
pub name: String,
pub parent_folder_id: String,
pub result: CreateResult,
}
impl Serialize for CreateOutcome {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
#[derive(Serialize)]
struct Wire<'a> {
name: &'a str,
parent_folder_id: &'a str,
resolved_folder_id: &'a str,
result: &'a CreateResult,
}
Wire {
name: &self.name,
parent_folder_id: &self.parent_folder_id,
resolved_folder_id: &self.parent_folder_id,
result: &self.result,
}
.serialize(serializer)
}
}
impl JsonlSerialize for CreateOutcome {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
write_scalar_jsonl(self, out)
}
}
pub async fn create(
drive: &DriveClient,
docs: &DocsClient,
opts: &CreateOptions,
rules: &[FolderPermissionRule],
) -> CreateOutcome {
let started = Instant::now();
let outcome = create_inner(drive, docs, opts, rules).await;
if !opts.dry_run {
record_attempt(&outcome, started.elapsed());
}
outcome
}
async fn create_inner(
drive: &DriveClient,
docs: &DocsClient,
opts: &CreateOptions,
rules: &[FolderPermissionRule],
) -> CreateOutcome {
let finish = |result| CreateOutcome {
name: opts.name.clone(),
parent_folder_id: opts.parent_folder_id.clone(),
result,
};
let files_api = FilesApi::new(drive);
let decision = match folder_ancestry::resolve_decision(
&files_api,
&opts.parent_folder_id,
DriveOperation::Create,
rules,
)
.await
{
Ok(decision) => decision,
Err(err) => {
return finish(CreateResult::Failed {
detail: err.to_string(),
})
}
};
if decision.verdict == write_gate::Verdict::Deny {
return finish(CreateResult::Blocked {
decided_by: decision.decided_by,
});
}
if opts.text.is_some() {
let docs_write_decision = match folder_ancestry::resolve_decision(
&files_api,
&opts.parent_folder_id,
DriveOperation::DocsWrite,
rules,
)
.await
{
Ok(decision) => decision,
Err(err) => {
return finish(CreateResult::Failed {
detail: err.to_string(),
})
}
};
if docs_write_decision.verdict == write_gate::Verdict::Deny
&& docs_write_decision.decided_by.is_some()
{
return finish(CreateResult::Blocked {
decided_by: docs_write_decision.decided_by,
});
}
}
let (chars, bytes) = opts
.text
.as_ref()
.map_or((0, 0), |text| (text.chars().count(), text.len()));
if opts.dry_run {
return finish(CreateResult::WouldCreate { chars, bytes });
}
let created = match files_api
.create(&opts.name, &opts.parent_folder_id, GOOGLE_DOC_MIME_TYPE)
.await
{
Ok(created) => created,
Err(err) => {
return finish(CreateResult::Failed {
detail: err.to_string(),
})
}
};
let Some(text) = opts.text.as_ref() else {
return finish(CreateResult::Created {
file_id: created.id,
seeded_chars: None,
});
};
let api = DocsApi::new(docs);
let document = match api
.get_document(&created.id, SuggestionsViewMode::default())
.await
{
Ok(document) => document,
Err(err) => {
return finish(CreateResult::CreatedTextFailed {
file_id: created.id,
detail: err.to_string(),
})
}
};
let Some(revision_id) = document.revision_id else {
return finish(CreateResult::CreatedTextFailed {
file_id: created.id,
detail: "the new document returned no revision id, so the seed could not be leased"
.to_string(),
});
};
match api
.batch_update(
&created.id,
DocsRequest::insert_text_at_end(text),
&revision_id,
)
.await
{
Ok(_) => finish(CreateResult::Created {
file_id: created.id,
seeded_chars: Some(chars),
}),
Err(err) => finish(CreateResult::CreatedTextFailed {
file_id: created.id,
detail: err.to_string(),
}),
}
}
fn record_attempt(outcome: &CreateOutcome, duration: Duration) {
let error = match &outcome.result {
CreateResult::Failed { detail } | CreateResult::CreatedTextFailed { detail, .. } => {
Some(detail.clone())
}
_ => None,
};
let decided_by = match &outcome.result {
CreateResult::Blocked { decided_by } => decided_by.as_ref(),
_ => None,
};
let decided_by = write_gate::decided_by_log_fields(decided_by);
let file_id = match &outcome.result {
CreateResult::Created { file_id, .. } | CreateResult::CreatedTextFailed { file_id, .. } => {
file_id.clone()
}
_ => String::new(),
};
let inserted_chars = match &outcome.result {
CreateResult::Created {
seeded_chars: Some(chars),
..
} => Some(*chars as i64),
_ => None,
};
request_log::record_drive_mutation(DriveMutationOutcome {
operation: "docs-create",
file_id,
file_name: outcome.name.clone(),
status: outcome.result.log_status().to_string(),
resolved_folder_id: Some(outcome.parent_folder_id.clone()),
decided_by_folder_id: decided_by.folder_id,
decided_by_depth: decided_by.depth,
decided_by_file_id: decided_by.file_id,
inserted_chars,
error,
duration,
..Default::default()
});
}
#[must_use]
pub fn describe(outcome: &CreateOutcome) -> String {
let name = &outcome.name;
let folder = &outcome.parent_folder_id;
match &outcome.result {
CreateResult::WouldCreate { chars, bytes } if *chars > 0 => format!(
"Would create: document '{name}' in {folder}, seeded with {chars} char(s) / \
{bytes} byte(s)"
),
CreateResult::WouldCreate { .. } => {
format!("Would create: document '{name}' in {folder}")
}
CreateResult::Blocked { decided_by } => match decided_by {
Some(rule) => format!(
"Blocked: '{name}' in {folder} — refused by rule on {} {}{}",
rule.kind_label(),
rule.id(),
rule.depth_suffix()
),
None => format!(
"Blocked: '{name}' in {folder} — refused by default policy (no matching rule)"
),
},
CreateResult::Created {
file_id,
seeded_chars,
} => match seeded_chars {
Some(chars) => {
format!("Created: '{name}' ({file_id}) in {folder}, seeded with {chars} char(s)")
}
None => format!("Created: '{name}' ({file_id}) in {folder}"),
},
CreateResult::CreatedTextFailed { file_id, detail } => format!(
"Partially failed: created '{name}' ({file_id}) in {folder}, but seeding its text \
failed: {detail} The document exists and is empty — it cannot be rolled back \
automatically. Clean up explicitly with `omni-dev drive trash {file_id}` \
(requires trash permission on the file or its folder)."
),
CreateResult::Failed { detail } => format!("Failed: '{name}' in {folder}: {detail}"),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::collections::HashSet;
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::docs::client::DOCS_API_URL;
use crate::test_support::env::MapEnv;
use crate::utils::secret::Secret;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_credentials() -> DriveCredentials {
DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveGrantedScopes::READONLY,
}
}
async fn clients(server: &MockServer) -> (DriveClient, DocsClient) {
Mock::given(method("POST"))
.and(path("/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token", "expires_in": 3600,
})))
.mount(server)
.await;
let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
crate::drive::client::test_support::replace_session(
&mut drive,
&test_credentials(),
&format!("{}/token", server.uri()),
);
let env = MapEnv::new().with(DOCS_API_URL, &server.uri());
let docs = DocsClient::from_drive_client_with(&env, &drive).unwrap();
(drive, docs)
}
fn mount_folder(id: &str) -> Mock {
Mock::given(method("GET"))
.and(path(format!("/drive/v3/files/{id}")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": id, "name": id,
"mimeType": "application/vnd.google-apps.folder", "parents": [],
})))
}
fn mount_files_create() -> Mock {
Mock::given(method("POST"))
.and(path("/drive/v3/files"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "new-1", "name": "New Doc", "mimeType": GOOGLE_DOC_MIME_TYPE,
})))
}
fn mount_new_document(revision: Option<&str>) -> Mock {
let mut body = serde_json::json!({"documentId": "new-1", "body": {"content": []}});
if let Some(revision) = revision {
body["revisionId"] = serde_json::json!(revision);
}
Mock::given(method("GET"))
.and(path("/v1/documents/new-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
}
fn rule(ops: &[DriveOperation], denies: &[DriveOperation]) -> FolderPermissionRule {
FolderPermissionRule {
folder_id: Some("folder-1".to_string()),
file_id: None,
recursive: true,
allow: ops.iter().copied().collect(),
deny: denies.iter().copied().collect::<HashSet<_>>(),
require_lease: true,
}
}
fn opts(text: Option<&str>, dry_run: bool) -> CreateOptions {
CreateOptions {
name: "New Doc".to_string(),
parent_folder_id: "folder-1".to_string(),
text: text.map(str::to_string),
dry_run,
}
}
#[test]
fn json_carries_parent_folder_id_and_the_legacy_resolved_folder_id() {
let outcome = CreateOutcome {
name: "Notes".to_string(),
parent_folder_id: "folder-1".to_string(),
result: CreateResult::Created {
file_id: "doc-1".to_string(),
seeded_chars: None,
},
};
let json = serde_json::to_value(&outcome).unwrap();
assert_eq!(json["parent_folder_id"], "folder-1");
assert_eq!(json["resolved_folder_id"], "folder-1");
assert_eq!(json["name"], "Notes");
assert_eq!(json["result"]["file_id"], "doc-1");
}
#[tokio::test]
async fn create_without_text_makes_one_files_create_and_zero_docs_calls() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_folder("folder-1").mount(&server).await;
mount_files_create().expect(1).mount(&server).await;
let outcome = create(
&drive,
&docs,
&opts(None, false),
&[rule(&[DriveOperation::Create], &[])],
)
.await;
assert_eq!(
outcome.result,
CreateResult::Created {
file_id: "new-1".to_string(),
seeded_chars: None,
}
);
}
#[tokio::test]
async fn create_with_text_reads_the_new_document_for_a_lease_before_seeding() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_folder("folder-1").mount(&server).await;
mount_files_create().mount(&server).await;
mount_new_document(Some("rev-new"))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/documents/new-1:batchUpdate"))
.and(wiremock::matchers::body_partial_json(serde_json::json!({
"writeControl": {"requiredRevisionId": "rev-new"},
})))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"documentId": "new-1", "replies": [{}]})),
)
.expect(1)
.mount(&server)
.await;
let outcome = create(
&drive,
&docs,
&opts(Some("hello"), false),
&[rule(&[DriveOperation::Create], &[])],
)
.await;
assert_eq!(
outcome.result,
CreateResult::Created {
file_id: "new-1".to_string(),
seeded_chars: Some(5),
}
);
}
#[tokio::test]
async fn a_seed_failure_reports_created_text_failed_carrying_the_id() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_folder("folder-1").mount(&server).await;
mount_files_create().mount(&server).await;
mount_new_document(Some("rev-new")).mount(&server).await;
Mock::given(method("POST"))
.and(path("/v1/documents/new-1:batchUpdate"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let outcome = create(
&drive,
&docs,
&opts(Some("hello"), false),
&[rule(&[DriveOperation::Create], &[])],
)
.await;
match &outcome.result {
CreateResult::CreatedTextFailed { file_id, .. } => assert_eq!(file_id, "new-1"),
other => panic!("expected CreatedTextFailed, got {other:?}"),
}
let text = describe(&outcome);
assert!(
text.contains("new-1"),
"the orphan must be findable: {text}"
);
assert!(text.contains("cannot be rolled back"), "{text}");
assert!(text.contains("omni-dev drive trash new-1"), "{text}");
assert!(text.contains("requires trash permission"), "{text}");
}
#[tokio::test]
async fn a_bare_default_deny_does_not_block_the_seed() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_folder("folder-1").mount(&server).await;
mount_files_create().mount(&server).await;
mount_new_document(Some("rev-new")).mount(&server).await;
Mock::given(method("POST"))
.and(path("/v1/documents/new-1:batchUpdate"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"documentId": "new-1", "replies": [{}]})),
)
.mount(&server)
.await;
let outcome = create(
&drive,
&docs,
&opts(Some("hello"), false),
&[rule(&[DriveOperation::Create], &[])],
)
.await;
assert!(matches!(outcome.result, CreateResult::Created { .. }));
}
#[tokio::test]
async fn an_explicit_docs_write_deny_blocks_the_seed_and_creates_nothing() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_folder("folder-1").mount(&server).await;
let outcome = create(
&drive,
&docs,
&opts(Some("hello"), false),
&[rule(
&[DriveOperation::Create],
&[DriveOperation::DocsWrite],
)],
)
.await;
assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
}
#[tokio::test]
async fn an_explicit_docs_write_deny_does_not_block_an_unseeded_create() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_folder("folder-1").mount(&server).await;
mount_files_create().mount(&server).await;
let outcome = create(
&drive,
&docs,
&opts(None, false),
&[rule(
&[DriveOperation::Create],
&[DriveOperation::DocsWrite],
)],
)
.await;
assert!(matches!(outcome.result, CreateResult::Created { .. }));
}
#[tokio::test]
async fn a_denied_create_is_blocked_and_creates_nothing() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_folder("folder-1").mount(&server).await;
let outcome = create(&drive, &docs, &opts(None, false), &[]).await;
assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
}
#[tokio::test]
async fn dry_run_creates_nothing() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_folder("folder-1").mount(&server).await;
let outcome = create(
&drive,
&docs,
&opts(Some("hello"), true),
&[rule(&[DriveOperation::Create], &[])],
)
.await;
assert_eq!(
outcome.result,
CreateResult::WouldCreate { chars: 5, bytes: 5 }
);
assert!(describe(&outcome).contains("seeded with 5 char(s)"));
}
}