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 docs;
8pub(crate) mod edit;
9pub(crate) mod format;
10pub(crate) mod helpers;
11pub(crate) mod lease;
12/// `drive move` — named `move_file` (not `move`, a Rust keyword) mirroring
13/// `crate::cli::atlassian::confluence::move_page`'s identical workaround.
14pub(crate) mod move_file;
15pub(crate) mod permissions;
16pub(crate) mod read;
17pub(crate) mod rename;
18pub(crate) mod search;
19pub(crate) mod sheets;
20pub(crate) mod slides;
21pub(crate) mod sync;
22pub(crate) mod trash;
23pub(crate) mod upload;
24
25use anyhow::Result;
26use clap::{Parser, Subcommand};
27
28use crate::drive::account::DRIVE_ACCOUNT_ENV;
29use crate::drive::client::DriveClient;
30
31/// Drive: search, read, sync, rename, and move Google Drive files via OAuth2.
32#[derive(Parser)]
33pub struct DriveCommand {
34    /// Selects a named Drive account configured in
35    /// `~/.omni-dev/settings.json` (AWS-CLI style, mirrors the top-level
36    /// `--profile`) for this invocation.
37    ///
38    /// Orthogonal to `--profile`: switching the Drive account never changes
39    /// which profile is active, and vice versa (see
40    /// [ADR-0066](../../../docs/adrs/adr-0066.md),
41    /// [ADR-0069](../../../docs/adrs/adr-0069.md)). Overrides
42    /// `OMNI_DEV_DRIVE_ACCOUNT`. Scoped to the `drive` subtree — not usable
43    /// before the `drive` subcommand name, only after it, so it can't
44    /// collide with an unrelated subcommand's own `--account` flag.
45    #[arg(long, global = true, value_name = "NAME")]
46    pub account: Option<String>,
47    /// The Drive subcommand to execute.
48    #[command(subcommand)]
49    pub command: DriveSubcommands,
50}
51
52/// Drive subcommands.
53#[derive(Subcommand)]
54pub enum DriveSubcommands {
55    /// Manages Drive OAuth2 credentials.
56    Auth(auth::AuthCommand),
57    /// Manages named Drive accounts.
58    Account(account::AccountCommand),
59    /// Searches Drive files.
60    Search(search::SearchCommand),
61    /// Reads a single Drive file's metadata or content.
62    Read(read::ReadCommand),
63    /// Finds Drive files sharing the same content hash.
64    Dedupe(dedupe::DedupeCommand),
65    /// Mirrors a Drive folder recursively to local disk (one-way, read-only scope).
66    Sync(sync::SyncCommand),
67    /// Creates a new file or folder, gated by the folder write-permission
68    /// rules (issue #1574). Requires the `drive.file` or `drive` scope
69    /// (`drive auth login --write-file`/`--write-full`).
70    Create(create::CreateCommand),
71    /// Uploads local content as a new file, gated by the folder
72    /// write-permission rules (issue #1574). Requires the `drive.file` or
73    /// `drive` scope (`drive auth login --write-file`/`--write-full`).
74    Upload(upload::UploadCommand),
75    /// Replaces an existing file's content, gated by the
76    /// write-permission rules (issues #1574, #1612). Requires the `drive.file`
77    /// scope if `omni-dev` created the file, or the unrestricted `drive`
78    /// scope for any pre-existing file (`drive auth login --write-file`
79    /// or `--write-full`).
80    Edit(edit::EditCommand),
81    /// Moves an individual file to Drive Trash, gated by `trash` permission.
82    /// Refuses folders; lease-exempt. Requires `drive auth login --write`.
83    Trash(trash::TrashCommand),
84    /// Restores an individual file from Drive Trash under `trash` permission.
85    /// Refuses folders; lease-exempt. Requires `drive auth login --write`.
86    Untrash(trash::TrashCommand),
87    /// Backs up a file and mints a Touch ID-authorised lease token,
88    /// required by `drive edit` and (in later phases) every other
89    /// content-mutating verb ([ADR-0080](../../docs/adrs/adr-0080.md)).
90    Lease(lease::LeaseCommand),
91    /// Renames a single Drive file. Requires the `drive.metadata` scope
92    /// (`drive auth login --write`).
93    Rename(rename::RenameCommand),
94    /// Moves one or more Drive files into a destination folder. Requires
95    /// the `drive.metadata` scope (`drive auth login --write`).
96    Move(move_file::MoveCommand),
97    /// Inspects the write-permission rules gating `drive
98    /// create`/`upload`/`edit`, `drive sheets
99    /// write`/`append`/`clear`/`create` and `drive docs
100    /// replace`/`append`/`create` (issues #1574, #1589, #1612, #1615).
101    Permissions(permissions::PermissionsCommand),
102    /// Reads the structure and text of a Google Doc via the Docs v1 API
103    /// (issue #1615).
104    Docs(docs::DocsCommand),
105    /// Reads Slides objects and replaces text on ordinary slides.
106    Slides(slides::SlidesCommand),
107    /// Reads and writes the cells of a Google Sheet via the Sheets v4 API
108    /// (issue #1589).
109    Sheets(sheets::SheetsCommand),
110}
111
112impl DriveCommand {
113    /// Executes the Drive command. `auth`/`account` must run without a
114    /// resolved client (they manage credentials/account selection);
115    /// `Permissions` and `Lease` resolve their own client lazily, per leaf
116    /// (see the comments below); every other subcommand resolves one
117    /// shared client **once** here and threads it down via
118    /// [`DriveSubcommands::dispatch`].
119    pub async fn execute(self) -> Result<()> {
120        // Propagates --account to DRIVE_ACCOUNT_ENV for the duration of this
121        // call only (crate::drive::account::resolve_account reads it),
122        // mirroring GmailCommand::execute. Set *before* matching Auth/
123        // Account: those subcommands need the resolved account too (e.g.
124        // `drive auth login --account work`). The guard restores the
125        // ambient value (or removes the var) on drop at the end of this
126        // function, so execute() is safe to call more than once per process
127        // (#1538).
128        let _account_guard = self
129            .account
130            .as_ref()
131            .map(|account| crate::utils::env::ScopedEnvVar::set(DRIVE_ACCOUNT_ENV, account));
132
133        match self.command {
134            DriveSubcommands::Auth(cmd) => cmd.execute().await,
135            DriveSubcommands::Account(cmd) => cmd.execute(),
136            // Permissions' three leaves have mixed client needs (`show` is
137            // config-only, `lookup-folder`/`check` both call the Drive
138            // API) — like Auth, it resolves its own client lazily per leaf
139            // rather than sharing the single eager resolution below.
140            DriveSubcommands::Permissions(cmd) => cmd.execute().await,
141            // `Lease`'s four leaves have mixed client needs: `acquire`/
142            // `restore` always call the Drive API, `release` never does
143            // (issue #1685 — it must work with no credentials at all, since
144            // it's how a lease gets stood down after `drive auth logout`),
145            // and `prune --dry-run` is a pure ledger read that doesn't
146            // either (issue #1743). Like `Permissions`, it resolves its own
147            // client lazily per leaf rather than sharing the eager
148            // resolution below — `helpers::create_client` is passed as a
149            // thunk so a leaf that never needs one never pays for
150            // credential resolution.
151            DriveSubcommands::Lease(cmd) => cmd.execute(helpers::create_client).await,
152            command => {
153                let client = helpers::create_client()?;
154                command.dispatch(&client).await
155            }
156        }
157    }
158}
159
160impl DriveSubcommands {
161    /// Routes a non-`Auth`/`Account`/`Permissions`/`Lease` subcommand
162    /// against the shared client. Those four arms are unreachable: all are
163    /// handled before client resolution in [`DriveCommand::execute`].
164    async fn dispatch(self, client: &DriveClient) -> Result<()> {
165        match self {
166            Self::Auth(_) => unreachable!("Auth is dispatched before client resolution"),
167            Self::Account(_) => unreachable!("Account is dispatched before client resolution"),
168            Self::Permissions(_) => {
169                unreachable!("Permissions is dispatched before client resolution")
170            }
171            Self::Lease(_) => unreachable!("Lease is dispatched before client resolution"),
172            Self::Search(cmd) => cmd.execute(client).await,
173            Self::Read(cmd) => cmd.execute(client).await,
174            Self::Dedupe(cmd) => cmd.execute(client).await,
175            Self::Sync(cmd) => cmd.execute(client).await,
176            Self::Create(cmd) => cmd.execute(client).await,
177            Self::Upload(cmd) => cmd.execute(client).await,
178            Self::Edit(cmd) => cmd.execute(client).await,
179            Self::Trash(cmd) => cmd.execute(client, false).await,
180            Self::Untrash(cmd) => cmd.execute(client, true).await,
181            Self::Rename(cmd) => cmd.execute(client).await,
182            Self::Move(cmd) => cmd.execute(client).await,
183            Self::Docs(cmd) => cmd.execute(client).await,
184            Self::Slides(cmd) => cmd.execute(client).await,
185            Self::Sheets(cmd) => cmd.execute(client).await,
186        }
187    }
188}
189
190#[cfg(test)]
191#[allow(clippy::unwrap_used)]
192mod tests {
193    use super::*;
194    use crate::cli::drive::format::OutputFormat;
195    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
196    use crate::utils::secret::Secret;
197
198    fn dead_credentials() -> DriveCredentials {
199        DriveCredentials {
200            client_id: "client".to_string(),
201            client_secret: Secret::new("secret"),
202            refresh_token: Secret::new("refresh"),
203            scope: DriveGrantedScopes::READONLY,
204        }
205    }
206
207    fn dead_client() -> DriveClient {
208        DriveClient::new("http://127.0.0.1:1", &dead_credentials()).unwrap()
209    }
210
211    /// The `StructureWriteArgs` every dispatch-routing test below wants:
212    /// live (non-dry-run), no lease, table output.
213    fn no_write_args() -> helpers::StructureWriteArgs {
214        helpers::StructureWriteArgs {
215            dry_run: false,
216            lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
217            output: OutputFormat::Table,
218        }
219    }
220
221    #[tokio::test]
222    async fn execute_routes_auth_subcommand_and_surfaces_missing_credentials() {
223        let guard = crate::drive::test_support::EnvGuard::take();
224        let _dir = guard.clear_credentials();
225
226        let cmd = DriveCommand {
227            account: None,
228            command: DriveSubcommands::Auth(auth::AuthCommand {
229                command: auth::AuthSubcommands::Status(auth::StatusCommand { all: false }),
230            }),
231        };
232        let err = cmd.execute().await.unwrap_err();
233        assert!(err.to_string().contains("not configured"));
234    }
235
236    #[tokio::test]
237    async fn execute_non_auth_subcommand_errors_when_credentials_missing() {
238        let guard = crate::drive::test_support::EnvGuard::take();
239        let _dir = guard.clear_credentials();
240
241        let cmd = DriveCommand {
242            account: None,
243            command: DriveSubcommands::Search(search::SearchCommand {
244                query: "name contains 'report'".to_string(),
245                limit: 10,
246                output: OutputFormat::Table,
247            }),
248        };
249        let err = cmd.execute().await.unwrap_err();
250        assert!(err.to_string().contains("not configured"));
251    }
252
253    /// `lease release` must reach the ledger with no credentials configured
254    /// (issue #1685) — it never calls the lazily-resolved client at all.
255    ///
256    /// Drives `LeaseCommand::run` directly rather than through
257    /// `DriveCommand::execute` — this token resolves `NoSuchToken`, whose
258    /// exit code is non-zero (issue #1775), and `execute` would
259    /// `std::process::exit` on that, aborting the test binary. Calling
260    /// `run` with a panicking client thunk is also a strictly stronger
261    /// check of "never calls the lazily-resolved client" than the old
262    /// indirect proxy of "would fail if it tried, since credentials are
263    /// missing".
264    #[tokio::test]
265    async fn execute_lease_release_needs_no_credentials() {
266        let guard = crate::drive::test_support::EnvGuard::take();
267        let dir = guard.clear_credentials();
268        let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
269
270        let cmd = lease::LeaseCommand::release_for_test("no-such-token".to_string());
271        let code = cmd
272            .run(|| panic!("release must never resolve a client"))
273            .await
274            .unwrap();
275        assert_eq!(code, 1, "NoSuchToken must be a non-zero exit");
276
277        assert_eq!(audit.verdicts(), vec!["release-no-such-token".to_string()]);
278    }
279
280    /// `lease prune --dry-run` is a pure ledger read and must work with no
281    /// credentials configured (issue #1743), the same way `release` does —
282    /// it never calls the lazily-resolved client either.
283    #[tokio::test]
284    async fn execute_lease_prune_dry_run_needs_no_credentials() {
285        let guard = crate::drive::test_support::EnvGuard::take();
286        let _dir = guard.clear_credentials();
287
288        let cmd = DriveCommand {
289            account: None,
290            command: DriveSubcommands::Lease(lease::LeaseCommand::prune_for_test("1h", true)),
291        };
292        cmd.execute().await.unwrap();
293    }
294
295    /// `acquire` always needs the Drive API, so it still resolves a client
296    /// lazily on first use and a missing credential is reported then.
297    #[tokio::test]
298    async fn execute_lease_acquire_still_errors_when_credentials_missing() {
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::Lease(lease::LeaseCommand::acquire_for_test(
305                "file-1".to_string(),
306            )),
307        };
308        let err = cmd.execute().await.unwrap_err();
309        assert!(err.to_string().contains("not configured"), "{err}");
310    }
311
312    #[tokio::test]
313    async fn execute_restores_account_env_var_after_return() {
314        let guard = crate::drive::test_support::EnvGuard::take();
315        let _dir = guard.clear_credentials();
316
317        let cmd = DriveCommand {
318            account: Some("work".to_string()),
319            command: DriveSubcommands::Account(account::AccountCommand {
320                command: account::AccountSubcommands::List(account::list::ListCommand {
321                    output: OutputFormat::Table,
322                }),
323            }),
324        };
325        cmd.execute().await.unwrap();
326        assert_eq!(std::env::var(DRIVE_ACCOUNT_ENV).ok(), None);
327    }
328
329    #[tokio::test]
330    async fn execute_restores_previous_account_env_var_after_return() {
331        let guard = crate::drive::test_support::EnvGuard::take();
332        let _dir = guard.clear_credentials();
333        std::env::set_var(DRIVE_ACCOUNT_ENV, "personal");
334
335        let cmd = DriveCommand {
336            account: Some("work".to_string()),
337            command: DriveSubcommands::Account(account::AccountCommand {
338                command: account::AccountSubcommands::List(account::list::ListCommand {
339                    output: OutputFormat::Table,
340                }),
341            }),
342        };
343        cmd.execute().await.unwrap();
344        assert_eq!(
345            std::env::var(DRIVE_ACCOUNT_ENV).ok().as_deref(),
346            Some("personal")
347        );
348    }
349
350    #[tokio::test]
351    async fn execute_does_not_leak_account_across_sequential_calls() {
352        let guard = crate::drive::test_support::EnvGuard::take();
353        let _dir = guard.clear_credentials();
354
355        let account_list_cmd = || DriveCommand {
356            account: None,
357            command: DriveSubcommands::Account(account::AccountCommand {
358                command: account::AccountSubcommands::List(account::list::ListCommand {
359                    output: OutputFormat::Table,
360                }),
361            }),
362        };
363
364        let first = DriveCommand {
365            account: Some("alpha".to_string()),
366            ..account_list_cmd()
367        };
368        first.execute().await.unwrap();
369        assert_eq!(std::env::var(DRIVE_ACCOUNT_ENV).ok(), None);
370
371        // If the first call's value had leaked, this second call — which
372        // omits --account entirely — would still see it via the env var.
373        account_list_cmd().execute().await.unwrap();
374        assert_eq!(std::env::var(DRIVE_ACCOUNT_ENV).ok(), None);
375    }
376
377    #[tokio::test]
378    async fn execute_absent_account_leaves_ambient_env_var_untouched() {
379        let guard = crate::drive::test_support::EnvGuard::take();
380        let _dir = guard.clear_credentials();
381        std::env::set_var(DRIVE_ACCOUNT_ENV, "personal");
382
383        let cmd = DriveCommand {
384            account: None,
385            command: DriveSubcommands::Account(account::AccountCommand {
386                command: account::AccountSubcommands::List(account::list::ListCommand {
387                    output: OutputFormat::Table,
388                }),
389            }),
390        };
391        cmd.execute().await.unwrap();
392        assert_eq!(
393            std::env::var(DRIVE_ACCOUNT_ENV).ok().as_deref(),
394            Some("personal")
395        );
396    }
397
398    #[tokio::test]
399    async fn execute_routes_account_list_without_client_resolution() {
400        let guard = crate::drive::test_support::EnvGuard::take();
401        let _dir = guard.clear_credentials();
402
403        let cmd = DriveCommand {
404            account: None,
405            command: DriveSubcommands::Account(account::AccountCommand {
406                command: account::AccountSubcommands::List(account::list::ListCommand {
407                    output: OutputFormat::Table,
408                }),
409            }),
410        };
411        cmd.execute().await.unwrap();
412    }
413
414    #[tokio::test]
415    async fn execute_routes_permissions_show_without_client_resolution() {
416        let guard = crate::drive::test_support::EnvGuard::take();
417        let _dir = guard.clear_credentials();
418
419        let cmd = DriveCommand {
420            account: None,
421            command: DriveSubcommands::Permissions(permissions::PermissionsCommand {
422                command: permissions::PermissionsSubcommands::Show(
423                    permissions::show::ShowCommand {
424                        output: OutputFormat::Table,
425                    },
426                ),
427            }),
428        };
429        cmd.execute().await.unwrap();
430    }
431
432    #[tokio::test]
433    async fn execute_routes_permissions_check_and_surfaces_missing_credentials() {
434        let guard = crate::drive::test_support::EnvGuard::take();
435        let _dir = guard.clear_credentials();
436
437        let cmd = DriveCommand {
438            account: None,
439            command: DriveSubcommands::Permissions(permissions::PermissionsCommand {
440                command: permissions::PermissionsSubcommands::Check(
441                    permissions::check::CheckCommand {
442                        id: "f1".to_string(),
443                        operation: permissions::check::OperationArg::Read,
444                        output: OutputFormat::Table,
445                    },
446                ),
447            }),
448        };
449        let err = cmd.execute().await.unwrap_err();
450        assert!(err.to_string().contains("not configured"));
451    }
452
453    #[tokio::test]
454    async fn dispatch_routes_slides_read_verbs_to_the_slides_host() {
455        let guard = crate::drive::test_support::EnvGuard::take();
456        guard.redirect_api_hosts_to_a_dead_port();
457        for command in [
458            slides::SlidesSubcommands::Info(slides::info::InfoCommand {
459                presentation_id: "p".into(),
460                output: OutputFormat::Table,
461            }),
462            slides::SlidesSubcommands::Read(slides::read::ReadCommand {
463                presentation_id: "p".into(),
464                slides: vec![],
465                output: OutputFormat::Table,
466            }),
467        ] {
468            let cmd = DriveSubcommands::Slides(slides::SlidesCommand { command });
469            assert!(cmd.dispatch(&dead_client()).await.is_err());
470        }
471    }
472
473    #[tokio::test]
474    async fn dispatch_routes_docs_info() {
475        // Like the Sheets routing tests, this needs an env guard: a
476        // `DocsClient` with no `DOCS_API_URL` set resolves the *real*
477        // `docs.googleapis.com`, so without the redirect this test would
478        // send a request to Google.
479        let guard = crate::drive::test_support::EnvGuard::take();
480        guard.redirect_api_hosts_to_a_dead_port();
481
482        let cmd = DriveSubcommands::Docs(docs::DocsCommand {
483            command: docs::DocsSubcommands::Info(docs::info::InfoCommand {
484                document_id: "d1".to_string(),
485                output: OutputFormat::Table,
486            }),
487        });
488        assert!(cmd.dispatch(&dead_client()).await.is_err());
489    }
490
491    #[tokio::test]
492    async fn dispatch_routes_docs_read() {
493        let guard = crate::drive::test_support::EnvGuard::take();
494        guard.redirect_api_hosts_to_a_dead_port();
495
496        let cmd = DriveSubcommands::Docs(docs::DocsCommand {
497            command: docs::DocsSubcommands::Read(docs::read::ReadCommand {
498                document_id: "d1".to_string(),
499                tab: None,
500                suggestions_view: docs::read::SuggestionsViewArg::Default,
501                output: OutputFormat::Table,
502            }),
503        });
504        assert!(cmd.dispatch(&dead_client()).await.is_err());
505    }
506
507    /// The Docs *write* verbs return `Ok(())` even when the API is
508    /// unreachable, which is the exit-code convention rather than an
509    /// oversight: a `Blocked`/`Failed`/`StaleRevision` outcome rides the
510    /// **output**, and `$?` stays 0 (ADR-0076 §13, and ADR-0070 §10 before
511    /// it). So these assert `is_ok()` — which still proves routing, since
512    /// every other arm of `dispatch` would surface the dead port as an
513    /// `Err` — and in doing so pin the convention itself.
514    ///
515    /// `clear_credentials` is what makes that deterministic: without it
516    /// `active_account_rules` reads the developer's real
517    /// `~/.omni-dev/settings.json`, so the result depends on the machine.
518    #[tokio::test]
519    async fn dispatch_routes_docs_replace() {
520        let guard = crate::drive::test_support::EnvGuard::take();
521        guard.redirect_api_hosts_to_a_dead_port();
522        let _dir = guard.clear_credentials();
523
524        let cmd = DriveSubcommands::Docs(docs::DocsCommand {
525            command: docs::DocsSubcommands::Replace(docs::write::ReplaceCommand {
526                document_id: "d1".to_string(),
527                search: "a".to_string(),
528                replace: "b".to_string(),
529                ignore_case: false,
530                dry_run: false,
531                lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
532                output: OutputFormat::Table,
533            }),
534        });
535        assert!(cmd.dispatch(&dead_client()).await.is_ok());
536    }
537
538    #[tokio::test]
539    async fn dispatch_routes_docs_append() {
540        let guard = crate::drive::test_support::EnvGuard::take();
541        guard.redirect_api_hosts_to_a_dead_port();
542
543        let _dir = guard.clear_credentials();
544
545        let cmd = DriveSubcommands::Docs(docs::DocsCommand {
546            command: docs::DocsSubcommands::Append(docs::write::AppendCommand {
547                document_id: "d1".to_string(),
548                text: Some("x".to_string()),
549                text_file: None,
550                dry_run: false,
551                lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
552                output: OutputFormat::Table,
553            }),
554        });
555        // See `dispatch_routes_docs_replace` for why this is `is_ok()`.
556        assert!(cmd.dispatch(&dead_client()).await.is_ok());
557    }
558
559    fn insert_command(
560        before: Option<&str>,
561        after: Option<&str>,
562        text: Option<&str>,
563        text_file: Option<&str>,
564    ) -> DriveSubcommands {
565        DriveSubcommands::Docs(docs::DocsCommand {
566            command: docs::DocsSubcommands::Insert(docs::write::InsertCommand {
567                segment_id: None,
568                tab_id: None,
569                document_id: "d1".to_string(),
570                before: before.map(str::to_string),
571                after: after.map(str::to_string),
572                text: text.map(str::to_string),
573                text_file: text_file.map(str::to_string),
574                ignore_case: true,
575                dry_run: false,
576                lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
577                output: OutputFormat::Table,
578            }),
579        })
580    }
581
582    fn delete_command(
583        match_text: Option<&str>,
584        from: Option<&str>,
585        to: Option<&str>,
586    ) -> DriveSubcommands {
587        DriveSubcommands::Docs(docs::DocsCommand {
588            command: docs::DocsSubcommands::Delete(docs::write::DeleteCommand {
589                segment_id: None,
590                tab_id: None,
591                document_id: "d1".to_string(),
592                match_text: match_text.map(str::to_string),
593                from: from.map(str::to_string),
594                to: to.map(str::to_string),
595                ignore_case: true,
596                dry_run: false,
597                lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
598                output: OutputFormat::Table,
599            }),
600        })
601    }
602
603    /// `insert` reaches the engine for either anchor side and either text
604    /// source, and — like every Docs write verb — reports an unreachable API
605    /// on the output rather than the exit code (see
606    /// `dispatch_routes_docs_replace`).
607    #[tokio::test]
608    async fn dispatch_routes_docs_insert_for_every_anchor_side_and_text_source() {
609        let guard = crate::drive::test_support::EnvGuard::take();
610        let dir = guard.clear_credentials();
611        guard.redirect_api_hosts_to_a_dead_port();
612        let file = dir.path().join("insert.txt");
613        std::fs::write(&file, "from a file").unwrap();
614        let file = file.to_str().unwrap();
615
616        for cmd in [
617            insert_command(Some("a"), None, Some("x"), None),
618            insert_command(None, Some("a"), Some("x"), None),
619            insert_command(Some("a"), None, None, Some(file)),
620            insert_command(None, Some("a"), None, Some(file)),
621        ] {
622            assert!(cmd.dispatch(&dead_client()).await.is_ok());
623        }
624    }
625
626    /// The engine never runs when the anchor or text selection is
627    /// ambiguous. clap already refuses these, so the checks guard commands
628    /// built any other way.
629    #[tokio::test]
630    async fn dispatch_refuses_an_insert_without_exactly_one_anchor_and_text_source() {
631        let guard = crate::drive::test_support::EnvGuard::take();
632        let _dir = guard.clear_credentials();
633        guard.redirect_api_hosts_to_a_dead_port();
634
635        for cmd in [
636            insert_command(None, None, Some("x"), None),
637            insert_command(Some("a"), Some("b"), Some("x"), None),
638        ] {
639            let err = cmd.dispatch(&dead_client()).await.unwrap_err();
640            assert!(err.to_string().contains("--before or --after"), "{err}");
641        }
642        for cmd in [
643            insert_command(Some("a"), None, None, None),
644            insert_command(Some("a"), None, Some("x"), Some("f")),
645        ] {
646            let err = cmd.dispatch(&dead_client()).await.unwrap_err();
647            assert!(err.to_string().contains("--text or --text-file"), "{err}");
648        }
649    }
650
651    #[tokio::test]
652    async fn dispatch_routes_docs_delete_for_a_match_and_for_a_range() {
653        let guard = crate::drive::test_support::EnvGuard::take();
654        let _dir = guard.clear_credentials();
655        guard.redirect_api_hosts_to_a_dead_port();
656
657        for cmd in [
658            delete_command(Some("a"), None, None),
659            delete_command(None, Some("a"), Some("b")),
660        ] {
661            assert!(cmd.dispatch(&dead_client()).await.is_ok());
662        }
663    }
664
665    #[tokio::test]
666    async fn dispatch_refuses_a_delete_that_is_not_a_match_or_a_full_range() {
667        let guard = crate::drive::test_support::EnvGuard::take();
668        let _dir = guard.clear_credentials();
669        guard.redirect_api_hosts_to_a_dead_port();
670
671        for cmd in [
672            delete_command(None, None, None),
673            delete_command(None, Some("a"), None),
674            delete_command(None, None, Some("b")),
675            delete_command(Some("a"), Some("a"), Some("b")),
676            delete_command(Some("a"), Some("a"), None),
677        ] {
678            let err = cmd.dispatch(&dead_client()).await.unwrap_err();
679            assert!(
680                err.to_string().contains("--match or both --from and --to"),
681                "{err}"
682            );
683        }
684    }
685
686    fn list_selection(
687        match_text: Option<&str>,
688        from: Option<&str>,
689        to: Option<&str>,
690    ) -> docs::write::ListSelection {
691        docs::write::ListSelection {
692            document_id: "d1".to_string(),
693            match_text: match_text.map(str::to_string),
694            from: from.map(str::to_string),
695            to: to.map(str::to_string),
696            ignore_case: true,
697            dry_run: false,
698            lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
699            output: OutputFormat::Table,
700        }
701    }
702
703    fn create_bullets_command(selection: docs::write::ListSelection) -> DriveSubcommands {
704        DriveSubcommands::Docs(docs::DocsCommand {
705            command: docs::DocsSubcommands::CreateBullets(docs::write::CreateBulletsCommand {
706                selection,
707                preset: docs::write::BulletPresetArg::BulletCheckbox,
708            }),
709        })
710    }
711
712    fn delete_bullets_command(selection: docs::write::ListSelection) -> DriveSubcommands {
713        DriveSubcommands::Docs(docs::DocsCommand {
714            command: docs::DocsSubcommands::DeleteBullets(docs::write::DeleteBulletsCommand {
715                selection,
716            }),
717        })
718    }
719
720    /// Both list verbs reach the shared engine for a `--match` selection and
721    /// for a `--from`/`--to` range, and — like every Docs write verb — report
722    /// an unreachable API on the output rather than the exit code (see
723    /// `dispatch_routes_docs_replace`).
724    #[tokio::test]
725    async fn dispatch_routes_docs_create_and_delete_bullets() {
726        let guard = crate::drive::test_support::EnvGuard::take();
727        let _dir = guard.clear_credentials();
728        guard.redirect_api_hosts_to_a_dead_port();
729
730        for cmd in [
731            create_bullets_command(list_selection(Some("a"), None, None)),
732            create_bullets_command(list_selection(None, Some("a"), Some("b"))),
733            delete_bullets_command(list_selection(Some("a"), None, None)),
734            delete_bullets_command(list_selection(None, Some("a"), Some("b"))),
735        ] {
736            assert!(cmd.dispatch(&dead_client()).await.is_ok());
737        }
738    }
739
740    /// The engine never runs when the selection is not a match or a full
741    /// range. clap already refuses these, so the check guards commands built
742    /// any other way.
743    #[tokio::test]
744    async fn dispatch_refuses_a_list_verb_that_is_not_a_match_or_a_full_range() {
745        let guard = crate::drive::test_support::EnvGuard::take();
746        let _dir = guard.clear_credentials();
747        guard.redirect_api_hosts_to_a_dead_port();
748
749        for (match_text, from, to) in [
750            (None, None, None),
751            (None, Some("a"), None),
752            (None, None, Some("b")),
753            (Some("a"), Some("a"), Some("b")),
754            (Some("a"), Some("a"), None),
755        ] {
756            for cmd in [
757                create_bullets_command(list_selection(match_text, from, to)),
758                delete_bullets_command(list_selection(match_text, from, to)),
759            ] {
760                let err = cmd.dispatch(&dead_client()).await.unwrap_err();
761                assert!(
762                    err.to_string().contains("--match or both --from and --to"),
763                    "{err}"
764                );
765            }
766        }
767    }
768
769    fn style_selection(
770        match_text: Option<&str>,
771        from: Option<&str>,
772        to: Option<&str>,
773    ) -> docs::style::Selection {
774        docs::style::Selection {
775            document_id: "d1".to_string(),
776            match_text: match_text.map(str::to_string),
777            from: from.map(str::to_string),
778            to: to.map(str::to_string),
779            ignore_case: true,
780            dry_run: false,
781            lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
782            output: OutputFormat::Table,
783        }
784    }
785
786    fn text_style_command(
787        match_text: Option<&str>,
788        from: Option<&str>,
789        to: Option<&str>,
790    ) -> DriveSubcommands {
791        DriveSubcommands::Docs(docs::DocsCommand {
792            command: docs::DocsSubcommands::TextStyle(docs::style::TextStyleCommand {
793                selection: style_selection(match_text, from, to),
794                bold: Some(true),
795                italic: None,
796                underline: None,
797                strikethrough: None,
798            }),
799        })
800    }
801
802    fn paragraph_style_command(
803        match_text: Option<&str>,
804        from: Option<&str>,
805        to: Option<&str>,
806    ) -> DriveSubcommands {
807        use crate::drive::docs::style::{Alignment, NamedStyle};
808        DriveSubcommands::Docs(docs::DocsCommand {
809            command: docs::DocsSubcommands::ParagraphStyle(docs::style::ParagraphStyleCommand {
810                selection: style_selection(match_text, from, to),
811                alignment: Some(Alignment::Center),
812                named_style: Some(NamedStyle::Title),
813            }),
814        })
815    }
816
817    /// Both formatting verbs reach the engine for a single match and for an
818    /// inclusive range, and — like every Docs write verb — report an
819    /// unreachable API on the output rather than the exit code (see
820    /// `dispatch_routes_docs_replace`).
821    #[tokio::test]
822    async fn dispatch_routes_docs_text_style_and_paragraph_style_for_a_match_and_for_a_range() {
823        let guard = crate::drive::test_support::EnvGuard::take();
824        let _dir = guard.clear_credentials();
825        guard.redirect_api_hosts_to_a_dead_port();
826
827        for cmd in [
828            text_style_command(Some("a"), None, None),
829            text_style_command(None, Some("a"), Some("b")),
830            paragraph_style_command(Some("a"), None, None),
831            paragraph_style_command(None, Some("a"), Some("b")),
832        ] {
833            assert!(cmd.dispatch(&dead_client()).await.is_ok());
834        }
835    }
836
837    /// The engine never runs when the selection is ambiguous. clap already
838    /// refuses these, so the check guards commands built any other way.
839    #[tokio::test]
840    async fn dispatch_refuses_a_format_that_is_not_a_match_or_a_full_range() {
841        let guard = crate::drive::test_support::EnvGuard::take();
842        let _dir = guard.clear_credentials();
843        guard.redirect_api_hosts_to_a_dead_port();
844
845        for (match_text, from, to) in [
846            (None, None, None),
847            (None, Some("a"), None),
848            (None, None, Some("b")),
849            (Some("a"), Some("a"), Some("b")),
850            (Some("a"), Some("a"), None),
851        ] {
852            for cmd in [
853                text_style_command(match_text, from, to),
854                paragraph_style_command(match_text, from, to),
855            ] {
856                let err = cmd.dispatch(&dead_client()).await.unwrap_err();
857                assert!(
858                    err.to_string().contains("--match or both --from and --to"),
859                    "{err}"
860                );
861            }
862        }
863    }
864
865    fn table_common() -> docs::table::Common {
866        docs::table::Common {
867            document_id: "d1".to_string(),
868            dry_run: false,
869            ignore_case: true,
870            lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
871            output: OutputFormat::Table,
872        }
873    }
874
875    fn insert_table_command(before: Option<&str>, after: Option<&str>) -> DriveSubcommands {
876        DriveSubcommands::Docs(docs::DocsCommand {
877            command: docs::DocsSubcommands::InsertTable(docs::table::InsertTableCommand {
878                common: table_common(),
879                before: before.map(str::to_string),
880                after: after.map(str::to_string),
881                rows: 2,
882                columns: 3,
883            }),
884        })
885    }
886
887    fn insert_dimension(before: bool, after: bool) -> docs::table::InsertDimensionCommand {
888        docs::table::InsertDimensionCommand {
889            common: table_common(),
890            cell: "a".to_string(),
891            before,
892            after,
893        }
894    }
895
896    fn delete_dimension() -> docs::table::DeleteDimensionCommand {
897        docs::table::DeleteDimensionCommand {
898            common: table_common(),
899            cell: "a".to_string(),
900        }
901    }
902
903    /// Every table verb reaches the shared write engine and — like every Docs
904    /// write verb — reports an unreachable API on the output rather than the
905    /// exit code (see `dispatch_routes_docs_replace`). Both insert-table
906    /// anchor sides and both insertion directions are exercised.
907    #[tokio::test]
908    async fn dispatch_routes_every_docs_table_verb() {
909        let guard = crate::drive::test_support::EnvGuard::take();
910        let _dir = guard.clear_credentials();
911        guard.redirect_api_hosts_to_a_dead_port();
912
913        let wrap = |command| DriveSubcommands::Docs(docs::DocsCommand { command });
914        for cmd in [
915            insert_table_command(Some("a"), None),
916            insert_table_command(None, Some("a")),
917            wrap(docs::DocsSubcommands::InsertTableRow(insert_dimension(
918                true, false,
919            ))),
920            wrap(docs::DocsSubcommands::InsertTableRow(insert_dimension(
921                false, true,
922            ))),
923            wrap(docs::DocsSubcommands::InsertTableColumn(insert_dimension(
924                true, false,
925            ))),
926            wrap(docs::DocsSubcommands::InsertTableColumn(insert_dimension(
927                false, true,
928            ))),
929            wrap(docs::DocsSubcommands::DeleteTableRow(delete_dimension())),
930            wrap(docs::DocsSubcommands::DeleteTableColumn(delete_dimension())),
931        ] {
932            assert!(cmd.dispatch(&dead_client()).await.is_ok());
933        }
934    }
935
936    /// `insert-table` never reaches the engine without exactly one anchor
937    /// side. clap already refuses these, so the check guards commands built
938    /// any other way.
939    #[tokio::test]
940    async fn dispatch_refuses_an_insert_table_without_exactly_one_anchor_side() {
941        let guard = crate::drive::test_support::EnvGuard::take();
942        let _dir = guard.clear_credentials();
943        guard.redirect_api_hosts_to_a_dead_port();
944
945        for (before, after) in [(None, None), (Some("a"), Some("b"))] {
946            let err = insert_table_command(before, after)
947                .dispatch(&dead_client())
948                .await
949                .unwrap_err();
950            assert!(
951                err.to_string()
952                    .contains("exactly one of --before or --after"),
953                "{err}"
954            );
955        }
956    }
957
958    fn scoped_args() -> docs::named_range::ScopedArgs {
959        docs::named_range::ScopedArgs {
960            document_id: "d1".to_string(),
961            tab: "legacy".to_string(),
962            segment: "body".to_string(),
963            dry_run: false,
964            lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
965            output: OutputFormat::Table,
966        }
967    }
968
969    fn named_range_command(command: docs::DocsSubcommands) -> DriveSubcommands {
970        DriveSubcommands::Docs(docs::DocsCommand { command })
971    }
972
973    fn replace_named_range_content(
974        text: Option<&str>,
975        text_file: Option<&str>,
976    ) -> DriveSubcommands {
977        named_range_command(docs::DocsSubcommands::ReplaceNamedRangeContent(
978            docs::named_range::ReplaceNamedRangeContentCommand {
979                scope: scoped_args(),
980                id: "nr".to_string(),
981                text: text.map(str::to_string),
982                text_file: text_file.map(str::to_string),
983            },
984        ))
985    }
986
987    /// All three named-range verbs reach the shared engine and, like every
988    /// Docs write verb, report an unreachable API on the output rather than
989    /// the exit code (see `dispatch_routes_docs_replace`).
990    #[tokio::test]
991    async fn dispatch_routes_docs_named_range_verbs() {
992        let guard = crate::drive::test_support::EnvGuard::take();
993        let _dir = guard.clear_credentials();
994        guard.redirect_api_hosts_to_a_dead_port();
995        let file = tempfile::NamedTempFile::new().unwrap();
996        std::fs::write(file.path(), "from a file").unwrap();
997
998        for cmd in [
999            named_range_command(docs::DocsSubcommands::CreateNamedRange(
1000                docs::named_range::CreateNamedRangeCommand {
1001                    scope: scoped_args(),
1002                    name: "label".to_string(),
1003                    start_index: 1,
1004                    end_index: 3,
1005                },
1006            )),
1007            named_range_command(docs::DocsSubcommands::DeleteNamedRange(
1008                docs::named_range::DeleteNamedRangeCommand {
1009                    scope: scoped_args(),
1010                    id: "nr".to_string(),
1011                },
1012            )),
1013            replace_named_range_content(Some("inline"), None),
1014            replace_named_range_content(None, Some(file.path().to_str().unwrap())),
1015        ] {
1016            assert!(cmd.dispatch(&dead_client()).await.is_ok());
1017        }
1018    }
1019
1020    /// The replacement text must come from exactly one source, and a
1021    /// `--text-file` that cannot be read stops the command before the engine.
1022    /// clap already enforces the first, so this guards commands built any
1023    /// other way.
1024    #[tokio::test]
1025    async fn dispatch_refuses_named_range_replacement_without_exactly_one_text_source() {
1026        let guard = crate::drive::test_support::EnvGuard::take();
1027        let _dir = guard.clear_credentials();
1028        guard.redirect_api_hosts_to_a_dead_port();
1029
1030        for (text, text_file) in [(None, None), (Some("x"), Some("f"))] {
1031            let err = replace_named_range_content(text, text_file)
1032                .dispatch(&dead_client())
1033                .await
1034                .unwrap_err();
1035            assert!(
1036                err.to_string()
1037                    .contains("exactly one of --text or --text-file"),
1038                "{err}"
1039            );
1040        }
1041
1042        let err = replace_named_range_content(None, Some("/nonexistent/no-such-file.txt"))
1043            .dispatch(&dead_client())
1044            .await
1045            .unwrap_err();
1046        assert!(err.to_string().contains("Failed to stat"), "{err}");
1047    }
1048
1049    #[tokio::test]
1050    async fn dispatch_routes_docs_create() {
1051        let guard = crate::drive::test_support::EnvGuard::take();
1052        guard.redirect_api_hosts_to_a_dead_port();
1053
1054        let _dir = guard.clear_credentials();
1055
1056        let cmd = DriveSubcommands::Docs(docs::DocsCommand {
1057            command: docs::DocsSubcommands::Create(docs::create::CreateCommand {
1058                name: "n".to_string(),
1059                parent: "folder-1".to_string(),
1060                text: None,
1061                text_file: None,
1062                dry_run: false,
1063                output: OutputFormat::Table,
1064            }),
1065        });
1066        // See `dispatch_routes_docs_replace` for why this is `is_ok()`.
1067        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1068    }
1069
1070    /// Like the Docs write verbs, trash and untrash report a refusal or an
1071    /// unreachable API through their **output** and exit 0, so `is_ok()` is
1072    /// the routing proof here. `--dry-run` plus a cleared credential store
1073    /// (no rules, so the default policy refuses) keeps it from mutating
1074    /// anything even if the dead port were somehow live.
1075    #[tokio::test]
1076    async fn dispatch_routes_trash_and_untrash() {
1077        let guard = crate::drive::test_support::EnvGuard::take();
1078        let _dir = guard.clear_credentials();
1079
1080        let leaf = || trash::TrashCommand {
1081            file_id: "file-1".to_string(),
1082            dry_run: true,
1083            output: OutputFormat::Table,
1084        };
1085        assert!(DriveSubcommands::Trash(leaf())
1086            .dispatch(&dead_client())
1087            .await
1088            .is_ok());
1089        assert!(DriveSubcommands::Untrash(leaf())
1090            .dispatch(&dead_client())
1091            .await
1092            .is_ok());
1093    }
1094
1095    #[tokio::test]
1096    async fn dispatch_routes_sheets_info() {
1097        // Unlike every other routing test, this one needs an env guard: a
1098        // `SheetsClient` with no `SHEETS_API_URL` set resolves the *real*
1099        // `sheets.googleapis.com`, so without the redirect this test would
1100        // send a request to Google.
1101        let guard = crate::drive::test_support::EnvGuard::take();
1102        guard.redirect_api_hosts_to_a_dead_port();
1103
1104        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1105            command: sheets::SheetsSubcommands::Info(sheets::info::InfoCommand {
1106                spreadsheet_id: "s1".to_string(),
1107                output: OutputFormat::Table,
1108            }),
1109        });
1110        assert!(cmd.dispatch(&dead_client()).await.is_err());
1111    }
1112
1113    #[tokio::test]
1114    async fn dispatch_routes_sheets_read() {
1115        let guard = crate::drive::test_support::EnvGuard::take();
1116        guard.redirect_api_hosts_to_a_dead_port();
1117
1118        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1119            command: sheets::SheetsSubcommands::Read(sheets::read::ReadCommand {
1120                spreadsheet_id: "s1".to_string(),
1121                range: None,
1122                sheet: None,
1123                render: sheets::read::RenderArg::Formatted,
1124                output: OutputFormat::Table,
1125            }),
1126        });
1127        assert!(cmd.dispatch(&dead_client()).await.is_err());
1128    }
1129
1130    #[tokio::test]
1131    async fn dispatch_routes_sheets_write() {
1132        let guard = crate::drive::test_support::EnvGuard::take();
1133        guard.redirect_api_hosts_to_a_dead_port();
1134        let _dir = guard.clear_credentials();
1135
1136        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1137            command: sheets::SheetsSubcommands::Write(sheets::write::WriteCommand {
1138                spreadsheet_id: "s1".to_string(),
1139                range: Some("A1".to_string()),
1140                sheet: None,
1141                values: "/definitely/not/here.csv".to_string(),
1142                values_format: sheets::values::ValuesFormat::Auto,
1143                input: sheets::write::InputArg::UserEntered,
1144                dry_run: false,
1145                lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1146                output: OutputFormat::Table,
1147            }),
1148        });
1149        assert!(cmd.dispatch(&dead_client()).await.is_err());
1150    }
1151
1152    #[tokio::test]
1153    async fn dispatch_routes_sheets_append() {
1154        let guard = crate::drive::test_support::EnvGuard::take();
1155        guard.redirect_api_hosts_to_a_dead_port();
1156        let _dir = guard.clear_credentials();
1157
1158        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1159            command: sheets::SheetsSubcommands::Append(sheets::write::AppendCommand {
1160                spreadsheet_id: "s1".to_string(),
1161                range: Some("A1".to_string()),
1162                sheet: None,
1163                values: "/definitely/not/here.csv".to_string(),
1164                values_format: sheets::values::ValuesFormat::Auto,
1165                input: sheets::write::InputArg::UserEntered,
1166                dry_run: false,
1167                lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1168                output: OutputFormat::Table,
1169            }),
1170        });
1171        assert!(cmd.dispatch(&dead_client()).await.is_err());
1172    }
1173
1174    #[tokio::test]
1175    async fn dispatch_routes_sheets_clear() {
1176        let guard = crate::drive::test_support::EnvGuard::take();
1177        guard.redirect_api_hosts_to_a_dead_port();
1178        let _dir = guard.clear_credentials();
1179
1180        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1181            command: sheets::SheetsSubcommands::Clear(sheets::write::ClearCommand {
1182                spreadsheet_id: "s1".to_string(),
1183                range: Some("A1".to_string()),
1184                sheet: None,
1185                dry_run: false,
1186                lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1187                output: OutputFormat::Table,
1188            }),
1189        });
1190        // Unlike its siblings this returns `Ok`, and that is the contract,
1191        // not an accident: `write`/`append` fail here only because reading
1192        // `--values` fails *before* the engine. `clear` reaches the engine,
1193        // which never returns `Err` — every failure is a `WriteResult`
1194        // variant and the process still exits 0 (ADR-0070 §10, ADR-0071
1195        // §12). Scripts must inspect the output, not `$?`.
1196        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1197    }
1198
1199    #[tokio::test]
1200    async fn dispatch_routes_sheets_create() {
1201        let guard = crate::drive::test_support::EnvGuard::take();
1202        guard.redirect_api_hosts_to_a_dead_port();
1203        let _dir = guard.clear_credentials();
1204
1205        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1206            command: sheets::SheetsSubcommands::Create(sheets::create::CreateCommand {
1207                name: "Budget".to_string(),
1208                parent: "parent-1".to_string(),
1209                values: None,
1210                values_format: sheets::values::ValuesFormat::Auto,
1211                input: sheets::write::InputArg::UserEntered,
1212                dry_run: false,
1213                output: OutputFormat::Table,
1214            }),
1215        });
1216        // Like `clear`, this reaches the engine (no `--values` to fail
1217        // reading first), and `create` never returns `Err` either — every
1218        // failure is a `CreateResult` variant (ADR-0070 §10, ADR-0071 §12).
1219        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1220    }
1221
1222    #[tokio::test]
1223    async fn dispatch_routes_sheets_add_sheet() {
1224        let guard = crate::drive::test_support::EnvGuard::take();
1225        guard.redirect_api_hosts_to_a_dead_port();
1226        let _dir = guard.clear_credentials();
1227
1228        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1229            command: sheets::SheetsSubcommands::AddSheet(sheets::structure::AddSheetCommand {
1230                target: sheets::structure::SpreadsheetIdArg {
1231                    spreadsheet_id: "sheet-1".to_string(),
1232                },
1233                title: "Q3".to_string(),
1234                index: None,
1235                rows: None,
1236                columns: None,
1237                write: no_write_args(),
1238            }),
1239        });
1240        // Reaches the engine, which never returns `Err` — every failure is a
1241        // `StructureResult` variant (ADR-0073 §13).
1242        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1243    }
1244
1245    #[tokio::test]
1246    async fn dispatch_routes_sheets_rename_sheet() {
1247        let guard = crate::drive::test_support::EnvGuard::take();
1248        guard.redirect_api_hosts_to_a_dead_port();
1249        let _dir = guard.clear_credentials();
1250
1251        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1252            command: sheets::SheetsSubcommands::RenameSheet(
1253                sheets::structure::RenameSheetCommand {
1254                    target: sheets::structure::SpreadsheetIdArg {
1255                        spreadsheet_id: "sheet-1".to_string(),
1256                    },
1257                    sheet: "Q2".to_string(),
1258                    title: "Q3".to_string(),
1259                    write: no_write_args(),
1260                },
1261            ),
1262        });
1263        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1264    }
1265
1266    #[tokio::test]
1267    async fn dispatch_routes_sheets_insert_rows() {
1268        let guard = crate::drive::test_support::EnvGuard::take();
1269        guard.redirect_api_hosts_to_a_dead_port();
1270        let _dir = guard.clear_credentials();
1271
1272        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1273            command: sheets::SheetsSubcommands::InsertRows(sheets::structure::InsertRowsCommand {
1274                target: sheets::structure::SpreadsheetIdArg {
1275                    spreadsheet_id: "sheet-1".to_string(),
1276                },
1277                sheet: "Q2".to_string(),
1278                at: 5,
1279                count: 3,
1280                write: no_write_args(),
1281            }),
1282        });
1283        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1284    }
1285
1286    #[tokio::test]
1287    async fn dispatch_routes_sheets_insert_columns() {
1288        let guard = crate::drive::test_support::EnvGuard::take();
1289        guard.redirect_api_hosts_to_a_dead_port();
1290        let _dir = guard.clear_credentials();
1291
1292        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1293            command: sheets::SheetsSubcommands::InsertColumns(
1294                sheets::structure::InsertColumnsCommand {
1295                    target: sheets::structure::SpreadsheetIdArg {
1296                        spreadsheet_id: "sheet-1".to_string(),
1297                    },
1298                    sheet: "Q2".to_string(),
1299                    at: 2,
1300                    count: 1,
1301                    write: no_write_args(),
1302                },
1303            ),
1304        });
1305        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1306    }
1307
1308    #[tokio::test]
1309    async fn dispatch_routes_sheets_move_rows() {
1310        let guard = crate::drive::test_support::EnvGuard::take();
1311        guard.redirect_api_hosts_to_a_dead_port();
1312        let _dir = guard.clear_credentials();
1313
1314        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1315            command: sheets::SheetsSubcommands::MoveRows(sheets::structure::MoveRowsCommand {
1316                target: sheets::structure::SpreadsheetIdArg {
1317                    spreadsheet_id: "sheet-1".to_string(),
1318                },
1319                sheet: "Q2".to_string(),
1320                at: 5,
1321                count: 3,
1322                before: 10,
1323                write: no_write_args(),
1324            }),
1325        });
1326        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1327    }
1328
1329    #[tokio::test]
1330    async fn dispatch_routes_sheets_move_columns() {
1331        let guard = crate::drive::test_support::EnvGuard::take();
1332        guard.redirect_api_hosts_to_a_dead_port();
1333        let _dir = guard.clear_credentials();
1334
1335        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1336            command: sheets::SheetsSubcommands::MoveColumns(
1337                sheets::structure::MoveColumnsCommand {
1338                    target: sheets::structure::SpreadsheetIdArg {
1339                        spreadsheet_id: "sheet-1".to_string(),
1340                    },
1341                    sheet: "Q2".to_string(),
1342                    at: 5,
1343                    count: 3,
1344                    before: 10,
1345                    write: no_write_args(),
1346                },
1347            ),
1348        });
1349        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1350    }
1351
1352    #[tokio::test]
1353    async fn dispatch_routes_sheets_move_chart() {
1354        let guard = crate::drive::test_support::EnvGuard::take();
1355        guard.redirect_api_hosts_to_a_dead_port();
1356        let _dir = guard.clear_credentials();
1357
1358        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1359            command: sheets::SheetsSubcommands::MoveChart(
1360                sheets::embedded_object::MoveChartCommand {
1361                    spreadsheet_id: "sheet-1".to_string(),
1362                    chart_id: 1,
1363                    sheet: None,
1364                    anchor: Some("F2".to_string()),
1365                    offset_x: None,
1366                    offset_y: None,
1367                    width: None,
1368                    height: None,
1369                    new_sheet: false,
1370                    write: helpers::StructureWriteArgs {
1371                        dry_run: false,
1372                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1373                        output: OutputFormat::Table,
1374                    },
1375                },
1376            ),
1377        });
1378        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1379    }
1380
1381    #[tokio::test]
1382    async fn dispatch_routes_sheets_move_slicer() {
1383        let guard = crate::drive::test_support::EnvGuard::take();
1384        guard.redirect_api_hosts_to_a_dead_port();
1385        let _dir = guard.clear_credentials();
1386
1387        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1388            command: sheets::SheetsSubcommands::MoveSlicer(
1389                sheets::embedded_object::MoveSlicerCommand {
1390                    spreadsheet_id: "sheet-1".to_string(),
1391                    slicer_id: 1,
1392                    sheet: None,
1393                    anchor: Some("F2".to_string()),
1394                    offset_x: None,
1395                    offset_y: None,
1396                    width: None,
1397                    height: None,
1398                    write: helpers::StructureWriteArgs {
1399                        dry_run: false,
1400                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1401                        output: OutputFormat::Table,
1402                    },
1403                },
1404            ),
1405        });
1406        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1407    }
1408
1409    #[tokio::test]
1410    async fn dispatch_routes_sheets_update_chart_border() {
1411        let guard = crate::drive::test_support::EnvGuard::take();
1412        guard.redirect_api_hosts_to_a_dead_port();
1413        let _dir = guard.clear_credentials();
1414
1415        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1416            command: sheets::SheetsSubcommands::UpdateChartBorder(
1417                sheets::embedded_object::UpdateChartBorderCommand {
1418                    spreadsheet_id: "sheet-1".to_string(),
1419                    chart_id: 1,
1420                    color: Some("#4A86E8".to_string()),
1421                    clear: false,
1422                    write: helpers::StructureWriteArgs {
1423                        dry_run: false,
1424                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1425                        output: OutputFormat::Table,
1426                    },
1427                },
1428            ),
1429        });
1430        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1431    }
1432
1433    #[tokio::test]
1434    async fn dispatch_routes_sheets_delete_sheet() {
1435        let guard = crate::drive::test_support::EnvGuard::take();
1436        guard.redirect_api_hosts_to_a_dead_port();
1437        let _dir = guard.clear_credentials();
1438
1439        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1440            command: sheets::SheetsSubcommands::DeleteSheet(
1441                sheets::structure::DeleteSheetCommand {
1442                    target: sheets::structure::SpreadsheetIdArg {
1443                        spreadsheet_id: "sheet-1".to_string(),
1444                    },
1445                    sheet: "Q2".to_string(),
1446                    write: no_write_args(),
1447                },
1448            ),
1449        });
1450        // Reaches the engine, which never returns `Err` — every failure is a
1451        // `StructureResult` variant (ADR-0073 §13, ADR-0077).
1452        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1453    }
1454
1455    #[tokio::test]
1456    async fn dispatch_routes_sheets_delete_rows() {
1457        let guard = crate::drive::test_support::EnvGuard::take();
1458        guard.redirect_api_hosts_to_a_dead_port();
1459        let _dir = guard.clear_credentials();
1460
1461        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1462            command: sheets::SheetsSubcommands::DeleteRows(sheets::structure::DeleteRowsCommand {
1463                target: sheets::structure::SpreadsheetIdArg {
1464                    spreadsheet_id: "sheet-1".to_string(),
1465                },
1466                sheet: "Q2".to_string(),
1467                at: 5,
1468                count: 3,
1469                write: no_write_args(),
1470            }),
1471        });
1472        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1473    }
1474
1475    #[tokio::test]
1476    async fn dispatch_routes_sheets_delete_columns() {
1477        let guard = crate::drive::test_support::EnvGuard::take();
1478        guard.redirect_api_hosts_to_a_dead_port();
1479        let _dir = guard.clear_credentials();
1480
1481        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1482            command: sheets::SheetsSubcommands::DeleteColumns(
1483                sheets::structure::DeleteColumnsCommand {
1484                    target: sheets::structure::SpreadsheetIdArg {
1485                        spreadsheet_id: "sheet-1".to_string(),
1486                    },
1487                    sheet: "Q2".to_string(),
1488                    at: 2,
1489                    count: 1,
1490                    write: no_write_args(),
1491                },
1492            ),
1493        });
1494        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1495    }
1496
1497    #[tokio::test]
1498    async fn dispatch_routes_sheets_delete_range() {
1499        let guard = crate::drive::test_support::EnvGuard::take();
1500        guard.redirect_api_hosts_to_a_dead_port();
1501        let _dir = guard.clear_credentials();
1502
1503        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1504            command: sheets::SheetsSubcommands::DeleteRange(
1505                sheets::structure::DeleteRangeCommand {
1506                    target: sheets::structure::SpreadsheetIdArg {
1507                        spreadsheet_id: "sheet-1".to_string(),
1508                    },
1509                    sheet: "Q2".to_string(),
1510                    range: sheets::structure::GridRangeArgs {
1511                        start_row: 2,
1512                        end_row: 4,
1513                        start_column: 2,
1514                        end_column: 3,
1515                        shift: sheets::structure::ShiftArg::Rows,
1516                    },
1517                    write: no_write_args(),
1518                },
1519            ),
1520        });
1521        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1522    }
1523
1524    #[tokio::test]
1525    async fn dispatch_routes_sheets_duplicate_sheet() {
1526        let guard = crate::drive::test_support::EnvGuard::take();
1527        let _dir = guard.clear_credentials();
1528
1529        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1530            command: sheets::SheetsSubcommands::DuplicateSheet(
1531                sheets::structure::DuplicateSheetCommand {
1532                    target: sheets::structure::SpreadsheetIdArg {
1533                        spreadsheet_id: "sheet-1".to_string(),
1534                    },
1535                    sheet: "Q2".to_string(),
1536                    title: Some("Q2 copy".to_string()),
1537                    index: None,
1538                    write: no_write_args(),
1539                },
1540            ),
1541        });
1542        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1543    }
1544
1545    #[tokio::test]
1546    async fn dispatch_routes_sheets_reorder_sheet() {
1547        let guard = crate::drive::test_support::EnvGuard::take();
1548        let _dir = guard.clear_credentials();
1549
1550        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1551            command: sheets::SheetsSubcommands::ReorderSheet(
1552                sheets::structure::ReorderSheetCommand {
1553                    target: sheets::structure::SpreadsheetIdArg {
1554                        spreadsheet_id: "sheet-1".to_string(),
1555                    },
1556                    sheet: "Q2".to_string(),
1557                    index: 0,
1558                    write: no_write_args(),
1559                },
1560            ),
1561        });
1562        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1563    }
1564
1565    #[tokio::test]
1566    async fn dispatch_routes_sheets_hide_sheet() {
1567        let guard = crate::drive::test_support::EnvGuard::take();
1568        let _dir = guard.clear_credentials();
1569
1570        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1571            command: sheets::SheetsSubcommands::HideSheet(sheets::structure::HideSheetCommand {
1572                target: sheets::structure::SpreadsheetIdArg {
1573                    spreadsheet_id: "sheet-1".to_string(),
1574                },
1575                sheet: "Q2".to_string(),
1576                write: no_write_args(),
1577            }),
1578        });
1579        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1580    }
1581
1582    #[tokio::test]
1583    async fn dispatch_routes_sheets_show_sheet() {
1584        let guard = crate::drive::test_support::EnvGuard::take();
1585        let _dir = guard.clear_credentials();
1586
1587        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1588            command: sheets::SheetsSubcommands::ShowSheet(sheets::structure::ShowSheetCommand {
1589                target: sheets::structure::SpreadsheetIdArg {
1590                    spreadsheet_id: "sheet-1".to_string(),
1591                },
1592                sheet: "Q2".to_string(),
1593                write: no_write_args(),
1594            }),
1595        });
1596        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1597    }
1598
1599    #[tokio::test]
1600    async fn dispatch_routes_sheets_update_workbook_properties() {
1601        let guard = crate::drive::test_support::EnvGuard::take();
1602        guard.redirect_api_hosts_to_a_dead_port();
1603        let _dir = guard.clear_credentials();
1604
1605        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1606            command: sheets::SheetsSubcommands::UpdateWorkbookProperties(
1607                sheets::structure::UpdateWorkbookPropertiesCommand {
1608                    target: sheets::structure::SpreadsheetIdArg {
1609                        spreadsheet_id: "sheet-1".to_string(),
1610                    },
1611                    locale: Some("en_US".to_string()),
1612                    time_zone: None,
1613                    auto_recalc: None,
1614                    iterative_calculation: None,
1615                    iterative_calculation_max_iterations: None,
1616                    iterative_calculation_convergence_threshold: None,
1617                    write: no_write_args(),
1618                },
1619            ),
1620        });
1621        // Reaches the engine, which never returns `Err` — every failure is a
1622        // `StructureResult` variant (ADR-0073 §13).
1623        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1624    }
1625
1626    #[tokio::test]
1627    async fn dispatch_routes_sheets_format_cells() {
1628        let guard = crate::drive::test_support::EnvGuard::take();
1629        let _dir = guard.clear_credentials();
1630
1631        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1632            command: sheets::SheetsSubcommands::FormatCells(sheets::format::FormatCellsCommand {
1633                spreadsheet_id: "sheet-1".to_string(),
1634                range: Some("A1:B2".to_string()),
1635                sheet: None,
1636                bold: Some(true),
1637                italic: None,
1638                strikethrough: None,
1639                underline: None,
1640                font_size: None,
1641                text_color: None,
1642                background: None,
1643                horizontal_align: Some(sheets::format::HorizontalAlign::Center),
1644                vertical_align: Some(sheets::format::VerticalAlign::Middle),
1645                number_format: Some("#,##0.00".to_string()),
1646                number_format_type: Some(sheets::format::NumberFormatType::Currency),
1647                wrap: Some(sheets::format::WrapStrategy::Wrap),
1648                font_family: None,
1649                text_rotation_angle: None,
1650                text_rotation_vertical: false,
1651                hyperlink_display_type: None,
1652                padding_top: None,
1653                padding_right: None,
1654                padding_bottom: None,
1655                padding_left: None,
1656                text_direction: None,
1657                write: helpers::StructureWriteArgs {
1658                    dry_run: false,
1659                    lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1660                    output: OutputFormat::Table,
1661                },
1662            }),
1663        });
1664        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1665    }
1666
1667    fn base_format_cells_command() -> sheets::format::FormatCellsCommand {
1668        sheets::format::FormatCellsCommand {
1669            spreadsheet_id: "sheet-1".to_string(),
1670            range: Some("A1:B2".to_string()),
1671            sheet: None,
1672            bold: None,
1673            italic: None,
1674            strikethrough: None,
1675            underline: None,
1676            font_size: None,
1677            text_color: None,
1678            background: None,
1679            horizontal_align: None,
1680            vertical_align: None,
1681            number_format: None,
1682            number_format_type: None,
1683            wrap: None,
1684            font_family: None,
1685            text_rotation_angle: None,
1686            text_rotation_vertical: false,
1687            hyperlink_display_type: None,
1688            padding_top: None,
1689            padding_right: None,
1690            padding_bottom: None,
1691            padding_left: None,
1692            text_direction: None,
1693            write: helpers::StructureWriteArgs {
1694                dry_run: false,
1695                lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1696                output: OutputFormat::Table,
1697            },
1698        }
1699    }
1700
1701    #[tokio::test]
1702    async fn dispatch_routes_sheets_format_cells_text_rotation_angle() {
1703        let guard = crate::drive::test_support::EnvGuard::take();
1704        let _dir = guard.clear_credentials();
1705
1706        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1707            command: sheets::SheetsSubcommands::FormatCells(sheets::format::FormatCellsCommand {
1708                text_rotation_angle: Some(45),
1709                ..base_format_cells_command()
1710            }),
1711        });
1712        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1713    }
1714
1715    #[tokio::test]
1716    async fn dispatch_routes_sheets_format_cells_text_rotation_vertical() {
1717        let guard = crate::drive::test_support::EnvGuard::take();
1718        let _dir = guard.clear_credentials();
1719
1720        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1721            command: sheets::SheetsSubcommands::FormatCells(sheets::format::FormatCellsCommand {
1722                text_rotation_vertical: true,
1723                ..base_format_cells_command()
1724            }),
1725        });
1726        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1727    }
1728
1729    #[tokio::test]
1730    async fn dispatch_routes_sheets_update_borders() {
1731        let guard = crate::drive::test_support::EnvGuard::take();
1732        let _dir = guard.clear_credentials();
1733
1734        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1735            command: sheets::SheetsSubcommands::UpdateBorders(
1736                sheets::format::UpdateBordersCommand {
1737                    spreadsheet_id: "sheet-1".to_string(),
1738                    range: Some("A1:B2".to_string()),
1739                    sheet: None,
1740                    top: true,
1741                    bottom: false,
1742                    left: false,
1743                    right: false,
1744                    all: false,
1745                    inner_horizontal: false,
1746                    inner_vertical: false,
1747                    style: sheets::format::BorderStyle::Dashed,
1748                    color: Some("#FF0000".to_string()),
1749                    write: helpers::StructureWriteArgs {
1750                        dry_run: false,
1751                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1752                        output: OutputFormat::Table,
1753                    },
1754                },
1755            ),
1756        });
1757        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1758    }
1759
1760    #[tokio::test]
1761    async fn dispatch_routes_sheets_merge_cells() {
1762        let guard = crate::drive::test_support::EnvGuard::take();
1763        let _dir = guard.clear_credentials();
1764
1765        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1766            command: sheets::SheetsSubcommands::MergeCells(sheets::format::MergeCellsCommand {
1767                spreadsheet_id: "sheet-1".to_string(),
1768                range: Some("A1:B2".to_string()),
1769                sheet: None,
1770                r#type: sheets::format::MergeType::Rows,
1771                dry_run: false,
1772                lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1773                output: OutputFormat::Table,
1774            }),
1775        });
1776        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1777    }
1778
1779    #[tokio::test]
1780    async fn dispatch_routes_sheets_unmerge_cells() {
1781        let guard = crate::drive::test_support::EnvGuard::take();
1782        let _dir = guard.clear_credentials();
1783
1784        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1785            command: sheets::SheetsSubcommands::UnmergeCells(sheets::format::UnmergeCellsCommand {
1786                spreadsheet_id: "sheet-1".to_string(),
1787                range: Some("A1:B2".to_string()),
1788                sheet: None,
1789                write: helpers::StructureWriteArgs {
1790                    dry_run: false,
1791                    lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1792                    output: OutputFormat::Table,
1793                },
1794            }),
1795        });
1796        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1797    }
1798
1799    #[tokio::test]
1800    async fn dispatch_routes_sheets_auto_resize_dimension() {
1801        let guard = crate::drive::test_support::EnvGuard::take();
1802        let _dir = guard.clear_credentials();
1803
1804        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1805            command: sheets::SheetsSubcommands::AutoResizeDimension(
1806                sheets::format::AutoResizeDimensionCommand {
1807                    spreadsheet_id: "sheet-1".to_string(),
1808                    sheet: "Q2".to_string(),
1809                    dimension: sheets::format::DimensionArg::Columns,
1810                    start: 1,
1811                    end: 3,
1812                    write: helpers::StructureWriteArgs {
1813                        dry_run: false,
1814                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1815                        output: OutputFormat::Table,
1816                    },
1817                },
1818            ),
1819        });
1820        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1821    }
1822
1823    #[tokio::test]
1824    async fn dispatch_routes_sheets_update_dimension_properties() {
1825        let guard = crate::drive::test_support::EnvGuard::take();
1826        let _dir = guard.clear_credentials();
1827
1828        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1829            command: sheets::SheetsSubcommands::UpdateDimensionProperties(
1830                sheets::format::UpdateDimensionPropertiesCommand {
1831                    spreadsheet_id: "sheet-1".to_string(),
1832                    sheet: "Q2".to_string(),
1833                    dimension: sheets::format::DimensionArg::Rows,
1834                    start: 1,
1835                    end: 3,
1836                    pixel_size: 42,
1837                    write: helpers::StructureWriteArgs {
1838                        dry_run: false,
1839                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1840                        output: OutputFormat::Table,
1841                    },
1842                },
1843            ),
1844        });
1845        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1846    }
1847
1848    #[tokio::test]
1849    async fn dispatch_routes_sheets_set_data_validation() {
1850        let guard = crate::drive::test_support::EnvGuard::take();
1851        let _dir = guard.clear_credentials();
1852
1853        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1854            command: sheets::SheetsSubcommands::SetDataValidation(Box::new(
1855                sheets::validation::SetDataValidationCommand {
1856                    spreadsheet_id: "sheet-1".to_string(),
1857                    range: Some("A1:A10".to_string()),
1858                    sheet: None,
1859                    one_of_list: Some(vec!["yes".to_string(), "no".to_string()]),
1860                    one_of_range: None,
1861                    number_between: None,
1862                    number_not_between: None,
1863                    number_greater: None,
1864                    number_greater_eq: None,
1865                    number_less: None,
1866                    number_less_eq: None,
1867                    number_eq: None,
1868                    number_not_eq: None,
1869                    text_contains: None,
1870                    text_not_contains: None,
1871                    text_starts_with: None,
1872                    text_ends_with: None,
1873                    text_eq: None,
1874                    date_after: None,
1875                    date_before: None,
1876                    date_on: None,
1877                    date_between: None,
1878                    blank: false,
1879                    not_blank: false,
1880                    checkbox: false,
1881                    custom_formula: None,
1882                    input_message: Some("Pick one".to_string()),
1883                    show_warning: false,
1884                    write: helpers::StructureWriteArgs {
1885                        dry_run: false,
1886                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1887                        output: OutputFormat::Table,
1888                    },
1889                },
1890            )),
1891        });
1892        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1893    }
1894
1895    #[tokio::test]
1896    async fn dispatch_routes_sheets_clear_data_validation() {
1897        let guard = crate::drive::test_support::EnvGuard::take();
1898        let _dir = guard.clear_credentials();
1899
1900        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1901            command: sheets::SheetsSubcommands::ClearDataValidation(
1902                sheets::validation::ClearDataValidationCommand {
1903                    spreadsheet_id: "sheet-1".to_string(),
1904                    range: Some("A1:A10".to_string()),
1905                    sheet: None,
1906                    write: helpers::StructureWriteArgs {
1907                        dry_run: false,
1908                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1909                        output: OutputFormat::Table,
1910                    },
1911                },
1912            ),
1913        });
1914        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1915    }
1916
1917    #[tokio::test]
1918    async fn dispatch_routes_sheets_set_developer_metadata() {
1919        let guard = crate::drive::test_support::EnvGuard::take();
1920        let _dir = guard.clear_credentials();
1921
1922        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1923            command: sheets::SheetsSubcommands::SetDeveloperMetadata(
1924                sheets::developer_metadata::SetDeveloperMetadataCommand {
1925                    spreadsheet_id: "sheet-1".to_string(),
1926                    key: "owner".to_string(),
1927                    value: "team-a".to_string(),
1928                    sheet: None,
1929                    dimension: None,
1930                    start: None,
1931                    end: None,
1932                    write: helpers::StructureWriteArgs {
1933                        dry_run: false,
1934                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1935                        output: OutputFormat::Table,
1936                    },
1937                },
1938            ),
1939        });
1940        // See `dispatch_routes_docs_replace` for why this is `is_ok()`: with
1941        // no configured account, `developer_metadata()` never returns
1942        // `Err` — it reports `Blocked`/`Failed` as a printed outcome.
1943        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1944    }
1945
1946    #[tokio::test]
1947    async fn dispatch_routes_sheets_delete_developer_metadata() {
1948        let guard = crate::drive::test_support::EnvGuard::take();
1949        let _dir = guard.clear_credentials();
1950
1951        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1952            command: sheets::SheetsSubcommands::DeleteDeveloperMetadata(
1953                sheets::developer_metadata::DeleteDeveloperMetadataCommand {
1954                    spreadsheet_id: "sheet-1".to_string(),
1955                    key: "owner".to_string(),
1956                    sheet: None,
1957                    dimension: None,
1958                    start: None,
1959                    end: None,
1960                    dry_run: false,
1961                    lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1962                    output: OutputFormat::Table,
1963                },
1964            ),
1965        });
1966        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1967    }
1968
1969    #[tokio::test]
1970    async fn dispatch_routes_sheets_search_developer_metadata() {
1971        // Read-only and ungated, like `sheets info`/`list-protections` —
1972        // needs the host redirect so it fails fast against a dead port
1973        // instead of trying the real Sheets API.
1974        let guard = crate::drive::test_support::EnvGuard::take();
1975        guard.redirect_api_hosts_to_a_dead_port();
1976
1977        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1978            command: sheets::SheetsSubcommands::SearchDeveloperMetadata(
1979                sheets::developer_metadata::SearchDeveloperMetadataCommand {
1980                    spreadsheet_id: "sheet-1".to_string(),
1981                    key: None,
1982                    sheet: None,
1983                    dimension: None,
1984                    start: None,
1985                    end: None,
1986                    output: OutputFormat::Table,
1987                },
1988            ),
1989        });
1990        assert!(cmd.dispatch(&dead_client()).await.is_err());
1991    }
1992
1993    #[tokio::test]
1994    async fn dispatch_routes_sheets_protect_range() {
1995        let guard = crate::drive::test_support::EnvGuard::take();
1996        let _dir = guard.clear_credentials();
1997
1998        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1999            command: sheets::SheetsSubcommands::ProtectRange(
2000                sheets::protection::ProtectRangeCommand {
2001                    spreadsheet_id: "sheet-1".to_string(),
2002                    range: Some("A1:B2".to_string()),
2003                    sheet: None,
2004                    whole_sheet: false,
2005                    description: Some("locked".to_string()),
2006                    warning_only: false,
2007                    editors: vec!["alice@example.com".to_string()],
2008                    write: helpers::StructureWriteArgs {
2009                        dry_run: false,
2010                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
2011                        output: OutputFormat::Table,
2012                    },
2013                },
2014            ),
2015        });
2016        assert!(cmd.dispatch(&dead_client()).await.is_ok());
2017    }
2018
2019    #[tokio::test]
2020    async fn dispatch_routes_sheets_update_protection() {
2021        let guard = crate::drive::test_support::EnvGuard::take();
2022        let _dir = guard.clear_credentials();
2023
2024        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
2025            command: sheets::SheetsSubcommands::UpdateProtection(
2026                sheets::protection::UpdateProtectionCommand {
2027                    spreadsheet_id: "sheet-1".to_string(),
2028                    range: Some("A1:B2".to_string()),
2029                    sheet: None,
2030                    whole_sheet: false,
2031                    description: Some("still locked".to_string()),
2032                    warning_only: Some(true),
2033                    add_editors: vec!["bob@example.com".to_string()],
2034                    remove_editors: vec!["alice@example.com".to_string()],
2035                    write: helpers::StructureWriteArgs {
2036                        dry_run: false,
2037                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
2038                        output: OutputFormat::Table,
2039                    },
2040                },
2041            ),
2042        });
2043        assert!(cmd.dispatch(&dead_client()).await.is_ok());
2044    }
2045
2046    #[tokio::test]
2047    async fn dispatch_routes_sheets_unprotect_range() {
2048        let guard = crate::drive::test_support::EnvGuard::take();
2049        let _dir = guard.clear_credentials();
2050
2051        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
2052            command: sheets::SheetsSubcommands::UnprotectRange(
2053                sheets::protection::UnprotectRangeCommand {
2054                    spreadsheet_id: "sheet-1".to_string(),
2055                    range: Some("A1:B2".to_string()),
2056                    sheet: None,
2057                    whole_sheet: false,
2058                    write: helpers::StructureWriteArgs {
2059                        dry_run: false,
2060                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
2061                        output: OutputFormat::Table,
2062                    },
2063                },
2064            ),
2065        });
2066        assert!(cmd.dispatch(&dead_client()).await.is_ok());
2067    }
2068
2069    #[tokio::test]
2070    async fn dispatch_routes_sheets_list_protections() {
2071        // Read-only and ungated, like `sheets info` — needs the host
2072        // redirect so it fails fast against a dead port instead of trying
2073        // the real Sheets API.
2074        let guard = crate::drive::test_support::EnvGuard::take();
2075        guard.redirect_api_hosts_to_a_dead_port();
2076
2077        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
2078            command: sheets::SheetsSubcommands::ListProtections(
2079                sheets::protection::ListProtectionsCommand {
2080                    spreadsheet_id: "sheet-1".to_string(),
2081                    output: OutputFormat::Table,
2082                },
2083            ),
2084        });
2085        assert!(cmd.dispatch(&dead_client()).await.is_err());
2086    }
2087
2088    /// A minimal `--cell-empty`-only rule (no `--background`/`--text-color`/
2089    /// `--bold`) so this stays a plain routing test: `conditional_format`'s
2090    /// own validation refuses it before any network call, and — like
2091    /// `ProtectRange` above — the mutating verb still reports that refusal
2092    /// as an `Ok` outcome rather than propagating an error, since it never
2093    /// reaches a dead-port network call at all.
2094    fn cell_empty_only_rule_args() -> sheets::conditional_format::ConditionalFormatRuleArgs {
2095        sheets::conditional_format::ConditionalFormatRuleArgs {
2096            number_between: None,
2097            number_not_between: None,
2098            number_greater: None,
2099            number_greater_eq: None,
2100            number_less: None,
2101            number_less_eq: None,
2102            number_eq: None,
2103            number_not_eq: None,
2104            text_contains: None,
2105            text_not_contains: None,
2106            text_starts_with: None,
2107            text_ends_with: None,
2108            text_eq: None,
2109            date_after: None,
2110            date_before: None,
2111            date_on: None,
2112            date_between: None,
2113            cell_empty: true,
2114            cell_not_empty: false,
2115            custom_formula: None,
2116            background: None,
2117            text_color: None,
2118            bold: None,
2119            gradient_min_color: None,
2120            gradient_max_color: None,
2121            gradient_mid_color: None,
2122            gradient_mid_type: None,
2123            gradient_mid_value: None,
2124        }
2125    }
2126
2127    #[tokio::test]
2128    async fn dispatch_routes_sheets_add_conditional_format() {
2129        let guard = crate::drive::test_support::EnvGuard::take();
2130        let _dir = guard.clear_credentials();
2131
2132        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
2133            command: sheets::SheetsSubcommands::AddConditionalFormat(Box::new(
2134                sheets::conditional_format::AddConditionalFormatCommand {
2135                    spreadsheet_id: "sheet-1".to_string(),
2136                    sheet: "Q1".to_string(),
2137                    ranges: vec!["A1:A10".to_string()],
2138                    index: None,
2139                    rule: cell_empty_only_rule_args(),
2140                    write: helpers::StructureWriteArgs {
2141                        dry_run: false,
2142                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
2143                        output: OutputFormat::Table,
2144                    },
2145                },
2146            )),
2147        });
2148        assert!(cmd.dispatch(&dead_client()).await.is_ok());
2149    }
2150
2151    #[tokio::test]
2152    async fn dispatch_routes_sheets_update_conditional_format() {
2153        let guard = crate::drive::test_support::EnvGuard::take();
2154        let _dir = guard.clear_credentials();
2155
2156        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
2157            command: sheets::SheetsSubcommands::UpdateConditionalFormat(Box::new(
2158                sheets::conditional_format::UpdateConditionalFormatCommand {
2159                    spreadsheet_id: "sheet-1".to_string(),
2160                    sheet: "Q1".to_string(),
2161                    index: 0,
2162                    ranges: vec!["A1:A10".to_string()],
2163                    rule: cell_empty_only_rule_args(),
2164                    dry_run: false,
2165                    lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
2166                    output: OutputFormat::Table,
2167                },
2168            )),
2169        });
2170        assert!(cmd.dispatch(&dead_client()).await.is_ok());
2171    }
2172
2173    #[tokio::test]
2174    async fn dispatch_routes_sheets_delete_conditional_format() {
2175        let guard = crate::drive::test_support::EnvGuard::take();
2176        let _dir = guard.clear_credentials();
2177
2178        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
2179            command: sheets::SheetsSubcommands::DeleteConditionalFormat(
2180                sheets::conditional_format::DeleteConditionalFormatCommand {
2181                    spreadsheet_id: "sheet-1".to_string(),
2182                    sheet: "Q1".to_string(),
2183                    index: 0,
2184                    dry_run: false,
2185                    lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
2186                    output: OutputFormat::Table,
2187                },
2188            ),
2189        });
2190        assert!(cmd.dispatch(&dead_client()).await.is_ok());
2191    }
2192
2193    #[tokio::test]
2194    async fn dispatch_routes_sheets_list_conditional_formats() {
2195        // Read-only and ungated, like `list-protections` — needs the host
2196        // redirect so it fails fast against a dead port instead of trying
2197        // the real Sheets API.
2198        let guard = crate::drive::test_support::EnvGuard::take();
2199        guard.redirect_api_hosts_to_a_dead_port();
2200
2201        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
2202            command: sheets::SheetsSubcommands::ListConditionalFormats(
2203                sheets::conditional_format::ListConditionalFormatsCommand {
2204                    spreadsheet_id: "sheet-1".to_string(),
2205                    output: OutputFormat::Table,
2206                },
2207            ),
2208        });
2209        assert!(cmd.dispatch(&dead_client()).await.is_err());
2210    }
2211
2212    #[tokio::test]
2213    async fn dispatch_routes_sheets_set_basic_filter() {
2214        let guard = crate::drive::test_support::EnvGuard::take();
2215        let _dir = guard.clear_credentials();
2216
2217        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
2218            command: sheets::SheetsSubcommands::SetBasicFilter(
2219                sheets::filter::SetBasicFilterCommand {
2220                    spreadsheet_id: "sheet-1".to_string(),
2221                    sheet: "Q1".to_string(),
2222                    range: "A1:D10".to_string(),
2223                    sort_by: vec!["0:asc".to_string()],
2224                    hide_values: vec!["1:Closed".to_string()],
2225                    write: helpers::StructureWriteArgs {
2226                        dry_run: false,
2227                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
2228                        output: OutputFormat::Table,
2229                    },
2230                },
2231            ),
2232        });
2233        assert!(cmd.dispatch(&dead_client()).await.is_ok());
2234    }
2235
2236    #[tokio::test]
2237    async fn dispatch_routes_sheets_clear_basic_filter() {
2238        let guard = crate::drive::test_support::EnvGuard::take();
2239        let _dir = guard.clear_credentials();
2240
2241        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
2242            command: sheets::SheetsSubcommands::ClearBasicFilter(
2243                sheets::filter::ClearBasicFilterCommand {
2244                    spreadsheet_id: "sheet-1".to_string(),
2245                    sheet: "Q1".to_string(),
2246                    write: helpers::StructureWriteArgs {
2247                        dry_run: false,
2248                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
2249                        output: OutputFormat::Table,
2250                    },
2251                },
2252            ),
2253        });
2254        assert!(cmd.dispatch(&dead_client()).await.is_ok());
2255    }
2256
2257    #[tokio::test]
2258    async fn dispatch_routes_sheets_add_filter_view() {
2259        let guard = crate::drive::test_support::EnvGuard::take();
2260        let _dir = guard.clear_credentials();
2261
2262        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
2263            command: sheets::SheetsSubcommands::AddFilterView(
2264                sheets::filter::AddFilterViewCommand {
2265                    spreadsheet_id: "sheet-1".to_string(),
2266                    sheet: "Q1".to_string(),
2267                    range: "A1:D10".to_string(),
2268                    title: Some("Open only".to_string()),
2269                    sort_by: Vec::new(),
2270                    hide_values: Vec::new(),
2271                    write: helpers::StructureWriteArgs {
2272                        dry_run: false,
2273                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
2274                        output: OutputFormat::Table,
2275                    },
2276                },
2277            ),
2278        });
2279        assert!(cmd.dispatch(&dead_client()).await.is_ok());
2280    }
2281
2282    #[tokio::test]
2283    async fn dispatch_routes_sheets_update_filter_view() {
2284        let guard = crate::drive::test_support::EnvGuard::take();
2285        let _dir = guard.clear_credentials();
2286
2287        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
2288            command: sheets::SheetsSubcommands::UpdateFilterView(
2289                sheets::filter::UpdateFilterViewCommand {
2290                    spreadsheet_id: "sheet-1".to_string(),
2291                    filter_view_id: 7,
2292                    sheet: None,
2293                    range: None,
2294                    title: Some("Renamed".to_string()),
2295                    sort_by: Vec::new(),
2296                    hide_values: Vec::new(),
2297                    clear_sort: false,
2298                    clear_criteria: false,
2299                    write: helpers::StructureWriteArgs {
2300                        dry_run: false,
2301                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
2302                        output: OutputFormat::Table,
2303                    },
2304                },
2305            ),
2306        });
2307        assert!(cmd.dispatch(&dead_client()).await.is_ok());
2308    }
2309
2310    #[tokio::test]
2311    async fn dispatch_routes_sheets_delete_filter_view() {
2312        let guard = crate::drive::test_support::EnvGuard::take();
2313        let _dir = guard.clear_credentials();
2314
2315        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
2316            command: sheets::SheetsSubcommands::DeleteFilterView(
2317                sheets::filter::DeleteFilterViewCommand {
2318                    spreadsheet_id: "sheet-1".to_string(),
2319                    filter_view_id: 7,
2320                    write: helpers::StructureWriteArgs {
2321                        dry_run: false,
2322                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
2323                        output: OutputFormat::Table,
2324                    },
2325                },
2326            ),
2327        });
2328        assert!(cmd.dispatch(&dead_client()).await.is_ok());
2329    }
2330
2331    #[tokio::test]
2332    async fn dispatch_routes_sheets_list_filter_views() {
2333        // Read-only and ungated, like `list-protections` — needs the host
2334        // redirect so it fails fast against a dead port instead of trying
2335        // the real Sheets API.
2336        let guard = crate::drive::test_support::EnvGuard::take();
2337        guard.redirect_api_hosts_to_a_dead_port();
2338
2339        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
2340            command: sheets::SheetsSubcommands::ListFilterViews(
2341                sheets::filter::ListFilterViewsCommand {
2342                    spreadsheet_id: "sheet-1".to_string(),
2343                    output: OutputFormat::Table,
2344                },
2345            ),
2346        });
2347        assert!(cmd.dispatch(&dead_client()).await.is_err());
2348    }
2349
2350    #[tokio::test]
2351    async fn dispatch_routes_search() {
2352        let cmd = DriveSubcommands::Search(search::SearchCommand {
2353            query: "name contains 'x'".to_string(),
2354            limit: 10,
2355            output: OutputFormat::Table,
2356        });
2357        assert!(cmd.dispatch(&dead_client()).await.is_err());
2358    }
2359
2360    #[tokio::test]
2361    async fn dispatch_routes_read() {
2362        let cmd = DriveSubcommands::Read(read::ReadCommand {
2363            file_id: "f1".to_string(),
2364            content: false,
2365            export_mime_type: None,
2366            out_file: None,
2367            verify: false,
2368            output: OutputFormat::Table,
2369        });
2370        assert!(cmd.dispatch(&dead_client()).await.is_err());
2371    }
2372
2373    #[tokio::test]
2374    async fn dispatch_routes_sync() {
2375        let dir = tempfile::tempdir().unwrap();
2376        let cmd = DriveSubcommands::Sync(sync::SyncCommand {
2377            folder_id: "root".into(),
2378            dest: dir.path().into(),
2379            export_mime_type: None,
2380            verify: false,
2381            dry_run: true,
2382            output: OutputFormat::Json,
2383        });
2384        assert!(cmd.dispatch(&dead_client()).await.is_err());
2385        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
2386    }
2387
2388    #[test]
2389    fn sync_parser_requires_destination_and_accepts_options() {
2390        use clap::Parser;
2391        assert!(DriveCommand::try_parse_from(["drive", "sync", "root"]).is_err());
2392        let cmd = DriveCommand::try_parse_from([
2393            "drive",
2394            "sync",
2395            "root",
2396            "--dest",
2397            "mirror",
2398            "--verify",
2399            "--dry-run",
2400            "--export-mime-type",
2401            "application/pdf",
2402            "-o",
2403            "json",
2404        ])
2405        .unwrap();
2406        let DriveSubcommands::Sync(sync) = cmd.command else {
2407            panic!("expected sync"); // omni-dev: coverage ignore-line reason="guards this test's assumption; the parse above always yields DriveSubcommands::Sync for a sync argv"
2408        };
2409        assert!(sync.verify && sync.dry_run);
2410        assert_eq!(sync.export_mime_type.as_deref(), Some("application/pdf"));
2411    }
2412
2413    #[tokio::test]
2414    async fn dispatch_routes_dedupe() {
2415        let cmd = DriveSubcommands::Dedupe(dedupe::DedupeCommand {
2416            query: "name contains 'x'".to_string(),
2417            limit: 10,
2418            output: OutputFormat::Table,
2419        });
2420        assert!(cmd.dispatch(&dead_client()).await.is_err());
2421    }
2422
2423    #[tokio::test]
2424    async fn dispatch_routes_create() {
2425        // Unlike rename/move (whose engine fns return `Result` and
2426        // propagate a network error via `?`), `create`'s engine fn always
2427        // returns an `Ok`-shaped `CreateOutcome` — a fetch failure against
2428        // the dead client becomes an embedded `Failed{detail}`, matching
2429        // the exit-0-regardless-of-outcome convention (ADR-0071 §12), not
2430        // a dispatch-level error. Needs env isolation (unlike the other
2431        // dispatch_routes_* tests): `create`'s CLI layer resolves the
2432        // active account's write_permissions.rules via `Settings::load()`,
2433        // so without a clean $HOME it reads whatever real
2434        // ~/.omni-dev/settings.json this process has.
2435        let guard = crate::drive::test_support::EnvGuard::take();
2436        let _dir = guard.clear_credentials();
2437
2438        let cmd = DriveSubcommands::Create(create::CreateCommand {
2439            name: "New File".to_string(),
2440            parent: "parent-1".to_string(),
2441            folder: false,
2442            mime_type: None,
2443            dry_run: false,
2444            output: OutputFormat::Table,
2445        });
2446        assert!(cmd.dispatch(&dead_client()).await.is_ok());
2447    }
2448
2449    #[tokio::test]
2450    async fn dispatch_routes_upload() {
2451        // Same env-isolation and exit-0-regardless-of-outcome reasoning as
2452        // dispatch_routes_create.
2453        let guard = crate::drive::test_support::EnvGuard::take();
2454        let _dir = guard.clear_credentials();
2455        let content_dir = tempfile::tempdir().unwrap();
2456        let local_path = content_dir.path().join("upload-me.txt");
2457        std::fs::write(&local_path, b"content").unwrap();
2458
2459        let cmd = DriveSubcommands::Upload(upload::UploadCommand {
2460            local_path,
2461            parent: "parent-1".to_string(),
2462            name: None,
2463            mime_type: None,
2464            dry_run: false,
2465            output: OutputFormat::Table,
2466        });
2467        assert!(cmd.dispatch(&dead_client()).await.is_ok());
2468    }
2469
2470    #[tokio::test]
2471    async fn dispatch_routes_edit() {
2472        // Same env-isolation and exit-0-regardless-of-outcome reasoning as
2473        // dispatch_routes_create/dispatch_routes_upload.
2474        let guard = crate::drive::test_support::EnvGuard::take();
2475        let _dir = guard.clear_credentials();
2476        let content_dir = tempfile::tempdir().unwrap();
2477        let content_path = content_dir.path().join("new-content.txt");
2478        std::fs::write(&content_path, b"content").unwrap();
2479
2480        let cmd = DriveSubcommands::Edit(edit::EditCommand {
2481            file_id: "f1".to_string(),
2482            content: content_path.to_str().unwrap().to_string(),
2483            mime_type: None,
2484            dry_run: false,
2485            lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
2486            output: OutputFormat::Table,
2487        });
2488        assert!(cmd.dispatch(&dead_client()).await.is_ok());
2489    }
2490
2491    #[tokio::test]
2492    async fn dispatch_routes_rename() {
2493        let cmd = DriveSubcommands::Rename(rename::RenameCommand {
2494            file_id: "f1".to_string(),
2495            new_name: "New Name".to_string(),
2496            dry_run: false,
2497            output: OutputFormat::Table,
2498        });
2499        assert!(cmd.dispatch(&dead_client()).await.is_err());
2500    }
2501
2502    #[tokio::test]
2503    async fn dispatch_routes_move() {
2504        let cmd = DriveSubcommands::Move(move_file::MoveCommand {
2505            file_ids: vec!["f1".to_string()],
2506            to: "dest1".to_string(),
2507            allow_visibility_increase: false,
2508            allow_visibility_decrease: false,
2509            allow_drive_boundary_crossing: false,
2510            dry_run: false,
2511            output: OutputFormat::Table,
2512        });
2513        assert!(cmd.dispatch(&dead_client()).await.is_err());
2514    }
2515}