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 dedupe;
6pub(crate) mod format;
7pub(crate) mod helpers;
8/// `drive move` — named `move_file` (not `move`, a Rust keyword) mirroring
9/// `crate::cli::atlassian::confluence::move_page`'s identical workaround.
10pub(crate) mod move_file;
11pub(crate) mod read;
12pub(crate) mod rename;
13pub(crate) mod search;
14
15use anyhow::Result;
16use clap::{Parser, Subcommand};
17
18use crate::drive::account::DRIVE_ACCOUNT_ENV;
19use crate::drive::client::DriveClient;
20
21/// Drive: search, read, rename, and move Google Drive files via OAuth2.
22#[derive(Parser)]
23pub struct DriveCommand {
24    /// Selects a named Drive account configured in
25    /// `~/.omni-dev/settings.json` (AWS-CLI style, mirrors the top-level
26    /// `--profile`) for this invocation.
27    ///
28    /// Orthogonal to `--profile`: switching the Drive account never changes
29    /// which profile is active, and vice versa (see
30    /// [ADR-0066](../../../docs/adrs/adr-0066.md),
31    /// [ADR-0069](../../../docs/adrs/adr-0069.md)). Overrides
32    /// `OMNI_DEV_DRIVE_ACCOUNT`. Scoped to the `drive` subtree — not usable
33    /// before the `drive` subcommand name, only after it, so it can't
34    /// collide with an unrelated subcommand's own `--account` flag.
35    #[arg(long, global = true, value_name = "NAME")]
36    pub account: Option<String>,
37    /// The Drive subcommand to execute.
38    #[command(subcommand)]
39    pub command: DriveSubcommands,
40}
41
42/// Drive subcommands.
43#[derive(Subcommand)]
44pub enum DriveSubcommands {
45    /// Manages Drive OAuth2 credentials.
46    Auth(auth::AuthCommand),
47    /// Manages named Drive accounts.
48    Account(account::AccountCommand),
49    /// Searches Drive files.
50    Search(search::SearchCommand),
51    /// Reads a single Drive file's metadata or content.
52    Read(read::ReadCommand),
53    /// Finds Drive files sharing the same content hash.
54    Dedupe(dedupe::DedupeCommand),
55    /// Renames a single Drive file. Requires the `drive.metadata` scope
56    /// (`drive auth login --write`).
57    Rename(rename::RenameCommand),
58    /// Moves one or more Drive files into a destination folder. Requires
59    /// the `drive.metadata` scope (`drive auth login --write`).
60    Move(move_file::MoveCommand),
61}
62
63impl DriveCommand {
64    /// Executes the Drive command. `auth`/`account` must run without a
65    /// resolved client (they manage credentials/account selection); every
66    /// other subcommand resolves one shared client **once** here and
67    /// threads it down via [`DriveSubcommands::dispatch`].
68    pub async fn execute(self) -> Result<()> {
69        // Propagates --account to DRIVE_ACCOUNT_ENV for the duration of this
70        // call only (crate::drive::account::resolve_account reads it),
71        // mirroring GmailCommand::execute. Set *before* matching Auth/
72        // Account: those subcommands need the resolved account too (e.g.
73        // `drive auth login --account work`). The guard restores the
74        // ambient value (or removes the var) on drop at the end of this
75        // function, so execute() is safe to call more than once per process
76        // (#1538).
77        let _account_guard = self
78            .account
79            .as_ref()
80            .map(|account| crate::utils::env::ScopedEnvVar::set(DRIVE_ACCOUNT_ENV, account));
81
82        match self.command {
83            DriveSubcommands::Auth(cmd) => cmd.execute().await,
84            DriveSubcommands::Account(cmd) => cmd.execute(),
85            command => {
86                let client = helpers::create_client()?;
87                command.dispatch(&client).await
88            }
89        }
90    }
91}
92
93impl DriveSubcommands {
94    /// Routes a non-`Auth`/`Account` subcommand against the shared client.
95    /// The `Auth`/`Account` arms are unreachable: both are handled before
96    /// client resolution in [`DriveCommand::execute`].
97    async fn dispatch(self, client: &DriveClient) -> Result<()> {
98        match self {
99            Self::Auth(_) => unreachable!("Auth is dispatched before client resolution"),
100            Self::Account(_) => unreachable!("Account is dispatched before client resolution"),
101            Self::Search(cmd) => cmd.execute(client).await,
102            Self::Read(cmd) => cmd.execute(client).await,
103            Self::Dedupe(cmd) => cmd.execute(client).await,
104            Self::Rename(cmd) => cmd.execute(client).await,
105            Self::Move(cmd) => cmd.execute(client).await,
106        }
107    }
108}
109
110#[cfg(test)]
111#[allow(clippy::unwrap_used)]
112mod tests {
113    use super::*;
114    use crate::cli::drive::format::OutputFormat;
115    use crate::drive::auth::{DriveCredentials, DriveScope};
116    use crate::utils::secret::Secret;
117
118    fn dead_credentials() -> DriveCredentials {
119        DriveCredentials {
120            client_id: "client".to_string(),
121            client_secret: Secret::new("secret"),
122            refresh_token: Secret::new("refresh"),
123            scope: DriveScope::ReadOnly,
124        }
125    }
126
127    fn dead_client() -> DriveClient {
128        DriveClient::new("http://127.0.0.1:1", &dead_credentials()).unwrap()
129    }
130
131    #[tokio::test]
132    async fn execute_routes_auth_subcommand_and_surfaces_missing_credentials() {
133        let guard = crate::drive::test_support::EnvGuard::take();
134        let _dir = guard.clear_credentials();
135
136        let cmd = DriveCommand {
137            account: None,
138            command: DriveSubcommands::Auth(auth::AuthCommand {
139                command: auth::AuthSubcommands::Status(auth::StatusCommand { all: false }),
140            }),
141        };
142        let err = cmd.execute().await.unwrap_err();
143        assert!(err.to_string().contains("not configured"));
144    }
145
146    #[tokio::test]
147    async fn execute_non_auth_subcommand_errors_when_credentials_missing() {
148        let guard = crate::drive::test_support::EnvGuard::take();
149        let _dir = guard.clear_credentials();
150
151        let cmd = DriveCommand {
152            account: None,
153            command: DriveSubcommands::Search(search::SearchCommand {
154                query: "name contains 'report'".to_string(),
155                limit: 10,
156                output: OutputFormat::Table,
157            }),
158        };
159        let err = cmd.execute().await.unwrap_err();
160        assert!(err.to_string().contains("not configured"));
161    }
162
163    #[tokio::test]
164    async fn execute_restores_account_env_var_after_return() {
165        let guard = crate::drive::test_support::EnvGuard::take();
166        let _dir = guard.clear_credentials();
167
168        let cmd = DriveCommand {
169            account: Some("work".to_string()),
170            command: DriveSubcommands::Account(account::AccountCommand {
171                command: account::AccountSubcommands::List(account::list::ListCommand {
172                    output: OutputFormat::Table,
173                }),
174            }),
175        };
176        cmd.execute().await.unwrap();
177        assert_eq!(std::env::var(DRIVE_ACCOUNT_ENV).ok(), None);
178    }
179
180    #[tokio::test]
181    async fn execute_restores_previous_account_env_var_after_return() {
182        let guard = crate::drive::test_support::EnvGuard::take();
183        let _dir = guard.clear_credentials();
184        std::env::set_var(DRIVE_ACCOUNT_ENV, "personal");
185
186        let cmd = DriveCommand {
187            account: Some("work".to_string()),
188            command: DriveSubcommands::Account(account::AccountCommand {
189                command: account::AccountSubcommands::List(account::list::ListCommand {
190                    output: OutputFormat::Table,
191                }),
192            }),
193        };
194        cmd.execute().await.unwrap();
195        assert_eq!(
196            std::env::var(DRIVE_ACCOUNT_ENV).ok().as_deref(),
197            Some("personal")
198        );
199    }
200
201    #[tokio::test]
202    async fn execute_does_not_leak_account_across_sequential_calls() {
203        let guard = crate::drive::test_support::EnvGuard::take();
204        let _dir = guard.clear_credentials();
205
206        let account_list_cmd = || DriveCommand {
207            account: None,
208            command: DriveSubcommands::Account(account::AccountCommand {
209                command: account::AccountSubcommands::List(account::list::ListCommand {
210                    output: OutputFormat::Table,
211                }),
212            }),
213        };
214
215        let first = DriveCommand {
216            account: Some("alpha".to_string()),
217            ..account_list_cmd()
218        };
219        first.execute().await.unwrap();
220        assert_eq!(std::env::var(DRIVE_ACCOUNT_ENV).ok(), None);
221
222        // If the first call's value had leaked, this second call — which
223        // omits --account entirely — would still see it via the env var.
224        account_list_cmd().execute().await.unwrap();
225        assert_eq!(std::env::var(DRIVE_ACCOUNT_ENV).ok(), None);
226    }
227
228    #[tokio::test]
229    async fn execute_absent_account_leaves_ambient_env_var_untouched() {
230        let guard = crate::drive::test_support::EnvGuard::take();
231        let _dir = guard.clear_credentials();
232        std::env::set_var(DRIVE_ACCOUNT_ENV, "personal");
233
234        let cmd = DriveCommand {
235            account: None,
236            command: DriveSubcommands::Account(account::AccountCommand {
237                command: account::AccountSubcommands::List(account::list::ListCommand {
238                    output: OutputFormat::Table,
239                }),
240            }),
241        };
242        cmd.execute().await.unwrap();
243        assert_eq!(
244            std::env::var(DRIVE_ACCOUNT_ENV).ok().as_deref(),
245            Some("personal")
246        );
247    }
248
249    #[tokio::test]
250    async fn execute_routes_account_list_without_client_resolution() {
251        let guard = crate::drive::test_support::EnvGuard::take();
252        let _dir = guard.clear_credentials();
253
254        let cmd = DriveCommand {
255            account: None,
256            command: DriveSubcommands::Account(account::AccountCommand {
257                command: account::AccountSubcommands::List(account::list::ListCommand {
258                    output: OutputFormat::Table,
259                }),
260            }),
261        };
262        cmd.execute().await.unwrap();
263    }
264
265    #[tokio::test]
266    async fn dispatch_routes_search() {
267        let cmd = DriveSubcommands::Search(search::SearchCommand {
268            query: "name contains 'x'".to_string(),
269            limit: 10,
270            output: OutputFormat::Table,
271        });
272        assert!(cmd.dispatch(&dead_client()).await.is_err());
273    }
274
275    #[tokio::test]
276    async fn dispatch_routes_read() {
277        let cmd = DriveSubcommands::Read(read::ReadCommand {
278            file_id: "f1".to_string(),
279            content: false,
280            export_mime_type: None,
281            out_file: None,
282            verify: false,
283            output: OutputFormat::Table,
284        });
285        assert!(cmd.dispatch(&dead_client()).await.is_err());
286    }
287
288    #[tokio::test]
289    async fn dispatch_routes_dedupe() {
290        let cmd = DriveSubcommands::Dedupe(dedupe::DedupeCommand {
291            query: "name contains 'x'".to_string(),
292            limit: 10,
293            output: OutputFormat::Table,
294        });
295        assert!(cmd.dispatch(&dead_client()).await.is_err());
296    }
297
298    #[tokio::test]
299    async fn dispatch_routes_rename() {
300        let cmd = DriveSubcommands::Rename(rename::RenameCommand {
301            file_id: "f1".to_string(),
302            new_name: "New Name".to_string(),
303            dry_run: false,
304            output: OutputFormat::Table,
305        });
306        assert!(cmd.dispatch(&dead_client()).await.is_err());
307    }
308
309    #[tokio::test]
310    async fn dispatch_routes_move() {
311        let cmd = DriveSubcommands::Move(move_file::MoveCommand {
312            file_ids: vec!["f1".to_string()],
313            to: "dest1".to_string(),
314            allow_visibility_increase: false,
315            allow_visibility_decrease: false,
316            allow_drive_boundary_crossing: false,
317            dry_run: false,
318            output: OutputFormat::Table,
319        });
320        assert!(cmd.dispatch(&dead_client()).await.is_err());
321    }
322}