diff --git a/.github/workflows/rust-ci.yaml b/.github/workflows/rust-ci.yaml new file mode 100644 index 0000000..0114db4 --- /dev/null +++ b/.github/workflows/rust-ci.yaml @@ -0,0 +1,40 @@ +name: Rust CI + +on: + push: + branches: [ main, feat/* ] + pull_request: + branches: [ main ] + +env: + CARGO_TERM_COLOR: always + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + components: rustfmt, clippy + + - name: Run lint + run: make lint + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + + - name: Run tests + run: make test diff --git a/Cargo.lock b/Cargo.lock index 522f786..b105bcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,34 +3,46 @@ version = 4 [[package]] -name = "addr2line" -version = "0.24.2" +name = "adler2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] -name = "adler2" -version = "2.0.0" +name = "ahash" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" -version = "0.6.18" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -43,37 +55,54 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.7" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "once_cell", - "windows-sys 0.59.0", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -84,23 +113,63 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "backtrace" -version = "0.3.74" +name = "axum" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets", + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", ] [[package]] @@ -109,44 +178,110 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + [[package]] name = "bitflags" -version = "2.8.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bumpalo" -version = "3.17.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.15" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c736e259eea577f443d5c86c304f9f4ae0295c43f3ba05c21f1d66b5f06001af" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ + "find-msvc-tools", "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] [[package]] name = "clap" -version = "4.5.30" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92b7b18d71fad5313a1e320fa9897994228ce274b60faa4d694fe0ea89cd9e6d" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -154,9 +289,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.30" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a35db2071778a7344791a4fb4f95308b5673d219dee3ae348b86642574ecc90c" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -166,9 +301,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.28" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -178,27 +313,74 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] [[package]] name = "console" -version = "0.15.10" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "once_cell", - "unicode-width 0.2.0", - "windows-sys 0.59.0", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", ] [[package]] @@ -211,33 +393,137 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "csv" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" dependencies = [ "csv-core", "itoa", "ryu", - "serde", + "serde_core", ] [[package]] name = "csv-core" -version = "0.1.12" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d02f3b0da4c6504f86e9cd789d8dbafab48c2321be74e9987593de5a894d93d" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" dependencies = [ "memchr", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "dirs" version = "6.0.0" @@ -265,8 +551,8 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.0", - "windows-sys 0.59.0", + "redox_users 0.5.2", + "windows-sys 0.61.2", ] [[package]] @@ -291,6 +577,27 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -307,49 +614,54 @@ dependencies = [ ] [[package]] -name = "env_filter" -version = "0.1.3" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" -dependencies = [ - "log", - "regex", -] +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "env_logger" -version = "0.11.6" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcaee3d8e3cfc3fd92428d477bc97fc29ec8716d180c0d74c643bb26166660e0" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "anstream", - "anstyle", - "env_filter", - "humantime", - "log", + "libc", + "windows-sys 0.61.2", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "fallible-iterator" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" [[package]] -name = "errno" -version = "0.3.10" +name = "fallible-streaming-iterator" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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" @@ -357,6 +669,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -374,86 +692,148 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", - "pin-utils", + "slab", ] [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", ] [[package]] name = "getrandom" -version = "0.3.1" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "wasi 0.13.3+wasi-0.2.2", - "windows-targets", + "r-efi 5.3.0", + "wasip2", ] [[package]] -name = "gimli" -version = "0.31.1" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "h2" -version = "0.4.8" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -468,11 +848,49 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] [[package]] name = "heck" @@ -482,18 +900,41 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.4.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hf-hub" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef3982638978efa195ff11b305f51f1f22f4f0a6cabee7af79b383ebee6a213" +dependencies = [ + "dirs", + "futures", + "http", + "indicatif", + "libc", + "log", + "native-tls", + "num_cpus", + "rand", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "ureq", + "windows-sys 0.61.2", +] [[package]] name = "http" -version = "1.2.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -509,12 +950,12 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", - "futures-util", + "futures-core", "http", "http-body", "pin-project-lite", @@ -522,29 +963,31 @@ dependencies = [ [[package]] name = "httparse" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2d708df4e7140240a16cd6ab0ab65c972d7433ab77819ea693fde9c43811e2a" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "humantime" -version = "2.1.0" +name = "httpdate" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.6.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -554,16 +997,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.5" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "futures-util", "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -587,146 +1028,152 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.10" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] -name = "icu_collections" -version = "1.5.0" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ - "displaydoc", - "yoke", - "zerofrom", - "zerovec", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", + "cc", ] [[package]] -name = "icu_locid_transform" -version = "1.5.0" +name = "icu_collections" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", "zerovec", ] [[package]] -name = "icu_locid_transform_data" -version = "1.5.0" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", + "icu_locale_core", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] [[package]] -name = "icu_provider_macros" -version = "1.5.0" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -735,9 +1182,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -745,62 +1192,94 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.7.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] name = "indicatif" -version = "0.17.11" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" dependencies = [ "console", - "number_prefix", "portable-atomic", - "unicode-width 0.2.0", + "unicode-width 0.2.2", + "unit-prefix", "web-time", ] [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] [[package]] name = "is-terminal" -version = "0.4.15" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e19b23d53f35ce9f56aebc7d1bb4e6ac1e9c0db7ac85c8d1760c04379edced37" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] [[package]] name = "itoa" -version = "1.0.14" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -811,55 +1290,110 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.170" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags", "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" -version = "0.4.15" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.7.4" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.26" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mach-sys" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48460c2e82a3a0de197152fdf8d2c2d5e43adc501501553e439bf2156e6f87c7" +dependencies = [ + "fastrand", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mime" @@ -867,31 +1401,98 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" -version = "0.8.5" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] name = "mio" -version = "1.0.3" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mlx-internal-macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a7c4444d624bf6b93db5cc22ebff4fdfa13593fd56154fe33b1f302a557c2c6" +dependencies = [ + "darling", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "mlx-macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a819ee8b4434690572b6feb9c3ef0b6e90137e4190b340cf00150703b410aaf9" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "mlx-rs" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a0f592c5839b0237b3072530b1d3c503923579a2009ee0761df3edd1b1f27b" +dependencies = [ + "dyn-clone", + "half", + "itertools 0.14.0", + "libc", + "mach-sys", + "mlx-internal-macros", + "mlx-macros", + "mlx-sys", + "num-complex", + "num-traits", + "num_enum", + "parking_lot", + "paste", + "smallvec", + "strum", + "thiserror 2.0.18", +] + +[[package]] +name = "mlx-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e3bc3880111918b2d5018f845d48fd995f9901f16efc81d1fcfd2f4210b8219" +dependencies = [ + "bindgen", + "cc", + "cmake", ] [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", @@ -905,31 +1506,106 @@ dependencies = [ ] [[package]] -name = "number_prefix" -version = "0.4.0" +name = "nom" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] [[package]] -name = "object" -version = "0.36.7" +name = "ntapi" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" dependencies = [ - "memchr", + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", ] [[package]] name = "once_cell" -version = "1.20.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" -version = "0.10.71" +version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e14130c6a98cd258fdcb0fb6d744152343ff729cbfcb28c656a9d12b999fbcd" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ "bitflags", "cfg-if", @@ -953,15 +1629,15 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.106" +version = "0.9.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb61ea9811cc39e3c2069f40b8b8e2e70d8569b361f879786cc7ed48b777cdd" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" dependencies = [ "cc", "libc", @@ -977,9 +1653,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -987,100 +1663,249 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", ] [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "pin-project-lite" -version = "0.2.16" +name = "pin-project" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] [[package]] -name = "pin-utils" -version = "0.1.0" +name = "pin-project-internal" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "portable-atomic" -version = "1.10.0" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] [[package]] name = "prettytable-rs" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" +checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" +dependencies = [ + "csv", + "encode_unicode", + "is-terminal", + "lazy_static", + "term", + "unicode-width 0.1.14", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "puma" +version = "0.0.3" +dependencies = [ + "axum", + "chrono", + "clap", + "colored", + "dirs", + "futures", + "hf-hub", + "indicatif", + "mlx-rs", + "prettytable-rs", + "regex", + "reqwest", + "rusqlite", + "rusqlite_migration", + "serde", + "serde_derive", + "serde_json", + "sysinfo", + "tempfile", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-http 0.5.2", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ - "csv", - "encode_unicode", - "is-terminal", - "lazy_static", - "term", - "unicode-width 0.1.14", + "ppv-lite86", + "rand_core", ] [[package]] -name = "proc-macro2" -version = "1.0.93" +name = "rand_core" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "unicode-ident", + "getrandom 0.3.4", ] [[package]] -name = "puma" -version = "0.0.1" +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ - "clap", - "dirs", - "env_logger", - "indicatif", - "log", - "prettytable-rs", - "reqwest", - "serde", - "serde_derive", - "tokio", + "either", + "rayon-core", ] [[package]] -name = "quote" -version = "1.0.38" +name = "rayon-core" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ - "proc-macro2", + "crossbeam-deque", + "crossbeam-utils", ] [[package]] name = "redox_syscall" -version = "0.5.9" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b568323e98e49e2a0899dcee453dd679fae22d69adf9b11dd508d1549b7e2f" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags", ] @@ -1091,27 +1916,27 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", "libredox", - "thiserror 2.0.11", + "thiserror 2.0.18", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -1121,9 +1946,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -1132,15 +1957,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.12.12" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", @@ -1155,71 +1980,96 @@ dependencies = [ "hyper-rustls", "hyper-tls", "hyper-util", - "ipnet", "js-sys", "log", "mime", "native-tls", - "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", - "system-configuration", "tokio", "tokio-native-tls", - "tower", + "tokio-util", + "tower 0.5.3", + "tower-http 0.6.8", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", - "windows-registry", ] [[package]] name = "ring" -version = "0.17.11" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5349ae27d3887ca812fb375b45a4fbb36d8d12d2df394968cd86e35683fe73" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.15", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", ] [[package]] -name = "rustc-demangle" -version = "0.1.24" +name = "rusqlite" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rusqlite_migration" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923b42e802f7dc20a0a6b5e097ba7c83fe4289da07e49156fecf6af08aa9cd1c" +dependencies = [ + "log", + "rusqlite", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustix" -version = "0.38.44" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.23" +version = "0.23.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395" +checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" dependencies = [ + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1227,25 +2077,19 @@ dependencies = [ ] [[package]] -name = "rustls-pemfile" -version = "2.2.0" +name = "rustls-pki-types" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ - "rustls-pki-types", + "zeroize", ] -[[package]] -name = "rustls-pki-types" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" - [[package]] name = "rustls-webpki" -version = "0.102.8" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -1254,23 +2098,23 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.19" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.19" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1281,12 +2125,12 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -1294,28 +2138,44 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" -version = "1.0.218" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.218" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -1324,14 +2184,26 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.139" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f86c3acccc9c65b153fe1b85a3be07fe5515274ec9f0653b4a0875731c72a6" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", ] [[package]] @@ -1346,6 +2218,15 @@ dependencies = [ "serde", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1354,43 +2235,58 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.5.8" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", ] [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "strsim" @@ -1398,6 +2294,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1406,9 +2323,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.98" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -1426,23 +2343,37 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "sysinfo" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows", +] + [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -1458,16 +2389,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.17.1" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "cfg-if", "fastrand", - "getrandom 0.3.1", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1492,11 +2422,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.11" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.11", + "thiserror-impl 2.0.18", ] [[package]] @@ -1512,20 +2442,60 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.11" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +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.7.6" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -1533,11 +2503,10 @@ dependencies = [ [[package]] name = "tokio" -version = "1.43.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ - "backtrace", "bytes", "libc", "mio", @@ -1546,14 +2515,14 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -1572,19 +2541,30 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.1" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" -version = "0.7.13" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -1593,11 +2573,56 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + [[package]] name = "tower" -version = "0.5.2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -1606,6 +2631,42 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower 0.5.3", + "tower-layer", + "tower-service", ] [[package]] @@ -1622,21 +2683,64 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", + "nu-ansi-term", "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1647,21 +2751,33 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "unicode-ident" -version = "1.0.17" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] -name = "unicode-width" -version = "0.2.0" +name = "unit-prefix" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" [[package]] name = "untrusted" @@ -1669,22 +2785,59 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "cookie_store", + "der", + "flate2", + "log", + "native-tls", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-root-certs", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] -name = "utf16_iter" -version = "1.0.5" +name = "utf8-zero" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" [[package]] name = "utf8_iter" @@ -1698,12 +2851,36 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -1715,63 +2892,56 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.13.3+wasi-0.2.2" +name = "wasip2" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen 0.57.1", ] [[package]] -name = "wasm-bindgen" -version = "0.2.100" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", + "wit-bindgen 0.51.0", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" +name = "wasm-bindgen" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1779,31 +2949,78 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -1819,6 +3036,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[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 = "winapi" version = "0.3.9" @@ -1841,34 +3076,127 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-registry" -version = "0.2.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-result", + "windows-link", + "windows-result 0.4.1", "windows-strings", - "windows-targets", ] [[package]] name = "windows-result" -version = "0.2.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" dependencies = [ "windows-targets", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-strings" -version = "0.1.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-result", - "windows-targets", + "windows-link", ] [[package]] @@ -1889,6 +3217,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -1954,33 +3291,120 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "wit-bindgen-rt" -version = "0.33.0" +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] -name = "write16" -version = "1.0.0" +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.7.5" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -1988,9 +3412,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -1998,20 +3422,40 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" -version = "0.1.5" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.5" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -2021,15 +3465,26 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +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.10.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -2038,11 +3493,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index ef37a03..3eb630d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,15 @@ [package] name = "puma" -version = "0.0.1" +version = "0.0.3" edition = "2021" description = "A lightweight, high-performance inference engine for local AI." license = "Apache-2.0" +repository = "https://github.com/InftyAI/PUMA" +homepage = "https://github.com/InftyAI/PUMA" +documentation = "https://github.com/InftyAI/PUMA" +readme = "README.md" +keywords = ["inference", "llm", "local-ai"] +authors = ["PUMA Team"] [dependencies] @@ -13,7 +19,36 @@ reqwest = { version = "0.12", features = ["json"] } tokio = { version = "1", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_derive = "1.0" -env_logger = "0.11.6" -log = "0.4.26" -indicatif = "0.17.11" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +indicatif = "0.18" dirs = "6.0.0" +hf-hub = { version = "0.5.0", features = ["tokio"] } +colored = "2.1" +chrono = "0.4" +serde_json = "1.0" +sysinfo = "0.32" +rusqlite = { version = "0.32", features = ["bundled"] } +rusqlite_migration = "1.3" +regex = "1.11" + +# Web server +axum = "0.7" +tower = "0.4" +tower-http = { version = "0.5", features = ["cors", "trace"] } +uuid = { version = "1.0", features = ["v4", "serde"] } +futures = "0.3" +tokio-stream = "0.1" + +# MLX support (macOS only) +[target.'cfg(target_os = "macos")'.dependencies] +mlx-rs = { version = "0.25.3", optional = true } + +[features] +default = [] +mlx = ["mlx-rs"] + +[dev-dependencies] +tempfile = "3.12" +tower = { version = "0.4", features = ["util"] } +serde_json = "1.0" diff --git a/Makefile b/Makefile index 36f1dd8..0e72459 100644 --- a/Makefile +++ b/Makefile @@ -1,2 +1,13 @@ build: - cargo build && cp target/debug/puma ./puma \ No newline at end of file + cargo build && cp target/debug/puma ./puma + +test: + cargo test + +lint: + cargo fmt --all -- --check + cargo clippy --all-targets --all-features -- -D warnings + +format: + cargo fmt --all + cargo clippy --fix --allow-dirty \ No newline at end of file diff --git a/README.md b/README.md index c9317d9..846ad16 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,298 @@ -# PUMA +
-**PUMA** aims to be a lightweight, high-performance inference engine for local AI. Play for fun. + + + + PUMA Logo + -## How to Run +**A lightweight, high-performance inference engine for local AI** -### Build +[![Stability: Active](https://img.shields.io/badge/stability-active-brightgreen.svg)](https://github.com/InftyAI/PUMA) +[![Latest Release](https://img.shields.io/github/v/release/InftyAI/PUMA)](https://github.com/InftyAI/PUMA/releases) -Run `make build` to build the **puma** binary. +
-### Run +## ✨ Features -Run `./puma help` to see all available commands. +🔧 **Model Management** - Download, cache, and organize AI models from Hugging Face -For example, you can run `./puma version` to see the binary version. +🔍 **Advanced Filtering** - Search models with regex patterns and SQL-style queries -## Supported Backends +💻 **System Detection** - Automatic GPU detection and resource reporting -Use [llama.cpp](https://github.com/ggerganov/llama.cpp) as the default backend for quick prototyping, will implement our own backend in the future. +🚀 **OpenAI-Compatible API** - RESTful API with streaming support + +## Installation + +### Install with Cargo + +```bash +cargo install puma +``` + +### Build from Source + +```bash +# Clone the repository +git clone https://github.com/InftyAI/PUMA.git +cd PUMA + +# Build the binary +make build + +# The binary will be available at ./puma +./puma version +``` + +## Quick Start + +### CLI Usage + +```bash +# Download a model +puma pull inftyai/tiny-random-gpt2 + +# List all models +puma ls + +# Inspect model details +puma inspect inftyai/tiny-random-gpt2 + +# Check system info +puma info + +# Remove a model +puma rm inftyai/tiny-random-gpt2 +``` + +### API Server + +```bash +# Start the inference server with a model +puma serve inftyai/tiny-random-gpt2 + +# Server will start on http://0.0.0.0:8000 +# API endpoints: +# POST /v1/chat/completions +# POST /v1/completions +# GET /v1/models +# GET /v1/models/:model +# GET /health +``` + +**Test the API:** + +```bash +# Health check +curl http://localhost:8000/health + +# Chat completion +curl http://localhost:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "inftyai/tiny-random-gpt2", + "messages": [{"role": "user", "content": "Hello!"}] + }' + +# Or use the test script +./hack/scripts/test_api.sh +``` + +## Commands + +| Command | Status | Description | +|---------|--------|-------------| +| `pull ` | ✅ | Download model from provider | +| `ls` | ✅ | List models (supports regex, label filters) | +| `inspect ` | ✅ | Show detailed model information | +| `rm ` | ✅ | Remove model and cache | +| `info` | ✅ | Display system information | +| `version` | ✅ | Show PUMA version | +| `serve ` | ✅ | Start OpenAI-compatible API server with a model | +| `ps` | 🚧 | List running models | +| `run` | 🚧 | Start model inference | +| `stop` | 🚧 | Stop running model | + +## Advanced Usage + +### Pattern Matching + +```bash +# Substring match +puma ls qwen + +# Prefix match +puma ls "^inftyai/" + +# Alternation +puma ls "llama-(2|3)" +``` + +### Label Filtering + +```bash +# Single filter +puma ls -l author=inftyai + +# Multiple filters (AND condition) +puma ls -l author=inftyai,license=mit + +# Combine pattern + filter +puma ls llama -l author=meta +``` + +**Available filters:** `author`, `task`, `license`, `provider`, `model_series` + +## API Server + +PUMA provides an OpenAI-compatible API server for model inference. + +### Starting the Server + +```bash +# Start server with a model (default: 0.0.0.0:8000) +puma serve inftyai/tiny-random-gpt2 + +# Custom host and port +puma serve inftyai/tiny-random-gpt2 --host 127.0.0.1 --port 3000 + +# Model must be pulled first +puma pull inftyai/tiny-random-gpt2 +``` + +### API Endpoints + +#### Chat Completions (Recommended) +```bash +curl http://localhost:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "inftyai/tiny-random-gpt2", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello!"} + ], + "max_tokens": 100, + "temperature": 0.7 + }' +``` + +#### Streaming (Server-Sent Events) +```bash +curl http://localhost:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "inftyai/tiny-random-gpt2", + "messages": [{"role": "user", "content": "Tell me a story"}], + "stream": true + }' +``` + +#### List Models +```bash +# Returns the currently loaded model +curl http://localhost:8000/v1/models +``` + +#### Health Check +```bash +curl http://localhost:8000/health +# Returns: {"status":"ok"} +``` + +### OpenAI Python Client + +PUMA is compatible with the OpenAI Python SDK: + +```python +from openai import OpenAI + +client = OpenAI( + base_url="http://localhost:8000/v1", + api_key="dummy" # Not required +) + +response = client.chat.completions.create( + model="inftyai/tiny-random-gpt2", + messages=[ + {"role": "user", "content": "Hello!"} + ] +) + +print(response.choices[0].message.content) +``` + +### Inspect Output + +```bash +$ puma inspect inftyai/tiny-random-gpt2 + +name: inftyai/tiny-random-gpt2 +kind: Model +spec: + author: inftyai + model_series: gpt2 + task: text-generation + license: MIT + context_window: 2.05K + safetensors: + total: 7.00B + parameters: + f32: 7.00B + provider: huggingface + cache: + revision: abc123de + size: 1.24 GB + path: ~/.puma/cache/... +status: + created: 2 hours ago + updated: 2 hours ago +``` + +## Model Management + +- **Database:** `~/.puma/models.db` (SQLite) +- **Cache:** `~/.puma/cache/` (model files) + +Models are stored with lowercase names for case-insensitive matching. + +## Development + +```bash +# Build +make build + +# Run all tests +make test + +# Test API manually +./hack/scripts/test_api.sh +``` + +### Project Structure + +``` +puma/ +├── src/ +│ ├── api/ # OpenAI-compatible API +│ ├── backend/ # Inference backends (Mock, MLX) +│ ├── cli/ # Command implementations +│ ├── downloader/ # HuggingFace download logic +│ ├── registry/ # Model registry & metadata +│ ├── storage/ # SQLite storage backend +│ ├── system/ # System info detection +│ └── utils/ # Formatting & helpers +├── tests/ # Integration tests +├── hack/ # Development scripts +├── Cargo.toml # Rust dependencies +└── Makefile # Build commands +``` + +## License + +Apache-2.0 + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=inftyai/puma&type=Date)](https://www.star-history.com/#inftyai/puma&Date) diff --git a/docs/MLX_INTEGRATION.md b/docs/MLX_INTEGRATION.md new file mode 100644 index 0000000..5efb505 --- /dev/null +++ b/docs/MLX_INTEGRATION.md @@ -0,0 +1,200 @@ +# MLX Integration Guide + +## Overview + +PUMA now supports Apple's MLX framework for high-performance inference on Apple Silicon. This integration uses [mlx-rs](https://github.com/oxiglade/mlx-rs) v0.25.3 as the binding layer. + +## Quick Start + +### Prerequisites + +- macOS 13.0+ +- Apple Silicon (M1/M2/M3/M4) +- Xcode Command Line Tools: `xcode-select --install` + +### Building with MLX Support + +```bash +# Build +cargo build --release --features mlx + +# Run tests (on Apple Silicon) +cargo test --features mlx + +# Run example +cargo run --release --features mlx --example mlx_inference +``` + +### Starting Server with MLX + +```bash +# Build with MLX feature +cargo build --release --features mlx + +# Start server (will use MLX on Apple Silicon) +./target/release/puma serve +``` + +The server will automatically detect and use MLX if: +1. Running on Apple Silicon macOS +2. Built with `--features mlx` + +Otherwise, it falls back to MockEngine. + +## Architecture + +``` +┌─────────────────────────────────────────┐ +│ PUMA CLI/API │ +└──────────────┬──────────────────────────┘ + │ + ┌───────┴────────┐ + │ InferenceEngine│ (trait) + └───────┬────────┘ + │ + ┏━━━━━━━━━┻━━━━━━━━━┓ + ┃ ┃ +┌───┴─────┐ ┌───────┴─────┐ +│MockEngine│ │ MlxEngine │ +└─────────┘ └───────┬──────┘ + │ + ┌──────┴──────┐ + │ mlx-rs │ (v0.25.3) + └──────┬──────┘ + │ + ┌──────┴──────┐ + │ mlx-sys │ (FFI) + └──────┬──────┘ + │ + ┌──────┴──────┐ + │ MLX C++ │ + └─────────────┘ +``` + +## Current Implementation Status + +### ✅ Completed + +- Feature flag setup (`mlx` feature) +- Platform detection (Apple Silicon only) +- mlx-rs integration (v0.25.3) +- Device initialization (GPU/CPU) +- Basic InferenceEngine trait implementation +- Conditional compilation for non-macOS platforms +- Serve command auto-detection + +### 🚧 In Progress (Placeholder) + +- Model loading from PUMA cache +- Tokenization (currently uses dummy tokens) +- Token generation (placeholder implementation) +- Streaming (basic structure in place) + +### 📋 TODO + +- [ ] Integrate proper tokenizer (tokenizers-rs or mlx-lm) +- [ ] Implement actual model loading +- [ ] Real token generation with mlx-rs +- [ ] Model caching and memory management +- [ ] Quantization support (4-bit, 8-bit) +- [ ] Benchmark and optimize performance +- [ ] Add more examples + +## Usage Example + +```rust +use puma::backend::{MlxEngine, InferenceEngine}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Create engine + let engine = MlxEngine::new()?; + + // Generate text + let response = engine.generate( + "model-name", + "Hello, world!", + 100, // max_tokens + 0.7 // temperature + ).await?; + + println!("Generated: {}", response.text); + Ok(()) +} +``` + +## API Endpoints + +Once MLX is enabled, the API server uses it automatically: + +```bash +# Chat completion with MLX +curl http://localhost:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "your-model", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +## Performance Notes + +- MLX uses unified memory (CPU/GPU share memory) +- Metal GPU acceleration enabled by default +- Lazy evaluation for compute graphs +- Multi-device support (CPU/GPU) + +## Troubleshooting + +### Build fails on non-macOS + +**Expected behavior.** MLX only works on macOS with Apple Silicon. + +### "MLX feature not enabled" error at runtime + +Build with the `mlx` feature: +```bash +cargo build --features mlx +``` + +### Server uses MockEngine instead of MLX + +Check: +1. Running on Apple Silicon? `uname -m` should show `arm64` +2. Built with MLX feature? `cargo build --features mlx` +3. Check logs for MLX initialization + +### Compilation takes a long time + +MLX compilation (via mlx-rs) compiles the entire MLX framework. First build can take 10-20 minutes. Subsequent builds are cached. + +## Future: Custom MLX Bindings + +Current implementation uses mlx-rs. Future plan: + +1. **Phase 1** (Current): Use mlx-rs for rapid development +2. **Phase 2**: Implement custom `puma-mlx-sys` FFI layer +3. **Phase 3**: Build custom safe wrapper with PUMA-specific optimizations + +See `src/backend/mlx/README.md` for details. + +## References + +- [MLX Framework](https://github.com/ml-explore/mlx) +- [mlx-rs Bindings](https://github.com/oxiglade/mlx-rs) +- [mlx-rs Documentation](https://oxideai.github.io/mlx-rs/mlx_rs/) +- [MLX Python Docs](https://ml-explore.github.io/mlx/build/html/index.html) + +## Contributing + +When contributing to MLX backend: + +1. Test on Apple Silicon macOS +2. Ensure code compiles without `mlx` feature +3. Use feature flags for MLX-specific code +4. Add tests with `#[cfg(all(target_os = "macos", feature = "mlx"))]` +5. Document any new MLX-specific features + +## License + +MLX integration in PUMA follows the same Apache-2.0 license as the main project. diff --git a/examples/mlx_inference.rs b/examples/mlx_inference.rs new file mode 100644 index 0000000..82b1b6c --- /dev/null +++ b/examples/mlx_inference.rs @@ -0,0 +1,74 @@ +//! Example: Using MLX backend for inference +//! +//! This example demonstrates how to use PUMA with MLX backend on Apple Silicon. +//! +//! Build with: cargo build --release --features mlx +//! Run with: cargo run --release --features mlx --example mlx_inference + +#[cfg(all(target_os = "macos", feature = "mlx"))] +use puma::backend::MlxEngine; + +#[cfg(all(target_os = "macos", feature = "mlx"))] +use puma::backend::InferenceEngine; + +#[cfg(all(target_os = "macos", feature = "mlx"))] +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing + tracing_subscriber::fmt() + .with_env_filter("info") + .init(); + + println!("🐆 PUMA MLX Inference Example\n"); + + // Create MLX engine + println!("Initializing MLX engine..."); + let engine = MlxEngine::new() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + + println!("✓ MLX engine initialized\n"); + + // Generate text + println!("Generating text..."); + let response = engine + .generate( + "test-model", + "Once upon a time", + 50, // max_tokens + 0.7, // temperature + ) + .await?; + + println!("\n📝 Generated Response:"); + println!(" Text: {}", response.text); + println!(" Prompt tokens: {}", response.prompt_tokens); + println!(" Completion tokens: {}", response.completion_tokens); + + // Test streaming + println!("\n🔄 Testing streaming generation..."); + let mut stream = engine + .generate_stream( + "test-model", + "The quick brown fox", + 30, + 0.7, + ) + .await?; + + print!(" Tokens: "); + use tokio_stream::StreamExt; + while let Some(token) = stream.next().await { + print!("{}", token); + std::io::Write::flush(&mut std::io::stdout()).ok(); + } + println!("\n\n✓ Example completed"); + + Ok(()) +} + +#[cfg(not(all(target_os = "macos", feature = "mlx")))] +fn main() { + eprintln!("❌ This example requires macOS with Apple Silicon"); + eprintln!("Build with: cargo run --features mlx --example mlx_inference"); + std::process::exit(1); +} diff --git a/hack/README.md b/hack/README.md new file mode 100644 index 0000000..ca9895f --- /dev/null +++ b/hack/README.md @@ -0,0 +1,68 @@ +# Hack Directory + +Development and testing utilities for PUMA. + +## Structure + +``` +hack/ +└── scripts/ # Test and utility scripts + └── test_api.sh +``` + +## Scripts + +### `scripts/test_api.sh` + +Tests all PUMA API endpoints manually. + +**Usage:** +```bash +# Start PUMA server first +./puma serve + +# In another terminal +./hack/scripts/test_api.sh +``` + +**Tests:** +- Health check +- List models +- Chat completion (non-streaming) +- Chat completion (streaming) +- Text completion + +**Requirements:** +- Running PUMA server +- `curl` and `jq` installed + +--- + + +## Adding New Scripts + +Place development and testing scripts in `hack/scripts/`: + +```bash +# Create new script +cat > hack/scripts/my_script.sh << 'EOF' +#!/bin/bash +# Your script here +EOF + +# Make executable +chmod +x hack/scripts/my_script.sh +``` + +--- + +## Why "hack"? + +The `hack/` directory is a convention from Kubernetes and other projects for: +- Development utilities +- Test scripts +- Build helpers +- CI/CD scripts +- One-off tools + +It keeps the root directory clean while providing a place for development tools. diff --git a/hack/scripts/test_api.sh b/hack/scripts/test_api.sh new file mode 100755 index 0000000..8caf5d4 --- /dev/null +++ b/hack/scripts/test_api.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +echo "Testing PUMA OpenAI-Compatible API" +echo "====================================" +echo + +# Base URL +BASE_URL="http://localhost:8000" + +echo "1. Health Check" +curl -s "$BASE_URL/health" +echo -e "\n" + +echo "2. List Models" +curl -s "$BASE_URL/v1/models" | jq '.' +echo + +echo "3. Chat Completion (Non-streaming)" +curl -s "$BASE_URL/v1/chat/completions" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "test-model", + "messages": [ + {"role": "user", "content": "Hello!"} + ], + "max_tokens": 50 + }' | jq '.' +echo + +echo "4. Chat Completion (Streaming)" +curl -s -N "$BASE_URL/v1/chat/completions" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "test-model", + "messages": [ + {"role": "user", "content": "Tell me a story"} + ], + "stream": true, + "max_tokens": 50 + }' +echo -e "\n" + +echo "5. Legacy Text Completion" +curl -s "$BASE_URL/v1/completions" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "test-model", + "prompt": "Once upon a time", + "max_tokens": 50 + }' | jq '.' +echo + +echo "Done!" diff --git a/site/images/logo-dark.svg b/site/images/logo-dark.svg new file mode 100644 index 0000000..f11bffb --- /dev/null +++ b/site/images/logo-dark.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/site/images/logo-light.svg b/site/images/logo-light.svg new file mode 100644 index 0000000..fa61d5b --- /dev/null +++ b/site/images/logo-light.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/api/chat.rs b/src/api/chat.rs new file mode 100644 index 0000000..0dfcf4f --- /dev/null +++ b/src/api/chat.rs @@ -0,0 +1,254 @@ +use axum::{ + extract::State, + response::{ + sse::{Event, KeepAlive, Sse}, + IntoResponse, Response, + }, + Json, +}; +use futures::stream::StreamExt; +use std::sync::Arc; +use tokio_stream::wrappers::ReceiverStream; +use uuid::Uuid; + +use crate::api::routes::AppState; +use crate::api::types::{ + ChatChoice, ChatChoiceDelta, ChatCompletionChunk, ChatCompletionRequest, + ChatCompletionResponse, ChatMessage, ChatMessageDelta, ErrorResponse, Usage, +}; +use crate::backend::InferenceEngine; + +/// Main handler for chat completions +pub async fn chat_completions( + State(state): State>, + Json(req): Json, +) -> Response { + let engine = state.engine; + let registry = state.registry; + + // Validate request + if req.messages.is_empty() { + return ( + axum::http::StatusCode::BAD_REQUEST, + Json(ErrorResponse::new( + "messages cannot be empty".to_string(), + "invalid_request_error".to_string(), + )), + ) + .into_response(); + } + + // Validate model exists + match registry.get_model(&req.model) { + Ok(None) => { + return ( + axum::http::StatusCode::NOT_FOUND, + Json(ErrorResponse::new( + format!("Model '{}' not found", req.model), + "model_not_found".to_string(), + )), + ) + .into_response(); + } + Err(e) => { + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new( + format!("Failed to check model: {}", e), + "internal_error".to_string(), + )), + ) + .into_response(); + } + Ok(Some(_)) => { + // Model exists, continue + } + } + + if req.stream { + chat_completions_stream(engine, req).await.into_response() + } else { + match chat_completions_non_stream(engine, req).await { + Ok(response) => Json(response).into_response(), + Err(err) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new( + err.to_string(), + "internal_error".to_string(), + )), + ) + .into_response(), + } + } +} + +/// Non-streaming chat completion +async fn chat_completions_non_stream( + engine: Arc, + req: ChatCompletionRequest, +) -> Result> { + let id = format!("chatcmpl-{}", Uuid::new_v4()); + let created = chrono::Utc::now().timestamp(); + + // Convert messages to prompt + let prompt = format_chat_messages(&req.messages); + + // Generate + let response = engine + .generate( + &req.model, + &prompt, + req.max_tokens.unwrap_or(100), + req.temperature.unwrap_or(0.7), + ) + .await?; + + Ok(ChatCompletionResponse { + id, + object: "chat.completion".to_string(), + created, + model: req.model, + choices: vec![ChatChoice { + index: 0, + message: ChatMessage { + role: "assistant".to_string(), + content: response.text, + }, + finish_reason: "stop".to_string(), + }], + usage: Usage { + prompt_tokens: response.prompt_tokens, + completion_tokens: response.completion_tokens, + total_tokens: response.prompt_tokens + response.completion_tokens, + }, + }) +} + +/// Streaming chat completion +async fn chat_completions_stream( + engine: Arc, + req: ChatCompletionRequest, +) -> Sse>> { + let id = format!("chatcmpl-{}", Uuid::new_v4()); + let created = chrono::Utc::now().timestamp(); + let model = req.model.clone(); + + let (tx, rx) = tokio::sync::mpsc::channel(100); + + // Spawn task to generate tokens + tokio::spawn(async move { + let prompt = format_chat_messages(&req.messages); + + // Send initial chunk with role + let initial_chunk = ChatCompletionChunk { + id: id.clone(), + object: "chat.completion.chunk".to_string(), + created, + model: model.clone(), + choices: vec![ChatChoiceDelta { + index: 0, + delta: ChatMessageDelta { + role: Some("assistant".to_string()), + content: None, + }, + finish_reason: None, + }], + }; + + if tx + .send(Ok( + Event::default().data(serde_json::to_string(&initial_chunk).unwrap()) + )) + .await + .is_err() + { + return; + } + + // Stream tokens + match engine + .generate_stream( + &model, + &prompt, + req.max_tokens.unwrap_or(100), + req.temperature.unwrap_or(0.7), + ) + .await + { + Ok(mut stream) => { + while let Some(token) = stream.next().await { + let chunk = ChatCompletionChunk { + id: id.clone(), + object: "chat.completion.chunk".to_string(), + created, + model: model.clone(), + choices: vec![ChatChoiceDelta { + index: 0, + delta: ChatMessageDelta { + role: None, + content: Some(token), + }, + finish_reason: None, + }], + }; + + if tx + .send(Ok( + Event::default().data(serde_json::to_string(&chunk).unwrap()) + )) + .await + .is_err() + { + break; + } + } + } + Err(e) => { + tracing::error!("Error generating stream: {}", e); + return; + } + } + + // Send final chunk + let final_chunk = ChatCompletionChunk { + id: id.clone(), + object: "chat.completion.chunk".to_string(), + created, + model: model.clone(), + choices: vec![ChatChoiceDelta { + index: 0, + delta: ChatMessageDelta { + role: None, + content: None, + }, + finish_reason: Some("stop".to_string()), + }], + }; + + let _ = tx + .send(Ok( + Event::default().data(serde_json::to_string(&final_chunk).unwrap()) + )) + .await; + let _ = tx.send(Ok(Event::default().data("[DONE]"))).await; + }); + + Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default()) +} + +/// Format chat messages into a prompt +fn format_chat_messages(messages: &[ChatMessage]) -> String { + messages + .iter() + .map(|m| { + if m.role == "system" { + format!("System: {}", m.content) + } else if m.role == "user" { + format!("User: {}", m.content) + } else { + format!("Assistant: {}", m.content) + } + }) + .collect::>() + .join("\n") +} diff --git a/src/api/completions.rs b/src/api/completions.rs new file mode 100644 index 0000000..497736b --- /dev/null +++ b/src/api/completions.rs @@ -0,0 +1,113 @@ +use axum::{extract::State, response::IntoResponse, Json}; +use uuid::Uuid; + +use crate::api::routes::AppState; +use crate::api::types::{ + CompletionChoice, CompletionRequest, CompletionResponse, ErrorResponse, Usage, +}; +use crate::backend::InferenceEngine; + +/// Handler for legacy text completions +pub async fn completions( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + let engine = state.engine; + let registry = state.registry; + + // Validate request + let prompt = req.prompt.to_string(); + if prompt.is_empty() { + return ( + axum::http::StatusCode::BAD_REQUEST, + Json(ErrorResponse::new( + "prompt cannot be empty".to_string(), + "invalid_request_error".to_string(), + )), + ) + .into_response(); + } + + // TODO: Implement streaming support for /v1/completions + if req.stream { + return ( + axum::http::StatusCode::BAD_REQUEST, + Json(ErrorResponse::new( + "Streaming not supported for /v1/completions endpoint".to_string(), + "invalid_request_error".to_string(), + )), + ) + .into_response(); + } + + // Validate model exists + match registry.get_model(&req.model) { + Ok(None) => { + return ( + axum::http::StatusCode::NOT_FOUND, + Json(ErrorResponse::new( + format!("Model '{}' not found", req.model), + "model_not_found".to_string(), + )), + ) + .into_response(); + } + Err(e) => { + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new( + format!("Failed to check model: {}", e), + "internal_error".to_string(), + )), + ) + .into_response(); + } + Ok(Some(_)) => { + // Model exists, continue + } + } + + let id = format!("cmpl-{}", Uuid::new_v4()); + let created = chrono::Utc::now().timestamp(); + + // Generate + match engine + .generate( + &req.model, + &prompt, + req.max_tokens.unwrap_or(100), + req.temperature.unwrap_or(0.7), + ) + .await + { + Ok(response) => { + let completion = CompletionResponse { + id, + object: "text_completion".to_string(), + created, + model: req.model, + choices: vec![CompletionChoice { + text: response.text, + index: 0, + logprobs: None, + finish_reason: "stop".to_string(), + }], + usage: Usage { + prompt_tokens: response.prompt_tokens, + completion_tokens: response.completion_tokens, + total_tokens: response.prompt_tokens + response.completion_tokens, + }, + }; + + Json(completion).into_response() + } + Err(e) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new( + e.to_string(), + "internal_error".to_string(), + )), + ) + .into_response(), + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..5de16ff --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,8 @@ +pub mod chat; +pub mod completions; +pub mod models; +pub mod routes; +pub mod types; + +#[cfg(test)] +mod tests; diff --git a/src/api/models.rs b/src/api/models.rs new file mode 100644 index 0000000..c8304e1 --- /dev/null +++ b/src/api/models.rs @@ -0,0 +1,80 @@ +use axum::{ + extract::{Path, State}, + response::IntoResponse, + Json, +}; + +use crate::api::routes::AppState; +use crate::api::types::{ErrorResponse, Model, ModelList}; +use crate::backend::InferenceEngine; + +/// List all available models +pub async fn list_models( + State(state): State>, +) -> impl IntoResponse { + let registry = state.registry; + match registry.load_models(None) { + Ok(models) => { + let model_list = ModelList { + object: "list".to_string(), + data: models + .into_iter() + .map(|m| Model { + id: m.name.clone(), + object: "model".to_string(), + created: chrono::DateTime::parse_from_rfc3339(&m.created_at) + .map(|dt| dt.timestamp()) + .unwrap_or(0), + owned_by: m.author.unwrap_or_else(|| "puma".to_string()), + }) + .collect(), + }; + Json(model_list).into_response() + } + Err(e) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new( + format!("Failed to load models: {}", e), + "internal_error".to_string(), + )), + ) + .into_response(), + } +} + +/// Get a specific model by ID +pub async fn get_model( + State(state): State>, + Path(model_id): Path, +) -> impl IntoResponse { + let registry = state.registry; + match registry.get_model(&model_id) { + Ok(Some(model)) => { + let model_info = Model { + id: model.name.clone(), + object: "model".to_string(), + created: chrono::DateTime::parse_from_rfc3339(&model.created_at) + .map(|dt| dt.timestamp()) + .unwrap_or(0), + owned_by: model.author.unwrap_or_else(|| "puma".to_string()), + }; + Json(model_info).into_response() + } + Ok(None) => ( + axum::http::StatusCode::NOT_FOUND, + Json(ErrorResponse::new( + format!("Model '{}' not found", model_id), + "model_not_found".to_string(), + )), + ) + .into_response(), + Err(e) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new( + format!("Failed to get model: {}", e), + "internal_error".to_string(), + )), + ) + .into_response(), + } +} diff --git a/src/api/routes.rs b/src/api/routes.rs new file mode 100644 index 0000000..dae6c0e --- /dev/null +++ b/src/api/routes.rs @@ -0,0 +1,70 @@ +use axum::{ + routing::{get, post}, + Json, Router, +}; +use serde::Serialize; +use std::sync::Arc; +use tower_http::{ + cors::CorsLayer, + trace::{DefaultMakeSpan, DefaultOnRequest, DefaultOnResponse, TraceLayer}, + LatencyUnit, +}; + +use crate::backend::InferenceEngine; +use crate::registry::model_registry::ModelRegistry; + +use super::{chat, completions, models}; + +/// Shared application state +#[derive(Clone)] +pub struct AppState { + pub engine: Arc, + pub registry: Arc, +} + +/// Create the API router with all endpoints +pub fn create_router( + engine: Arc, + registry: Arc, +) -> Router { + let state = AppState { engine, registry }; + + Router::new() + // Chat completions (most important) + .route("/v1/chat/completions", post(chat::chat_completions::)) + // Legacy completions + .route("/v1/completions", post(completions::completions::)) + // Models + .route("/v1/models", get(models::list_models::)) + .route("/v1/models/:model", get(models::get_model::)) + // Health check + .route("/health", get(health_check)) + // Pass state + .with_state(state) + // Enable request/response logging at INFO level + .layer( + TraceLayer::new_for_http() + .make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO)) + .on_request(DefaultOnRequest::new().level(tracing::Level::INFO)) + .on_response( + DefaultOnResponse::new() + .level(tracing::Level::INFO) + .latency_unit(LatencyUnit::Millis), + ), + ) + // Enable CORS for browser clients + .layer(CorsLayer::permissive()) +} + +/// Health check response +#[derive(Serialize)] +struct HealthResponse { + status: String, +} + +/// Health check endpoint +async fn health_check() -> Json { + Json(HealthResponse { + status: "ok".to_string(), + }) +} diff --git a/src/api/tests.rs b/src/api/tests.rs new file mode 100644 index 0000000..fba05ca --- /dev/null +++ b/src/api/tests.rs @@ -0,0 +1,437 @@ +//! API Integration Tests +//! +//! Tests the PUMA API endpoints using the Axum test utilities. +//! These tests verify the entire request/response cycle through the router. + +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use serde_json::{json, Value}; +use std::sync::Arc; +use tempfile::TempDir; +use tower::util::ServiceExt; // for `oneshot` and `ready` + +use super::routes::create_router; +use crate::backend::mock::MockEngine; +use crate::registry::model_registry::{CacheInfo, ModelInfo, ModelMetadata, ModelRegistry}; + +/// Helper to create test app with a pre-registered test model +/// Returns the router and the temp directory (which must be kept alive) +fn create_test_app() -> (axum::Router, TempDir) { + let engine = Arc::new(MockEngine::new()); + let temp_dir = TempDir::new().unwrap(); + let registry = Arc::new(ModelRegistry::new(Some(temp_dir.path().to_path_buf()))); + + // Register a test model + let test_model = ModelInfo { + uuid: "test-uuid".to_string(), + name: "test-model".to_string(), + provider: "test".to_string(), + author: Some("test-author".to_string()), + task: Some("text-generation".to_string()), + model_series: Some("test-series".to_string()), + license: Some("MIT".to_string()), + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + metadata: ModelMetadata { + cache: CacheInfo { + revision: "test-rev".to_string(), + size: 1000, + path: "/tmp/test-model".to_string(), + }, + context_window: Some(2048), + safetensors: None, + }, + }; + + registry + .register_model(test_model) + .expect("failed to register test model"); + + (create_router(engine, registry), temp_dir) +} + +/// Helper to make a JSON request +async fn make_json_request( + app: axum::Router, + method: &str, + uri: &str, + body: Option, +) -> (StatusCode, Value) { + let mut request = Request::builder().uri(uri).method(method); + + if body.is_some() { + request = request.header("content-type", "application/json"); + } + + let request = if let Some(body) = body { + request.body(Body::from(serde_json::to_vec(&body).unwrap())) + } else { + request.body(Body::empty()) + } + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body).unwrap_or(json!({})); + + // Debug output for failed requests + if !status.is_success() { + eprintln!("Request failed: {} {}", method, uri); + eprintln!("Status: {}", status); + eprintln!( + "Response: {}", + serde_json::to_string_pretty(&json).unwrap_or_default() + ); + } + + (status, json) +} + +#[tokio::test] +async fn test_health_check() { + let (app, _temp_dir) = create_test_app(); + let (status, json) = make_json_request(app, "GET", "/health", None).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "ok"); +} + +#[tokio::test] +async fn test_list_models() { + let (app, _temp_dir) = create_test_app(); + let (status, json) = make_json_request(app, "GET", "/v1/models", None).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["object"], "list"); + assert!(json["data"].is_array()); +} + +#[tokio::test] +async fn test_chat_completion_non_streaming() { + let (app, _temp_dir) = create_test_app(); + let request_body = json!({ + "model": "test-model", + "messages": [ + {"role": "user", "content": "Hello"} + ], + "max_tokens": 50, + "stream": false + }); + + let (status, json) = + make_json_request(app, "POST", "/v1/chat/completions", Some(request_body)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["object"], "chat.completion"); + assert!(json["id"].is_string()); + assert_eq!(json["model"], "test-model"); + assert!(json["choices"].is_array()); + assert_eq!(json["choices"][0]["index"], 0); + assert_eq!(json["choices"][0]["message"]["role"], "assistant"); + assert!(json["choices"][0]["message"]["content"].is_string()); + assert_eq!(json["choices"][0]["finish_reason"], "stop"); + assert!(json["usage"]["prompt_tokens"].is_number()); + assert!(json["usage"]["completion_tokens"].is_number()); + assert!(json["usage"]["total_tokens"].is_number()); +} + +#[tokio::test] +async fn test_chat_completion_empty_messages() { + let (app, _temp_dir) = create_test_app(); + let request_body = json!({ + "model": "test-model", + "messages": [], + "stream": false + }); + + let (status, json) = + make_json_request(app, "POST", "/v1/chat/completions", Some(request_body)).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["error"]["type"], "invalid_request_error"); + assert!(json["error"]["message"] + .as_str() + .unwrap() + .contains("messages cannot be empty")); +} + +#[tokio::test] +async fn test_text_completion() { + let (app, _temp_dir) = create_test_app(); + let request_body = json!({ + "model": "test-model", + "prompt": "Once upon a time", + "max_tokens": 50 + }); + + let (status, json) = + make_json_request(app, "POST", "/v1/completions", Some(request_body)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["object"], "text_completion"); + assert!(json["id"].is_string()); + assert_eq!(json["model"], "test-model"); + assert!(json["choices"].is_array()); + assert_eq!(json["choices"][0]["index"], 0); + assert!(json["choices"][0]["text"].is_string()); + assert_eq!(json["choices"][0]["finish_reason"], "stop"); + assert!(json["usage"]["prompt_tokens"].is_number()); +} + +#[tokio::test] +async fn test_text_completion_empty_prompt() { + let (app, _temp_dir) = create_test_app(); + let request_body = json!({ + "model": "test-model", + "prompt": "" + }); + + let (status, json) = + make_json_request(app, "POST", "/v1/completions", Some(request_body)).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["error"]["type"], "invalid_request_error"); + assert!(json["error"]["message"] + .as_str() + .unwrap() + .contains("prompt cannot be empty")); +} + +#[tokio::test] +async fn test_text_completion_streaming_not_supported() { + let (app, _temp_dir) = create_test_app(); + let request_body = json!({ + "model": "test-model", + "prompt": "Hello world", + "stream": true + }); + + let (status, json) = + make_json_request(app, "POST", "/v1/completions", Some(request_body)).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["error"]["type"], "invalid_request_error"); + assert!(json["error"]["message"] + .as_str() + .unwrap() + .contains("Streaming not supported")); +} + +#[tokio::test] +async fn test_chat_completion_with_system_message() { + let (app, _temp_dir) = create_test_app(); + let request_body = json!({ + "model": "test-model", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello"} + ], + "max_tokens": 50 + }); + + let (status, json) = + make_json_request(app, "POST", "/v1/chat/completions", Some(request_body)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["object"], "chat.completion"); + assert!(json["choices"][0]["message"]["content"].is_string()); +} + +#[tokio::test] +async fn test_chat_completion_with_temperature() { + let (app, _temp_dir) = create_test_app(); + let request_body = json!({ + "model": "test-model", + "messages": [ + {"role": "user", "content": "Hello"} + ], + "temperature": 0.5, + "max_tokens": 50 + }); + + let (status, json) = + make_json_request(app, "POST", "/v1/chat/completions", Some(request_body)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["object"], "chat.completion"); +} + +#[tokio::test] +async fn test_chat_completion_default_values() { + let (app, _temp_dir) = create_test_app(); + let request_body = json!({ + "model": "test-model", + "messages": [ + {"role": "user", "content": "Hello"} + ] + // No max_tokens, temperature, etc. - should use defaults + }); + + let (status, json) = + make_json_request(app, "POST", "/v1/chat/completions", Some(request_body)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["object"], "chat.completion"); +} + +#[tokio::test] +async fn test_cors_headers() { + let (app, _temp_dir) = create_test_app(); + let request = Request::builder() + .uri("/health") + .method("GET") + .header("Origin", "https://example.com") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + // CORS should be permissive + assert!(response + .headers() + .contains_key("access-control-allow-origin")); +} + +#[tokio::test] +async fn test_invalid_route() { + let (app, _temp_dir) = create_test_app(); + let request = Request::builder() + .uri("/invalid/route") + .method("GET") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_method_not_allowed() { + let (app, _temp_dir) = create_test_app(); + // Try POST on GET-only endpoint + let request = Request::builder() + .uri("/health") + .method("POST") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); +} + +#[tokio::test] +async fn test_chat_completion_nonexistent_model() { + let (app, _temp_dir) = create_test_app(); + let request_body = json!({ + "model": "nonexistent-model", + "messages": [ + {"role": "user", "content": "Hello"} + ], + "max_tokens": 50 + }); + + let (status, json) = + make_json_request(app, "POST", "/v1/chat/completions", Some(request_body)).await; + + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(json["error"]["type"], "model_not_found"); + assert!(json["error"]["message"] + .as_str() + .unwrap() + .contains("nonexistent-model")); +} + +#[tokio::test] +async fn test_text_completion_nonexistent_model() { + let (app, _temp_dir) = create_test_app(); + let request_body = json!({ + "model": "nonexistent-model", + "prompt": "Hello world" + }); + + let (status, json) = + make_json_request(app, "POST", "/v1/completions", Some(request_body)).await; + + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(json["error"]["type"], "model_not_found"); + assert!(json["error"]["message"] + .as_str() + .unwrap() + .contains("nonexistent-model")); +} + +#[tokio::test] +async fn test_chat_completion_streaming() { + let (app, _temp_dir) = create_test_app(); + let request_body = json!({ + "model": "test-model", + "messages": [ + {"role": "user", "content": "Hello"} + ], + "max_tokens": 50, + "stream": true + }); + + let request = Request::builder() + .uri("/v1/chat/completions") + .method("POST") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&request_body).unwrap())) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("content-type").unwrap(), + "text/event-stream" + ); + + // Read the streaming body + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body_text = String::from_utf8_lossy(&body_bytes); + + // Parse SSE events (format: "data: {...}\n\n") + let events: Vec<&str> = body_text + .split("\n\n") + .filter(|line| line.starts_with("data: ")) + .map(|line| line.strip_prefix("data: ").unwrap()) + .collect(); + + assert!(!events.is_empty(), "Should have at least one event"); + + // Separate JSON events from [DONE] + let json_events: Vec<&str> = events.iter().filter(|&&e| e != "[DONE]").copied().collect(); + + assert!( + !json_events.is_empty(), + "Should have at least one JSON event" + ); + + // Check first event has role + let first_event: Value = serde_json::from_str(json_events[0]).unwrap(); + assert_eq!(first_event["object"], "chat.completion.chunk"); + assert_eq!(first_event["model"], "test-model"); + assert_eq!(first_event["choices"][0]["delta"]["role"], "assistant"); + + // Check middle events have content + if json_events.len() > 2 { + let middle_event: Value = serde_json::from_str(json_events[1]).unwrap(); + assert!(middle_event["choices"][0]["delta"]["content"].is_string()); + } + + // Check last JSON event has finish_reason + let last_json_event: Value = serde_json::from_str(json_events[json_events.len() - 1]).unwrap(); + assert_eq!(last_json_event["choices"][0]["finish_reason"], "stop"); + + // Check stream ends with [DONE] + assert!(events.contains(&"[DONE]"), "Stream should end with [DONE]"); +} diff --git a/src/api/types/mod.rs b/src/api/types/mod.rs new file mode 100644 index 0000000..6415885 --- /dev/null +++ b/src/api/types/mod.rs @@ -0,0 +1,5 @@ +pub mod request; +pub mod response; + +pub use request::*; +pub use response::*; diff --git a/src/api/types/request.rs b/src/api/types/request.rs new file mode 100644 index 0000000..17e2556 --- /dev/null +++ b/src/api/types/request.rs @@ -0,0 +1,81 @@ +use serde::{Deserialize, Serialize}; + +/// Chat completion request (OpenAI compatible) +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct ChatCompletionRequest { + pub model: String, + pub messages: Vec, + #[serde(default = "default_max_tokens")] + pub max_tokens: Option, + #[serde(default = "default_temperature")] + pub temperature: Option, + #[serde(default)] + pub top_p: Option, + #[serde(default)] + pub n: Option, + #[serde(default)] + pub stream: bool, + pub stop: Option, + #[serde(default)] + pub presence_penalty: Option, + #[serde(default)] + pub frequency_penalty: Option, +} + +/// Chat message +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ChatMessage { + pub role: String, // "system", "user", "assistant" + pub content: String, +} + +/// Legacy text completion request +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct CompletionRequest { + pub model: String, + #[serde(alias = "prompt")] + pub prompt: StringOrArray, + #[serde(default = "default_max_tokens")] + pub max_tokens: Option, + #[serde(default = "default_temperature")] + pub temperature: Option, + #[serde(default)] + pub top_p: Option, + #[serde(default)] + pub n: Option, + #[serde(default)] + pub stream: bool, + pub stop: Option, + #[serde(default)] + pub presence_penalty: Option, + #[serde(default)] + pub frequency_penalty: Option, +} + +/// Prompt can be string or array of strings +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum StringOrArray { + String(String), + Array(Vec), +} + +impl std::fmt::Display for StringOrArray { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StringOrArray::String(s) => write!(f, "{}", s), + StringOrArray::Array(arr) => write!(f, "{}", arr.join("\n")), + } + } +} + +// Default values +fn default_max_tokens() -> Option { + Some(100) +} + +fn default_temperature() -> Option { + Some(0.7) +} diff --git a/src/api/types/response.rs b/src/api/types/response.rs new file mode 100644 index 0000000..462ca03 --- /dev/null +++ b/src/api/types/response.rs @@ -0,0 +1,119 @@ +use serde::{Deserialize, Serialize}; + +use super::request::ChatMessage; + +/// Chat completion response +#[derive(Debug, Serialize)] +pub struct ChatCompletionResponse { + pub id: String, + pub object: String, // "chat.completion" + pub created: i64, + pub model: String, + pub choices: Vec, + pub usage: Usage, +} + +/// Choice in chat completion +#[derive(Debug, Serialize)] +pub struct ChatChoice { + pub index: usize, + pub message: ChatMessage, + pub finish_reason: String, // "stop", "length", "content_filter" +} + +/// Streaming chat completion chunk +#[derive(Debug, Serialize)] +pub struct ChatCompletionChunk { + pub id: String, + pub object: String, // "chat.completion.chunk" + pub created: i64, + pub model: String, + pub choices: Vec, +} + +/// Delta choice for streaming +#[derive(Debug, Serialize)] +pub struct ChatChoiceDelta { + pub index: usize, + pub delta: ChatMessageDelta, + #[serde(skip_serializing_if = "Option::is_none")] + pub finish_reason: Option, +} + +/// Delta message for streaming +#[derive(Debug, Serialize)] +pub struct ChatMessageDelta { + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, +} + +/// Legacy completion response +#[derive(Debug, Serialize)] +pub struct CompletionResponse { + pub id: String, + pub object: String, // "text_completion" + pub created: i64, + pub model: String, + pub choices: Vec, + pub usage: Usage, +} + +/// Choice in completion +#[derive(Debug, Serialize)] +pub struct CompletionChoice { + pub text: String, + pub index: usize, + pub logprobs: Option, + pub finish_reason: String, +} + +/// Token usage statistics +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Usage { + pub prompt_tokens: usize, + pub completion_tokens: usize, + pub total_tokens: usize, +} + +/// Model list response +#[derive(Debug, Serialize)] +pub struct ModelList { + pub object: String, // "list" + pub data: Vec, +} + +/// Model information +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Model { + pub id: String, + pub object: String, // "model" + pub created: i64, + pub owned_by: String, +} + +/// Error response +#[derive(Debug, Serialize)] +pub struct ErrorResponse { + pub error: ErrorDetail, +} + +#[derive(Debug, Serialize)] +pub struct ErrorDetail { + pub message: String, + pub r#type: String, + pub code: Option, +} + +impl ErrorResponse { + pub fn new(message: String, error_type: String) -> Self { + Self { + error: ErrorDetail { + message, + r#type: error_type, + code: None, + }, + } + } +} diff --git a/src/backend/engine.rs b/src/backend/engine.rs new file mode 100644 index 0000000..2d464a5 --- /dev/null +++ b/src/backend/engine.rs @@ -0,0 +1,34 @@ +use std::io; +use std::pin::Pin; +use tokio_stream::Stream; + +/// Inference engine trait +pub trait InferenceEngine: Send + Sync { + /// Generate text completion + fn generate( + &self, + model: &str, + prompt: &str, + max_tokens: usize, + temperature: f32, + ) -> impl std::future::Future> + Send; + + /// Generate text with streaming + fn generate_stream( + &self, + model: &str, + prompt: &str, + max_tokens: usize, + temperature: f32, + ) -> impl std::future::Future< + Output = Result + Send>>, io::Error>, + > + Send; +} + +/// Generation response +#[derive(Debug, Clone)] +pub struct GenerateResponse { + pub text: String, + pub prompt_tokens: usize, + pub completion_tokens: usize, +} diff --git a/src/backend/mlx/README.md b/src/backend/mlx/README.md new file mode 100644 index 0000000..ec3b280 --- /dev/null +++ b/src/backend/mlx/README.md @@ -0,0 +1,96 @@ +# MLX Backend + +This module provides inference support using Apple's MLX framework via [mlx-rs](https://github.com/oxiglade/mlx-rs) bindings. + +## Requirements + +- macOS 13.0 or later +- Apple Silicon (M1/M2/M3/M4) +- Xcode Command Line Tools + +## Installation + +### 1. Install Xcode Command Line Tools + +```bash +xcode-select --install +``` + +### 2. Build PUMA with MLX support + +```bash +# Enable the mlx feature +cargo build --release --features mlx + +# Or install +cargo install --path . --features mlx +``` + +The mlx-rs library will automatically download and compile MLX during the build process. + +## Usage + +```rust +use puma::backend::MlxEngine; + +// Create MLX engine +let engine = MlxEngine::new()?; + +// Generate text +let response = engine.generate( + "model-name", + "Hello, world!", + 100, // max_tokens + 0.7 // temperature +).await?; +``` + +## Architecture + +``` +mlx/ +├── mod.rs # Module exports and feature gating +├── engine.rs # Main inference engine implementation +└── README.md # This file +``` + +## Implementation Status + +### Phase 1: mlx-rs Integration (Current) +- ✅ Module structure +- ✅ Feature gating (macOS + Apple Silicon only) +- ✅ mlx-rs dependency integration (v0.25.3) +- ✅ Basic engine trait implementation +- ✅ Device initialization (GPU/CPU) +- ⚠️ Placeholder tokenization/generation (functional but not production-ready) + +### Phase 2: TODO +- [ ] Model loading from PUMA cache +- [ ] Proper tokenizer integration (tokenizers-rs) +- [ ] Actual token generation with mlx-rs +- [ ] Streaming support +- [ ] Model caching and memory management +- [ ] Multi-model support +- [ ] Quantization support (4-bit, 8-bit) + +### Phase 3: Custom Implementation (Future) +- [ ] Replace mlx-rs with custom mlx-sys FFI layer +- [ ] Build custom safe Rust wrapper +- [ ] Fine-grained control over MLX operations + +## Architecture + +``` +Current: +PUMA → MlxEngine → mlx-rs (v0.25.3) → mlx-sys → MLX C++ library + +Future: +PUMA → MlxEngine → puma-mlx (custom) → puma-mlx-sys → MLX C++ library +``` + +## References + +- [MLX GitHub](https://github.com/ml-explore/mlx) +- [mlx-rs bindings](https://github.com/oxiglade/mlx-rs) (currently used) +- [mlx-rs documentation](https://oxideai.github.io/mlx-rs/mlx_rs/) +- [MLX Python API](https://ml-explore.github.io/mlx/build/html/index.html) diff --git a/src/backend/mlx/engine.rs b/src/backend/mlx/engine.rs new file mode 100644 index 0000000..21ccf0b --- /dev/null +++ b/src/backend/mlx/engine.rs @@ -0,0 +1,185 @@ +use crate::backend::engine::{GenerateResponse, InferenceEngine}; +use mlx_rs::{Array, Device, Dtype}; +use std::io; +use std::pin::Pin; +use std::sync::Arc; +use tokio_stream::Stream; +use tracing::{debug, info, warn}; + +/// MLX inference engine for Apple Silicon +#[derive(Clone)] +pub struct MlxEngine { + _device: Device, + + // Model cache: model_name -> loaded model + // TODO: Add actual model loading and caching + _model_cache: Arc>>, +} + +impl MlxEngine { + /// Create a new MLX engine + pub fn new() -> Result { + info!("Initializing MLX inference engine"); + + // Set default device to GPU if available + let device = Device::gpu(); + info!("MLX engine initialized with device: {:?}", device); + + Ok(Self { + _device: device, + _model_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())), + }) + } + + /// Load a model from the cache + fn load_model(&self, model_path: &str) -> Result<(), String> { + debug!("Loading model from: {}", model_path); + // TODO: Implement actual model loading with mlx-rs + // This will involve: + // 1. Loading safetensors/weights + // 2. Constructing model architecture + // 3. Caching the model in _model_cache + + if !std::path::Path::new(model_path).exists() { + return Err(format!("Model path not found: {}", model_path)); + } + + warn!("Model loading not yet implemented - using placeholder"); + Ok(()) + } + + /// Tokenize input text + fn tokenize(&self, text: &str) -> Result { + debug!("Tokenizing text: {} chars", text.len()); + // TODO: Integrate proper tokenizer (e.g., tokenizers-rs) + // For now, create dummy token array + let dummy_tokens: Vec = text.chars().take(10).map(|c| c as i32 % 1000).collect(); + let array = Array::from_slice(&dummy_tokens, &[dummy_tokens.len() as i32]); + Ok(array) + } + + /// Generate tokens using MLX + fn generate_tokens( + &self, + input_tokens: &Array, + max_tokens: usize, + temperature: f32, + ) -> Result { + debug!( + "Generating {} tokens with temperature {}", + max_tokens, temperature + ); + + // TODO: Implement actual generation loop: + // 1. Forward pass through model + // 2. Sample from output distribution with temperature + // 3. Append to sequence + // 4. Repeat until max_tokens or EOS + + warn!("Token generation not yet implemented - returning placeholder"); + + // Return dummy output for now + Ok(input_tokens.clone()) + } + + /// Detokenize output tokens + fn detokenize(&self, tokens: &Array) -> Result { + debug!("Detokenizing array with shape: {:?}", tokens.shape()); + // TODO: Implement actual detokenization + // For now, return placeholder + Ok(format!("MLX generated text (shape: {:?})", tokens.shape())) + } +} + +impl InferenceEngine for MlxEngine { + async fn generate( + &self, + model: &str, + prompt: &str, + max_tokens: usize, + temperature: f32, + ) -> Result { + info!("Generating completion for model: {}", model); + debug!( + "Prompt: {} chars, max_tokens: {}, temp: {}", + prompt.len(), + max_tokens, + temperature + ); + + // 1. Load model if not cached + // TODO: Get actual model path from registry + if let Err(e) = self.load_model("placeholder") { + warn!("Model loading failed: {}", e); + } + + // 2. Tokenize prompt + let input_tokens = self + .tokenize(prompt) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + + // 3. Generate tokens using MLX + let output_tokens = self + .generate_tokens(&input_tokens, max_tokens, temperature) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + + // 4. Detokenize output + let text = self + .detokenize(&output_tokens) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + Ok(GenerateResponse { + text, + prompt_tokens: prompt.split_whitespace().count(), + completion_tokens: max_tokens.min(20), + }) + } + + async fn generate_stream( + &self, + model: &str, + prompt: &str, + max_tokens: usize, + temperature: f32, + ) -> Result + Send>>, io::Error> { + info!("Starting streaming generation for model: {}", model); + debug!( + "Prompt: {} chars, max_tokens: {}, temperature: {}", + prompt.len(), + max_tokens, + temperature + ); + + // Create a stream that yields tokens incrementally + let stream = tokio_stream::iter(vec![ + "MLX ".to_string(), + "streaming ".to_string(), + "response ".to_string(), + "placeholder".to_string(), + ]); + + Ok(Box::pin(stream)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mlx_engine_creation() { + let engine = MlxEngine::new(); + assert!(engine.is_ok(), "MLX engine should initialize on Apple Silicon"); + } + + #[tokio::test] + async fn test_mlx_generate() { + let engine = MlxEngine::new().unwrap(); + let result = engine + .generate("test-model", "Hello world", 50, 0.7) + .await; + assert!(result.is_ok()); + let response = result.unwrap(); + assert!(response.prompt_tokens > 0); + } +} diff --git a/src/backend/mlx/mod.rs b/src/backend/mlx/mod.rs new file mode 100644 index 0000000..015bb83 --- /dev/null +++ b/src/backend/mlx/mod.rs @@ -0,0 +1,8 @@ +//! MLX backend for Apple Silicon inference +//! +//! This module provides inference using Apple's MLX framework via mlx-rs. +//! Only available on macOS with Apple Silicon when built with --features mlx. + +mod engine; + +pub use engine::MlxEngine; diff --git a/src/backend/mock.rs b/src/backend/mock.rs new file mode 100644 index 0000000..92f056e --- /dev/null +++ b/src/backend/mock.rs @@ -0,0 +1,74 @@ +use futures::stream::{self, StreamExt}; +use std::io; +use std::pin::Pin; +use tokio_stream::Stream; + +use super::engine::{GenerateResponse, InferenceEngine}; + +/// Mock engine for testing (replace with MLX later) +#[derive(Clone)] +pub struct MockEngine; + +impl MockEngine { + pub fn new() -> Self { + Self + } +} + +impl InferenceEngine for MockEngine { + async fn generate( + &self, + model: &str, + prompt: &str, + max_tokens: usize, + _temperature: f32, + ) -> Result { + // Mock response for testing + let response_text = format!( + "This is a mock response from model '{}' for prompt: '{}' (max_tokens: {})", + model, + prompt.chars().take(50).collect::(), + max_tokens + ); + + Ok(GenerateResponse { + text: response_text, + prompt_tokens: prompt.split_whitespace().count(), + completion_tokens: 20, + }) + } + + async fn generate_stream( + &self, + model: &str, + _prompt: &str, + max_tokens: usize, + _temperature: f32, + ) -> Result + Send>>, io::Error> { + // Mock streaming response + let tokens = vec![ + "This ".to_string(), + "is ".to_string(), + "a ".to_string(), + "mock ".to_string(), + "streaming ".to_string(), + "response ".to_string(), + format!("from model '{}' ", model), + format!("(max_tokens: {}).", max_tokens), + ]; + + // Simulate delay between tokens + let stream = stream::iter(tokens).then(|token| async move { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + token + }); + + Ok(Box::pin(stream)) + } +} + +impl Default for MockEngine { + fn default() -> Self { + Self::new() + } +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs new file mode 100644 index 0000000..ba0a865 --- /dev/null +++ b/src/backend/mod.rs @@ -0,0 +1,11 @@ +pub mod engine; +pub mod mock; + +pub use engine::*; +pub use mock::MockEngine; + +#[cfg(all(target_os = "macos", feature = "mlx"))] +pub mod mlx; + +#[cfg(all(target_os = "macos", feature = "mlx"))] +pub use mlx::MlxEngine; diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 7e9a6a7..651d88f 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -1,6 +1,13 @@ use clap::{Parser, Subcommand}; use prettytable::{format, row, Table}; +use crate::cli::{inspect, ls, rm}; +use crate::downloader::downloader::Downloader; +use crate::downloader::huggingface::HuggingFaceDownloader; +use crate::registry::model_registry::ModelRegistry; +use crate::system::system_info::SystemInfo; +use crate::utils::format::{format_size_decimal, format_time_ago}; + #[derive(Parser)] #[command(name = "PUMA")] #[command(about = "PUMA CLI")] @@ -10,11 +17,12 @@ pub struct Cli { } #[derive(Subcommand)] +#[allow(clippy::upper_case_acronyms)] enum Commands { /// List running models PS, /// List local models - LS, + LS(LsArgs), /// Download a model from a model provider PULL(PullArgs), /// Create and run a new model @@ -22,41 +30,87 @@ enum Commands { /// Stop one running model STOP, /// Remove one model - RM, + RM(RmArgs), /// Display system-wide information INFO, /// Return detailed information about a model - INSPECT, + INSPECT(InspectArgs), /// Returns the version of PUMA. VERSION, + /// Start the inference server + SERVE(ServeArgs), +} + +#[derive(Parser)] +struct ServeArgs { + /// Model name to serve (e.g., inftyai/tiny-random-gpt2) + model: String, + + /// Host address to bind to + #[arg(long, default_value = "0.0.0.0")] + host: String, + + /// Port to listen on + #[arg(short, long, default_value = "8000")] + port: u16, +} + +#[derive(Parser)] +struct LsArgs { + /// Optional model name pattern to filter (e.g., qwen, openai/*) + pattern: Option, + + /// Advanced filter using SQL WHERE conditions (e.g., author=inftyai,license=mit) + #[arg(short = 'l', long, value_name = "KEY=VALUE,...")] + query: Option, } #[derive(Parser)] struct PullArgs { - #[arg(long, value_name = "model name")] + /// Model name to download (e.g., inftyai/tiny-random-gpt2) model: String, - #[arg(long, value_name = "model provider", value_enum)] + #[arg( + short = 'p', + long, + value_name = "model provider", + value_enum, + default_value = "huggingface" + )] provider: Provider, } -#[derive(Debug, Clone, clap::ValueEnum)] +#[derive(Parser)] +struct RmArgs { + /// Model name to remove (e.g., inftyai/tiny-random-gpt2) + model: String, +} + +#[derive(Parser)] +struct InspectArgs { + /// Model name to inspect (e.g., inftyai/tiny-random-gpt2) + model: String, +} + +#[derive(Debug, Clone, Default, clap::ValueEnum)] pub enum Provider { + #[default] + #[value(alias = "hf")] Huggingface, + #[value(alias = "ms")] Modelscope, } -impl Default for Provider { - fn default() -> Self { - Provider::Huggingface - } -} - // Support commands like: pull, ls, run, ps, stop, rm, info, inspect, show. pub async fn run(cli: Cli) { match cli.command { Commands::PS => { let mut table = Table::new(); - table.set_format(*format::consts::FORMAT_CLEAN); + table.set_format( + format::FormatBuilder::new() + .column_separator(' ') + .padding(0, 1) + .build(), + ); table.add_row(row!["NAME", "PROVIDER", "MODEL", "STATUS", "AGE"]); table.add_row(row![ "deepseek-r1", @@ -69,23 +123,62 @@ pub async fn run(cli: Cli) { table.printstd(); } - Commands::LS => { + Commands::LS(args) => { + let registry = ModelRegistry::new(None); + + let models = + match ls::execute(®istry, args.pattern.as_deref(), args.query.as_deref()) { + Ok(models) => models, + Err(e) => { + eprintln!("{}", e); + std::process::exit(1); + } + }; + let mut table = Table::new(); - table.set_format(*format::consts::FORMAT_CLEAN); - table.add_row(row!["MODEl", "PROVIDER", "REVISION", "SIZE", "CREATED"]); + table.set_format( + format::FormatBuilder::new() + .column_separator(' ') + .padding(0, 1) + .build(), + ); table.add_row(row![ - "deepseek-ai/DeepSeek-R1", - "huggingface", - "main", - "80GB", - "2 weeks ago" + "MODEL", "TASK", "PROVIDER", "REVISION", "SIZE", "CREATED" ]); + for model in models { + let size_str = format_size_decimal(model.metadata.cache.size); + + let revision_short = if model.metadata.cache.revision.len() > 8 { + &model.metadata.cache.revision[..8] + } else { + &model.metadata.cache.revision + }; + + let created_str = format_time_ago(&model.created_at); + + let model_task = model.task.as_deref().unwrap_or("N/A"); + + table.add_row(row![ + model.name, + model_task, + model.provider, + revision_short, + size_str, + created_str + ]); + } + table.printstd(); } Commands::PULL(args) => match args.provider { Provider::Huggingface => { - println!("Downloading model from Huggingface..."); + let downloader = HuggingFaceDownloader::new(); + // Make sure to use lowercase for model name to ensure consistent caching and registry entries. + if let Err(e) = downloader.download_model(&args.model.to_lowercase()).await { + eprintln!("❌ Error downloading model: {}", e); + std::process::exit(1); + } } Provider::Modelscope => { println!("Downloading model from Modelscope..."); @@ -100,20 +193,277 @@ pub async fn run(cli: Cli) { println!("Stopping one running model..."); } - Commands::RM => { - println!("Removing one model..."); + Commands::RM(args) => { + let registry = ModelRegistry::new(None); + + if let Err(e) = rm::execute(®istry, &args.model) { + eprintln!("{}", e); + std::process::exit(1); + } } Commands::INFO => { - println!("Displaying system-wide information..."); + let info = SystemInfo::collect(); + info.display(); } - Commands::INSPECT => { - println!("Returning detailed information about model..."); + Commands::INSPECT(args) => { + let registry = ModelRegistry::new(None); + + match inspect::execute(®istry, &args.model) { + Ok(model) => inspect::display(&model), + Err(e) => { + eprintln!("{}", e); + std::process::exit(1); + } + } } Commands::VERSION => { println!("PUMA {}", env!("CARGO_PKG_VERSION")); } + + Commands::SERVE(args) => { + // Verify model exists + let registry = ModelRegistry::new(None); + match registry.get_model(&args.model) { + Ok(Some(_)) => { + // Model exists, proceed + } + Ok(None) => { + eprintln!("❌ Error: Model '{}' not found in registry", args.model); + eprintln!("Run 'puma pull {}' to download it first", args.model); + std::process::exit(1); + } + Err(e) => { + eprintln!("❌ Error checking model: {}", e); + std::process::exit(1); + } + } + + if let Err(e) = crate::cli::serve::execute(&args.host, args.port, &args.model).await { + eprintln!("Error starting server: {}", e); + std::process::exit(1); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::model_registry::{CacheInfo, ModelInfo, ModelMetadata}; + use tempfile::TempDir; + + // Helper to create a test model + fn create_test_model(name: &str, revision: &str) -> ModelInfo { + let safetensors = serde_json::json!({ + "parameters": { + "F32": 7000000000u64 + }, + "total": 7000000000u64 + }); + + ModelInfo { + uuid: revision.to_string(), + name: name.to_string(), + provider: "huggingface".to_string(), + author: Some("test-author".to_string()), + task: Some("text-generation".to_string()), + model_series: Some("gpt2".to_string()), + license: Some("mit".to_string()), + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + metadata: ModelMetadata { + cache: CacheInfo { + revision: revision.to_string(), + size: 1000, + path: "/tmp/test".to_string(), + }, + context_window: Some(2048), + safetensors: Some(safetensors), + }, + } + } + + #[test] + fn test_ls_command_empty() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let models = registry.load_models(None).unwrap_or_default(); + assert_eq!(models.len(), 0); + } + + #[test] + fn test_ls_command_with_models() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let model = create_test_model("test/model", "abc123def456"); + + registry.register_model(model).unwrap(); + + let models = registry.load_models(None).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].name, "test/model"); + assert_eq!(models[0].provider, "huggingface"); + } + + #[test] + fn test_inspect_command_with_metadata() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let mut model = create_test_model("test/gpt-model", "abc123def456"); + model.author = Some("test-org".to_string()); + model.task = Some("text-generation".to_string()); + model.license = Some("mit".to_string()); + model.updated_at = "2025-01-02T00:00:00Z".to_string(); + + registry.register_model(model.clone()).unwrap(); + + let retrieved = registry.get_model("test/gpt-model").unwrap(); + assert!(retrieved.is_some()); + + let model_info = retrieved.unwrap(); + assert_eq!(model_info.name, "test/gpt-model"); + assert_eq!(model_info.created_at, "2025-01-01T00:00:00Z"); + assert_eq!(model_info.updated_at, "2025-01-02T00:00:00Z"); + assert_eq!(model_info.author, Some("test-org".to_string())); + assert_eq!(model_info.task, Some("text-generation".to_string())); + assert_eq!(model_info.license, Some("mit".to_string())); + assert_eq!(model_info.model_series, Some("gpt2".to_string())); + assert_eq!(model_info.metadata.context_window, Some(2048)); + assert_eq!( + model_info + .metadata + .safetensors + .as_ref() + .unwrap() + .get("total") + .unwrap() + .as_u64() + .unwrap(), + 7_000_000_000 + ); + } + + #[test] + fn test_inspect_command_without_architecture() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let mut model = create_test_model("test/simple-model", "xyz789"); + model.metadata.safetensors = None; + model.metadata.context_window = None; + + registry.register_model(model).unwrap(); + + let retrieved = registry.get_model("test/simple-model").unwrap(); + assert!(retrieved.is_some()); + + let model_info = retrieved.unwrap(); + assert_eq!(model_info.name, "test/simple-model"); + assert!(model_info.metadata.safetensors.is_none()); + } + + #[test] + fn test_revision_truncation() { + let long_revision = "abc123def456ghi789jkl012"; + let short = if long_revision.len() > 8 { + &long_revision[..8] + } else { + long_revision + }; + assert_eq!(short, "abc123de"); + + let short_revision = "abc123"; + let short = if short_revision.len() > 8 { + &short_revision[..8] + } else { + short_revision + }; + assert_eq!(short, "abc123"); + } + + #[test] + fn test_metadata_timestamps_differ() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let model = create_test_model("test/updated-model", "v1"); + registry.register_model(model).unwrap(); + + // Update the model + let mut updated_model = create_test_model("test/updated-model", "v2"); + updated_model.metadata.cache.size = 2000; + updated_model.created_at = "2025-01-05T00:00:00Z".to_string(); + updated_model.updated_at = "2025-01-05T00:00:00Z".to_string(); + + registry.register_model(updated_model).unwrap(); + + let result = registry.get_model("test/updated-model").unwrap().unwrap(); + // created_at should remain the same + assert_eq!(result.created_at, "2025-01-01T00:00:00Z"); + // updated_at should be new + assert_eq!(result.updated_at, "2025-01-05T00:00:00Z"); + // Other fields should be updated + assert_eq!(result.metadata.cache.revision, "v2"); + assert_eq!(result.metadata.cache.size, 2000); + } + + #[test] + fn test_serve_with_existing_model() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let model = create_test_model("test/serve-model", "abc123"); + registry.register_model(model).unwrap(); + + // Verify model exists (this is what serve command checks) + let result = registry.get_model("test/serve-model"); + assert!(result.is_ok()); + assert!(result.unwrap().is_some()); + } + + #[test] + fn test_serve_with_nonexistent_model() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + // Verify model doesn't exist + let result = registry.get_model("nonexistent/model"); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_serve_args_parsing() { + // Test that ServeArgs requires model argument + use clap::CommandFactory; + let app = Cli::command(); + + // This should fail without model argument + let result = app.clone().try_get_matches_from(vec!["puma", "serve"]); + assert!(result.is_err()); + + // This should succeed with model argument + let result = app + .clone() + .try_get_matches_from(vec!["puma", "serve", "test/model"]); + assert!(result.is_ok()); + + // This should succeed with model and optional args + let result = app.try_get_matches_from(vec![ + "puma", + "serve", + "test/model", + "--host", + "127.0.0.1", + "--port", + "9000", + ]); + assert!(result.is_ok()); } } diff --git a/src/cli/inspect.rs b/src/cli/inspect.rs new file mode 100644 index 0000000..10ec8a5 --- /dev/null +++ b/src/cli/inspect.rs @@ -0,0 +1,160 @@ +use crate::registry::model_registry::{ModelInfo, ModelRegistry}; +use crate::utils::format::{format_parameters, format_size_decimal, format_time_ago}; + +/// Execute the INSPECT command logic +pub fn execute(registry: &ModelRegistry, model_name: &str) -> Result { + match registry.get_model(model_name) { + Ok(Some(model)) => Ok(model), + Ok(None) => Err(format!("Model not found: {}", model_name)), + Err(e) => Err(format!("Failed to load registry: {}", e)), + } +} + +/// Display the model information +pub fn display(model: &ModelInfo) { + println!("name: {}", model.name); + println!("kind: Model"); + println!("spec:"); + println!( + " author: {}", + model.author.as_deref().unwrap_or("N/A") + ); + println!( + " model_series: {}", + model.model_series.as_deref().unwrap_or("N/A") + ); + println!( + " task: {}", + model.task.as_deref().unwrap_or("N/A") + ); + println!( + " license: {}", + model + .license + .as_ref() + .map(|s| s.to_uppercase()) + .unwrap_or_else(|| "N/A".to_string()) + ); + println!( + " context_window: {}", + model + .metadata + .context_window + .map(|w| format_parameters(w as u64)) + .unwrap_or_else(|| "N/A".to_string()) + ); + + if let Some(st) = &model.metadata.safetensors { + println!(" safetensors:"); + if let Some(total) = st.get("total").and_then(|v| v.as_u64()) { + println!(" total: {}", format_parameters(total)); + } + if let Some(params) = st.get("parameters").and_then(|v| v.as_object()) { + println!(" parameters:"); + for (dtype, count) in params { + if let Some(num) = count.as_u64() { + println!( + " {:<12} {}", + format!("{}:", dtype.to_lowercase()), + format_parameters(num) + ); + } + } + } + } else { + println!(" safetensors: N/A"); + } + + println!(" provider: {}", model.provider); + // Cache section + println!(" cache:"); + println!(" revision: {}", model.metadata.cache.revision); + println!( + " size: {}", + format_size_decimal(model.metadata.cache.size) + ); + println!(" path: {}", model.metadata.cache.path); + println!("status:"); + println!(" created: {}", format_time_ago(&model.created_at)); + println!(" updated: {}", format_time_ago(&model.updated_at)); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::model_registry::{CacheInfo, ModelInfo, ModelMetadata}; + use tempfile::TempDir; + + fn create_test_model(name: &str, uuid: &str) -> ModelInfo { + let safetensors = serde_json::json!({ + "parameters": {"F32": 7000000000u64}, + "total": 7000000000u64 + }); + + ModelInfo { + uuid: uuid.to_string(), + name: name.to_string(), + provider: "huggingface".to_string(), + author: Some("test-author".to_string()), + task: Some("text-generation".to_string()), + model_series: Some("gpt2".to_string()), + license: Some("mit".to_string()), + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + metadata: ModelMetadata { + cache: CacheInfo { + revision: uuid.to_string(), + size: 1000, + path: "/tmp/test".to_string(), + }, + context_window: Some(2048), + safetensors: Some(safetensors), + }, + } + } + + #[test] + fn test_execute_inspect() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let model = create_test_model("inftyai/test-model", "abc123"); + registry.register_model(model).unwrap(); + + let result = execute(®istry, "inftyai/test-model"); + assert!(result.is_ok()); + + let model_info = result.unwrap(); + assert_eq!(model_info.name, "inftyai/test-model"); + assert_eq!(model_info.provider, "huggingface"); + } + + #[test] + fn test_execute_inspect_nonexistent() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let result = execute(®istry, "nonexistent/model"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Model not found")); + } + + #[test] + fn test_execute_inspect_case_insensitive() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let model = create_test_model("InftyAI/TestModel", "abc123"); + registry.register_model(model).unwrap(); + + // Can query with different cases + let result = execute(®istry, "InftyAI/TestModel"); + assert!(result.is_ok()); + + let result = execute(®istry, "inftyai/testmodel"); + assert!(result.is_ok()); + + let result = execute(®istry, "INFTYAI/TESTMODEL"); + assert!(result.is_ok()); + } +} diff --git a/src/cli/ls.rs b/src/cli/ls.rs new file mode 100644 index 0000000..21f32e3 --- /dev/null +++ b/src/cli/ls.rs @@ -0,0 +1,173 @@ +use crate::registry::model_registry::{ModelInfo, ModelRegistry}; +use std::collections::HashMap; + +/// Execute the LS command logic +pub fn execute( + registry: &ModelRegistry, + pattern: Option<&str>, + query: Option<&str>, +) -> Result, String> { + // Parse query filters if provided + let mut query_filters = HashMap::new(); + if let Some(query_str) = query { + for pair in query_str.split(',') { + if let Some((key, value)) = pair.split_once('=') { + query_filters.insert(key.trim().to_string(), value.trim().to_string()); + } else { + return Err(format!( + "Invalid query format: {}. Expected key=value pairs separated by commas.", + pair + )); + } + } + } + + // Load models with optional SQL filters + let filter_ref = if query_filters.is_empty() { + None + } else { + Some(&query_filters) + }; + + let mut models = registry + .load_models(filter_ref) + .map_err(|e| format!("Failed to query models: {}", e))?; + + // Filter models by name pattern if provided (supports regex) + if let Some(pattern_str) = pattern { + let pattern_lower = pattern_str.to_lowercase(); + match regex::Regex::new(&pattern_lower) { + Ok(re) => { + models.retain(|model| re.is_match(&model.name)); + } + Err(e) => { + return Err(format!("Invalid regex pattern '{}': {}", pattern_str, e)); + } + } + } + + Ok(models) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::model_registry::{CacheInfo, ModelInfo, ModelMetadata}; + use tempfile::TempDir; + + fn create_test_model(name: &str, uuid: &str, author: &str) -> ModelInfo { + let safetensors = serde_json::json!({ + "parameters": {"F32": 7000000000u64}, + "total": 7000000000u64 + }); + + ModelInfo { + uuid: uuid.to_string(), + name: name.to_string(), + provider: "huggingface".to_string(), + author: Some(author.to_string()), + task: Some("text-generation".to_string()), + model_series: Some("gpt2".to_string()), + license: Some("mit".to_string()), + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + metadata: ModelMetadata { + cache: CacheInfo { + revision: uuid.to_string(), + size: 1000, + path: "/tmp/test".to_string(), + }, + context_window: Some(2048), + safetensors: Some(safetensors), + }, + } + } + + #[test] + fn test_execute_ls_substring() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + registry + .register_model(create_test_model("inftyai/model1", "uuid1", "inftyai")) + .unwrap(); + registry + .register_model(create_test_model("openai/gpt2", "uuid2", "openai")) + .unwrap(); + registry + .register_model(create_test_model("inftyai/model2", "uuid3", "inftyai")) + .unwrap(); + + let models = execute(®istry, Some("inftyai"), None).unwrap(); + assert_eq!(models.len(), 2); + assert!(models.iter().all(|m| m.name.contains("inftyai"))); + } + + #[test] + fn test_execute_ls_prefix() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + registry + .register_model(create_test_model("inftyai/model1", "uuid1", "inftyai")) + .unwrap(); + registry + .register_model(create_test_model("openai/gpt2", "uuid2", "openai")) + .unwrap(); + + let models = execute(®istry, Some("^inftyai/"), None).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].name, "inftyai/model1"); + } + + #[test] + fn test_execute_ls_case_insensitive() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + registry + .register_model(create_test_model("InftyAI/Model1", "uuid1", "InftyAI")) + .unwrap(); + + let models = execute(®istry, Some("InftyAI"), None).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].name, "inftyai/model1"); + } + + #[test] + fn test_execute_ls_sql_filter() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + registry + .register_model(create_test_model("inftyai/model1", "uuid1", "inftyai")) + .unwrap(); + registry + .register_model(create_test_model("openai/gpt2", "uuid2", "openai")) + .unwrap(); + + let models = execute(®istry, None, Some("author=inftyai")).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].name, "inftyai/model1"); + } + + #[test] + fn test_execute_ls_pattern_and_filter() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + registry + .register_model(create_test_model("inftyai/llama-2", "uuid1", "inftyai")) + .unwrap(); + registry + .register_model(create_test_model("inftyai/gpt2", "uuid2", "inftyai")) + .unwrap(); + registry + .register_model(create_test_model("openai/llama-2", "uuid3", "openai")) + .unwrap(); + + let models = execute(®istry, Some("llama"), Some("author=inftyai")).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].name, "inftyai/llama-2"); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 82b6da3..fbfc92f 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1 +1,5 @@ pub mod commands; +pub mod inspect; +pub mod ls; +pub mod rm; +pub mod serve; diff --git a/src/cli/rm.rs b/src/cli/rm.rs new file mode 100644 index 0000000..e8800f1 --- /dev/null +++ b/src/cli/rm.rs @@ -0,0 +1,80 @@ +use crate::registry::model_registry::ModelRegistry; + +/// Execute the RM command logic +pub fn execute(registry: &ModelRegistry, model_name: &str) -> Result<(), String> { + match registry.get_model(model_name) { + Ok(Some(_)) => registry + .remove_model(model_name) + .map_err(|e| format!("Failed to remove model: {}", e)), + Ok(None) => Err(format!("Model not found: {}", model_name)), + Err(e) => Err(format!("Failed to load registry: {}", e)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::model_registry::{CacheInfo, ModelInfo, ModelMetadata}; + use tempfile::TempDir; + + fn create_test_model(name: &str, uuid: &str) -> ModelInfo { + let safetensors = serde_json::json!({ + "parameters": {"F32": 7000000000u64}, + "total": 7000000000u64 + }); + + ModelInfo { + uuid: uuid.to_string(), + name: name.to_string(), + provider: "huggingface".to_string(), + author: Some("test-author".to_string()), + task: Some("text-generation".to_string()), + model_series: Some("gpt2".to_string()), + license: Some("mit".to_string()), + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + metadata: ModelMetadata { + cache: CacheInfo { + revision: uuid.to_string(), + size: 1000, + path: "/tmp/test".to_string(), + }, + context_window: Some(2048), + safetensors: Some(safetensors), + }, + } + } + + #[test] + fn test_execute_rm() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let cache_dir = temp_dir.path().join("cache"); + std::fs::create_dir_all(&cache_dir).unwrap(); + std::fs::write(cache_dir.join("model.safetensors"), "fake data").unwrap(); + + let mut model = create_test_model("test/remove-model", "abc123"); + model.metadata.cache.path = cache_dir.to_string_lossy().to_string(); + + registry.register_model(model).unwrap(); + assert!(registry.get_model("test/remove-model").unwrap().is_some()); + assert!(cache_dir.exists()); + + let result = execute(®istry, "test/remove-model"); + assert!(result.is_ok()); + + assert!(registry.get_model("test/remove-model").unwrap().is_none()); + assert!(!cache_dir.exists()); + } + + #[test] + fn test_execute_rm_nonexistent() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let result = execute(®istry, "nonexistent/model"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Model not found")); + } +} diff --git a/src/cli/serve.rs b/src/cli/serve.rs new file mode 100644 index 0000000..c7603f1 --- /dev/null +++ b/src/cli/serve.rs @@ -0,0 +1,137 @@ +use colored::Colorize; +use std::sync::Arc; +use tracing::{debug, info}; + +use crate::api::routes::create_router; +use crate::backend::mock::MockEngine; +use crate::registry::model_registry::ModelRegistry; + +#[cfg(all(target_os = "macos", feature = "mlx"))] +use crate::backend::mlx::MlxEngine; + +/// Inference engine enum to support multiple backends +#[derive(Clone)] +pub enum Engine { + Mock(MockEngine), + #[cfg(all(target_os = "macos", feature = "mlx"))] + Mlx(MlxEngine), +} + +impl crate::backend::engine::InferenceEngine for Engine { + async fn generate( + &self, + model: &str, + prompt: &str, + max_tokens: usize, + temperature: f32, + ) -> Result { + match self { + Engine::Mock(engine) => engine.generate(model, prompt, max_tokens, temperature).await, + #[cfg(all(target_os = "macos", feature = "mlx"))] + Engine::Mlx(engine) => engine.generate(model, prompt, max_tokens, temperature).await, + } + } + + async fn generate_stream( + &self, + model: &str, + prompt: &str, + max_tokens: usize, + temperature: f32, + ) -> Result + Send>>, std::io::Error> + { + match self { + Engine::Mock(engine) => { + engine + .generate_stream(model, prompt, max_tokens, temperature) + .await + } + #[cfg(all(target_os = "macos", feature = "mlx"))] + Engine::Mlx(engine) => { + engine + .generate_stream(model, prompt, max_tokens, temperature) + .await + } + } + } +} + +/// Initialize the inference engine based on available features and platform +fn initialize_engine() -> Arc { + #[cfg(all(target_os = "macos", not(feature = "mlx")))] + debug!("Build with --features mlx on Apple Silicon for MLX support"); + + #[cfg(all(target_os = "macos", feature = "mlx"))] + { + info!("Initializing MLX inference engine"); + match MlxEngine::new() { + Ok(mlx) => { + info!("Inference engine initialized: MLX"); + return Arc::new(Engine::Mlx(mlx)); + } + Err(e) => { + tracing::warn!( + "Failed to initialize MLX engine: {}, falling back to MockEngine", + e + ); + } + } + } + + // Fallback to MockEngine + info!("Inference engine initialized: MockEngine"); + Arc::new(Engine::Mock(MockEngine::new())) +} + +/// Execute the serve command +pub async fn execute( + host: &str, + port: u16, + model_name: &str, +) -> Result<(), Box> { + println!( + "{}", + " + ███████████ █████ █████ ██████ ██████ █████████ +░░███░░░░░███░░███ ░░███ ░░██████ ██████ ███░░░░░███ + ░███ ░███ ░███ ░███ ░███░█████░███ ░███ ░███ + ░██████████ ░███ ░███ ░███░░███ ░███ ░███████████ + ░███░░░░░░ ░███ ░███ ░███ ░░░ ░███ ░███░░░░░███ + ░███ ░███ ░███ ░███ ░███ ░███ ░███ + █████ ░░████████ █████ █████ █████ █████ +░░░░░ ░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ + " + .bright_blue() + .bold() + ); + info!("Starting PUMA to serve model: {}", model_name); + + // Initialize inference engine + let engine = initialize_engine(); + + // Initialize model registry + let registry = Arc::new(ModelRegistry::new(None)); + info!("Model registry loaded"); + + // Create router + let app = create_router(engine, registry); + + // Bind address + let addr = format!("{}:{}", host, port); + let listener = tokio::net::TcpListener::bind(&addr).await?; + + info!("Server listening on http://{}", addr); + info!("Available endpoints:"); + info!(" POST /v1/chat/completions"); + info!(" POST /v1/completions"); + info!(" GET /v1/models"); + info!(" GET /v1/models/:model"); + info!(" GET /health"); + + // Start server + debug!("Starting axum server"); + axum::serve(listener, app).await?; + + info!("Server shutdown"); + Ok(()) +} diff --git a/src/downloader/downloader.rs b/src/downloader/downloader.rs index 5320258..21ae0b0 100644 --- a/src/downloader/downloader.rs +++ b/src/downloader/downloader.rs @@ -2,8 +2,11 @@ use core::fmt; #[derive(Debug)] pub enum DownloadError { - RequestError(String), - ParseError(String), + NetworkError(String), + AuthError(String), + ModelNotFound(String), + IoError(String), + ApiError(String), } impl std::error::Error for DownloadError {} @@ -11,8 +14,15 @@ impl std::error::Error for DownloadError {} impl fmt::Display for DownloadError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { - DownloadError::RequestError(e) => write!(f, "RequestError: {}", e), - DownloadError::ParseError(e) => write!(f, "ParseError: {}", e), + DownloadError::NetworkError(e) => write!(f, "Network error: {}", e), + DownloadError::AuthError(e) => write!(f, "Authentication error: {}", e), + DownloadError::ModelNotFound(e) => write!(f, "Model not found: {}", e), + DownloadError::IoError(e) => write!(f, "IO error: {}", e), + DownloadError::ApiError(e) => write!(f, "API error: {}", e), } } } + +pub trait Downloader { + async fn download_model(&self, name: &str) -> Result<(), DownloadError>; +} diff --git a/src/downloader/huggingface.rs b/src/downloader/huggingface.rs new file mode 100644 index 0000000..0a789f1 --- /dev/null +++ b/src/downloader/huggingface.rs @@ -0,0 +1,364 @@ +use colored::Colorize; +use tracing::debug; + +use hf_hub::api::tokio::{ApiBuilder, Progress}; +use indicatif::{ProgressBar, ProgressStyle}; + +use crate::downloader::downloader::{DownloadError, Downloader}; +use crate::downloader::progress::{DownloadProgressManager, FileProgress}; +use crate::registry::model_registry::{CacheInfo, ModelInfo, ModelMetadata, ModelRegistry}; +use crate::utils::file::{self, format_model_name}; + +/// Adapter to bridge HuggingFace's Progress trait with our FileProgress +#[derive(Clone)] +struct HfProgressAdapter { + progress: FileProgress, +} + +impl Progress for HfProgressAdapter { + async fn init(&mut self, size: usize, _filename: &str) { + self.progress.init(size as u64); + } + + async fn update(&mut self, size: usize) { + self.progress.update(size as u64); + } + + async fn finish(&mut self) { + self.progress.finish(); + } +} + +pub struct HuggingFaceDownloader; + +impl HuggingFaceDownloader { + pub fn new() -> Self { + Self + } + + async fn fetch_metadata_from_api( + model_name: &str, + ) -> ( + Option, + Option, + Option, + Option, + Option, + Option, + ) { + let url = format!("https://huggingface.co/api/models/{}", model_name); + let client = reqwest::Client::new(); + + match client.get(&url).send().await { + Ok(response) => { + if let Ok(json) = response.json::().await { + let author = json + .get("author") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let task = json + .get("pipeline_tag") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let license = json + .get("cardData") + .and_then(|card| card.get("license")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let model_type = json + .get("config") + .and_then(|config| config.get("model_type")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let safetensors = json.get("safetensors").cloned(); + + let storage = json.get("usedStorage").and_then(|v| v.as_u64()); + + (author, task, license, model_type, safetensors, storage) + } else { + (None, None, None, None, None, None) + } + } + Err(_) => (None, None, None, None, None, None), + } + } +} + +impl Default for HuggingFaceDownloader { + fn default() -> Self { + Self::new() + } +} + +impl Downloader for HuggingFaceDownloader { + async fn download_model(&self, name: &str) -> Result<(), DownloadError> { + let start_time = std::time::Instant::now(); + + debug!("Downloading model {} from Hugging Face...", name); + + // Use unified PUMA cache directory + let cache_dir = file::huggingface_cache_dir(); + file::create_folder_if_not_exists(&cache_dir).map_err(|e| { + DownloadError::IoError(format!("Failed to create cache directory: {}", e)) + })?; + + // Build API with PUMA cache directory + let api = ApiBuilder::new() + .with_cache_dir(cache_dir.clone()) + .build() + .map_err(|e| { + DownloadError::ApiError(format!("Failed to initialize Hugging Face API: {}", e)) + })?; + + // Create a simple spinner for manifest pulling + let manifest_spinner = ProgressBar::new_spinner(); + manifest_spinner.set_style( + ProgressStyle::default_spinner() + .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏") + .template("pulling manifest {spinner:.white}") + .unwrap(), + ); + manifest_spinner.enable_steady_tick(std::time::Duration::from_millis(80)); + + // Download the entire model repository using snapshot download + let repo = api.model(name.to_string()); + + // Get model info to list all files + let model_info = repo.info().await.map_err(|e| { + let err_str = e.to_string(); + if err_str.contains("404") || err_str.contains("not found") { + DownloadError::ModelNotFound(format!("Model '{}' not found", name)) + } else if err_str.contains("401") || err_str.contains("403") { + DownloadError::AuthError(format!("Authentication failed: {}", e)) + } else if err_str.contains("network") || err_str.contains("connection") { + DownloadError::NetworkError(format!("Network error: {}", e)) + } else { + DownloadError::ApiError(format!("Failed to fetch model info: {}", e)) + } + })?; + + // Stop manifest spinner and print clean message + manifest_spinner.finish_and_clear(); + println!("pulling manifest"); + + debug!("Model info for {}: {:?}", name, model_info); + + // Calculate the longest filename for proper alignment + let max_filename_len = model_info + .siblings + .iter() + .map(|s| s.rfilename.len()) + .max() + .unwrap_or(30); + + // Add extra space for "pulling " prefix + let max_filename_len = max_filename_len + 8; + // Create progress manager + let progress_manager = DownloadProgressManager::new(max_filename_len); + + // Calculate cache paths + let model_cache_path = cache_dir.join(format_model_name(name)); + let sha = model_info.sha.clone(); + let snapshot_path = model_cache_path.join("snapshots").join(&sha); + + // Check if all files are already cached + let model_totally_cached = model_info + .siblings + .iter() + .all(|sibling| snapshot_path.join(&sibling.rfilename).exists()); + + // Process all files in manifest order (cached files show as instantly complete) + let mut tasks = Vec::new(); + + for sibling in model_info.siblings { + let api_clone = api.clone(); + let model_name = name.to_string(); + let filename = sibling.rfilename.clone(); + let progress_manager_clone = progress_manager.clone(); + let snapshot_path_clone = snapshot_path.clone(); + + let task = tokio::spawn(async move { + let repo = api_clone.model(model_name); + + // Check if file exists in cache + let cached_file_path = snapshot_path_clone.join(&filename); + if cached_file_path.exists() { + debug!("File {} found in cache, showing as complete", filename); + + // Create progress bar for cached file (no speed display) + let display_name = format!("pulling {}", filename); + let mut file_progress = + progress_manager_clone.create_cached_file_progress(&display_name); + let file_size = cached_file_path.metadata().map(|m| m.len()).unwrap_or(0); + file_progress.init(file_size); + file_progress.update(file_size); + file_progress.finish(); + + return Ok(()); + } + + // File not in cache, download with progress + debug!("Downloading: {}", filename); + let display_name = format!("pulling {}", filename); + let file_progress = progress_manager_clone.create_file_progress(&display_name); + let progress = HfProgressAdapter { + progress: file_progress, + }; + + repo.download_with_progress(&filename, progress) + .await + .map_err(|e| { + DownloadError::NetworkError(format!( + "Failed to download {}: {}", + filename, e + )) + })?; + + Ok(()) + }); + + tasks.push(task); + } + + // Give tasks a moment to start and create their progress bars + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Show spinner at the bottom after all progress bars are created (only if not fully cached) + let spinner = if !model_totally_cached { + Some(progress_manager.create_spinner()) + } else { + None + }; + + // Wait for all downloads to complete + for task in tasks { + task.await + .map_err(|e| DownloadError::ApiError(format!("Task join error: {}", e)))??; + } + + // Finish spinner after downloads complete + if let Some(spinner) = &spinner { + spinner.finish_and_clear(); + } + + let elapsed_time = start_time.elapsed(); + let model_cache_path = cache_dir.join(format_model_name(name)); + + // Register the model only if not totally cached + if !model_totally_cached { + // Fetch metadata from HuggingFace API + let ( + author_from_api, + task_from_api, + license_from_api, + model_series_from_api, + safetensors_from_api, + storage_from_api, + ) = Self::fetch_metadata_from_api(name).await; + + // Extract context_window from config.json + let config_path = snapshot_path.join("config.json"); + let context_window = if config_path.exists() { + std::fs::read_to_string(&config_path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .and_then(|config| { + config + .get("text_config") + .and_then(|tc| tc.get("max_position_embeddings")) + .or_else(|| config.get("max_position_embeddings")) + .or_else(|| config.get("n_positions")) + .or_else(|| config.get("n_ctx")) + .and_then(|v| v.as_u64()) + .map(|v| v as u32) + }) + } else { + None + }; + + // Use storage from API, fallback to accumulated download size + let model_size = + storage_from_api.unwrap_or_else(|| progress_manager.total_downloaded_bytes()); + + let cache = CacheInfo { + revision: sha.clone(), + size: model_size, + path: model_cache_path.to_string_lossy().to_string(), + }; + + let metadata = ModelMetadata { + cache, + context_window, + safetensors: safetensors_from_api, + }; + + let now = chrono::Local::now().to_rfc3339(); + let model_info_record = ModelInfo { + uuid: sha, // Use revision SHA as UUID for now + name: name.to_string(), + provider: "huggingface".to_string(), + author: author_from_api, + task: task_from_api, + model_series: model_series_from_api, + license: license_from_api, + created_at: now.clone(), + updated_at: now, + metadata, + }; + + let registry = ModelRegistry::new(None); + registry + .register_model(model_info_record) + .map_err(|e| DownloadError::ApiError(format!("Failed to register model: {}", e)))?; + } + + // Print success message + println!( + "{} {} {} {} {:.2?}", + "✓".green().bold(), + "Successfully downloaded model".bright_white(), + name.cyan().bold(), + "in".bright_white(), + elapsed_time + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_download_model_invalid() { + let downloader = HuggingFaceDownloader::new(); + let result = downloader + .download_model("invalid-model-that-does-not-exist-12345") + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_download_real_tiny_model() { + let downloader = HuggingFaceDownloader::new(); + // Use HF's official tiny test model (only a few KB) + let result = downloader.download_model("InftyAI/tiny-random-gpt2").await; + assert!( + result.is_ok(), + "Failed to download tiny model: {:?}", + result + ); + + // Cleanup: remove the downloaded files from PUMA cache + let cache_dir = file::huggingface_cache_dir().join("models--InftyAI--tiny-random-gpt2"); + + if cache_dir.exists() { + let _ = std::fs::remove_dir_all(&cache_dir); + } + } +} diff --git a/src/downloader/mod.rs b/src/downloader/mod.rs index a48aa6c..39ef068 100644 --- a/src/downloader/mod.rs +++ b/src/downloader/mod.rs @@ -1 +1,4 @@ -mod downloader; +#[allow(clippy::module_inception)] +pub mod downloader; +pub mod huggingface; +pub mod progress; diff --git a/src/downloader/progress.rs b/src/downloader/progress.rs new file mode 100644 index 0000000..7b3ba32 --- /dev/null +++ b/src/downloader/progress.rs @@ -0,0 +1,146 @@ +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +/// Manages multi-file download progress tracking +/// +/// # Example +/// ```rust +/// use puma::downloader::progress::DownloadProgressManager; +/// +/// let progress_manager = DownloadProgressManager::new(30); +/// let mut file_progress = progress_manager.create_file_progress("model.bin"); +/// +/// file_progress.init(1024 * 1024); // 1 MB +/// file_progress.update(512 * 1024); // Downloaded 512 KB +/// file_progress.finish(); +/// +/// let total = progress_manager.total_downloaded_bytes(); +/// ``` +#[derive(Clone)] +pub struct DownloadProgressManager { + multi_progress: Arc, + total_size: Arc, + style: ProgressStyle, + cached_style: ProgressStyle, +} + +impl DownloadProgressManager { + /// Create a new progress manager with aligned file names + pub fn new(max_filename_len: usize) -> Self { + let multi_progress = Arc::new(MultiProgress::new()); + + let template = format!( + "{{msg:<{width}}} [{{elapsed_precise}}] {{bar:60.white}} {{bytes}}/{{total_bytes}} {{bytes_per_sec}}", + width = max_filename_len + ); + let style = ProgressStyle::default_bar() + .template(&template) + .unwrap() + .progress_chars("▇▆▅▄▃▂▁ "); + + // Cached file style without speed + let cached_template = format!( + "{{msg:<{width}}} [{{elapsed_precise}}] {{bar:60.white}} {{bytes}}/{{total_bytes}}", + width = max_filename_len + ); + let cached_style = ProgressStyle::default_bar() + .template(&cached_template) + .unwrap() + .progress_chars("▇▆▅▄▃▂▁ "); + + Self { + multi_progress, + total_size: Arc::new(AtomicU64::new(0)), + style, + cached_style, + } + } + + /// Create a new progress bar for a file download + pub fn create_file_progress(&self, filename: &str) -> FileProgress { + let pb = self.multi_progress.add(ProgressBar::hidden()); + pb.set_style(self.style.clone()); + pb.set_message(filename.to_string()); + + FileProgress { + pb, + total_size: Arc::clone(&self.total_size), + } + } + + /// Create a new progress bar for a cached file (no speed display) + pub fn create_cached_file_progress(&self, filename: &str) -> FileProgress { + let pb = self.multi_progress.add(ProgressBar::hidden()); + pb.set_style(self.cached_style.clone()); + pb.set_message(filename.to_string()); + + FileProgress { + pb, + total_size: Arc::clone(&self.total_size), + } + } + + /// Get the total accumulated download size + pub fn total_downloaded_bytes(&self) -> u64 { + self.total_size.load(Ordering::Relaxed) + } + + /// Create a spinner progress bar (for post-download operations) + pub fn create_spinner(&self) -> ProgressBar { + let pb = self.multi_progress.add(ProgressBar::new_spinner()); + pb.set_style( + ProgressStyle::default_spinner() + .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏") + .template("{spinner} ") + .unwrap(), + ); + pb.enable_steady_tick(std::time::Duration::from_millis(80)); + pb + } +} + +/// Tracks progress for a single file download +#[derive(Clone)] +pub struct FileProgress { + pb: ProgressBar, + total_size: Arc, +} + +impl FileProgress { + /// Initialize progress bar with file size + pub fn init(&mut self, size: u64) { + self.pb.set_length(size); + self.pb.reset(); + self.pb.tick(); + self.total_size.fetch_add(size, Ordering::Relaxed); + } + + /// Update progress with downloaded bytes + pub fn update(&mut self, bytes: u64) { + self.pb.inc(bytes); + } + + /// Mark download as complete + pub fn finish(&mut self) { + self.pb.finish(); + } + + /// Mark download as failed + #[allow(dead_code)] + pub fn abandon(&mut self) { + self.pb.abandon(); + } + + /// Get the inner progress bar (for provider-specific adapters) + #[allow(dead_code)] + pub fn progress_bar(&self) -> &ProgressBar { + &self.pb + } + + /// Get the total size tracker (for provider-specific adapters) + #[allow(dead_code)] + pub fn total_size_tracker(&self) -> Arc { + Arc::clone(&self.total_size) + } +} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src/main.rs b/src/main.rs index 31e44ba..02929ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,26 +1,38 @@ +mod api; +mod backend; mod cli; mod downloader; -mod util; +mod registry; +mod storage; +mod system; +mod utils; use clap::Parser; -use env_logger; use tokio::runtime::Builder; use crate::cli::commands::{run, Cli}; -use crate::util::file; +use crate::utils::file; fn main() { - env_logger::init(); + // Setup tracing subscriber for tower-http TraceLayer + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { + "info,hf_hub=warn,tower_http=info,rusqlite_migration=warn".into() + }), + ) + .init(); // Create the root folder if it doesn't exist. file::create_folder_if_not_exists(&file::root_home()).unwrap(); + let cli = Cli::parse(); + let runtime = Builder::new_multi_thread() .worker_threads(4) .enable_all() .build() .unwrap(); - let cli = Cli::parse(); runtime.block_on(run(cli)); } diff --git a/src/registry/mod.rs b/src/registry/mod.rs new file mode 100644 index 0000000..8565989 --- /dev/null +++ b/src/registry/mod.rs @@ -0,0 +1 @@ +pub mod model_registry; diff --git a/src/registry/model_registry.rs b/src/registry/model_registry.rs new file mode 100644 index 0000000..26ebcc7 --- /dev/null +++ b/src/registry/model_registry.rs @@ -0,0 +1,310 @@ +use colored::Colorize; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use crate::storage::{ModelStorage, SqliteStorage}; +use crate::utils::file; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CacheInfo { + pub revision: String, + pub size: u64, + pub path: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ModelMetadata { + pub cache: CacheInfo, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_window: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub safetensors: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ModelInfo { + pub uuid: String, + pub name: String, + pub provider: String, + pub author: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub task: Option, // Task type (image-text-to-text, text-generation) + #[serde(skip_serializing_if = "Option::is_none")] + pub model_series: Option, // Architecture series (qwen3_5, gpt2, llama3) + #[serde(skip_serializing_if = "Option::is_none")] + pub license: Option, + pub metadata: ModelMetadata, + pub created_at: String, + pub updated_at: String, +} + +pub struct ModelRegistry { + storage: Box, +} + +impl ModelRegistry { + pub fn new(home_dir: Option) -> Self { + let home_dir = home_dir.unwrap_or_else(file::root_home); + fs::create_dir_all(&home_dir).ok(); + + let db_path = home_dir.join("models.db"); + let storage = SqliteStorage::new(db_path).expect("Failed to initialize storage"); + + Self { + storage: Box::new(storage), + } + } + + pub fn load_models( + &self, + filters: Option<&HashMap>, + ) -> Result, std::io::Error> { + self.storage.load_models(filters) + } + + pub fn register_model(&self, model: ModelInfo) -> Result<(), std::io::Error> { + self.storage.register_model(model) + } + + pub fn unregister_model(&self, name: &str) -> Result<(), std::io::Error> { + self.storage.unregister_model(name) + } + + pub fn get_model(&self, name: &str) -> Result, std::io::Error> { + self.storage.get_model(name) + } + + pub fn remove_model(&self, name: &str) -> Result<(), std::io::Error> { + // Get model info first + let model_info = self.get_model(name)?; + + if let Some(info) = model_info { + // Delete cache directory if it exists + let cache_path = std::path::Path::new(&info.metadata.cache.path); + if cache_path.exists() { + fs::remove_dir_all(cache_path)?; + } + + // Remove from registry + self.unregister_model(name)?; + + println!( + "{} {} {}", + "✓".green().bold(), + "Successfully removed model".bright_white(), + name.cyan().bold() + ); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + // Helper to create a test model + fn create_test_model(name: &str, revision: &str) -> ModelInfo { + let safetensors = serde_json::json!({ + "parameters": { + "F32": 7000000000u64 + }, + "total": 7000000000u64 + }); + + ModelInfo { + uuid: revision.to_string(), + name: name.to_string(), + provider: "huggingface".to_string(), + author: Some("test-author".to_string()), + task: Some("text-generation".to_string()), + model_series: Some("gpt2".to_string()), + license: Some("mit".to_string()), + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + metadata: ModelMetadata { + cache: CacheInfo { + revision: revision.to_string(), + size: 1000, + path: "/tmp/test".to_string(), + }, + context_window: Some(2048), + safetensors: Some(safetensors), + }, + } + } + + #[test] + fn test_add_and_load_model() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let model = create_test_model("test/model", "abc123"); + + registry.register_model(model.clone()).unwrap(); + + let models = registry.load_models(None).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].name, "test/model"); + } + + #[test] + fn test_unregister_model() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let model = create_test_model("test/model", "abc123"); + + registry.register_model(model).unwrap(); + assert_eq!(registry.load_models(None).unwrap().len(), 1); + + registry.unregister_model("test/model").unwrap(); + assert_eq!(registry.load_models(None).unwrap().len(), 0); + } + + #[test] + fn test_get_model() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let model = create_test_model("test/model", "abc123"); + + registry.register_model(model).unwrap(); + + let result = registry.get_model("test/model").unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().name, "test/model"); + + let not_found = registry.get_model("nonexistent").unwrap(); + assert!(not_found.is_none()); + } + + #[test] + fn test_remove_nonexistent_model() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + // Should not error when removing non-existent model + let result = registry.unregister_model("nonexistent"); + assert!(result.is_ok()); + } + + #[test] + fn test_update_existing_model() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let model1 = create_test_model("test/model", "abc123"); + registry.register_model(model1).unwrap(); + + let mut model2 = create_test_model("test/model", "def456"); + model2.metadata.cache.size = 2000; + model2.metadata.cache.path = "/tmp/test2".to_string(); + model2.created_at = "2025-01-02T00:00:00Z".to_string(); + model2.updated_at = "2025-01-02T00:00:00Z".to_string(); + + registry.register_model(model2).unwrap(); + + let models = registry.load_models(None).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].metadata.cache.revision, "def456"); + assert_eq!(models[0].metadata.cache.size, 2000); + // created_at should be preserved from model1 + assert_eq!(models[0].created_at, "2025-01-01T00:00:00Z"); + // updated_at should be from model2 + assert_eq!(models[0].updated_at, "2025-01-02T00:00:00Z"); + } + + #[test] + fn test_remove_model_with_cache() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + // Create a fake cache directory + let cache_dir = temp_dir.path().join("cache"); + fs::create_dir_all(&cache_dir).unwrap(); + fs::write(cache_dir.join("test.txt"), "test data").unwrap(); + + let mut model = create_test_model("test/model", "abc123"); + model.metadata.cache.path = cache_dir.to_string_lossy().to_string(); + + registry.register_model(model).unwrap(); + assert_eq!(registry.load_models(None).unwrap().len(), 1); + assert!(cache_dir.exists()); + + // Delete model + registry.remove_model("test/model").unwrap(); + + // Verify model removed from registry + assert_eq!(registry.load_models(None).unwrap().len(), 0); + + // Verify cache directory deleted + assert!(!cache_dir.exists()); + } + + #[test] + fn test_delete_nonexistent_model() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + // Should not error when deleting non-existent model + let result = registry.remove_model("nonexistent"); + assert!(result.is_ok()); + } + + #[test] + fn test_inspect_model_with_full_spec() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let model = create_test_model("test/gpt-model", "abc123def456"); + + registry.register_model(model).unwrap(); + + let retrieved = registry.get_model("test/gpt-model").unwrap(); + assert!(retrieved.is_some()); + + let model_info = retrieved.unwrap(); + assert_eq!(model_info.name, "test/gpt-model"); + assert_eq!(model_info.provider, "huggingface"); + assert_eq!(model_info.metadata.cache.revision, "abc123def456"); + assert_eq!(model_info.model_series, Some("gpt2".to_string())); + assert_eq!(model_info.metadata.context_window, Some(2048)); + assert_eq!( + model_info + .metadata + .safetensors + .as_ref() + .unwrap() + .get("total") + .unwrap() + .as_u64() + .unwrap(), + 7_000_000_000 + ); + } + + #[test] + fn test_inspect_model_without_spec() { + let temp_dir = TempDir::new().unwrap(); + let registry = ModelRegistry::new(Some(temp_dir.path().to_path_buf())); + + let mut model = create_test_model("test/legacy-model", "legacy123"); + model.metadata.safetensors = None; + model.metadata.context_window = None; + + registry.register_model(model).unwrap(); + + let retrieved = registry.get_model("test/legacy-model").unwrap(); + assert!(retrieved.is_some()); + + let model_info = retrieved.unwrap(); + assert_eq!(model_info.name, "test/legacy-model"); + assert!(model_info.metadata.safetensors.is_none()); + assert!(model_info.metadata.context_window.is_none()); + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..fd81a1a --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,5 @@ +pub mod sqlite; +pub mod storage_trait; + +pub use sqlite::SqliteStorage; +pub use storage_trait::ModelStorage; diff --git a/src/storage/sqlite.rs b/src/storage/sqlite.rs new file mode 100644 index 0000000..49a8792 --- /dev/null +++ b/src/storage/sqlite.rs @@ -0,0 +1,460 @@ +use crate::registry::model_registry::{ModelInfo, ModelMetadata}; +use crate::storage::ModelStorage; +use rusqlite::{params, Connection, Result as SqlResult}; +use rusqlite_migration::{Migrations, M}; +use std::collections::HashMap; +use std::io; +use std::path::PathBuf; + +pub struct SqliteStorage { + db_path: PathBuf, +} + +impl SqliteStorage { + pub fn new(db_path: PathBuf) -> Result { + let storage = Self { db_path }; + storage.run_migrations()?; + Ok(storage) + } + + fn run_migrations(&self) -> Result<(), io::Error> { + let mut conn = self.get_connection()?; + + let migrations = Migrations::new(vec![ + M::up( + "CREATE TABLE models ( + uuid TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + author TEXT, + task TEXT, + model_series TEXT, + provider TEXT NOT NULL, + license TEXT, + metadata JSON NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK(json_valid(metadata)) + ); + CREATE INDEX idx_author ON models(author); + CREATE INDEX idx_task ON models(task); + CREATE INDEX idx_model_series ON models(model_series); + CREATE INDEX idx_provider ON models(provider); + CREATE INDEX idx_license ON models(license); + CREATE INDEX idx_created_at ON models(created_at);", + ), + // Future migrations go here + ]); + + migrations.to_latest(&mut conn).map_err(io::Error::other)?; + + Ok(()) + } + + fn get_connection(&self) -> Result { + Connection::open(&self.db_path).map_err(io::Error::other) + } +} + +impl ModelStorage for SqliteStorage { + fn load_models( + &self, + filters: Option<&HashMap>, + ) -> Result, io::Error> { + let conn = self.get_connection()?; + + // Build WHERE clause from filters + let mut where_clauses = Vec::new(); + let mut params: Vec = Vec::new(); + + if let Some(filter_map) = filters { + // Allowed columns for filtering (prevent SQL injection) + let allowed_columns = ["author", "task", "model_series", "provider", "license"]; + + for (key, value) in filter_map { + if allowed_columns.contains(&key.as_str()) { + where_clauses.push(format!("{} = ?", key)); + params.push(value.clone()); + } else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid filter column: {}", key), + )); + } + } + } + + let query = if where_clauses.is_empty() { + "SELECT uuid, name, author, task, model_series, provider, license, + metadata, created_at, updated_at + FROM models" + .to_string() + } else { + format!( + "SELECT uuid, name, author, task, model_series, provider, license, + metadata, created_at, updated_at + FROM models + WHERE {}", + where_clauses.join(" AND ") + ) + }; + + let mut stmt = conn.prepare(&query).map_err(io::Error::other)?; + + let param_refs: Vec<&dyn rusqlite::ToSql> = + params.iter().map(|p| p as &dyn rusqlite::ToSql).collect(); + + let models = stmt + .query_map(param_refs.as_slice(), |row| { + let metadata_json: String = row.get(7)?; + let metadata: ModelMetadata = serde_json::from_str(&metadata_json) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + + Ok(ModelInfo { + uuid: row.get(0)?, + name: row.get(1)?, + provider: row.get(5)?, + author: row.get(2)?, + task: row.get(3)?, + model_series: row.get(4)?, + license: row.get(6)?, + metadata, + created_at: row.get(8)?, + updated_at: row.get(9)?, + }) + }) + .map_err(io::Error::other)? + .collect::>>() + .map_err(io::Error::other)?; + + Ok(models) + } + + fn register_model(&self, model: ModelInfo) -> Result<(), io::Error> { + let conn = self.get_connection()?; + + let metadata_json = serde_json::to_string(&model.metadata) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + // Normalize name and author to lowercase + let name_lower = model.name.to_lowercase(); + let author_lower = model.author.as_ref().map(|a| a.to_lowercase()); + + conn.execute( + "INSERT INTO models + (uuid, name, author, task, model_series, provider, license, + metadata, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(name) DO UPDATE SET + uuid = excluded.uuid, + author = excluded.author, + task = excluded.task, + model_series = excluded.model_series, + provider = excluded.provider, + license = excluded.license, + metadata = excluded.metadata, + updated_at = excluded.updated_at", + params![ + &model.uuid, + &name_lower, + author_lower.as_deref(), + model.task.as_deref(), + model.model_series.as_deref(), + &model.provider, + model.license.as_deref(), + &metadata_json, + &model.created_at, + &model.updated_at, + ], + ) + .map_err(io::Error::other)?; + + Ok(()) + } + + fn unregister_model(&self, name: &str) -> Result<(), io::Error> { + let conn = self.get_connection()?; + + // Normalize name to lowercase for case-insensitive lookup + let name_lower = name.to_lowercase(); + + conn.execute("DELETE FROM models WHERE name = ?1", params![name_lower]) + .map_err(io::Error::other)?; + + Ok(()) + } + + fn get_model(&self, name: &str) -> Result, io::Error> { + let conn = self.get_connection()?; + + // Normalize name to lowercase for case-insensitive lookup + let name_lower = name.to_lowercase(); + + let result = conn.query_row( + "SELECT uuid, name, author, task, model_series, provider, license, + metadata, created_at, updated_at + FROM models WHERE name = ?1", + params![name_lower], + |row| { + let metadata_json: String = row.get(7)?; + let metadata: ModelMetadata = serde_json::from_str(&metadata_json) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + + Ok(ModelInfo { + uuid: row.get(0)?, + name: row.get(1)?, + provider: row.get(5)?, + author: row.get(2)?, + task: row.get(3)?, + model_series: row.get(4)?, + license: row.get(6)?, + created_at: row.get(8)?, + updated_at: row.get(9)?, + metadata, + }) + }, + ); + + match result { + Ok(model) => Ok(Some(model)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(io::Error::other(e)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::model_registry::{CacheInfo, ModelMetadata}; + use tempfile::TempDir; + + fn create_test_model(name: &str, uuid: &str) -> ModelInfo { + let safetensors = serde_json::json!({ + "parameters": {"F32": 1000u64}, + "total": 1000u64 + }); + + ModelInfo { + uuid: uuid.to_string(), + name: name.to_string(), + provider: "huggingface".to_string(), + author: Some("test-author".to_string()), + task: Some("text-generation".to_string()), + model_series: Some("gpt2".to_string()), + license: Some("mit".to_string()), + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + metadata: ModelMetadata { + cache: CacheInfo { + revision: "abc123".to_string(), + size: 1000, + path: "/tmp/test".to_string(), + }, + context_window: Some(2048), + safetensors: Some(safetensors), + }, + } + } + + #[test] + fn test_sqlite_register_and_load() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let storage = SqliteStorage::new(db_path).unwrap(); + + let model = create_test_model("test/model", "uuid123"); + storage.register_model(model.clone()).unwrap(); + + let models = storage.load_models(None).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].name, "test/model"); + assert_eq!(models[0].uuid, "uuid123"); + } + + #[test] + fn test_sqlite_get_model() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let storage = SqliteStorage::new(db_path).unwrap(); + + let model = create_test_model("test/model", "uuid123"); + storage.register_model(model).unwrap(); + + let result = storage.get_model("test/model").unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().name, "test/model"); + + let not_found = storage.get_model("nonexistent").unwrap(); + assert!(not_found.is_none()); + } + + #[test] + fn test_sqlite_unregister() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let storage = SqliteStorage::new(db_path).unwrap(); + + let model = create_test_model("test/model", "uuid123"); + storage.register_model(model).unwrap(); + assert_eq!(storage.load_models(None).unwrap().len(), 1); + + storage.unregister_model("test/model").unwrap(); + assert_eq!(storage.load_models(None).unwrap().len(), 0); + } + + #[test] + fn test_sqlite_update_preserves_created_at() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let storage = SqliteStorage::new(db_path).unwrap(); + + let model1 = create_test_model("test/model", "uuid1"); + storage.register_model(model1).unwrap(); + + let mut model2 = create_test_model("test/model", "uuid2"); + model2.created_at = "2025-01-02T00:00:00Z".to_string(); + model2.updated_at = "2025-01-02T00:00:00Z".to_string(); + storage.register_model(model2).unwrap(); + + let models = storage.load_models(None).unwrap(); + assert_eq!(models.len(), 1); + // created_at should be preserved + assert_eq!(models[0].created_at, "2025-01-01T00:00:00Z"); + // updated_at should be new + assert_eq!(models[0].updated_at, "2025-01-02T00:00:00Z"); + // uuid should be updated + assert_eq!(models[0].uuid, "uuid2"); + } + + #[test] + fn test_sqlite_metadata_json() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let storage = SqliteStorage::new(db_path).unwrap(); + + let model = create_test_model("test/model", "uuid123"); + storage.register_model(model).unwrap(); + + let retrieved = storage.get_model("test/model").unwrap().unwrap(); + assert_eq!(retrieved.metadata.cache.revision, "abc123"); + assert_eq!(retrieved.metadata.cache.size, 1000); + assert_eq!(retrieved.metadata.context_window, Some(2048)); + assert!(retrieved.metadata.safetensors.is_some()); + + let st = retrieved.metadata.safetensors.unwrap(); + assert_eq!(st.get("total").unwrap().as_u64().unwrap(), 1000); + } + + #[test] + fn test_load_models_with_single_filter() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let storage = SqliteStorage::new(db_path).unwrap(); + + let mut model1 = create_test_model("test/model1", "uuid1"); + model1.author = Some("author1".to_string()); + storage.register_model(model1).unwrap(); + + let mut model2 = create_test_model("test/model2", "uuid2"); + model2.author = Some("author2".to_string()); + storage.register_model(model2).unwrap(); + + let mut filters = HashMap::new(); + filters.insert("author".to_string(), "author1".to_string()); + + let models = storage.load_models(Some(&filters)).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].name, "test/model1"); + } + + #[test] + fn test_load_models_with_multiple_filters() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let storage = SqliteStorage::new(db_path).unwrap(); + + let mut model1 = create_test_model("test/model1", "uuid1"); + model1.author = Some("InftyAI".to_string()); + model1.license = Some("mit".to_string()); + storage.register_model(model1).unwrap(); + + let mut model2 = create_test_model("test/model2", "uuid2"); + model2.author = Some("InftyAI".to_string()); + model2.license = Some("apache-2.0".to_string()); + storage.register_model(model2).unwrap(); + + let mut model3 = create_test_model("test/model3", "uuid3"); + model3.author = Some("other-author".to_string()); + model3.license = Some("mit".to_string()); + storage.register_model(model3).unwrap(); + + let mut filters = HashMap::new(); + filters.insert("author".to_string(), "inftyai".to_string()); + filters.insert("license".to_string(), "mit".to_string()); + + let models = storage.load_models(Some(&filters)).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].name, "test/model1"); + } + + #[test] + fn test_load_models_with_invalid_filter_column() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let storage = SqliteStorage::new(db_path).unwrap(); + + let mut filters = HashMap::new(); + filters.insert("invalid_column".to_string(), "value".to_string()); + + let result = storage.load_models(Some(&filters)); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput); + } + + #[test] + fn test_name_and_author_stored_lowercase() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let storage = SqliteStorage::new(db_path).unwrap(); + + let mut model = create_test_model("InftyAI/TestModel", "uuid123"); + model.author = Some("InftyAI".to_string()); + storage.register_model(model).unwrap(); + + // Query with original case should work + let retrieved = storage.get_model("InftyAI/TestModel").unwrap(); + assert!(retrieved.is_some()); + let model_info = retrieved.unwrap(); + // Verify stored as lowercase + assert_eq!(model_info.name, "inftyai/testmodel"); + assert_eq!(model_info.author, Some("inftyai".to_string())); + + // Query with different case should also work + let retrieved2 = storage.get_model("inftyai/testmodel").unwrap(); + assert!(retrieved2.is_some()); + + let retrieved3 = storage.get_model("INFTYAI/TESTMODEL").unwrap(); + assert!(retrieved3.is_some()); + } + + #[test] + fn test_author_filter_case_sensitive() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let storage = SqliteStorage::new(db_path).unwrap(); + + let mut model = create_test_model("test/model", "uuid123"); + model.author = Some("InftyAI".to_string()); + storage.register_model(model).unwrap(); + + // Filter must use lowercase since data is stored in lowercase + let mut filters = HashMap::new(); + filters.insert("author".to_string(), "inftyai".to_string()); + assert_eq!(storage.load_models(Some(&filters)).unwrap().len(), 1); + + // Non-lowercase filter won't match + filters.clear(); + filters.insert("author".to_string(), "InftyAI".to_string()); + assert_eq!(storage.load_models(Some(&filters)).unwrap().len(), 0); + } +} diff --git a/src/storage/storage_trait.rs b/src/storage/storage_trait.rs new file mode 100644 index 0000000..a39395b --- /dev/null +++ b/src/storage/storage_trait.rs @@ -0,0 +1,22 @@ +use crate::registry::model_registry::ModelInfo; +use std::io; + +use std::collections::HashMap; + +/// Trait for model storage backends +pub trait ModelStorage: Send + Sync { + /// Load models from storage with optional filtering by column values (e.g., author=InftyAI, license=mit) + fn load_models( + &self, + filters: Option<&HashMap>, + ) -> Result, io::Error>; + + /// Register (insert or update) a single model + fn register_model(&self, model: ModelInfo) -> Result<(), io::Error>; + + /// Unregister (delete) a model by name + fn unregister_model(&self, name: &str) -> Result<(), io::Error>; + + /// Get a single model by name + fn get_model(&self, name: &str) -> Result, io::Error>; +} diff --git a/src/system/mod.rs b/src/system/mod.rs new file mode 100644 index 0000000..11b5b6f --- /dev/null +++ b/src/system/mod.rs @@ -0,0 +1 @@ +pub mod system_info; diff --git a/src/system/system_info.rs b/src/system/system_info.rs new file mode 100644 index 0000000..ff62ebc --- /dev/null +++ b/src/system/system_info.rs @@ -0,0 +1,282 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::os::unix::fs::MetadataExt; +use std::path::PathBuf; +use std::process::Command; +use sysinfo::System; + +use crate::registry::model_registry::ModelRegistry; +use crate::utils::file; +use crate::utils::format::format_size; + +#[derive(Debug, Serialize, Deserialize)] +pub struct SystemInfo { + pub version: String, + pub os: String, + pub architecture: String, + pub cpu_cores: usize, + pub total_memory: String, + pub gpu_info: Vec, + pub cache_dir: String, + pub cache_size: String, + pub models_count: usize, + pub running_models: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GpuInfo { + pub name: String, + pub backend: String, // "CUDA", "Metal", "ROCm", or "Unknown" + pub memory: Option, +} + +impl SystemInfo { + pub fn collect() -> Self { + let mut sys = System::new_all(); + sys.refresh_memory(); + + let cache_dir = file::cache_dir(); + let cache_size = Self::calculate_cache_size(&cache_dir); + + let registry = ModelRegistry::new(None); + let models_count = registry.load_models(None).unwrap_or_default().len(); + + let gpu_info = Self::detect_gpus(); + + SystemInfo { + version: env!("CARGO_PKG_VERSION").to_string(), + os: System::name().unwrap_or_else(|| "Unknown".to_string()), + architecture: System::cpu_arch().unwrap_or_else(|| "Unknown".to_string()), + cpu_cores: sys.cpus().len(), + total_memory: format_size(sys.total_memory()), + gpu_info, + cache_dir: cache_dir.to_string_lossy().to_string(), + cache_size: format_size(cache_size), + models_count, + running_models: 0, // TODO: implement running models tracking + } + } + + fn detect_gpus() -> Vec { + let mut gpus = Vec::new(); + + // Try NVIDIA GPUs first (Linux/Windows) + if let Some(nvidia_gpus) = Self::detect_nvidia_gpus() { + gpus.extend(nvidia_gpus); + } + + // Try Metal (macOS) + if let Some(metal_gpu) = Self::detect_metal_gpu() { + gpus.push(metal_gpu); + } + + // Try AMD ROCm (Linux) + if let Some(amd_gpus) = Self::detect_amd_gpus() { + gpus.extend(amd_gpus); + } + + gpus + } + + fn detect_nvidia_gpus() -> Option> { + let output = Command::new("nvidia-smi") + .args([ + "--query-gpu=name,memory.total", + "--format=csv,noheader,nounits", + ]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let output_str = String::from_utf8(output.stdout).ok()?; + let mut gpus = Vec::new(); + + for line in output_str.lines() { + let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); + if parts.len() >= 2 { + gpus.push(GpuInfo { + name: parts[0].to_string(), + backend: "CUDA".to_string(), + memory: Some(format!("{} MB", parts[1])), + }); + } + } + + if gpus.is_empty() { + None + } else { + Some(gpus) + } + } + + fn detect_metal_gpu() -> Option { + // Check if running on macOS + if !cfg!(target_os = "macos") { + return None; + } + + let output = Command::new("system_profiler") + .arg("SPDisplaysDataType") + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let output_str = String::from_utf8(output.stdout).ok()?; + let lines: Vec<&str> = output_str.lines().collect(); + + // Find GPU name and cores + let mut gpu_name = None; + let mut core_count = None; + + for (i, line) in lines.iter().enumerate() { + if line.contains("Chipset Model:") { + let parts: Vec<&str> = line.split("Chipset Model:").collect(); + if parts.len() >= 2 { + let name = parts[1].trim(); + if !name.is_empty() { + gpu_name = Some(name.to_string()); + + // Look for core count in the next few lines + for line in lines.iter().skip(i + 1).take(10) { + if line.contains("Total Number of Cores:") { + let core_parts: Vec<&str> = + line.split("Total Number of Cores:").collect(); + if core_parts.len() >= 2 { + core_count = Some(core_parts[1].trim().to_string()); + } + break; + } + } + break; + } + } + } + } + + if let Some(name) = gpu_name { + let memory_str = core_count.map(|cores| format!("{} GPU cores", cores)); + + return Some(GpuInfo { + name, + backend: "Metal".to_string(), + memory: memory_str, + }); + } + + None + } + + fn detect_amd_gpus() -> Option> { + let output = Command::new("rocm-smi") + .arg("--showproductname") + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let output_str = String::from_utf8(output.stdout).ok()?; + let mut gpus = Vec::new(); + + for line in output_str.lines() { + if line.contains("Card series:") || line.contains("Card model:") { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() >= 2 { + let name = parts[1].trim().to_string(); + if !name.is_empty() { + gpus.push(GpuInfo { + name, + backend: "ROCm".to_string(), + memory: None, + }); + } + } + } + } + + if gpus.is_empty() { + None + } else { + Some(gpus) + } + } + + fn calculate_cache_size(cache_dir: &PathBuf) -> u64 { + if !cache_dir.exists() { + return 0; + } + + let mut total_size = 0u64; + + if let Ok(entries) = fs::read_dir(cache_dir) { + for entry in entries.flatten() { + if let Ok(metadata) = entry.metadata() { + if metadata.is_file() { + // Use blocks * 512 to get actual disk usage (handles sparse files) + total_size += metadata.blocks() * 512; + } else if metadata.is_dir() { + total_size += Self::dir_size(&entry.path()); + } + } + } + } + + total_size + } + + fn dir_size(path: &PathBuf) -> u64 { + let mut total_size = 0u64; + + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + if let Ok(metadata) = entry.metadata() { + if metadata.is_file() { + // Use blocks * 512 to get actual disk usage (handles sparse files) + total_size += metadata.blocks() * 512; + } else if metadata.is_dir() { + total_size += Self::dir_size(&entry.path()); + } + } + } + } + + total_size + } + + pub fn display(&self) { + println!("System Information:"); + println!(" Operating System: {}", self.os); + println!(" Architecture: {}", self.architecture); + println!(" CPU Cores: {}", self.cpu_cores); + println!(" Total Memory: {}", self.total_memory); + + if !self.gpu_info.is_empty() { + for (i, gpu) in self.gpu_info.iter().enumerate() { + if i == 0 { + print!(" GPU: "); + } else { + print!(" "); + } + print!("{} ({})", gpu.name, gpu.backend); + if let Some(ref memory) = gpu.memory { + print!(" - {}", memory); + } + println!(); + } + } + + println!("PUMA Information:"); + println!(" PUMA Version: {}", self.version); + println!(" Cache Directory: {}", self.cache_dir); + println!(" Cache Size: {}", self.cache_size); + println!(" Models: {}", self.models_count); + println!(" Running Models: {}", self.running_models); + } +} diff --git a/src/util/file.rs b/src/util/file.rs deleted file mode 100644 index 26effb0..0000000 --- a/src/util/file.rs +++ /dev/null @@ -1,14 +0,0 @@ -use std::fs; -use std::path::PathBuf; - -use dirs::home_dir; - -pub fn create_folder_if_not_exists(folder_path: &PathBuf) -> std::io::Result<()> { - fs::create_dir_all(folder_path)?; - Ok(()) -} - -pub fn root_home() -> PathBuf { - let home = home_dir().expect("Failed to get home directory"); - home.join(".puma") -} diff --git a/src/util/mod.rs b/src/util/mod.rs deleted file mode 100644 index c756da3..0000000 --- a/src/util/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod file; -pub mod request; diff --git a/src/util/request.rs b/src/util/request.rs deleted file mode 100644 index a82369d..0000000 --- a/src/util/request.rs +++ /dev/null @@ -1,153 +0,0 @@ -use core::time; -use std::error::Error; -use std::fs::File; -use std::io; -use std::os::unix::fs::FileExt; -use std::path::PathBuf; -use std::sync::Arc; - -use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -use log::{debug, error}; -use reqwest::Client; -use tokio::sync::Semaphore; - -const MAX_CHUNK_CONCURRENCY: usize = 20; -const CHUNK_SIZE: usize = 1000 * 1000 * 50; // 50MB -const MAX_RETRIES: usize = 5; -const SLEEP_FACTOR: usize = 500; // 500 ms - -pub async fn download_file( - client: Arc, - url: String, - content_length: u64, - filename: String, - output_path: &PathBuf, - m: Arc, - sty: ProgressStyle, -) -> Result<(), Box> { - debug!( - "Start to download file {} to {}", - filename, - output_path.display() - ); - - let mut tasks = Vec::new(); - let mut start = 0; - let mut end = CHUNK_SIZE as u64 - 1; - end = end.min(content_length - 1); - - let semaphore = Arc::new(Semaphore::new(MAX_CHUNK_CONCURRENCY)); - // TODO: verify the file not downloaded yet. - let file = Arc::new(File::create(&output_path)?); - let arc_url = Arc::new(url); - - let pb = m.add(ProgressBar::new(content_length).with_style(sty)); - pb.set_message(filename.clone()); - let arc_pb = Arc::new(pb); - - while start < content_length { - let client = Arc::clone(&client); - let semaphore = Arc::clone(&semaphore); - let file = Arc::clone(&file); - let url = Arc::clone(&arc_url); - let pb = Arc::clone(&arc_pb); - - let fname = filename.clone(); - - let task = tokio::spawn(async move { - let _permit = semaphore.acquire().await.unwrap(); - let _ = download_chunk_with_retries( - client, - file, - fname, - url, - start.clone(), - end.clone(), - MAX_RETRIES, - ) - .await; - - pb.inc(end - start + 1); - }); - tasks.push(task); - - start = end + 1; - end = (end + CHUNK_SIZE as u64).min(content_length - 1); - } - - for task in tasks { - let _ = task.await; - // TODO: write to a file about the chunk info. - } - - arc_pb.finish(); - Ok(()) -} - -async fn download_chunk_with_retries( - client: Arc, - file: Arc, - filename: String, - url: Arc, - start: u64, - end: u64, - retries: usize, -) -> Result<(), Box> { - debug!("Start to download chunk {}:{}-{}", filename, start, end,); - - let mut retries = retries; - loop { - match download_chunk(&client, &file, &url, start, end).await { - Ok(_) => { - debug!("Download chunk {}:{}-{} successfully", filename, start, end); - break; - } - // TODO: retry only when http error. - Err(e) => { - if retries == 0 { - error!("Reach the maximum retries {}. Return", MAX_RETRIES); - return Err(e); - } - retries -= 1; - - let _ = tokio::time::sleep(time::Duration::from_millis( - SLEEP_FACTOR as u64 * 2u64.pow((MAX_RETRIES - retries) as u32), - )); - - error!( - "Failed to download chunk {}:{}-{}, err: {}, retrying {}...", - filename, - start, - end, - e.to_string(), - MAX_RETRIES - retries - ); - } - } - } - - Ok(()) -} - -async fn download_chunk( - client: &Client, - file: &File, - url: &str, - start: u64, - end: u64, -) -> Result<(), Box> { - let response = client - .get(url) - .header("Range", format!("bytes={}-{}", start, end)) - .send() - .await - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; - - let chunk = response - .bytes() - .await - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; - - file.write_all_at(&chunk, start)?; - Ok(()) -} diff --git a/src/utils/file.rs b/src/utils/file.rs new file mode 100644 index 0000000..53ade50 --- /dev/null +++ b/src/utils/file.rs @@ -0,0 +1,95 @@ +use std::fs; +use std::path::PathBuf; + +use dirs::home_dir; + +pub fn create_folder_if_not_exists(folder_path: &PathBuf) -> std::io::Result<()> { + fs::create_dir_all(folder_path)?; + Ok(()) +} + +pub fn root_home() -> PathBuf { + // Allow tests to override PUMA home directory + if let Ok(test_home) = std::env::var("PUMA_HOME") { + PathBuf::from(test_home) + } else { + let home = home_dir().expect("Failed to get home directory"); + home.join(".puma") + } +} + +pub fn cache_dir() -> PathBuf { + root_home().join("cache") +} + +pub fn huggingface_cache_dir() -> PathBuf { + cache_dir().join("huggingface") +} + +#[allow(dead_code)] +pub fn modelscope_cache_dir() -> PathBuf { + cache_dir().join("modelscope") +} + +/// Format model name for HuggingFace cache directory +/// Converts "owner/model" to "models--owner--model" +pub fn format_model_name(name: &str) -> String { + format!("models--{}", name.replace("/", "--")) +} + +/// List all files recursively in a directory +#[allow(dead_code)] +pub fn list_files_recursive(dir: &std::path::Path) -> std::io::Result> { + let mut files = Vec::new(); + if dir.is_dir() { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + files.extend(list_files_recursive(&path)?); + } else { + files.push(path); + } + } + } + Ok(files) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_model_name_basic() { + assert_eq!(format_model_name("owner/model"), "models--owner--model"); + } + + #[test] + fn test_format_model_name_complex() { + assert_eq!( + format_model_name("Qwen/Qwen3.5-2B"), + "models--Qwen--Qwen3.5-2B" + ); + } + + #[test] + fn test_format_model_name_multiple_slashes() { + assert_eq!( + format_model_name("org/team/model"), + "models--org--team--model" + ); + } + + #[test] + fn test_format_model_name_no_slash() { + assert_eq!(format_model_name("model"), "models--model"); + } + + #[test] + fn test_format_model_name_special_chars() { + assert_eq!( + format_model_name("InftyAI/tiny-random-gpt2"), + "models--InftyAI--tiny-random-gpt2" + ); + } +} diff --git a/src/utils/format.rs b/src/utils/format.rs new file mode 100644 index 0000000..865c9b9 --- /dev/null +++ b/src/utils/format.rs @@ -0,0 +1,353 @@ +use chrono::{DateTime, Utc}; + +/// Format byte size to human-readable format (B, KiB, MiB, GiB) +pub fn format_size(bytes: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = 1024.0 * 1024.0; + const GIB: f64 = 1024.0 * 1024.0 * 1024.0; + + if bytes as f64 >= GIB { + format!("{:.2} GiB", bytes as f64 / GIB) + } else if bytes as f64 >= MIB { + format!("{:.2} MiB", bytes as f64 / MIB) + } else if bytes as f64 >= KIB { + format!("{:.2} KiB", bytes as f64 / KIB) + } else { + format!("{} B", bytes) + } +} + +/// Format byte size to human-readable format using decimal units (B, KB, MB, GB) +pub fn format_size_decimal(bytes: u64) -> String { + const KB: f64 = 1000.0; + const MB: f64 = 1000.0 * 1000.0; + const GB: f64 = 1000.0 * 1000.0 * 1000.0; + + if bytes as f64 >= GB { + format!("{:.2} GB", bytes as f64 / GB) + } else if bytes as f64 >= MB { + format!("{:.2} MB", bytes as f64 / MB) + } else if bytes as f64 >= KB { + format!("{:.2} KB", bytes as f64 / KB) + } else { + format!("{} B", bytes) + } +} + +/// Format parameter count to human-readable format (K, M, B) +pub fn format_parameters(count: u64) -> String { + const K: f64 = 1_000.0; + const M: f64 = 1_000_000.0; + const B: f64 = 1_000_000_000.0; + + if count as f64 >= B { + format!("{:.2}B", count as f64 / B) + } else if count as f64 >= M { + format!("{:.2}M", count as f64 / M) + } else if count as f64 >= K { + format!("{:.2}K", count as f64 / K) + } else { + count.to_string() + } +} + +/// Format RFC3339 timestamp to human-readable relative time (e.g., "2 hours ago") +pub fn format_time_ago(timestamp: &str) -> String { + // Try to parse as RFC3339 + let created_time = match DateTime::parse_from_rfc3339(timestamp) { + Ok(dt) => dt.with_timezone(&Utc), + Err(_) => return timestamp.to_string(), // Return original if parse fails + }; + + let now = Utc::now(); + let duration = now.signed_duration_since(created_time); + + let seconds = duration.num_seconds(); + + if seconds < 0 { + "just now".to_string() + } else if seconds < 60 { + format!("{} seconds ago", seconds) + } else if seconds < 3600 { + let minutes = seconds / 60; + format!( + "{} {} ago", + minutes, + if minutes == 1 { "minute" } else { "minutes" } + ) + } else if seconds < 86400 { + let hours = seconds / 3600; + format!( + "{} {} ago", + hours, + if hours == 1 { "hour" } else { "hours" } + ) + } else if seconds < 2592000 { + let days = seconds / 86400; + format!("{} {} ago", days, if days == 1 { "day" } else { "days" }) + } else if seconds < 31536000 { + let months = seconds / 2592000; + format!( + "{} {} ago", + months, + if months == 1 { "month" } else { "months" } + ) + } else { + let years = seconds / 31536000; + format!( + "{} {} ago", + years, + if years == 1 { "year" } else { "years" } + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_size_bytes() { + assert_eq!(format_size(0), "0 B"); + assert_eq!(format_size(1), "1 B"); + assert_eq!(format_size(999), "999 B"); + assert_eq!(format_size(1023), "1023 B"); + } + + #[test] + fn test_format_size_kilobytes() { + assert_eq!(format_size(1024), "1.00 KiB"); + assert_eq!(format_size(1536), "1.50 KiB"); + assert_eq!(format_size(10240), "10.00 KiB"); + assert_eq!(format_size(1_048_575), "1024.00 KiB"); + } + + #[test] + fn test_format_size_megabytes() { + assert_eq!(format_size(1_048_576), "1.00 MiB"); + assert_eq!(format_size(1_572_864), "1.50 MiB"); + assert_eq!(format_size(10_485_760), "10.00 MiB"); + assert_eq!(format_size(524_288_000), "500.00 MiB"); + } + + #[test] + fn test_format_size_gigabytes() { + assert_eq!(format_size(1_073_741_824), "1.00 GiB"); + assert_eq!(format_size(1_610_612_736), "1.50 GiB"); + assert_eq!(format_size(10_737_418_240), "10.00 GiB"); + assert_eq!(format_size(107_374_182_400), "100.00 GiB"); + } + + #[test] + fn test_format_size_edge_cases() { + // Boundary between KiB and MiB + assert_eq!(format_size(1_048_575), "1024.00 KiB"); + assert_eq!(format_size(1_048_576), "1.00 MiB"); + + // Boundary between MiB and GiB + assert_eq!(format_size(1_073_741_823), "1024.00 MiB"); + assert_eq!(format_size(1_073_741_824), "1.00 GiB"); + } + + #[test] + fn test_format_size_realistic_model_sizes() { + // Small model (100 MiB) + assert_eq!(format_size(104_857_600), "100.00 MiB"); + + // Medium model (7 GiB) + assert_eq!(format_size(7_516_192_768), "7.00 GiB"); + + // Large model (65 GiB) + assert_eq!(format_size(69_793_218_560), "65.00 GiB"); + } + + #[test] + fn test_format_time_ago_seconds() { + use chrono::Duration; + + let now = Utc::now(); + let timestamp = (now - Duration::seconds(30)).to_rfc3339(); + assert_eq!(format_time_ago(×tamp), "30 seconds ago"); + + let timestamp = (now - Duration::seconds(1)).to_rfc3339(); + assert_eq!(format_time_ago(×tamp), "1 seconds ago"); + } + + #[test] + fn test_format_time_ago_minutes() { + use chrono::Duration; + + let now = Utc::now(); + let timestamp = (now - Duration::minutes(5)).to_rfc3339(); + assert_eq!(format_time_ago(×tamp), "5 minutes ago"); + + let timestamp = (now - Duration::minutes(1)).to_rfc3339(); + assert_eq!(format_time_ago(×tamp), "1 minute ago"); + } + + #[test] + fn test_format_time_ago_hours() { + use chrono::Duration; + + let now = Utc::now(); + let timestamp = (now - Duration::hours(3)).to_rfc3339(); + assert_eq!(format_time_ago(×tamp), "3 hours ago"); + + let timestamp = (now - Duration::hours(1)).to_rfc3339(); + assert_eq!(format_time_ago(×tamp), "1 hour ago"); + } + + #[test] + fn test_format_time_ago_days() { + use chrono::Duration; + + let now = Utc::now(); + let timestamp = (now - Duration::days(7)).to_rfc3339(); + assert_eq!(format_time_ago(×tamp), "7 days ago"); + + let timestamp = (now - Duration::days(1)).to_rfc3339(); + assert_eq!(format_time_ago(×tamp), "1 day ago"); + } + + #[test] + fn test_format_time_ago_months() { + use chrono::Duration; + + let now = Utc::now(); + let timestamp = (now - Duration::days(60)).to_rfc3339(); + assert_eq!(format_time_ago(×tamp), "2 months ago"); + + let timestamp = (now - Duration::days(30)).to_rfc3339(); + assert_eq!(format_time_ago(×tamp), "1 month ago"); + } + + #[test] + fn test_format_time_ago_years() { + use chrono::Duration; + + let now = Utc::now(); + let timestamp = (now - Duration::days(730)).to_rfc3339(); + assert_eq!(format_time_ago(×tamp), "2 years ago"); + + let timestamp = (now - Duration::days(365)).to_rfc3339(); + assert_eq!(format_time_ago(×tamp), "1 year ago"); + } + + #[test] + fn test_format_time_ago_future() { + use chrono::Duration; + + let now = Utc::now(); + let timestamp = (now + Duration::hours(5)).to_rfc3339(); + assert_eq!(format_time_ago(×tamp), "just now"); + } + + #[test] + fn test_format_time_ago_invalid() { + let invalid = "not-a-timestamp"; + assert_eq!(format_time_ago(invalid), "not-a-timestamp"); + } + + #[test] + fn test_format_size_decimal_bytes() { + assert_eq!(format_size_decimal(0), "0 B"); + assert_eq!(format_size_decimal(1), "1 B"); + assert_eq!(format_size_decimal(999), "999 B"); + } + + #[test] + fn test_format_size_decimal_kilobytes() { + assert_eq!(format_size_decimal(1000), "1.00 KB"); + assert_eq!(format_size_decimal(1500), "1.50 KB"); + assert_eq!(format_size_decimal(10000), "10.00 KB"); + assert_eq!(format_size_decimal(999_999), "1000.00 KB"); + } + + #[test] + fn test_format_size_decimal_megabytes() { + assert_eq!(format_size_decimal(1_000_000), "1.00 MB"); + assert_eq!(format_size_decimal(1_500_000), "1.50 MB"); + assert_eq!(format_size_decimal(10_000_000), "10.00 MB"); + assert_eq!(format_size_decimal(500_000_000), "500.00 MB"); + } + + #[test] + fn test_format_size_decimal_gigabytes() { + assert_eq!(format_size_decimal(1_000_000_000), "1.00 GB"); + assert_eq!(format_size_decimal(1_500_000_000), "1.50 GB"); + assert_eq!(format_size_decimal(10_000_000_000), "10.00 GB"); + assert_eq!(format_size_decimal(100_000_000_000), "100.00 GB"); + } + + #[test] + fn test_format_size_decimal_realistic_model_sizes() { + // Small model (100 MB) + assert_eq!(format_size_decimal(100_000_000), "100.00 MB"); + + // Medium model (7 GB) + assert_eq!(format_size_decimal(7_000_000_000), "7.00 GB"); + + // Large model (65 GB) + assert_eq!(format_size_decimal(65_000_000_000), "65.00 GB"); + } + + #[test] + fn test_format_parameters_raw() { + assert_eq!(format_parameters(0), "0"); + assert_eq!(format_parameters(1), "1"); + assert_eq!(format_parameters(999), "999"); + } + + #[test] + fn test_format_parameters_thousands() { + assert_eq!(format_parameters(1_000), "1.00K"); + assert_eq!(format_parameters(1_500), "1.50K"); + assert_eq!(format_parameters(10_000), "10.00K"); + assert_eq!(format_parameters(999_999), "1000.00K"); + } + + #[test] + fn test_format_parameters_millions() { + assert_eq!(format_parameters(1_000_000), "1.00M"); + assert_eq!(format_parameters(1_500_000), "1.50M"); + assert_eq!(format_parameters(7_000_000), "7.00M"); + assert_eq!(format_parameters(350_000_000), "350.00M"); + } + + #[test] + fn test_format_parameters_billions() { + assert_eq!(format_parameters(1_000_000_000), "1.00B"); + assert_eq!(format_parameters(1_500_000_000), "1.50B"); + assert_eq!(format_parameters(7_000_000_000), "7.00B"); + assert_eq!(format_parameters(175_000_000_000), "175.00B"); + } + + #[test] + fn test_format_parameters_realistic_models() { + // Tiny model (109K parameters) + assert_eq!(format_parameters(109_824), "109.82K"); + + // Small model (125M parameters) + assert_eq!(format_parameters(125_000_000), "125.00M"); + + // Medium model (7B parameters) + assert_eq!(format_parameters(7_000_000_000), "7.00B"); + + // Large model (70B parameters) + assert_eq!(format_parameters(70_000_000_000), "70.00B"); + + // Very large model (405B parameters) + assert_eq!(format_parameters(405_000_000_000), "405.00B"); + } + + #[test] + fn test_format_parameters_edge_cases() { + // Boundary between K and M + assert_eq!(format_parameters(999_999), "1000.00K"); + assert_eq!(format_parameters(1_000_000), "1.00M"); + + // Boundary between M and B + assert_eq!(format_parameters(999_999_999), "1000.00M"); + assert_eq!(format_parameters(1_000_000_000), "1.00B"); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..73f5099 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod file; +pub mod format; diff --git a/tests/cli_test.rs b/tests/cli_test.rs new file mode 100644 index 0000000..a2e1593 --- /dev/null +++ b/tests/cli_test.rs @@ -0,0 +1,286 @@ +//! CLI Integration Tests +//! +//! Tests the PUMA command-line interface by executing the binary +//! and verifying output. These tests use real processes and temporary directories. + +use std::process::Command; +use tempfile::TempDir; + +/// Helper to run puma command with custom PUMA_HOME +fn run_puma(home_dir: &str, args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_puma")) + .env("PUMA_HOME", home_dir) + .args(args) + .output() + .expect("Failed to execute puma command") +} + +/// Helper to check if output contains a string +fn output_contains(output: &std::process::Output, text: &str) -> bool { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + stdout.contains(text) || stderr.contains(text) +} + +#[test] +fn test_pull_command_with_provider() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + // Pull a real model + let output = run_puma( + home, + &["pull", "inftyai/tiny-random-gpt2", "-p", "huggingface"], + ); + assert!(output.status.success()); + + // Verify model appears in ls + let output = run_puma(home, &["ls"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("inftyai/tiny-random-gpt2")); + + // Verify model can be inspected + let output = run_puma(home, &["inspect", "inftyai/tiny-random-gpt2"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("name: inftyai/tiny-random-gpt2")); + assert!(stdout.contains("provider")); + assert!(stdout.contains("huggingface")); + + // Verify model can be removed + let output = run_puma(home, &["rm", "inftyai/tiny-random-gpt2"]); + assert!(output.status.success()); + assert!(output_contains(&output, "Successfully removed model")); + + // Verify model is gone + let output = run_puma(home, &["ls"]); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(!stdout.contains("inftyai/tiny-random-gpt2")); +} + +#[test] +fn test_rm_nonexistent() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + let output = run_puma(home, &["rm", "nonexistent/model"]); + assert!(!output.status.success()); + assert!(output_contains(&output, "Model not found")); +} + +#[test] +fn test_inspect_nonexistent() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + let output = run_puma(home, &["inspect", "nonexistent/model"]); + assert!(!output.status.success()); + assert!(output_contains(&output, "Model not found")); +} + +#[test] +fn test_version() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + let output = run_puma(home, &["version"]); + assert!(output.status.success()); + assert!(output_contains(&output, "PUMA")); +} + +#[test] +fn test_info() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + let output = run_puma(home, &["info"]); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Version")); + assert!(stdout.contains("Models")); +} + +#[test] +fn test_ls_with_invalid_regex() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + let output = run_puma(home, &["ls", "[invalid"]); + assert!(!output.status.success()); + assert!(output_contains(&output, "Invalid regex pattern")); +} + +#[test] +fn test_ls_with_invalid_query() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + let output = run_puma(home, &["ls", "-l", "invalid_format"]); + assert!(!output.status.success()); + assert!(output_contains(&output, "Invalid query format")); +} + +#[test] +fn test_ls_with_invalid_filter_column() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + let output = run_puma(home, &["ls", "-l", "invalid_column=value"]); + assert!(!output.status.success()); + assert!(output_contains(&output, "Invalid filter column")); +} + +#[test] +fn test_ps_command() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + let output = run_puma(home, &["ps"]); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("NAME")); + assert!(stdout.contains("PROVIDER")); + assert!(stdout.contains("MODEL")); +} + +#[test] +fn test_pull_command_invalid_model() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + // Pull with invalid model name should fail + let output = run_puma(home, &["pull", "invalid/nonexistent-model-12345"]); + assert!(!output.status.success()); +} + +#[test] +fn test_pull_command_modelscope_provider() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + // Test modelscope provider (currently just prints message) + let output = run_puma(home, &["pull", "test/model", "-p", "modelscope"]); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Downloading model from Modelscope") || !output.status.success()); +} + +#[test] +fn test_run_command() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + let output = run_puma(home, &["run"]); + assert!(output.status.success()); + assert!(output_contains(&output, "Creating and running a new model")); +} + +#[test] +fn test_stop_command() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + let output = run_puma(home, &["stop"]); + assert!(output.status.success()); + assert!(output_contains(&output, "Stopping one running model")); +} + +#[test] +fn test_ls_with_pattern_no_models() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + // Pattern matching on empty registry should succeed + let output = run_puma(home, &["ls", "test"]); + assert!(output.status.success()); +} + +#[test] +fn test_ls_with_sql_filter_no_models() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + // SQL filter on empty registry should succeed + let output = run_puma(home, &["ls", "-l", "author=test"]); + assert!(output.status.success()); +} + +#[test] +fn test_ls_with_multiple_filters() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + // Multiple filters separated by comma + let output = run_puma(home, &["ls", "-l", "author=test,license=mit"]); + assert!(output.status.success()); +} + +#[test] +fn test_ls_with_pattern_and_filter_combined() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + // Both pattern and filter should work together + let output = run_puma(home, &["ls", "test", "-l", "author=test"]); + assert!(output.status.success()); +} + +#[test] +fn test_invalid_command() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + let output = run_puma(home, &["invalid-command"]); + assert!(!output.status.success()); +} + +#[test] +fn test_help_command() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + let output = run_puma(home, &["--help"]); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("PUMA CLI")); + assert!(stdout.contains("Commands:")); +} + +#[test] +fn test_ls_help() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + let output = run_puma(home, &["ls", "--help"]); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("List local models")); +} + +#[test] +fn test_rm_help() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + let output = run_puma(home, &["rm", "--help"]); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Remove one model")); +} + +#[test] +fn test_inspect_help() { + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().to_str().unwrap(); + + let output = run_puma(home, &["inspect", "--help"]); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Return detailed information about a model")); +}