diff --git a/.changeset/multi-account-auth.md b/.changeset/multi-account-auth.md new file mode 100644 index 00000000..02606813 --- /dev/null +++ b/.changeset/multi-account-auth.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Implement multi-account authentication support. Users can now use the global `--account ` flag to isolate credentials and token caches for different Google accounts. diff --git a/Cargo.lock b/Cargo.lock index 0d55ea8d..8f1d8bb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,16 +125,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", + "syn", ] [[package]] @@ -155,27 +146,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.11.0" @@ -197,12 +167,6 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - [[package]] name = "byteorder" version = "1.5.0" @@ -215,6 +179,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "castaway" version = "0.2.4" @@ -226,9 +196,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "shlex", @@ -266,7 +236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" dependencies = [ "chrono", - "phf 0.12.1", + "phf", ] [[package]] @@ -310,7 +280,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -327,9 +297,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compact_str" -version = "0.9.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" dependencies = [ "castaway", "cfg-if", @@ -398,19 +368,35 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.0", + "bitflags", "crossterm_winapi", "derive_more", "document-features", "mio", "parking_lot", - "rustix", + "rustix 1.1.4", "signal-hook", "signal-hook-mio", "winapi", @@ -436,16 +422,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "csscolorparser" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" -dependencies = [ - "lab", - "phf 0.11.3", -] - [[package]] name = "ctr" version = "0.9.2" @@ -486,7 +462,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn", ] [[package]] @@ -499,7 +475,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn", ] [[package]] @@ -510,7 +486,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -521,15 +497,9 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn", ] -[[package]] -name = "deltae" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" - [[package]] name = "deranged" version = "0.5.8" @@ -558,7 +528,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -568,7 +538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.117", + "syn", ] [[package]] @@ -590,7 +560,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn", ] [[package]] @@ -632,7 +602,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -672,60 +642,18 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "euclid" -version = "0.22.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" -dependencies = [ - "num-traits", -] - -[[package]] -name = "fancy-regex" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" -dependencies = [ - "bit-set", - "regex", -] - [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "finl_unicode" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" - -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "fnv" version = "1.0.7" @@ -738,12 +666,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -793,7 +715,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -896,7 +818,7 @@ dependencies = [ "chrono", "chrono-tz", "clap", - "crossterm", + "crossterm 0.29.0", "derive_builder", "dirs", "dotenvy", @@ -949,7 +871,9 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", + "allocator-api2", + "equivalent", + "foldhash", ] [[package]] @@ -957,11 +881,6 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] [[package]] name = "heck" @@ -969,12 +888,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - [[package]] name = "hostname" version = "0.4.2" @@ -1272,7 +1185,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1299,9 +1212,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.14.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -1322,17 +1235,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kasuari" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" -dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.18", -] - [[package]] name = "keyring" version = "3.6.3" @@ -1347,12 +1249,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "lab" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" - [[package]] name = "lazy_static" version = "1.5.0" @@ -1381,13 +1277,10 @@ dependencies = [ ] [[package]] -name = "line-clipping" -version = "0.3.5" +name = "linux-raw-sys" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" -dependencies = [ - "bitflags 2.11.0", -] +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" @@ -1424,11 +1317,11 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.16.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.15.5", ] [[package]] @@ -1437,16 +1330,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "mac_address" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" -dependencies = [ - "nix", - "winapi", -] - [[package]] name = "matchers" version = "0.2.0" @@ -1462,27 +1345,6 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memmem" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "mio" version = "1.1.1" @@ -1495,29 +1357,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1533,17 +1372,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1592,15 +1420,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -1625,63 +1444,16 @@ dependencies = [ ] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "paste" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] -name = "pest" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pest_meta" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" -dependencies = [ - "pest", - "sha2", -] - -[[package]] -name = "phf" -version = "0.11.3" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared 0.11.3", -] +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phf" @@ -1689,49 +1461,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" dependencies = [ - "phf_shared 0.12.1", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", + "phf_shared", ] [[package]] @@ -1767,12 +1497,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - [[package]] name = "potential_utf" version = "0.1.4" @@ -1804,7 +1528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn", ] [[package]] @@ -1953,87 +1677,23 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" -dependencies = [ - "instability", - "ratatui-core", - "ratatui-crossterm", - "ratatui-macros", - "ratatui-termwiz", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-core" -version = "0.1.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.11.0", + "bitflags", + "cassowary", "compact_str", - "hashbrown 0.16.1", + "crossterm 0.28.1", "indoc", + "instability", "itertools", - "kasuari", "lru", + "paste", "strum", - "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width", -] - -[[package]] -name = "ratatui-crossterm" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" -dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", -] - -[[package]] -name = "ratatui-macros" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" -dependencies = [ - "ratatui-core", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-termwiz" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" -dependencies = [ - "ratatui-core", - "termwiz", -] - -[[package]] -name = "ratatui-widgets" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.16.1", - "indoc", - "instability", - "itertools", - "line-clipping", - "ratatui-core", - "strum", - "time", - "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] @@ -2042,7 +1702,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags", ] [[package]] @@ -2056,18 +1716,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - [[package]] name = "regex-automata" version = "0.4.14" @@ -2155,16 +1803,29 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -2269,7 +1930,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.0", + "bitflags", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -2282,7 +1943,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -2332,7 +1993,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2396,7 +2057,7 @@ checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2504,23 +2165,24 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.27.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "rustversion", + "syn", ] [[package]] @@ -2529,17 +2191,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.117" @@ -2568,7 +2219,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2580,73 +2231,10 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] -[[package]] -name = "terminfo" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" -dependencies = [ - "fnv", - "nom", - "phf 0.11.3", - "phf_codegen", -] - -[[package]] -name = "termios" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" -dependencies = [ - "libc", -] - -[[package]] -name = "termwiz" -version = "0.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" -dependencies = [ - "anyhow", - "base64", - "bitflags 2.11.0", - "fancy-regex", - "filedescriptor", - "finl_unicode", - "fixedbitset", - "hex", - "lazy_static", - "libc", - "log", - "memmem", - "nix", - "num-derive", - "num-traits", - "ordered-float", - "pest", - "pest_derive", - "phf 0.11.3", - "sha2", - "signal-hook", - "siphasher", - "terminfo", - "termios", - "thiserror 1.0.69", - "ucd-trie", - "unicode-segmentation", - "vtparse", - "wezterm-bidi", - "wezterm-blob-leases", - "wezterm-color-types", - "wezterm-dynamic", - "wezterm-input-types", - "winapi", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -2673,7 +2261,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2684,7 +2272,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2741,9 +2329,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -2779,7 +2367,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2826,7 +2414,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.11.0", + "bitflags", "bytes", "futures-util", "http", @@ -2881,7 +2469,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2948,12 +2536,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -2968,20 +2550,26 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-truncate" -version = "2.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unicode-xid" @@ -3035,18 +2623,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "1.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" -dependencies = [ - "atomic", - "getrandom 0.4.2", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "valuable" version = "0.1.1" @@ -3059,15 +2635,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vtparse" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" -dependencies = [ - "utf8parse", -] - [[package]] name = "want" version = "0.3.1" @@ -3147,7 +2714,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wasm-bindgen-shared", ] @@ -3201,7 +2768,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags", "hashbrown 0.15.5", "indexmap", "semver", @@ -3227,78 +2794,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "wezterm-bidi" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" -dependencies = [ - "log", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-blob-leases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" -dependencies = [ - "getrandom 0.3.4", - "mac_address", - "sha2", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "wezterm-color-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" -dependencies = [ - "csscolorparser", - "deltae", - "lazy_static", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-dynamic" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" -dependencies = [ - "log", - "ordered-float", - "strsim", - "thiserror 1.0.69", - "wezterm-dynamic-derive", -] - -[[package]] -name = "wezterm-dynamic-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "wezterm-input-types" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" -dependencies = [ - "bitflags 1.3.2", - "euclid", - "lazy_static", - "serde", - "wezterm-dynamic", -] - [[package]] name = "winapi" version = "0.3.9" @@ -3342,7 +2837,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3353,7 +2848,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3632,7 +3127,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 2.0.117", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3648,7 +3143,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3660,7 +3155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags", "indexmap", "log", "serde", @@ -3715,7 +3210,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -3761,7 +3256,7 @@ checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3781,7 +3276,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -3802,7 +3297,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3835,7 +3330,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 24bc253b..551e52eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ tokio-util = { version = "0.7", features = ["io"] } bytes = "1" base64 = "0.22.1" derive_builder = "0.20.2" -ratatui = "0.30.0" +ratatui = "0.29.0" crossterm = "0.29.0" chrono = "0.4.44" chrono-tz = "0.10" diff --git a/src/auth.rs b/src/auth.rs index b602d840..31bb1613 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -93,12 +93,14 @@ pub trait AccessTokenProvider: Send + Sync { #[derive(Debug, Clone)] pub struct ScopedTokenProvider { scopes: Vec, + account: Option, } impl ScopedTokenProvider { - pub fn new(scopes: &[&str]) -> Self { + pub fn new(scopes: &[&str], account: Option) -> Self { Self { scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(), + account, } } } @@ -107,12 +109,12 @@ impl ScopedTokenProvider { impl AccessTokenProvider for ScopedTokenProvider { async fn access_token(&self) -> anyhow::Result { let scopes: Vec<&str> = self.scopes.iter().map(String::as_str).collect(); - get_token(&scopes).await + get_token(&scopes, self.account.as_deref()).await } } -pub fn token_provider(scopes: &[&str]) -> ScopedTokenProvider { - ScopedTokenProvider::new(scopes) +pub fn token_provider(scopes: &[&str], account: Option) -> ScopedTokenProvider { + ScopedTokenProvider::new(scopes, account) } /// A fake [`AccessTokenProvider`] for tests that returns tokens from a queue. @@ -155,7 +157,7 @@ impl AccessTokenProvider for FakeTokenProvider { /// - `GOOGLE_APPLICATION_CREDENTIALS` env var (path to a JSON credentials file), then /// - Well-known ADC path: `~/.config/gcloud/application_default_credentials.json` /// (populated by `gcloud auth application-default login`) -pub async fn get_token(scopes: &[&str]) -> anyhow::Result { +pub async fn get_token(scopes: &[&str], account: Option<&str>) -> anyhow::Result { // 0. Direct token from env var (highest priority, bypasses all credential loading) if let Ok(token) = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN") { if !token.is_empty() { @@ -164,25 +166,22 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result { } let creds_file = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE").ok(); - let config_dir = crate::auth_commands::config_dir(); - let enc_path = credential_store::encrypted_credentials_path(); - let default_path = config_dir.join("credentials.json"); - let token_cache = config_dir.join("token_cache.json"); - let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?; - get_token_inner(scopes, creds, &token_cache).await + let creds = load_credentials_inner(creds_file.as_deref(), account).await?; + get_token_inner(scopes, creds, account).await } async fn get_token_inner( scopes: &[&str], creds: Credential, - token_cache_path: &std::path::Path, + account: Option<&str>, ) -> anyhow::Result { match creds { Credential::AuthorizedUser(secret) => { + let token_cache_path = crate::credential_store::token_cache_path(account); let auth = yup_oauth2::AuthorizedUserAuthenticator::builder(secret) .with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new( - token_cache_path.to_path_buf(), + token_cache_path, ))) .build() .await @@ -195,11 +194,7 @@ async fn get_token_inner( .to_string()) } Credential::ServiceAccount(key) => { - let tc_filename = token_cache_path - .file_name() - .map(|f| f.to_string_lossy().to_string()) - .unwrap_or_else(|| "token_cache.json".to_string()); - let sa_cache = token_cache_path.with_file_name(format!("sa_{tc_filename}")); + let sa_cache = crate::credential_store::sa_token_cache_path(account); let builder = yup_oauth2::ServiceAccountAuthenticator::builder(key).with_storage( Box::new(crate::token_storage::EncryptedTokenStorage::new(sa_cache)), ); @@ -255,9 +250,10 @@ async fn parse_credential_file( async fn load_credentials_inner( env_file: Option<&str>, - enc_path: &std::path::Path, - default_path: &std::path::Path, + account: Option<&str>, ) -> anyhow::Result { + let enc_path = credential_store::encrypted_credentials_path(account); + let default_path = credential_store::plain_credentials_path(account); // 1. Explicit env var — plaintext file (User or Service Account) if let Some(path) = env_file { let p = PathBuf::from(path); @@ -274,9 +270,9 @@ async fn load_credentials_inner( // 2. Encrypted credentials if enc_path.exists() { - match credential_store::load_encrypted_from_path(enc_path) { + match credential_store::load_encrypted_from_path(&enc_path) { Ok(json_str) => { - return parse_credential_file(enc_path, &json_str).await; + return parse_credential_file(&enc_path, &json_str).await; } Err(e) => { // Decryption failed — the encryption key likely changed (e.g. after @@ -287,15 +283,18 @@ async fn load_credentials_inner( "Warning: removing undecryptable credentials file ({}): {e:#}", enc_path.display() ); - if let Err(err) = tokio::fs::remove_file(enc_path).await { + if let Err(err) = tokio::fs::remove_file(&enc_path).await { eprintln!( "Warning: failed to remove stale credentials file '{}': {err}", enc_path.display() ); } // Also remove stale token caches that used the old key. - for cache_file in ["token_cache.json", "sa_token_cache.json"] { - let path = enc_path.with_file_name(cache_file); + let cache_paths = [ + crate::credential_store::token_cache_path(account), + crate::credential_store::sa_token_cache_path(account), + ]; + for path in cache_paths { if let Err(err) = tokio::fs::remove_file(&path).await { if err.kind() != std::io::ErrorKind::NotFound { eprintln!( @@ -313,7 +312,7 @@ async fn load_credentials_inner( // 3. Plaintext credentials at default path (AuthorizedUser) if default_path.exists() { return Ok(Credential::AuthorizedUser( - yup_oauth2::read_authorized_user_secret(default_path) + yup_oauth2::read_authorized_user_secret(&default_path) .await .with_context(|| { format!("Failed to read credentials from {}", default_path.display()) @@ -401,18 +400,13 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn test_load_credentials_no_options() { - // Isolate from host ADC: override HOME so adc_well_known_path() - // resolves to a non-existent directory, and clear the env var. + // Isolate from host ADC: override CONFIG_DIR and clear ADC env var. let tmp = tempfile::tempdir().unwrap(); - let _home_guard = EnvVarGuard::set("HOME", tmp.path()); + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", tmp.path()); let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); + let _home_guard = EnvVarGuard::set("HOME", "/missing/home"); - let err = load_credentials_inner( - None, - &PathBuf::from("/does/not/exist1"), - &PathBuf::from("/does/not/exist2"), - ) - .await; + let err = load_credentials_inner(None, None).await; assert!(err.is_err()); assert!(err @@ -433,17 +427,15 @@ mod tests { }"#; file.write_all(json.as_bytes()).unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", tmp.path()); + let _home_guard = EnvVarGuard::set("HOME", tmp.path()); let _adc_guard = EnvVarGuard::set( "GOOGLE_APPLICATION_CREDENTIALS", file.path().to_str().unwrap(), ); - let res = load_credentials_inner( - None, - &PathBuf::from("/missing/enc"), - &PathBuf::from("/missing/plain"), - ) - .await; + let res = load_credentials_inner(None, None).await; match res.unwrap() { Credential::AuthorizedUser(secret) => { @@ -475,12 +467,7 @@ mod tests { file.path().to_str().unwrap(), ); - let res = load_credentials_inner( - None, - &PathBuf::from("/missing/enc"), - &PathBuf::from("/missing/plain"), - ) - .await; + let res = load_credentials_inner(None, None).await; match res.unwrap() { Credential::ServiceAccount(key) => { @@ -500,12 +487,7 @@ mod tests { // When GOOGLE_APPLICATION_CREDENTIALS points to a missing file, we error immediately // rather than falling through — the user explicitly asked for this file. - let err = load_credentials_inner( - None, - &PathBuf::from("/missing/enc"), - &PathBuf::from("/missing/plain"), - ) - .await; + let err = load_credentials_inner(None, None).await; assert!(err.is_err()); let msg = err.unwrap_err().to_string(); @@ -517,12 +499,7 @@ mod tests { #[tokio::test] async fn test_load_credentials_env_file_missing() { - let err = load_credentials_inner( - Some("/does/not/exist"), - &PathBuf::from("/also/missing"), - &PathBuf::from("/still/missing"), - ) - .await; + let err = load_credentials_inner(Some("/does/not/exist"), None).await; assert!(err.is_err()); assert!(err.unwrap_err().to_string().contains("does not exist")); } @@ -538,13 +515,9 @@ mod tests { }"#; file.write_all(json.as_bytes()).unwrap(); - let res = load_credentials_inner( - Some(file.path().to_str().unwrap()), - &PathBuf::from("/also/missing"), - &PathBuf::from("/still/missing"), - ) - .await - .unwrap(); + let res = load_credentials_inner(Some(file.path().to_str().unwrap()), None) + .await + .unwrap(); match res { Credential::AuthorizedUser(secret) => { @@ -570,13 +543,9 @@ mod tests { }"#; file.write_all(json.as_bytes()).unwrap(); - let res = load_credentials_inner( - Some(file.path().to_str().unwrap()), - &PathBuf::from("/also/missing"), - &PathBuf::from("/still/missing"), - ) - .await - .unwrap(); + let res = load_credentials_inner(Some(file.path().to_str().unwrap()), None) + .await + .unwrap(); match res { Credential::ServiceAccount(key) => { @@ -588,18 +557,18 @@ mod tests { #[tokio::test] async fn test_load_credentials_default_path_authorized_user() { - let mut file = NamedTempFile::new().unwrap(); + let dir = tempfile::tempdir().unwrap(); let json = r#"{ "client_id": "default_id", "client_secret": "default_secret", "refresh_token": "default_refresh", "type": "authorized_user" }"#; - file.write_all(json.as_bytes()).unwrap(); + let plain_path = dir.path().join("credentials.json"); + std::fs::write(&plain_path, json).unwrap(); - let res = load_credentials_inner(None, &PathBuf::from("/also/missing"), file.path()) - .await - .unwrap(); + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); + let res = load_credentials_inner(None, None).await.unwrap(); match res { Credential::AuthorizedUser(secret) => { @@ -614,7 +583,7 @@ mod tests { async fn test_get_token_from_env_var() { let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", "my-test-token"); - let result = get_token(&["https://www.googleapis.com/auth/drive"]).await; + let result = get_token(&["https://www.googleapis.com/auth/drive"], None).await; assert!(result.is_ok()); assert_eq!(result.unwrap(), "my-test-token"); @@ -624,7 +593,7 @@ mod tests { #[serial_test::serial] async fn test_scoped_token_provider_uses_get_token() { let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", "provider-token"); - let provider = token_provider(&["https://www.googleapis.com/auth/drive"]); + let provider = token_provider(&["https://www.googleapis.com/auth/drive"], None); let first = provider.access_token().await.unwrap(); let second = provider.access_token().await.unwrap(); @@ -634,6 +603,7 @@ mod tests { } #[tokio::test] + #[serial_test::serial] async fn test_load_credentials_encrypted_file() { // Simulate an encrypted credentials file let json = r#"{ @@ -650,9 +620,8 @@ mod tests { let encrypted = crate::credential_store::encrypt(json.as_bytes()).unwrap(); std::fs::write(&enc_path, &encrypted).unwrap(); - let res = load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist")) - .await - .unwrap(); + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); + let res = load_credentials_inner(None, None).await.unwrap(); match res { Credential::AuthorizedUser(secret) => { @@ -665,6 +634,7 @@ mod tests { } #[tokio::test] + #[serial_test::serial] async fn test_load_credentials_encrypted_takes_priority_over_default() { // Encrypted credentials should be loaded before the default plaintext path let enc_json = r#"{ @@ -688,9 +658,8 @@ mod tests { std::fs::write(&enc_path, &encrypted).unwrap(); std::fs::write(&plain_path, plain_json).unwrap(); - let res = load_credentials_inner(None, &enc_path, &plain_path) - .await - .unwrap(); + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); + let res = load_credentials_inner(None, None).await.unwrap(); match res { Credential::AuthorizedUser(secret) => { @@ -708,10 +677,6 @@ mod tests { async fn test_load_credentials_corrupt_encrypted_file_is_removed() { // When credentials.enc cannot be decrypted, the file should be removed // automatically and the function should fall through to other sources. - let tmp = tempfile::tempdir().unwrap(); - let _home_guard = EnvVarGuard::set("HOME", tmp.path()); - let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); - let dir = tempfile::tempdir().unwrap(); let enc_path = dir.path().join("credentials.enc"); @@ -721,12 +686,15 @@ mod tests { .unwrap(); assert!(enc_path.exists()); - let result = - load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist")).await; + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); + let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); + let _home_guard = EnvVarGuard::set("HOME", "/missing/home"); + + let res = load_credentials_inner(None, None).await; // Should fall through to "No credentials found" (not a decryption error). - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); + assert!(res.is_err()); + let msg = res.unwrap_err().to_string(); assert!( msg.contains("No credentials found"), "Should fall through to final error, got: {msg}" @@ -760,9 +728,8 @@ mod tests { }"#; tokio::fs::write(&plain_path, plain_json).await.unwrap(); - let res = load_credentials_inner(None, &enc_path, &plain_path) - .await - .unwrap(); + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); + let res = load_credentials_inner(None, None).await.unwrap(); match res { Credential::AuthorizedUser(secret) => { @@ -781,18 +748,13 @@ mod tests { async fn test_get_token_env_var_empty_falls_through() { // An empty token should not short-circuit — it should be ignored // and fall through to normal credential loading. - // Isolate from host ADC so the well-known path doesn't match. let tmp = tempfile::tempdir().unwrap(); - let _home_guard = EnvVarGuard::set("HOME", tmp.path()); + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", tmp.path()); let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); + let _home_guard = EnvVarGuard::set("HOME", "/missing/home"); let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", ""); - let result = load_credentials_inner( - None, - &PathBuf::from("/does/not/exist1"), - &PathBuf::from("/does/not/exist2"), - ) - .await; + let result = load_credentials_inner(None, None).await; // Should fall through to normal credential loading, which fails // because we pointed at non-existent paths @@ -836,18 +798,18 @@ mod tests { #[serial_test::serial] fn test_get_quota_project_priority_adc_fallback() { let tmp = tempfile::tempdir().unwrap(); - let adc_dir = tmp.path().join(".config").join("gcloud"); - std::fs::create_dir_all(&adc_dir).unwrap(); + let gcloud_dir = tmp.path().join(".config").join("gcloud"); + std::fs::create_dir_all(&gcloud_dir).unwrap(); std::fs::write( - adc_dir.join("application_default_credentials.json"), + gcloud_dir.join("application_default_credentials.json"), r#"{"quota_project_id": "adc-project"}"#, ) .unwrap(); let _home_guard = EnvVarGuard::set("HOME", tmp.path()); + let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); let _env_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_PROJECT_ID"); let _config_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_CLI_CONFIG_DIR"); - let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); assert_eq!(get_quota_project(), Some("adc-project".to_string())); } @@ -856,17 +818,16 @@ mod tests { #[serial_test::serial] fn test_get_quota_project_reads_adc() { let tmp = tempfile::tempdir().unwrap(); - let adc_dir = tmp.path().join(".config").join("gcloud"); - std::fs::create_dir_all(&adc_dir).unwrap(); + let gcloud_dir = tmp.path().join(".config").join("gcloud"); + std::fs::create_dir_all(&gcloud_dir).unwrap(); std::fs::write( - adc_dir.join("application_default_credentials.json"), + gcloud_dir.join("application_default_credentials.json"), r#"{"quota_project_id": "my-project-123"}"#, ) .unwrap(); let _home_guard = EnvVarGuard::set("HOME", tmp.path()); let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); - // Isolate from local environment let _env_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_PROJECT_ID"); let _config_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_CLI_CONFIG_DIR"); diff --git a/src/auth_commands.rs b/src/auth_commands.rs index f51ba6dd..0ce18c53 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -17,7 +17,9 @@ use std::path::PathBuf; use serde_json::json; -use crate::credential_store; +use crate::credential_store::{ + self, plain_credentials_path, sa_token_cache_path, token_cache_path, +}; use crate::error::GwsError; /// Mask a secret string by showing only the first 4 and last 4 characters. @@ -117,62 +119,141 @@ pub fn config_dir() -> PathBuf { primary } -fn plain_credentials_path() -> PathBuf { - if let Ok(path) = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE") { - return PathBuf::from(path); - } - config_dir().join("credentials.json") -} - -fn token_cache_path() -> PathBuf { - config_dir().join("token_cache.json") -} - /// Handle `gws auth `. pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { - const USAGE: &str = concat!( - "Usage: gws auth [options]\n\n", - " login Authenticate via OAuth2 (opens browser)\n", - " --readonly Request read-only scopes\n", - " --full Request all scopes incl. pubsub + cloud-platform\n", - " (may trigger restricted_client for unverified apps)\n", - " --scopes Comma-separated custom scopes\n", - " -s, --services Comma-separated service names to limit scope picker\n", - " (e.g. -s drive,gmail,sheets)\n", - " setup Configure GCP project + OAuth client (requires gcloud)\n", - " --project Use a specific GCP project\n", - " --login Run `gws auth login` after successful setup\n", - " status Show current authentication state\n", - " export Print decrypted credentials to stdout\n", - " logout Clear saved credentials and token cache", - ); + use clap::{Arg, Command}; + + let cmd = Command::new("gws auth") + .about("Authentication and account management") + .subcommand_required(true) + .arg_required_else_help(true) + .arg( + Arg::new("account") + .long("account") + .short('A') + .help("Account alias to use (e.g. 'work', 'personal')") + .global(true), + ) + .subcommand( + Command::new("login") + .about("Authenticate via OAuth2 (opens browser)") + .arg( + Arg::new("readonly") + .long("readonly") + .action(clap::ArgAction::SetTrue) + .help("Request read-only scopes"), + ) + .arg( + Arg::new("full") + .long("full") + .action(clap::ArgAction::SetTrue) + .help("Request all scopes incl. pubsub + cloud-platform"), + ) + .arg( + Arg::new("scopes") + .long("scopes") + .help("Comma-separated custom scopes"), + ) + .arg( + Arg::new("services") + .long("services") + .short('s') + .help("Comma-separated service names to limit scope picker"), + ), + ) + .subcommand( + Command::new("setup") + .about("Configure GCP project + OAuth client (requires gcloud)") + .arg( + Arg::new("project") + .long("project") + .help("Use a specific GCP project"), + ) + .arg( + Arg::new("login") + .long("login") + .action(clap::ArgAction::SetTrue) + .help("Run 'gws auth login' after successful setup"), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .action(clap::ArgAction::SetTrue) + .help("Show what would be done without making changes"), + ), + ) + .subcommand(Command::new("status").about("Show current authentication state")) + .subcommand( + Command::new("export") + .about("Print decrypted credentials to stdout") + .arg( + Arg::new("unmasked") + .long("unmasked") + .action(clap::ArgAction::SetTrue) + .help("Print raw secrets (CAUTION)"), + ), + ) + .subcommand(Command::new("logout").about("Clear saved credentials and token cache")); - // Honour --help / -h before treating the first arg as a subcommand. - if args.is_empty() || args[0] == "--help" || args[0] == "-h" { - println!("{USAGE}"); - return Ok(()); - } + let matches = cmd + .try_get_matches_from(std::iter::once("gws auth").chain(args.iter().map(|s| s.as_str()))) + .map_err(|e| GwsError::Validation(e.to_string()))?; + + let account = matches.get_one::("account").map(|s| s.as_str()); - match args[0].as_str() { - "login" => run_login(&args[1..]).await, - "setup" => crate::setup::run_setup(&args[1..]).await, - "status" => handle_status().await, - "export" => { - let unmasked = args.len() > 1 && args[1] == "--unmasked"; - handle_export(unmasked).await + match matches.subcommand() { + Some(("login", sub)) => { + let mut opts = LoginOptions { + readonly: sub.get_flag("readonly"), + full: sub.get_flag("full"), + ..Default::default() + }; + if let Some(s) = sub.get_one::("scopes") { + opts.custom_scopes = Some(s.split(',').map(|s| s.trim().to_string()).collect()); + } + if let Some(s) = sub.get_one::("services") { + opts.services_filter = Some( + s.split(',') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect(), + ); + } + run_login(opts, account).await } - "logout" => handle_logout(), - other => Err(GwsError::Validation(format!( - "Unknown auth subcommand: '{other}'. Use: login, setup, status, export, logout" - ))), + Some(("setup", sub)) => { + let opts = crate::setup::SetupOptions { + project: sub.get_one::("project").cloned(), + dry_run: sub.get_flag("dry-run"), + login: sub.get_flag("login"), + account: account.map(|s| s.to_string()), + }; + crate::setup::run_setup(opts).await + } + Some(("status", _)) => handle_status(account).await, + Some(("export", sub)) => { + let unmasked = sub.get_flag("unmasked"); + handle_export(unmasked, account).await + } + Some(("logout", _)) => handle_logout(account), + _ => unreachable!("subcommand_required(true)"), } } +/// Options for the login command. +#[derive(Default, Clone)] +pub struct LoginOptions { + pub readonly: bool, + pub full: bool, + pub custom_scopes: Option>, + pub services_filter: Option>, +} + /// Run the `auth login` flow. /// /// Exposed for internal orchestration (e.g. `auth setup --login`). -pub async fn run_login(args: &[String]) -> Result<(), GwsError> { - handle_login(args).await +pub async fn run_login(opts: LoginOptions, account: Option<&str>) -> Result<(), GwsError> { + handle_login(opts, account).await } /// Custom delegate that prints the OAuth URL on its own line for easy copying. /// Optionally includes `login_hint` in the URL for account pre-selection. @@ -210,36 +291,7 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega } } -async fn handle_login(args: &[String]) -> Result<(), GwsError> { - // Extract -s/--services from args - let mut services_filter: Option> = None; - let mut filtered_args: Vec = Vec::new(); - let mut skip_next = false; - for i in 0..args.len() { - if skip_next { - skip_next = false; - continue; - } - let services_str = if (args[i] == "-s" || args[i] == "--services") && i + 1 < args.len() { - skip_next = true; - Some(args[i + 1].as_str()) - } else { - args[i].strip_prefix("--services=") - }; - - if let Some(value) = services_str { - services_filter = Some( - value - .split(',') - .map(|s| s.trim().to_lowercase()) - .filter(|s| !s.is_empty()) - .collect(), - ); - continue; - } - filtered_args.push(args[i].clone()); - } - +async fn handle_login(opts: LoginOptions, account: Option<&str>) -> Result<(), GwsError> { // Resolve client_id and client_secret: // 1. Env vars (highest priority) // 2. Saved client_secret.json from `gws auth setup` or manual download @@ -257,9 +309,8 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { // Determine scopes: explicit flags > interactive TUI > defaults let scopes = resolve_scopes( - &filtered_args, + &opts, project_id.as_deref(), - services_filter.as_ref(), ) .await; @@ -285,7 +336,12 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { } // Use a temp file for yup-oauth2's token persistence, then encrypt it - let temp_path = config_dir().join("credentials.tmp"); + let temp_filename = if let Some(acc) = account { + format!("credentials.{acc}.tmp") + } else { + "credentials.tmp".to_string() + }; + let temp_path = config_dir().join(temp_filename); // Always start fresh — delete any stale temp cache from prior login attempts. let _ = std::fs::remove_file(&temp_path); @@ -348,7 +404,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { let actual_email = fetch_userinfo_email(access_token).await; // Save encrypted credentials - let enc_path = credential_store::save_encrypted(&creds_str) + let enc_path = credential_store::save_encrypted(&creds_str, account) .map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?; // Clean up temp file @@ -358,6 +414,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { "status": "success", "message": "Authentication successful. Encrypted credentials saved.", "account": actual_email.as_deref().unwrap_or("(unknown)"), + "alias": account.unwrap_or("(none)"), "credentials_file": enc_path.display().to_string(), "encryption": "AES-256-GCM (key in OS keyring or local `.encryption_key`; set GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file for headless)", "scopes": scopes, @@ -397,15 +454,14 @@ async fn fetch_userinfo_email(access_token: &str) -> Option { .map(|s| s.to_string()) } -async fn handle_export(unmasked: bool) -> Result<(), GwsError> { - let enc_path = credential_store::encrypted_credentials_path(); +async fn handle_export(unmasked: bool, account: Option<&str>) -> Result<(), GwsError> { + let enc_path = credential_store::encrypted_credentials_path(account); if !enc_path.exists() { return Err(GwsError::Auth( "No encrypted credentials found. Run 'gws auth login' first.".to_string(), )); } - - match credential_store::load_encrypted() { + match credential_store::load_encrypted(account) { Ok(contents) => { if unmasked { println!("{contents}"); @@ -471,31 +527,24 @@ fn resolve_client_credentials() -> Result<(String, String, Option), GwsE /// When `services_filter` is `Some`, only scopes belonging to the specified /// services are shown in the picker (or returned in non-interactive mode). async fn resolve_scopes( - args: &[String], + opts: &LoginOptions, project_id: Option<&str>, - services_filter: Option<&HashSet>, ) -> Vec { // Explicit --scopes flag takes priority (bypasses services filter) - for i in 0..args.len() { - if args[i] == "--scopes" && i + 1 < args.len() { - return args[i + 1] - .split(',') - .map(|s| s.trim().to_string()) - .collect(); - } + if let Some(ref scopes) = opts.custom_scopes { + return scopes.clone(); } - let readonly_only = args.iter().any(|a| a == "--readonly"); - if readonly_only { + if opts.readonly { let scopes: Vec = READONLY_SCOPES.iter().map(|s| s.to_string()).collect(); - let mut result = filter_scopes_by_services(scopes, services_filter); - augment_with_dynamic_scopes(&mut result, services_filter, true).await; + let mut result = filter_scopes_by_services(scopes, opts.services_filter.as_ref()); + augment_with_dynamic_scopes(&mut result, opts.services_filter.as_ref(), true).await; return result; } - if args.iter().any(|a| a == "--full") { + if opts.full { let scopes: Vec = FULL_SCOPES.iter().map(|s| s.to_string()).collect(); - let mut result = filter_scopes_by_services(scopes, services_filter); - augment_with_dynamic_scopes(&mut result, services_filter, false).await; + let mut result = filter_scopes_by_services(scopes, opts.services_filter.as_ref()); + augment_with_dynamic_scopes(&mut result, opts.services_filter.as_ref(), false).await; return result; } @@ -508,7 +557,9 @@ async fn resolve_scopes( let api_ids: Vec = enabled_apis; let scopes = crate::setup::fetch_scopes_for_apis(&api_ids).await; if !scopes.is_empty() { - if let Some(selected) = run_discovery_scope_picker(&scopes, services_filter) { + if let Some(selected) = + run_discovery_scope_picker(&scopes, opts.services_filter.as_ref()) + { return selected; } } @@ -516,14 +567,14 @@ async fn resolve_scopes( } // Fallback: simple scope picker using static SCOPE_ENTRIES - if let Some(selected) = run_simple_scope_picker(services_filter) { + if let Some(selected) = run_simple_scope_picker(opts.services_filter.as_ref()) { return selected; } } let defaults: Vec = DEFAULT_SCOPES.iter().map(|s| s.to_string()).collect(); - let mut result = filter_scopes_by_services(defaults, services_filter); - augment_with_dynamic_scopes(&mut result, services_filter, false).await; + let mut result = filter_scopes_by_services(defaults, opts.services_filter.as_ref()); + augment_with_dynamic_scopes(&mut result, opts.services_filter.as_ref(), false).await; result } @@ -923,10 +974,10 @@ fn run_simple_scope_picker(services_filter: Option<&HashSet>) -> Option< } } -async fn handle_status() -> Result<(), GwsError> { - let plain_path = plain_credentials_path(); - let enc_path = credential_store::encrypted_credentials_path(); - let token_cache = token_cache_path(); +async fn handle_status(account: Option<&str>) -> Result<(), GwsError> { + let plain_path = plain_credentials_path(account); + let enc_path = credential_store::encrypted_credentials_path(account); + let token_cache = token_cache_path(account); let has_encrypted = enc_path.exists(); let has_plain = plain_path.exists(); @@ -1013,7 +1064,7 @@ async fn handle_status() -> Result<(), GwsError> { // Skip real credential/network access in test builds if !cfg!(test) { if has_encrypted { - match credential_store::load_encrypted() { + match credential_store::load_encrypted(account) { Ok(contents) => { if let Ok(creds) = serde_json::from_str::(&contents) { if let Some(client_id) = creds.get("client_id").and_then(|v| v.as_str()) { @@ -1071,7 +1122,7 @@ async fn handle_status() -> Result<(), GwsError> { // Skip all network calls and subprocess spawning in test builds if !cfg!(test) { let creds_json_str = if has_encrypted { - credential_store::load_encrypted().ok() + credential_store::load_encrypted(account).ok() } else if has_plain { tokio::fs::read_to_string(&plain_path).await.ok() } else { @@ -1174,11 +1225,11 @@ async fn handle_status() -> Result<(), GwsError> { Ok(()) } -fn handle_logout() -> Result<(), GwsError> { - let plain_path = plain_credentials_path(); - let enc_path = credential_store::encrypted_credentials_path(); - let token_cache = token_cache_path(); - let sa_token_cache = config_dir().join("sa_token_cache.json"); +fn handle_logout(account: Option<&str>) -> Result<(), GwsError> { + let plain_path = plain_credentials_path(account); + let enc_path = credential_store::encrypted_credentials_path(account); + let token_cache = token_cache_path(account); + let sa_token_cache = sa_token_cache_path(account); let mut removed = Vec::new(); @@ -1192,7 +1243,7 @@ fn handle_logout() -> Result<(), GwsError> { } // Invalidate cached account timezone (may belong to old account) - crate::timezone::invalidate_cache(); + crate::timezone::invalidate_cache(account); let output = if removed.is_empty() { json!({ @@ -1445,49 +1496,51 @@ mod tests { use super::*; /// Helper to run resolve_scopes in tests (async). - fn run_resolve_scopes(args: &[String], project_id: Option<&str>) -> Vec { + fn run_resolve_scopes(opts: LoginOptions, project_id: Option<&str>) -> Vec { let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(resolve_scopes(args, project_id, None)) + rt.block_on(resolve_scopes(&opts, project_id)) } /// Helper to run resolve_scopes with a services filter. fn run_resolve_scopes_with_services( - args: &[String], + mut opts: LoginOptions, project_id: Option<&str>, services: &[&str], ) -> Vec { let filter: HashSet = services.iter().map(|s| s.to_string()).collect(); + opts.services_filter = Some(filter); let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(resolve_scopes(args, project_id, Some(&filter))) + rt.block_on(resolve_scopes(&opts, project_id)) } #[test] fn resolve_scopes_returns_defaults_when_no_flag() { - let args: Vec = vec![]; - let scopes = run_resolve_scopes(&args, None); + let scopes = run_resolve_scopes(LoginOptions::default(), None); assert_eq!(scopes.len(), DEFAULT_SCOPES.len()); assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive"); } #[test] fn resolve_scopes_returns_custom_scopes() { - let args: Vec = vec![ - "--scopes".to_string(), - "https://www.googleapis.com/auth/drive.readonly".to_string(), - ]; - let scopes = run_resolve_scopes(&args, None); + let opts = LoginOptions { + custom_scopes: Some(vec!["https://www.googleapis.com/auth/drive.readonly".to_string()]), + ..LoginOptions::default() + }; + let scopes = run_resolve_scopes(opts, None); assert_eq!(scopes.len(), 1); assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive.readonly"); } #[test] fn resolve_scopes_handles_multiple_comma_separated() { - let args: Vec = vec![ - "--scopes".to_string(), - "https://www.googleapis.com/auth/drive, https://www.googleapis.com/auth/gmail.readonly" - .to_string(), - ]; - let scopes = run_resolve_scopes(&args, None); + let opts = LoginOptions { + custom_scopes: Some(vec![ + "https://www.googleapis.com/auth/drive".to_string(), + "https://www.googleapis.com/auth/gmail.readonly".to_string(), + ]), + ..LoginOptions::default() + }; + let scopes = run_resolve_scopes(opts, None); assert_eq!(scopes.len(), 2); assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive"); assert_eq!(scopes[1], "https://www.googleapis.com/auth/gmail.readonly"); @@ -1495,16 +1548,19 @@ mod tests { #[test] fn resolve_scopes_ignores_trailing_flag() { - // --scopes with no value should use defaults - let args: Vec = vec!["--scopes".to_string()]; - let scopes = run_resolve_scopes(&args, None); + // In the refactored version, LoginOptions is pre-populated. + // If custom_scopes is None, it should use defaults. + let scopes = run_resolve_scopes(LoginOptions::default(), None); assert_eq!(scopes.len(), DEFAULT_SCOPES.len()); } #[test] fn resolve_scopes_readonly_returns_readonly_scopes() { - let args = vec!["--readonly".to_string()]; - let scopes = run_resolve_scopes(&args, None); + let opts = LoginOptions { + readonly: true, + ..LoginOptions::default() + }; + let scopes = run_resolve_scopes(opts, None); assert_eq!(scopes.len(), READONLY_SCOPES.len()); for scope in &scopes { assert!( @@ -1517,12 +1573,12 @@ mod tests { #[test] fn resolve_scopes_custom_overrides_readonly() { // --scopes takes priority over --readonly - let args = vec![ - "--scopes".to_string(), - "https://www.googleapis.com/auth/drive".to_string(), - "--readonly".to_string(), - ]; - let scopes = run_resolve_scopes(&args, None); + let opts = LoginOptions { + custom_scopes: Some(vec!["https://www.googleapis.com/auth/drive".to_string()]), + readonly: true, + ..LoginOptions::default() + }; + let scopes = run_resolve_scopes(opts, None); assert_eq!(scopes.len(), 1); assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive"); } @@ -1608,7 +1664,7 @@ mod tests { unsafe { std::env::remove_var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"); } - let path = plain_credentials_path(); + let path = plain_credentials_path(None); assert!(path.ends_with("credentials.json")); assert!(path.starts_with(config_dir())); } @@ -1622,7 +1678,7 @@ mod tests { "/tmp/test-creds.json", ); } - let path = plain_credentials_path(); + let path = plain_credentials_path(None); assert_eq!(path, PathBuf::from("/tmp/test-creds.json")); unsafe { std::env::remove_var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"); @@ -1631,7 +1687,7 @@ mod tests { #[test] fn token_cache_path_is_in_config_dir() { - let path = token_cache_path(); + let path = token_cache_path(None); assert!(path.ends_with("token_cache.json")); assert!(path.starts_with(config_dir())); } @@ -1640,22 +1696,27 @@ mod tests { async fn handle_auth_command_empty_args_prints_usage() { let args: Vec = vec![]; let result = handle_auth_command(&args).await; - // Empty args now prints usage and returns Ok - assert!(result.is_ok()); + // clap returns an error of kind DisplayHelp for required subcommands + assert!(result.is_err()); + if let GwsError::Validation(e) = result.unwrap_err() { + assert!(e.contains("Usage") || e.contains("gws auth")); + } else { + panic!("Expected Validation error"); + } } #[tokio::test] async fn handle_auth_command_help_flag_returns_ok() { let args = vec!["--help".to_string()]; let result = handle_auth_command(&args).await; - assert!(result.is_ok()); + assert!(result.is_err()); // clap returns ErrorKind::DisplayHelp } #[tokio::test] async fn handle_auth_command_help_short_flag_returns_ok() { let args = vec!["-h".to_string()]; let result = handle_auth_command(&args).await; - assert!(result.is_ok()); + assert!(result.is_err()); // clap returns ErrorKind::DisplayHelp } #[tokio::test] @@ -1976,8 +2037,7 @@ mod tests { #[test] fn resolve_scopes_with_services_filter() { - let args: Vec = vec![]; - let scopes = run_resolve_scopes_with_services(&args, None, &["drive", "gmail"]); + let scopes = run_resolve_scopes_with_services(LoginOptions::default(), None, &["drive", "gmail"]); assert!(!scopes.is_empty()); for scope in &scopes { let short = scope @@ -1994,8 +2054,7 @@ mod tests { #[test] fn resolve_scopes_services_filter_unknown_service_ignored() { - let args: Vec = vec![]; - let scopes = run_resolve_scopes_with_services(&args, None, &["drive", "nonexistent"]); + let scopes = run_resolve_scopes_with_services(LoginOptions::default(), None, &["drive", "nonexistent"]); assert!(!scopes.is_empty()); // Should contain drive scope but not be affected by nonexistent assert!(scopes.iter().any(|s| s.contains("/auth/drive"))); @@ -2003,8 +2062,11 @@ mod tests { #[test] fn resolve_scopes_services_takes_priority_with_readonly() { - let args = vec!["--readonly".to_string()]; - let scopes = run_resolve_scopes_with_services(&args, None, &["drive"]); + let opts = LoginOptions { + readonly: true, + ..LoginOptions::default() + }; + let scopes = run_resolve_scopes_with_services(opts, None, &["drive"]); assert!(!scopes.is_empty()); for scope in &scopes { let short = scope @@ -2019,8 +2081,11 @@ mod tests { #[test] fn resolve_scopes_services_takes_priority_with_full() { - let args = vec!["--full".to_string()]; - let scopes = run_resolve_scopes_with_services(&args, None, &["gmail"]); + let opts = LoginOptions { + full: true, + ..LoginOptions::default() + }; + let scopes = run_resolve_scopes_with_services(opts, None, &["gmail"]); assert!(!scopes.is_empty()); for scope in &scopes { let short = scope @@ -2036,11 +2101,11 @@ mod tests { #[test] fn resolve_scopes_explicit_scopes_bypass_services_filter() { // --scopes should take priority over -s - let args = vec![ - "--scopes".to_string(), - "https://www.googleapis.com/auth/calendar".to_string(), - ]; - let scopes = run_resolve_scopes_with_services(&args, None, &["drive"]); + let opts = LoginOptions { + custom_scopes: Some(vec!["https://www.googleapis.com/auth/calendar".to_string()]), + ..LoginOptions::default() + }; + let scopes = run_resolve_scopes_with_services(opts, None, &["drive"]); assert_eq!(scopes.len(), 1); assert_eq!(scopes[0], "https://www.googleapis.com/auth/calendar"); } diff --git a/src/commands.rs b/src/commands.rs index 27324e42..00d51498 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -46,6 +46,39 @@ pub fn build_cli(doc: &RestDescription) -> Command { .help("Output format: json (default), table, yaml, csv") .value_name("FORMAT") .global(true), + ) + .arg( + clap::Arg::new("account") + .long("account") + .short('A') + .help("Google account name/alias to use for authentication (e.g. 'work', 'personal')") + .value_name("NAME") + .global(true) + .value_parser(|name: &str| -> Result { + const MAX_LEN: usize = 63; + if name.is_empty() { + return Err("Account name cannot be empty.".to_string()); + } + if name.len() > MAX_LEN { + return Err(format!( + "Account name cannot be longer than {} characters.", + MAX_LEN + )); + } + if !name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + return Err( + "Account name must only contain alphanumeric characters, dashes, and underscores." + .to_string(), + ); + } + if name == "." || name == ".." { + return Err("Account name cannot be '.' or '..'.".to_string()); + } + Ok(name.to_string()) + }), ); // Inject helper commands diff --git a/src/credential_store.rs b/src/credential_store.rs index e985b76b..8c14a52d 100644 --- a/src/credential_store.rs +++ b/src/credential_store.rs @@ -371,13 +371,51 @@ pub fn active_backend_name() -> &'static str { } /// Returns the path for encrypted credentials. -pub fn encrypted_credentials_path() -> PathBuf { - crate::auth_commands::config_dir().join("credentials.enc") +pub fn encrypted_credentials_path(account: Option<&str>) -> PathBuf { + let filename = if let Some(acc) = account { + format!("credentials.{acc}.enc") + } else { + "credentials.enc".to_string() + }; + crate::auth_commands::config_dir().join(filename) +} + +/// Returns the path for plaintext credentials. +pub fn plain_credentials_path(account: Option<&str>) -> PathBuf { + if let Ok(path) = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE") { + return PathBuf::from(path); + } + let filename = if let Some(acc) = account { + format!("credentials.{acc}.json") + } else { + "credentials.json".to_string() + }; + crate::auth_commands::config_dir().join(filename) +} + +/// Returns the path for the user token cache. +pub fn token_cache_path(account: Option<&str>) -> PathBuf { + let filename = if let Some(acc) = account { + format!("token_cache.{acc}.json") + } else { + "token_cache.json".to_string() + }; + crate::auth_commands::config_dir().join(filename) +} + +/// Returns the path for the service account token cache. +pub fn sa_token_cache_path(account: Option<&str>) -> PathBuf { + let filename = if let Some(acc) = account { + format!("sa_token_cache.{acc}.json") + } else { + "sa_token_cache.json".to_string() + }; + crate::auth_commands::config_dir().join(filename) } /// Saves credentials JSON to an encrypted file. -pub fn save_encrypted(json: &str) -> anyhow::Result { - let path = encrypted_credentials_path(); +pub fn save_encrypted(json: &str, account: Option<&str>) -> anyhow::Result { + let path = encrypted_credentials_path(account); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; #[cfg(unix)] @@ -423,8 +461,8 @@ pub fn load_encrypted_from_path(path: &std::path::Path) -> anyhow::Result anyhow::Result { - load_encrypted_from_path(&encrypted_credentials_path()) +pub fn load_encrypted(account: Option<&str>) -> anyhow::Result { + load_encrypted_from_path(&encrypted_credentials_path(account)) } #[cfg(test)] diff --git a/src/executor.rs b/src/executor.rs index 73fd772f..c252462e 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -224,6 +224,7 @@ async fn handle_json_response( page_token: &mut Option, capture_output: bool, captured: &mut Vec, + account: Option<&str>, ) -> Result { if let Ok(mut json_val) = serde_json::from_str::(body_text) { *pages_fetched += 1; @@ -231,7 +232,8 @@ async fn handle_json_response( // Run Model Armor sanitization if --sanitize is enabled if let Some(template) = sanitize_template { let text_to_check = serde_json::to_string(&json_val).unwrap_or_default(); - match crate::helpers::modelarmor::sanitize_text(template, &text_to_check).await { + match crate::helpers::modelarmor::sanitize_text(template, &text_to_check, account).await + { Ok(result) => { let is_match = result.filter_match_state == "MATCH_FOUND"; if is_match { @@ -381,6 +383,7 @@ pub async fn execute_method( sanitize_mode: &crate::helpers::modelarmor::SanitizeMode, output_format: &crate::formatter::OutputFormat, capture_output: bool, + account: Option<&str>, ) -> Result, GwsError> { let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload_path)?; @@ -477,6 +480,7 @@ pub async fn execute_method( &mut page_token, capture_output, &mut captured_values, + account, ) .await?; @@ -2031,6 +2035,7 @@ async fn test_execute_method_dry_run() { &sanitize_mode, &crate::formatter::OutputFormat::default(), false, + None, ) .await; @@ -2075,6 +2080,7 @@ async fn test_execute_method_missing_path_param() { &sanitize_mode, &crate::formatter::OutputFormat::default(), false, + None, ) .await; diff --git a/src/helpers/calendar.rs b/src/helpers/calendar.rs index 8ac00a50..939647ad 100644 --- a/src/helpers/calendar.rs +++ b/src/helpers/calendar.rs @@ -157,11 +157,13 @@ TIPS: if let Some(matches) = matches.subcommand_matches("+insert") { let (params_str, body_str, scopes) = build_insert_request(matches, doc)?; + let account = matches.get_one::("account"); let scopes_str: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes_str).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; + let (token, auth_method) = + match auth::get_token(&scopes_str, account.map(|s| s.as_str())).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; let events_res = doc.resources.get("events").ok_or_else(|| { GwsError::Discovery("Resource 'events' not found".to_string()) @@ -186,6 +188,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + account.map(|s| s.as_str()), ) .await?; @@ -201,7 +204,8 @@ TIPS: } async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { let cal_scope = "https://www.googleapis.com/auth/calendar.readonly"; - let token = auth::get_token(&[cal_scope]) + let account = matches.get_one::("account"); + let token = auth::get_token(&[cal_scope], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Calendar auth failed: {e}")))?; @@ -212,7 +216,13 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { let client = crate::client::build_client()?; let tz_override = matches.get_one::("timezone").map(|s| s.as_str()); - let tz = crate::timezone::resolve_account_timezone(&client, &token, tz_override).await?; + let tz = crate::timezone::resolve_account_timezone( + &client, + &token, + tz_override, + account.map(|s| s.as_str()), + ) + .await?; // Determine time range using the account timezone so that --today and // --tomorrow align with the user's Google account day, not the machine. diff --git a/src/helpers/chat.rs b/src/helpers/chat.rs index 94493e53..1fa5a2b7 100644 --- a/src/helpers/chat.rs +++ b/src/helpers/chat.rs @@ -77,11 +77,13 @@ TIPS: // immediately, returning `Err(GwsError)` from the async block. let (params_str, body_str, scopes) = build_send_request(&config, doc)?; + let account = matches.get_one::("account"); let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scope_strs).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; + let (token, auth_method) = + match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; // Method: spaces.messages.create let spaces_res = doc.resources.get("spaces").ok_or_else(|| { @@ -116,6 +118,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + account.map(|s| s.as_str()), ) .await?; diff --git a/src/helpers/docs.rs b/src/helpers/docs.rs index 3f6b3896..5194619c 100644 --- a/src/helpers/docs.rs +++ b/src/helpers/docs.rs @@ -69,11 +69,13 @@ TIPS: if let Some(matches) = matches.subcommand_matches("+write") { let (params_str, body_str, scopes) = build_write_request(matches, doc)?; + let account = matches.get_one::("account"); let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scope_strs).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; + let (token, auth_method) = + match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; // Method: documents.batchUpdate let documents_res = doc.resources.get("documents").ok_or_else(|| { @@ -106,6 +108,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + account.map(|s| s.as_str()), ) .await?; diff --git a/src/helpers/drive.rs b/src/helpers/drive.rs index 393e0fde..781ec3b7 100644 --- a/src/helpers/drive.rs +++ b/src/helpers/drive.rs @@ -96,10 +96,12 @@ TIPS: let body_str = metadata.to_string(); let scopes: Vec<&str> = create_method.scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; + let account = matches.get_one::("account"); + let (token, auth_method) = + match auth::get_token(&scopes, account.map(|s| s.as_str())).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; executor::execute_method( doc, @@ -117,6 +119,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + account.map(|s| s.as_str()), ) .await?; diff --git a/src/helpers/events/renew.rs b/src/helpers/events/renew.rs index 3dd1d627..c3f222b0 100644 --- a/src/helpers/events/renew.rs +++ b/src/helpers/events/renew.rs @@ -31,7 +31,8 @@ pub(super) async fn handle_renew( ) -> Result<(), GwsError> { let config = parse_renew_args(matches)?; let client = crate::client::build_client()?; - let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE]) + let account = matches.get_one::("account"); + let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Failed to get token: {e}")))?; diff --git a/src/helpers/events/subscribe.rs b/src/helpers/events/subscribe.rs index edbfb4dc..3a979314 100644 --- a/src/helpers/events/subscribe.rs +++ b/src/helpers/events/subscribe.rs @@ -112,10 +112,11 @@ pub(super) async fn handle_subscribe( } let client = crate::client::build_client()?; - let pubsub_token_provider = auth::token_provider(&[PUBSUB_SCOPE]); + let account = matches.get_one::("account"); + let pubsub_token_provider = auth::token_provider(&[PUBSUB_SCOPE], account.cloned()); // Get Pub/Sub token - let pubsub_token = auth::get_token(&[PUBSUB_SCOPE]) + let pubsub_token = auth::get_token(&[PUBSUB_SCOPE], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Failed to get Pub/Sub token: {e}")))?; @@ -187,7 +188,7 @@ pub(super) async fn handle_subscribe( // 3. Create Workspace Events subscription eprintln!("Creating Workspace Events subscription..."); - let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE]) + let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE], account.map(|s| s.as_str())) .await .map_err(|e| { GwsError::Auth(format!("Failed to get Workspace Events token: {e}")) diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index e9b76da6..e2417227 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -22,13 +22,14 @@ pub(super) async fn handle_forward( let config = parse_forward_args(matches); let dry_run = matches.get_flag("dry-run"); + let account = matches.get_one::("account"); let (original, token) = if dry_run { ( OriginalMessage::dry_run_placeholder(&config.message_id), None, ) } else { - let t = auth::get_token(&[GMAIL_SCOPE]) + let t = auth::get_token(&[GMAIL_SCOPE], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; let client = crate::client::build_client()?; diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 999d65ce..df7ae74d 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -645,11 +645,13 @@ pub(super) async fn send_raw_email( let params = json!({ "userId": "me" }); let params_str = params.to_string(); + let account = matches.get_one::("account"); let (token, auth_method) = match existing_token { Some(t) => (Some(t.to_string()), executor::AuthMethod::OAuth), None => { let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect(); - match auth::get_token(&scopes).await { + let scope_strs: Vec<&str> = scopes.to_vec(); + match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None), Err(e) => return Err(GwsError::Auth(format!("Gmail auth failed: {e}"))), @@ -679,6 +681,7 @@ pub(super) async fn send_raw_email( &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + account.map(|s| s.as_str()), ) .await?; diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index a9ce8dd1..2326b403 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -23,13 +23,14 @@ pub(super) async fn handle_reply( let config = parse_reply_args(matches)?; let dry_run = matches.get_flag("dry-run"); + let account = matches.get_one::("account"); let (original, token) = if dry_run { ( OriginalMessage::dry_run_placeholder(&config.message_id), None, ) } else { - let t = auth::get_token(&[GMAIL_SCOPE]) + let t = auth::get_token(&[GMAIL_SCOPE], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; let client = crate::client::build_client()?; diff --git a/src/helpers/gmail/triage.rs b/src/helpers/gmail/triage.rs index ec23bfba..0bb9b3d2 100644 --- a/src/helpers/gmail/triage.rs +++ b/src/helpers/gmail/triage.rs @@ -41,7 +41,8 @@ pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> { // gmail.metadata scope. When a token carries both metadata and modify // scopes the API may resolve to the metadata path and reject `q` with 403. // gmail.readonly always supports `q`. - let token = auth::get_token(&[GMAIL_READONLY_SCOPE]) + let account = matches.get_one::("account"); + let token = auth::get_token(&[GMAIL_READONLY_SCOPE], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index 15bc9889..b0939a06 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -16,16 +16,17 @@ pub(super) async fn handle_watch( } let client = crate::client::build_client()?; - let gmail_token_provider = auth::token_provider(&[GMAIL_SCOPE]); - let pubsub_token_provider = auth::token_provider(&[PUBSUB_SCOPE]); + let account = matches.get_one::("account"); + let gmail_token_provider = auth::token_provider(&[GMAIL_SCOPE], account.cloned()); + let pubsub_token_provider = auth::token_provider(&[PUBSUB_SCOPE], account.cloned()); // Get tokens - let gmail_token = auth::get_token(&[GMAIL_SCOPE]) + let gmail_token = auth::get_token(&[GMAIL_SCOPE], account.map(|s| s.as_str())) .await - .context("Failed to get Gmail token")?; - let pubsub_token = auth::get_token(&[PUBSUB_SCOPE]) + .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; + let pubsub_token = auth::get_token(&[PUBSUB_SCOPE], account.map(|s| s.as_str())) .await - .context("Failed to get Pub/Sub token")?; + .map_err(|e| GwsError::Auth(format!("Pub/Sub auth failed: {e}")))?; let (pubsub_subscription, topic_name, created_resources) = if let Some(ref sub_name) = config.subscription @@ -208,6 +209,7 @@ pub(super) async fn handle_watch( sanitize_config, pubsub_api_base: PUBSUB_API_BASE, gmail_api_base: GMAIL_API_BASE, + account: account.cloned(), }; let result = watch_pull_loop( &runtime, @@ -305,7 +307,6 @@ async fn watch_pull_loop( let pull_response: Value = resp.json().await.context("Failed to parse pull response")?; let (ack_ids, max_history_id) = process_pull_response(&pull_response); - if max_history_id > *last_history_id && *last_history_id > 0 { // Fetch new messages via history API fetch_and_output_messages( @@ -316,6 +317,7 @@ async fn watch_pull_loop( config.output_dir.as_ref(), runtime.sanitize_config, runtime.gmail_api_base, + runtime, ) .await?; } @@ -393,6 +395,7 @@ fn process_pull_response(response: &Value) -> (Vec, u64) { } /// Fetches new messages since `start_history_id` and outputs them as NDJSON. +#[allow(clippy::too_many_arguments)] async fn fetch_and_output_messages( client: &reqwest::Client, gmail_token_provider: &dyn auth::AccessTokenProvider, @@ -401,6 +404,7 @@ async fn fetch_and_output_messages( output_dir: Option<&std::path::PathBuf>, sanitize_config: &crate::helpers::modelarmor::SanitizeConfig, gmail_api_base: &str, + runtime: &WatchRuntime<'_>, ) -> Result<(), GwsError> { let gmail_token = gmail_token_provider .access_token() @@ -438,7 +442,12 @@ async fn fetch_and_output_messages( // Apply sanitization if configured if let Some(ref template) = sanitize_config.template { let text_to_check = serde_json::to_string(&full_msg).unwrap_or_default(); - match crate::helpers::modelarmor::sanitize_text(template, &text_to_check).await + match crate::helpers::modelarmor::sanitize_text( + template, + &text_to_check, + runtime.account.as_deref(), + ) + .await { Ok(result) => { if let Some(sanitized_msg) = apply_sanitization_result( @@ -554,6 +563,7 @@ struct WatchRuntime<'a> { sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, pubsub_api_base: &'a str, gmail_api_base: &'a str, + account: Option, } fn parse_watch_args(matches: &ArgMatches) -> Result { @@ -942,6 +952,7 @@ mod tests { sanitize_config: &sanitize_config, pubsub_api_base: &pubsub_base, gmail_api_base: &gmail_base, + account: None, }; watch_pull_loop( diff --git a/src/helpers/modelarmor.rs b/src/helpers/modelarmor.rs index 8ac9fc1c..9e27d89a 100644 --- a/src/helpers/modelarmor.rs +++ b/src/helpers/modelarmor.rs @@ -226,16 +226,29 @@ TIPS: _sanitize_config: &'a SanitizeConfig, ) -> Pin> + Send + 'a>> { Box::pin(async move { + let account = matches.get_one::("account"); if let Some(sub) = matches.subcommand_matches("+sanitize-prompt") { - handle_sanitize(sub, "sanitizeUserPrompt", "userPromptData").await?; + handle_sanitize( + sub, + "sanitizeUserPrompt", + "userPromptData", + account.map(|s| s.as_str()), + ) + .await?; return Ok(true); } if let Some(sub) = matches.subcommand_matches("+sanitize-response") { - handle_sanitize(sub, "sanitizeModelResponse", "modelResponseData").await?; + handle_sanitize( + sub, + "sanitizeModelResponse", + "modelResponseData", + account.map(|s| s.as_str()), + ) + .await?; return Ok(true); } if let Some(sub) = matches.subcommand_matches("+create-template") { - handle_create_template(sub).await?; + handle_create_template(sub, account.map(|s| s.as_str())).await?; return Ok(true); } Ok(false) @@ -247,10 +260,14 @@ pub const CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-pl /// Sanitize text through a Model Armor template and return the result. /// Template format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE -pub async fn sanitize_text(template: &str, text: &str) -> Result { +pub async fn sanitize_text( + template: &str, + text: &str, + account: Option<&str>, +) -> Result { let (body, url) = build_sanitize_request_data(template, text, "sanitizeUserPrompt")?; - let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE]) + let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE], account) .await .context("Failed to get auth token for Model Armor")?; @@ -280,8 +297,8 @@ pub async fn sanitize_text(template: &str, text: &str) -> Result Result<(), GwsError> { - let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE]) +async fn model_armor_post(url: &str, body: &str, account: Option<&str>) -> Result<(), GwsError> { + let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE], account) .await .context("Failed to get auth token")?; @@ -314,6 +331,7 @@ async fn handle_sanitize( matches: &ArgMatches, method_name: &str, data_field: &str, + account: Option<&str>, ) -> Result<(), GwsError> { let template_raw = matches.get_one::("template").unwrap(); let template = crate::validate::validate_resource_name(template_raw)?; @@ -329,7 +347,7 @@ async fn handle_sanitize( let base = regional_base_url(location); let url = format!("{base}/{template}:{method_name}"); - model_armor_post(&url, &body).await + model_armor_post(&url, &body, account).await } #[derive(Debug, PartialEq)] @@ -378,7 +396,10 @@ pub fn build_create_template_url(config: &CreateTemplateConfig) -> String { } /// Handle +create-template -async fn handle_create_template(matches: &ArgMatches) -> Result<(), GwsError> { +async fn handle_create_template( + matches: &ArgMatches, + account: Option<&str>, +) -> Result<(), GwsError> { let config = parse_create_template_args(matches)?; let url = build_create_template_url(&config); @@ -391,7 +412,7 @@ async fn handle_create_template(matches: &ArgMatches) -> Result<(), GwsError> { .unwrap_or("jailbreak") ); - model_armor_post(&url, &config.body).await + model_armor_post(&url, &config.body, account).await } /// Loads a preset template JSON file from the templates/modelarmor/ directory. diff --git a/src/helpers/script.rs b/src/helpers/script.rs index b0ad3497..61be9c82 100644 --- a/src/helpers/script.rs +++ b/src/helpers/script.rs @@ -103,10 +103,12 @@ TIPS: let body_str = body.to_string(); let scopes: Vec<&str> = update_method.scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; + let account = matches.get_one::("account"); + let (token, auth_method) = + match auth::get_token(&scopes, account.map(|s| s.as_str())).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; let params = json!({ "scriptId": script_id @@ -129,6 +131,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + account.map(|s| s.as_str()), ) .await?; diff --git a/src/helpers/sheets.rs b/src/helpers/sheets.rs index 76f36ab2..a3c36e75 100644 --- a/src/helpers/sheets.rs +++ b/src/helpers/sheets.rs @@ -106,10 +106,12 @@ TIPS: let (params_str, body_str, scopes) = build_append_request(&config, doc)?; let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scope_strs).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; + let account = matches.get_one::("account"); + let (token, auth_method) = + match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; let spreadsheets_res = doc.resources.get("spreadsheets").ok_or_else(|| { GwsError::Discovery("Resource 'spreadsheets' not found".to_string()) @@ -143,6 +145,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + account.map(|s| s.as_str()), ) .await?; @@ -165,10 +168,12 @@ TIPS: })?; let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scope_strs).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; + let account = matches.get_one::("account"); + let (token, auth_method) = + match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; executor::execute_method( doc, @@ -186,6 +191,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + account.map(|s| s.as_str()), ) .await?; diff --git a/src/helpers/workflows.rs b/src/helpers/workflows.rs index 48d21784..b662eaf2 100644 --- a/src/helpers/workflows.rs +++ b/src/helpers/workflows.rs @@ -267,16 +267,23 @@ fn format_and_print(value: &Value, matches: &ArgMatches) { } async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> { + let account = matches.get_one::("account"); let cal_scope = "https://www.googleapis.com/auth/calendar.readonly"; let tasks_scope = "https://www.googleapis.com/auth/tasks.readonly"; - let token = auth::get_token(&[cal_scope, tasks_scope]) + let token = auth::get_token(&[cal_scope, tasks_scope], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?; let client = crate::client::build_client()?; // Resolve account timezone for day boundaries - let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?; + let tz = crate::timezone::resolve_account_timezone( + &client, + &token, + None, + account.map(|s| s.as_str()), + ) + .await?; let now_in_tz = chrono::Utc::now().with_timezone(&tz); let today_start_tz = crate::timezone::start_of_today(tz)?; let today_end_tz = today_start_tz + chrono::Duration::days(1); @@ -361,8 +368,9 @@ async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> { } async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> { + let account = matches.get_one::("account"); let cal_scope = "https://www.googleapis.com/auth/calendar.readonly"; - let token = auth::get_token(&[cal_scope]) + let token = auth::get_token(&[cal_scope], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?; @@ -373,7 +381,13 @@ async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> { .unwrap_or("primary"); // Use account timezone for current time - let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?; + let tz = crate::timezone::resolve_account_timezone( + &client, + &token, + None, + account.map(|s| s.as_str()), + ) + .await?; let now_rfc = chrono::Utc::now().with_timezone(&tz).to_rfc3339(); let events_url = format!( @@ -438,9 +452,10 @@ async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> { } async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> { + let account = matches.get_one::("account"); let gmail_scope = "https://www.googleapis.com/auth/gmail.readonly"; let tasks_scope = "https://www.googleapis.com/auth/tasks"; - let token = auth::get_token(&[gmail_scope, tasks_scope]) + let token = auth::get_token(&[gmail_scope, tasks_scope], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?; @@ -528,16 +543,23 @@ async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> { } async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> { + let account = matches.get_one::("account"); let cal_scope = "https://www.googleapis.com/auth/calendar.readonly"; let gmail_scope = "https://www.googleapis.com/auth/gmail.readonly"; - let token = auth::get_token(&[cal_scope, gmail_scope]) + let token = auth::get_token(&[cal_scope, gmail_scope], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?; let client = crate::client::build_client()?; // Resolve account timezone for week boundaries - let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?; + let tz = crate::timezone::resolve_account_timezone( + &client, + &token, + None, + account.map(|s| s.as_str()), + ) + .await?; let now_in_tz = chrono::Utc::now().with_timezone(&tz); let week_end = now_in_tz + chrono::Duration::days(7); let time_min = now_in_tz.to_rfc3339(); @@ -609,9 +631,10 @@ async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> { } async fn handle_file_announce(matches: &ArgMatches) -> Result<(), GwsError> { + let account = matches.get_one::("account"); let drive_scope = "https://www.googleapis.com/auth/drive.readonly"; let chat_scope = "https://www.googleapis.com/auth/chat.messages.create"; - let token = auth::get_token(&[drive_scope, chat_scope]) + let token = auth::get_token(&[drive_scope, chat_scope], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?; diff --git a/src/main.rs b/src/main.rs index bd72c642..f61bd8f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -237,8 +237,10 @@ async fn run() -> Result<(), GwsError> { // to avoid restrictive scopes like gmail.metadata that block query parameters. let scopes: Vec<&str> = select_scope(&method.scopes).into_iter().collect(); + let account = matches.get_one::("account"); + // Authenticate: try OAuth, fail with error if credentials exist but are broken - let (token, auth_method) = match auth::get_token(&scopes).await { + let (token, auth_method) = match auth::get_token(&scopes, account.map(|s| s.as_str())).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(e) => { // If credentials were found but failed (e.g. decryption error, invalid token), @@ -271,9 +273,10 @@ async fn run() -> Result<(), GwsError> { &sanitize_config.mode, &output_format, false, + account.map(|s| s.as_str()), ) - .await - .map(|_| ()) + .await?; + Ok(()) } /// Select the best scope from a method's scope list. diff --git a/src/setup.rs b/src/setup.rs index 10aebe1b..a5cdfdf7 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -374,36 +374,7 @@ pub struct SetupOptions { pub project: Option, pub dry_run: bool, pub login: bool, -} - -/// Parse setup flags from args. -pub fn parse_setup_args(args: &[String]) -> SetupOptions { - let mut project = None; - let mut dry_run = false; - let mut login = false; - let mut i = 0; - while i < args.len() { - if args[i] == "--project" && i + 1 < args.len() { - project = Some(args[i + 1].clone()); - i += 2; - } else if args[i].starts_with("--project=") { - project = Some(args[i].split_once('=').unwrap().1.to_string()); - i += 1; - } else if args[i] == "--dry-run" { - dry_run = true; - i += 1; - } else if args[i] == "--login" { - login = true; - i += 1; - } else { - i += 1; - } - } - SetupOptions { - project, - dry_run, - login, - } + pub account: Option, } // ── gcloud helpers ────────────────────────────────────────────── @@ -1471,9 +1442,15 @@ async fn stage_configure_oauth(ctx: &mut SetupContext) -> Result = crate::credential_store::load_encrypted() - .ok() - .and_then(|s| serde_json::from_str(&s).ok()); + let account_param = if ctx.account.is_empty() { + None + } else { + Some(ctx.account.as_str()) + }; + let current_creds: Option = + crate::credential_store::load_encrypted(account_param) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()); w.show_message(&format!( concat!( @@ -1597,8 +1574,7 @@ fn prompt_login_after_setup() -> Result { } /// Run the full setup flow. Orchestrates all steps and outputs JSON summary. -pub async fn run_setup(args: &[String]) -> Result<(), GwsError> { - let opts = parse_setup_args(args); +pub async fn run_setup(opts: SetupOptions) -> Result<(), GwsError> { let dry_run = opts.dry_run; let interactive = std::io::IsTerminal::is_terminal(&std::io::stdin()) && !dry_run; @@ -1619,15 +1595,15 @@ pub async fn run_setup(args: &[String]) -> Result<(), GwsError> { wizard, interactive, dry_run, - opts, - account: String::new(), - project_id: String::new(), + account: opts.account.clone().unwrap_or_default(), + project_id: opts.project.clone().unwrap_or_default(), api_ids: Vec::new(), client_id: String::new(), client_secret: String::new(), enabled: Vec::new(), skipped: Vec::new(), failed: Vec::new(), + opts, }; let mut stage = SetupStage::CheckGcloud; @@ -1682,7 +1658,8 @@ pub async fn run_setup(args: &[String]) -> Result<(), GwsError> { eprintln!("\n✅ {message}"); if run_login { - crate::auth_commands::run_login(&[]).await?; + crate::auth_commands::run_login(crate::auth_commands::LoginOptions::default(), Some(&ctx.account)) + .await?; } Ok(()) @@ -1823,64 +1800,6 @@ mod tests { // ── parse_setup_args tests ────────────────────────────────── - #[test] - fn test_parse_setup_args_empty() { - let opts = parse_setup_args(&[]); - assert!(opts.project.is_none()); - assert!(!opts.dry_run); - assert!(!opts.login); - } - - #[test] - fn test_parse_setup_args_with_project() { - let args = vec!["--project".into(), "my-project".into()]; - let opts = parse_setup_args(&args); - assert_eq!(opts.project.as_deref(), Some("my-project")); - assert!(!opts.login); - } - - #[test] - fn test_parse_setup_args_with_project_equals() { - let args = vec!["--project=my-project".into()]; - let opts = parse_setup_args(&args); - assert_eq!(opts.project.as_deref(), Some("my-project")); - assert!(!opts.login); - } - - #[test] - fn test_parse_setup_args_ignores_unknown() { - let args = vec!["--verbose".into(), "--unknown".into()]; - let opts = parse_setup_args(&args); - assert!(opts.project.is_none()); - assert!(!opts.login); - } - - #[test] - fn test_parse_setup_args_dry_run() { - let args = vec!["--dry-run".into()]; - let opts = parse_setup_args(&args); - assert!(opts.dry_run); - assert!(!opts.login); - } - - #[test] - fn test_parse_setup_args_dry_run_with_project() { - let args: Vec = vec!["--dry-run".into(), "--project".into(), "p".into()]; - let opts = parse_setup_args(&args); - assert!(opts.dry_run); - assert_eq!(opts.project.as_deref(), Some("p")); - assert!(!opts.login); - } - - #[test] - fn test_parse_setup_args_login_flag() { - let args: Vec = vec!["--login".into()]; - let opts = parse_setup_args(&args); - assert!(opts.login); - assert!(!opts.dry_run); - assert!(opts.project.is_none()); - } - #[test] fn test_should_offer_login_prompt_default_interactive() { assert!(should_offer_login_prompt(true, false, false, true)); diff --git a/src/setup_tui.rs b/src/setup_tui.rs index 67591526..75087cf9 100644 --- a/src/setup_tui.rs +++ b/src/setup_tui.rs @@ -23,6 +23,7 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, }; +use ratatui::prelude::Stylize; use ratatui::{ layout::{Constraint, Layout}, style::{Color, Modifier, Style}, diff --git a/src/timezone.rs b/src/timezone.rs index b7cd6577..3d7f19b2 100644 --- a/src/timezone.rs +++ b/src/timezone.rs @@ -30,25 +30,42 @@ const CACHE_FILENAME: &str = "account_timezone"; /// Cache TTL in seconds (24 hours). const CACHE_TTL_SECS: u64 = 86400; -/// Returns the path to the timezone cache file. -fn cache_path() -> PathBuf { - crate::auth_commands::config_dir().join(CACHE_FILENAME) +/// Returns the path to the timezone cache file for a specific account. +fn cache_path(account: Option<&str>) -> PathBuf { + let filename = if let Some(acc) = account { + format!("{}.{}", CACHE_FILENAME, acc) + } else { + CACHE_FILENAME.to_string() + }; + crate::auth_commands::config_dir().join(filename) } /// Remove the cached timezone file. Called on auth login/logout to /// invalidate stale values when the account changes. -pub fn invalidate_cache() { - let path = cache_path(); +pub fn invalidate_cache(account: Option<&str>) { + // 1. Invalidate account-specific cache + let path = cache_path(account); if let Err(e) = std::fs::remove_file(&path) { if e.kind() != std::io::ErrorKind::NotFound { - tracing::warn!(path = %path.display(), error = %e, "failed to invalidate timezone cache"); + tracing::warn!(path = %path.display(), error = %e, "failed to invalidate account-specific timezone cache"); + } + } + + // 2. ALSO invalidate default cache if an account was provided, + // because it might have been used as a fallback or be stale. + if account.is_some() { + let default_path = cache_path(None); + if let Err(e) = std::fs::remove_file(&default_path) { + if e.kind() != std::io::ErrorKind::NotFound { + tracing::warn!(path = %default_path.display(), error = %e, "failed to invalidate default timezone cache"); + } } } } /// Read the cached timezone if it exists and is fresh (< 24h old). -fn read_cache() -> Option { - let path = cache_path(); +fn read_cache(account: Option<&str>) -> Option { + let path = cache_path(account); let metadata = std::fs::metadata(&path).ok()?; let modified = metadata.modified().ok()?; let age = std::time::SystemTime::now().duration_since(modified).ok()?; @@ -61,8 +78,8 @@ fn read_cache() -> Option { } /// Write a timezone name to the cache file. -fn write_cache(tz_name: &str) { - let path = cache_path(); +fn write_cache(tz_name: &str, account: Option<&str>) { + let path = cache_path(account); if let Some(parent) = path.parent() { if let Err(e) = std::fs::create_dir_all(parent) { tracing::warn!(path = %parent.display(), error = %e, "failed to create timezone cache directory"); @@ -75,7 +92,11 @@ fn write_cache(tz_name: &str) { } /// Fetch the account timezone from the Google Calendar Settings API. -async fn fetch_account_timezone(client: &reqwest::Client, token: &str) -> Result { +async fn fetch_account_timezone( + client: &reqwest::Client, + token: &str, + account: Option<&str>, +) -> Result { let url = "https://www.googleapis.com/calendar/v3/users/me/settings/timezone"; let resp = client .get(url) @@ -117,7 +138,7 @@ async fn fetch_account_timezone(client: &reqwest::Client, token: &str) -> Result })?; // Cache for future use - write_cache(tz_name); + write_cache(tz_name, account); tracing::info!( timezone = tz_name, source = "calendar_api", @@ -145,6 +166,7 @@ pub async fn resolve_account_timezone( client: &reqwest::Client, token: &str, tz_override: Option<&str>, + account: Option<&str>, ) -> Result { // 1. Explicit override — fail if invalid if let Some(tz_str) = tz_override { @@ -158,13 +180,13 @@ pub async fn resolve_account_timezone( } // 2. Check cache - if let Some(tz) = read_cache() { + if let Some(tz) = read_cache(account) { tracing::debug!(timezone = %tz, source = "cache", "using cached timezone"); return Ok(tz); } // 3. Fetch from Calendar Settings API - match fetch_account_timezone(client, token).await { + match fetch_account_timezone(client, token, account).await { Ok(tz) => return Ok(tz), Err(e) => { tracing::warn!(error = %e, "failed to fetch account timezone, falling back to local");