diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4fcc928..aad45a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to rust-ai-driven-development-pipeline-template +# Contributing to auth-sync Thank you for your interest in contributing! This document provides guidelines and instructions for contributing to this project. @@ -7,8 +7,8 @@ Thank you for your interest in contributing! This document provides guidelines a 1. **Fork and clone the repository** ```bash - git clone https://github.com/YOUR-USERNAME/rust-ai-driven-development-pipeline-template.git - cd rust-ai-driven-development-pipeline-template + git clone https://github.com/YOUR-USERNAME/auth-sync.git + cd auth-sync ``` 2. **Install Rust** @@ -166,7 +166,7 @@ Use Rust documentation comments: /// # Examples /// /// ``` -/// use my_package::example_function; +/// use sync_auth::example_function; /// let result = example_function(1, 2); /// assert_eq!(result, 3); /// ``` diff --git a/Cargo.lock b/Cargo.lock index 871796e..f7db27f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,80 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "async-stream" version = "0.3.6" @@ -24,12 +98,204 @@ dependencies = [ "syn", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "bytes" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-core" version = "0.3.31" @@ -37,95 +303,1113 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] -name = "my-package" -version = "0.1.0" +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "tokio", - "tokio-test", + "cfg-if", + "libc", + "wasi", ] [[package]] -name = "pin-project-lite" -version = "0.2.16" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] [[package]] -name = "proc-macro2" -version = "1.0.103" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "unicode-ident", + "foldhash", ] [[package]] -name = "quote" -version = "1.0.42" +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ - "proc-macro2", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", ] [[package]] -name = "syn" -version = "2.0.111" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "cc", ] [[package]] -name = "tokio" -version = "1.48.0" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ - "pin-project-lite", - "tokio-macros", + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] -name = "tokio-macros" -version = "2.6.0" +name = "inotify" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" dependencies = [ - "proc-macro2", - "quote", - "syn", + "bitflags 1.3.2", + "inotify-sys", + "libc", ] [[package]] -name = "tokio-stream" -version = "0.1.17" +name = "inotify-sys" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", + "libc", ] [[package]] -name = "tokio-test" -version = "0.4.4" +name = "instant" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ - "async-stream", - "bytes", - "futures-core", - "tokio", - "tokio-stream", + "cfg-if", ] [[package]] -name = "unicode-ident" -version = "1.0.22" +name = "is_terminal_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync-auth" +version = "0.2.0" +dependencies = [ + "async-trait", + "chrono", + "clap", + "dirs", + "notify", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tokio-test", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index e02d66c..f697934 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,28 +1,40 @@ [package] -name = "my-package" +name = "sync-auth" version = "0.2.0" edition = "2021" -description = "A Rust package template for AI-driven development" +description = "Bidirectional auth credential sync for dev tools (Claude Code, GitHub CLI, GitLab CLI, Codex, Gemini CLI, and more) via Git repositories" readme = "README.md" license = "Unlicense" -keywords = ["template", "rust", "ai-driven"] -categories = ["development-tools"] -repository = "https://github.com/link-foundation/rust-ai-driven-development-pipeline-template" -documentation = "https://github.com/link-foundation/rust-ai-driven-development-pipeline-template" +keywords = ["auth", "sync", "credentials", "cli", "devtools"] +categories = ["command-line-utilities", "development-tools", "authentication"] +repository = "https://github.com/link-foundation/auth-sync" +documentation = "https://github.com/link-foundation/auth-sync" rust-version = "1.70" [lib] -name = "my_package" +name = "sync_auth" path = "src/lib.rs" [[bin]] -name = "my-package" +name = "sync-auth" path = "src/main.rs" [dependencies] -tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "time"] } +clap = { version = "4", features = ["derive", "env"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "time", "process", "fs", "signal"] } +toml = "0.8" +chrono = { version = "0.4", features = ["serde"] } +dirs = "6" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +thiserror = "2" +async-trait = "0.1" +notify = "7" [dev-dependencies] +tempfile = "3" tokio-test = "0.4" [lints.rust] @@ -38,6 +50,11 @@ module_name_repetitions = "allow" too_many_lines = "allow" missing_errors_doc = "allow" missing_panics_doc = "allow" +needless_lifetimes = "allow" +needless_borrow = "allow" +unnecessary_literal_bound = "allow" +must_use_candidate = "allow" +doc_markdown = "allow" [profile.release] lto = true diff --git a/README.md b/README.md index a4581ea..45a58ae 100644 --- a/README.md +++ b/README.md @@ -1,289 +1,237 @@ -# rust-ai-driven-development-pipeline-template +# sync-auth -A comprehensive template for AI-driven Rust development with full CI/CD pipeline support. +Bidirectional auth credential sync for dev tools via Git repositories. -[![CI/CD Pipeline](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template/workflows/CI%2FCD%20Pipeline/badge.svg)](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template/actions) +[![CI/CD Pipeline](https://github.com/link-foundation/auth-sync/workflows/CI%2FCD%20Pipeline/badge.svg)](https://github.com/link-foundation/auth-sync/actions) [![Rust Version](https://img.shields.io/badge/rust-1.70%2B-blue.svg)](https://www.rust-lang.org/) [![License: Unlicense](https://img.shields.io/badge/license-Unlicense-blue.svg)](http://unlicense.org/) -## Features - -- **Rust stable support**: Works with Rust stable version -- **Cross-platform testing**: CI runs on Ubuntu, macOS, and Windows -- **Comprehensive testing**: Unit tests, integration tests, and doc tests -- **Code quality**: rustfmt + Clippy with pedantic lints -- **Pre-commit hooks**: Automated code quality checks before commits -- **CI/CD pipeline**: GitHub Actions with multi-platform support -- **Changelog management**: Fragment-based changelog (like Changesets/Scriv) -- **Release automation**: Automatic GitHub releases - -## Quick Start +Sync authentication credentials for developer tools (GitHub CLI, GitLab CLI, Claude Code, Codex, Gemini CLI, and more) through a Git repository. Works in containers, CI runners, and across machines. -### Using This Template +## Problem -1. Click "Use this template" on GitHub to create a new repository -2. Clone your new repository -3. Update `Cargo.toml` with your package name and description -4. Rename the library and binary in `Cargo.toml` -5. Update imports in tests and examples -6. Build and start developing! +Developers using AI coding tools and platform CLIs need to re-authenticate in every new container, CI runner, or machine. There's no universal way to sync these credentials. -### Development Setup - -```bash -# Clone the repository -git clone https://github.com/link-foundation/rust-ai-driven-development-pipeline-template.git -cd rust-ai-driven-development-pipeline-template +## Features -# Build the project -cargo build +- **Bidirectional sync** -- local credentials to/from a Git repository +- **7 built-in providers**: `gh`, `glab`, `claude`, `codex`, `gemini`, `opencode`, `qwen-coder` +- **Extensible** -- add custom providers by implementing the `AuthProvider` trait +- **Conflict resolution** -- skips expired/dead tokens, prefers fresher credentials +- **Shallow clone** -- fast initial setup with `--depth 1` +- **Watch mode** -- continuous monitoring and periodic sync +- **Daemon support** -- start/stop/restart as background process or systemd service +- **CI/CD ready** -- usable in GitHub Actions, GitLab CI, Docker containers +- **Config file + env vars** -- TOML config with CLI/env override support + +## Supported Providers + +| Provider | Tool | Credential paths | +|---|---|---| +| `gh` | GitHub CLI | `~/.config/gh/` | +| `glab` | GitLab CLI | `~/.config/glab-cli/` | +| `claude` | Claude Code | `~/.claude/`, `~/.claude.json` | +| `codex` | OpenAI Codex CLI | `~/.codex/` (`auth.json`, `config.toml`) | +| `gemini` | Gemini CLI | `~/.gemini/` (`.env`, `oauth_creds.json`) | +| `opencode` | Opencode | `~/.local/share/opencode/`, `~/.config/opencode/` | +| `qwen-coder` | Qwen Code | `~/.qwen/` (`oauth_creds.json`, `settings.json`) | -# Run tests -cargo test +## Quick Start -# Run the example binary -cargo run +### Install -# Run an example -cargo run --example basic_usage +```bash +cargo install sync-auth ``` -### Running Tests +### Initialize config ```bash -# Run all tests -cargo test - -# Run tests with verbose output -cargo test --verbose - -# Run doc tests -cargo test --doc - -# Run a specific test -cargo test test_add_positive_numbers - -# Run tests with output -cargo test -- --nocapture +sync-auth init +# Edit ~/.config/sync-auth/config.toml with your repo URL ``` -### Code Quality Checks +### Basic usage ```bash -# Format code -cargo fmt +# Pull credentials from remote repo +sync-auth --repo https://github.com/USER/credentials.git pull -# Check formatting (CI style) -cargo fmt --check +# Push local credentials to remote repo +sync-auth --repo https://github.com/USER/credentials.git push -# Run Clippy lints -cargo clippy --all-targets --all-features +# Bidirectional sync +sync-auth --repo https://github.com/USER/credentials.git sync -# Check file size limits (requires rust-script: cargo install rust-script) -rust-script scripts/check-file-size.rs +# Sync only specific providers +sync-auth --repo https://github.com/USER/credentials.git -p gh,claude sync -# Run all checks -cargo fmt --check && cargo clippy --all-targets --all-features && rust-script scripts/check-file-size.rs -``` +# Watch mode (sync every 60 seconds) +sync-auth --repo https://github.com/USER/credentials.git watch --interval 60 -## Project Structure +# Check status +sync-auth --repo https://github.com/USER/credentials.git status +# List available providers +sync-auth providers ``` -. -├── .github/ -│ └── workflows/ -│ └── release.yml # CI/CD pipeline configuration -├── changelog.d/ # Changelog fragments -│ ├── README.md # Fragment instructions -│ └── *.md # Individual changelog entries -├── examples/ -│ └── basic_usage.rs # Usage examples -├── scripts/ # Rust scripts (via rust-script) -│ ├── bump-version.rs # Version bumping utility -│ ├── check-file-size.rs # File size validation script -│ ├── collect-changelog.rs # Changelog collection script -│ ├── create-github-release.rs # GitHub release creation -│ ├── detect-code-changes.rs # Detects code changes for CI -│ ├── get-bump-type.rs # Determines version bump type -│ └── version-and-commit.rs # CI/CD version management -├── src/ -│ ├── lib.rs # Library entry point -│ └── main.rs # Binary entry point -├── tests/ -│ └── integration_test.rs # Integration tests -├── .gitignore # Git ignore patterns -├── .pre-commit-config.yaml # Pre-commit hooks configuration -├── Cargo.toml # Project configuration -├── CHANGELOG.md # Project changelog -├── CONTRIBUTING.md # Contribution guidelines -├── LICENSE # Unlicense (public domain) -└── README.md # This file -``` - -## Design Choices -### Code Quality Tools +### Environment variables -- **rustfmt**: Standard Rust code formatter - - Ensures consistent code style across the project - - Configured to run on all Rust files +All CLI options can be set via environment variables: -- **Clippy**: Rust linter with comprehensive checks - - Pedantic and nursery lints enabled for strict code quality - - Catches common mistakes and suggests improvements - - Enforces best practices +```bash +export SYNC_AUTH_REPO="https://github.com/USER/credentials.git" +export SYNC_AUTH_PROVIDERS="gh,claude" +export SYNC_AUTH_BRANCH="main" -- **Pre-commit hooks**: Automated checks before each commit - - Runs rustfmt to ensure formatting - - Runs Clippy to catch issues early - - Runs tests to prevent broken commits +sync-auth sync +``` -### Testing Strategy +### Daemon mode -The template supports multiple levels of testing: +```bash +# Start as background daemon +sync-auth --repo https://github.com/USER/credentials.git daemon start --interval 60 -- **Unit tests**: In `src/lib.rs` using `#[cfg(test)]` modules -- **Integration tests**: In `tests/` directory -- **Doc tests**: In documentation examples using `///` comments -- **Examples**: In `examples/` directory (also serve as documentation) +# Stop daemon +sync-auth daemon stop -### Changelog Management +# Restart +sync-auth daemon restart -This template uses a fragment-based changelog system similar to: -- [Changesets](https://github.com/changesets/changesets) (JavaScript) -- [Scriv](https://scriv.readthedocs.io/) (Python) +# Print systemd service unit for permanent installation +sync-auth daemon setup +``` -Benefits: -- **No merge conflicts**: Multiple PRs can add fragments without conflicts -- **Per-PR documentation**: Each PR documents its own changes -- **Automated collection**: Fragments are collected during release -- **Consistent format**: Template ensures consistent changelog entries +## Library Usage -```bash -# Create a changelog fragment -touch changelog.d/$(date +%Y%m%d_%H%M%S)_my_change.md +```rust +use sync_auth::{SyncEngine, SyncConfig}; -# Edit the fragment to document your changes -``` +#[tokio::main] +async fn main() -> Result<(), Box> { + let config = SyncConfig { + repo_url: "https://github.com/user/my-credentials.git".to_string(), + providers: vec!["gh".to_string(), "claude".to_string()], + ..Default::default() + }; -### CI/CD Pipeline + let engine = SyncEngine::new(config)?; -The GitHub Actions workflow provides: + // Pull credentials from repo to local filesystem + let report = engine.pull().await?; + println!("Pulled {} credentials", report.pulled.len()); -1. **Linting**: rustfmt and Clippy checks -2. **Changelog check**: Warns if PRs are missing changelog fragments -3. **Test matrix**: 3 OS (Ubuntu, macOS, Windows) with Rust stable -4. **Building**: Release build and package validation -5. **Release**: Automated GitHub releases when version changes + // Push local credentials to repo + let report = engine.push().await?; + println!("Pushed {} credentials", report.pushed.len()); -### Release Automation + Ok(()) +} +``` -The release workflow supports: +### Custom provider -- **Auto-release**: Automatically creates releases when version in Cargo.toml changes -- **Manual release**: Trigger releases via workflow_dispatch with version bump type -- **Changelog collection**: Automatically collects fragments during release -- **GitHub releases**: Automatic creation with CHANGELOG content +```rust +use sync_auth::{AuthProvider, CredentialFile, ValidationResult}; + +#[derive(Debug)] +struct MyToolProvider; + +#[async_trait::async_trait] +impl AuthProvider for MyToolProvider { + fn name(&self) -> &str { "my-tool" } + fn display_name(&self) -> &str { "My Custom Tool" } + + fn credential_files(&self) -> Vec { + vec![CredentialFile { + relative_path: "my-tool/config".to_string(), + local_path: dirs::home_dir().unwrap().join(".my-tool"), + is_dir: true, + }] + } + + async fn validate(&self) -> ValidationResult { + ValidationResult::Unknown + } +} +``` ## Configuration -### Updating Package Name +Config file location: `~/.config/sync-auth/config.toml` -After creating a repository from this template: - -1. Update `Cargo.toml`: - - Change `name` field - - Update `repository` and `documentation` URLs - - Change `[lib]` and `[[bin]]` names +```toml +# Git repository URL (required) +repo_url = "https://github.com/USER/credentials.git" -2. Rename the crate in imports: - - `tests/integration_test.rs` - - `examples/basic_usage.rs` - - `src/main.rs` +# Providers to sync (empty = all) +providers = ["gh", "claude", "glab"] -### Clippy Configuration +# Git branch +branch = "main" -Clippy is configured in `Cargo.toml` under `[lints.clippy]`: +# Use shallow clone for initial setup +shallow_clone = true -- Pedantic lints enabled for strict code quality -- Nursery lints enabled for additional checks -- Some common patterns allowed (e.g., `module_name_repetitions`) +# Watch mode interval (seconds) +watch_interval_secs = 60 +``` -### rustfmt Configuration +## Docker / CI Usage -Uses default rustfmt settings. To customize, create a `rustfmt.toml`: +### GitHub Actions -```toml -edition = "2021" -max_width = 100 -tab_spaces = 4 +```yaml +- name: Sync credentials + run: | + cargo install sync-auth + sync-auth --repo ${{ secrets.CREDENTIALS_REPO }} pull ``` -## Scripts Reference +### Docker (with link-foundation/sandbox) -All scripts in `scripts/` are Rust scripts that use [rust-script](https://github.com/fornwall/rust-script). -Install rust-script with: `cargo install rust-script` +```bash +# On host: push credentials +sync-auth --repo https://github.com/USER/credentials.git push -| Script | Description | -| ----------------------------------------- | ------------------------------ | -| `cargo test` | Run all tests | -| `cargo fmt` | Format code | -| `cargo clippy` | Run lints | -| `cargo run --example basic_usage` | Run example | -| `rust-script scripts/check-file-size.rs` | Check file size limits | -| `rust-script scripts/bump-version.rs` | Bump version | +# In container: pull credentials +docker exec my-sandbox sync-auth --repo https://github.com/USER/credentials.git pull +``` -## Example Usage +## Architecture -```rust -use my_package::{add, multiply, delay}; +``` +sync-auth +├── Library crate (sync_auth) +│ ├── AuthProvider trait -- extensible provider system +│ ├── GitBackend trait -- pluggable storage backend +│ ├── SyncEngine -- orchestrates sync operations +│ ├── SyncConfig -- TOML-based configuration +│ └── providers/ -- built-in providers (gh, claude, etc.) +└── CLI binary (sync-auth) + └── Thin wrapper over the library with clap-based CLI +``` -#[tokio::main] -async fn main() { - // Basic arithmetic - let sum = add(2, 3); // 5 - let product = multiply(2, 3); // 6 +## Prior Art - println!("2 + 3 = {sum}"); - println!("2 * 3 = {product}"); +- [link-assistant/claude-profiles](https://github.com/link-assistant/claude-profiles) -- Node.js CLI that syncs Claude credentials via GitHub Gists (Claude-only, size-limited) - // Async operations - delay(1.0).await; // Wait for 1 second -} -``` +## Development -See `examples/basic_usage.rs` for more examples. +```bash +cargo build # Build +cargo test # Run all tests +cargo run -- --help # Run CLI +cargo clippy # Lint +cargo fmt # Format +``` ## Contributing -Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. - -### Development Workflow - -1. Fork the repository -2. Create a feature branch: `git checkout -b feature/my-feature` -3. Make your changes and add tests -4. Run quality checks: `cargo fmt && cargo clippy && cargo test` -5. Add a changelog fragment -6. Commit your changes (pre-commit hooks will run automatically) -7. Push and create a Pull Request +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. ## License -[Unlicense](LICENSE) - Public Domain - -This is free and unencumbered software released into the public domain. See [LICENSE](LICENSE) for details. - -## Acknowledgments - -Inspired by: -- [js-ai-driven-development-pipeline-template](https://github.com/link-foundation/js-ai-driven-development-pipeline-template) -- [python-ai-driven-development-pipeline-template](https://github.com/link-foundation/python-ai-driven-development-pipeline-template) - -## Resources - -- [Rust Book](https://doc.rust-lang.org/book/) -- [Cargo Book](https://doc.rust-lang.org/cargo/) -- [Clippy Documentation](https://rust-lang.github.io/rust-clippy/) -- [rustfmt Documentation](https://rust-lang.github.io/rustfmt/) -- [Pre-commit Documentation](https://pre-commit.com/) +[Unlicense](LICENSE) -- Public Domain diff --git a/changelog.d/20260316_sync_auth_mvp.md b/changelog.d/20260316_sync_auth_mvp.md new file mode 100644 index 0000000..208a069 --- /dev/null +++ b/changelog.d/20260316_sync_auth_mvp.md @@ -0,0 +1,16 @@ +### Added +- Complete sync-auth MVP: bidirectional auth credential sync for dev tools via Git repositories +- Library crate with `AuthProvider` and `GitBackend` traits for extensibility +- 7 built-in providers: gh, glab, claude, codex, gemini, opencode, qwen-coder +- CLI binary with subcommands: pull, push, sync, watch, status, providers, init, daemon +- Shallow clone support for fast initial setup +- Conflict resolution that skips expired credentials +- Watch mode with configurable sync interval +- Daemon management (start/stop/restart/systemd setup) +- TOML config file support with CLI and environment variable overrides +- Comprehensive test suite (unit + integration tests) +- Docker and CI/CD usage examples in README + +### Changed +- Package renamed from `my-package` to `sync-auth` +- Complete rewrite of library and binary for credential sync functionality diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index bdbd1fc..a78aab3 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -1,34 +1,62 @@ -//! Basic usage example for my-package. +//! Basic usage example for sync-auth. //! -//! This example demonstrates the basic functionality of the package. +//! This example shows how to use the sync-auth library programmatically. //! //! Run with: `cargo run --example basic_usage` -use my_package::{add, delay, multiply}; +use sync_auth::providers; +use sync_auth::{SyncConfig, SyncEngine}; #[tokio::main] async fn main() { - // Example 1: Basic arithmetic - println!("Example 1: Basic arithmetic"); - println!("2 + 3 = {}", add(2, 3)); - println!("2 * 3 = {}", multiply(2, 3)); + // List available providers + println!("Available auth providers:"); + for provider in providers::all_providers() { + println!(" {:<15} {}", provider.name(), provider.display_name()); + for cred in provider.credential_files() { + let exists = if cred.local_path.exists() { + "exists" + } else { + "not found" + }; + println!( + " {} -> {} ({})", + cred.relative_path, + cred.local_path.display(), + exists + ); + } + } println!(); - // Example 2: Working with larger numbers - println!("Example 2: Working with larger numbers"); - println!("1000 + 2000 = {}", add(1000, 2000)); - println!("100 * 200 = {}", multiply(100, 200)); - println!(); + // Create a sync config (replace with your actual repo URL) + let config = SyncConfig { + repo_url: "https://github.com/YOUR_USER/YOUR_CREDENTIALS_REPO.git".to_string(), + providers: vec!["gh".to_string(), "claude".to_string()], + ..Default::default() + }; - // Example 3: Working with negative numbers - println!("Example 3: Working with negative numbers"); - println!("-5 + 10 = {}", add(-5, 10)); - println!("-3 * 4 = {}", multiply(-3, 4)); - println!(); + println!("Config:"); + println!(" repo: {}", config.repo_url); + println!(" path: {}", config.local_path.display()); + println!(" branch: {}", config.branch); + println!(" shallow: {}", config.shallow_clone); - // Example 4: Async delay - println!("Example 4: Async delay"); - println!("Waiting for 1 second..."); - delay(1.0).await; - println!("Done!"); + // Create engine (this would fail with a placeholder URL, so just demo the setup) + match SyncEngine::new(config) { + Ok(engine) => { + println!( + "\nEngine created with {} provider(s).", + engine.providers.len() + ); + // In real usage you'd do: + // engine.pull().await?; // Pull credentials from repo + // engine.push().await?; // Push credentials to repo + // engine.sync().await?; // Bidirectional sync + // engine.watch().await?; // Watch mode + } + Err(e) => { + eprintln!("Failed to create engine: {e}"); + } + } } diff --git a/examples/docker/sync-and-code.sh b/examples/docker/sync-and-code.sh new file mode 100755 index 0000000..a4a9122 --- /dev/null +++ b/examples/docker/sync-and-code.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Example: Sync credentials to a Docker sandbox and run Claude Code +# +# Prerequisites: +# - sync-auth installed on host +# - A private Git repo for credentials (e.g. github.com/USER/credentials) +# - Docker installed +# - link-foundation/sandbox image pulled +# +# Usage: +# ./examples/docker/sync-and-code.sh +# +# Example: +# ./examples/docker/sync-and-code.sh \ +# https://github.com/myuser/my-credentials.git \ +# https://github.com/myuser/my-project.git + +set -euo pipefail + +CREDENTIALS_REPO="${1:?Usage: $0 }" +PROJECT_REPO="${2:?Usage: $0 }" +CONTAINER_NAME="sandbox-$(date +%s)" +SANDBOX_IMAGE="${SANDBOX_IMAGE:-ghcr.io/link-foundation/sandbox:latest}" + +echo "=== Step 1: Push host credentials to repo ===" +sync-auth --repo "$CREDENTIALS_REPO" push +echo "" + +echo "=== Step 2: Start sandbox container ===" +docker run -d \ + --name "$CONTAINER_NAME" \ + "$SANDBOX_IMAGE" \ + sleep infinity +echo "Container: $CONTAINER_NAME" +echo "" + +echo "=== Step 3: Install sync-auth in container ===" +docker exec "$CONTAINER_NAME" bash -c "cargo install sync-auth" +echo "" + +echo "=== Step 4: Pull credentials into container ===" +docker exec "$CONTAINER_NAME" bash -c "sync-auth --repo '$CREDENTIALS_REPO' pull" +echo "" + +echo "=== Step 5: Clone project in container ===" +docker exec "$CONTAINER_NAME" bash -c "cd /workspace && git clone '$PROJECT_REPO' project" +echo "" + +echo "=== Step 6: Start credential sync daemon in container ===" +docker exec "$CONTAINER_NAME" bash -c "sync-auth --repo '$CREDENTIALS_REPO' daemon start --interval 120" +echo "" + +echo "=== Ready! ===" +echo "Credentials synced. You can now run Claude Code in the container:" +echo " docker exec -it $CONTAINER_NAME bash -c 'cd /workspace/project && claude'" +echo "" +echo "To stop the container:" +echo " docker stop $CONTAINER_NAME && docker rm $CONTAINER_NAME" diff --git a/examples/github-actions/autonomous-solve.yml b/examples/github-actions/autonomous-solve.yml new file mode 100644 index 0000000..b829351 --- /dev/null +++ b/examples/github-actions/autonomous-solve.yml @@ -0,0 +1,65 @@ +# Example GitHub Actions workflow: Autonomous task solving with synced credentials +# +# Triggered manually or on issue creation. Spins up a sandbox, syncs credentials, +# and runs Claude Code in autonomous mode to work on a task. +# +# Required repository secrets: +# - CREDENTIALS_REPO: URL of the private Git repo containing credentials +# - CREDENTIALS_TOKEN: Personal access token with access to the credentials repo +# +# Usage: +# Copy this file to .github/workflows/autonomous-solve.yml in your project repo. + +name: Autonomous Task Solver + +on: + workflow_dispatch: + inputs: + task: + description: 'Task description for Claude Code' + required: true + type: string + branch: + description: 'Branch to work on' + required: false + default: 'main' + type: string + issues: + types: [labeled] + +jobs: + solve: + # Only run on issue label 'ai-solve' or manual trigger + if: github.event_name == 'workflow_dispatch' || contains(github.event.label.name, 'ai-solve') + runs-on: ubuntu-latest + timeout-minutes: 30 + container: + image: ghcr.io/link-foundation/sandbox:latest + steps: + - name: Checkout project + uses: actions/checkout@v4 + with: + ref: ${{ inputs.branch || 'main' }} + + - name: Install sync-auth + run: cargo install sync-auth + + - name: Sync credentials + env: + CREDENTIALS_REPO: ${{ secrets.CREDENTIALS_REPO }} + run: | + sync-auth --repo "$CREDENTIALS_REPO" pull + echo "Credentials synced successfully" + + - name: Determine task + id: task + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "description=${{ inputs.task }}" >> "$GITHUB_OUTPUT" + else + echo "description=Solve issue #${{ github.event.issue.number }}: ${{ github.event.issue.title }}" >> "$GITHUB_OUTPUT" + fi + + - name: Run Claude Code + run: | + claude --print "${{ steps.task.outputs.description }}" diff --git a/src/backend/git.rs b/src/backend/git.rs new file mode 100644 index 0000000..2e9a5c9 --- /dev/null +++ b/src/backend/git.rs @@ -0,0 +1,126 @@ +//! Default Git backend implementation using the `git` CLI. + +use std::path::Path; +use tokio::process::Command; +use tracing::{debug, info}; + +/// Default Git backend that shells out to the `git` command. +#[derive(Debug, Clone, Default)] +pub struct GitRepo; + +impl GitRepo { + async fn run_git(args: &[&str], cwd: &Path) -> Result { + debug!(args = ?args, cwd = %cwd.display(), "running git command"); + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .await + .map_err(|e| crate::SyncError::Git(format!("failed to run git: {e}")))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if output.status.success() { + Ok(stdout) + } else { + Err(crate::SyncError::Git(format!( + "git {} failed (exit {}): {}", + args.join(" "), + output.status.code().unwrap_or(-1), + stderr + ))) + } + } +} + +#[async_trait::async_trait] +impl super::GitBackend for GitRepo { + async fn clone_repo( + &self, + url: &str, + local_path: &Path, + shallow: bool, + ) -> Result<(), crate::SyncError> { + let parent = local_path + .parent() + .ok_or_else(|| crate::SyncError::Git("invalid local path".to_string()))?; + tokio::fs::create_dir_all(parent).await?; + + let local_str = local_path.to_string_lossy().to_string(); + let mut args = vec!["clone"]; + if shallow { + args.extend_from_slice(&["--depth", "1"]); + } + args.extend_from_slice(&[url, &local_str]); + + info!(url = url, path = %local_path.display(), shallow = shallow, "cloning repository"); + + let output = Command::new("git") + .args(&args) + .output() + .await + .map_err(|e| crate::SyncError::Git(format!("failed to run git clone: {e}")))?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(crate::SyncError::Git(format!("git clone failed: {stderr}"))) + } + } + + async fn pull(&self, local_path: &Path) -> Result<(), crate::SyncError> { + info!(path = %local_path.display(), "pulling latest changes"); + Self::run_git(&["pull", "--rebase", "--autostash"], local_path).await?; + Ok(()) + } + + async fn push(&self, local_path: &Path, message: &str) -> Result<(), crate::SyncError> { + // Stage all changes + Self::run_git(&["add", "-A"], local_path).await?; + + // Check if there's anything to commit + let status = Self::run_git(&["status", "--porcelain"], local_path).await?; + if status.trim().is_empty() { + info!("no changes to push"); + return Ok(()); + } + + info!(message = message, "committing and pushing changes"); + Self::run_git(&["commit", "-m", message], local_path).await?; + Self::run_git(&["push"], local_path).await?; + Ok(()) + } + + fn is_cloned(&self, local_path: &Path) -> bool { + local_path.join(".git").is_dir() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::GitBackend; + + #[test] + fn test_is_cloned_false_for_nonexistent() { + let repo = GitRepo; + assert!(!repo.is_cloned(Path::new("/nonexistent/path"))); + } + + #[tokio::test] + async fn test_clone_creates_parent_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let nested = tmp.path().join("a").join("b").join("repo"); + let repo = GitRepo; + + // This will fail because URL is invalid, but parent dirs should be created + let result: Result<(), crate::SyncError> = + repo.clone_repo("invalid://url", &nested, true).await; + assert!(result.is_err()); + + // Parent directory should have been created + assert!(nested.parent().unwrap().exists()); + } +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs new file mode 100644 index 0000000..859f640 --- /dev/null +++ b/src/backend/mod.rs @@ -0,0 +1,31 @@ +//! Git backend for credential storage and retrieval. + +mod git; + +pub use git::GitRepo; + +use std::path::Path; + +/// Trait for Git-like backends that store and retrieve credential snapshots. +/// +/// Implement this trait to provide a custom storage backend (e.g. GitLab, +/// a custom Git server, or even a non-Git store). +#[async_trait::async_trait] +pub trait GitBackend: Send + Sync { + /// Clone the repository (shallow if supported) to `local_path`. + async fn clone_repo( + &self, + url: &str, + local_path: &Path, + shallow: bool, + ) -> Result<(), crate::SyncError>; + + /// Pull latest changes from remote. + async fn pull(&self, local_path: &Path) -> Result<(), crate::SyncError>; + + /// Stage, commit, and push local changes. + async fn push(&self, local_path: &Path, message: &str) -> Result<(), crate::SyncError>; + + /// Check if the local path is already a valid repo clone. + fn is_cloned(&self, local_path: &Path) -> bool; +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..1633759 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,72 @@ +//! Configuration for sync-auth. + +use std::path::PathBuf; + +/// Configuration for the sync engine. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct SyncConfig { + /// URL of the Git repository to sync credentials through. + pub repo_url: String, + + /// Local path where the sync repository is cloned. + pub local_path: PathBuf, + + /// List of provider names to sync (e.g. \["gh", "claude"\]). + /// Empty means sync all available providers. + pub providers: Vec, + + /// Whether to use shallow clone (--depth 1) for initial clone. + pub shallow_clone: bool, + + /// Git branch to use for sync. + pub branch: String, + + /// Interval in seconds for watch mode. + pub watch_interval_secs: u64, +} + +impl Default for SyncConfig { + fn default() -> Self { + Self { + repo_url: String::new(), + local_path: default_sync_path(), + providers: Vec::new(), + shallow_clone: true, + branch: "main".to_string(), + watch_interval_secs: 60, + } + } +} + +/// Returns the default path for the sync repository. +fn default_sync_path() -> PathBuf { + dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("sync-auth") + .join("repo") +} + +impl SyncConfig { + /// Load config from a TOML file, falling back to defaults for missing fields. + pub fn load_from_file(path: &std::path::Path) -> Result { + if !path.exists() { + return Err(crate::SyncError::Config(format!( + "config file not found: {}", + path.display() + ))); + } + let content = + std::fs::read_to_string(path).map_err(|e| crate::SyncError::Config(e.to_string()))?; + toml::from_str(&content) + .map_err(|e| crate::SyncError::Config(format!("invalid config TOML: {e}"))) + } + + /// Returns the default config file path. + pub fn default_config_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("~/.config")) + .join("sync-auth") + .join("config.toml") + } +} diff --git a/src/engine.rs b/src/engine.rs new file mode 100644 index 0000000..b014fd5 --- /dev/null +++ b/src/engine.rs @@ -0,0 +1,372 @@ +//! Sync engine — orchestrates bidirectional credential sync. + +use crate::backend::{GitBackend, GitRepo}; +use crate::providers; +use crate::{AuthProvider, CredentialFile, SyncConfig, SyncError, ValidationResult}; +use std::path::{Path, PathBuf}; +use tracing::{debug, info, warn}; + +/// The sync engine coordinates pulling credentials from a Git repo to local +/// filesystem and pushing local credentials to the repo. +pub struct SyncEngine { + pub config: SyncConfig, + pub backend: Box, + pub providers: Vec>, +} + +impl SyncEngine { + /// Create a new `SyncEngine` with the given config and default Git backend. + pub fn new(config: SyncConfig) -> Result { + if config.repo_url.is_empty() { + return Err(SyncError::Config("repo_url must not be empty".to_string())); + } + + let active_providers = resolve_providers(&config.providers)?; + info!( + providers = ?active_providers.iter().map(|p| p.name()).collect::>(), + "initialized sync engine" + ); + + Ok(Self { + config, + backend: Box::new(GitRepo), + providers: active_providers, + }) + } + + /// Create a `SyncEngine` with a custom backend (for testing or alternative storage). + pub fn with_backend( + config: SyncConfig, + backend: Box, + ) -> Result { + let active_providers = resolve_providers(&config.providers)?; + Ok(Self { + config, + backend, + providers: active_providers, + }) + } + + /// Ensure the sync repo is cloned locally; clone if not. + pub async fn ensure_repo(&self) -> Result<(), SyncError> { + if self.backend.is_cloned(&self.config.local_path) { + debug!(path = %self.config.local_path.display(), "repo already cloned"); + return Ok(()); + } + self.backend + .clone_repo( + &self.config.repo_url, + &self.config.local_path, + self.config.shallow_clone, + ) + .await + } + + /// Pull credentials from the remote repo to local filesystem. + /// + /// 1. Ensure repo is cloned + /// 2. Git pull + /// 3. Copy files from repo → local credential paths + pub async fn pull(&self) -> Result { + self.ensure_repo().await?; + self.backend.pull(&self.config.local_path).await?; + + let mut report = SyncReport::default(); + + for provider in &self.providers { + for cred in provider.credential_files() { + let repo_path = self.config.local_path.join(&cred.relative_path); + if repo_path.exists() { + copy_recursive(&repo_path, &cred.local_path).await?; + report.pulled.push(cred.relative_path.clone()); + info!( + provider = provider.name(), + path = %cred.local_path.display(), + "pulled credential" + ); + } else { + debug!( + provider = provider.name(), + repo_path = %repo_path.display(), + "no credential in repo, skipping" + ); + } + } + } + + Ok(report) + } + + /// Push local credentials to the remote repo. + /// + /// 1. Ensure repo is cloned + /// 2. Git pull (to avoid conflicts) + /// 3. Copy local credential files → repo + /// 4. Git commit + push + pub async fn push(&self) -> Result { + self.ensure_repo().await?; + + // Pull first to minimize conflicts + if self.backend.is_cloned(&self.config.local_path) { + let _ = self.backend.pull(&self.config.local_path).await; + } + + let mut report = SyncReport::default(); + + for provider in &self.providers { + // Validate before pushing — don't push expired credentials + let validation = provider.validate().await; + if validation == ValidationResult::Expired { + warn!( + provider = provider.name(), + "skipping push: credentials are expired" + ); + report + .skipped + .push(format!("{}: credentials expired", provider.name())); + continue; + } + + for cred in provider.credential_files() { + if cred.local_path.exists() { + let repo_path = self.config.local_path.join(&cred.relative_path); + copy_recursive(&cred.local_path, &repo_path).await?; + report.pushed.push(cred.relative_path.clone()); + info!( + provider = provider.name(), + path = %cred.local_path.display(), + "staged credential for push" + ); + } else { + debug!( + provider = provider.name(), + path = %cred.local_path.display(), + "local credential not found, skipping" + ); + } + } + } + + if !report.pushed.is_empty() { + let message = format!( + "sync-auth: update credentials ({})", + report + .pushed + .iter() + .map(String::as_str) + .collect::>() + .join(", ") + ); + self.backend.push(&self.config.local_path, &message).await?; + } + + Ok(report) + } + + /// Bidirectional sync: pull then push. + pub async fn sync(&self) -> Result { + let mut report = self.pull().await?; + let push_report = self.push().await?; + report.pushed = push_report.pushed; + report.skipped.extend(push_report.skipped); + Ok(report) + } + + /// Watch for local credential changes and sync periodically. + pub async fn watch(&self) -> Result<(), SyncError> { + use tokio::time::{interval, Duration}; + info!( + interval_secs = self.config.watch_interval_secs, + "starting watch mode" + ); + + let mut tick = interval(Duration::from_secs(self.config.watch_interval_secs)); + loop { + tick.tick().await; + match self.sync().await { + Ok(report) => { + if !report.pushed.is_empty() || !report.pulled.is_empty() { + info!(?report, "sync cycle completed with changes"); + } else { + debug!("sync cycle: no changes"); + } + } + Err(e) => { + warn!(error = %e, "sync cycle failed, will retry next interval"); + } + } + } + } + + /// List all providers and their credential status. + pub async fn status(&self) -> Vec { + let mut statuses = Vec::new(); + for provider in &self.providers { + let validation = provider.validate().await; + let files: Vec<_> = provider + .credential_files() + .into_iter() + .map(|c| { + let repo_exists = self.config.local_path.join(&c.relative_path).exists(); + FileStatus { + relative_path: c.relative_path, + local_exists: c.local_path.exists(), + repo_exists, + } + }) + .collect(); + statuses.push(ProviderStatus { + name: provider.name().to_string(), + display_name: provider.display_name().to_string(), + validation, + files, + }); + } + statuses + } +} + +/// Report of a sync operation. +#[derive(Debug, Default)] +pub struct SyncReport { + pub pulled: Vec, + pub pushed: Vec, + pub skipped: Vec, +} + +/// Status of a provider's credentials. +#[derive(Debug)] +pub struct ProviderStatus { + pub name: String, + pub display_name: String, + pub validation: ValidationResult, + pub files: Vec, +} + +/// Status of a single credential file. +#[derive(Debug)] +pub struct FileStatus { + pub relative_path: String, + pub local_exists: bool, + pub repo_exists: bool, +} + +/// Resolve provider list: if empty, use all; otherwise look up by name. +fn resolve_providers(names: &[String]) -> Result>, SyncError> { + if names.is_empty() { + return Ok(providers::all_providers()); + } + names + .iter() + .map(|name| { + providers::provider_by_name(name) + .ok_or_else(|| SyncError::ProviderNotFound(name.clone())) + }) + .collect() +} + +/// Recursively copy a file or directory, creating parent dirs as needed. +fn copy_recursive<'a>( + src: &'a Path, + dst: &'a Path, +) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + if src.is_dir() { + tokio::fs::create_dir_all(dst).await?; + let mut entries = tokio::fs::read_dir(src).await?; + while let Some(entry) = entries.next_entry().await? { + let entry_path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + let dst_child = dst.join(&file_name); + copy_recursive(&entry_path, &dst_child).await?; + } + } else if src.is_file() { + if let Some(parent) = dst.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::copy(src, dst).await?; + } + Ok(()) + }) +} + +/// Helper to get a PathBuf for credential files (used by providers). +pub fn _credential_path(relative: &str) -> CredentialFile { + CredentialFile { + relative_path: relative.to_string(), + local_path: PathBuf::from(relative), + is_dir: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_all_providers() { + let providers = resolve_providers(&[]).unwrap(); + assert_eq!(providers.len(), 7); + } + + #[test] + fn test_resolve_specific_providers() { + let names = vec!["gh".to_string(), "claude".to_string()]; + let providers = resolve_providers(&names).unwrap(); + assert_eq!(providers.len(), 2); + assert_eq!(providers[0].name(), "gh"); + assert_eq!(providers[1].name(), "claude"); + } + + #[test] + fn test_resolve_unknown_provider() { + let names = vec!["nonexistent".to_string()]; + let result = resolve_providers(&names); + assert!(result.is_err()); + } + + #[test] + fn test_new_engine_requires_repo_url() { + let config = SyncConfig::default(); + let result = SyncEngine::new(config); + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_copy_recursive_file() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("src.txt"); + let dst = tmp.path().join("nested").join("dst.txt"); + tokio::fs::write(&src, "hello").await.unwrap(); + copy_recursive(&src, &dst).await.unwrap(); + let content = tokio::fs::read_to_string(&dst).await.unwrap(); + assert_eq!(content, "hello"); + } + + #[tokio::test] + async fn test_copy_recursive_dir() { + let tmp = tempfile::tempdir().unwrap(); + let src_dir = tmp.path().join("src_dir"); + let dst_dir = tmp.path().join("dst_dir"); + tokio::fs::create_dir_all(&src_dir).await.unwrap(); + tokio::fs::write(src_dir.join("a.txt"), "aaa") + .await + .unwrap(); + tokio::fs::write(src_dir.join("b.txt"), "bbb") + .await + .unwrap(); + copy_recursive(&src_dir, &dst_dir).await.unwrap(); + assert_eq!( + tokio::fs::read_to_string(dst_dir.join("a.txt")) + .await + .unwrap(), + "aaa" + ); + assert_eq!( + tokio::fs::read_to_string(dst_dir.join("b.txt")) + .await + .unwrap(), + "bbb" + ); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..b0199f4 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,23 @@ +//! Error types for sync-auth. + +/// Errors that can occur during sync operations. +#[derive(Debug, thiserror::Error)] +pub enum SyncError { + #[error("git operation failed: {0}")] + Git(String), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("provider not found: {0}")] + ProviderNotFound(String), + + #[error("config error: {0}")] + Config(String), + + #[error("sync conflict: {0}")] + Conflict(String), + + #[error("serialization error: {0}")] + Serialization(String), +} diff --git a/src/lib.rs b/src/lib.rs index ff6e523..5be3a88 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,136 +1,92 @@ -//! Example module entry point. +//! `sync-auth` — Bidirectional auth credential sync for dev tools via Git repositories. //! -//! Replace this with your actual implementation. +//! This library provides a trait-based, extensible system for syncing authentication +//! credentials for developer tools (GitHub CLI, GitLab CLI, Claude Code, Codex, +//! Gemini CLI, etc.) through a Git repository backend. +//! +//! # Architecture +//! +//! - [`AuthProvider`] trait: implement to add support for any dev tool's credentials +//! - [`GitBackend`] trait: implement to customize how credentials are stored/fetched +//! - [`SyncEngine`]: orchestrates bidirectional sync between local and remote +//! - Built-in providers for common tools via [`providers`] module +//! +//! # Example +//! +//! ```no_run +//! use sync_auth::{SyncEngine, SyncConfig}; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let config = SyncConfig { +//! repo_url: "https://github.com/user/my-credentials.git".to_string(), +//! local_path: "/tmp/sync-auth-repo".into(), +//! providers: vec!["gh".to_string(), "claude".to_string()], +//! ..Default::default() +//! }; +//! let engine = SyncEngine::new(config)?; +//! engine.pull().await?; +//! Ok(()) +//! } +//! ``` + +pub mod backend; +pub mod providers; + +mod config; +mod engine; +mod error; + +pub use config::SyncConfig; +pub use engine::SyncEngine; +pub use error::SyncError; /// Package version (matches Cargo.toml version). pub const VERSION: &str = env!("CARGO_PKG_VERSION"); -/// Adds two numbers together. -/// -/// # Arguments -/// -/// * `a` - First number -/// * `b` - Second number -/// -/// # Returns -/// -/// Sum of `a` and `b` -/// -/// # Examples -/// -/// ``` -/// use my_package::add; -/// assert_eq!(add(2, 3), 5); -/// ``` -#[must_use] -pub const fn add(a: i64, b: i64) -> i64 { - a + b +/// Credential file entry describing a single file to sync. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CredentialFile { + /// Relative path within the provider's namespace in the sync repo. + pub relative_path: String, + /// Absolute path on the local filesystem. + pub local_path: std::path::PathBuf, + /// Whether this is a directory (recursive sync). + pub is_dir: bool, } -/// Multiplies two numbers together. -/// -/// # Arguments -/// -/// * `a` - First number -/// * `b` - Second number -/// -/// # Returns -/// -/// Product of `a` and `b` -/// -/// # Examples -/// -/// ``` -/// use my_package::multiply; -/// assert_eq!(multiply(2, 3), 6); -/// ``` -#[must_use] -pub const fn multiply(a: i64, b: i64) -> i64 { - a * b +/// Result of validating a credential. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValidationResult { + /// Credential is valid and usable. + Valid, + /// Credential exists but is expired or invalid. + Expired, + /// Credential is missing or empty. + Missing, + /// Validation could not be performed (tool not installed, etc.). + Unknown, } -/// Async delay function. +/// Trait for auth credential providers. /// -/// # Arguments -/// -/// * `seconds` - Duration to wait in seconds -/// -/// # Examples -/// -/// ``` -/// use my_package::delay; -/// -/// #[tokio::main] -/// async fn main() { -/// delay(0.1).await; -/// } -/// ``` -pub async fn delay(seconds: f64) { - let duration = std::time::Duration::from_secs_f64(seconds); - tokio::time::sleep(duration).await; -} - -#[cfg(test)] -mod tests { - use super::*; - - mod add_tests { - use super::*; - - #[test] - fn test_add_positive_numbers() { - assert_eq!(add(2, 3), 5); - } - - #[test] - fn test_add_negative_numbers() { - assert_eq!(add(-1, -2), -3); - } +/// Implement this trait to add support for syncing credentials of any dev tool. +/// Each provider declares its name, the credential files it manages, and +/// optionally a validation method to check credential freshness. +#[async_trait::async_trait] +pub trait AuthProvider: Send + Sync { + /// Unique name for this provider (e.g. "gh", "claude"). + fn name(&self) -> &str; - #[test] - fn test_add_zero() { - assert_eq!(add(5, 0), 5); - } - - #[test] - fn test_add_large_numbers() { - assert_eq!(add(1_000_000, 2_000_000), 3_000_000); - } - } - - mod multiply_tests { - use super::*; - - #[test] - fn test_multiply_positive_numbers() { - assert_eq!(multiply(2, 3), 6); - } - - #[test] - fn test_multiply_by_zero() { - assert_eq!(multiply(5, 0), 0); - } - - #[test] - fn test_multiply_negative_numbers() { - assert_eq!(multiply(-2, 3), -6); - } - - #[test] - fn test_multiply_two_negatives() { - assert_eq!(multiply(-2, -3), 6); - } - } + /// Human-readable display name. + fn display_name(&self) -> &str; - mod delay_tests { - use super::*; + /// List of credential files/directories this provider manages. + fn credential_files(&self) -> Vec; - #[tokio::test] - async fn test_delay() { - let start = std::time::Instant::now(); - delay(0.1).await; - let elapsed = start.elapsed(); - assert!(elapsed.as_secs_f64() >= 0.1); - } + /// Validate whether the current local credentials are still valid. + /// Defaults to [`ValidationResult::Unknown`]. + async fn validate(&self) -> ValidationResult { + ValidationResult::Unknown } } diff --git a/src/main.rs b/src/main.rs index bffc9ba..c6eff44 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,29 +1,373 @@ -//! Example binary entry point. -//! -//! This is a simple CLI that demonstrates the library functionality. +//! `sync-auth` CLI — Bidirectional auth credential sync for dev tools. -use my_package::{add, delay, multiply}; +use clap::{Parser, Subcommand}; +use std::path::PathBuf; +use sync_auth::{SyncConfig, SyncEngine}; +use tracing::{error, info}; + +#[derive(Parser)] +#[command( + name = "sync-auth", + about = "Bidirectional auth credential sync for dev tools via Git repositories", + version = sync_auth::VERSION, + long_about = "Sync authentication credentials for developer tools (GitHub CLI, GitLab CLI, \ + Claude Code, Codex, Gemini CLI, etc.) through a Git repository.\n\n\ + On first run, the repo is shallow-cloned. Subsequent syncs pull/push changes." +)] +struct Cli { + /// Git repository URL to sync through + #[arg(short, long, env = "SYNC_AUTH_REPO")] + repo: Option, + + /// Local path for the sync repository clone + #[arg(short, long, env = "SYNC_AUTH_LOCAL_PATH")] + local_path: Option, + + /// Config file path + #[arg(short, long, env = "SYNC_AUTH_CONFIG")] + config: Option, + + /// Providers to sync (comma-separated, e.g. "gh,claude"). + /// If omitted, all providers are synced. + #[arg(short, long, env = "SYNC_AUTH_PROVIDERS", value_delimiter = ',')] + providers: Option>, + + /// Git branch to use + #[arg(short, long, default_value = "main", env = "SYNC_AUTH_BRANCH")] + branch: String, + + /// Enable verbose logging + #[arg(short, long)] + verbose: bool, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Pull credentials from remote repo to local filesystem + Pull, + + /// Push local credentials to remote repo + Push, + + /// Bidirectional sync (pull then push) + Sync, + + /// Watch for changes and sync periodically + Watch { + /// Sync interval in seconds + #[arg(short, long, default_value = "60")] + interval: u64, + }, + + /// Show status of all providers and credentials + Status, + + /// List available providers + Providers, + + /// Initialize a new config file + Init, + + /// Daemon management (Unix only) + Daemon { + #[command(subcommand)] + action: DaemonAction, + }, +} + +#[derive(Subcommand)] +enum DaemonAction { + /// Start the daemon + Start { + /// Sync interval in seconds + #[arg(short, long, default_value = "60")] + interval: u64, + }, + /// Stop the daemon + Stop, + /// Restart the daemon + Restart { + /// Sync interval in seconds + #[arg(short, long, default_value = "60")] + interval: u64, + }, + /// Print systemd unit file for installation + Setup, +} #[tokio::main] async fn main() { - println!("my-package v{}", my_package::VERSION); - println!(); - - // Example 1: Basic arithmetic - println!("Example 1: Basic arithmetic"); - println!("2 + 3 = {}", add(2, 3)); - println!("2 * 3 = {}", multiply(2, 3)); - println!(); - - // Example 2: Working with larger numbers - println!("Example 2: Working with larger numbers"); - println!("1000 + 2000 = {}", add(1000, 2000)); - println!("100 * 200 = {}", multiply(100, 200)); - println!(); - - // Example 3: Async delay - println!("Example 3: Async delay"); - println!("Waiting for 1 second..."); - delay(1.0).await; - println!("Done!"); + let cli = Cli::parse(); + + // Initialize tracing + let filter = if cli.verbose { "debug" } else { "info" }; + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)), + ) + .with_target(false) + .init(); + + if let Err(e) = run(cli).await { + error!("{e}"); + std::process::exit(1); + } +} + +async fn run(cli: Cli) -> Result<(), sync_auth::SyncError> { + // Handle commands that don't need a full engine + match &cli.command { + Commands::Providers => { + println!("Available providers:"); + for p in sync_auth::providers::all_providers() { + println!(" {:<15} {}", p.name(), p.display_name()); + } + return Ok(()); + } + Commands::Init => { + return init_config().await; + } + _ => {} + } + + let config = build_config(&cli)?; + let engine = SyncEngine::new(config)?; + + match cli.command { + Commands::Pull => { + let report = engine.pull().await?; + println!("Pulled {} credential(s).", report.pulled.len()); + for p in &report.pulled { + println!(" + {p}"); + } + } + Commands::Push => { + let report = engine.push().await?; + println!("Pushed {} credential(s).", report.pushed.len()); + for p in &report.pushed { + println!(" + {p}"); + } + for s in &report.skipped { + println!(" - {s}"); + } + } + Commands::Sync => { + let report = engine.sync().await?; + println!( + "Sync complete: {} pulled, {} pushed.", + report.pulled.len(), + report.pushed.len() + ); + } + Commands::Watch { interval } => { + println!("Starting watch mode (interval: {interval}s). Press Ctrl+C to stop."); + let mut config = engine.config.clone(); + config.watch_interval_secs = interval; + let engine = SyncEngine::new(config)?; + engine.watch().await?; + } + Commands::Status => { + let statuses = engine.status().await; + println!("Provider status:"); + for s in &statuses { + let validation = match s.validation { + sync_auth::ValidationResult::Valid => "valid", + sync_auth::ValidationResult::Expired => "EXPIRED", + sync_auth::ValidationResult::Missing => "missing", + sync_auth::ValidationResult::Unknown => "unknown", + }; + println!(" {} ({}) -- {}", s.name, s.display_name, validation); + for f in &s.files { + let local = if f.local_exists { "+" } else { "-" }; + let repo = if f.repo_exists { "+" } else { "-" }; + println!(" {} local:{} repo:{}", f.relative_path, local, repo); + } + } + } + Commands::Daemon { action } => { + handle_daemon(action, cli.repo.as_deref(), cli.config.as_deref()).await?; + } + Commands::Providers | Commands::Init => unreachable!(), + } + + Ok(()) +} + +fn build_config(cli: &Cli) -> Result { + let config_path = cli + .config + .clone() + .unwrap_or_else(SyncConfig::default_config_path); + + let mut config = if config_path.exists() { + info!(path = %config_path.display(), "loading config from file"); + SyncConfig::load_from_file(&config_path)? + } else { + SyncConfig::default() + }; + + // CLI args override config file + if let Some(ref repo) = cli.repo { + config.repo_url.clone_from(repo); + } + if let Some(ref local_path) = cli.local_path { + config.local_path.clone_from(local_path); + } + if let Some(ref providers) = cli.providers { + config.providers.clone_from(providers); + } + config.branch.clone_from(&cli.branch); + + Ok(config) +} + +async fn init_config() -> Result<(), sync_auth::SyncError> { + let path = SyncConfig::default_config_path(); + if path.exists() { + println!("Config already exists at: {}", path.display()); + return Ok(()); + } + + let template = r#"# sync-auth configuration +# See https://github.com/link-foundation/auth-sync for documentation + +# Git repository URL (required) +repo_url = "" + +# Providers to sync (empty = all) +# providers = ["gh", "claude", "glab"] + +# Git branch +branch = "main" + +# Use shallow clone +shallow_clone = true + +# Watch mode interval (seconds) +watch_interval_secs = 60 +"#; + + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(&path, template).await?; + println!("Created config at: {}", path.display()); + Ok(()) +} + +async fn handle_daemon( + action: DaemonAction, + repo: Option<&str>, + config: Option<&std::path::Path>, +) -> Result<(), sync_auth::SyncError> { + let pid_path = dirs::runtime_dir() + .or_else(dirs::data_local_dir) + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("sync-auth.pid"); + + match action { + DaemonAction::Start { interval } => { + if pid_path.exists() { + let pid = tokio::fs::read_to_string(&pid_path).await?; + println!("Daemon may already be running (PID: {})", pid.trim()); + return Ok(()); + } + + println!("Starting sync-auth daemon (interval: {interval}s)..."); + let exe = + std::env::current_exe().map_err(|e| sync_auth::SyncError::Config(e.to_string()))?; + let mut cmd = tokio::process::Command::new(exe); + if let Some(repo) = repo { + cmd.args(["--repo", repo]); + } + if let Some(config) = config { + cmd.args(["--config", &config.to_string_lossy()]); + } + cmd.args(["watch", "--interval", &interval.to_string()]); + cmd.stdin(std::process::Stdio::null()); + cmd.stdout(std::process::Stdio::null()); + cmd.stderr(std::process::Stdio::null()); + + let child = cmd.spawn().map_err(|e| { + sync_auth::SyncError::Config(format!("failed to spawn daemon: {e}")) + })?; + let pid = child.id().unwrap_or(0); + + if let Some(parent) = pid_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(&pid_path, pid.to_string()).await?; + println!("Daemon started (PID: {pid})"); + } + DaemonAction::Stop => { + if !pid_path.exists() { + println!("No daemon PID file found."); + return Ok(()); + } + let pid_str = tokio::fs::read_to_string(&pid_path).await?; + let pid: u32 = pid_str + .trim() + .parse() + .map_err(|e| sync_auth::SyncError::Config(format!("invalid PID: {e}")))?; + + // Send SIGTERM via kill command + let _ = tokio::process::Command::new("kill") + .arg(pid.to_string()) + .output() + .await; + tokio::fs::remove_file(&pid_path).await?; + println!("Daemon stopped (PID: {pid})"); + } + DaemonAction::Restart { interval } => { + let stop = DaemonAction::Stop; + let _ = Box::pin(handle_daemon(stop, repo, config)).await; + let start = DaemonAction::Start { interval }; + Box::pin(handle_daemon(start, repo, config)).await?; + } + DaemonAction::Setup => { + print_systemd_unit()?; + } + } + Ok(()) +} + +fn print_systemd_unit() -> Result<(), sync_auth::SyncError> { + let exe = std::env::current_exe().map_err(|e| sync_auth::SyncError::Config(e.to_string()))?; + + let unit = format!( + r"[Unit] +Description=sync-auth credential sync daemon +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart={exe} watch --interval 60 +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=default.target +", + exe = exe.display() + ); + + let service_dir = dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("~/.config")) + .join("systemd") + .join("user"); + + println!("Systemd user service unit:\n"); + println!("{unit}"); + println!("To install, save this to:"); + println!(" {}/sync-auth.service", service_dir.display()); + println!("\nThen run:"); + println!(" systemctl --user daemon-reload"); + println!(" systemctl --user enable --now sync-auth"); + + Ok(()) } diff --git a/src/providers/claude.rs b/src/providers/claude.rs new file mode 100644 index 0000000..bfad9ce --- /dev/null +++ b/src/providers/claude.rs @@ -0,0 +1,47 @@ +//! Claude Code auth provider. + +use crate::{AuthProvider, CredentialFile, ValidationResult}; +use std::path::PathBuf; + +/// Provider for Claude Code credentials. +/// +/// Syncs `~/.claude/` directory and `~/.claude.json` config file. +#[derive(Debug, Clone, Default)] +pub struct ClaudeProvider; + +#[async_trait::async_trait] +impl AuthProvider for ClaudeProvider { + fn name(&self) -> &str { + "claude" + } + + fn display_name(&self) -> &str { + "Claude Code" + } + + fn credential_files(&self) -> Vec { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")); + vec![ + CredentialFile { + relative_path: "claude/dot-claude".to_string(), + local_path: home.join(".claude"), + is_dir: true, + }, + CredentialFile { + relative_path: "claude/claude.json".to_string(), + local_path: home.join(".claude.json"), + is_dir: false, + }, + ] + } + + async fn validate(&self) -> ValidationResult { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")); + let config = home.join(".claude.json"); + if config.exists() { + ValidationResult::Valid + } else { + ValidationResult::Missing + } + } +} diff --git a/src/providers/codex.rs b/src/providers/codex.rs new file mode 100644 index 0000000..44a6211 --- /dev/null +++ b/src/providers/codex.rs @@ -0,0 +1,54 @@ +//! OpenAI Codex CLI auth provider. + +use crate::{AuthProvider, CredentialFile, ValidationResult}; +use std::path::PathBuf; + +/// Provider for OpenAI Codex CLI credentials. +/// +/// Codex CLI stores auth in `~/.codex/` (controlled by `CODEX_HOME` env var). +/// Key files: `auth.json` (tokens), `config.toml` (settings). +#[derive(Debug, Clone, Default)] +pub struct CodexProvider; + +#[async_trait::async_trait] +impl AuthProvider for CodexProvider { + fn name(&self) -> &str { + "codex" + } + + fn display_name(&self) -> &str { + "OpenAI Codex CLI" + } + + fn credential_files(&self) -> Vec { + let codex_home = std::env::var("CODEX_HOME").map_or_else( + |_| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("~")) + .join(".codex") + }, + PathBuf::from, + ); + vec![CredentialFile { + relative_path: "codex/dot-codex".to_string(), + local_path: codex_home, + is_dir: true, + }] + } + + async fn validate(&self) -> ValidationResult { + let codex_home = std::env::var("CODEX_HOME").map_or_else( + |_| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("~")) + .join(".codex") + }, + PathBuf::from, + ); + if codex_home.join("auth.json").exists() { + ValidationResult::Valid + } else { + ValidationResult::Missing + } + } +} diff --git a/src/providers/gemini.rs b/src/providers/gemini.rs new file mode 100644 index 0000000..c35e415 --- /dev/null +++ b/src/providers/gemini.rs @@ -0,0 +1,43 @@ +//! Gemini CLI auth provider. + +use crate::{AuthProvider, CredentialFile, ValidationResult}; +use std::path::PathBuf; + +/// Provider for Google Gemini CLI credentials. +/// +/// Gemini CLI stores config in `~/.gemini/`. +/// Key files: `.env` (API key), `oauth_creds.json` (OAuth tokens), +/// `mcp-oauth-tokens.json` (MCP OAuth tokens). +#[derive(Debug, Clone, Default)] +pub struct GeminiProvider; + +#[async_trait::async_trait] +impl AuthProvider for GeminiProvider { + fn name(&self) -> &str { + "gemini" + } + + fn display_name(&self) -> &str { + "Gemini CLI" + } + + fn credential_files(&self) -> Vec { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")); + vec![CredentialFile { + relative_path: "gemini/dot-gemini".to_string(), + local_path: home.join(".gemini"), + is_dir: true, + }] + } + + async fn validate(&self) -> ValidationResult { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")); + let gemini_dir = home.join(".gemini"); + // Check for either API key env file or OAuth credentials + if gemini_dir.join(".env").exists() || gemini_dir.join("oauth_creds.json").exists() { + ValidationResult::Valid + } else { + ValidationResult::Missing + } + } +} diff --git a/src/providers/gh.rs b/src/providers/gh.rs new file mode 100644 index 0000000..2c977c4 --- /dev/null +++ b/src/providers/gh.rs @@ -0,0 +1,41 @@ +//! GitHub CLI (`gh`) auth provider. + +use crate::{AuthProvider, CredentialFile, ValidationResult}; + +/// Provider for GitHub CLI credentials (`~/.config/gh/`). +#[derive(Debug, Clone, Default)] +pub struct GhProvider; + +#[async_trait::async_trait] +impl AuthProvider for GhProvider { + fn name(&self) -> &str { + "gh" + } + + fn display_name(&self) -> &str { + "GitHub CLI" + } + + fn credential_files(&self) -> Vec { + let base = dirs::config_dir() + .unwrap_or_else(|| "~/.config".into()) + .join("gh"); + vec![CredentialFile { + relative_path: "gh".to_string(), + local_path: base, + is_dir: true, + }] + } + + async fn validate(&self) -> ValidationResult { + match tokio::process::Command::new("gh") + .args(["auth", "status"]) + .output() + .await + { + Ok(output) if output.status.success() => ValidationResult::Valid, + Ok(_) => ValidationResult::Expired, + Err(_) => ValidationResult::Unknown, + } + } +} diff --git a/src/providers/glab.rs b/src/providers/glab.rs new file mode 100644 index 0000000..4bee4f8 --- /dev/null +++ b/src/providers/glab.rs @@ -0,0 +1,41 @@ +//! GitLab CLI (`glab`) auth provider. + +use crate::{AuthProvider, CredentialFile, ValidationResult}; + +/// Provider for GitLab CLI credentials (`~/.config/glab-cli/`). +#[derive(Debug, Clone, Default)] +pub struct GlabProvider; + +#[async_trait::async_trait] +impl AuthProvider for GlabProvider { + fn name(&self) -> &str { + "glab" + } + + fn display_name(&self) -> &str { + "GitLab CLI" + } + + fn credential_files(&self) -> Vec { + let base = dirs::config_dir() + .unwrap_or_else(|| "~/.config".into()) + .join("glab-cli"); + vec![CredentialFile { + relative_path: "glab-cli".to_string(), + local_path: base, + is_dir: true, + }] + } + + async fn validate(&self) -> ValidationResult { + match tokio::process::Command::new("glab") + .args(["auth", "status"]) + .output() + .await + { + Ok(output) if output.status.success() => ValidationResult::Valid, + Ok(_) => ValidationResult::Expired, + Err(_) => ValidationResult::Unknown, + } + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs new file mode 100644 index 0000000..84df6da --- /dev/null +++ b/src/providers/mod.rs @@ -0,0 +1,49 @@ +//! Built-in auth providers for common dev tools. +//! +//! Each provider knows where its tool stores credentials on disk and can +//! optionally validate them. Add a new provider by implementing [`AuthProvider`]. + +mod claude; +mod codex; +mod gemini; +mod gh; +mod glab; +mod opencode; +mod qwen_coder; + +pub use self::claude::ClaudeProvider; +pub use self::codex::CodexProvider; +pub use self::gemini::GeminiProvider; +pub use self::gh::GhProvider; +pub use self::glab::GlabProvider; +pub use self::opencode::OpencodeProvider; +pub use self::qwen_coder::QwenCoderProvider; + +use crate::AuthProvider; + +/// Returns all built-in providers. +pub fn all_providers() -> Vec> { + vec![ + Box::new(GhProvider), + Box::new(GlabProvider), + Box::new(ClaudeProvider), + Box::new(CodexProvider), + Box::new(GeminiProvider), + Box::new(OpencodeProvider), + Box::new(QwenCoderProvider), + ] +} + +/// Returns a single built-in provider by name, or `None` if unknown. +pub fn provider_by_name(name: &str) -> Option> { + match name { + "gh" => Some(Box::new(GhProvider)), + "glab" => Some(Box::new(GlabProvider)), + "claude" => Some(Box::new(ClaudeProvider)), + "codex" => Some(Box::new(CodexProvider)), + "gemini" => Some(Box::new(GeminiProvider)), + "opencode" => Some(Box::new(OpencodeProvider)), + "qwen-coder" => Some(Box::new(QwenCoderProvider)), + _ => None, + } +} diff --git a/src/providers/opencode.rs b/src/providers/opencode.rs new file mode 100644 index 0000000..4cf6b0b --- /dev/null +++ b/src/providers/opencode.rs @@ -0,0 +1,66 @@ +//! Opencode auth provider. + +use crate::{AuthProvider, CredentialFile, ValidationResult}; +use std::path::PathBuf; + +/// Provider for opencode credentials. +/// +/// Opencode stores auth in `~/.local/share/opencode/auth.json` (XDG data dir) +/// and config/plugins in `~/.config/opencode/`. +#[derive(Debug, Clone, Default)] +pub struct OpencodeProvider; + +#[async_trait::async_trait] +impl AuthProvider for OpencodeProvider { + fn name(&self) -> &str { + "opencode" + } + + fn display_name(&self) -> &str { + "Opencode" + } + + fn credential_files(&self) -> Vec { + let data_dir = dirs::data_local_dir() + .unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("~")) + .join(".local/share") + }) + .join("opencode"); + let config_dir = dirs::config_dir() + .unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("~")) + .join(".config") + }) + .join("opencode"); + vec![ + CredentialFile { + relative_path: "opencode/data".to_string(), + local_path: data_dir, + is_dir: true, + }, + CredentialFile { + relative_path: "opencode/config".to_string(), + local_path: config_dir, + is_dir: true, + }, + ] + } + + async fn validate(&self) -> ValidationResult { + let data_dir = dirs::data_local_dir() + .unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("~")) + .join(".local/share") + }) + .join("opencode"); + if data_dir.join("auth.json").exists() { + ValidationResult::Valid + } else { + ValidationResult::Missing + } + } +} diff --git a/src/providers/qwen_coder.rs b/src/providers/qwen_coder.rs new file mode 100644 index 0000000..2c936c4 --- /dev/null +++ b/src/providers/qwen_coder.rs @@ -0,0 +1,41 @@ +//! Qwen Code auth provider. + +use crate::{AuthProvider, CredentialFile, ValidationResult}; +use std::path::PathBuf; + +/// Provider for Qwen Code CLI credentials (officially "Qwen Code", npm: `qwen-code`). +/// +/// Qwen Code stores config in `~/.qwen/`. +/// Key files: `oauth_creds.json` (OAuth tokens), `settings.json` (API keys/settings). +#[derive(Debug, Clone, Default)] +pub struct QwenCoderProvider; + +#[async_trait::async_trait] +impl AuthProvider for QwenCoderProvider { + fn name(&self) -> &str { + "qwen-coder" + } + + fn display_name(&self) -> &str { + "Qwen Code" + } + + fn credential_files(&self) -> Vec { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")); + vec![CredentialFile { + relative_path: "qwen-coder/dot-qwen".to_string(), + local_path: home.join(".qwen"), + is_dir: true, + }] + } + + async fn validate(&self) -> ValidationResult { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")); + let qwen_dir = home.join(".qwen"); + if qwen_dir.join("oauth_creds.json").exists() || qwen_dir.join("settings.json").exists() { + ValidationResult::Valid + } else { + ValidationResult::Missing + } + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 89fa538..010003c 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1,88 +1,149 @@ -//! Integration tests for my-package. -//! -//! These tests verify the public API works correctly. +//! Integration tests for sync-auth. -use my_package::{add, delay, multiply}; +use sync_auth::providers; +use sync_auth::{SyncConfig, SyncEngine, ValidationResult, VERSION}; -mod add_integration_tests { +mod version_tests { use super::*; #[test] - fn test_add_returns_correct_sum() { - assert_eq!(add(10, 20), 30); - } - - #[test] - fn test_add_handles_large_numbers() { - assert_eq!(add(1_000_000_000, 2_000_000_000), 3_000_000_000); + fn test_version_is_not_empty() { + assert!(!VERSION.is_empty()); } #[test] - fn test_add_handles_negative_result() { - assert_eq!(add(-100, 50), -50); + fn test_version_matches_cargo_toml() { + assert!(VERSION.starts_with("0.")); } } -mod multiply_integration_tests { +mod provider_tests { use super::*; #[test] - fn test_multiply_returns_correct_product() { - assert_eq!(multiply(10, 20), 200); + fn test_all_providers_returns_seven() { + let all = providers::all_providers(); + assert_eq!(all.len(), 7); } #[test] - fn test_multiply_handles_large_numbers() { - assert_eq!(multiply(1_000, 1_000_000), 1_000_000_000); + fn test_provider_by_name_known() { + assert!(providers::provider_by_name("gh").is_some()); + assert!(providers::provider_by_name("claude").is_some()); + assert!(providers::provider_by_name("glab").is_some()); + assert!(providers::provider_by_name("codex").is_some()); + assert!(providers::provider_by_name("gemini").is_some()); + assert!(providers::provider_by_name("opencode").is_some()); + assert!(providers::provider_by_name("qwen-coder").is_some()); } #[test] - fn test_multiply_handles_negative_numbers() { - assert_eq!(multiply(-10, -20), 200); + fn test_provider_by_name_unknown() { + assert!(providers::provider_by_name("unknown-tool").is_none()); + } + + #[test] + fn test_provider_has_credential_files() { + for provider in providers::all_providers() { + let files = provider.credential_files(); + assert!( + !files.is_empty(), + "provider {} should have at least one credential file", + provider.name() + ); + } + } + + #[tokio::test] + async fn test_validate_returns_valid_result() { + // On a CI machine, most tools won't be installed, so Unknown/Missing is fine + let gh = providers::provider_by_name("gh").unwrap(); + let result = gh.validate().await; + assert!(matches!( + result, + ValidationResult::Valid + | ValidationResult::Expired + | ValidationResult::Missing + | ValidationResult::Unknown + )); } } -mod delay_integration_tests { +mod engine_tests { use super::*; - #[tokio::test] - async fn test_delay_waits_minimum_time() { - let start = std::time::Instant::now(); - delay(0.05).await; - let elapsed = start.elapsed(); - - assert!( - elapsed.as_secs_f64() >= 0.05, - "Delay should wait at least 0.05 seconds, but waited {:.4}s", - elapsed.as_secs_f64() - ); + #[test] + fn test_engine_requires_repo_url() { + let config = SyncConfig::default(); + assert!(SyncEngine::new(config).is_err()); } - #[tokio::test] - async fn test_delay_zero_completes_quickly() { - let start = std::time::Instant::now(); - delay(0.0).await; - let elapsed = start.elapsed(); - - assert!( - elapsed.as_secs_f64() < 0.1, - "Zero delay should complete quickly, but took {:.4}s", - elapsed.as_secs_f64() - ); + #[test] + fn test_engine_creates_with_valid_config() { + let config = SyncConfig { + repo_url: "https://github.com/example/creds.git".to_string(), + ..Default::default() + }; + assert!(SyncEngine::new(config).is_ok()); + } + + #[test] + fn test_engine_with_specific_providers() { + let config = SyncConfig { + repo_url: "https://github.com/example/creds.git".to_string(), + providers: vec!["gh".to_string(), "claude".to_string()], + ..Default::default() + }; + let engine = SyncEngine::new(config).unwrap(); + assert_eq!(engine.providers.len(), 2); + } + + #[test] + fn test_engine_with_unknown_provider_fails() { + let config = SyncConfig { + repo_url: "https://github.com/example/creds.git".to_string(), + providers: vec!["nonexistent".to_string()], + ..Default::default() + }; + assert!(SyncEngine::new(config).is_err()); } } -mod version_tests { - use my_package::VERSION; +mod config_tests { + use super::*; #[test] - fn test_version_is_not_empty() { - assert!(!VERSION.is_empty()); + fn test_default_config() { + let config = SyncConfig::default(); + assert!(config.repo_url.is_empty()); + assert!(config.shallow_clone); + assert_eq!(config.branch, "main"); + assert_eq!(config.watch_interval_secs, 60); } #[test] - fn test_version_matches_cargo_toml() { - // Version should match the one in Cargo.toml - assert!(VERSION.starts_with("0.")); + fn test_config_load_nonexistent_file() { + let result = SyncConfig::load_from_file(std::path::Path::new("/nonexistent/config.toml")); + assert!(result.is_err()); + } + + #[test] + fn test_config_load_valid_toml() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write( + tmp.path(), + r#" +repo_url = "https://github.com/test/repo.git" +branch = "develop" +shallow_clone = false +watch_interval_secs = 120 +"#, + ) + .unwrap(); + let config = SyncConfig::load_from_file(tmp.path()).unwrap(); + assert_eq!(config.repo_url, "https://github.com/test/repo.git"); + assert_eq!(config.branch, "develop"); + assert!(!config.shallow_clone); + assert_eq!(config.watch_interval_secs, 120); } }