Skip to main content

omni_dev/drive/
create.rs

1//! Drive file/folder creation — the first content-mutating capability this
2//! feature exists for (issue #1574, [ADR-0071](../../docs/adrs/adr-0071.md)).
3//!
4//! Single-target, unlike `file_move.rs`'s N-file batch — so this follows
5//! `rename.rs`'s simpler linear-function shape (gate check, then a
6//! `dry_run` early return, then the mutating call, all inline in one
7//! function) rather than a Plan/Execute split: there's no shared
8//! destination-fetch to amortize across a batch that doesn't exist here.
9//! `--dry-run` and a real run therefore share the exact same gate
10//! classification by construction (same function, same early-return
11//! branch), not merely by convention.
12
13use std::time::{Duration, Instant};
14
15use serde::Serialize;
16
17use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
18use crate::drive::client::DriveClient;
19use crate::drive::files_api::FilesApi;
20use crate::drive::folder_ancestry;
21use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
22use crate::request_log::{self, DriveMutationOutcome};
23
24/// Per-call create options.
25#[derive(Debug, Clone)]
26pub struct CreateOptions {
27    /// The new file/folder's display name.
28    pub name: String,
29    /// The folder id to create it in.
30    pub parent_folder_id: String,
31    /// The MIME type to create — the CLI resolves `--folder`/`--mime-type`/
32    /// a default into this before calling in.
33    pub mime_type: String,
34    /// When `true`, classify but never call `files.create`.
35    pub dry_run: bool,
36}
37
38/// What happened (or, under `--dry-run`, would happen).
39#[derive(Debug, Clone, Serialize)]
40#[serde(tag = "status", rename_all = "kebab-case")]
41pub enum CreateResult {
42    /// `--dry-run`, and the gate would allow it.
43    WouldCreate,
44    /// The folder write-permission gate refused it.
45    Blocked {
46        /// The rule that decided the refusal, if any (`None` means the
47        /// bare default policy — create/upload/edit default deny).
48        decided_by: Option<DecidingRule>,
49    },
50    /// `files.create` succeeded.
51    Created {
52        /// The newly created file's id.
53        file_id: String,
54    },
55    /// An API/validation error.
56    Failed {
57        /// A human-readable summary of what failed.
58        detail: String,
59    },
60}
61
62impl CreateResult {
63    /// The request-log `status` string — hand-written, deliberately
64    /// decoupled from the wire `#[serde(tag = ...)]` shape, mirroring
65    /// `MoveResult::log_status`'s existing precedent.
66    fn log_status(&self) -> &'static str {
67        match self {
68            Self::WouldCreate => "would-create",
69            Self::Blocked { .. } => "blocked",
70            Self::Created { .. } => "created",
71            Self::Failed { .. } => "failed",
72        }
73    }
74}
75
76/// The planned (and, after a real run, final) outcome of one `create` call.
77#[derive(Debug, Clone, Serialize)]
78pub struct CreateOutcome {
79    /// The requested name.
80    pub name: String,
81    /// The requested parent folder id.
82    pub parent_folder_id: String,
83    /// The result.
84    pub result: CreateResult,
85}
86
87impl JsonlSerialize for CreateOutcome {
88    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<(), anyhow::Error> {
89        write_scalar_jsonl(self, out)
90    }
91}
92
93/// Creates `opts.name` inside `opts.parent_folder_id`, gated by
94/// `rules` (the active account's `write_permissions.rules`).
95///
96/// Every real (non-dry-run) attempt — created, blocked, or failed — is
97/// logged via [`request_log::record_drive_mutation`], even when the gate
98/// refused before any Drive API call was made. A `--dry-run` preview is
99/// never logged, matching `drive move`'s existing precedent (its CLI layer
100/// calls `file_move::plan` but never `execute` under `--dry-run`, and only
101/// `execute` logs).
102pub async fn create(
103    client: &DriveClient,
104    opts: &CreateOptions,
105    rules: &[FolderPermissionRule],
106) -> CreateOutcome {
107    let started = Instant::now();
108    let outcome = create_inner(client, opts, rules).await;
109    if !opts.dry_run {
110        record_attempt(&outcome, started.elapsed());
111    }
112    outcome
113}
114
115async fn create_inner(
116    client: &DriveClient,
117    opts: &CreateOptions,
118    rules: &[FolderPermissionRule],
119) -> CreateOutcome {
120    let files_api = FilesApi::new(client);
121    let decision = match folder_ancestry::resolve_decision(
122        &files_api,
123        &opts.parent_folder_id,
124        DriveOperation::Create,
125        rules,
126    )
127    .await
128    {
129        Ok(decision) => decision,
130        Err(err) => {
131            return CreateOutcome {
132                name: opts.name.clone(),
133                parent_folder_id: opts.parent_folder_id.clone(),
134                result: CreateResult::Failed {
135                    detail: err.to_string(),
136                },
137            }
138        }
139    };
140    if decision.verdict == write_gate::Verdict::Deny {
141        return CreateOutcome {
142            name: opts.name.clone(),
143            parent_folder_id: opts.parent_folder_id.clone(),
144            result: CreateResult::Blocked {
145                decided_by: decision.decided_by,
146            },
147        };
148    }
149
150    if opts.dry_run {
151        return CreateOutcome {
152            name: opts.name.clone(),
153            parent_folder_id: opts.parent_folder_id.clone(),
154            result: CreateResult::WouldCreate,
155        };
156    }
157
158    let result = match files_api
159        .create(&opts.name, &opts.parent_folder_id, &opts.mime_type)
160        .await
161    {
162        Ok(file) => CreateResult::Created { file_id: file.id },
163        Err(err) => CreateResult::Failed {
164            detail: err.to_string(),
165        },
166    };
167    CreateOutcome {
168        name: opts.name.clone(),
169        parent_folder_id: opts.parent_folder_id.clone(),
170        result,
171    }
172}
173
174/// Builds and writes the [`DriveMutationOutcome`] for one `create` attempt.
175fn record_attempt(outcome: &CreateOutcome, duration: Duration) {
176    let error = match &outcome.result {
177        CreateResult::Failed { detail } => Some(detail.clone()),
178        _ => None,
179    };
180    let decided_by = match &outcome.result {
181        CreateResult::Blocked { decided_by } => decided_by.as_ref(),
182        _ => None,
183    };
184    let decided_by = write_gate::decided_by_log_fields(decided_by);
185    request_log::record_drive_mutation(DriveMutationOutcome {
186        operation: "create",
187        file_id: match &outcome.result {
188            CreateResult::Created { file_id } => file_id.clone(),
189            // No file id exists yet for a Blocked/Failed create — the name
190            // is the only identifying information available.
191            _ => String::new(),
192        },
193        file_name: outcome.name.clone(),
194        status: outcome.result.log_status().to_string(),
195        added_principals: Vec::new(),
196        removed_principals: Vec::new(),
197        crosses_drive_boundary: false,
198        resolved_folder_id: Some(outcome.parent_folder_id.clone()),
199        decided_by_folder_id: decided_by.folder_id,
200        decided_by_depth: decided_by.depth,
201        decided_by_file_id: decided_by.file_id,
202        error,
203        duration,
204        ..Default::default()
205    });
206}
207
208#[cfg(test)]
209#[allow(clippy::unwrap_used, clippy::expect_used)]
210mod tests {
211    use super::*;
212    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
213    use crate::drive::types::GOOGLE_FOLDER_MIME_TYPE;
214    use crate::drive::write_gate::DriveOperation;
215    use crate::utils::secret::Secret;
216
217    fn test_credentials() -> DriveCredentials {
218        DriveCredentials {
219            client_id: "client-1".to_string(),
220            client_secret: Secret::new("secret-1"),
221            refresh_token: Secret::new("refresh-1"),
222            scope: DriveGrantedScopes::READONLY,
223        }
224    }
225
226    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
227        wiremock::Mock::given(wiremock::matchers::method("POST"))
228            .and(wiremock::matchers::path("/token"))
229            .respond_with(
230                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
231                    "access_token": "test-token",
232                    "expires_in": 3600,
233                })),
234            )
235            .mount(server)
236            .await;
237
238        let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
239        crate::drive::client::test_support::replace_session(
240            &mut client,
241            &test_credentials(),
242            &format!("{}/token", server.uri()),
243        );
244        client
245    }
246
247    fn mount_parent_folder(id: &str) -> wiremock::MockBuilder {
248        wiremock::Mock::given(wiremock::matchers::method("GET"))
249            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
250    }
251
252    fn opts(dry_run: bool) -> CreateOptions {
253        CreateOptions {
254            name: "New File".to_string(),
255            parent_folder_id: "parent-1".to_string(),
256            mime_type: "text/plain".to_string(),
257            dry_run,
258        }
259    }
260
261    fn allow_rule() -> FolderPermissionRule {
262        FolderPermissionRule {
263            folder_id: Some("parent-1".to_string()),
264            file_id: None,
265            recursive: false,
266            allow: std::iter::once(DriveOperation::Create).collect(),
267            deny: std::collections::HashSet::default(),
268            require_lease: true,
269        }
270    }
271
272    #[tokio::test]
273    async fn allowed_target_succeeds_and_calls_create_endpoint_once() {
274        let server = wiremock::MockServer::start().await;
275        let client = client_with_bootstrapped_token(&server).await;
276        mount_parent_folder("parent-1")
277            .respond_with(
278                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
279                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
280                })),
281            )
282            .mount(&server)
283            .await;
284        wiremock::Mock::given(wiremock::matchers::method("POST"))
285            .and(wiremock::matchers::path("/drive/v3/files"))
286            .respond_with(
287                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
288                    "id": "new-file-1", "name": "New File",
289                })),
290            )
291            .expect(1)
292            .mount(&server)
293            .await;
294
295        let outcome = create(&client, &opts(false), &[allow_rule()]).await;
296        assert!(matches!(
297            outcome.result,
298            CreateResult::Created { file_id } if file_id == "new-file-1"
299        ));
300    }
301
302    #[tokio::test]
303    async fn denied_target_refuses_with_zero_create_calls() {
304        let server = wiremock::MockServer::start().await;
305        let client = client_with_bootstrapped_token(&server).await;
306        mount_parent_folder("parent-1")
307            .respond_with(
308                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
309                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
310                })),
311            )
312            .mount(&server)
313            .await;
314        // Deliberately no POST /drive/v3/files mock mounted at all — an
315        // accidental create attempt fails loudly with wiremock's "no
316        // matching mock" error, structurally proving the gate runs before,
317        // not after, the network call (mirrors rename.rs/move_file.rs's
318        // existing dry-run test convention).
319
320        let outcome = create(&client, &opts(false), &[]).await;
321        assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
322    }
323
324    #[tokio::test]
325    async fn denied_target_still_writes_a_drivemutation_log_record() {
326        let server = wiremock::MockServer::start().await;
327        let client = client_with_bootstrapped_token(&server).await;
328        mount_parent_folder("parent-1")
329            .respond_with(
330                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
331                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
332                })),
333            )
334            .mount(&server)
335            .await;
336
337        // Not directly observable from here without a log-capturing seam;
338        // record_attempt's own construction is covered by the request_log
339        // unit tests. This test instead pins the *outcome* shape
340        // record_attempt consumes, so a future refactor that stops
341        // producing a Blocked{decided_by} for a real denied run would be
342        // caught here.
343        let outcome = create(&client, &opts(false), &[]).await;
344        assert!(!outcome.name.is_empty());
345        assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
346    }
347
348    #[tokio::test]
349    async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
350        let server = wiremock::MockServer::start().await;
351        let client = client_with_bootstrapped_token(&server).await;
352        mount_parent_folder("parent-1")
353            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
354            .mount(&server)
355            .await;
356
357        let outcome = create(&client, &opts(false), &[allow_rule()]).await;
358        assert!(
359            matches!(outcome.result, CreateResult::Failed { .. }),
360            "a fetch failure must never silently fall through to Created/WouldCreate"
361        );
362    }
363
364    #[tokio::test]
365    async fn insufficient_scope_403_surfaces_the_write_file_hint() {
366        let server = wiremock::MockServer::start().await;
367        let client = client_with_bootstrapped_token(&server).await;
368        mount_parent_folder("parent-1")
369            .respond_with(
370                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
371                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
372                })),
373            )
374            .mount(&server)
375            .await;
376        wiremock::Mock::given(wiremock::matchers::method("POST"))
377            .and(wiremock::matchers::path("/drive/v3/files"))
378            .respond_with(
379                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
380                    "error": {
381                        "message": "Insufficient Permission",
382                        "errors": [{"reason": "insufficientPermissions"}],
383                    }
384                })),
385            )
386            .mount(&server)
387            .await;
388
389        let outcome = create(&client, &opts(false), &[allow_rule()]).await;
390        let CreateResult::Failed { detail } = outcome.result else {
391            panic!("expected Failed, got {:?}", outcome.result);
392        };
393        assert!(detail.contains("--write-file"), "{detail}");
394        assert!(detail.contains("--write-full"), "{detail}");
395    }
396
397    #[tokio::test]
398    async fn dry_run_never_calls_create_endpoint() {
399        let server = wiremock::MockServer::start().await;
400        let client = client_with_bootstrapped_token(&server).await;
401        mount_parent_folder("parent-1")
402            .respond_with(
403                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
404                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
405                })),
406            )
407            .mount(&server)
408            .await;
409        // No POST mock mounted — same structural proof as the denied test.
410
411        let outcome = create(&client, &opts(true), &[allow_rule()]).await;
412        assert!(matches!(outcome.result, CreateResult::WouldCreate));
413    }
414
415    #[tokio::test]
416    async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
417        let server = wiremock::MockServer::start().await;
418        let client = client_with_bootstrapped_token(&server).await;
419        mount_parent_folder("parent-1")
420            .respond_with(
421                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
422                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
423                })),
424            )
425            .expect(2)
426            .mount(&server)
427            .await;
428
429        let dry_run_outcome = create(&client, &opts(true), &[]).await;
430        let real_outcome = create(&client, &opts(false), &[]).await;
431        assert!(matches!(
432            dry_run_outcome.result,
433            CreateResult::Blocked { .. }
434        ));
435        assert!(matches!(real_outcome.result, CreateResult::Blocked { .. }));
436    }
437}