diff --git a/Cargo.lock b/Cargo.lock index d5f27ad..4f81a3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,7 +68,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -79,7 +79,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -190,6 +190,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -280,6 +290,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -408,6 +427,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "document-features" version = "0.2.12" @@ -436,7 +466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -469,6 +499,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "finl_unicode" version = "1.4.0" @@ -481,6 +517,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -499,6 +545,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -533,6 +588,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -602,6 +668,88 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -614,6 +762,27 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -708,9 +877,12 @@ dependencies = [ "miette", "ratatui", "serde", + "serde_json", "serde_yaml", + "time", "tracing", "tracing-subscriber", + "ureq", "walkdir", ] @@ -753,6 +925,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "litrs" version = "1.0.0" @@ -857,6 +1035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -868,7 +1047,7 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -900,7 +1079,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -997,6 +1176,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pest" version = "2.8.6" @@ -1104,6 +1289,15 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1288,6 +1482,20 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -1313,7 +1521,42 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -1425,6 +1668,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -1456,6 +1705,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "siphasher" version = "1.0.3" @@ -1474,6 +1729,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1507,6 +1768,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "supports-color" version = "3.0.2" @@ -1550,6 +1817,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "terminal_size" version = "0.4.4" @@ -1557,7 +1835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1689,12 +1967,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -1703,6 +1983,26 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tracing" version = "0.1.44" @@ -1825,6 +2125,46 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1977,6 +2317,24 @@ dependencies = [ "semver", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -2071,7 +2429,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2086,6 +2444,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[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" @@ -2095,6 +2462,70 @@ 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 = "wit-bindgen" version = "0.51.0" @@ -2189,6 +2620,95 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 15de24a..1f83484 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,9 @@ miette = { version = "7", features = ["fancy"] } ratatui = "0.30" serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" +serde_json = "1" +ureq = "2" +time = { version = "0.3", features = ["formatting"] } tracing = "0.1" tracing-subscriber = "0.3" walkdir = "2" diff --git a/src/compose/down.rs b/src/compose/down.rs index 97cb88e..3b59dee 100644 --- a/src/compose/down.rs +++ b/src/compose/down.rs @@ -1,4 +1,72 @@ +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ComposeDownRequest { pub remove_volumes: bool, + pub remove_orphans: bool, +} + +impl Default for ComposeDownRequest { + fn default() -> Self { + Self { + remove_volumes: false, + remove_orphans: true, + } + } +} + +/// Execute `docker compose down`. +pub fn execute(request: &ComposeDownRequest, project_root: &Path) -> Result { + let mut args = vec!["compose".to_string(), "down".to_string()]; + + if request.remove_volumes { + args.push("-v".to_string()); + } + + if request.remove_orphans { + args.push("--remove-orphans".to_string()); + } + + let output = Command::new("docker") + .args(&args) + .current_dir(project_root) + .output() + .context("Failed to execute docker compose down")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if output.status.success() { + Ok(format!("{stdout}{stderr}")) + } else { + anyhow::bail!("docker compose down failed: {stderr}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compose_down_defaults() { + let request = ComposeDownRequest::default(); + assert!(!request.remove_volumes); + assert!(request.remove_orphans); + } + + #[test] + fn test_execute() { + crate::utils::test_support::set_mock_path(); + let request = ComposeDownRequest { + remove_volumes: true, + remove_orphans: true, + }; + let res = execute(&request, Path::new(".")); + assert!(res.is_ok()); + let output = res.unwrap(); + assert!(output.contains("Stopping container")); + } } diff --git a/src/compose/logs.rs b/src/compose/logs.rs index ef5a578..86f5dd1 100644 --- a/src/compose/logs.rs +++ b/src/compose/logs.rs @@ -1,4 +1,90 @@ +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ComposeLogRequest { pub follow: bool, + pub service: Option, + pub tail: Option, +} + +impl Default for ComposeLogRequest { + fn default() -> Self { + Self { + follow: false, + service: None, + tail: Some(100), + } + } +} + +/// Fetch compose logs. +pub fn fetch(request: &ComposeLogRequest, project_root: &Path) -> Result> { + let mut args = vec!["compose".to_string(), "logs".to_string()]; + + if request.follow { + args.push("--follow".to_string()); + } + + if let Some(tail) = request.tail { + args.push("--tail".to_string()); + args.push(tail.to_string()); + } + + if let Some(service) = &request.service { + args.push(service.clone()); + } + + let output = Command::new("docker") + .args(&args) + .current_dir(project_root) + .output() + .context("Failed to execute docker compose logs")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("docker compose logs failed: {}", stderr); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{stdout}{stderr}"); + + let lines = combined + .lines() + .filter(|line| !line.is_empty()) + .map(|line| line.to_string()) + .collect(); + + Ok(lines) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compose_log_request_defaults() { + let request = ComposeLogRequest::default(); + assert!(!request.follow); + assert!(request.service.is_none()); + assert_eq!(request.tail, Some(100)); + } + + #[test] + fn test_fetch_compose_logs() { + crate::utils::test_support::set_mock_path(); + let request = ComposeLogRequest { + follow: false, + service: Some("web".to_string()), + tail: Some(10), + }; + let res = fetch(&request, Path::new(".")); + assert!(res.is_ok()); + let lines = res.unwrap(); + assert!(lines.len() >= 2); + assert_eq!(lines[0], "compose log line 1"); + } } diff --git a/src/compose/mod.rs b/src/compose/mod.rs index 90dd0aa..bd719c4 100644 --- a/src/compose/mod.rs +++ b/src/compose/mod.rs @@ -1,4 +1,5 @@ pub mod down; pub mod logs; pub mod restart; +pub mod services; pub mod up; diff --git a/src/compose/restart.rs b/src/compose/restart.rs index 22d3fc4..4b62eb6 100644 --- a/src/compose/restart.rs +++ b/src/compose/restart.rs @@ -1,4 +1,62 @@ +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ComposeRestartRequest { pub service: Option, } + +/// Execute `docker compose restart`. +pub fn execute(request: &ComposeRestartRequest, project_root: &Path) -> Result { + let mut args = vec!["compose".to_string(), "restart".to_string()]; + + if let Some(service) = &request.service { + args.push(service.clone()); + } + + let output = Command::new("docker") + .args(&args) + .current_dir(project_root) + .output() + .context("Failed to execute docker compose restart")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if output.status.success() { + Ok(format!("{stdout}{stderr}")) + } else { + anyhow::bail!("docker compose restart failed: {stderr}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compose_restart_without_service() { + let request = ComposeRestartRequest { service: None }; + assert!(request.service.is_none()); + } + + #[test] + fn compose_restart_with_service() { + let request = ComposeRestartRequest { + service: Some("web".to_string()), + }; + assert_eq!(request.service, Some("web".to_string())); + } + + #[test] + fn test_execute() { + crate::utils::test_support::set_mock_path(); + let request = ComposeRestartRequest { + service: Some("db".to_string()), + }; + let res = execute(&request, Path::new(".")); + assert!(res.is_ok()); + } +} diff --git a/src/compose/services.rs b/src/compose/services.rs new file mode 100644 index 0000000..202430c --- /dev/null +++ b/src/compose/services.rs @@ -0,0 +1,72 @@ +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result}; + +/// List the services defined in the Compose file. +pub fn list(project_root: &Path) -> Result> { + let output = Command::new("docker") + .args(["compose", "config", "--services"]) + .current_dir(project_root) + .output() + .context("Failed to execute docker compose config --services")?; + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("docker compose config --services failed: {err}"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let services = stdout + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| line.trim().to_string()) + .collect(); + + Ok(services) +} + +/// Check if any Compose services are currently running. +pub fn running(project_root: &Path) -> Result> { + let output = Command::new("docker") + .args(["compose", "ps", "--services", "--filter", "status=running"]) + .current_dir(project_root) + .output() + .context("Failed to execute docker compose ps --services")?; + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("docker compose ps --services failed: {err}"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let services = stdout + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| line.trim().to_string()) + .collect(); + + Ok(services) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn services_module_compiles() { + let _: fn(&std::path::Path) -> anyhow::Result> = super::list; + } + + #[test] + fn test_list_and_running() { + crate::utils::test_support::set_mock_path(); + let path = std::path::Path::new("."); + + let services = list(path).unwrap(); + assert_eq!(services, vec!["service1", "service2"]); + + let running_services = running(path).unwrap(); + assert_eq!(running_services, vec!["service1"]); + } +} diff --git a/src/compose/up.rs b/src/compose/up.rs index 5ab4545..2923dcc 100644 --- a/src/compose/up.rs +++ b/src/compose/up.rs @@ -1,4 +1,86 @@ +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ComposeUpRequest { pub detached: bool, + pub services: Vec, + pub build: bool, +} + +impl Default for ComposeUpRequest { + fn default() -> Self { + Self { + detached: true, + services: Vec::new(), + build: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComposeUpResult { + pub success: bool, + pub output: String, +} + +/// Execute `docker compose up`. +pub fn execute(request: &ComposeUpRequest, project_root: &Path) -> Result { + let mut args = vec!["compose".to_string(), "up".to_string()]; + + if request.detached { + args.push("-d".to_string()); + } + + if request.build { + args.push("--build".to_string()); + } + + for service in &request.services { + args.push(service.clone()); + } + + let output = Command::new("docker") + .args(&args) + .current_dir(project_root) + .output() + .context("Failed to execute docker compose up")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + Ok(ComposeUpResult { + success: output.status.success(), + output: format!("{stdout}{stderr}"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compose_up_defaults_to_detached() { + let request = ComposeUpRequest::default(); + assert!(request.detached); + assert!(request.services.is_empty()); + assert!(!request.build); + } + + #[test] + fn test_execute() { + crate::utils::test_support::set_mock_path(); + let request = ComposeUpRequest { + detached: true, + services: vec!["db".to_string()], + build: true, + }; + let res = execute(&request, Path::new(".")); + assert!(res.is_ok()); + let result = res.unwrap(); + assert!(result.success); + assert!(result.output.contains("Starting container")); + } } diff --git a/src/config/paths.rs b/src/config/paths.rs index 09c6e4a..91a278a 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -14,3 +14,28 @@ pub fn config_file() -> PathBuf { pub fn history_file() -> PathBuf { config_dir().join("history.yaml") } + +pub fn deploy_history_file() -> PathBuf { + config_dir().join("deploy_history.yaml") +} + +pub fn doctor_report_file() -> PathBuf { + config_dir().join("doctor_report.json") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deploy_history_path_is_under_config_dir() { + let path = deploy_history_file(); + assert!(path.to_string_lossy().contains("deploy_history.yaml")); + } + + #[test] + fn doctor_report_path_is_under_config_dir() { + let path = doctor_report_file(); + assert!(path.to_string_lossy().contains("doctor_report.json")); + } +} diff --git a/src/config/settings.rs b/src/config/settings.rs index 83d62e3..8cfd2a4 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -7,6 +7,14 @@ use serde::{Deserialize, Serialize}; pub struct Settings { pub theme: String, pub default_environment: String, + #[serde(default)] + pub registry: Option, + #[serde(default = "default_namespace")] + pub default_namespace: String, +} + +fn default_namespace() -> String { + "default".to_string() } impl Default for Settings { @@ -14,6 +22,8 @@ impl Default for Settings { Self { theme: "dark".to_string(), default_environment: "development".to_string(), + registry: None, + default_namespace: default_namespace(), } } } @@ -41,6 +51,12 @@ impl Settings { fs::write(path, content) .with_context(|| format!("Unable to write settings to {}", path.display())) } + + /// Update the theme and persist the change. + pub fn set_theme(&mut self, theme: &str, path: &Path) -> Result<()> { + self.theme = theme.to_string(); + self.save(path) + } } #[cfg(test)] @@ -65,6 +81,8 @@ mod tests { let settings = Settings { theme: "nord".to_string(), default_environment: "staging".to_string(), + registry: Some("ghcr.io".to_string()), + default_namespace: "staging".to_string(), }; settings.save(&path).unwrap(); @@ -73,4 +91,35 @@ mod tests { assert_eq!(settings, loaded); fs::remove_file(path).unwrap(); } + + #[test] + fn settings_backward_compatible() { + let path = std::env::temp_dir().join(format!( + "kdc-settings-compat-{}.yaml", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + + // Write an old-format config (without registry and default_namespace). + let old_content = "theme: dark\ndefault_environment: development\n"; + fs::write(&path, old_content).unwrap(); + + let loaded = Settings::load_or_default(&path).unwrap(); + assert_eq!(loaded.theme, "dark"); + assert!(loaded.registry.is_none()); + assert_eq!(loaded.default_namespace, "default"); + + fs::remove_file(path).unwrap(); + } + + #[test] + fn default_settings_have_expected_values() { + let settings = Settings::default(); + assert_eq!(settings.theme, "dark"); + assert_eq!(settings.default_environment, "development"); + assert!(settings.registry.is_none()); + assert_eq!(settings.default_namespace, "default"); + } } diff --git a/src/deploy/environments.rs b/src/deploy/environments.rs index 720b170..5440173 100644 --- a/src/deploy/environments.rs +++ b/src/deploy/environments.rs @@ -1 +1,58 @@ -pub use crate::project::environment::Environment; +use crate::project::environment::Environment; + +pub use crate::project::environment::Environment as DeployEnvironment; + +/// Map an environment to a Kubernetes namespace. +pub fn resolve_namespace(env: &Environment) -> String { + match env { + Environment::Development => "default".to_string(), + Environment::Staging => "staging".to_string(), + Environment::Production => "production".to_string(), + } +} + +/// Parse an environment string into an `Environment` enum. +pub fn from_string(s: &str) -> Environment { + match s.to_lowercase().as_str() { + "staging" | "stg" => Environment::Staging, + "production" | "prod" => Environment::Production, + "development" | "dev" | "" => Environment::Development, + other => { + tracing::warn!( + "Unknown environment input '{}', falling back to Development", + other + ); + Environment::Development + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn development_resolves_to_default_namespace() { + assert_eq!(resolve_namespace(&Environment::Development), "default"); + } + + #[test] + fn staging_resolves_to_staging_namespace() { + assert_eq!(resolve_namespace(&Environment::Staging), "staging"); + } + + #[test] + fn production_resolves_to_production_namespace() { + assert_eq!(resolve_namespace(&Environment::Production), "production"); + } + + #[test] + fn from_string_parses_variations() { + assert_eq!(from_string("staging"), Environment::Staging); + assert_eq!(from_string("stg"), Environment::Staging); + assert_eq!(from_string("production"), Environment::Production); + assert_eq!(from_string("prod"), Environment::Production); + assert_eq!(from_string("development"), Environment::Development); + assert_eq!(from_string("anything"), Environment::Development); + } +} diff --git a/src/deploy/history.rs b/src/deploy/history.rs new file mode 100644 index 0000000..d900db6 --- /dev/null +++ b/src/deploy/history.rs @@ -0,0 +1,161 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// A record of a single deployment execution. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DeploymentRecord { + pub timestamp: String, + pub environment: String, + pub image_tag: String, + pub success: bool, + pub steps_completed: usize, + pub steps_total: usize, + pub duration_secs: f64, + pub message: String, +} + +/// Persistent deployment history stored as YAML. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct DeploymentHistory { + pub records: Vec, +} + +const MAX_RECORDS: usize = 50; + +impl DeploymentHistory { + /// Record a new deployment, keeping only the most recent entries. + pub fn record(&mut self, entry: DeploymentRecord) { + self.records.insert(0, entry); + self.records.truncate(MAX_RECORDS); + } + + /// Load deployment history from a YAML file, or return an empty history. + pub fn load_or_default(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + + let content = std::fs::read_to_string(path) + .with_context(|| format!("Unable to read deploy history from {}", path.display()))?; + serde_yaml::from_str(&content) + .with_context(|| format!("Unable to parse deploy history from {}", path.display())) + } + + /// Save deployment history to a YAML file. + pub fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "Unable to create deploy history directory {}", + parent.display() + ) + })?; + } + + let content = + serde_yaml::to_string(self).context("Unable to serialize deployment history")?; + std::fs::write(path, content) + .with_context(|| format!("Unable to write deploy history to {}", path.display())) + } + + /// Return the most recent successful deployment, if any. + pub fn last_success(&self) -> Option<&DeploymentRecord> { + self.records.iter().find(|r| r.success) + } + + /// Return the total number of deployments recorded. + pub fn total_deployments(&self) -> usize { + self.records.len() + } + + /// Return the number of successful deployments. + pub fn successful_deployments(&self) -> usize { + self.records.iter().filter(|r| r.success).count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_record(success: bool) -> DeploymentRecord { + DeploymentRecord { + timestamp: "2026-05-29T12:00:00Z".to_string(), + environment: "development".to_string(), + image_tag: "myapp:latest".to_string(), + success, + steps_completed: 5, + steps_total: 5, + duration_secs: 10.5, + message: "All steps completed".to_string(), + } + } + + #[test] + fn records_are_newest_first() { + let mut history = DeploymentHistory::default(); + let mut r1 = sample_record(true); + r1.timestamp = "2026-05-28T12:00:00Z".to_string(); + let mut r2 = sample_record(true); + r2.timestamp = "2026-05-29T12:00:00Z".to_string(); + + history.record(r1); + history.record(r2.clone()); + + assert_eq!(history.records[0].timestamp, r2.timestamp); + } + + #[test] + fn history_truncates_at_max() { + let mut history = DeploymentHistory::default(); + for i in 0..60 { + let mut record = sample_record(true); + record.timestamp = format!("2026-05-29T{i:02}:00:00Z"); + history.record(record); + } + assert_eq!(history.records.len(), 50); + } + + #[test] + fn last_success_finds_most_recent() { + let mut history = DeploymentHistory::default(); + history.record(sample_record(false)); + history.record(sample_record(true)); + + assert!(history.last_success().is_some()); + assert!(history.last_success().unwrap().success); + } + + #[test] + fn counts_are_correct() { + let mut history = DeploymentHistory::default(); + history.record(sample_record(true)); + history.record(sample_record(false)); + history.record(sample_record(true)); + + assert_eq!(history.total_deployments(), 3); + assert_eq!(history.successful_deployments(), 2); + } + + #[test] + fn history_yaml_round_trip() { + let path = std::env::temp_dir().join(format!( + "kdc-deploy-history-{}.yaml", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + + let mut history = DeploymentHistory::default(); + history.record(sample_record(true)); + history.save(&path).unwrap(); + + let loaded = DeploymentHistory::load_or_default(&path).unwrap(); + assert_eq!(history, loaded); + + std::fs::remove_file(path).unwrap(); + } +} diff --git a/src/deploy/mod.rs b/src/deploy/mod.rs index 5c51c10..ef0a24e 100644 --- a/src/deploy/mod.rs +++ b/src/deploy/mod.rs @@ -1,4 +1,5 @@ pub mod environments; +pub mod history; pub mod pipeline; pub mod release; pub mod rollback; diff --git a/src/deploy/pipeline.rs b/src/deploy/pipeline.rs index 498190c..1b8a0ef 100644 --- a/src/deploy/pipeline.rs +++ b/src/deploy/pipeline.rs @@ -1,6 +1,9 @@ -use anyhow::Result; +use std::process::Command; +use std::time::Instant; -use crate::project::{ProjectCapabilities, RuntimeCapabilities}; +use anyhow::{Context, Result}; + +use crate::project::{ProjectCapabilities, ProjectContext, RuntimeCapabilities}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PipelineStep { @@ -21,6 +24,50 @@ pub struct DeploymentPlan { pub blockers: Vec, } +#[derive(Debug, Clone, PartialEq)] +pub struct PipelineStepResult { + pub step: PipelineStep, + pub success: bool, + pub message: String, + pub duration_secs: f64, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PipelineExecution { + pub results: Vec, + pub overall_success: bool, +} + +impl PipelineExecution { + pub fn total_duration_secs(&self) -> f64 { + self.results.iter().map(|r| r.duration_secs).sum() + } + + pub fn render(&self) -> String { + let mut lines = vec![format!( + "Pipeline Execution: {}", + if self.overall_success { + "SUCCESS" + } else { + "FAILED" + } + )]; + + for result in &self.results { + let marker = if result.success { "✓" } else { "✗" }; + lines.push(format!( + " {marker} {} ({:.1}s) - {}", + result.step.label(), + result.duration_secs, + result.message + )); + } + + lines.push(format!("Total: {:.1}s", self.total_duration_secs())); + lines.join("\n") + } +} + impl DeploymentPlan { pub fn ready(&self) -> bool { self.blockers.is_empty() @@ -98,6 +145,213 @@ pub fn plan(capabilities: &ProjectCapabilities, runtime: &RuntimeCapabilities) - DeploymentPlan { steps, blockers } } +pub trait CommandRunner { + fn run( + &self, + cmd: &str, + args: &[&str], + current_dir: Option<&std::path::Path>, + ) -> Result; +} + +pub struct RealCommandRunner; + +impl CommandRunner for RealCommandRunner { + fn run( + &self, + cmd: &str, + args: &[&str], + current_dir: Option<&std::path::Path>, + ) -> Result { + let mut command = Command::new(cmd); + command.args(args); + if let Some(dir) = current_dir { + command.current_dir(dir); + } + command + .output() + .context(format!("Failed to execute {}", cmd)) + } +} + +/// Execute the deployment pipeline against a real project. +pub fn execute_pipeline( + plan: &DeploymentPlan, + project: &ProjectContext, + capabilities: &ProjectCapabilities, + environment_str: &str, +) -> Result { + execute_pipeline_with_runner( + plan, + project, + capabilities, + environment_str, + &RealCommandRunner, + ) +} + +pub fn execute_pipeline_with_runner( + plan: &DeploymentPlan, + project: &ProjectContext, + capabilities: &ProjectCapabilities, + environment_str: &str, + runner: &dyn CommandRunner, +) -> Result { + if !plan.ready() { + anyhow::bail!("Deployment plan has blockers: {}", plan.blockers.join(", ")); + } + + let env = crate::deploy::environments::from_string(environment_str); + let namespace = crate::deploy::environments::resolve_namespace(&env); + + let mut results = Vec::new(); + let mut overall_success = true; + + for step in &plan.steps { + let start = Instant::now(); + let step_result = match step { + PipelineStep::Build => execute_build_step(project, runner), + PipelineStep::DockerBuild => execute_docker_build_step(project), + PipelineStep::DockerPush => execute_docker_push_step(project), + PipelineStep::DeploymentUpdate => { + execute_deployment_update_step(project, capabilities, &namespace, runner) + } + PipelineStep::RolloutVerification => { + let deployment_name = project.name.to_lowercase().replace(' ', "-"); + execute_rollout_verification_step(&deployment_name, &namespace, runner) + } + }; + let duration_secs = start.elapsed().as_secs_f64(); + + match step_result { + Ok(message) => { + results.push(PipelineStepResult { + step: *step, + success: true, + message, + duration_secs, + }); + } + Err(err) => { + overall_success = false; + results.push(PipelineStepResult { + step: *step, + success: false, + message: err.to_string(), + duration_secs, + }); + // Stop the pipeline on first failure. + break; + } + } + } + + Ok(PipelineExecution { + results, + overall_success, + }) +} + +fn execute_build_step(project: &ProjectContext, runner: &dyn CommandRunner) -> Result { + let build_cmd = + crate::templates::stacks::build_command(project.stack).unwrap_or("echo 'No build step'"); + + let parts: Vec<&str> = build_cmd.split_whitespace().collect(); + if parts.is_empty() { + return Ok("No build command for this stack".to_string()); + } + + let output = runner.run(parts[0], &parts[1..], Some(&project.root))?; + + if output.status.success() { + Ok(format!("Build completed: {build_cmd}")) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Build failed: {stderr}") + } +} + +fn execute_docker_build_step(project: &ProjectContext) -> Result { + let image_name = project.name.to_lowercase().replace(' ', "-"); + let request = crate::docker::build::BuildRequest { + image: image_name.clone(), + tag: "latest".to_string(), + }; + + let result = crate::docker::build::execute(&request, &project.root)?; + if result.success { + Ok(format!("Docker image built: {}", result.image_tag)) + } else { + anyhow::bail!("Docker build failed: {}", result.output) + } +} + +fn execute_docker_push_step(project: &ProjectContext) -> Result { + let image_name = project.name.to_lowercase().replace(' ', "-"); + let full_tag = format!("{image_name}:latest"); + + crate::docker::images::push(&full_tag)?; + Ok(format!("Pushed {full_tag}")) +} + +fn execute_deployment_update_step( + project: &ProjectContext, + capabilities: &ProjectCapabilities, + namespace: &str, + runner: &dyn CommandRunner, +) -> Result { + if !capabilities.kubernetes { + return Ok("No Kubernetes manifests to apply".to_string()); + } + + let k8s_dir = project.root.join("k8s"); + if !k8s_dir.exists() || !k8s_dir.is_dir() { + anyhow::bail!("k8s/ directory is absent"); + } + + let manifest_path = k8s_dir.to_string_lossy().to_string(); + + let output = runner.run( + "kubectl", + &["apply", "-f", &manifest_path, "-n", namespace], + None, + )?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(format!("Deployment updated: {stdout}")) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("kubectl apply failed: {stderr}") + } +} + +fn execute_rollout_verification_step( + name: &str, + namespace: &str, + runner: &dyn CommandRunner, +) -> Result { + let output = runner.run( + "kubectl", + &[ + "rollout", + "status", + &format!("deployment/{}", name), + "-n", + namespace, + "--timeout=120s", + ], + None, + )?; + + if output.status.success() { + Ok("Rollout verified successfully".to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Rollout verification failed: {stderr}") + } +} + #[cfg(test)] mod tests { use crate::project::{ProjectCapabilities, RuntimeCapabilities}; @@ -132,4 +386,307 @@ mod tests { ] ); } + + #[test] + fn plan_has_blockers_without_docker() { + let plan = plan( + &ProjectCapabilities::default(), + &RuntimeCapabilities::default(), + ); + + assert!(!plan.ready()); + assert!(plan.blockers.contains(&"Dockerfile is missing".to_string())); + } + + #[test] + fn pipeline_execution_renders() { + use super::{PipelineExecution, PipelineStepResult}; + + let execution = PipelineExecution { + results: vec![ + PipelineStepResult { + step: PipelineStep::Build, + success: true, + message: "done".to_string(), + duration_secs: 2.5, + }, + PipelineStepResult { + step: PipelineStep::DockerBuild, + success: false, + message: "Dockerfile not found".to_string(), + duration_secs: 0.1, + }, + ], + overall_success: false, + }; + + let rendered = execution.render(); + assert!(rendered.contains("FAILED")); + assert!(rendered.contains("✓ Build Application")); + assert!(rendered.contains("✗ Docker Build")); + } + + #[test] + fn plan_blocked_without_cluster() { + let plan = plan( + &ProjectCapabilities { + docker: true, + kubernetes: true, + ..ProjectCapabilities::default() + }, + &RuntimeCapabilities { + docker_running: true, + cluster_connected: false, + ..RuntimeCapabilities::default() + }, + ); + + assert!(!plan.ready()); + assert!(plan + .blockers + .contains(&"Kubernetes cluster is not connected".to_string())); + } + + #[test] + fn pipeline_execution_render_shows_success() { + use super::{PipelineExecution, PipelineStepResult}; + + let execution = PipelineExecution { + results: vec![ + PipelineStepResult { + step: PipelineStep::Build, + success: true, + message: "done".to_string(), + duration_secs: 1.5, + }, + PipelineStepResult { + step: PipelineStep::DockerBuild, + success: true, + message: "built".to_string(), + duration_secs: 5.0, + }, + ], + overall_success: true, + }; + + let rendered = execution.render(); + assert!(rendered.contains("SUCCESS")); + assert!(rendered.contains("✓ Build Application")); + assert!(rendered.contains("✓ Docker Build")); + } + + #[test] + fn pipeline_execution_total_duration() { + use super::{PipelineExecution, PipelineStepResult}; + + let execution = PipelineExecution { + results: vec![ + PipelineStepResult { + step: PipelineStep::Build, + success: true, + message: "ok".to_string(), + duration_secs: 2.0, + }, + PipelineStepResult { + step: PipelineStep::DockerBuild, + success: true, + message: "ok".to_string(), + duration_secs: 3.5, + }, + ], + overall_success: true, + }; + + assert!((execution.total_duration_secs() - 5.5).abs() < f64::EPSILON); + } + + #[test] + fn deployment_plan_render_includes_steps_and_blockers() { + use super::DeploymentPlan; + + let plan = DeploymentPlan { + steps: vec![PipelineStep::Build, PipelineStep::DockerBuild], + blockers: vec!["Docker daemon is not running".to_string()], + }; + + let rendered = plan.render(); + assert!(rendered.contains("Build Application")); + assert!(rendered.contains("Docker Build")); + assert!(rendered.contains("Docker daemon is not running")); + assert!(rendered.contains("Ready: false")); + } +} + +#[cfg(test)] +mod pipeline_mock_tests { + use super::*; + use std::sync::Mutex; + + struct MockRunner { + calls: Mutex)>>, + success: bool, + } + + impl CommandRunner for MockRunner { + fn run( + &self, + cmd: &str, + args: &[&str], + _current_dir: Option<&std::path::Path>, + ) -> Result { + self.calls.lock().unwrap().push(( + cmd.to_string(), + args.iter().map(|s| s.to_string()).collect(), + )); + let status = if self.success { + Command::new("true").status().unwrap() + } else { + Command::new("false").status().unwrap() + }; + Ok(std::process::Output { + status, + stdout: b"mock-output".to_vec(), + stderr: b"mock-error".to_vec(), + }) + } + } + + #[test] + fn test_execute_deployment_update_step() { + let temp = std::env::temp_dir().join(format!( + "kdc-k8s-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(temp.join("k8s")).unwrap(); + + let project = ProjectContext { + name: "test-proj".to_string(), + root: temp.clone(), + stack: crate::domain::project::ProjectStack::Rust, + assets: vec![], + }; + let caps = ProjectCapabilities { + kubernetes: true, + ..Default::default() + }; + let runner = MockRunner { + calls: Mutex::new(vec![]), + success: true, + }; + + let result = execute_deployment_update_step(&project, &caps, "my-namespace", &runner); + assert!(result.is_ok()); + + let calls = runner.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "kubectl"); + assert!(calls[0].1.contains(&"-n".to_string())); + assert!(calls[0].1.contains(&"my-namespace".to_string())); + assert!(calls[0] + .1 + .contains(&temp.join("k8s").to_string_lossy().to_string())); + + std::fs::remove_dir_all(temp).unwrap(); + } + + #[test] + fn test_execute_deployment_update_step_missing_k8s() { + let temp = std::env::temp_dir().join(format!( + "kdc-k8s-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&temp).unwrap(); // no k8s folder + + let project = ProjectContext { + name: "test-proj".to_string(), + root: temp.clone(), + stack: crate::domain::project::ProjectStack::Rust, + assets: vec![], + }; + let caps = ProjectCapabilities { + kubernetes: true, + ..Default::default() + }; + let runner = MockRunner { + calls: Mutex::new(vec![]), + success: true, + }; + + let result = execute_deployment_update_step(&project, &caps, "my-namespace", &runner); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().to_string(), "k8s/ directory is absent"); + + std::fs::remove_dir_all(temp).unwrap(); + } + + #[test] + fn test_execute_rollout_verification_step() { + let runner = MockRunner { + calls: Mutex::new(vec![]), + success: true, + }; + + let result = execute_rollout_verification_step("my-app", "my-namespace", &runner); + assert!(result.is_ok()); + + let calls = runner.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "kubectl"); + assert!(calls[0].1.contains(&"deployment/my-app".to_string())); + assert!(calls[0].1.contains(&"-n".to_string())); + assert!(calls[0].1.contains(&"my-namespace".to_string())); + } + + #[test] + fn test_execute_pipeline_with_runner() { + crate::utils::test_support::set_mock_path(); + + let temp = std::env::temp_dir().join(format!( + "kdc-pipeline-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(temp.join("k8s")).unwrap(); + + let project = ProjectContext { + name: "test-proj".to_string(), + root: temp.clone(), + stack: crate::domain::project::ProjectStack::Rust, + assets: vec![], + }; + let caps = ProjectCapabilities { + docker: true, + kubernetes: true, + deployment: true, + ..Default::default() + }; + let runtime = RuntimeCapabilities { + docker_running: true, + cluster_connected: true, + ..Default::default() + }; + + let plan = plan(&caps, &runtime); + assert!(plan.ready()); + + let runner = MockRunner { + calls: Mutex::new(vec![]), + success: true, + }; + + let res = + execute_pipeline_with_runner(&plan, &project, &caps, "development", &runner).unwrap(); + assert!(res.overall_success); + assert_eq!(res.results.len(), 5); + + std::fs::remove_dir_all(temp).unwrap(); + } } diff --git a/src/deploy/rollback.rs b/src/deploy/rollback.rs index 8c95d39..a235c75 100644 --- a/src/deploy/rollback.rs +++ b/src/deploy/rollback.rs @@ -1,4 +1,101 @@ +use std::process::Command; + +use anyhow::{Context, Result}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RollbackRequest { + pub deployment_name: Option, pub target_revision: Option, } + +/// Execute a kubectl rollout undo for the given deployment. +pub fn execute(request: &RollbackRequest, namespace: &str) -> Result { + let deployment = request.deployment_name.as_deref().unwrap_or("deployment"); + + let mut args = vec![ + "rollout".to_string(), + "undo".to_string(), + format!("deployment/{deployment}"), + "-n".to_string(), + namespace.to_string(), + ]; + + if let Some(revision) = &request.target_revision { + args.push(format!("--to-revision={revision}")); + } + + let output = Command::new("kubectl") + .args(&args) + .output() + .context("Failed to execute kubectl rollout undo")?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + if output.status.success() { + Ok(format!("Rollback completed: {stdout}")) + } else { + anyhow::bail!("Rollback failed: {stderr}") + } +} + +/// Check rollout history for a deployment. +pub fn history(deployment_name: &str, namespace: &str) -> Result { + let output = Command::new("kubectl") + .args([ + "rollout", + "history", + &format!("deployment/{deployment_name}"), + "-n", + namespace, + ]) + .output() + .context("Failed to execute kubectl rollout history")?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("rollout history failed: {stderr}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rollback_request_with_revision() { + let request = RollbackRequest { + deployment_name: Some("my-app".to_string()), + target_revision: Some("3".to_string()), + }; + assert_eq!(request.deployment_name, Some("my-app".to_string())); + assert_eq!(request.target_revision, Some("3".to_string())); + } + + #[test] + fn rollback_request_without_revision() { + let request = RollbackRequest { + deployment_name: None, + target_revision: None, + }; + assert!(request.deployment_name.is_none()); + assert!(request.target_revision.is_none()); + } + + #[test] + fn test_execute_and_history() { + crate::utils::test_support::set_mock_path(); + + let request = RollbackRequest { + deployment_name: Some("my-app".to_string()), + target_revision: Some("2".to_string()), + }; + let res = execute(&request, "default").unwrap(); + assert!(res.contains("rolled back")); + + let hist = history("my-app", "default").unwrap(); + assert!(hist.contains("REVISION")); + } +} diff --git a/src/docker/build.rs b/src/docker/build.rs index 3f59a7c..902d047 100644 --- a/src/docker/build.rs +++ b/src/docker/build.rs @@ -1,5 +1,104 @@ +use std::path::Path; +use std::process::Command; +use std::time::Instant; + +use anyhow::{Context, Result}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct BuildRequest { pub image: String, pub tag: String, } + +impl BuildRequest { + pub fn full_tag(&self) -> String { + format!("{}:{}", self.image, self.tag) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BuildResult { + pub success: bool, + pub image_tag: String, + pub output: String, + pub duration_secs: u64, +} + +fn docker_build_with_args( + request: &BuildRequest, + project_root: &Path, + extra_args: &[&str], +) -> Result { + let full_tag = request.full_tag(); + let start = Instant::now(); + + let mut args = vec!["build"]; + args.extend_from_slice(extra_args); + args.extend_from_slice(&["-t", &full_tag, "."]); + + let output = Command::new("docker") + .args(&args) + .current_dir(project_root) + .output() + .context("Failed to execute docker build")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{stdout}{stderr}"); + + Ok(BuildResult { + success: output.status.success(), + image_tag: full_tag, + output: combined, + duration_secs: start.elapsed().as_secs(), + }) +} + +/// Build a Docker image from the Dockerfile in `project_root`. +pub fn execute(request: &BuildRequest, project_root: &Path) -> Result { + docker_build_with_args(request, project_root, &[]) +} + +/// Rebuild a Docker image (equivalent to build with `--no-cache`). +pub fn rebuild(request: &BuildRequest, project_root: &Path) -> Result { + docker_build_with_args(request, project_root, &["--no-cache"]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_request_formats_full_tag() { + let request = BuildRequest { + image: "myapp".to_string(), + tag: "latest".to_string(), + }; + assert_eq!(request.full_tag(), "myapp:latest"); + } + + #[test] + fn build_request_with_registry() { + let request = BuildRequest { + image: "registry.io/myapp".to_string(), + tag: "v1.0.0".to_string(), + }; + assert_eq!(request.full_tag(), "registry.io/myapp:v1.0.0"); + } + + #[test] + fn test_execute_and_rebuild() { + crate::utils::test_support::set_mock_path(); + let request = BuildRequest { + image: "myapp".to_string(), + tag: "latest".to_string(), + }; + let res = execute(&request, Path::new(".")).unwrap(); + assert!(res.success); + assert_eq!(res.image_tag, "myapp:latest"); + + let res_rebuild = rebuild(&request, Path::new(".")).unwrap(); + assert!(res_rebuild.success); + assert_eq!(res_rebuild.image_tag, "myapp:latest"); + } +} diff --git a/src/docker/containers.rs b/src/docker/containers.rs index 4ce5f9e..eacdc6c 100644 --- a/src/docker/containers.rs +++ b/src/docker/containers.rs @@ -1,6 +1,117 @@ +use std::process::Command; + +use anyhow::{Context, Result}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContainerSummary { pub id: String, pub name: String, + pub image: String, pub status: String, + pub ports: String, +} + +fn list_with_args(args: &[&str]) -> Result> { + let output = Command::new("docker") + .args(args) + .output() + .context("Failed to execute docker ps")?; + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("docker ps failed: {err}"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let containers = stdout + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| { + let parts: Vec<&str> = line.splitn(5, '\t').collect(); + if parts.len() >= 4 { + Some(ContainerSummary { + id: parts[0].to_string(), + name: parts[1].to_string(), + image: parts[2].to_string(), + status: parts[3].to_string(), + ports: parts.get(4).unwrap_or(&"").to_string(), + }) + } else { + None + } + }) + .collect(); + + Ok(containers) +} + +/// List running Docker containers. +pub fn list() -> Result> { + list_with_args(&[ + "ps", + "--format", + "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}", + ]) +} + +/// List all Docker containers (including stopped). +pub fn list_all() -> Result> { + list_with_args(&[ + "ps", + "-a", + "--format", + "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}", + ]) +} + +/// Inspect a container and return the raw JSON output. +pub fn inspect(container_id: &str) -> Result { + let output = Command::new("docker") + .args(["inspect", container_id]) + .output() + .context("Failed to execute docker inspect")?; + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("docker inspect failed: {err}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn container_summary_fields() { + let container = ContainerSummary { + id: "abc123".to_string(), + name: "my-app".to_string(), + image: "nginx:latest".to_string(), + status: "Up 5 minutes".to_string(), + ports: "0.0.0.0:80->80/tcp".to_string(), + }; + assert_eq!(container.id, "abc123"); + assert_eq!(container.name, "my-app"); + assert_eq!(container.image, "nginx:latest"); + assert_eq!(container.status, "Up 5 minutes"); + assert_eq!(container.ports, "0.0.0.0:80->80/tcp"); + } + + #[test] + fn test_list_and_inspect() { + crate::utils::test_support::set_mock_path(); + + let containers = list().unwrap(); + assert_eq!(containers.len(), 1); + assert_eq!(containers[0].id, "container123"); + assert_eq!(containers[0].name, "web-app"); + + let all_containers = list_all().unwrap(); + assert_eq!(all_containers.len(), 1); + + let details = inspect("container123").unwrap(); + assert_eq!(details, "manifest-info-json"); // from our mock router + } } diff --git a/src/docker/images.rs b/src/docker/images.rs index 53205f1..cf6ca35 100644 --- a/src/docker/images.rs +++ b/src/docker/images.rs @@ -1,5 +1,146 @@ +use std::process::Command; + +use anyhow::{Context, Result}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct DockerImage { pub repository: String, pub tag: String, + pub image_id: String, + pub size: String, +} + +impl DockerImage { + pub fn full_name(&self) -> String { + if self.tag.is_empty() || self.tag == "" { + self.repository.clone() + } else { + format!("{}:{}", self.repository, self.tag) + } + } +} + +/// List Docker images on the local machine. +pub fn list() -> Result> { + let output = Command::new("docker") + .args([ + "images", + "--format", + "{{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Size}}", + ]) + .output() + .context("Failed to execute docker images")?; + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("docker images failed: {err}"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let images = stdout + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| { + let parts: Vec<&str> = line.splitn(4, '\t').collect(); + if parts.len() >= 4 { + Some(DockerImage { + repository: parts[0].to_string(), + tag: parts[1].to_string(), + image_id: parts[2].to_string(), + size: parts[3].to_string(), + }) + } else { + None + } + }) + .collect(); + + Ok(images) +} + +/// Tag a Docker image with a new tag. +pub fn tag(source: &str, new_tag: &str) -> Result<()> { + let output = Command::new("docker") + .args(["tag", source, new_tag]) + .output() + .context("Failed to execute docker tag")?; + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("docker tag failed: {err}"); + } + + Ok(()) +} + +/// Delete a Docker image. +pub fn delete(image: &str) -> Result { + let output = Command::new("docker") + .args(["rmi", image]) + .output() + .context("Failed to execute docker rmi")?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if output.status.success() { + Ok(stdout) + } else { + let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("docker rmi failed: {err}") + } +} + +/// Push a Docker image to a registry. +pub fn push(image: &str) -> Result { + let output = Command::new("docker") + .args(["push", image]) + .output() + .context("Failed to execute docker push")?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if output.status.success() { + Ok(stdout) + } else { + let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("docker push failed: {err}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn docker_image_full_name() { + let image = DockerImage { + repository: "myapp".to_string(), + tag: "v1.0".to_string(), + image_id: "sha256:abc".to_string(), + size: "150MB".to_string(), + }; + assert_eq!(image.full_name(), "myapp:v1.0"); + } + + #[test] + fn docker_image_full_name_without_tag() { + let image = DockerImage { + repository: "myapp".to_string(), + tag: "".to_string(), + image_id: "sha256:abc".to_string(), + size: "150MB".to_string(), + }; + assert_eq!(image.full_name(), "myapp"); + } + + #[test] + fn test_image_ops() { + crate::utils::test_support::set_mock_path(); + + let images = list().unwrap(); + assert_eq!(images.len(), 1); + assert_eq!(images[0].repository, "myapp"); + + assert!(tag("myapp:latest", "myapp:v2").is_ok()); + assert!(delete("myapp:latest").is_ok()); + assert!(push("myapp:latest").is_ok()); + } } diff --git a/src/docker/logs.rs b/src/docker/logs.rs index 4cf2a2a..0e16db5 100644 --- a/src/docker/logs.rs +++ b/src/docker/logs.rs @@ -1,4 +1,100 @@ +use std::process::Command; + +use anyhow::{Context, Result}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct DockerLogLine { pub message: String, } + +/// Fetch the last `tail` lines of logs from a Docker container. +pub fn fetch(container_id: &str, tail: usize) -> Result> { + let tail_str = tail.to_string(); + let output = Command::new("docker") + .args(["logs", "--tail", &tail_str, container_id]) + .output() + .context("Failed to execute docker logs")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("docker logs failed: {stderr}"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Docker sends some log output to stderr, so combine both streams. + let combined = if stdout.ends_with('\n') || stdout.is_empty() { + format!("{stdout}{stderr}") + } else { + format!("{stdout}\n{stderr}") + }; + + let lines = combined + .lines() + .filter(|line| !line.is_empty()) + .map(|line| DockerLogLine { + message: line.to_string(), + }) + .collect(); + + Ok(lines) +} + +/// Fetch all logs from a Docker container. +pub fn fetch_all(container_id: &str) -> Result> { + let output = Command::new("docker") + .args(["logs", container_id]) + .output() + .context("Failed to execute docker logs")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("docker logs failed: {stderr}"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Docker sends some log output to stderr, so combine both streams. + let combined = if stdout.ends_with('\n') || stdout.is_empty() { + format!("{stdout}{stderr}") + } else { + format!("{stdout}\n{stderr}") + }; + + let lines = combined + .lines() + .filter(|line| !line.is_empty()) + .map(|line| DockerLogLine { + message: line.to_string(), + }) + .collect(); + + Ok(lines) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn docker_log_line_holds_message() { + let line = DockerLogLine { + message: "Server started on port 8080".to_string(), + }; + assert_eq!(line.message, "Server started on port 8080"); + } + + #[test] + fn test_fetch_and_fetch_all() { + crate::utils::test_support::set_mock_path(); + + let logs = fetch("container123", 10).unwrap(); + assert_eq!(logs.len(), 3); + assert_eq!(logs[0].message, "line1"); + + let all_logs = fetch_all("container123").unwrap(); + assert_eq!(all_logs.len(), 3); + } +} diff --git a/src/docker/networks.rs b/src/docker/networks.rs index d4d7317..c0070f8 100644 --- a/src/docker/networks.rs +++ b/src/docker/networks.rs @@ -1,4 +1,76 @@ +use std::process::Command; + +use anyhow::{Context, Result}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct DockerNetwork { + pub id: String, pub name: String, + pub driver: String, + pub scope: String, +} + +/// List Docker networks. +pub fn list() -> Result> { + let output = Command::new("docker") + .args([ + "network", + "ls", + "--format", + "{{.ID}}\t{{.Name}}\t{{.Driver}}\t{{.Scope}}", + ]) + .output() + .context("Failed to execute docker network ls")?; + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("docker network ls failed: {err}"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let networks = stdout + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| { + let parts: Vec<&str> = line.splitn(4, '\t').collect(); + if parts.len() >= 4 { + Some(DockerNetwork { + id: parts[0].to_string(), + name: parts[1].to_string(), + driver: parts[2].to_string(), + scope: parts[3].to_string(), + }) + } else { + None + } + }) + .collect(); + + Ok(networks) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn docker_network_fields() { + let network = DockerNetwork { + id: "abc123".to_string(), + name: "bridge".to_string(), + driver: "bridge".to_string(), + scope: "local".to_string(), + }; + assert_eq!(network.name, "bridge"); + assert_eq!(network.driver, "bridge"); + } + + #[test] + fn test_list() { + crate::utils::test_support::set_mock_path(); + let networks = list().unwrap(); + assert_eq!(networks.len(), 1); + assert_eq!(networks[0].name, "app-network"); + assert_eq!(networks[0].driver, "bridge"); + } } diff --git a/src/docker/run.rs b/src/docker/run.rs index 1779e22..98d9e79 100644 --- a/src/docker/run.rs +++ b/src/docker/run.rs @@ -1,4 +1,160 @@ +use std::process::Command; + +use anyhow::{Context, Result}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RunRequest { pub image: String, + pub name: Option, + pub ports: Vec, + pub env_vars: Vec<(String, String)>, + pub detached: bool, +} + +impl Default for RunRequest { + fn default() -> Self { + Self { + image: String::new(), + name: None, + ports: Vec::new(), + env_vars: Vec::new(), + detached: true, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RunResult { + pub container_id: String, + pub success: bool, + pub output: String, +} + +/// Run a Docker container from the given image. +pub fn execute(request: &RunRequest) -> Result { + let mut args = vec!["run".to_string()]; + + if request.detached { + args.push("-d".to_string()); + } + + if let Some(name) = &request.name { + args.push("--name".to_string()); + args.push(name.clone()); + } + + for port in &request.ports { + args.push("-p".to_string()); + args.push(port.clone()); + } + + for (key, value) in &request.env_vars { + args.push("-e".to_string()); + args.push(format!("{key}={value}")); + } + + args.push(request.image.clone()); + + let output = Command::new("docker") + .args(&args) + .output() + .context("Failed to execute docker run")?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + let container_id = if request.detached && output.status.success() { + stdout.clone() + } else { + String::new() + }; + + Ok(RunResult { + container_id, + success: output.status.success(), + output: if output.status.success() { + stdout + } else { + stderr + }, + }) +} + +/// Stop a running Docker container. +pub fn stop(container_id: &str) -> Result { + let output = Command::new("docker") + .args(["stop", container_id]) + .output() + .context("Failed to execute docker stop")?; + + let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if output.status.success() { + Ok(result) + } else { + let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("docker stop failed: {err}") + } +} + +/// Restart a Docker container. +pub fn restart(container_id: &str) -> Result { + let output = Command::new("docker") + .args(["restart", container_id]) + .output() + .context("Failed to execute docker restart")?; + + let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if output.status.success() { + Ok(result) + } else { + let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("docker restart failed: {err}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_request_defaults_to_detached() { + let request = RunRequest { + image: "nginx:latest".to_string(), + ..Default::default() + }; + assert!(request.detached); + } + + #[test] + fn run_request_builds_with_ports_and_env() { + let request = RunRequest { + image: "myapp:latest".to_string(), + name: Some("my-container".to_string()), + ports: vec!["8080:80".to_string()], + env_vars: vec![("NODE_ENV".to_string(), "production".to_string())], + detached: true, + }; + assert_eq!(request.ports.len(), 1); + assert_eq!(request.env_vars.len(), 1); + assert_eq!(request.name, Some("my-container".to_string())); + } + + #[test] + fn test_run_stop_restart() { + crate::utils::test_support::set_mock_path(); + + let request = RunRequest { + image: "nginx:latest".to_string(), + name: Some("my-container".to_string()), + ports: vec!["80:80".to_string()], + env_vars: vec![("KEY".to_string(), "VAL".to_string())], + detached: true, + }; + let res = execute(&request).unwrap(); + assert!(res.success); + assert_eq!(res.container_id, "container123"); + + assert!(stop("container123").is_ok()); + assert!(restart("container123").is_ok()); + } } diff --git a/src/docker/volumes.rs b/src/docker/volumes.rs index 8eafa81..6bc596d 100644 --- a/src/docker/volumes.rs +++ b/src/docker/volumes.rs @@ -1,4 +1,73 @@ +use std::process::Command; + +use anyhow::{Context, Result}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct DockerVolume { pub name: String, + pub driver: String, + pub mountpoint: String, +} + +/// List Docker volumes. +pub fn list() -> Result> { + let output = Command::new("docker") + .args([ + "volume", + "ls", + "--format", + "{{.Name}}\t{{.Driver}}\t{{.Mountpoint}}", + ]) + .output() + .context("Failed to execute docker volume ls")?; + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("docker volume ls failed: {err}"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let volumes = stdout + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| { + let parts: Vec<&str> = line.splitn(3, '\t').collect(); + if parts.len() >= 3 { + Some(DockerVolume { + name: parts[0].to_string(), + driver: parts[1].to_string(), + mountpoint: parts[2].to_string(), + }) + } else { + None + } + }) + .collect(); + + Ok(volumes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn docker_volume_fields() { + let volume = DockerVolume { + name: "my-data".to_string(), + driver: "local".to_string(), + mountpoint: "/var/lib/docker/volumes/my-data/_data".to_string(), + }; + assert_eq!(volume.name, "my-data"); + assert_eq!(volume.driver, "local"); + } + + #[test] + fn test_list() { + crate::utils::test_support::set_mock_path(); + let volumes = list().unwrap(); + assert_eq!(volumes.len(), 1); + assert_eq!(volumes[0].name, "db-data"); + assert_eq!(volumes[0].driver, "local"); + } } diff --git a/src/doctor/docker_check.rs b/src/doctor/docker_check.rs index fd79dfd..2fc3aa7 100644 --- a/src/doctor/docker_check.rs +++ b/src/doctor/docker_check.rs @@ -1,6 +1,49 @@ +use std::process::Command; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DockerStatus { Unknown, Running, Unavailable, } + +/// Check if the Docker daemon is currently running. +pub fn check_daemon() -> DockerStatus { + match Command::new("docker").arg("info").output() { + Ok(output) if output.status.success() => DockerStatus::Running, + Ok(_) => DockerStatus::Unavailable, + Err(_) => DockerStatus::Unknown, + } +} + +/// Retrieve the installed Docker version string, if available. +pub fn check_version() -> Option { + Command::new("docker") + .arg("--version") + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn docker_status_variants() { + assert_ne!(DockerStatus::Running, DockerStatus::Unavailable); + assert_ne!(DockerStatus::Unknown, DockerStatus::Running); + } + + #[test] + fn test_check_daemon_and_version() { + crate::utils::test_support::set_mock_path(); + + let status = check_daemon(); + assert_eq!(status, DockerStatus::Running); + + let version = check_version().unwrap(); + assert!(version.contains("Docker version")); + } +} diff --git a/src/doctor/environment_check.rs b/src/doctor/environment_check.rs index 0156a49..46ec735 100644 --- a/src/doctor/environment_check.rs +++ b/src/doctor/environment_check.rs @@ -1,11 +1,11 @@ use std::process::Command; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct DoctorReport { pub checks: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct DoctorCheck { pub name: String, pub ok: bool, @@ -29,8 +29,33 @@ impl DoctorReport { .collect::>() .join("\n") } + + /// Export the doctor report as a JSON string for structured consumption. + pub fn export_json(&self) -> String { + serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string()) + } + + /// Count how many checks passed. + pub fn passed_count(&self) -> usize { + self.checks.iter().filter(|c| c.ok).count() + } + + /// Count total checks. + pub fn total_count(&self) -> usize { + self.checks.len() + } + + /// Return a short summary line. + pub fn summary_line(&self) -> String { + format!( + "Doctor: {}/{} checks passed", + self.passed_count(), + self.total_count() + ) + } } +/// Run basic doctor checks (Docker CLI, daemon, Kubernetes tooling). pub fn run() -> DoctorReport { DoctorReport { checks: vec![ @@ -41,6 +66,25 @@ pub fn run() -> DoctorReport { } } +/// Run the full set of doctor checks including registry and additional diagnostics. +pub fn run_full(registry_url: Option<&str>) -> DoctorReport { + let mut checks = vec![ + command_check("Docker CLI", "docker", "--version"), + docker_daemon_check(), + docker_version_check(), + kubernetes_tool_check(), + kubernetes_context_check(), + ]; + + if let Some(url) = registry_url { + checks.push(registry_connectivity_check(url)); + } + + checks.push(os_install_hints_check()); + + DoctorReport { checks } +} + fn docker_daemon_check() -> DoctorCheck { match check_command("docker", "info") { CommandStatus::Available => DoctorCheck { @@ -64,6 +108,32 @@ fn docker_daemon_check() -> DoctorCheck { } } +fn docker_version_check() -> DoctorCheck { + match Command::new("docker").arg("--version").output() { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + DoctorCheck { + name: "Docker Version".to_string(), + ok: true, + detail: version, + suggestion: None, + } + } + Ok(_) => DoctorCheck { + name: "Docker Version".to_string(), + ok: false, + detail: "docker returned an error".to_string(), + suggestion: Some("Reinstall Docker".to_string()), + }, + Err(_) => DoctorCheck { + name: "Docker Version".to_string(), + ok: false, + detail: "docker not found".to_string(), + suggestion: Some(install_hint_for("docker")), + }, + } +} + fn kubernetes_tool_check() -> DoctorCheck { kubernetes_tool_check_with(check_command) } @@ -101,12 +171,139 @@ fn kubernetes_tool_check_with(check_command: impl Fn(&str, &str) -> CommandStatu name: "Kubernetes Tooling".to_string(), ok: false, detail: "kubectl and minikube not found".to_string(), - suggestion: Some("Install kubectl or Minikube".to_string()), + suggestion: Some(install_hint_for("kubectl")), }, }, } } +fn kubernetes_context_check() -> DoctorCheck { + match Command::new("kubectl") + .args(["config", "current-context"]) + .output() + { + Ok(output) if output.status.success() => { + let context = String::from_utf8_lossy(&output.stdout).trim().to_string(); + DoctorCheck { + name: "Kubernetes Context".to_string(), + ok: true, + detail: format!("current context: {context}"), + suggestion: None, + } + } + Ok(_) => DoctorCheck { + name: "Kubernetes Context".to_string(), + ok: false, + detail: "no active context set".to_string(), + suggestion: Some("Run kubectl config use-context ".to_string()), + }, + Err(_) => DoctorCheck { + name: "Kubernetes Context".to_string(), + ok: false, + detail: "kubectl not available".to_string(), + suggestion: Some(install_hint_for("kubectl")), + }, + } +} + +fn is_docker_running() -> bool { + Command::new("docker") + .args(["info"]) + .output() + .map(|out| out.status.success()) + .unwrap_or(false) +} + +fn inspect_manifest(image_target: &str) -> Result { + match Command::new("docker") + .args(["manifest", "inspect", image_target]) + .output() + { + Ok(out) => { + if out.status.success() { + let detail = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let detail_snippet = if detail.len() > 100 { + format!("{}...", &detail[..100]) + } else { + detail + }; + Ok(detail_snippet) + } else { + Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) + } + } + Err(err) => Err(format!("Failed to run docker manifest command: {err}")), + } +} + +fn registry_connectivity_check(registry_url: &str) -> DoctorCheck { + // Try a lightweight check by running `docker manifest inspect` against a + // known public image on the registry. This validates connectivity without + // needing credentials for the probe itself. + if !is_docker_running() { + return DoctorCheck { + name: "Registry Connectivity".to_string(), + ok: false, + detail: "Docker daemon is not available".to_string(), + suggestion: Some("Start Docker Desktop or the Docker service first".to_string()), + }; + } + + let image_target = if registry_url == "docker.io" { + "docker.io/library/alpine:latest".to_string() + } else { + format!("{}/alpine:latest", registry_url.trim_end_matches('/')) + }; + + match inspect_manifest(&image_target) { + Ok(detail_snippet) => DoctorCheck { + name: "Registry Connectivity".to_string(), + ok: true, + detail: format!( + "Successfully inspected {}: {}", + image_target, detail_snippet + ), + suggestion: None, + }, + Err(err) => DoctorCheck { + name: "Registry Connectivity".to_string(), + ok: false, + detail: format!("Failed to inspect {}: {}", image_target, err), + suggestion: Some("Run 'docker login' or check credentials/connectivity".to_string()), + }, + } +} + +fn os_install_hints_check() -> DoctorCheck { + let os = std::env::consts::OS; + let hint = match os { + "macos" => "Use Homebrew: brew install docker kubectl", + "linux" => "Use apt: sudo apt install docker.io kubectl", + "windows" => "Use Chocolatey: choco install docker-desktop kubernetes-cli", + _ => "Visit docs.docker.com and kubernetes.io for installation instructions", + }; + + DoctorCheck { + name: "OS Install Hints".to_string(), + ok: true, + detail: format!("Detected OS: {os}"), + suggestion: Some(hint.to_string()), + } +} + +fn install_hint_for(tool: &str) -> String { + let os = std::env::consts::OS; + match (os, tool) { + ("macos", "docker") => "Install with: brew install --cask docker".to_string(), + ("macos", "kubectl") => "Install with: brew install kubectl".to_string(), + ("linux", "docker") => "Install with: sudo apt install docker.io".to_string(), + ("linux", "kubectl") => "Install with: sudo snap install kubectl --classic".to_string(), + ("windows", "docker") => "Install with: choco install docker-desktop".to_string(), + ("windows", "kubectl") => "Install with: choco install kubernetes-cli".to_string(), + _ => format!("Install {tool} or add it to PATH"), + } +} + fn command_check(name: &str, command: &str, arg: &str) -> DoctorCheck { match check_command(command, arg) { CommandStatus::Available => DoctorCheck { @@ -125,7 +322,7 @@ fn command_check(name: &str, command: &str, arg: &str) -> DoctorCheck { name: name.to_string(), ok: false, detail: "not found".to_string(), - suggestion: Some(format!("Install {command} or add it to PATH")), + suggestion: Some(install_hint_for(command)), }, } } @@ -147,7 +344,7 @@ enum CommandStatus { #[cfg(test)] mod tests { - use super::{kubernetes_tool_check_with, CommandStatus, DoctorCheck}; + use super::{kubernetes_tool_check_with, CommandStatus, DoctorCheck, DoctorReport}; #[test] fn falls_back_to_minikube_when_kubectl_is_missing() { @@ -175,4 +372,59 @@ mod tests { assert_eq!(check.detail, "kubectl and minikube not found"); assert!(!check.ok); } + + #[test] + fn report_summary_line() { + let report = DoctorReport { + checks: vec![ + DoctorCheck { + name: "A".to_string(), + ok: true, + detail: "ok".to_string(), + suggestion: None, + }, + DoctorCheck { + name: "B".to_string(), + ok: false, + detail: "fail".to_string(), + suggestion: Some("fix".to_string()), + }, + ], + }; + + assert_eq!(report.passed_count(), 1); + assert_eq!(report.total_count(), 2); + assert_eq!(report.summary_line(), "Doctor: 1/2 checks passed"); + } + + #[test] + fn report_export_json_is_valid() { + let report = DoctorReport { + checks: vec![DoctorCheck { + name: "Docker CLI".to_string(), + ok: true, + detail: "available".to_string(), + suggestion: None, + }], + }; + + let json = report.export_json(); + let val: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(val["checks"][0]["name"], "Docker CLI"); + assert_eq!(val["checks"][0]["ok"], true); + assert_eq!(val["checks"][0]["suggestion"], serde_json::Value::Null); + } + + #[test] + fn test_run_and_run_full() { + crate::utils::test_support::set_mock_path(); + + let report = super::run(); + assert!(report.total_count() > 0); + + let report_full = super::run_full(Some("docker.io")); + assert!(report_full.total_count() > 0); + let rendered = report_full.render(); + assert!(rendered.contains("OK")); + } } diff --git a/src/doctor/kubernetes_check.rs b/src/doctor/kubernetes_check.rs index e9553d1..de1f682 100644 --- a/src/doctor/kubernetes_check.rs +++ b/src/doctor/kubernetes_check.rs @@ -1,6 +1,76 @@ +use std::process::Command; + +use anyhow::{Context, Result}; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KubernetesStatus { Unknown, Connected, Disconnected, } + +/// Check if a Kubernetes cluster is reachable. +pub fn check_cluster() -> KubernetesStatus { + match Command::new("kubectl").args(["cluster-info"]).output() { + Ok(output) if output.status.success() => KubernetesStatus::Connected, + Ok(_) => KubernetesStatus::Disconnected, + Err(_) => KubernetesStatus::Unknown, + } +} + +/// Get the currently active kubectl context name. +pub fn current_context() -> Option { + Command::new("kubectl") + .args(["config", "current-context"]) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// List the names of nodes in the current cluster. +pub fn check_nodes() -> Result> { + let output = Command::new("kubectl") + .args(["get", "nodes", "-o", "jsonpath={.items[*].metadata.name}"]) + .output() + .context("Failed to execute kubectl get nodes")?; + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!("kubectl get nodes failed: {err}"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let nodes = stdout + .split_whitespace() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + + Ok(nodes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kubernetes_status_variants() { + assert_ne!(KubernetesStatus::Connected, KubernetesStatus::Disconnected); + assert_ne!(KubernetesStatus::Unknown, KubernetesStatus::Connected); + } + + #[test] + fn test_check_cluster_and_nodes() { + crate::utils::test_support::set_mock_path(); + + let status = check_cluster(); + assert_eq!(status, KubernetesStatus::Connected); + + let ctx = current_context().unwrap(); + assert_eq!(ctx, "minikube"); + + let nodes = check_nodes().unwrap(); + assert_eq!(nodes, vec!["node1", "node2"]); + } +} diff --git a/src/doctor/registry_check.rs b/src/doctor/registry_check.rs index 2d182f3..a93f62e 100644 --- a/src/doctor/registry_check.rs +++ b/src/doctor/registry_check.rs @@ -1,6 +1,154 @@ +use std::process::Command; + +use serde::{Deserialize, Serialize}; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RegistryStatus { Unknown, Connected, Disconnected, } + +/// Configuration for a container registry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RegistryConfig { + pub url: String, + pub username: Option, +} + +impl Default for RegistryConfig { + fn default() -> Self { + Self { + url: "docker.io".to_string(), + username: None, + } + } +} + +fn run_command_with_timeout( + cmd: &str, + args: &[&str], + timeout: std::time::Duration, +) -> anyhow::Result { + let mut child = Command::new(cmd) + .args(args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + + let start = std::time::Instant::now(); + loop { + if child.try_wait()?.is_some() { + let output = child.wait_with_output()?; + return Ok(output); + } + if start.elapsed() >= timeout { + child.kill()?; + anyhow::bail!("Command timed out after {:?}", timeout); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } +} + +/// Check if a container registry is reachable by verifying Docker connectivity. +fn build_v2_url(registry: &str) -> String { + let domain = registry + .trim_start_matches("https://") + .trim_start_matches("http://"); + + let target_domain = if domain == "docker.io" { + "registry-1.docker.io" + } else { + domain + }; + + format!("https://{}/v2/", target_domain) +} + +fn probe_registry_endpoint(url: &str) -> Option { + match ureq::head(url) + .timeout(std::time::Duration::from_secs(3)) + .call() + { + Ok(resp) => { + let code = resp.status(); + if code == 200 || code == 401 { + Some(RegistryStatus::Connected) + } else { + Some(RegistryStatus::Disconnected) + } + } + Err(ureq::Error::Status(code, _)) => { + if code == 401 { + Some(RegistryStatus::Connected) + } else { + Some(RegistryStatus::Disconnected) + } + } + Err(_) => None, + } +} + +fn check_docker_fallback() -> bool { + match run_command_with_timeout("docker", &["info"], std::time::Duration::from_secs(2)) { + Ok(output) => output.status.success(), + Err(_) => false, + } +} + +/// Check if a container registry is reachable by verifying Docker connectivity. +pub fn check_registry(registry: &str) -> RegistryStatus { + if registry.is_empty() { + return RegistryStatus::Unknown; + } + + let url = build_v2_url(registry); + + if let Some(status) = probe_registry_endpoint(&url) { + status + } else if check_docker_fallback() { + RegistryStatus::Connected + } else { + RegistryStatus::Disconnected + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_config_defaults() { + let config = RegistryConfig::default(); + assert_eq!(config.url, "docker.io"); + assert!(config.username.is_none()); + } + + #[test] + fn registry_config_with_custom_url() { + let config = RegistryConfig { + url: "ghcr.io".to_string(), + username: Some("user".to_string()), + }; + assert_eq!(config.url, "ghcr.io"); + assert_eq!(config.username, Some("user".to_string())); + } + + #[test] + fn registry_status_variants() { + assert_ne!(RegistryStatus::Connected, RegistryStatus::Disconnected); + assert_ne!(RegistryStatus::Unknown, RegistryStatus::Connected); + } + + #[test] + fn test_check_registry() { + crate::utils::test_support::set_mock_path(); + + let status_empty = check_registry(""); + assert_eq!(status_empty, RegistryStatus::Unknown); + + // Fallback to docker info since invalid-url will fail HTTP HEAD + let status_fallback = check_registry("invalid-url"); + assert_eq!(status_fallback, RegistryStatus::Connected); + } +} diff --git a/src/main.rs b/src/main.rs index 3af499d..8f96978 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,8 +39,21 @@ enum Command { InitConfig, /// Print a dry-run deployment plan. DeployPlan, + /// Execute the deployment pipeline. + Deploy { + /// Target environment (development, staging, production). + #[arg(short, long, default_value = "development")] + environment: String, + }, /// Check the local development environment. - Doctor, + Doctor { + /// Run the full set of checks including registry and OS hints. + #[arg(long)] + full: bool, + /// Export the doctor report as JSON. + #[arg(long)] + json: bool, + }, } fn main() -> anyhow::Result<()> { @@ -52,9 +65,8 @@ fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { - Some(Command::Doctor) => { - let report = doctor::environment_check::run(); - println!("{}", report.render()); + Some(Command::Doctor { full, json }) => { + run_doctor(full, json)?; } Some(Command::InitConfig) => { let path = config::paths::config_file(); @@ -98,6 +110,9 @@ fn main() -> anyhow::Result<()> { let plan = deploy::pipeline::plan(&state.capabilities, &state.runtime); println!("{}", plan.render()); } + Some(Command::Deploy { environment }) => { + run_deploy(cli.project, &environment)?; + } None => { let state = startup::initialize_with_options( cli.project, @@ -111,3 +126,91 @@ fn main() -> anyhow::Result<()> { Ok(()) } + +fn run_doctor(full: bool, json: bool) -> anyhow::Result<()> { + let report = if full { + let settings = config::settings::Settings::load_or_default(&config::paths::config_file())?; + doctor::environment_check::run_full(settings.registry.as_deref()) + } else { + doctor::environment_check::run() + }; + + if json { + println!("{}", report.export_json()); + // Also save to the doctor report file. + let report_path = config::paths::doctor_report_file(); + if let Some(parent) = report_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&report_path, report.export_json())?; + println!("Report saved to {}", report_path.display()); + } else { + println!("{}", report.render()); + println!("\n{}", report.summary_line()); + } + Ok(()) +} + +fn run_deploy(project: std::path::PathBuf, environment: &str) -> anyhow::Result<()> { + let state = startup::initialize(project)?; + let plan = deploy::pipeline::plan(&state.capabilities, &state.runtime); + + if !plan.ready() { + let mut msg = "Deployment plan has blockers:\n".to_string(); + for blocker in &plan.blockers { + msg.push_str(&format!(" - {}\n", blocker)); + } + anyhow::bail!("{}", msg.trim_end()); + } + + println!("Executing deployment pipeline...\n"); + let execution = deploy::pipeline::execute_pipeline( + &plan, + &state.project, + &state.capabilities, + environment, + )?; + println!("{}", execution.render()); + + // Record the deployment in history. + let env = deploy::environments::from_string(environment); + let history_path = config::paths::deploy_history_file(); + let mut history = deploy::history::DeploymentHistory::load_or_default(&history_path)?; + history.record(deploy::history::DeploymentRecord { + timestamp: chrono_timestamp(), + environment: env.to_string(), + image_tag: format!( + "{}:latest", + state.project.name.to_lowercase().replace(' ', "-") + ), + success: execution.overall_success, + steps_completed: execution.results.iter().filter(|r| r.success).count(), + steps_total: execution.results.len(), + duration_secs: execution.total_duration_secs(), + message: if execution.overall_success { + "All steps completed".to_string() + } else { + execution + .results + .iter() + .find(|r| !r.success) + .map(|r| r.message.clone()) + .unwrap_or_else(|| "Unknown failure".to_string()) + }, + }); + history.save(&history_path)?; + println!("\nDeployment recorded in {}", history_path.display()); + Ok(()) +} + +/// Generate an ISO 8601 timestamp string. +fn chrono_timestamp() -> String { + let now = time::OffsetDateTime::now_utc(); + if let Ok(formatted) = now.format(&time::format_description::well_known::Rfc3339) { + return formatted; + } + let duration = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + format!("epoch:{}", duration.as_secs()) +} diff --git a/src/services/executor.rs b/src/services/executor.rs index 85295a9..6a389c1 100644 --- a/src/services/executor.rs +++ b/src/services/executor.rs @@ -1,10 +1,255 @@ +use std::path::Path; + use anyhow::Result; +use crate::{compose, config, docker, project::ProjectContext}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExecutionResult { + pub success: bool, pub message: String, + pub output_lines: Vec, } pub trait CommandExecutor { fn execute(&self, action_id: &str) -> Result; } + +/// The main executor that dispatches action IDs to module functions. +pub struct KdcExecutor<'a> { + pub project: &'a ProjectContext, +} + +impl<'a> KdcExecutor<'a> { + pub fn new(project: &'a ProjectContext) -> Self { + Self { project } + } +} + +impl<'a> CommandExecutor for KdcExecutor<'a> { + fn execute(&self, action_id: &str) -> Result { + match action_id { + "docker.build" => self.docker_build(), + "docker.run" => self.docker_run(), + "docker.logs" => self.docker_logs(), + "compose.up" => self.compose_up(), + "compose.down" => self.compose_down(), + "compose.logs" => self.compose_logs(), + "project.analysis" => self.project_analysis(), + "settings.open" => Ok(ExecutionResult { + success: true, + message: "Settings screen opened".to_string(), + output_lines: Vec::new(), + }), + _ => Ok(ExecutionResult { + success: false, + message: format!("Unknown action: {action_id}"), + output_lines: Vec::new(), + }), + } + } +} + +impl<'a> KdcExecutor<'a> { + fn docker_build(&self) -> Result { + let request = docker::build::BuildRequest { + image: self.project.name.to_lowercase().replace(' ', "-"), + tag: "latest".to_string(), + }; + + let result = docker::build::execute(&request, &self.project.root)?; + let lines: Vec = result.output.lines().map(|l| l.to_string()).collect(); + + Ok(ExecutionResult { + success: result.success, + message: if result.success { + format!("Built {}", result.image_tag) + } else { + "Docker build failed".to_string() + }, + output_lines: lines, + }) + } + + fn docker_run(&self) -> Result { + let image = format!( + "{}:latest", + self.project.name.to_lowercase().replace(' ', "-") + ); + let request = docker::run::RunRequest { + image, + ..Default::default() + }; + + let result = docker::run::execute(&request)?; + Ok(ExecutionResult { + success: result.success, + message: if result.success { + format!("Container started: {}", result.container_id) + } else { + format!("Failed to start container: {}", result.output) + }, + output_lines: vec![result.output], + }) + } + + fn docker_logs(&self) -> Result { + let containers = docker::containers::list()?; + if containers.is_empty() { + return Ok(ExecutionResult { + success: true, + message: "No running containers".to_string(), + output_lines: Vec::new(), + }); + } + + let first = &containers[0]; + let logs = docker::logs::fetch(&first.id, 50)?; + let lines: Vec = logs.iter().map(|l| l.message.clone()).collect(); + + Ok(ExecutionResult { + success: true, + message: format!("Logs from container: {}", first.name), + output_lines: lines, + }) + } + + fn compose_up(&self) -> Result { + let request = compose::up::ComposeUpRequest::default(); + let result = compose::up::execute(&request, &self.project.root)?; + let lines: Vec = result.output.lines().map(|l| l.to_string()).collect(); + + Ok(ExecutionResult { + success: result.success, + message: if result.success { + "Compose up completed".to_string() + } else { + "Compose up failed".to_string() + }, + output_lines: lines, + }) + } + + fn compose_down(&self) -> Result { + let request = compose::down::ComposeDownRequest::default(); + let output = compose::down::execute(&request, &self.project.root)?; + let lines: Vec = output.lines().map(|l| l.to_string()).collect(); + + Ok(ExecutionResult { + success: true, + message: "Compose down completed".to_string(), + output_lines: lines, + }) + } + + fn compose_logs(&self) -> Result { + let request = compose::logs::ComposeLogRequest::default(); + let lines = compose::logs::fetch(&request, &self.project.root)?; + + Ok(ExecutionResult { + success: true, + message: "Compose logs fetched".to_string(), + output_lines: lines, + }) + } + + fn project_analysis(&self) -> Result { + let capabilities = config::settings::Settings::default(); + Ok(ExecutionResult { + success: true, + message: format!("Project: {} ({})", self.project.name, self.project.stack), + output_lines: vec![ + format!("Root: {}", self.project.root.display()), + format!("Stack: {}", self.project.stack), + format!("Assets: {}", self.project.assets.len()), + format!("Theme: {}", capabilities.theme), + ], + }) + } +} + +/// Convenience function: run an action by ID and return the path to the +/// project root. Useful for modules that need the project directory. +pub fn project_root(project: &ProjectContext) -> &Path { + &project.root +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn execution_result_fields() { + let result = ExecutionResult { + success: true, + message: "done".to_string(), + output_lines: vec!["line1".to_string(), "line2".to_string()], + }; + assert!(result.success); + assert_eq!(result.output_lines.len(), 2); + } + + #[test] + fn unknown_action_returns_failure() { + use crate::domain::project::ProjectStack; + use std::path::PathBuf; + + let project = ProjectContext { + name: "test".to_string(), + root: PathBuf::from("."), + stack: ProjectStack::Unknown, + assets: Vec::new(), + }; + let executor = KdcExecutor::new(&project); + let result = executor.execute("unknown.action").unwrap(); + assert!(!result.success); + assert!(result.message.contains("Unknown action")); + } + + #[test] + fn test_executor_actions() { + crate::utils::test_support::set_mock_path(); + use crate::domain::project::ProjectStack; + use std::path::PathBuf; + + let project = ProjectContext { + name: "test-app".to_string(), + root: PathBuf::from("."), + stack: ProjectStack::Rust, + assets: Vec::new(), + }; + let executor = KdcExecutor::new(&project); + + // Test settings open + let res = executor.execute("settings.open").unwrap(); + assert!(res.success); + + // Test project analysis + let res = executor.execute("project.analysis").unwrap(); + assert!(res.success); + + // Test compose.up + let res = executor.execute("compose.up").unwrap(); + assert!(res.success); + + // Test compose.down + let res = executor.execute("compose.down").unwrap(); + assert!(res.success); + + // Test compose.logs + let res = executor.execute("compose.logs").unwrap(); + assert!(res.success); + + // Test docker.build + let res = executor.execute("docker.build").unwrap(); + assert!(res.success); + + // Test docker.run + let res = executor.execute("docker.run").unwrap(); + assert!(res.success); + + // Test docker.logs (with mock container list first) + let res = executor.execute("docker.logs").unwrap(); + assert!(res.success); + } +} diff --git a/src/storage/preferences.rs b/src/storage/preferences.rs index aae6f90..f77fd77 100644 --- a/src/storage/preferences.rs +++ b/src/storage/preferences.rs @@ -1,3 +1,6 @@ +use std::path::Path; + +use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -14,3 +17,67 @@ impl Default for Preferences { } } } + +impl Preferences { + /// Load preferences from a YAML file, or return defaults. + pub fn load_or_default(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + + let content = std::fs::read_to_string(path) + .with_context(|| format!("Unable to read preferences from {}", path.display()))?; + serde_yaml::from_str(&content) + .with_context(|| format!("Unable to parse preferences from {}", path.display())) + } + + /// Save preferences to a YAML file. + pub fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "Unable to create preferences directory {}", + parent.display() + ) + })?; + } + + let content = serde_yaml::to_string(self).context("Unable to serialize preferences")?; + std::fs::write(path, content) + .with_context(|| format!("Unable to write preferences to {}", path.display())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preferences_round_trip() { + let path = std::env::temp_dir().join(format!( + "kdc-prefs-{}.yaml", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + + let prefs = Preferences { + theme: "catppuccin".to_string(), + beginner_mode: false, + }; + + prefs.save(&path).unwrap(); + let loaded = Preferences::load_or_default(&path).unwrap(); + assert_eq!(prefs, loaded); + + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn preferences_defaults() { + let prefs = Preferences::default(); + assert_eq!(prefs.theme, "dark"); + assert!(prefs.beginner_mode); + } +} diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index 2670c5f..14e1d8d 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -231,6 +231,24 @@ fn render_sidebar(frame: &mut Frame, area: Rect, state: &AppState, palette: them } fn render_main(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { + // If there is execution output, show it in the main area. + if let Some(lines) = &state.ui.execution_output { + let title = state + .ui + .execution_title + .as_deref() + .unwrap_or("Execution Output"); + let content = lines.join("\n"); + render_panel( + frame, + area, + &format!(" {title} "), + format!("{content}\n\nPress Esc or navigate to dismiss."), + palette, + ); + return; + } + match state.current_screen { Screen::Dashboard => render_dashboard(frame, area, state, palette), Screen::Docker => render_docker(frame, area, state, palette), @@ -463,8 +481,191 @@ fn render_panel( ); } +fn welcome_rect(area: Rect) -> Rect { + let width_u32 = (area.width as u32 * 65 / 100) + .max(60) + .min(area.width as u32); + let height_u32 = 25u32.min(area.height as u32).max(20); + + let width = width_u32 as u16; + let height = height_u32 as u16; + let x = area.width.saturating_sub(width) / 2; + let y = area.height.saturating_sub(height) / 2; + Rect { + x, + y, + width, + height, + } +} + +fn render_outer_block( + frame: &mut Frame, + welcome_area: Rect, + palette: theme::Palette, +) -> Block<'static> { + let outer_block = Block::default().borders(Borders::ALL).title(Span::styled( + " KDC - Welcome ", + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + )); + frame.render_widget(Clear, welcome_area); + frame.render_widget(outer_block.clone(), welcome_area); + outer_block +} + +fn render_ascii_banner(frame: &mut Frame, chunk: Rect, palette: theme::Palette) { + let ascii_art = vec![ + Line::from(Span::styled( + " _ ______ ____ ", + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + " | |/ / _ \\ / ___|", + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + " | ' /| | | | | ", + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + " | . \\| |_| | |___ ", + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + " |_|\\_\\____/ \\____|", + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + )), + ]; + frame.render_widget( + Paragraph::new(ascii_art).alignment(Alignment::Center), + chunk, + ); +} + +fn render_subtitle(frame: &mut Frame, chunk: Rect, palette: theme::Palette) { + let subtitle_info = vec![ + Line::from(Span::styled( + "Kubernetes & Docker Commander like a boss.", + Style::default().fg(palette.text), + )), + Line::from(Span::styled( + "https://github.com/utkarsh232005/kdc-cli", + Style::default().fg(palette.muted), + )), + Line::from(vec![ + Span::raw("[with "), + Span::styled("♥", Style::default().fg(palette.danger)), + Span::raw(" by "), + Span::styled("@utkarsh232005", Style::default().fg(palette.success)), + Span::raw("]"), + ]), + ]; + frame.render_widget( + Paragraph::new(subtitle_info).alignment(Alignment::Center), + chunk, + ); +} + +fn capability_line(label: &str, present: bool, palette: theme::Palette) -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" {}: ", label), Style::default().fg(palette.muted)), + Span::styled( + if present { "Found" } else { "Missing" }, + Style::default().fg(if present { + palette.success + } else { + palette.warning + }), + ), + ]) +} + +fn render_capabilities_card( + frame: &mut Frame, + chunk: Rect, + state: &AppState, + palette: theme::Palette, +) { + let mut details = Vec::new(); + details.push(Line::from(vec![ + Span::styled(" Root: ", Style::default().fg(palette.muted)), + Span::styled( + format!("{}", state.project.root.display()), + Style::default().fg(palette.text), + ), + ])); + details.push(Line::from(vec![ + Span::styled(" Stack: ", Style::default().fg(palette.muted)), + Span::styled( + format!("{}", state.project.stack), + Style::default().fg(palette.text), + ), + ])); + details.push(capability_line( + "Dockerfile", + state.capabilities.docker, + palette, + )); + details.push(capability_line( + "Compose", + state.capabilities.compose, + palette, + )); + details.push(capability_line( + "Kubernetes", + state.capabilities.kubernetes, + palette, + )); + details.push(capability_line( + "Helm Chart", + state.capabilities.helm, + palette, + )); + + frame.render_widget( + Paragraph::new(details) + .block( + Block::default() + .title(" Current Directory Details ") + .borders(Borders::ALL), + ) + .style(Style::default().fg(palette.text)), + chunk, + ); +} + fn render_first_launch(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { - let area = centered_rect(56, 52, area); + let welcome_area = welcome_rect(area); + let outer_block = render_outer_block(frame, welcome_area, palette); + let inner_area = outer_block.inner(welcome_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), // ASCII art + Constraint::Length(4), // Subtitle, link, author + Constraint::Length(8), // Project card + Constraint::Min(5), // Options + ]) + .split(inner_area); + + render_ascii_banner(frame, chunks[0], palette); + render_subtitle(frame, chunks[1], palette); + render_capabilities_card(frame, chunks[2], state, palette); + + // 4. Action/Choice List let choices = [ FirstLaunchChoice::UseCurrentFolder, FirstLaunchChoice::BrowseFolder, @@ -490,14 +691,9 @@ fn render_first_launch(frame: &mut Frame, area: Rect, state: &AppState, palette: }) .collect::>(); - frame.render_widget(Clear, area); frame.render_widget( - List::new(items).block( - Block::default() - .title(" KDC - Kubernetes Docker Commander ") - .borders(Borders::ALL), - ), - area, + List::new(items).block(Block::default().title(" Actions ").borders(Borders::ALL)), + chunks[3], ); } @@ -699,12 +895,22 @@ fn reload_project(state: &mut AppState, path: PathBuf) -> io::Result<()> { fn cycle_theme(state: &mut AppState) { state.ui.active_theme = state.ui.active_theme.next(); - state.settings.theme = state + let theme_str = state .ui .active_theme .label() .to_lowercase() .replace(' ', "-"); + state.settings.theme = theme_str; + + // Persist the theme change to the config file. + let config_path = crate::config::paths::config_file(); + if let Err(err) = state.settings.save(&config_path) { + state.ui.push_notification(Notification::warning(format!( + "Could not save theme: {err}" + ))); + } + state.ui.push_notification(Notification::info(format!( "Theme: {}", state.ui.active_theme.label() @@ -763,3 +969,65 @@ fn render_short_list(values: &[String]) -> String { fn empty_state(title: &str, body: &str, suggestion: &str) -> String { format!("{title}\n\n{body}\n\nSuggestion:\n{suggestion}") } + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::KeyCode; + use ratatui::{backend::TestBackend, Terminal}; + + #[test] + fn test_render_all_phases() { + crate::utils::test_support::set_mock_path(); + let backend = TestBackend::new(120, 40); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = crate::app::startup::initialize(std::path::PathBuf::from(".")).unwrap(); + + // 1. First Launch + state.ui.phase = UiPhase::FirstLaunch; + let res = terminal.draw(|frame| { + render(frame, &state); + }); + assert!(res.is_ok()); + + // 2. Scanning + state.ui.phase = UiPhase::Scanning; + let res = terminal.draw(|frame| { + render(frame, &state); + }); + assert!(res.is_ok()); + + // 3. Ready (main screen) + state.ui.phase = UiPhase::Ready; + let res = terminal.draw(|frame| { + render(frame, &state); + }); + assert!(res.is_ok()); + } + + #[test] + fn test_handle_first_launch_key() { + crate::utils::test_support::set_mock_path(); + let mut state = crate::app::startup::initialize(std::path::PathBuf::from(".")).unwrap(); + state.ui.first_launch_choice = 0; + let res = handle_first_launch_key(&mut state, KeyCode::Down); + assert!(res.is_ok()); + assert_eq!(state.ui.first_launch_choice, 1); + + let res = handle_first_launch_key(&mut state, KeyCode::Up); + assert!(res.is_ok()); + assert_eq!(state.ui.first_launch_choice, 0); + + let res = handle_first_launch_key(&mut state, KeyCode::Enter); + assert!(res.is_ok()); + } + + #[test] + fn test_cycle_theme() { + crate::utils::test_support::set_mock_path(); + let mut state = crate::app::startup::initialize(std::path::PathBuf::from(".")).unwrap(); + let initial_theme = state.ui.active_theme; + cycle_theme(&mut state); + assert_ne!(state.ui.active_theme, initial_theme); + } +} diff --git a/src/ui/state.rs b/src/ui/state.rs index bac280a..9f4f171 100644 --- a/src/ui/state.rs +++ b/src/ui/state.rs @@ -19,8 +19,8 @@ pub enum FirstLaunchChoice { impl FirstLaunchChoice { pub fn label(self) -> &'static str { match self { - Self::UseCurrentFolder => "Use Current Folder", - Self::BrowseFolder => "Browse Folder", + Self::UseCurrentFolder => "Initialize KDC in current directory", + Self::BrowseFolder => "Select another directory", Self::Exit => "Exit", } } @@ -109,6 +109,8 @@ pub struct UiState { pub notifications: Vec, pub active_theme: ThemeName, pub picked_folder: Option, + pub execution_output: Option>, + pub execution_title: Option, } impl UiState { @@ -125,6 +127,8 @@ impl UiState { notifications: Vec::new(), active_theme, picked_folder: None, + execution_output: None, + execution_title: None, } } @@ -170,6 +174,23 @@ impl UiState { pub fn tick_notifications(&mut self) { self.notifications.retain_mut(Notification::tick); } + + /// Show execution output from a command in the main area. + pub fn show_execution_output(&mut self, title: String, lines: Vec) { + self.execution_title = Some(title); + self.execution_output = Some(lines); + } + + /// Clear the execution output panel. + pub fn clear_execution_output(&mut self) { + self.execution_title = None; + self.execution_output = None; + } + + /// Whether there is execution output to display. + pub fn has_execution_output(&self) -> bool { + self.execution_output.is_some() + } } #[cfg(test)] @@ -202,4 +223,20 @@ mod tests { assert_eq!(state.phase, UiPhase::Ready); assert_eq!(state.scan_progress, 100); } + + #[test] + fn test_execution_output() { + let mut state = UiState::new(false, ThemeName::Dark); + assert!(!state.has_execution_output()); + + state.show_execution_output("My Title".to_string(), vec!["line 1".to_string()]); + assert!(state.has_execution_output()); + assert_eq!(state.execution_title, Some("My Title".to_string())); + assert_eq!(state.execution_output, Some(vec!["line 1".to_string()])); + + state.clear_execution_output(); + assert!(!state.has_execution_output()); + assert!(state.execution_title.is_none()); + assert!(state.execution_output.is_none()); + } } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index d521fbd..ba3edee 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1 +1,4 @@ pub mod fs; + +#[cfg(test)] +pub mod test_support; diff --git a/src/utils/test_support.rs b/src/utils/test_support.rs new file mode 100644 index 0000000..2d5739a --- /dev/null +++ b/src/utils/test_support.rs @@ -0,0 +1,186 @@ +use std::fs::{self, File}; +use std::io::Write; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +static MOCK_BIN_DIR: OnceLock = OnceLock::new(); + +static DOCKER_SCRIPT: &str = r#"#!/bin/bash +case "$1" in + ps) + if [[ "$*" == *"-a"* ]]; then + echo -e "container123\tweb-app\tnginx:latest\tUp 5 minutes\t0.0.0.0:80->80/tcp" + else + echo -e "container123\tweb-app\tnginx:latest\tUp 5 minutes\t0.0.0.0:80->80/tcp" + fi + ;; + logs) + echo "line1" + echo "line2" + echo "line3" + ;; + volume) + if [ "$2" = "ls" ]; then + echo -e "db-data\tlocal\t/var/lib/docker/volumes/db-data/_data" + fi + ;; + network) + if [ "$2" = "ls" ]; then + echo -e "net123\tapp-network\tbridge\tlocal" + fi + ;; + images) + echo -e "myapp\tlatest\tsha256:abc123\t150MB" + ;; + build) + echo "Building image..." + echo "Successfully built sha256:abc123" + ;; + run) + echo "container123" + ;; + stop) + echo "container123" + ;; + restart) + echo "container123" + ;; + compose) + case "$2" in + logs) + echo "compose log line 1" + echo "compose log line 2" + ;; + config) + echo "service1" + echo "service2" + ;; + ps) + echo "service1" + ;; + up) + echo "Creating network..." + echo "Starting container..." + ;; + down) + echo "Stopping container..." + echo "Removing network..." + ;; + *) + ;; + esac + ;; + info) + echo "Docker Info mock" + ;; + inspect) + echo "manifest-info-json" + ;; + manifest) + if [ "$2" = "inspect" ]; then + echo "manifest-info-json" + fi + ;; + --version) + echo "Docker version 24.0.7, build afdd53b" + ;; + *) + echo "Unknown mock docker command: $*" >&2 + exit 0 + ;; +esac +"#; + +static KUBECTL_SCRIPT: &str = r#"#!/bin/bash +case "$1" in + cluster-info) + echo "Kubernetes control plane is running at https://127.0.0.1:6443" + ;; + config) + if [ "$2" = "current-context" ]; then + echo "minikube" + fi + ;; + get) + if [ "$2" = "nodes" ]; then + echo "node1 node2" + fi + ;; + rollout) + case "$2" in + undo) + echo "deployment.apps/deployment rolled back" + ;; + history) + echo "REVISION CHANGE-CAUSE" + echo "1 Initial deployment" + echo "2 Updated image" + ;; + status) + echo "deployment \"my-app\" successfully rolled out" + ;; + *) + ;; + esac + ;; + apply) + echo "deployment.apps/my-app configured" + ;; + version) + echo "Client Version: v1.28.2" + ;; + *) + echo "Unknown mock kubectl command: $*" >&2 + exit 0 + ;; +esac +"#; + +pub fn setup_mock_bin() -> PathBuf { + MOCK_BIN_DIR + .get_or_init(|| { + let temp = std::env::temp_dir().join(format!( + "kdc-mock-bin-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&temp).unwrap(); + + let kdc_home = temp.join(".kdc"); + fs::create_dir_all(&kdc_home).unwrap(); + std::env::set_var("KDC_HOME", &kdc_home); + + write_docker_script(&temp); + write_kubectl_script(&temp); + + temp + }) + .clone() +} + +fn write_docker_script(temp: &Path) { + let docker_path = temp.join("docker"); + let mut docker_file = File::create(&docker_path).unwrap(); + docker_file.write_all(DOCKER_SCRIPT.as_bytes()).unwrap(); + let mut perms = fs::metadata(&docker_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&docker_path, perms).unwrap(); +} + +fn write_kubectl_script(temp: &Path) { + let kubectl_path = temp.join("kubectl"); + let mut kubectl_file = File::create(&kubectl_path).unwrap(); + kubectl_file.write_all(KUBECTL_SCRIPT.as_bytes()).unwrap(); + let mut perms = fs::metadata(&kubectl_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&kubectl_path, perms).unwrap(); +} + +pub fn set_mock_path() { + let mock_bin = setup_mock_bin(); + let path = std::env::var("PATH").unwrap_or_default(); + std::env::set_var("PATH", format!("{}:{}", mock_bin.to_string_lossy(), path)); +} diff --git a/tests/compose_execution.rs b/tests/compose_execution.rs new file mode 100644 index 0000000..960dc77 --- /dev/null +++ b/tests/compose_execution.rs @@ -0,0 +1,73 @@ +use kdc::compose::{ + down::ComposeDownRequest, logs::ComposeLogRequest, restart::ComposeRestartRequest, + up::ComposeUpRequest, +}; + +#[test] +fn compose_up_request_defaults() { + let request = ComposeUpRequest::default(); + assert!(request.detached); + assert!(request.services.is_empty()); + assert!(!request.build); +} + +#[test] +fn compose_up_request_with_services() { + let request = ComposeUpRequest { + detached: true, + services: vec!["web".to_string(), "db".to_string()], + build: true, + }; + assert_eq!(request.services.len(), 2); + assert!(request.build); +} + +#[test] +fn compose_down_request_defaults() { + let request = ComposeDownRequest::default(); + assert!(!request.remove_volumes); + assert!(request.remove_orphans); +} + +#[test] +fn compose_down_with_volumes() { + let request = ComposeDownRequest { + remove_volumes: true, + remove_orphans: true, + }; + assert!(request.remove_volumes); + assert!(request.remove_orphans); +} + +#[test] +fn compose_restart_without_service() { + let request = ComposeRestartRequest { service: None }; + assert!(request.service.is_none()); +} + +#[test] +fn compose_restart_specific_service() { + let request = ComposeRestartRequest { + service: Some("web".to_string()), + }; + assert_eq!(request.service, Some("web".to_string())); +} + +#[test] +fn compose_log_request_defaults() { + let request = ComposeLogRequest::default(); + assert!(!request.follow); + assert!(request.service.is_none()); + assert_eq!(request.tail, Some(100)); +} + +#[test] +fn compose_log_request_with_service() { + let request = ComposeLogRequest { + follow: false, + service: Some("api".to_string()), + tail: Some(50), + }; + assert_eq!(request.service, Some("api".to_string())); + assert_eq!(request.tail, Some(50)); +} diff --git a/tests/docker_execution.rs b/tests/docker_execution.rs new file mode 100644 index 0000000..2c55ade --- /dev/null +++ b/tests/docker_execution.rs @@ -0,0 +1,119 @@ +use kdc::docker::{ + build::BuildRequest, containers::ContainerSummary, images::DockerImage, logs::DockerLogLine, + networks::DockerNetwork, run::RunRequest, volumes::DockerVolume, +}; + +#[test] +fn build_request_creates_full_tag() { + let request = BuildRequest { + image: "myapp".to_string(), + tag: "latest".to_string(), + }; + assert_eq!(request.full_tag(), "myapp:latest"); +} + +#[test] +fn build_request_with_registry_prefix() { + let request = BuildRequest { + image: "ghcr.io/org/myapp".to_string(), + tag: "v2.0.0".to_string(), + }; + assert_eq!(request.full_tag(), "ghcr.io/org/myapp:v2.0.0"); +} + +#[test] +fn run_request_default_is_detached() { + let request = RunRequest::default(); + assert!(request.detached); + assert!(request.ports.is_empty()); + assert!(request.env_vars.is_empty()); + assert!(request.name.is_none()); +} + +#[test] +fn run_request_with_all_fields() { + let request = RunRequest { + image: "nginx:alpine".to_string(), + name: Some("web-server".to_string()), + ports: vec!["8080:80".to_string(), "443:443".to_string()], + env_vars: vec![ + ("NODE_ENV".to_string(), "production".to_string()), + ("PORT".to_string(), "3000".to_string()), + ], + detached: true, + }; + + assert_eq!(request.image, "nginx:alpine"); + assert_eq!(request.name, Some("web-server".to_string())); + assert_eq!(request.ports.len(), 2); + assert_eq!(request.env_vars.len(), 2); +} + +#[test] +fn container_summary_has_all_fields() { + let container = ContainerSummary { + id: "abc123def456".to_string(), + name: "my-app".to_string(), + image: "nginx:latest".to_string(), + status: "Up 5 minutes".to_string(), + ports: "0.0.0.0:80->80/tcp".to_string(), + }; + + assert_eq!(container.id, "abc123def456"); + assert_eq!(container.name, "my-app"); + assert_eq!(container.image, "nginx:latest"); + assert!(container.status.contains("Up")); +} + +#[test] +fn docker_image_full_name_with_tag() { + let image = DockerImage { + repository: "myapp".to_string(), + tag: "v1.0".to_string(), + image_id: "sha256:abc123".to_string(), + size: "150MB".to_string(), + }; + assert_eq!(image.full_name(), "myapp:v1.0"); +} + +#[test] +fn docker_image_full_name_without_tag() { + let image = DockerImage { + repository: "myapp".to_string(), + tag: "".to_string(), + image_id: "sha256:abc123".to_string(), + size: "150MB".to_string(), + }; + assert_eq!(image.full_name(), "myapp"); +} + +#[test] +fn docker_log_line_message() { + let line = DockerLogLine { + message: "INFO: Server started on port 8080".to_string(), + }; + assert!(line.message.contains("8080")); +} + +#[test] +fn docker_network_fields() { + let network = DockerNetwork { + id: "net123".to_string(), + name: "app-network".to_string(), + driver: "bridge".to_string(), + scope: "local".to_string(), + }; + assert_eq!(network.name, "app-network"); + assert_eq!(network.driver, "bridge"); +} + +#[test] +fn docker_volume_fields() { + let volume = DockerVolume { + name: "db-data".to_string(), + driver: "local".to_string(), + mountpoint: "/var/lib/docker/volumes/db-data/_data".to_string(), + }; + assert_eq!(volume.name, "db-data"); + assert!(volume.mountpoint.contains("db-data")); +} diff --git a/tests/doctor_enhanced.rs b/tests/doctor_enhanced.rs new file mode 100644 index 0000000..fb93cee --- /dev/null +++ b/tests/doctor_enhanced.rs @@ -0,0 +1,132 @@ +use kdc::doctor::{ + docker_check::DockerStatus, + environment_check::{DoctorCheck, DoctorReport}, + kubernetes_check::KubernetesStatus, + registry_check::{RegistryConfig, RegistryStatus}, +}; + +#[test] +fn doctor_report_export_json() { + let report = DoctorReport { + checks: vec![ + DoctorCheck { + name: "Docker CLI".to_string(), + ok: true, + detail: "available".to_string(), + suggestion: None, + }, + DoctorCheck { + name: "Docker Daemon".to_string(), + ok: false, + detail: "not reachable".to_string(), + suggestion: Some("Start Docker Desktop".to_string()), + }, + ], + }; + + let json = report.export_json(); + let val: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(val["checks"][0]["name"], "Docker CLI"); + assert_eq!(val["checks"][0]["ok"], true); + assert_eq!(val["checks"][0]["suggestion"], serde_json::Value::Null); + assert_eq!(val["checks"][1]["name"], "Docker Daemon"); + assert_eq!(val["checks"][1]["ok"], false); + assert_eq!(val["checks"][1]["suggestion"], "Start Docker Desktop"); +} + +#[test] +fn doctor_report_summary_line() { + let report = DoctorReport { + checks: vec![ + DoctorCheck { + name: "A".to_string(), + ok: true, + detail: "ok".to_string(), + suggestion: None, + }, + DoctorCheck { + name: "B".to_string(), + ok: false, + detail: "fail".to_string(), + suggestion: Some("fix".to_string()), + }, + DoctorCheck { + name: "C".to_string(), + ok: true, + detail: "ok".to_string(), + suggestion: None, + }, + ], + }; + + assert_eq!(report.passed_count(), 2); + assert_eq!(report.total_count(), 3); + assert_eq!(report.summary_line(), "Doctor: 2/3 checks passed"); +} + +#[test] +fn doctor_report_render() { + let report = DoctorReport { + checks: vec![DoctorCheck { + name: "Docker CLI".to_string(), + ok: true, + detail: "available".to_string(), + suggestion: None, + }], + }; + + let rendered = report.render(); + assert!(rendered.contains("OK Docker CLI - available")); +} + +#[test] +fn doctor_report_warning_render() { + let report = DoctorReport { + checks: vec![DoctorCheck { + name: "Docker Daemon".to_string(), + ok: false, + detail: "not running".to_string(), + suggestion: Some("Start Docker Desktop".to_string()), + }], + }; + + let rendered = report.render(); + assert!(rendered.contains("WARN Docker Daemon")); + assert!(rendered.contains("Suggestion: Start Docker Desktop")); +} + +#[test] +fn docker_status_enum_values() { + assert_ne!(DockerStatus::Running, DockerStatus::Unavailable); + assert_ne!(DockerStatus::Unknown, DockerStatus::Running); + assert_ne!(DockerStatus::Unknown, DockerStatus::Unavailable); +} + +#[test] +fn kubernetes_status_enum_values() { + assert_ne!(KubernetesStatus::Connected, KubernetesStatus::Disconnected); + assert_ne!(KubernetesStatus::Unknown, KubernetesStatus::Connected); +} + +#[test] +fn registry_status_enum_values() { + assert_ne!(RegistryStatus::Connected, RegistryStatus::Disconnected); + assert_ne!(RegistryStatus::Unknown, RegistryStatus::Connected); +} + +#[test] +fn registry_config_defaults() { + let config = RegistryConfig::default(); + assert_eq!(config.url, "docker.io"); + assert!(config.username.is_none()); +} + +#[test] +fn registry_config_custom() { + let config = RegistryConfig { + url: "ghcr.io".to_string(), + username: Some("user".to_string()), + }; + assert_eq!(config.url, "ghcr.io"); + assert_eq!(config.username, Some("user".to_string())); +}