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 upload;
21
22use anyhow::Result;
23use clap::{Parser, Subcommand};
24
25use crate::drive::account::DRIVE_ACCOUNT_ENV;
26use crate::drive::client::DriveClient;
27
28/// Drive: search, read, rename, and move Google Drive files via OAuth2.
29#[derive(Parser)]
30pub struct DriveCommand {
31    /// Selects a named Drive account configured in
32    /// `~/.omni-dev/settings.json` (AWS-CLI style, mirrors the top-level
33    /// `--profile`) for this invocation.
34    ///
35    /// Orthogonal to `--profile`: switching the Drive account never changes
36    /// which profile is active, and vice versa (see
37    /// [ADR-0066](../../../docs/adrs/adr-0066.md),
38    /// [ADR-0069](../../../docs/adrs/adr-0069.md)). Overrides
39    /// `OMNI_DEV_DRIVE_ACCOUNT`. Scoped to the `drive` subtree — not usable
40    /// before the `drive` subcommand name, only after it, so it can't
41    /// collide with an unrelated subcommand's own `--account` flag.
42    #[arg(long, global = true, value_name = "NAME")]
43    pub account: Option<String>,
44    /// The Drive subcommand to execute.
45    #[command(subcommand)]
46    pub command: DriveSubcommands,
47}
48
49/// Drive subcommands.
50#[derive(Subcommand)]
51pub enum DriveSubcommands {
52    /// Manages Drive OAuth2 credentials.
53    Auth(auth::AuthCommand),
54    /// Manages named Drive accounts.
55    Account(account::AccountCommand),
56    /// Searches Drive files.
57    Search(search::SearchCommand),
58    /// Reads a single Drive file's metadata or content.
59    Read(read::ReadCommand),
60    /// Finds Drive files sharing the same content hash.
61    Dedupe(dedupe::DedupeCommand),
62    /// Creates a new file or folder, gated by the folder write-permission
63    /// rules (issue #1574). Requires the `drive.file` or `drive` scope
64    /// (`drive auth login --write-file`/`--write-full`).
65    Create(create::CreateCommand),
66    /// Uploads local content as a new file, gated by the folder
67    /// write-permission rules (issue #1574). Requires the `drive.file` or
68    /// `drive` scope (`drive auth login --write-file`/`--write-full`).
69    Upload(upload::UploadCommand),
70    /// Replaces an existing file's content, gated by the
71    /// write-permission rules (issues #1574, #1612). Requires the `drive.file`
72    /// scope if `omni-dev` created the file, or the unrestricted `drive`
73    /// scope for any pre-existing file (`drive auth login --write-file`
74    /// or `--write-full`).
75    Edit(edit::EditCommand),
76    /// Backs up a file and mints a Touch ID-authorised lease token,
77    /// required by `drive edit` and (in later phases) every other
78    /// content-mutating verb ([ADR-0080](../../docs/adrs/adr-0080.md)).
79    Lease(lease::LeaseCommand),
80    /// Renames a single Drive file. Requires the `drive.metadata` scope
81    /// (`drive auth login --write`).
82    Rename(rename::RenameCommand),
83    /// Moves one or more Drive files into a destination folder. Requires
84    /// the `drive.metadata` scope (`drive auth login --write`).
85    Move(move_file::MoveCommand),
86    /// Inspects the write-permission rules gating `drive
87    /// create`/`upload`/`edit`, `drive sheets
88    /// write`/`append`/`clear`/`create` and `drive docs
89    /// replace`/`append`/`create` (issues #1574, #1589, #1612, #1615).
90    Permissions(permissions::PermissionsCommand),
91    /// Reads the structure and text of a Google Doc via the Docs v1 API
92    /// (issue #1615).
93    Docs(docs::DocsCommand),
94    /// Reads and writes the cells of a Google Sheet via the Sheets v4 API
95    /// (issue #1589).
96    Sheets(sheets::SheetsCommand),
97}
98
99impl DriveCommand {
100    /// Executes the Drive command. `auth`/`account` must run without a
101    /// resolved client (they manage credentials/account selection);
102    /// `Permissions` and `Lease` resolve their own client lazily, per leaf
103    /// (see the comments below); every other subcommand resolves one
104    /// shared client **once** here and threads it down via
105    /// [`DriveSubcommands::dispatch`].
106    pub async fn execute(self) -> Result<()> {
107        // Propagates --account to DRIVE_ACCOUNT_ENV for the duration of this
108        // call only (crate::drive::account::resolve_account reads it),
109        // mirroring GmailCommand::execute. Set *before* matching Auth/
110        // Account: those subcommands need the resolved account too (e.g.
111        // `drive auth login --account work`). The guard restores the
112        // ambient value (or removes the var) on drop at the end of this
113        // function, so execute() is safe to call more than once per process
114        // (#1538).
115        let _account_guard = self
116            .account
117            .as_ref()
118            .map(|account| crate::utils::env::ScopedEnvVar::set(DRIVE_ACCOUNT_ENV, account));
119
120        match self.command {
121            DriveSubcommands::Auth(cmd) => cmd.execute().await,
122            DriveSubcommands::Account(cmd) => cmd.execute(),
123            // Permissions' three leaves have mixed client needs (`show` is
124            // config-only, `lookup-folder`/`check` both call the Drive
125            // API) — like Auth, it resolves its own client lazily per leaf
126            // rather than sharing the single eager resolution below.
127            DriveSubcommands::Permissions(cmd) => cmd.execute().await,
128            // `Lease`'s four leaves have mixed client needs: `acquire`/
129            // `restore` always call the Drive API, `release` never does
130            // (issue #1685 — it must work with no credentials at all, since
131            // it's how a lease gets stood down after `drive auth logout`),
132            // and `prune --dry-run` is a pure ledger read that doesn't
133            // either (issue #1743). Like `Permissions`, it resolves its own
134            // client lazily per leaf rather than sharing the eager
135            // resolution below — `helpers::create_client` is passed as a
136            // thunk so a leaf that never needs one never pays for
137            // credential resolution.
138            DriveSubcommands::Lease(cmd) => cmd.execute(helpers::create_client).await,
139            command => {
140                let client = helpers::create_client()?;
141                command.dispatch(&client).await
142            }
143        }
144    }
145}
146
147impl DriveSubcommands {
148    /// Routes a non-`Auth`/`Account`/`Permissions`/`Lease` subcommand
149    /// against the shared client. Those four arms are unreachable: all are
150    /// handled before client resolution in [`DriveCommand::execute`].
151    async fn dispatch(self, client: &DriveClient) -> Result<()> {
152        match self {
153            Self::Auth(_) => unreachable!("Auth is dispatched before client resolution"),
154            Self::Account(_) => unreachable!("Account is dispatched before client resolution"),
155            Self::Permissions(_) => {
156                unreachable!("Permissions is dispatched before client resolution")
157            }
158            Self::Lease(_) => unreachable!("Lease is dispatched before client resolution"),
159            Self::Search(cmd) => cmd.execute(client).await,
160            Self::Read(cmd) => cmd.execute(client).await,
161            Self::Dedupe(cmd) => cmd.execute(client).await,
162            Self::Create(cmd) => cmd.execute(client).await,
163            Self::Upload(cmd) => cmd.execute(client).await,
164            Self::Edit(cmd) => cmd.execute(client).await,
165            Self::Rename(cmd) => cmd.execute(client).await,
166            Self::Move(cmd) => cmd.execute(client).await,
167            Self::Docs(cmd) => cmd.execute(client).await,
168            Self::Sheets(cmd) => cmd.execute(client).await,
169        }
170    }
171}
172
173#[cfg(test)]
174#[allow(clippy::unwrap_used)]
175mod tests {
176    use super::*;
177    use crate::cli::drive::format::OutputFormat;
178    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
179    use crate::utils::secret::Secret;
180
181    fn dead_credentials() -> DriveCredentials {
182        DriveCredentials {
183            client_id: "client".to_string(),
184            client_secret: Secret::new("secret"),
185            refresh_token: Secret::new("refresh"),
186            scope: DriveGrantedScopes::READONLY,
187        }
188    }
189
190    fn dead_client() -> DriveClient {
191        DriveClient::new("http://127.0.0.1:1", &dead_credentials()).unwrap()
192    }
193
194    /// The `StructureWriteArgs` every dispatch-routing test below wants:
195    /// live (non-dry-run), no lease, table output.
196    fn no_write_args() -> helpers::StructureWriteArgs {
197        helpers::StructureWriteArgs {
198            dry_run: false,
199            lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
200            output: OutputFormat::Table,
201        }
202    }
203
204    #[tokio::test]
205    async fn execute_routes_auth_subcommand_and_surfaces_missing_credentials() {
206        let guard = crate::drive::test_support::EnvGuard::take();
207        let _dir = guard.clear_credentials();
208
209        let cmd = DriveCommand {
210            account: None,
211            command: DriveSubcommands::Auth(auth::AuthCommand {
212                command: auth::AuthSubcommands::Status(auth::StatusCommand { all: false }),
213            }),
214        };
215        let err = cmd.execute().await.unwrap_err();
216        assert!(err.to_string().contains("not configured"));
217    }
218
219    #[tokio::test]
220    async fn execute_non_auth_subcommand_errors_when_credentials_missing() {
221        let guard = crate::drive::test_support::EnvGuard::take();
222        let _dir = guard.clear_credentials();
223
224        let cmd = DriveCommand {
225            account: None,
226            command: DriveSubcommands::Search(search::SearchCommand {
227                query: "name contains 'report'".to_string(),
228                limit: 10,
229                output: OutputFormat::Table,
230            }),
231        };
232        let err = cmd.execute().await.unwrap_err();
233        assert!(err.to_string().contains("not configured"));
234    }
235
236    /// `lease release` must reach the ledger with no credentials configured
237    /// (issue #1685) — it never calls the lazily-resolved client at all.
238    ///
239    /// Drives `LeaseCommand::run` directly rather than through
240    /// `DriveCommand::execute` — this token resolves `NoSuchToken`, whose
241    /// exit code is non-zero (issue #1775), and `execute` would
242    /// `std::process::exit` on that, aborting the test binary. Calling
243    /// `run` with a panicking client thunk is also a strictly stronger
244    /// check of "never calls the lazily-resolved client" than the old
245    /// indirect proxy of "would fail if it tried, since credentials are
246    /// missing".
247    #[tokio::test]
248    async fn execute_lease_release_needs_no_credentials() {
249        let guard = crate::drive::test_support::EnvGuard::take();
250        let dir = guard.clear_credentials();
251        let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
252
253        let cmd = lease::LeaseCommand::release_for_test("no-such-token".to_string());
254        let code = cmd
255            .run(|| panic!("release must never resolve a client"))
256            .await
257            .unwrap();
258        assert_eq!(code, 1, "NoSuchToken must be a non-zero exit");
259
260        assert_eq!(audit.verdicts(), vec!["release-no-such-token".to_string()]);
261    }
262
263    /// `lease prune --dry-run` is a pure ledger read and must work with no
264    /// credentials configured (issue #1743), the same way `release` does —
265    /// it never calls the lazily-resolved client either.
266    #[tokio::test]
267    async fn execute_lease_prune_dry_run_needs_no_credentials() {
268        let guard = crate::drive::test_support::EnvGuard::take();
269        let _dir = guard.clear_credentials();
270
271        let cmd = DriveCommand {
272            account: None,
273            command: DriveSubcommands::Lease(lease::LeaseCommand::prune_for_test("1h", true)),
274        };
275        cmd.execute().await.unwrap();
276    }
277
278    /// `acquire` always needs the Drive API, so it still resolves a client
279    /// lazily on first use and a missing credential is reported then.
280    #[tokio::test]
281    async fn execute_lease_acquire_still_errors_when_credentials_missing() {
282        let guard = crate::drive::test_support::EnvGuard::take();
283        let _dir = guard.clear_credentials();
284
285        let cmd = DriveCommand {
286            account: None,
287            command: DriveSubcommands::Lease(lease::LeaseCommand::acquire_for_test(
288                "file-1".to_string(),
289            )),
290        };
291        let err = cmd.execute().await.unwrap_err();
292        assert!(err.to_string().contains("not configured"), "{err}");
293    }
294
295    #[tokio::test]
296    async fn execute_restores_account_env_var_after_return() {
297        let guard = crate::drive::test_support::EnvGuard::take();
298        let _dir = guard.clear_credentials();
299
300        let cmd = DriveCommand {
301            account: Some("work".to_string()),
302            command: DriveSubcommands::Account(account::AccountCommand {
303                command: account::AccountSubcommands::List(account::list::ListCommand {
304                    output: OutputFormat::Table,
305                }),
306            }),
307        };
308        cmd.execute().await.unwrap();
309        assert_eq!(std::env::var(DRIVE_ACCOUNT_ENV).ok(), None);
310    }
311
312    #[tokio::test]
313    async fn execute_restores_previous_account_env_var_after_return() {
314        let guard = crate::drive::test_support::EnvGuard::take();
315        let _dir = guard.clear_credentials();
316        std::env::set_var(DRIVE_ACCOUNT_ENV, "personal");
317
318        let cmd = DriveCommand {
319            account: Some("work".to_string()),
320            command: DriveSubcommands::Account(account::AccountCommand {
321                command: account::AccountSubcommands::List(account::list::ListCommand {
322                    output: OutputFormat::Table,
323                }),
324            }),
325        };
326        cmd.execute().await.unwrap();
327        assert_eq!(
328            std::env::var(DRIVE_ACCOUNT_ENV).ok().as_deref(),
329            Some("personal")
330        );
331    }
332
333    #[tokio::test]
334    async fn execute_does_not_leak_account_across_sequential_calls() {
335        let guard = crate::drive::test_support::EnvGuard::take();
336        let _dir = guard.clear_credentials();
337
338        let account_list_cmd = || DriveCommand {
339            account: None,
340            command: DriveSubcommands::Account(account::AccountCommand {
341                command: account::AccountSubcommands::List(account::list::ListCommand {
342                    output: OutputFormat::Table,
343                }),
344            }),
345        };
346
347        let first = DriveCommand {
348            account: Some("alpha".to_string()),
349            ..account_list_cmd()
350        };
351        first.execute().await.unwrap();
352        assert_eq!(std::env::var(DRIVE_ACCOUNT_ENV).ok(), None);
353
354        // If the first call's value had leaked, this second call — which
355        // omits --account entirely — would still see it via the env var.
356        account_list_cmd().execute().await.unwrap();
357        assert_eq!(std::env::var(DRIVE_ACCOUNT_ENV).ok(), None);
358    }
359
360    #[tokio::test]
361    async fn execute_absent_account_leaves_ambient_env_var_untouched() {
362        let guard = crate::drive::test_support::EnvGuard::take();
363        let _dir = guard.clear_credentials();
364        std::env::set_var(DRIVE_ACCOUNT_ENV, "personal");
365
366        let cmd = DriveCommand {
367            account: None,
368            command: DriveSubcommands::Account(account::AccountCommand {
369                command: account::AccountSubcommands::List(account::list::ListCommand {
370                    output: OutputFormat::Table,
371                }),
372            }),
373        };
374        cmd.execute().await.unwrap();
375        assert_eq!(
376            std::env::var(DRIVE_ACCOUNT_ENV).ok().as_deref(),
377            Some("personal")
378        );
379    }
380
381    #[tokio::test]
382    async fn execute_routes_account_list_without_client_resolution() {
383        let guard = crate::drive::test_support::EnvGuard::take();
384        let _dir = guard.clear_credentials();
385
386        let cmd = DriveCommand {
387            account: None,
388            command: DriveSubcommands::Account(account::AccountCommand {
389                command: account::AccountSubcommands::List(account::list::ListCommand {
390                    output: OutputFormat::Table,
391                }),
392            }),
393        };
394        cmd.execute().await.unwrap();
395    }
396
397    #[tokio::test]
398    async fn execute_routes_permissions_show_without_client_resolution() {
399        let guard = crate::drive::test_support::EnvGuard::take();
400        let _dir = guard.clear_credentials();
401
402        let cmd = DriveCommand {
403            account: None,
404            command: DriveSubcommands::Permissions(permissions::PermissionsCommand {
405                command: permissions::PermissionsSubcommands::Show(
406                    permissions::show::ShowCommand {
407                        output: OutputFormat::Table,
408                    },
409                ),
410            }),
411        };
412        cmd.execute().await.unwrap();
413    }
414
415    #[tokio::test]
416    async fn execute_routes_permissions_check_and_surfaces_missing_credentials() {
417        let guard = crate::drive::test_support::EnvGuard::take();
418        let _dir = guard.clear_credentials();
419
420        let cmd = DriveCommand {
421            account: None,
422            command: DriveSubcommands::Permissions(permissions::PermissionsCommand {
423                command: permissions::PermissionsSubcommands::Check(
424                    permissions::check::CheckCommand {
425                        id: "f1".to_string(),
426                        operation: permissions::check::OperationArg::Read,
427                        output: OutputFormat::Table,
428                    },
429                ),
430            }),
431        };
432        let err = cmd.execute().await.unwrap_err();
433        assert!(err.to_string().contains("not configured"));
434    }
435
436    #[tokio::test]
437    async fn dispatch_routes_docs_info() {
438        // Like the Sheets routing tests, this needs an env guard: a
439        // `DocsClient` with no `DOCS_API_URL` set resolves the *real*
440        // `docs.googleapis.com`, so without the redirect this test would
441        // send a request to Google.
442        let guard = crate::drive::test_support::EnvGuard::take();
443        guard.redirect_api_hosts_to_a_dead_port();
444
445        let cmd = DriveSubcommands::Docs(docs::DocsCommand {
446            command: docs::DocsSubcommands::Info(docs::info::InfoCommand {
447                document_id: "d1".to_string(),
448                output: OutputFormat::Table,
449            }),
450        });
451        assert!(cmd.dispatch(&dead_client()).await.is_err());
452    }
453
454    #[tokio::test]
455    async fn dispatch_routes_docs_read() {
456        let guard = crate::drive::test_support::EnvGuard::take();
457        guard.redirect_api_hosts_to_a_dead_port();
458
459        let cmd = DriveSubcommands::Docs(docs::DocsCommand {
460            command: docs::DocsSubcommands::Read(docs::read::ReadCommand {
461                document_id: "d1".to_string(),
462                tab: None,
463                suggestions_view: docs::read::SuggestionsViewArg::Default,
464                output: OutputFormat::Table,
465            }),
466        });
467        assert!(cmd.dispatch(&dead_client()).await.is_err());
468    }
469
470    /// The Docs *write* verbs return `Ok(())` even when the API is
471    /// unreachable, which is the exit-code convention rather than an
472    /// oversight: a `Blocked`/`Failed`/`StaleRevision` outcome rides the
473    /// **output**, and `$?` stays 0 (ADR-0076 §13, and ADR-0070 §10 before
474    /// it). So these assert `is_ok()` — which still proves routing, since
475    /// every other arm of `dispatch` would surface the dead port as an
476    /// `Err` — and in doing so pin the convention itself.
477    ///
478    /// `clear_credentials` is what makes that deterministic: without it
479    /// `active_account_rules` reads the developer's real
480    /// `~/.omni-dev/settings.json`, so the result depends on the machine.
481    #[tokio::test]
482    async fn dispatch_routes_docs_replace() {
483        let guard = crate::drive::test_support::EnvGuard::take();
484        guard.redirect_api_hosts_to_a_dead_port();
485        let _dir = guard.clear_credentials();
486
487        let cmd = DriveSubcommands::Docs(docs::DocsCommand {
488            command: docs::DocsSubcommands::Replace(docs::write::ReplaceCommand {
489                document_id: "d1".to_string(),
490                search: "a".to_string(),
491                replace: "b".to_string(),
492                ignore_case: false,
493                dry_run: false,
494                lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
495                output: OutputFormat::Table,
496            }),
497        });
498        assert!(cmd.dispatch(&dead_client()).await.is_ok());
499    }
500
501    #[tokio::test]
502    async fn dispatch_routes_docs_append() {
503        let guard = crate::drive::test_support::EnvGuard::take();
504        guard.redirect_api_hosts_to_a_dead_port();
505
506        let _dir = guard.clear_credentials();
507
508        let cmd = DriveSubcommands::Docs(docs::DocsCommand {
509            command: docs::DocsSubcommands::Append(docs::write::AppendCommand {
510                document_id: "d1".to_string(),
511                text: Some("x".to_string()),
512                text_file: None,
513                dry_run: false,
514                lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
515                output: OutputFormat::Table,
516            }),
517        });
518        // See `dispatch_routes_docs_replace` for why this is `is_ok()`.
519        assert!(cmd.dispatch(&dead_client()).await.is_ok());
520    }
521
522    #[tokio::test]
523    async fn dispatch_routes_docs_create() {
524        let guard = crate::drive::test_support::EnvGuard::take();
525        guard.redirect_api_hosts_to_a_dead_port();
526
527        let _dir = guard.clear_credentials();
528
529        let cmd = DriveSubcommands::Docs(docs::DocsCommand {
530            command: docs::DocsSubcommands::Create(docs::create::CreateCommand {
531                name: "n".to_string(),
532                parent: "folder-1".to_string(),
533                text: None,
534                text_file: None,
535                dry_run: false,
536                output: OutputFormat::Table,
537            }),
538        });
539        // See `dispatch_routes_docs_replace` for why this is `is_ok()`.
540        assert!(cmd.dispatch(&dead_client()).await.is_ok());
541    }
542
543    #[tokio::test]
544    async fn dispatch_routes_sheets_info() {
545        // Unlike every other routing test, this one needs an env guard: a
546        // `SheetsClient` with no `SHEETS_API_URL` set resolves the *real*
547        // `sheets.googleapis.com`, so without the redirect this test would
548        // send a request to Google.
549        let guard = crate::drive::test_support::EnvGuard::take();
550        guard.redirect_api_hosts_to_a_dead_port();
551
552        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
553            command: sheets::SheetsSubcommands::Info(sheets::info::InfoCommand {
554                spreadsheet_id: "s1".to_string(),
555                output: OutputFormat::Table,
556            }),
557        });
558        assert!(cmd.dispatch(&dead_client()).await.is_err());
559    }
560
561    #[tokio::test]
562    async fn dispatch_routes_sheets_read() {
563        let guard = crate::drive::test_support::EnvGuard::take();
564        guard.redirect_api_hosts_to_a_dead_port();
565
566        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
567            command: sheets::SheetsSubcommands::Read(sheets::read::ReadCommand {
568                spreadsheet_id: "s1".to_string(),
569                range: None,
570                sheet: None,
571                render: sheets::read::RenderArg::Formatted,
572                output: OutputFormat::Table,
573            }),
574        });
575        assert!(cmd.dispatch(&dead_client()).await.is_err());
576    }
577
578    #[tokio::test]
579    async fn dispatch_routes_sheets_write() {
580        let guard = crate::drive::test_support::EnvGuard::take();
581        guard.redirect_api_hosts_to_a_dead_port();
582        let _dir = guard.clear_credentials();
583
584        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
585            command: sheets::SheetsSubcommands::Write(sheets::write::WriteCommand {
586                spreadsheet_id: "s1".to_string(),
587                range: Some("A1".to_string()),
588                sheet: None,
589                values: "/definitely/not/here.csv".to_string(),
590                values_format: sheets::values::ValuesFormat::Auto,
591                input: sheets::write::InputArg::UserEntered,
592                dry_run: false,
593                lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
594                output: OutputFormat::Table,
595            }),
596        });
597        assert!(cmd.dispatch(&dead_client()).await.is_err());
598    }
599
600    #[tokio::test]
601    async fn dispatch_routes_sheets_append() {
602        let guard = crate::drive::test_support::EnvGuard::take();
603        guard.redirect_api_hosts_to_a_dead_port();
604        let _dir = guard.clear_credentials();
605
606        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
607            command: sheets::SheetsSubcommands::Append(sheets::write::AppendCommand {
608                spreadsheet_id: "s1".to_string(),
609                range: Some("A1".to_string()),
610                sheet: None,
611                values: "/definitely/not/here.csv".to_string(),
612                values_format: sheets::values::ValuesFormat::Auto,
613                input: sheets::write::InputArg::UserEntered,
614                dry_run: false,
615                lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
616                output: OutputFormat::Table,
617            }),
618        });
619        assert!(cmd.dispatch(&dead_client()).await.is_err());
620    }
621
622    #[tokio::test]
623    async fn dispatch_routes_sheets_clear() {
624        let guard = crate::drive::test_support::EnvGuard::take();
625        guard.redirect_api_hosts_to_a_dead_port();
626        let _dir = guard.clear_credentials();
627
628        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
629            command: sheets::SheetsSubcommands::Clear(sheets::write::ClearCommand {
630                spreadsheet_id: "s1".to_string(),
631                range: Some("A1".to_string()),
632                sheet: None,
633                dry_run: false,
634                lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
635                output: OutputFormat::Table,
636            }),
637        });
638        // Unlike its siblings this returns `Ok`, and that is the contract,
639        // not an accident: `write`/`append` fail here only because reading
640        // `--values` fails *before* the engine. `clear` reaches the engine,
641        // which never returns `Err` — every failure is a `WriteResult`
642        // variant and the process still exits 0 (ADR-0070 §10, ADR-0071
643        // §12). Scripts must inspect the output, not `$?`.
644        assert!(cmd.dispatch(&dead_client()).await.is_ok());
645    }
646
647    #[tokio::test]
648    async fn dispatch_routes_sheets_create() {
649        let guard = crate::drive::test_support::EnvGuard::take();
650        guard.redirect_api_hosts_to_a_dead_port();
651        let _dir = guard.clear_credentials();
652
653        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
654            command: sheets::SheetsSubcommands::Create(sheets::create::CreateCommand {
655                name: "Budget".to_string(),
656                parent: "parent-1".to_string(),
657                values: None,
658                values_format: sheets::values::ValuesFormat::Auto,
659                input: sheets::write::InputArg::UserEntered,
660                dry_run: false,
661                output: OutputFormat::Table,
662            }),
663        });
664        // Like `clear`, this reaches the engine (no `--values` to fail
665        // reading first), and `create` never returns `Err` either — every
666        // failure is a `CreateResult` variant (ADR-0070 §10, ADR-0071 §12).
667        assert!(cmd.dispatch(&dead_client()).await.is_ok());
668    }
669
670    #[tokio::test]
671    async fn dispatch_routes_sheets_add_sheet() {
672        let guard = crate::drive::test_support::EnvGuard::take();
673        guard.redirect_api_hosts_to_a_dead_port();
674        let _dir = guard.clear_credentials();
675
676        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
677            command: sheets::SheetsSubcommands::AddSheet(sheets::structure::AddSheetCommand {
678                target: sheets::structure::SpreadsheetIdArg {
679                    spreadsheet_id: "sheet-1".to_string(),
680                },
681                title: "Q3".to_string(),
682                index: None,
683                rows: None,
684                columns: None,
685                write: no_write_args(),
686            }),
687        });
688        // Reaches the engine, which never returns `Err` — every failure is a
689        // `StructureResult` variant (ADR-0073 §13).
690        assert!(cmd.dispatch(&dead_client()).await.is_ok());
691    }
692
693    #[tokio::test]
694    async fn dispatch_routes_sheets_rename_sheet() {
695        let guard = crate::drive::test_support::EnvGuard::take();
696        guard.redirect_api_hosts_to_a_dead_port();
697        let _dir = guard.clear_credentials();
698
699        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
700            command: sheets::SheetsSubcommands::RenameSheet(
701                sheets::structure::RenameSheetCommand {
702                    target: sheets::structure::SpreadsheetIdArg {
703                        spreadsheet_id: "sheet-1".to_string(),
704                    },
705                    sheet: "Q2".to_string(),
706                    title: "Q3".to_string(),
707                    write: no_write_args(),
708                },
709            ),
710        });
711        assert!(cmd.dispatch(&dead_client()).await.is_ok());
712    }
713
714    #[tokio::test]
715    async fn dispatch_routes_sheets_insert_rows() {
716        let guard = crate::drive::test_support::EnvGuard::take();
717        guard.redirect_api_hosts_to_a_dead_port();
718        let _dir = guard.clear_credentials();
719
720        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
721            command: sheets::SheetsSubcommands::InsertRows(sheets::structure::InsertRowsCommand {
722                target: sheets::structure::SpreadsheetIdArg {
723                    spreadsheet_id: "sheet-1".to_string(),
724                },
725                sheet: "Q2".to_string(),
726                at: 5,
727                count: 3,
728                write: no_write_args(),
729            }),
730        });
731        assert!(cmd.dispatch(&dead_client()).await.is_ok());
732    }
733
734    #[tokio::test]
735    async fn dispatch_routes_sheets_insert_columns() {
736        let guard = crate::drive::test_support::EnvGuard::take();
737        guard.redirect_api_hosts_to_a_dead_port();
738        let _dir = guard.clear_credentials();
739
740        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
741            command: sheets::SheetsSubcommands::InsertColumns(
742                sheets::structure::InsertColumnsCommand {
743                    target: sheets::structure::SpreadsheetIdArg {
744                        spreadsheet_id: "sheet-1".to_string(),
745                    },
746                    sheet: "Q2".to_string(),
747                    at: 2,
748                    count: 1,
749                    write: no_write_args(),
750                },
751            ),
752        });
753        assert!(cmd.dispatch(&dead_client()).await.is_ok());
754    }
755
756    #[tokio::test]
757    async fn dispatch_routes_sheets_move_rows() {
758        let guard = crate::drive::test_support::EnvGuard::take();
759        guard.redirect_api_hosts_to_a_dead_port();
760        let _dir = guard.clear_credentials();
761
762        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
763            command: sheets::SheetsSubcommands::MoveRows(sheets::structure::MoveRowsCommand {
764                target: sheets::structure::SpreadsheetIdArg {
765                    spreadsheet_id: "sheet-1".to_string(),
766                },
767                sheet: "Q2".to_string(),
768                at: 5,
769                count: 3,
770                before: 10,
771                write: no_write_args(),
772            }),
773        });
774        assert!(cmd.dispatch(&dead_client()).await.is_ok());
775    }
776
777    #[tokio::test]
778    async fn dispatch_routes_sheets_move_columns() {
779        let guard = crate::drive::test_support::EnvGuard::take();
780        guard.redirect_api_hosts_to_a_dead_port();
781        let _dir = guard.clear_credentials();
782
783        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
784            command: sheets::SheetsSubcommands::MoveColumns(
785                sheets::structure::MoveColumnsCommand {
786                    target: sheets::structure::SpreadsheetIdArg {
787                        spreadsheet_id: "sheet-1".to_string(),
788                    },
789                    sheet: "Q2".to_string(),
790                    at: 5,
791                    count: 3,
792                    before: 10,
793                    write: no_write_args(),
794                },
795            ),
796        });
797        assert!(cmd.dispatch(&dead_client()).await.is_ok());
798    }
799
800    #[tokio::test]
801    async fn dispatch_routes_sheets_move_chart() {
802        let guard = crate::drive::test_support::EnvGuard::take();
803        guard.redirect_api_hosts_to_a_dead_port();
804        let _dir = guard.clear_credentials();
805
806        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
807            command: sheets::SheetsSubcommands::MoveChart(
808                sheets::embedded_object::MoveChartCommand {
809                    spreadsheet_id: "sheet-1".to_string(),
810                    chart_id: 1,
811                    sheet: None,
812                    anchor: Some("F2".to_string()),
813                    offset_x: None,
814                    offset_y: None,
815                    width: None,
816                    height: None,
817                    new_sheet: false,
818                    write: helpers::StructureWriteArgs {
819                        dry_run: false,
820                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
821                        output: OutputFormat::Table,
822                    },
823                },
824            ),
825        });
826        assert!(cmd.dispatch(&dead_client()).await.is_ok());
827    }
828
829    #[tokio::test]
830    async fn dispatch_routes_sheets_move_slicer() {
831        let guard = crate::drive::test_support::EnvGuard::take();
832        guard.redirect_api_hosts_to_a_dead_port();
833        let _dir = guard.clear_credentials();
834
835        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
836            command: sheets::SheetsSubcommands::MoveSlicer(
837                sheets::embedded_object::MoveSlicerCommand {
838                    spreadsheet_id: "sheet-1".to_string(),
839                    slicer_id: 1,
840                    sheet: None,
841                    anchor: Some("F2".to_string()),
842                    offset_x: None,
843                    offset_y: None,
844                    width: None,
845                    height: None,
846                    write: helpers::StructureWriteArgs {
847                        dry_run: false,
848                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
849                        output: OutputFormat::Table,
850                    },
851                },
852            ),
853        });
854        assert!(cmd.dispatch(&dead_client()).await.is_ok());
855    }
856
857    #[tokio::test]
858    async fn dispatch_routes_sheets_update_chart_border() {
859        let guard = crate::drive::test_support::EnvGuard::take();
860        guard.redirect_api_hosts_to_a_dead_port();
861        let _dir = guard.clear_credentials();
862
863        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
864            command: sheets::SheetsSubcommands::UpdateChartBorder(
865                sheets::embedded_object::UpdateChartBorderCommand {
866                    spreadsheet_id: "sheet-1".to_string(),
867                    chart_id: 1,
868                    color: Some("#4A86E8".to_string()),
869                    clear: false,
870                    write: helpers::StructureWriteArgs {
871                        dry_run: false,
872                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
873                        output: OutputFormat::Table,
874                    },
875                },
876            ),
877        });
878        assert!(cmd.dispatch(&dead_client()).await.is_ok());
879    }
880
881    #[tokio::test]
882    async fn dispatch_routes_sheets_delete_sheet() {
883        let guard = crate::drive::test_support::EnvGuard::take();
884        guard.redirect_api_hosts_to_a_dead_port();
885        let _dir = guard.clear_credentials();
886
887        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
888            command: sheets::SheetsSubcommands::DeleteSheet(
889                sheets::structure::DeleteSheetCommand {
890                    target: sheets::structure::SpreadsheetIdArg {
891                        spreadsheet_id: "sheet-1".to_string(),
892                    },
893                    sheet: "Q2".to_string(),
894                    write: no_write_args(),
895                },
896            ),
897        });
898        // Reaches the engine, which never returns `Err` — every failure is a
899        // `StructureResult` variant (ADR-0073 §13, ADR-0077).
900        assert!(cmd.dispatch(&dead_client()).await.is_ok());
901    }
902
903    #[tokio::test]
904    async fn dispatch_routes_sheets_delete_rows() {
905        let guard = crate::drive::test_support::EnvGuard::take();
906        guard.redirect_api_hosts_to_a_dead_port();
907        let _dir = guard.clear_credentials();
908
909        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
910            command: sheets::SheetsSubcommands::DeleteRows(sheets::structure::DeleteRowsCommand {
911                target: sheets::structure::SpreadsheetIdArg {
912                    spreadsheet_id: "sheet-1".to_string(),
913                },
914                sheet: "Q2".to_string(),
915                at: 5,
916                count: 3,
917                write: no_write_args(),
918            }),
919        });
920        assert!(cmd.dispatch(&dead_client()).await.is_ok());
921    }
922
923    #[tokio::test]
924    async fn dispatch_routes_sheets_delete_columns() {
925        let guard = crate::drive::test_support::EnvGuard::take();
926        guard.redirect_api_hosts_to_a_dead_port();
927        let _dir = guard.clear_credentials();
928
929        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
930            command: sheets::SheetsSubcommands::DeleteColumns(
931                sheets::structure::DeleteColumnsCommand {
932                    target: sheets::structure::SpreadsheetIdArg {
933                        spreadsheet_id: "sheet-1".to_string(),
934                    },
935                    sheet: "Q2".to_string(),
936                    at: 2,
937                    count: 1,
938                    write: no_write_args(),
939                },
940            ),
941        });
942        assert!(cmd.dispatch(&dead_client()).await.is_ok());
943    }
944
945    #[tokio::test]
946    async fn dispatch_routes_sheets_delete_range() {
947        let guard = crate::drive::test_support::EnvGuard::take();
948        guard.redirect_api_hosts_to_a_dead_port();
949        let _dir = guard.clear_credentials();
950
951        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
952            command: sheets::SheetsSubcommands::DeleteRange(
953                sheets::structure::DeleteRangeCommand {
954                    target: sheets::structure::SpreadsheetIdArg {
955                        spreadsheet_id: "sheet-1".to_string(),
956                    },
957                    sheet: "Q2".to_string(),
958                    range: sheets::structure::GridRangeArgs {
959                        start_row: 2,
960                        end_row: 4,
961                        start_column: 2,
962                        end_column: 3,
963                        shift: sheets::structure::ShiftArg::Rows,
964                    },
965                    write: no_write_args(),
966                },
967            ),
968        });
969        assert!(cmd.dispatch(&dead_client()).await.is_ok());
970    }
971
972    #[tokio::test]
973    async fn dispatch_routes_sheets_duplicate_sheet() {
974        let guard = crate::drive::test_support::EnvGuard::take();
975        let _dir = guard.clear_credentials();
976
977        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
978            command: sheets::SheetsSubcommands::DuplicateSheet(
979                sheets::structure::DuplicateSheetCommand {
980                    target: sheets::structure::SpreadsheetIdArg {
981                        spreadsheet_id: "sheet-1".to_string(),
982                    },
983                    sheet: "Q2".to_string(),
984                    title: Some("Q2 copy".to_string()),
985                    index: None,
986                    write: no_write_args(),
987                },
988            ),
989        });
990        assert!(cmd.dispatch(&dead_client()).await.is_ok());
991    }
992
993    #[tokio::test]
994    async fn dispatch_routes_sheets_reorder_sheet() {
995        let guard = crate::drive::test_support::EnvGuard::take();
996        let _dir = guard.clear_credentials();
997
998        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
999            command: sheets::SheetsSubcommands::ReorderSheet(
1000                sheets::structure::ReorderSheetCommand {
1001                    target: sheets::structure::SpreadsheetIdArg {
1002                        spreadsheet_id: "sheet-1".to_string(),
1003                    },
1004                    sheet: "Q2".to_string(),
1005                    index: 0,
1006                    write: no_write_args(),
1007                },
1008            ),
1009        });
1010        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1011    }
1012
1013    #[tokio::test]
1014    async fn dispatch_routes_sheets_hide_sheet() {
1015        let guard = crate::drive::test_support::EnvGuard::take();
1016        let _dir = guard.clear_credentials();
1017
1018        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1019            command: sheets::SheetsSubcommands::HideSheet(sheets::structure::HideSheetCommand {
1020                target: sheets::structure::SpreadsheetIdArg {
1021                    spreadsheet_id: "sheet-1".to_string(),
1022                },
1023                sheet: "Q2".to_string(),
1024                write: no_write_args(),
1025            }),
1026        });
1027        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1028    }
1029
1030    #[tokio::test]
1031    async fn dispatch_routes_sheets_show_sheet() {
1032        let guard = crate::drive::test_support::EnvGuard::take();
1033        let _dir = guard.clear_credentials();
1034
1035        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1036            command: sheets::SheetsSubcommands::ShowSheet(sheets::structure::ShowSheetCommand {
1037                target: sheets::structure::SpreadsheetIdArg {
1038                    spreadsheet_id: "sheet-1".to_string(),
1039                },
1040                sheet: "Q2".to_string(),
1041                write: no_write_args(),
1042            }),
1043        });
1044        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1045    }
1046
1047    #[tokio::test]
1048    async fn dispatch_routes_sheets_update_workbook_properties() {
1049        let guard = crate::drive::test_support::EnvGuard::take();
1050        guard.redirect_api_hosts_to_a_dead_port();
1051        let _dir = guard.clear_credentials();
1052
1053        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1054            command: sheets::SheetsSubcommands::UpdateWorkbookProperties(
1055                sheets::structure::UpdateWorkbookPropertiesCommand {
1056                    target: sheets::structure::SpreadsheetIdArg {
1057                        spreadsheet_id: "sheet-1".to_string(),
1058                    },
1059                    locale: Some("en_US".to_string()),
1060                    time_zone: None,
1061                    auto_recalc: None,
1062                    iterative_calculation: None,
1063                    iterative_calculation_max_iterations: None,
1064                    iterative_calculation_convergence_threshold: None,
1065                    write: no_write_args(),
1066                },
1067            ),
1068        });
1069        // Reaches the engine, which never returns `Err` — every failure is a
1070        // `StructureResult` variant (ADR-0073 §13).
1071        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1072    }
1073
1074    #[tokio::test]
1075    async fn dispatch_routes_sheets_format_cells() {
1076        let guard = crate::drive::test_support::EnvGuard::take();
1077        let _dir = guard.clear_credentials();
1078
1079        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1080            command: sheets::SheetsSubcommands::FormatCells(sheets::format::FormatCellsCommand {
1081                spreadsheet_id: "sheet-1".to_string(),
1082                range: Some("A1:B2".to_string()),
1083                sheet: None,
1084                bold: Some(true),
1085                italic: None,
1086                strikethrough: None,
1087                underline: None,
1088                font_size: None,
1089                text_color: None,
1090                background: None,
1091                horizontal_align: Some(sheets::format::HorizontalAlign::Center),
1092                vertical_align: Some(sheets::format::VerticalAlign::Middle),
1093                number_format: Some("#,##0.00".to_string()),
1094                number_format_type: Some(sheets::format::NumberFormatType::Currency),
1095                wrap: Some(sheets::format::WrapStrategy::Wrap),
1096                font_family: None,
1097                text_rotation_angle: None,
1098                text_rotation_vertical: false,
1099                hyperlink_display_type: None,
1100                padding_top: None,
1101                padding_right: None,
1102                padding_bottom: None,
1103                padding_left: None,
1104                text_direction: None,
1105                write: helpers::StructureWriteArgs {
1106                    dry_run: false,
1107                    lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1108                    output: OutputFormat::Table,
1109                },
1110            }),
1111        });
1112        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1113    }
1114
1115    fn base_format_cells_command() -> sheets::format::FormatCellsCommand {
1116        sheets::format::FormatCellsCommand {
1117            spreadsheet_id: "sheet-1".to_string(),
1118            range: Some("A1:B2".to_string()),
1119            sheet: None,
1120            bold: None,
1121            italic: None,
1122            strikethrough: None,
1123            underline: None,
1124            font_size: None,
1125            text_color: None,
1126            background: None,
1127            horizontal_align: None,
1128            vertical_align: None,
1129            number_format: None,
1130            number_format_type: None,
1131            wrap: None,
1132            font_family: None,
1133            text_rotation_angle: None,
1134            text_rotation_vertical: false,
1135            hyperlink_display_type: None,
1136            padding_top: None,
1137            padding_right: None,
1138            padding_bottom: None,
1139            padding_left: None,
1140            text_direction: None,
1141            write: helpers::StructureWriteArgs {
1142                dry_run: false,
1143                lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1144                output: OutputFormat::Table,
1145            },
1146        }
1147    }
1148
1149    #[tokio::test]
1150    async fn dispatch_routes_sheets_format_cells_text_rotation_angle() {
1151        let guard = crate::drive::test_support::EnvGuard::take();
1152        let _dir = guard.clear_credentials();
1153
1154        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1155            command: sheets::SheetsSubcommands::FormatCells(sheets::format::FormatCellsCommand {
1156                text_rotation_angle: Some(45),
1157                ..base_format_cells_command()
1158            }),
1159        });
1160        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1161    }
1162
1163    #[tokio::test]
1164    async fn dispatch_routes_sheets_format_cells_text_rotation_vertical() {
1165        let guard = crate::drive::test_support::EnvGuard::take();
1166        let _dir = guard.clear_credentials();
1167
1168        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1169            command: sheets::SheetsSubcommands::FormatCells(sheets::format::FormatCellsCommand {
1170                text_rotation_vertical: true,
1171                ..base_format_cells_command()
1172            }),
1173        });
1174        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1175    }
1176
1177    #[tokio::test]
1178    async fn dispatch_routes_sheets_update_borders() {
1179        let guard = crate::drive::test_support::EnvGuard::take();
1180        let _dir = guard.clear_credentials();
1181
1182        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1183            command: sheets::SheetsSubcommands::UpdateBorders(
1184                sheets::format::UpdateBordersCommand {
1185                    spreadsheet_id: "sheet-1".to_string(),
1186                    range: Some("A1:B2".to_string()),
1187                    sheet: None,
1188                    top: true,
1189                    bottom: false,
1190                    left: false,
1191                    right: false,
1192                    all: false,
1193                    inner_horizontal: false,
1194                    inner_vertical: false,
1195                    style: sheets::format::BorderStyle::Dashed,
1196                    color: Some("#FF0000".to_string()),
1197                    write: helpers::StructureWriteArgs {
1198                        dry_run: false,
1199                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1200                        output: OutputFormat::Table,
1201                    },
1202                },
1203            ),
1204        });
1205        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1206    }
1207
1208    #[tokio::test]
1209    async fn dispatch_routes_sheets_merge_cells() {
1210        let guard = crate::drive::test_support::EnvGuard::take();
1211        let _dir = guard.clear_credentials();
1212
1213        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1214            command: sheets::SheetsSubcommands::MergeCells(sheets::format::MergeCellsCommand {
1215                spreadsheet_id: "sheet-1".to_string(),
1216                range: Some("A1:B2".to_string()),
1217                sheet: None,
1218                r#type: sheets::format::MergeType::Rows,
1219                dry_run: false,
1220                lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1221                output: OutputFormat::Table,
1222            }),
1223        });
1224        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1225    }
1226
1227    #[tokio::test]
1228    async fn dispatch_routes_sheets_unmerge_cells() {
1229        let guard = crate::drive::test_support::EnvGuard::take();
1230        let _dir = guard.clear_credentials();
1231
1232        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1233            command: sheets::SheetsSubcommands::UnmergeCells(sheets::format::UnmergeCellsCommand {
1234                spreadsheet_id: "sheet-1".to_string(),
1235                range: Some("A1:B2".to_string()),
1236                sheet: None,
1237                write: helpers::StructureWriteArgs {
1238                    dry_run: false,
1239                    lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1240                    output: OutputFormat::Table,
1241                },
1242            }),
1243        });
1244        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1245    }
1246
1247    #[tokio::test]
1248    async fn dispatch_routes_sheets_auto_resize_dimension() {
1249        let guard = crate::drive::test_support::EnvGuard::take();
1250        let _dir = guard.clear_credentials();
1251
1252        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1253            command: sheets::SheetsSubcommands::AutoResizeDimension(
1254                sheets::format::AutoResizeDimensionCommand {
1255                    spreadsheet_id: "sheet-1".to_string(),
1256                    sheet: "Q2".to_string(),
1257                    dimension: sheets::format::DimensionArg::Columns,
1258                    start: 1,
1259                    end: 3,
1260                    write: helpers::StructureWriteArgs {
1261                        dry_run: false,
1262                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1263                        output: OutputFormat::Table,
1264                    },
1265                },
1266            ),
1267        });
1268        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1269    }
1270
1271    #[tokio::test]
1272    async fn dispatch_routes_sheets_update_dimension_properties() {
1273        let guard = crate::drive::test_support::EnvGuard::take();
1274        let _dir = guard.clear_credentials();
1275
1276        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1277            command: sheets::SheetsSubcommands::UpdateDimensionProperties(
1278                sheets::format::UpdateDimensionPropertiesCommand {
1279                    spreadsheet_id: "sheet-1".to_string(),
1280                    sheet: "Q2".to_string(),
1281                    dimension: sheets::format::DimensionArg::Rows,
1282                    start: 1,
1283                    end: 3,
1284                    pixel_size: 42,
1285                    write: helpers::StructureWriteArgs {
1286                        dry_run: false,
1287                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1288                        output: OutputFormat::Table,
1289                    },
1290                },
1291            ),
1292        });
1293        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1294    }
1295
1296    #[tokio::test]
1297    async fn dispatch_routes_sheets_set_data_validation() {
1298        let guard = crate::drive::test_support::EnvGuard::take();
1299        let _dir = guard.clear_credentials();
1300
1301        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1302            command: sheets::SheetsSubcommands::SetDataValidation(Box::new(
1303                sheets::validation::SetDataValidationCommand {
1304                    spreadsheet_id: "sheet-1".to_string(),
1305                    range: Some("A1:A10".to_string()),
1306                    sheet: None,
1307                    one_of_list: Some(vec!["yes".to_string(), "no".to_string()]),
1308                    one_of_range: None,
1309                    number_between: None,
1310                    number_not_between: None,
1311                    number_greater: None,
1312                    number_greater_eq: None,
1313                    number_less: None,
1314                    number_less_eq: None,
1315                    number_eq: None,
1316                    number_not_eq: None,
1317                    text_contains: None,
1318                    text_not_contains: None,
1319                    text_starts_with: None,
1320                    text_ends_with: None,
1321                    text_eq: None,
1322                    date_after: None,
1323                    date_before: None,
1324                    date_on: None,
1325                    date_between: None,
1326                    blank: false,
1327                    not_blank: false,
1328                    checkbox: false,
1329                    custom_formula: None,
1330                    input_message: Some("Pick one".to_string()),
1331                    show_warning: false,
1332                    write: helpers::StructureWriteArgs {
1333                        dry_run: false,
1334                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1335                        output: OutputFormat::Table,
1336                    },
1337                },
1338            )),
1339        });
1340        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1341    }
1342
1343    #[tokio::test]
1344    async fn dispatch_routes_sheets_clear_data_validation() {
1345        let guard = crate::drive::test_support::EnvGuard::take();
1346        let _dir = guard.clear_credentials();
1347
1348        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1349            command: sheets::SheetsSubcommands::ClearDataValidation(
1350                sheets::validation::ClearDataValidationCommand {
1351                    spreadsheet_id: "sheet-1".to_string(),
1352                    range: Some("A1:A10".to_string()),
1353                    sheet: None,
1354                    write: helpers::StructureWriteArgs {
1355                        dry_run: false,
1356                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1357                        output: OutputFormat::Table,
1358                    },
1359                },
1360            ),
1361        });
1362        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1363    }
1364
1365    #[tokio::test]
1366    async fn dispatch_routes_sheets_set_developer_metadata() {
1367        let guard = crate::drive::test_support::EnvGuard::take();
1368        let _dir = guard.clear_credentials();
1369
1370        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1371            command: sheets::SheetsSubcommands::SetDeveloperMetadata(
1372                sheets::developer_metadata::SetDeveloperMetadataCommand {
1373                    spreadsheet_id: "sheet-1".to_string(),
1374                    key: "owner".to_string(),
1375                    value: "team-a".to_string(),
1376                    sheet: None,
1377                    dimension: None,
1378                    start: None,
1379                    end: None,
1380                    write: helpers::StructureWriteArgs {
1381                        dry_run: false,
1382                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1383                        output: OutputFormat::Table,
1384                    },
1385                },
1386            ),
1387        });
1388        // See `dispatch_routes_docs_replace` for why this is `is_ok()`: with
1389        // no configured account, `developer_metadata()` never returns
1390        // `Err` — it reports `Blocked`/`Failed` as a printed outcome.
1391        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1392    }
1393
1394    #[tokio::test]
1395    async fn dispatch_routes_sheets_delete_developer_metadata() {
1396        let guard = crate::drive::test_support::EnvGuard::take();
1397        let _dir = guard.clear_credentials();
1398
1399        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1400            command: sheets::SheetsSubcommands::DeleteDeveloperMetadata(
1401                sheets::developer_metadata::DeleteDeveloperMetadataCommand {
1402                    spreadsheet_id: "sheet-1".to_string(),
1403                    key: "owner".to_string(),
1404                    sheet: None,
1405                    dimension: None,
1406                    start: None,
1407                    end: None,
1408                    dry_run: false,
1409                    lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1410                    output: OutputFormat::Table,
1411                },
1412            ),
1413        });
1414        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1415    }
1416
1417    #[tokio::test]
1418    async fn dispatch_routes_sheets_search_developer_metadata() {
1419        // Read-only and ungated, like `sheets info`/`list-protections` —
1420        // needs the host redirect so it fails fast against a dead port
1421        // instead of trying the real Sheets API.
1422        let guard = crate::drive::test_support::EnvGuard::take();
1423        guard.redirect_api_hosts_to_a_dead_port();
1424
1425        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1426            command: sheets::SheetsSubcommands::SearchDeveloperMetadata(
1427                sheets::developer_metadata::SearchDeveloperMetadataCommand {
1428                    spreadsheet_id: "sheet-1".to_string(),
1429                    key: None,
1430                    sheet: None,
1431                    dimension: None,
1432                    start: None,
1433                    end: None,
1434                    output: OutputFormat::Table,
1435                },
1436            ),
1437        });
1438        assert!(cmd.dispatch(&dead_client()).await.is_err());
1439    }
1440
1441    #[tokio::test]
1442    async fn dispatch_routes_sheets_protect_range() {
1443        let guard = crate::drive::test_support::EnvGuard::take();
1444        let _dir = guard.clear_credentials();
1445
1446        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1447            command: sheets::SheetsSubcommands::ProtectRange(
1448                sheets::protection::ProtectRangeCommand {
1449                    spreadsheet_id: "sheet-1".to_string(),
1450                    range: Some("A1:B2".to_string()),
1451                    sheet: None,
1452                    whole_sheet: false,
1453                    description: Some("locked".to_string()),
1454                    warning_only: false,
1455                    editors: vec!["alice@example.com".to_string()],
1456                    write: helpers::StructureWriteArgs {
1457                        dry_run: false,
1458                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1459                        output: OutputFormat::Table,
1460                    },
1461                },
1462            ),
1463        });
1464        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1465    }
1466
1467    #[tokio::test]
1468    async fn dispatch_routes_sheets_update_protection() {
1469        let guard = crate::drive::test_support::EnvGuard::take();
1470        let _dir = guard.clear_credentials();
1471
1472        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1473            command: sheets::SheetsSubcommands::UpdateProtection(
1474                sheets::protection::UpdateProtectionCommand {
1475                    spreadsheet_id: "sheet-1".to_string(),
1476                    range: Some("A1:B2".to_string()),
1477                    sheet: None,
1478                    whole_sheet: false,
1479                    description: Some("still locked".to_string()),
1480                    warning_only: Some(true),
1481                    add_editors: vec!["bob@example.com".to_string()],
1482                    remove_editors: vec!["alice@example.com".to_string()],
1483                    write: helpers::StructureWriteArgs {
1484                        dry_run: false,
1485                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1486                        output: OutputFormat::Table,
1487                    },
1488                },
1489            ),
1490        });
1491        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1492    }
1493
1494    #[tokio::test]
1495    async fn dispatch_routes_sheets_unprotect_range() {
1496        let guard = crate::drive::test_support::EnvGuard::take();
1497        let _dir = guard.clear_credentials();
1498
1499        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1500            command: sheets::SheetsSubcommands::UnprotectRange(
1501                sheets::protection::UnprotectRangeCommand {
1502                    spreadsheet_id: "sheet-1".to_string(),
1503                    range: Some("A1:B2".to_string()),
1504                    sheet: None,
1505                    whole_sheet: false,
1506                    write: helpers::StructureWriteArgs {
1507                        dry_run: false,
1508                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1509                        output: OutputFormat::Table,
1510                    },
1511                },
1512            ),
1513        });
1514        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1515    }
1516
1517    #[tokio::test]
1518    async fn dispatch_routes_sheets_list_protections() {
1519        // Read-only and ungated, like `sheets info` — needs the host
1520        // redirect so it fails fast against a dead port instead of trying
1521        // the real Sheets API.
1522        let guard = crate::drive::test_support::EnvGuard::take();
1523        guard.redirect_api_hosts_to_a_dead_port();
1524
1525        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1526            command: sheets::SheetsSubcommands::ListProtections(
1527                sheets::protection::ListProtectionsCommand {
1528                    spreadsheet_id: "sheet-1".to_string(),
1529                    output: OutputFormat::Table,
1530                },
1531            ),
1532        });
1533        assert!(cmd.dispatch(&dead_client()).await.is_err());
1534    }
1535
1536    /// A minimal `--cell-empty`-only rule (no `--background`/`--text-color`/
1537    /// `--bold`) so this stays a plain routing test: `conditional_format`'s
1538    /// own validation refuses it before any network call, and — like
1539    /// `ProtectRange` above — the mutating verb still reports that refusal
1540    /// as an `Ok` outcome rather than propagating an error, since it never
1541    /// reaches a dead-port network call at all.
1542    fn cell_empty_only_rule_args() -> sheets::conditional_format::ConditionalFormatRuleArgs {
1543        sheets::conditional_format::ConditionalFormatRuleArgs {
1544            number_between: None,
1545            number_not_between: None,
1546            number_greater: None,
1547            number_greater_eq: None,
1548            number_less: None,
1549            number_less_eq: None,
1550            number_eq: None,
1551            number_not_eq: None,
1552            text_contains: None,
1553            text_not_contains: None,
1554            text_starts_with: None,
1555            text_ends_with: None,
1556            text_eq: None,
1557            date_after: None,
1558            date_before: None,
1559            date_on: None,
1560            date_between: None,
1561            cell_empty: true,
1562            cell_not_empty: false,
1563            custom_formula: None,
1564            background: None,
1565            text_color: None,
1566            bold: None,
1567            gradient_min_color: None,
1568            gradient_max_color: None,
1569            gradient_mid_color: None,
1570            gradient_mid_type: None,
1571            gradient_mid_value: None,
1572        }
1573    }
1574
1575    #[tokio::test]
1576    async fn dispatch_routes_sheets_add_conditional_format() {
1577        let guard = crate::drive::test_support::EnvGuard::take();
1578        let _dir = guard.clear_credentials();
1579
1580        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1581            command: sheets::SheetsSubcommands::AddConditionalFormat(Box::new(
1582                sheets::conditional_format::AddConditionalFormatCommand {
1583                    spreadsheet_id: "sheet-1".to_string(),
1584                    sheet: "Q1".to_string(),
1585                    ranges: vec!["A1:A10".to_string()],
1586                    index: None,
1587                    rule: cell_empty_only_rule_args(),
1588                    write: helpers::StructureWriteArgs {
1589                        dry_run: false,
1590                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1591                        output: OutputFormat::Table,
1592                    },
1593                },
1594            )),
1595        });
1596        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1597    }
1598
1599    #[tokio::test]
1600    async fn dispatch_routes_sheets_update_conditional_format() {
1601        let guard = crate::drive::test_support::EnvGuard::take();
1602        let _dir = guard.clear_credentials();
1603
1604        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1605            command: sheets::SheetsSubcommands::UpdateConditionalFormat(Box::new(
1606                sheets::conditional_format::UpdateConditionalFormatCommand {
1607                    spreadsheet_id: "sheet-1".to_string(),
1608                    sheet: "Q1".to_string(),
1609                    index: 0,
1610                    ranges: vec!["A1:A10".to_string()],
1611                    rule: cell_empty_only_rule_args(),
1612                    dry_run: false,
1613                    lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1614                    output: OutputFormat::Table,
1615                },
1616            )),
1617        });
1618        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1619    }
1620
1621    #[tokio::test]
1622    async fn dispatch_routes_sheets_delete_conditional_format() {
1623        let guard = crate::drive::test_support::EnvGuard::take();
1624        let _dir = guard.clear_credentials();
1625
1626        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1627            command: sheets::SheetsSubcommands::DeleteConditionalFormat(
1628                sheets::conditional_format::DeleteConditionalFormatCommand {
1629                    spreadsheet_id: "sheet-1".to_string(),
1630                    sheet: "Q1".to_string(),
1631                    index: 0,
1632                    dry_run: false,
1633                    lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1634                    output: OutputFormat::Table,
1635                },
1636            ),
1637        });
1638        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1639    }
1640
1641    #[tokio::test]
1642    async fn dispatch_routes_sheets_list_conditional_formats() {
1643        // Read-only and ungated, like `list-protections` — needs the host
1644        // redirect so it fails fast against a dead port instead of trying
1645        // the real Sheets API.
1646        let guard = crate::drive::test_support::EnvGuard::take();
1647        guard.redirect_api_hosts_to_a_dead_port();
1648
1649        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1650            command: sheets::SheetsSubcommands::ListConditionalFormats(
1651                sheets::conditional_format::ListConditionalFormatsCommand {
1652                    spreadsheet_id: "sheet-1".to_string(),
1653                    output: OutputFormat::Table,
1654                },
1655            ),
1656        });
1657        assert!(cmd.dispatch(&dead_client()).await.is_err());
1658    }
1659
1660    #[tokio::test]
1661    async fn dispatch_routes_sheets_set_basic_filter() {
1662        let guard = crate::drive::test_support::EnvGuard::take();
1663        let _dir = guard.clear_credentials();
1664
1665        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1666            command: sheets::SheetsSubcommands::SetBasicFilter(
1667                sheets::filter::SetBasicFilterCommand {
1668                    spreadsheet_id: "sheet-1".to_string(),
1669                    sheet: "Q1".to_string(),
1670                    range: "A1:D10".to_string(),
1671                    sort_by: vec!["0:asc".to_string()],
1672                    hide_values: vec!["1:Closed".to_string()],
1673                    write: helpers::StructureWriteArgs {
1674                        dry_run: false,
1675                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1676                        output: OutputFormat::Table,
1677                    },
1678                },
1679            ),
1680        });
1681        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1682    }
1683
1684    #[tokio::test]
1685    async fn dispatch_routes_sheets_clear_basic_filter() {
1686        let guard = crate::drive::test_support::EnvGuard::take();
1687        let _dir = guard.clear_credentials();
1688
1689        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1690            command: sheets::SheetsSubcommands::ClearBasicFilter(
1691                sheets::filter::ClearBasicFilterCommand {
1692                    spreadsheet_id: "sheet-1".to_string(),
1693                    sheet: "Q1".to_string(),
1694                    write: helpers::StructureWriteArgs {
1695                        dry_run: false,
1696                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1697                        output: OutputFormat::Table,
1698                    },
1699                },
1700            ),
1701        });
1702        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1703    }
1704
1705    #[tokio::test]
1706    async fn dispatch_routes_sheets_add_filter_view() {
1707        let guard = crate::drive::test_support::EnvGuard::take();
1708        let _dir = guard.clear_credentials();
1709
1710        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1711            command: sheets::SheetsSubcommands::AddFilterView(
1712                sheets::filter::AddFilterViewCommand {
1713                    spreadsheet_id: "sheet-1".to_string(),
1714                    sheet: "Q1".to_string(),
1715                    range: "A1:D10".to_string(),
1716                    title: Some("Open only".to_string()),
1717                    sort_by: Vec::new(),
1718                    hide_values: Vec::new(),
1719                    write: helpers::StructureWriteArgs {
1720                        dry_run: false,
1721                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1722                        output: OutputFormat::Table,
1723                    },
1724                },
1725            ),
1726        });
1727        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1728    }
1729
1730    #[tokio::test]
1731    async fn dispatch_routes_sheets_update_filter_view() {
1732        let guard = crate::drive::test_support::EnvGuard::take();
1733        let _dir = guard.clear_credentials();
1734
1735        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1736            command: sheets::SheetsSubcommands::UpdateFilterView(
1737                sheets::filter::UpdateFilterViewCommand {
1738                    spreadsheet_id: "sheet-1".to_string(),
1739                    filter_view_id: 7,
1740                    sheet: None,
1741                    range: None,
1742                    title: Some("Renamed".to_string()),
1743                    sort_by: Vec::new(),
1744                    hide_values: Vec::new(),
1745                    clear_sort: false,
1746                    clear_criteria: false,
1747                    write: helpers::StructureWriteArgs {
1748                        dry_run: false,
1749                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1750                        output: OutputFormat::Table,
1751                    },
1752                },
1753            ),
1754        });
1755        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1756    }
1757
1758    #[tokio::test]
1759    async fn dispatch_routes_sheets_delete_filter_view() {
1760        let guard = crate::drive::test_support::EnvGuard::take();
1761        let _dir = guard.clear_credentials();
1762
1763        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1764            command: sheets::SheetsSubcommands::DeleteFilterView(
1765                sheets::filter::DeleteFilterViewCommand {
1766                    spreadsheet_id: "sheet-1".to_string(),
1767                    filter_view_id: 7,
1768                    write: helpers::StructureWriteArgs {
1769                        dry_run: false,
1770                        lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1771                        output: OutputFormat::Table,
1772                    },
1773                },
1774            ),
1775        });
1776        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1777    }
1778
1779    #[tokio::test]
1780    async fn dispatch_routes_sheets_list_filter_views() {
1781        // Read-only and ungated, like `list-protections` — needs the host
1782        // redirect so it fails fast against a dead port instead of trying
1783        // the real Sheets API.
1784        let guard = crate::drive::test_support::EnvGuard::take();
1785        guard.redirect_api_hosts_to_a_dead_port();
1786
1787        let cmd = DriveSubcommands::Sheets(sheets::SheetsCommand {
1788            command: sheets::SheetsSubcommands::ListFilterViews(
1789                sheets::filter::ListFilterViewsCommand {
1790                    spreadsheet_id: "sheet-1".to_string(),
1791                    output: OutputFormat::Table,
1792                },
1793            ),
1794        });
1795        assert!(cmd.dispatch(&dead_client()).await.is_err());
1796    }
1797
1798    #[tokio::test]
1799    async fn dispatch_routes_search() {
1800        let cmd = DriveSubcommands::Search(search::SearchCommand {
1801            query: "name contains 'x'".to_string(),
1802            limit: 10,
1803            output: OutputFormat::Table,
1804        });
1805        assert!(cmd.dispatch(&dead_client()).await.is_err());
1806    }
1807
1808    #[tokio::test]
1809    async fn dispatch_routes_read() {
1810        let cmd = DriveSubcommands::Read(read::ReadCommand {
1811            file_id: "f1".to_string(),
1812            content: false,
1813            export_mime_type: None,
1814            out_file: None,
1815            verify: false,
1816            output: OutputFormat::Table,
1817        });
1818        assert!(cmd.dispatch(&dead_client()).await.is_err());
1819    }
1820
1821    #[tokio::test]
1822    async fn dispatch_routes_dedupe() {
1823        let cmd = DriveSubcommands::Dedupe(dedupe::DedupeCommand {
1824            query: "name contains 'x'".to_string(),
1825            limit: 10,
1826            output: OutputFormat::Table,
1827        });
1828        assert!(cmd.dispatch(&dead_client()).await.is_err());
1829    }
1830
1831    #[tokio::test]
1832    async fn dispatch_routes_create() {
1833        // Unlike rename/move (whose engine fns return `Result` and
1834        // propagate a network error via `?`), `create`'s engine fn always
1835        // returns an `Ok`-shaped `CreateOutcome` — a fetch failure against
1836        // the dead client becomes an embedded `Failed{detail}`, matching
1837        // the exit-0-regardless-of-outcome convention (ADR-0071 §12), not
1838        // a dispatch-level error. Needs env isolation (unlike the other
1839        // dispatch_routes_* tests): `create`'s CLI layer resolves the
1840        // active account's write_permissions.rules via `Settings::load()`,
1841        // so without a clean $HOME it reads whatever real
1842        // ~/.omni-dev/settings.json this process has.
1843        let guard = crate::drive::test_support::EnvGuard::take();
1844        let _dir = guard.clear_credentials();
1845
1846        let cmd = DriveSubcommands::Create(create::CreateCommand {
1847            name: "New File".to_string(),
1848            parent: "parent-1".to_string(),
1849            folder: false,
1850            mime_type: None,
1851            dry_run: false,
1852            output: OutputFormat::Table,
1853        });
1854        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1855    }
1856
1857    #[tokio::test]
1858    async fn dispatch_routes_upload() {
1859        // Same env-isolation and exit-0-regardless-of-outcome reasoning as
1860        // dispatch_routes_create.
1861        let guard = crate::drive::test_support::EnvGuard::take();
1862        let _dir = guard.clear_credentials();
1863        let content_dir = tempfile::tempdir().unwrap();
1864        let local_path = content_dir.path().join("upload-me.txt");
1865        std::fs::write(&local_path, b"content").unwrap();
1866
1867        let cmd = DriveSubcommands::Upload(upload::UploadCommand {
1868            local_path,
1869            parent: "parent-1".to_string(),
1870            name: None,
1871            mime_type: None,
1872            dry_run: false,
1873            output: OutputFormat::Table,
1874        });
1875        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1876    }
1877
1878    #[tokio::test]
1879    async fn dispatch_routes_edit() {
1880        // Same env-isolation and exit-0-regardless-of-outcome reasoning as
1881        // dispatch_routes_create/dispatch_routes_upload.
1882        let guard = crate::drive::test_support::EnvGuard::take();
1883        let _dir = guard.clear_credentials();
1884        let content_dir = tempfile::tempdir().unwrap();
1885        let content_path = content_dir.path().join("new-content.txt");
1886        std::fs::write(&content_path, b"content").unwrap();
1887
1888        let cmd = DriveSubcommands::Edit(edit::EditCommand {
1889            file_id: "f1".to_string(),
1890            content: content_path.to_str().unwrap().to_string(),
1891            mime_type: None,
1892            dry_run: false,
1893            lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
1894            output: OutputFormat::Table,
1895        });
1896        assert!(cmd.dispatch(&dead_client()).await.is_ok());
1897    }
1898
1899    #[tokio::test]
1900    async fn dispatch_routes_rename() {
1901        let cmd = DriveSubcommands::Rename(rename::RenameCommand {
1902            file_id: "f1".to_string(),
1903            new_name: "New Name".to_string(),
1904            dry_run: false,
1905            output: OutputFormat::Table,
1906        });
1907        assert!(cmd.dispatch(&dead_client()).await.is_err());
1908    }
1909
1910    #[tokio::test]
1911    async fn dispatch_routes_move() {
1912        let cmd = DriveSubcommands::Move(move_file::MoveCommand {
1913            file_ids: vec!["f1".to_string()],
1914            to: "dest1".to_string(),
1915            allow_visibility_increase: false,
1916            allow_visibility_decrease: false,
1917            allow_drive_boundary_crossing: false,
1918            dry_run: false,
1919            output: OutputFormat::Table,
1920        });
1921        assert!(cmd.dispatch(&dead_client()).await.is_err());
1922    }
1923}