diff --git a/Cargo.lock b/Cargo.lock index e20d4a7..289e41b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,15 @@ 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 = "1.0.0" @@ -61,12 +70,24 @@ dependencies = [ "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 = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.22.1" @@ -113,11 +134,25 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[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.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -137,9 +172,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -161,24 +196,29 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "comfy-table" -version = "7.2.2" +version = "7.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" dependencies = [ "crossterm", "unicode-segmentation", "unicode-width", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crossterm" -version = "0.29.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", - "document-features", "parking_lot", "rustix", "winapi", @@ -205,13 +245,10 @@ dependencies = [ ] [[package]] -name = "document-features" -version = "0.2.12" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" @@ -220,7 +257,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -353,6 +390,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "heck" version = "0.5.0" @@ -457,15 +500,38 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", - "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -473,9 +539,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -486,9 +552,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -500,15 +566,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -520,15 +586,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", @@ -560,6 +626,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -612,11 +688,21 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + [[package]] name = "linux-raw-sys" -version = "0.12.1" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "litemap" @@ -624,12 +710,6 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - [[package]] name = "lock_api" version = "0.4.14" @@ -699,7 +779,16 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", ] [[package]] @@ -986,15 +1075,15 @@ checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustix" -version = "1.1.4" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1103,6 +1192,15 @@ dependencies = [ "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 = "serde_urlencoded" version = "0.7.1" @@ -1115,6 +1213,21 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1257,9 +1370,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -1304,6 +1417,47 @@ dependencies = [ "tokio", ] +[[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 = "tower" version = "0.5.3" @@ -1476,6 +1630,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -1493,9 +1653,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] @@ -1590,9 +1750,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -1619,12 +1779,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[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" @@ -1716,11 +1929,20 @@ 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" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" @@ -1761,8 +1983,10 @@ dependencies = [ "rpassword", "serde", "serde_json", + "serde_yml", "thiserror", "tokio", + "toml", "tracing", "tracing-subscriber", "youtrack-client-api", @@ -1770,10 +1994,12 @@ dependencies = [ [[package]] name = "youtrack-client-api" -version = "0.1.0" -source = "git+https://github.com/ghostspice/youtrack-client-api?tag=v0.1.0#0e9317d78641be1d542a3801c25e9bad11f132d2" +version = "0.1.1" +source = "git+https://github.com/ghostspice/youtrack-client-api?rev=75ffdd3#75ffdd311579eb7a31fc349055a24041bcd2da3a" dependencies = [ + "chrono", "futures-core", + "pin-project-lite", "reqwest", "secrecy", "serde", diff --git a/Cargo.toml b/Cargo.toml index 6c96c6e..ecbbe13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,11 @@ [package] name = "youtrack-cli" version = "0.1.0" -rust-version = "1.85.0" +rust-version = "1.88.0" edition = "2024" description = "Terminal-first CLI for YouTrack, wrapping the youtrack-client-api crate." license = "MIT" +build = "build.rs" [[bin]] name = "youtrack-cli" @@ -12,14 +13,16 @@ path = "src/main.rs" [dependencies] # Upstream crate is not yet published to crates.io (branch 0.1.x, no releases). -youtrack-client-api = { git = "https://github.com/ghostspice/youtrack-client-api", tag = "v0.1.0" } +youtrack-client-api = { git = "https://github.com/ghostspice/youtrack-client-api", rev = "75ffdd3" } clap = { version = "4.5", features = ["derive"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } serde = "1" serde_json = "1" -comfy-table = "7.1" +comfy-table = ">=7.1, <7.2" futures = "0.3" rpassword = "7.4" thiserror = "2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +serde_yml = "0.0.12" +toml = "0.8" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..d1a6f7c --- /dev/null +++ b/build.rs @@ -0,0 +1,62 @@ +use std::env; +use std::process::exit; + +fn main() { + println!("cargo:rerun-if-env-changed=BUILD_YOUTRACK_URL"); + println!("cargo:rerun-if-env-changed=BUILD_YOUTRACK_TOKEN"); + println!("cargo:rerun-if-env-changed=BUILD_EMBED_MODE"); + + let youtrack_url = env::var("BUILD_YOUTRACK_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let youtrack_token = env::var("BUILD_YOUTRACK_TOKEN") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + let requested_mode = env::var("BUILD_EMBED_MODE").ok(); + let mode = requested_mode.unwrap_or_else(|| { + if youtrack_url.is_some() || youtrack_token.is_some() { + "defaults".to_string() + } else { + "standard".to_string() + } + }); + + match mode.as_str() { + "standard" => { + if youtrack_url.is_some() || youtrack_token.is_some() { + eprintln!( + "BUILD_YOUTRACK_URL/BUILD_YOUTRACK_TOKEN were provided with BUILD_EMBED_MODE=standard" + ); + exit(1); + } + } + "defaults" => {} + "locked" => { + if youtrack_url.is_none() { + eprintln!("BUILD_EMBED_MODE=locked requires BUILD_YOUTRACK_URL"); + exit(1); + } + if youtrack_token.is_none() { + eprintln!("BUILD_EMBED_MODE=locked requires BUILD_YOUTRACK_TOKEN"); + exit(1); + } + } + other => { + eprintln!("Unsupported BUILD_EMBED_MODE value: {other}"); + exit(1); + } + } + + println!("cargo:rustc-env=YOUTRACK_CLI_EMBED_MODE={mode}"); + + if let Some(youtrack_url) = youtrack_url { + println!("cargo:rustc-env=YOUTRACK_CLI_EMBED_URL={youtrack_url}"); + } + + if let Some(youtrack_token) = youtrack_token { + println!("cargo:rustc-env=YOUTRACK_CLI_EMBED_TOKEN={youtrack_token}"); + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index b475f2f..7855e6d 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.85.0" +channel = "1.88.0" components = ["rustfmt", "clippy"] diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..7b52db7 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,132 @@ +use tracing_subscriber::EnvFilter; + +use crate::auth; +use crate::cli::{Cli, Command, CommentCommand, IssueCommand}; +use crate::commands; +use crate::config::{self, ResolvedConfig}; +use crate::error::{CliError, CliResult}; +use crate::output::OutputFormat; +use youtrack_client_api::YouTrackClient; + +pub struct AppContext { + pub config: ResolvedConfig, + pub client: YouTrackClient, +} + +pub async fn run(cli: Cli) -> CliResult<()> { + init_tracing(cli.verbose, cli.quiet); + + let pending = config::resolve(&cli)?; + let mut prepared = auth::prepare_initial_config(pending.clone()).await?; + print_verbose_config(&prepared.config); + + let context = build_context(prepared.config.clone())?; + match dispatch_command(&context, &cli).await { + Err(err) + if auth::should_retry_with_prompt(&err, pending.embed_mode, prepared.prompted) => + { + prepared = auth::prompt_for_retry(&pending).await?; + + if prepared.config.verbose && !prepared.config.quiet { + eprintln!("token: {}", prepared.config.token_origin); + } + + let retry_context = build_context(prepared.config)?; + dispatch_command(&retry_context, &cli).await + } + result => result, + } +} + +fn build_context(config: ResolvedConfig) -> CliResult { + let client = YouTrackClient::builder(&config.youtrack_url) + .bearer_token(config.token.clone()) + .build() + .map_err(CliError::Api)?; + Ok(AppContext { config, client }) +} + +fn resolve_output(cli: &Cli) -> OutputFormat { + if let Some(fmt) = cli.output { + return fmt; + } + if cli.json { + return OutputFormat::Json; + } + OutputFormat::Table +} + +async fn dispatch_command(ctx: &AppContext, cli: &Cli) -> CliResult<()> { + let mode = resolve_output(cli); + match &cli.command { + Command::Issue(issue) => match &issue.command { + IssueCommand::List(args) => { + commands::issue::list(&ctx.client, args.clone(), mode).await + } + IssueCommand::Get { id } => commands::issue::get(&ctx.client, id, mode).await, + IssueCommand::Create(args) => { + commands::issue::create(&ctx.client, args.clone(), mode).await + } + IssueCommand::Update(args) => { + commands::issue::update(&ctx.client, args.clone(), mode).await + } + IssueCommand::Delete(args) => { + commands::issue::delete(&ctx.client, args.clone()).await + } + IssueCommand::Comment(c) => match &c.command { + CommentCommand::List { issue_id } => { + commands::comment::list(&ctx.client, issue_id, mode).await + } + CommentCommand::Add { issue_id, text } => { + commands::comment::add(&ctx.client, issue_id, text.clone(), mode).await + } + CommentCommand::Update { + issue_id, + comment_id, + text, + } => { + commands::comment::update( + &ctx.client, + issue_id, + comment_id, + text.clone(), + mode, + ) + .await + } + CommentCommand::Delete { + issue_id, + comment_id, + yes, + } => commands::comment::delete(&ctx.client, issue_id, comment_id, *yes).await, + }, + IssueCommand::Attachment(a) => { + commands::attachment::dispatch(&ctx.client, a.command.clone(), mode).await + } + }, + } +} + +fn print_verbose_config(config: &ResolvedConfig) { + if config.verbose && !config.quiet { + eprintln!("youtrack_url: {}", config.youtrack_url_origin); + eprintln!("token: {}", config.token_origin); + eprintln!("output: {}", config.output); + } +} + +fn init_tracing(verbose: bool, quiet: bool) { + let level = if quiet { + "error" + } else if verbose { + "debug" + } else { + "warn" + }; + + let _ = tracing_subscriber::fmt() + .with_target(false) + .without_time() + .with_env_filter(EnvFilter::new(level)) + .try_init(); +} diff --git a/src/auth.rs b/src/auth.rs index 8a45172..03345ab 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,64 +1,240 @@ -//! Credential resolution. Order of precedence: +//! Authentication and credential resolution. //! -//! 1. Runtime env vars: `YOUTRACK_URL` + `YOUTRACK_TOKEN`. -//! 2. Compile-time embedded: `YOUTRACK_URL_EMBED` + `YOUTRACK_TOKEN_EMBED` -//! (baked in at `cargo build` via `option_env!`). Distinct names from the -//! runtime vars so a CI build with `YOUTRACK_TOKEN` exported can't -//! accidentally embed a token into the artifact. -//! 3. Interactive prompt, only if stdin is a TTY. +//! Implements the prepare/retry pattern: `prepare_initial_config` resolves +//! a token (or prompts for one), and `should_retry_with_prompt` + `prompt_for_retry` +//! handle one-shot re-prompting on Unauthorized errors. -use std::env; -use std::io::{self, IsTerminal, Write}; +#[cfg(target_family = "unix")] +use std::fs::OpenOptions; +#[cfg(not(target_family = "unix"))] +use std::io::IsTerminal; -use crate::error::{CliError, CliResult}; +use crate::config::{EmbeddedMode, PendingAuth, PendingConfig, ResolvedConfig, ValueOrigin}; +use crate::error::CliError; +use youtrack_client_api::YouTrackClient; -const EMBEDDED_URL: Option<&str> = option_env!("YOUTRACK_URL_EMBED"); -const EMBEDDED_TOKEN: Option<&str> = option_env!("YOUTRACK_TOKEN_EMBED"); +pub const MISSING_TOKEN_MESSAGE: &str = "YouTrack token is required. Set --token, YOUTRACK_TOKEN, the config file token, embed one at build time, or run from an interactive terminal with --auth."; +pub const INTERACTIVE_AUTH_MESSAGE: &str = "Interactive auth requires an interactive terminal."; -pub struct Credentials { - pub url: String, - pub token: String, +const AUTHENTICATION_FAILED_MESSAGE: &str = "Authentication failed for the provided token."; +const EMPTY_TOKEN_MESSAGE: &str = "YouTrack token cannot be empty."; +const PROMPT_LABEL: &str = "YouTrack token: "; +const MAX_PROMPT_ATTEMPTS: usize = 3; + +#[derive(Debug, Clone)] +pub struct PreparedAuth { + pub config: ResolvedConfig, + pub prompted: bool, +} + +#[derive(Debug, PartialEq, Eq)] +enum InitialAuthAction { + UseToken, + Prompt, } -pub fn resolve() -> CliResult { - if let (Ok(url), Ok(token)) = (env::var("YOUTRACK_URL"), env::var("YOUTRACK_TOKEN")) { - if !url.is_empty() && !token.is_empty() { - return Ok(Credentials { url, token }); +pub async fn prepare_initial_config(pending: PendingConfig) -> Result { + match initial_auth_action(&pending.auth, prompt_available())? { + InitialAuthAction::UseToken => { + let PendingAuth::Token { value, origin } = pending.auth.clone() else { + unreachable!("use-token action requires a resolved token"); + }; + + Ok(PreparedAuth { + config: pending.with_auth(value, origin), + prompted: false, + }) } + InitialAuthAction::Prompt => Ok(PreparedAuth { + config: prompt_and_validate(&pending).await?, + prompted: true, + }), } +} + +pub fn should_retry_with_prompt(err: &CliError, embed_mode: EmbeddedMode, prompted: bool) -> bool { + should_retry_with_prompt_for_state(err, embed_mode, prompted, prompt_available()) +} + +fn should_retry_with_prompt_for_state( + err: &CliError, + embed_mode: EmbeddedMode, + prompted: bool, + prompt_available: bool, +) -> bool { + embed_mode != EmbeddedMode::Locked + && !prompted + && prompt_available + && matches!( + err, + CliError::Api(youtrack_client_api::Error::Unauthorized) + ) +} + +pub async fn prompt_for_retry(pending: &PendingConfig) -> Result { + Ok(PreparedAuth { + config: prompt_and_validate(pending).await?, + prompted: true, + }) +} - if let (Some(url), Some(token)) = (EMBEDDED_URL, EMBEDDED_TOKEN) { - if !url.is_empty() && !token.is_empty() { - return Ok(Credentials { - url: url.to_string(), - token: token.to_string(), - }); +fn initial_auth_action( + auth: &PendingAuth, + prompt_available: bool, +) -> Result { + match auth { + PendingAuth::Token { .. } => Ok(InitialAuthAction::UseToken), + PendingAuth::PromptRequested if prompt_available => Ok(InitialAuthAction::Prompt), + PendingAuth::PromptRequested => { + Err(CliError::Config(INTERACTIVE_AUTH_MESSAGE.to_string())) } + PendingAuth::Missing if prompt_available => Ok(InitialAuthAction::Prompt), + PendingAuth::Missing => Err(CliError::Config(MISSING_TOKEN_MESSAGE.to_string())), } +} + +async fn prompt_and_validate(pending: &PendingConfig) -> Result { + for attempt in 0..MAX_PROMPT_ATTEMPTS { + let token = prompt_for_token()?; + if token.trim().is_empty() { + if attempt + 1 == MAX_PROMPT_ATTEMPTS { + return Err(CliError::Config(EMPTY_TOKEN_MESSAGE.to_string())); + } - if io::stdin().is_terminal() { - return prompt(); + eprintln!("error: {EMPTY_TOKEN_MESSAGE}"); + continue; + } + + let config = pending.with_auth(token, ValueOrigin::Prompt); + match validate_prompted_token(&config).await { + Ok(()) => return Ok(config), + Err(CliError::Api(youtrack_client_api::Error::Unauthorized)) + if attempt + 1 < MAX_PROMPT_ATTEMPTS => + { + eprintln!("error: {AUTHENTICATION_FAILED_MESSAGE}"); + } + Err(err) => return Err(err), + } } - Err(CliError::NoCredentials) + Err(CliError::Api( + youtrack_client_api::Error::Unauthorized, + )) } -fn prompt() -> CliResult { - let mut stderr = io::stderr(); - write!(stderr, "YouTrack URL: ")?; - stderr.flush()?; - let mut url = String::new(); - io::stdin().read_line(&mut url)?; - let url = url.trim().to_string(); - if url.is_empty() { - return Err(CliError::NoCredentials); +fn prompt_for_token() -> Result { + if !prompt_available() { + return Err(CliError::Config(INTERACTIVE_AUTH_MESSAGE.to_string())); } - let token = rpassword::prompt_password("YouTrack token: ")?; - let token = token.trim().to_string(); - if token.is_empty() { - return Err(CliError::NoCredentials); + rpassword::prompt_password(PROMPT_LABEL).map_err(Into::into) +} + +async fn validate_prompted_token(config: &ResolvedConfig) -> Result<(), CliError> { + let client = YouTrackClient::builder(&config.youtrack_url) + .bearer_token(config.token.clone()) + .build() + .map_err(CliError::Api)?; + client.users().me(None).await?; + Ok(()) +} + +fn prompt_available() -> bool { + #[cfg(target_family = "unix")] + { + return OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .is_ok(); } - Ok(Credentials { url, token }) + #[cfg(not(target_family = "unix"))] + { + std::io::stdin().is_terminal() + || std::io::stdout().is_terminal() + || std::io::stderr().is_terminal() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ValueOrigin; + + #[test] + fn prompts_immediately_when_requested() { + let action = initial_auth_action(&PendingAuth::PromptRequested, true) + .expect("prompt should be allowed"); + + assert_eq!(action, InitialAuthAction::Prompt); + } + + #[test] + fn prompts_when_token_is_missing_and_tty_exists() { + let action = + initial_auth_action(&PendingAuth::Missing, true).expect("prompt should be allowed"); + + assert_eq!(action, InitialAuthAction::Prompt); + } + + #[test] + fn rejects_missing_token_without_tty() { + let err = initial_auth_action(&PendingAuth::Missing, false) + .expect_err("missing token should fail without tty"); + + assert_eq!(err.to_string(), MISSING_TOKEN_MESSAGE); + } + + #[test] + fn retries_only_once_after_unauthorized() { + let err = CliError::Api(youtrack_client_api::Error::Unauthorized); + + assert!(should_retry_with_prompt_for_state( + &err, + EmbeddedMode::Standard, + false, + true, + )); + assert!(!should_retry_with_prompt_for_state( + &err, + EmbeddedMode::Standard, + true, + true, + )); + assert!(!should_retry_with_prompt_for_state( + &err, + EmbeddedMode::Locked, + false, + true, + )); + } + + #[test] + fn does_not_retry_non_auth_errors() { + let err = CliError::Config(crate::config::LOCKED_AUTH_MESSAGE.to_string()); + + assert!(!should_retry_with_prompt_for_state( + &err, + EmbeddedMode::Standard, + false, + true, + )); + } + + #[test] + fn prompt_origin_is_available_for_runtime_config() { + let pending = PendingConfig { + youtrack_url: "https://youtrack.example.com".to_string(), + auth: PendingAuth::PromptRequested, + output: crate::output::OutputFormat::Table, + verbose: true, + quiet: false, + youtrack_url_origin: ValueOrigin::CliFlag, + embed_mode: EmbeddedMode::Standard, + }; + let resolved = pending.with_auth("token".to_string(), ValueOrigin::Prompt); + + assert_eq!(resolved.token_origin, ValueOrigin::Prompt); + } } diff --git a/src/cli.rs b/src/cli.rs index a44c55e..2ed600c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; use clap::{Args, Parser, Subcommand}; +use crate::output::OutputFormat; + const TOP_AFTER_HELP: &str = "\ Examples: # List open issues in the DEMO project @@ -18,15 +20,19 @@ Examples: youtrack-cli issue attachment upload BT-14394 --file ./screenshot.png Authentication (first match wins): - 1. env vars — export YOUTRACK_URL and YOUTRACK_TOKEN - 2. embedded — set YOUTRACK_URL_EMBED / YOUTRACK_TOKEN_EMBED at `cargo build` - 3. prompt — interactive, requires a TTY on stdin + 1. CLI flags — --youtrack-url and --token + 2. env vars — export YOUTRACK_URL and YOUTRACK_TOKEN + 3. config — ~/.config/youtrack-cli/config.toml + 4. embedded — set BUILD_YOUTRACK_URL / BUILD_YOUTRACK_TOKEN at `cargo build` + 5. prompt — interactive, requires a TTY on stdin Exit codes: 0 success - 1 runtime error (network, API, auth) - 2 usage error - 3 not found (HTTP 404) + 1 runtime error (network, API) + 2 configuration / usage error + 3 authentication or authorization failure + 4 not found (HTTP 404) + 5 rate limited "; const LIST_AFTER_HELP: &str = "\ @@ -125,8 +131,24 @@ Example: after_help = TOP_AFTER_HELP, )] pub struct Cli { + /// YouTrack base URL. Overrides config and environment values. + #[arg(long, global = true, help_heading = "Global Options")] + pub youtrack_url: Option, + + /// YouTrack permanent token. Overrides config and environment values. + #[arg(long, global = true, conflicts_with = "auth", help_heading = "Global Options")] + pub token: Option, + + /// Prompt for a YouTrack token and ignore other token sources. + #[arg(long, global = true, conflicts_with = "token", help_heading = "Global Options")] + pub auth: bool, + + /// Output format for command results. + #[arg(long, global = true, value_enum, help_heading = "Global Options")] + pub output: Option, + /// Emit JSON instead of a human-readable table. - #[arg(long, global = true)] + #[arg(long, global = true, hide = true)] pub json: bool, /// Show config-source details and enable debug logging. @@ -173,7 +195,7 @@ pub enum IssueCommand { Attachment(AttachmentArgs), } -#[derive(Args, Debug)] +#[derive(Args, Debug, Clone)] #[command(after_help = LIST_AFTER_HELP)] pub struct IssueListArgs { /// YouTrack query (e.g. "project: DEMO state: Open"). @@ -190,7 +212,7 @@ pub struct IssueListArgs { pub all: bool, } -#[derive(Args, Debug)] +#[derive(Args, Debug, Clone)] #[command(after_help = CREATE_AFTER_HELP)] pub struct IssueCreateArgs { /// Project id (internal id or short name). @@ -204,7 +226,7 @@ pub struct IssueCreateArgs { pub description: Option, } -#[derive(Args, Debug)] +#[derive(Args, Debug, Clone)] #[command(after_help = UPDATE_AFTER_HELP)] pub struct IssueUpdateArgs { pub id: String, @@ -214,7 +236,7 @@ pub struct IssueUpdateArgs { pub description: Option, } -#[derive(Args, Debug)] +#[derive(Args, Debug, Clone)] #[command(after_help = DELETE_AFTER_HELP)] pub struct IssueDeleteArgs { pub id: String, @@ -265,7 +287,7 @@ pub struct AttachmentArgs { pub command: AttachmentCommand, } -#[derive(Subcommand, Debug)] +#[derive(Subcommand, Debug, Clone)] pub enum AttachmentCommand { /// List attachments on an issue. #[command(after_help = ATTACHMENT_LIST_AFTER_HELP)] @@ -294,3 +316,69 @@ pub enum AttachmentCommand { yes: bool, }, } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn parse_output_flag_json() { + let cli = Cli::try_parse_from(["youtrack-cli", "--output", "json", "issue", "list"]) + .expect("should parse"); + assert_eq!(cli.output, Some(OutputFormat::Json)); + } + + #[test] + fn parse_output_flag_yaml() { + let cli = Cli::try_parse_from(["youtrack-cli", "--output", "yaml", "issue", "list"]) + .expect("should parse"); + assert_eq!(cli.output, Some(OutputFormat::Yaml)); + } + + #[test] + fn parse_hidden_json_flag() { + let cli = + Cli::try_parse_from(["youtrack-cli", "--json", "issue", "list"]).expect("should parse"); + assert!(cli.json); + } + + #[test] + fn parse_youtrack_url_flag() { + let cli = Cli::try_parse_from([ + "youtrack-cli", + "--youtrack-url", + "https://yt.example.com", + "issue", + "list", + ]) + .expect("should parse"); + assert_eq!(cli.youtrack_url.as_deref(), Some("https://yt.example.com")); + } + + #[test] + fn parse_token_flag() { + let cli = Cli::try_parse_from([ + "youtrack-cli", + "--token", + "perm:abc123", + "issue", + "list", + ]) + .expect("should parse"); + assert_eq!(cli.token.as_deref(), Some("perm:abc123")); + } + + #[test] + fn token_and_auth_conflict() { + let result = Cli::try_parse_from([ + "youtrack-cli", + "--token", + "abc", + "--auth", + "issue", + "list", + ]); + assert!(result.is_err()); + } +} diff --git a/src/client.rs b/src/client.rs deleted file mode 100644 index 37350d1..0000000 --- a/src/client.rs +++ /dev/null @@ -1,11 +0,0 @@ -use youtrack_client_api::YouTrackClient; - -use crate::auth::Credentials; -use crate::error::CliResult; - -pub fn build(creds: &Credentials) -> CliResult { - let client = YouTrackClient::builder(&creds.url) - .bearer_token(creds.token.clone()) - .build()?; - Ok(client) -} diff --git a/src/commands/attachment.rs b/src/commands/attachment.rs index e1525b0..a1ce777 100644 --- a/src/commands/attachment.rs +++ b/src/commands/attachment.rs @@ -5,14 +5,14 @@ use youtrack_client_api::{Fields, YouTrackClient}; use crate::cli::AttachmentCommand; use crate::error::{CliError, CliResult}; -use crate::output::{emit, AttachmentList, OutputMode}; +use crate::output::{emit, AttachmentList, OutputFormat}; use super::confirm; pub async fn dispatch( client: &YouTrackClient, cmd: AttachmentCommand, - mode: OutputMode, + mode: OutputFormat, ) -> CliResult<()> { match cmd { AttachmentCommand::List { issue_id } => list(client, &issue_id, mode).await, @@ -45,7 +45,7 @@ pub async fn dispatch( } } -async fn list(client: &YouTrackClient, issue_id: &str, mode: OutputMode) -> CliResult<()> { +async fn list(client: &YouTrackClient, issue_id: &str, mode: OutputFormat) -> CliResult<()> { let items = client.issues().attachments(issue_id).list(None).await?; emit(&AttachmentList(&items), mode) } @@ -56,7 +56,7 @@ async fn upload( file_name: &str, path: &Path, replace: bool, - mode: OutputMode, + mode: OutputFormat, ) -> CliResult<()> { if replace { // Cheap preflight — we only need `id` to call delete, plus `name` to match. @@ -76,7 +76,7 @@ async fn upload( client.issues().attachments(issue_id).delete(id).await?; } - if matches!(mode, OutputMode::Table) && !matches.is_empty() { + if matches!(mode, OutputFormat::Table) && !matches.is_empty() { let n = matches.len(); let noun = if n == 1 { "attachment" } else { "attachments" }; eprintln!("Replaced {n} existing {noun} with the same name."); diff --git a/src/commands/comment.rs b/src/commands/comment.rs index 2bc55d7..bdad05c 100644 --- a/src/commands/comment.rs +++ b/src/commands/comment.rs @@ -2,11 +2,11 @@ use youtrack_client_api::models::comment::{CreateComment, UpdateComment}; use youtrack_client_api::YouTrackClient; use crate::error::CliResult; -use crate::output::{emit, CommentList, OutputMode}; +use crate::output::{emit, CommentList, OutputFormat}; use super::confirm; -pub async fn list(client: &YouTrackClient, issue_id: &str, mode: OutputMode) -> CliResult<()> { +pub async fn list(client: &YouTrackClient, issue_id: &str, mode: OutputFormat) -> CliResult<()> { let comments = client.issues().comments(issue_id).list(None).await?; emit(&CommentList(&comments), mode) } @@ -15,7 +15,7 @@ pub async fn add( client: &YouTrackClient, issue_id: &str, text: String, - mode: OutputMode, + mode: OutputFormat, ) -> CliResult<()> { let payload = CreateComment { text, @@ -35,7 +35,7 @@ pub async fn update( issue_id: &str, comment_id: &str, text: String, - mode: OutputMode, + mode: OutputFormat, ) -> CliResult<()> { let payload = UpdateComment { text: Some(text), diff --git a/src/commands/issue.rs b/src/commands/issue.rs index 0985941..d71b6f0 100644 --- a/src/commands/issue.rs +++ b/src/commands/issue.rs @@ -4,11 +4,15 @@ use youtrack_client_api::YouTrackClient; use crate::cli::{IssueCreateArgs, IssueDeleteArgs, IssueListArgs, IssueUpdateArgs}; use crate::error::CliResult; -use crate::output::{emit, IssueList, OutputMode}; +use crate::output::{emit, IssueList, OutputFormat}; use super::confirm; -pub async fn list(client: &YouTrackClient, args: IssueListArgs, mode: OutputMode) -> CliResult<()> { +pub async fn list( + client: &YouTrackClient, + args: IssueListArgs, + mode: OutputFormat, +) -> CliResult<()> { let mut builder = client.issues().list(); if let Some(q) = args.query { builder = builder.query(q); @@ -23,7 +27,7 @@ pub async fn list(client: &YouTrackClient, args: IssueListArgs, mode: OutputMode emit(&IssueList(&issues), mode) } -pub async fn get(client: &YouTrackClient, id: &str, mode: OutputMode) -> CliResult<()> { +pub async fn get(client: &YouTrackClient, id: &str, mode: OutputFormat) -> CliResult<()> { let issue = client.issues().get(id, None).await?; emit(&issue, mode) } @@ -31,7 +35,7 @@ pub async fn get(client: &YouTrackClient, id: &str, mode: OutputMode) -> CliResu pub async fn create( client: &YouTrackClient, args: IssueCreateArgs, - mode: OutputMode, + mode: OutputFormat, ) -> CliResult<()> { let payload = CreateIssue { summary: args.summary, @@ -46,7 +50,7 @@ pub async fn create( pub async fn update( client: &YouTrackClient, args: IssueUpdateArgs, - mode: OutputMode, + mode: OutputFormat, ) -> CliResult<()> { let payload = UpdateIssue { summary: args.summary, diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..a72b5af --- /dev/null +++ b/src/config.rs @@ -0,0 +1,462 @@ +use std::env; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::cli::Cli; +use crate::error::CliError; +use crate::output::OutputFormat; + +pub const LOCKED_AUTH_MESSAGE: &str = "Interactive auth is unavailable in locked builds."; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EmbeddedMode { + Standard, + Defaults, + Locked, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValueOrigin { + CliFlag, + Environment(String), + ConfigFile(PathBuf), + Embedded, + Default, + Prompt, +} + +impl fmt::Display for ValueOrigin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CliFlag => write!(f, "flag"), + Self::Environment(name) => write!(f, "env:{name}"), + Self::ConfigFile(path) => write!(f, "config:{}", path.display()), + Self::Embedded => write!(f, "embedded"), + Self::Default => write!(f, "default"), + Self::Prompt => write!(f, "prompt"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PendingAuth { + Token { value: String, origin: ValueOrigin }, + PromptRequested, + Missing, +} + +#[derive(Debug, Clone)] +pub struct PendingConfig { + pub youtrack_url: String, + pub auth: PendingAuth, + pub output: OutputFormat, + pub verbose: bool, + pub quiet: bool, + pub youtrack_url_origin: ValueOrigin, + pub embed_mode: EmbeddedMode, +} + +impl PendingConfig { + pub fn with_auth(&self, token: String, token_origin: ValueOrigin) -> ResolvedConfig { + ResolvedConfig { + youtrack_url: self.youtrack_url.clone(), + token, + output: self.output, + verbose: self.verbose, + quiet: self.quiet, + youtrack_url_origin: self.youtrack_url_origin.clone(), + token_origin, + embed_mode: self.embed_mode, + } + } +} + +#[derive(Debug, Clone)] +pub struct ResolvedConfig { + pub youtrack_url: String, + pub token: String, + pub output: OutputFormat, + pub verbose: bool, + pub quiet: bool, + pub youtrack_url_origin: ValueOrigin, + pub token_origin: ValueOrigin, + pub embed_mode: EmbeddedMode, +} + +#[derive(Debug)] +struct EmbeddedConfig { + mode: EmbeddedMode, + youtrack_url: Option, + token: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct FileConfig { + youtrack_url: Option, + token: Option, + output: Option, +} + +pub fn resolve(cli: &Cli) -> Result { + let embedded = embedded_config()?; + let file_path = default_config_path(); + let file_config = load_file_config(file_path.as_deref())?; + + resolve_with(cli, file_config.as_ref(), &embedded) +} + +fn resolve_with( + cli: &Cli, + file_config: Option<&(PathBuf, FileConfig)>, + embedded: &EmbeddedConfig, +) -> Result { + let output = resolve_output(cli, file_config)?; + let verbose = cli.verbose; + let quiet = cli.quiet; + + let (youtrack_url, youtrack_url_origin) = + resolve_youtrack_url(cli, file_config, embedded)?; + let auth = resolve_auth(cli, file_config, embedded)?; + + Ok(PendingConfig { + youtrack_url, + auth, + output, + verbose, + quiet, + youtrack_url_origin, + embed_mode: embedded.mode, + }) +} + +fn resolve_youtrack_url( + cli: &Cli, + file_config: Option<&(PathBuf, FileConfig)>, + embedded: &EmbeddedConfig, +) -> Result<(String, ValueOrigin), CliError> { + if embedded.mode == EmbeddedMode::Locked { + let value = embedded.youtrack_url.clone().ok_or_else(|| { + CliError::Config("locked build is missing embedded YOUTRACK_URL".to_string()) + })?; + return Ok((value, ValueOrigin::Embedded)); + } + + if let Some(value) = cli.youtrack_url.clone() { + return Ok((value, ValueOrigin::CliFlag)); + } + + if let Ok(value) = env::var("YOUTRACK_URL") { + if !value.trim().is_empty() { + return Ok((value, ValueOrigin::Environment("YOUTRACK_URL".to_string()))); + } + } + + if let Some((path, cfg)) = file_config { + if let Some(value) = cfg.youtrack_url.clone() { + let (resolved, origin) = resolve_config_string(value, path, "youtrack_url")?; + return Ok((resolved, origin)); + } + } + + if let Some(value) = embedded.youtrack_url.clone() { + return Ok((value, ValueOrigin::Embedded)); + } + + // Unlike GitLab, there is no sensible default URL for YouTrack. + Err(CliError::Config( + "YouTrack URL is required. Set --youtrack-url, YOUTRACK_URL, \ + or add youtrack_url to ~/.config/youtrack-cli/config.toml" + .to_string(), + )) +} + +fn resolve_auth( + cli: &Cli, + file_config: Option<&(PathBuf, FileConfig)>, + embedded: &EmbeddedConfig, +) -> Result { + if embedded.mode == EmbeddedMode::Locked { + if cli.auth { + return Err(CliError::Config(LOCKED_AUTH_MESSAGE.to_string())); + } + + let value = embedded.token.clone().ok_or_else(|| { + CliError::Config("locked build is missing embedded YOUTRACK_TOKEN".to_string()) + })?; + return Ok(PendingAuth::Token { + value, + origin: ValueOrigin::Embedded, + }); + } + + if cli.auth { + return Ok(PendingAuth::PromptRequested); + } + + if let Some(value) = cli.token.clone() { + return Ok(PendingAuth::Token { + value, + origin: ValueOrigin::CliFlag, + }); + } + + if let Ok(value) = env::var("YOUTRACK_TOKEN") { + if !value.trim().is_empty() { + return Ok(PendingAuth::Token { + value, + origin: ValueOrigin::Environment("YOUTRACK_TOKEN".to_string()), + }); + } + } + + if let Some((path, cfg)) = file_config { + if let Some(value) = cfg.token.clone() { + if let Some((resolved, origin)) = resolve_token_config_string(value, path) { + return Ok(PendingAuth::Token { + value: resolved, + origin, + }); + } + } + } + + if let Some(value) = embedded.token.clone() { + return Ok(PendingAuth::Token { + value, + origin: ValueOrigin::Embedded, + }); + } + + Ok(PendingAuth::Missing) +} + +fn resolve_output( + cli: &Cli, + file_config: Option<&(PathBuf, FileConfig)>, +) -> Result { + if let Some(value) = cli.output { + return Ok(value); + } + + if cli.json { + return Ok(OutputFormat::Json); + } + + if let Ok(value) = env::var("YOUTRACK_OUTPUT") { + return value + .parse() + .map_err(|err: String| CliError::Config(format!("invalid YOUTRACK_OUTPUT value: {err}"))); + } + + if let Some((_, cfg)) = file_config { + if let Some(value) = cfg.output { + return Ok(value); + } + } + + Ok(OutputFormat::Table) +} + +fn resolve_config_string( + raw: String, + path: &Path, + field_name: &str, +) -> Result<(String, ValueOrigin), CliError> { + if let Some(env_name) = raw.strip_prefix("env:") { + let value = env::var(env_name).map_err(|_| { + CliError::Config(format!( + "config field `{field_name}` references env var `{env_name}`, but it is not set" + )) + })?; + return Ok((value, ValueOrigin::Environment(env_name.to_string()))); + } + + Ok((raw, ValueOrigin::ConfigFile(path.to_path_buf()))) +} + +fn resolve_token_config_string(raw: String, path: &Path) -> Option<(String, ValueOrigin)> { + if let Some(env_name) = raw.strip_prefix("env:") { + let value = env::var(env_name).ok()?; + if value.trim().is_empty() { + return None; + } + + return Some((value, ValueOrigin::Environment(env_name.to_string()))); + } + + if raw.trim().is_empty() { + return None; + } + + Some((raw, ValueOrigin::ConfigFile(path.to_path_buf()))) +} + +fn load_file_config(path: Option<&Path>) -> Result, CliError> { + let Some(path) = path else { + return Ok(None); + }; + + if !path.exists() { + return Ok(None); + } + + let contents = fs::read_to_string(path)?; + let parsed = toml::from_str::(&contents)?; + Ok(Some((path.to_path_buf(), parsed))) +} + +fn default_config_path() -> Option { + if let Ok(value) = env::var("XDG_CONFIG_HOME") { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some( + PathBuf::from(trimmed) + .join("youtrack-cli") + .join("config.toml"), + ); + } + } + + if let Ok(value) = env::var("HOME") { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some( + PathBuf::from(trimmed) + .join(".config") + .join("youtrack-cli") + .join("config.toml"), + ); + } + } + + None +} + +fn embedded_config() -> Result { + let mode = match option_env!("YOUTRACK_CLI_EMBED_MODE").unwrap_or("standard") { + "standard" => EmbeddedMode::Standard, + "defaults" => EmbeddedMode::Defaults, + "locked" => EmbeddedMode::Locked, + other => { + return Err(CliError::Config(format!( + "unsupported embedded build mode baked into binary: {other}" + ))); + } + }; + + Ok(EmbeddedConfig { + mode, + youtrack_url: option_env!("YOUTRACK_CLI_EMBED_URL").map(ToString::to_string), + token: option_env!("YOUTRACK_CLI_EMBED_TOKEN").map(ToString::to_string), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::{Cli, Command, IssueArgs, IssueCommand, IssueListArgs}; + + fn base_cli() -> Cli { + Cli { + youtrack_url: None, + token: None, + auth: false, + output: None, + json: false, + verbose: false, + quiet: false, + command: Command::Issue(IssueArgs { + command: IssueCommand::List(IssueListArgs { + query: None, + top: 50, + skip: 0, + all: false, + }), + }), + } + } + + #[test] + fn resolves_prompt_requested_when_auth_flag_is_set() { + let mut cli = base_cli(); + cli.auth = true; + cli.youtrack_url = Some("https://youtrack.example.com".to_string()); + + let config = resolve_with( + &cli, + None, + &EmbeddedConfig { + mode: EmbeddedMode::Standard, + youtrack_url: None, + token: None, + }, + ) + .expect("config should resolve"); + + assert_eq!(config.auth, PendingAuth::PromptRequested); + } + + #[test] + fn resolves_missing_auth_when_no_token_source_exists() { + let mut cli = base_cli(); + cli.youtrack_url = Some("https://youtrack.example.com".to_string()); + + let config = resolve_with( + &cli, + None, + &EmbeddedConfig { + mode: EmbeddedMode::Standard, + youtrack_url: None, + token: None, + }, + ) + .expect("config should resolve"); + + assert_eq!(config.auth, PendingAuth::Missing); + } + + #[test] + fn rejects_auth_flag_in_locked_mode() { + let mut cli = base_cli(); + cli.auth = true; + + let err = resolve_with( + &cli, + None, + &EmbeddedConfig { + mode: EmbeddedMode::Locked, + youtrack_url: Some("https://youtrack.example.com".to_string()), + token: Some("embedded-token".to_string()), + }, + ) + .expect_err("locked mode should reject --auth"); + + assert_eq!(err.to_string(), LOCKED_AUTH_MESSAGE); + } + + #[test] + fn displays_prompt_value_origin() { + assert_eq!(ValueOrigin::Prompt.to_string(), "prompt"); + } + + #[test] + fn errors_when_no_url_source_exists() { + let cli = base_cli(); + + let err = resolve_with( + &cli, + None, + &EmbeddedConfig { + mode: EmbeddedMode::Standard, + youtrack_url: None, + token: None, + }, + ) + .expect_err("should error when no URL is available"); + + assert!(err.to_string().contains("YouTrack URL is required")); + } +} diff --git a/src/error.rs b/src/error.rs index 98b65aa..af175b7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -8,13 +8,8 @@ pub enum CliError { #[error("{0}")] Api(#[from] ApiError), - #[error( - "no credentials resolved. Provide them via:\n \ - 1. env vars: YOUTRACK_URL and YOUTRACK_TOKEN\n \ - 2. compile-time embed: set YOUTRACK_URL_EMBED and YOUTRACK_TOKEN_EMBED at `cargo build`\n \ - 3. interactive prompt (requires a TTY on stdin)" - )] - NoCredentials, + #[error("{0}")] + Config(String), #[error("operation cancelled")] Cancelled, @@ -24,17 +19,39 @@ pub enum CliError { #[error("{0}")] Json(#[from] serde_json::Error), + + #[error(transparent)] + Yaml(#[from] serde_yml::Error), + + #[error(transparent)] + Toml(#[from] toml::de::Error), + + #[error( + "no credentials resolved. Provide them via:\n \ + 1. env vars: YOUTRACK_URL and YOUTRACK_TOKEN\n \ + 2. compile-time embed: set build env vars at `cargo build`\n \ + 3. config file: ~/.config/youtrack-cli/config.toml\n \ + 4. interactive prompt (requires a TTY on stdin)" + )] + NoCredentials, } impl CliError { pub fn exit_code(&self) -> ExitCode { match self { - CliError::Api(ApiError::Api { status, .. }) if status.as_u16() == 404 => { - ExitCode::from(3) - } + Self::Config(_) => ExitCode::from(2), + Self::Api(ApiError::Config(_)) => ExitCode::from(2), + Self::Api(ApiError::Unauthorized) => ExitCode::from(3), + Self::Api(ApiError::Forbidden) => ExitCode::from(3), + Self::Api(ApiError::NotFound(_)) => ExitCode::from(4), + Self::Api(ApiError::RateLimited { .. }) => ExitCode::from(5), _ => ExitCode::from(1), } } + + pub fn print(&self) { + eprintln!("error: {self}"); + } } pub type CliResult = Result; diff --git a/src/main.rs b/src/main.rs index 10bc88a..f9c4857 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,91 +1,25 @@ +mod app; mod auth; mod cli; -mod client; mod commands; +mod config; mod error; mod output; use std::process::ExitCode; use clap::Parser; -use tracing_subscriber::EnvFilter; -use crate::cli::{Cli, Command, CommentCommand, IssueCommand}; -use crate::error::CliResult; -use crate::output::OutputMode; +use crate::cli::Cli; #[tokio::main] async fn main() -> ExitCode { let cli = Cli::parse(); - init_tracing(cli.verbose, cli.quiet); - match run(cli).await { + match app::run(cli).await { Ok(()) => ExitCode::SUCCESS, Err(err) => { - eprintln!("error: {err}"); - let mut source = std::error::Error::source(&err); - while let Some(cause) = source { - eprintln!(" caused by: {cause}"); - source = cause.source(); - } + err.print(); err.exit_code() } } } - -async fn run(cli: Cli) -> CliResult<()> { - let mode = if cli.json { - OutputMode::Json - } else { - OutputMode::Table - }; - - let creds = auth::resolve()?; - let client = client::build(&creds)?; - - match cli.command { - Command::Issue(issue) => match issue.command { - IssueCommand::List(args) => commands::issue::list(&client, args, mode).await, - IssueCommand::Get { id } => commands::issue::get(&client, &id, mode).await, - IssueCommand::Create(args) => commands::issue::create(&client, args, mode).await, - IssueCommand::Update(args) => commands::issue::update(&client, args, mode).await, - IssueCommand::Delete(args) => commands::issue::delete(&client, args).await, - IssueCommand::Comment(c) => match c.command { - CommentCommand::List { issue_id } => { - commands::comment::list(&client, &issue_id, mode).await - } - CommentCommand::Add { issue_id, text } => { - commands::comment::add(&client, &issue_id, text, mode).await - } - CommentCommand::Update { - issue_id, - comment_id, - text, - } => commands::comment::update(&client, &issue_id, &comment_id, text, mode).await, - CommentCommand::Delete { - issue_id, - comment_id, - yes, - } => commands::comment::delete(&client, &issue_id, &comment_id, yes).await, - }, - IssueCommand::Attachment(a) => { - commands::attachment::dispatch(&client, a.command, mode).await - } - }, - } -} - -fn init_tracing(verbose: bool, quiet: bool) { - let level = if quiet { - "error" - } else if verbose { - "debug" - } else { - "warn" - }; - - let _ = tracing_subscriber::fmt() - .with_target(false) - .without_time() - .with_env_filter(EnvFilter::new(level)) - .try_init(); -} diff --git a/src/output.rs b/src/output.rs index 1f4005e..c7fa624 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,30 +1,62 @@ +use std::fmt; +use std::str::FromStr; + +use clap::ValueEnum; use comfy_table::{Cell, ContentArrangement, Table}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use youtrack_client_api::models::attachment::IssueAttachment; use youtrack_client_api::models::comment::IssueComment; use youtrack_client_api::models::issue::Issue; use crate::error::CliResult; -#[derive(Copy, Clone, Debug)] -pub enum OutputMode { +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] +#[serde(rename_all = "lowercase")] +pub enum OutputFormat { Table, Json, + Yaml, +} + +impl fmt::Display for OutputFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Table => write!(f, "table"), + Self::Json => write!(f, "json"), + Self::Yaml => write!(f, "yaml"), + } + } +} + +impl FromStr for OutputFormat { + type Err = String; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "table" => Ok(Self::Table), + "json" => Ok(Self::Json), + "yaml" => Ok(Self::Yaml), + other => Err(format!("unsupported output format: {other}")), + } + } } pub trait Render { fn render_table(&self) -> Table; } -pub fn emit(value: &T, mode: OutputMode) -> CliResult<()> +pub fn emit(value: &T, mode: OutputFormat) -> CliResult<()> where T: Render + Serialize, { match mode { - OutputMode::Json => { + OutputFormat::Json => { println!("{}", serde_json::to_string_pretty(value)?); } - OutputMode::Table => { + OutputFormat::Yaml => { + print!("{}", serde_yml::to_string(value)?); + } + OutputFormat::Table => { println!("{}", value.render_table()); } } @@ -48,7 +80,7 @@ fn truncate(s: &str, max: usize) -> String { s.to_string() } else { let mut out: String = s.chars().take(max.saturating_sub(1)).collect(); - out.push('…'); + out.push('\u{2026}'); out } }