Skip to main content

omni_dev/cli/
drive.rs

1//! Drive CLI commands.
2
3pub(crate) mod account;
4pub(crate) mod auth;
5pub(crate) mod create;
6pub(crate) mod dedupe;
7pub(crate) mod edit;
8pub(crate) mod format;
9pub(crate) mod helpers;
10/// `drive move` — named `move_file` (not `move`, a Rust keyword) mirroring
11/// `crate::cli::atlassian::confluence::move_page`'s identical workaround.
12pub(crate) mod move_file;
13pub(crate) mod permissions;
14pub(crate) mod read;
15pub(crate) mod rename;
16pub(crate) mod search;
17pub(crate) mod upload;
18
19use anyhow::Result;
20use clap::{Parser, Subcommand};
21
22use crate::drive::account::DRIVE_ACCOUNT_ENV;
23use crate::drive::client::DriveClient;
24
25/// Drive: search, read, rename, and move Google Drive files via OAuth2.
26#[derive(Parser)]
27pub struct DriveCommand {
28    /// Selects a named Drive account configured in
29    /// `~/.omni-dev/settings.json` (AWS-CLI style, mirrors the top-level
30    /// `--profile`) for this invocation.
31    ///
32    /// Orthogonal to `--profile`: switching the Drive account never changes
33    /// which profile is active, and vice versa (see
34    /// [ADR-0066](../../../docs/adrs/adr-0066.md),
35    /// [ADR-0069](../../../docs/adrs/adr-0069.md)). Overrides
36    /// `OMNI_DEV_DRIVE_ACCOUNT`. Scoped to the `drive` subtree — not usable
37    /// before the `drive` subcommand name, only after it, so it can't
38    /// collide with an unrelated subcommand's own `--account` flag.
39    #[arg(long, global = true, value_name = "NAME")]
40    pub account: Option<String>,
41    /// The Drive subcommand to execute.
42    #[command(subcommand)]
43    pub command: DriveSubcommands,
44}
45
46/// Drive subcommands.
47#[derive(Subcommand)]
48pub enum DriveSubcommands {
49    /// Manages Drive OAuth2 credentials.
50    Auth(auth::AuthCommand),
51    /// Manages named Drive accounts.
52    Account(account::AccountCommand),
53    /// Searches Drive files.
54    Search(search::SearchCommand),
55    /// Reads a single Drive file's metadata or content.
56    Read(read::ReadCommand),
57    /// Finds Drive files sharing the same content hash.
58    Dedupe(dedupe::DedupeCommand),
59    /// Creates a new file or folder, gated by the folder write-permission
60    /// rules (issue #1574). Requires the `drive.file` or `drive` scope
61    /// (`drive auth login --write-file`/`--write-full`).
62    Create(create::CreateCommand),
63    /// Uploads local content as a new file, gated by the folder
64    /// write-permission rules (issue #1574). Requires the `drive.file` or
65    /// `drive` scope (`drive auth login --write-file`/`--write-full`).
66    Upload(upload::UploadCommand),
67    /// Replaces an existing file's content, gated by the folder
68    /// write-permission rules (issue #1574). Requires the `drive.file`
69    /// scope if `omni-dev` created the file, or the unrestricted `drive`
70    /// scope for any pre-existing file (`drive auth login --write-file`
71    /// or `--write-full`).
72    Edit(edit::EditCommand),
73    /// Renames a single Drive file. Requires the `drive.metadata` scope
74    /// (`drive auth login --write`).
75    Rename(rename::RenameCommand),
76    /// Moves one or more Drive files into a destination folder. Requires
77    /// the `drive.metadata` scope (`drive auth login --write`).
78    Move(move_file::MoveCommand),
79    /// Inspects the folder-scoped write-permission rules gating `drive
80    /// create`/`upload`/`edit` (issue #1574).
81    Permissions(permissions::PermissionsCommand),
82}
83
84impl DriveCommand {
85    /// Executes the Drive command. `auth`/`account` must run without a
86    /// resolved client (they manage credentials/account selection); every
87    /// other subcommand resolves one shared client **once** here and
88    /// threads it down via [`DriveSubcommands::dispatch`].
89    pub async fn execute(self) -> Result<()> {
90        // Propagates --account to DRIVE_ACCOUNT_ENV for the duration of this
91        // call only (crate::drive::account::resolve_account reads it),
92        // mirroring GmailCommand::execute. Set *before* matching Auth/
93        // Account: those subcommands need the resolved account too (e.g.
94        // `drive auth login --account work`). The guard restores the
95        // ambient value (or removes the var) on drop at the end of this
96        // function, so execute() is safe to call more than once per process
97        // (#1538).
98        let _account_guard = self
99            .account
100            .as_ref()
101            .map(|account| crate::utils::env::ScopedEnvVar::set(DRIVE_ACCOUNT_ENV, account));
102
103        match self.command {
104            DriveSubcommands::Auth(cmd) => cmd.execute().await,
105            DriveSubcommands::Account(cmd) => cmd.execute(),
106            // Permissions' three leaves have mixed client needs (`show` is
107            // config-only, `lookup-folder`/`check` both call the Drive
108            // API) — like Auth, it resolves its own client lazily per leaf
109            // rather than sharing the single eager resolution below.
110            DriveSubcommands::Permissions(cmd) => cmd.execute().await,
111            command => {
112                let client = helpers::create_client()?;
113                command.dispatch(&client).await
114            }
115        }
116    }
117}
118
119impl DriveSubcommands {
120    /// Routes a non-`Auth`/`Account`/`Permissions` subcommand against the
121    /// shared client. Those three arms are unreachable: all are handled
122    /// before client resolution in [`DriveCommand::execute`].
123    async fn dispatch(self, client: &DriveClient) -> Result<()> {
124        match self {
125            Self::Auth(_) => unreachable!("Auth is dispatched before client resolution"),
126            Self::Account(_) => unreachable!("Account is dispatched before client resolution"),
127            Self::Permissions(_) => {
128                unreachable!("Permissions is dispatched before client resolution")
129            }
130            Self::Search(cmd) => cmd.execute(client).await,
131            Self::Read(cmd) => cmd.execute(client).await,
132            Self::Dedupe(cmd) => cmd.execute(client).await,
133            Self::Create(cmd) => cmd.execute(client).await,
134            Self::Upload(cmd) => cmd.execute(client).await,
135            Self::Edit(cmd) => cmd.execute(client).await,
136            Self::Rename(cmd) => cmd.execute(client).await,
137            Self::Move(cmd) => cmd.execute(client).await,
138        }
139    }
140}
141
142#[cfg(test)]
143#[allow(clippy::unwrap_used)]
144mod tests {
145    use super::*;
146    use crate::cli::drive::format::OutputFormat;
147    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
148    use crate::utils::secret::Secret;
149
150    fn dead_credentials() -> DriveCredentials {
151        DriveCredentials {
152            client_id: "client".to_string(),
153            client_secret: Secret::new("secret"),
154            refresh_token: Secret::new("refresh"),
155            scope: DriveGrantedScopes::READONLY,
156        }
157    }
158
159    fn dead_client() -> DriveClient {
160        DriveClient::new("http://127.0.0.1:1", &dead_credentials()).unwrap()
161    }
162
163    #[tokio::test]
164    async fn execute_routes_auth_subcommand_and_surfaces_missing_credentials() {
165        let guard = crate::drive::test_support::EnvGuard::take();
166        let _dir = guard.clear_credentials();
167
168        let cmd = DriveCommand {
169            account: None,
170            command: DriveSubcommands::Auth(auth::AuthCommand {
171                command: auth::AuthSubcommands::Status(auth::StatusCommand { all: false }),
172            }),
173        };
174        let err = cmd.execute().await.unwrap_err();
175        assert!(err.to_string().contains("not configured"));
176    }
177
178    #[tokio::test]
179    async fn execute_non_auth_subcommand_errors_when_credentials_missing() {
180        let guard = crate::drive::test_support::EnvGuard::take();
181        let _dir = guard.clear_credentials();
182
183        let cmd = DriveCommand {
184            account: None,
185            command: DriveSubcommands::Search(search::SearchCommand {
186                query: "name contains 'report'".to_string(),
187                limit: 10,
188                output: OutputFormat::Table,
189            }),
190        };
191        let err = cmd.execute().await.unwrap_err();
192        assert!(err.to_string().contains("not configured"));
193    }
194
195    #[tokio::test]
196    async fn execute_restores_account_env_var_after_return() {
197        let guard = crate::drive::test_support::EnvGuard::take();
198        let _dir = guard.clear_credentials();
199
200        let cmd = DriveCommand {
201            account: Some("work".to_string()),
202            command: DriveSubcommands::Account(account::AccountCommand {
203                command: account::AccountSubcommands::List(account::list::ListCommand {
204                    output: OutputFormat::Table,
205                }),
206            }),
207        };
208        cmd.execute().await.unwrap();
209        assert_eq!(std::env::var(DRIVE_ACCOUNT_ENV).ok(), None);
210    }
211
212    #[tokio::test]
213    async fn execute_restores_previous_account_env_var_after_return() {
214        let guard = crate::drive::test_support::EnvGuard::take();
215        let _dir = guard.clear_credentials();
216        std::env::set_var(DRIVE_ACCOUNT_ENV, "personal");
217
218        let cmd = DriveCommand {
219            account: Some("work".to_string()),
220            command: DriveSubcommands::Account(account::AccountCommand {
221                command: account::AccountSubcommands::List(account::list::ListCommand {
222                    output: OutputFormat::Table,
223                }),
224            }),
225        };
226        cmd.execute().await.unwrap();
227        assert_eq!(
228            std::env::var(DRIVE_ACCOUNT_ENV).ok().as_deref(),
229            Some("personal")
230        );
231    }
232
233    #[tokio::test]
234    async fn execute_does_not_leak_account_across_sequential_calls() {
235        let guard = crate::drive::test_support::EnvGuard::take();
236        let _dir = guard.clear_credentials();
237
238        let account_list_cmd = || DriveCommand {
239            account: None,
240            command: DriveSubcommands::Account(account::AccountCommand {
241                command: account::AccountSubcommands::List(account::list::ListCommand {
242                    output: OutputFormat::Table,
243                }),
244            }),
245        };
246
247        let first = DriveCommand {
248            account: Some("alpha".to_string()),
249            ..account_list_cmd()
250        };
251        first.execute().await.unwrap();
252        assert_eq!(std::env::var(DRIVE_ACCOUNT_ENV).ok(), None);
253
254        // If the first call's value had leaked, this second call — which
255        // omits --account entirely — would still see it via the env var.
256        account_list_cmd().execute().await.unwrap();
257        assert_eq!(std::env::var(DRIVE_ACCOUNT_ENV).ok(), None);
258    }
259
260    #[tokio::test]
261    async fn execute_absent_account_leaves_ambient_env_var_untouched() {
262        let guard = crate::drive::test_support::EnvGuard::take();
263        let _dir = guard.clear_credentials();
264        std::env::set_var(DRIVE_ACCOUNT_ENV, "personal");
265
266        let cmd = DriveCommand {
267            account: None,
268            command: DriveSubcommands::Account(account::AccountCommand {
269                command: account::AccountSubcommands::List(account::list::ListCommand {
270                    output: OutputFormat::Table,
271                }),
272            }),
273        };
274        cmd.execute().await.unwrap();
275        assert_eq!(
276            std::env::var(DRIVE_ACCOUNT_ENV).ok().as_deref(),
277            Some("personal")
278        );
279    }
280
281    #[tokio::test]
282    async fn execute_routes_account_list_without_client_resolution() {
283        let guard = crate::drive::test_support::EnvGuard::take();
284        let _dir = guard.clear_credentials();
285
286        let cmd = DriveCommand {
287            account: None,
288            command: DriveSubcommands::Account(account::AccountCommand {
289                command: account::AccountSubcommands::List(account::list::ListCommand {
290                    output: OutputFormat::Table,
291                }),
292            }),
293        };
294        cmd.execute().await.unwrap();
295    }
296
297    #[tokio::test]
298    async fn execute_routes_permissions_show_without_client_resolution() {
299        let guard = crate::drive::test_support::EnvGuard::take();
300        let _dir = guard.clear_credentials();
301
302        let cmd = DriveCommand {
303            account: None,
304            command: DriveSubcommands::Permissions(permissions::PermissionsCommand {
305                command: permissions::PermissionsSubcommands::Show(
306                    permissions::show::ShowCommand {
307                        output: OutputFormat::Table,
308                    },
309                ),
310            }),
311        };
312        cmd.execute().await.unwrap();
313    }
314
315    #[tokio::test]
316    async fn execute_routes_permissions_check_and_surfaces_missing_credentials() {
317        let guard = crate::drive::test_support::EnvGuard::take();
318        let _dir = guard.clear_credentials();
319
320        let cmd = DriveCommand {
321            account: None,
322            command: DriveSubcommands::Permissions(permissions::PermissionsCommand {
323                command: permissions::PermissionsSubcommands::Check(
324                    permissions::check::CheckCommand {
325                        id: "f1".to_string(),
326                        operation: permissions::check::OperationArg::Read,
327                        output: OutputFormat::Table,
328                    },
329                ),
330            }),
331        };
332        let err = cmd.execute().await.unwrap_err();
333        assert!(err.to_string().contains("not configured"));
334    }
335
336    #[tokio::test]
337    async fn dispatch_routes_search() {
338        let cmd = DriveSubcommands::Search(search::SearchCommand {
339            query: "name contains 'x'".to_string(),
340            limit: 10,
341            output: OutputFormat::Table,
342        });
343        assert!(cmd.dispatch(&dead_client()).await.is_err());
344    }
345
346    #[tokio::test]
347    async fn dispatch_routes_read() {
348        let cmd = DriveSubcommands::Read(read::ReadCommand {
349            file_id: "f1".to_string(),
350            content: false,
351            export_mime_type: None,
352            out_file: None,
353            verify: false,
354            output: OutputFormat::Table,
355        });
356        assert!(cmd.dispatch(&dead_client()).await.is_err());
357    }
358
359    #[tokio::test]
360    async fn dispatch_routes_dedupe() {
361        let cmd = DriveSubcommands::Dedupe(dedupe::DedupeCommand {
362            query: "name contains 'x'".to_string(),
363            limit: 10,
364            output: OutputFormat::Table,
365        });
366        assert!(cmd.dispatch(&dead_client()).await.is_err());
367    }
368
369    #[tokio::test]
370    async fn dispatch_routes_create() {
371        // Unlike rename/move (whose engine fns return `Result` and
372        // propagate a network error via `?`), `create`'s engine fn always
373        // returns an `Ok`-shaped `CreateOutcome` — a fetch failure against
374        // the dead client becomes an embedded `Failed{detail}`, matching
375        // the exit-0-regardless-of-outcome convention (ADR-0071 §12), not
376        // a dispatch-level error. Needs env isolation (unlike the other
377        // dispatch_routes_* tests): `create`'s CLI layer resolves the
378        // active account's write_permissions.rules via `Settings::load()`,
379        // so without a clean $HOME it reads whatever real
380        // ~/.omni-dev/settings.json this process has.
381        let guard = crate::drive::test_support::EnvGuard::take();
382        let _dir = guard.clear_credentials();
383
384        let cmd = DriveSubcommands::Create(create::CreateCommand {
385            name: "New File".to_string(),
386            parent: "parent-1".to_string(),
387            folder: false,
388            mime_type: None,
389            dry_run: false,
390            output: OutputFormat::Table,
391        });
392        assert!(cmd.dispatch(&dead_client()).await.is_ok());
393    }
394
395    #[tokio::test]
396    async fn dispatch_routes_upload() {
397        // Same env-isolation and exit-0-regardless-of-outcome reasoning as
398        // dispatch_routes_create.
399        let guard = crate::drive::test_support::EnvGuard::take();
400        let _dir = guard.clear_credentials();
401        let content_dir = tempfile::tempdir().unwrap();
402        let local_path = content_dir.path().join("upload-me.txt");
403        std::fs::write(&local_path, b"content").unwrap();
404
405        let cmd = DriveSubcommands::Upload(upload::UploadCommand {
406            local_path,
407            parent: "parent-1".to_string(),
408            name: None,
409            mime_type: None,
410            dry_run: false,
411            output: OutputFormat::Table,
412        });
413        assert!(cmd.dispatch(&dead_client()).await.is_ok());
414    }
415
416    #[tokio::test]
417    async fn dispatch_routes_edit() {
418        // Same env-isolation and exit-0-regardless-of-outcome reasoning as
419        // dispatch_routes_create/dispatch_routes_upload.
420        let guard = crate::drive::test_support::EnvGuard::take();
421        let _dir = guard.clear_credentials();
422        let content_dir = tempfile::tempdir().unwrap();
423        let content_path = content_dir.path().join("new-content.txt");
424        std::fs::write(&content_path, b"content").unwrap();
425
426        let cmd = DriveSubcommands::Edit(edit::EditCommand {
427            file_id: "f1".to_string(),
428            content: content_path.to_str().unwrap().to_string(),
429            mime_type: None,
430            dry_run: false,
431            output: OutputFormat::Table,
432        });
433        assert!(cmd.dispatch(&dead_client()).await.is_ok());
434    }
435
436    #[tokio::test]
437    async fn dispatch_routes_rename() {
438        let cmd = DriveSubcommands::Rename(rename::RenameCommand {
439            file_id: "f1".to_string(),
440            new_name: "New Name".to_string(),
441            dry_run: false,
442            output: OutputFormat::Table,
443        });
444        assert!(cmd.dispatch(&dead_client()).await.is_err());
445    }
446
447    #[tokio::test]
448    async fn dispatch_routes_move() {
449        let cmd = DriveSubcommands::Move(move_file::MoveCommand {
450            file_ids: vec!["f1".to_string()],
451            to: "dest1".to_string(),
452            allow_visibility_increase: false,
453            allow_visibility_decrease: false,
454            allow_drive_boundary_crossing: false,
455            dry_run: false,
456            output: OutputFormat::Table,
457        });
458        assert!(cmd.dispatch(&dead_client()).await.is_err());
459    }
460}