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.
+
+
+
+
+
-## How to Run
+**A lightweight, high-performance inference engine for local AI**
-### Build
+[](https://github.com/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
+
+[](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"));
+}