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_folder_id, decided_by_depth) = 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,
200        decided_by_depth,
201        error,
202        duration,
203    });
204}
205
206#[cfg(test)]
207#[allow(clippy::unwrap_used, clippy::expect_used)]
208mod tests {
209    use super::*;
210    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
211    use crate::drive::types::GOOGLE_FOLDER_MIME_TYPE;
212    use crate::drive::write_gate::DriveOperation;
213    use crate::utils::secret::Secret;
214
215    fn test_credentials() -> DriveCredentials {
216        DriveCredentials {
217            client_id: "client-1".to_string(),
218            client_secret: Secret::new("secret-1"),
219            refresh_token: Secret::new("refresh-1"),
220            scope: DriveGrantedScopes::READONLY,
221        }
222    }
223
224    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
225        wiremock::Mock::given(wiremock::matchers::method("POST"))
226            .and(wiremock::matchers::path("/token"))
227            .respond_with(
228                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
229                    "access_token": "test-token",
230                    "expires_in": 3600,
231                })),
232            )
233            .mount(server)
234            .await;
235
236        let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
237        crate::drive::client::test_support::replace_session(
238            &mut client,
239            &test_credentials(),
240            &format!("{}/token", server.uri()),
241        );
242        client
243    }
244
245    fn mount_parent_folder(id: &str) -> wiremock::MockBuilder {
246        wiremock::Mock::given(wiremock::matchers::method("GET"))
247            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
248    }
249
250    fn opts(dry_run: bool) -> CreateOptions {
251        CreateOptions {
252            name: "New File".to_string(),
253            parent_folder_id: "parent-1".to_string(),
254            mime_type: "text/plain".to_string(),
255            dry_run,
256        }
257    }
258
259    fn allow_rule() -> FolderPermissionRule {
260        FolderPermissionRule {
261            folder_id: "parent-1".to_string(),
262            recursive: false,
263            allow: std::iter::once(DriveOperation::Create).collect(),
264            deny: std::collections::HashSet::default(),
265        }
266    }
267
268    #[tokio::test]
269    async fn allowed_target_succeeds_and_calls_create_endpoint_once() {
270        let server = wiremock::MockServer::start().await;
271        let client = client_with_bootstrapped_token(&server).await;
272        mount_parent_folder("parent-1")
273            .respond_with(
274                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
275                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
276                })),
277            )
278            .mount(&server)
279            .await;
280        wiremock::Mock::given(wiremock::matchers::method("POST"))
281            .and(wiremock::matchers::path("/drive/v3/files"))
282            .respond_with(
283                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
284                    "id": "new-file-1", "name": "New File",
285                })),
286            )
287            .expect(1)
288            .mount(&server)
289            .await;
290
291        let outcome = create(&client, &opts(false), &[allow_rule()]).await;
292        assert!(matches!(
293            outcome.result,
294            CreateResult::Created { file_id } if file_id == "new-file-1"
295        ));
296    }
297
298    #[tokio::test]
299    async fn denied_target_refuses_with_zero_create_calls() {
300        let server = wiremock::MockServer::start().await;
301        let client = client_with_bootstrapped_token(&server).await;
302        mount_parent_folder("parent-1")
303            .respond_with(
304                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
305                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
306                })),
307            )
308            .mount(&server)
309            .await;
310        // Deliberately no POST /drive/v3/files mock mounted at all — an
311        // accidental create attempt fails loudly with wiremock's "no
312        // matching mock" error, structurally proving the gate runs before,
313        // not after, the network call (mirrors rename.rs/move_file.rs's
314        // existing dry-run test convention).
315
316        let outcome = create(&client, &opts(false), &[]).await;
317        assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
318    }
319
320    #[tokio::test]
321    async fn denied_target_still_writes_a_drivemutation_log_record() {
322        let server = wiremock::MockServer::start().await;
323        let client = client_with_bootstrapped_token(&server).await;
324        mount_parent_folder("parent-1")
325            .respond_with(
326                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
327                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
328                })),
329            )
330            .mount(&server)
331            .await;
332
333        // Not directly observable from here without a log-capturing seam;
334        // record_attempt's own construction is covered by the request_log
335        // unit tests. This test instead pins the *outcome* shape
336        // record_attempt consumes, so a future refactor that stops
337        // producing a Blocked{decided_by} for a real denied run would be
338        // caught here.
339        let outcome = create(&client, &opts(false), &[]).await;
340        assert!(!outcome.name.is_empty());
341        assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
342    }
343
344    #[tokio::test]
345    async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
346        let server = wiremock::MockServer::start().await;
347        let client = client_with_bootstrapped_token(&server).await;
348        mount_parent_folder("parent-1")
349            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
350            .mount(&server)
351            .await;
352
353        let outcome = create(&client, &opts(false), &[allow_rule()]).await;
354        assert!(
355            matches!(outcome.result, CreateResult::Failed { .. }),
356            "a fetch failure must never silently fall through to Created/WouldCreate"
357        );
358    }
359
360    #[tokio::test]
361    async fn insufficient_scope_403_surfaces_the_write_file_hint() {
362        let server = wiremock::MockServer::start().await;
363        let client = client_with_bootstrapped_token(&server).await;
364        mount_parent_folder("parent-1")
365            .respond_with(
366                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
367                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
368                })),
369            )
370            .mount(&server)
371            .await;
372        wiremock::Mock::given(wiremock::matchers::method("POST"))
373            .and(wiremock::matchers::path("/drive/v3/files"))
374            .respond_with(
375                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
376                    "error": {
377                        "message": "Insufficient Permission",
378                        "errors": [{"reason": "insufficientPermissions"}],
379                    }
380                })),
381            )
382            .mount(&server)
383            .await;
384
385        let outcome = create(&client, &opts(false), &[allow_rule()]).await;
386        let CreateResult::Failed { detail } = outcome.result else {
387            panic!("expected Failed, got {:?}", outcome.result);
388        };
389        assert!(detail.contains("--write-file"), "{detail}");
390        assert!(detail.contains("--write-full"), "{detail}");
391    }
392
393    #[tokio::test]
394    async fn dry_run_never_calls_create_endpoint() {
395        let server = wiremock::MockServer::start().await;
396        let client = client_with_bootstrapped_token(&server).await;
397        mount_parent_folder("parent-1")
398            .respond_with(
399                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
400                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
401                })),
402            )
403            .mount(&server)
404            .await;
405        // No POST mock mounted — same structural proof as the denied test.
406
407        let outcome = create(&client, &opts(true), &[allow_rule()]).await;
408        assert!(matches!(outcome.result, CreateResult::WouldCreate));
409    }
410
411    #[tokio::test]
412    async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
413        let server = wiremock::MockServer::start().await;
414        let client = client_with_bootstrapped_token(&server).await;
415        mount_parent_folder("parent-1")
416            .respond_with(
417                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
418                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
419                })),
420            )
421            .expect(2)
422            .mount(&server)
423            .await;
424
425        let dry_run_outcome = create(&client, &opts(true), &[]).await;
426        let real_outcome = create(&client, &opts(false), &[]).await;
427        assert!(matches!(
428            dry_run_outcome.result,
429            CreateResult::Blocked { .. }
430        ));
431        assert!(matches!(real_outcome.result, CreateResult::Blocked { .. }));
432    }
433}