From 5779193f9360033b014092c0e918c7587298afb2 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Dec 2025 21:37:08 +0545 Subject: [PATCH 01/14] refactor(integration): integrate soar with modular crates --- Cargo.lock | 509 +++++++------ Cargo.toml | 10 +- {soar-cli => crates/soar-cli}/Cargo.toml | 5 +- {soar-cli => crates/soar-cli}/build.rs | 0 {soar-cli => crates/soar-cli}/src/cli.rs | 0 {soar-cli => crates/soar-cli}/src/download.rs | 54 +- crates/soar-cli/src/health.rs | 165 +++++ {soar-cli => crates/soar-cli}/src/inspect.rs | 122 ++-- {soar-cli => crates/soar-cli}/src/install.rs | 317 ++++++-- crates/soar-cli/src/list.rs | 680 ++++++++++++++++++ {soar-cli => crates/soar-cli}/src/logging.rs | 0 {soar-cli => crates/soar-cli}/src/main.rs | 19 +- crates/soar-cli/src/nest.rs | 45 ++ {soar-cli => crates/soar-cli}/src/progress.rs | 62 +- crates/soar-cli/src/remove.rs | 92 +++ {soar-cli => crates/soar-cli}/src/run.rs | 66 +- .../soar-cli}/src/self_actions.rs | 0 crates/soar-cli/src/state.rs | 323 +++++++++ {soar-cli => crates/soar-cli}/src/update.rs | 296 ++++---- crates/soar-cli/src/use.rs | 152 ++++ {soar-cli => crates/soar-cli}/src/utils.rs | 42 +- crates/soar-config/src/config.rs | 9 + crates/soar-config/src/display.rs | 44 ++ crates/soar-config/src/lib.rs | 1 + {soar-core => crates/soar-core}/CHANGELOG.md | 0 {soar-core => crates/soar-core}/Cargo.toml | 8 +- crates/soar-core/src/constants.rs | 10 + crates/soar-core/src/database/connection.rs | 176 +++++ crates/soar-core/src/database/mod.rs | 2 + crates/soar-core/src/database/models.rs | 274 +++++++ crates/soar-core/src/error.rs | 195 +++++ {soar-core => crates/soar-core}/src/lib.rs | 0 .../soar-core}/src/package/install.rs | 260 +++---- .../soar-core}/src/package/mod.rs | 1 + .../soar-core}/src/package/query.rs | 27 +- .../soar-core}/src/package/remove.rs | 42 +- crates/soar-core/src/package/update.rs | 37 + {soar-core => crates/soar-core}/src/utils.rs | 23 +- crates/soar-db/Cargo.toml | 4 + .../up.sql | 1 - .../up.sql | 3 +- crates/soar-db/src/connection.rs | 211 ++++++ crates/soar-db/src/error.rs | 78 ++ crates/soar-db/src/lib.rs | 92 +-- crates/soar-db/src/migration.rs | 100 ++- crates/soar-db/src/models/core.rs | 72 +- crates/soar-db/src/models/metadata.rs | 172 ++++- crates/soar-db/src/models/types.rs | 35 - crates/soar-db/src/repository/core.rs | 647 +++++++++++++++++ crates/soar-db/src/repository/metadata.rs | 492 +++++++++++++ crates/soar-db/src/repository/mod.rs | 12 + crates/soar-db/src/repository/nest.rs | 66 ++ crates/soar-db/src/schema/core.rs | 1 - crates/soar-db/src/schema/metadata.rs | 4 +- crates/soar-registry/src/lib.rs | 5 +- crates/soar-registry/src/metadata.rs | 185 ++++- crates/soar-registry/src/package.rs | 9 - crates/soar-utils/Cargo.toml | 2 + crates/soar-utils/src/error.rs | 597 +++++---------- crates/soar-utils/src/fs.rs | 10 +- soar-cli/src/health.rs | 142 ---- soar-cli/src/list.rs | 640 ----------------- soar-cli/src/nest.rs | 40 -- soar-cli/src/remove.rs | 67 -- soar-cli/src/state.rs | 314 -------- soar-cli/src/use.rs | 152 ---- soar-core/migrations/core/V5_baseline.sql | 30 - .../migrations/core/V6_add_portable_cache.sql | 1 - soar-core/migrations/metadata/V1_initial.sql | 78 -- soar-core/migrations/nests/V1_initial.sql | 5 - soar-core/src/constants.rs | 10 - soar-core/src/database/connection.rs | 59 -- soar-core/src/database/migration.rs | 116 --- soar-core/src/database/mod.rs | 7 - soar-core/src/database/models.rs | 285 -------- soar-core/src/database/nests/mod.rs | 2 - soar-core/src/database/nests/models.rs | 17 - soar-core/src/database/nests/repository.rs | 40 -- soar-core/src/database/packages/mod.rs | 5 - soar-core/src/database/packages/models.rs | 105 --- soar-core/src/database/packages/query.rs | 582 --------------- soar-core/src/database/repository.rs | 162 ----- soar-core/src/database/statements.rs | 56 -- soar-core/src/error.rs | 136 ---- soar-core/src/package/update.rs | 0 85 files changed, 5532 insertions(+), 4385 deletions(-) rename {soar-cli => crates/soar-cli}/Cargo.toml (89%) rename {soar-cli => crates/soar-cli}/build.rs (100%) rename {soar-cli => crates/soar-cli}/src/cli.rs (100%) rename {soar-cli => crates/soar-cli}/src/download.rs (86%) create mode 100644 crates/soar-cli/src/health.rs rename {soar-cli => crates/soar-cli}/src/inspect.rs (55%) rename {soar-cli => crates/soar-cli}/src/install.rs (66%) create mode 100644 crates/soar-cli/src/list.rs rename {soar-cli => crates/soar-cli}/src/logging.rs (100%) rename {soar-cli => crates/soar-cli}/src/main.rs (96%) create mode 100644 crates/soar-cli/src/nest.rs rename {soar-cli => crates/soar-cli}/src/progress.rs (71%) create mode 100644 crates/soar-cli/src/remove.rs rename {soar-cli => crates/soar-cli}/src/run.rs (72%) rename {soar-cli => crates/soar-cli}/src/self_actions.rs (100%) create mode 100644 crates/soar-cli/src/state.rs rename {soar-cli => crates/soar-cli}/src/update.rs (50%) create mode 100644 crates/soar-cli/src/use.rs rename {soar-cli => crates/soar-cli}/src/utils.rs (89%) create mode 100644 crates/soar-config/src/display.rs rename {soar-core => crates/soar-core}/CHANGELOG.md (100%) rename {soar-core => crates/soar-core}/Cargo.toml (83%) create mode 100644 crates/soar-core/src/constants.rs create mode 100644 crates/soar-core/src/database/connection.rs create mode 100644 crates/soar-core/src/database/mod.rs create mode 100644 crates/soar-core/src/database/models.rs create mode 100644 crates/soar-core/src/error.rs rename {soar-core => crates/soar-core}/src/lib.rs (100%) rename {soar-core => crates/soar-core}/src/package/install.rs (60%) rename {soar-core => crates/soar-core}/src/package/mod.rs (75%) rename {soar-core => crates/soar-core}/src/package/query.rs (69%) rename {soar-core => crates/soar-core}/src/package/remove.rs (79%) create mode 100644 crates/soar-core/src/package/update.rs rename {soar-core => crates/soar-core}/src/utils.rs (77%) create mode 100644 crates/soar-db/src/connection.rs create mode 100644 crates/soar-db/src/error.rs create mode 100644 crates/soar-db/src/repository/core.rs create mode 100644 crates/soar-db/src/repository/metadata.rs create mode 100644 crates/soar-db/src/repository/mod.rs create mode 100644 crates/soar-db/src/repository/nest.rs delete mode 100644 soar-cli/src/health.rs delete mode 100644 soar-cli/src/list.rs delete mode 100644 soar-cli/src/nest.rs delete mode 100644 soar-cli/src/remove.rs delete mode 100644 soar-cli/src/state.rs delete mode 100644 soar-cli/src/use.rs delete mode 100644 soar-core/migrations/core/V5_baseline.sql delete mode 100644 soar-core/migrations/core/V6_add_portable_cache.sql delete mode 100644 soar-core/migrations/metadata/V1_initial.sql delete mode 100644 soar-core/migrations/nests/V1_initial.sql delete mode 100644 soar-core/src/constants.rs delete mode 100644 soar-core/src/database/connection.rs delete mode 100644 soar-core/src/database/migration.rs delete mode 100644 soar-core/src/database/mod.rs delete mode 100644 soar-core/src/database/models.rs delete mode 100644 soar-core/src/database/nests/mod.rs delete mode 100644 soar-core/src/database/nests/models.rs delete mode 100644 soar-core/src/database/nests/repository.rs delete mode 100644 soar-core/src/database/packages/mod.rs delete mode 100644 soar-core/src/database/packages/models.rs delete mode 100644 soar-core/src/database/packages/query.rs delete mode 100644 soar-core/src/database/repository.rs delete mode 100644 soar-core/src/database/statements.rs delete mode 100644 soar-core/src/error.rs delete mode 100644 soar-core/src/package/update.rs diff --git a/Cargo.lock b/Cargo.lock index 958a31cf..cddb731e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,34 @@ 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 = "ansi-str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "060de1453b69f46304b28274f382132f4e72c55637cf362920926a70d090890d" +dependencies = [ + "ansitok", +] + +[[package]] +name = "ansitok" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a8acea8c2f1c60f0a92a8cd26bf96ca97db56f10bbcab238bbe0cceba659ee" +dependencies = [ + "nom", + "vte", +] + [[package]] name = "anstream" version = "0.6.20" @@ -216,6 +244,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.23.2" @@ -275,6 +309,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.2.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -403,6 +450,12 @@ dependencies = [ "url", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -580,6 +633,7 @@ dependencies = [ "diesel_derives", "downcast-rs", "libsqlite3-sys", + "serde_json", "sqlite-wasm-rs", "time", ] @@ -645,7 +699,7 @@ version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ - "litrs 1.0.0", + "litrs", ] [[package]] @@ -655,7 +709,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed6b3e31251e87acd1b74911aed84071c8364fc9087972748ade2f1094ccce34" dependencies = [ "documented-macros", - "phf 0.12.1", + "phf", "thiserror 2.0.17", ] @@ -722,18 +776,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - [[package]] name = "fast-glob" version = "1.0.0" @@ -786,12 +828,6 @@ 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 = "form_urlencoded" version = "1.2.2" @@ -940,30 +976,12 @@ dependencies = [ "scroll", ] -[[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.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "heck" version = "0.5.0" @@ -996,6 +1014,30 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -1122,25 +1164,6 @@ dependencies = [ "png", ] -[[package]] -name = "include_dir" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" -dependencies = [ - "include_dir_macros", -] - -[[package]] -name = "include_dir_macros" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" -dependencies = [ - "proc-macro2", - "quote", -] - [[package]] name = "indexmap" version = "2.12.0" @@ -1148,7 +1171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown", ] [[package]] @@ -1224,9 +1247,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -1246,9 +1269,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "libredox" @@ -1267,7 +1290,6 @@ version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" dependencies = [ - "cc", "pkg-config", "vcpkg", ] @@ -1284,12 +1306,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" -[[package]] -name = "litrs" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" - [[package]] name = "litrs" version = "1.0.0" @@ -1404,6 +1420,12 @@ dependencies = [ "quote", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "minisign-verify" version = "0.2.4" @@ -1422,13 +1444,13 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1462,6 +1484,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -1524,6 +1556,19 @@ version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +[[package]] +name = "papergrid" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1" +dependencies = [ + "ansi-str", + "ansitok", + "bytecount", + "fnv", + "unicode-width 0.2.1", +] + [[package]] name = "parking_lot" version = "0.12.4" @@ -1563,15 +1608,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared 0.11.3", -] - [[package]] name = "phf" version = "0.12.1" @@ -1579,27 +1615,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" dependencies = [ "phf_macros", - "phf_shared 0.12.1", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", + "phf_shared", ] [[package]] @@ -1609,7 +1625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b" dependencies = [ "fastrand", - "phf_shared 0.12.1", + "phf_shared", ] [[package]] @@ -1618,23 +1634,13 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368" dependencies = [ - "phf_generator 0.12.1", - "phf_shared 0.12.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", "syn", ] -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", - "uncased", -] - [[package]] name = "phf_shared" version = "0.12.1" @@ -1726,6 +1732,28 @@ dependencies = [ "toml_edit 0.22.27", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -1765,15 +1793,6 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.2" @@ -1781,7 +1800,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", - "rand_core 0.9.3", + "rand_core", ] [[package]] @@ -1791,15 +1810,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - [[package]] name = "rand_core" version = "0.9.3" @@ -1881,32 +1894,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rusqlite" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" -dependencies = [ - "bitflags", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "rusqlite-macros", - "smallvec", -] - -[[package]] -name = "rusqlite-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddfb91de75df4c93dc85781b3bc7e5d5ce3c2de9eb23dc7c4e3b82ae48b22ef8" -dependencies = [ - "fallible-iterator", - "litrs 0.4.2", - "sqlite3-parser", -] - [[package]] name = "rustc-demangle" version = "0.1.26" @@ -2198,22 +2185,25 @@ version = "0.8.1" dependencies = [ "clap", "indicatif", + "miette", "minisign-verify", "nu-ansi-term", "once_cell", - "rand 0.9.2", + "rand", "rayon", "regex", - "rusqlite", "semver", "serde", "serde_json", "soar-config", "soar-core", + "soar-db", "soar-dl", "soar-package", "soar-registry", "soar-utils", + "tabled", + "terminal_size", "tokio", "toml", "tracing", @@ -2239,13 +2229,15 @@ dependencies = [ name = "soar-core" version = "0.8.1" dependencies = [ - "include_dir", + "chrono", + "diesel", + "miette", "nix", "regex", - "rusqlite", "serde", "serde_json", "soar-config", + "soar-db", "soar-dl", "soar-package", "soar-registry", @@ -2262,8 +2254,12 @@ version = "0.1.0" dependencies = [ "diesel", "diesel_migrations", + "miette", + "regex", "serde", "serde_json", + "soar-registry", + "thiserror 2.0.17", ] [[package]] @@ -2320,9 +2316,11 @@ name = "soar-utils" version = "0.1.0" dependencies = [ "blake3", + "miette", "nix", "serial_test", "tempfile", + "thiserror 2.0.17", ] [[package]] @@ -2343,24 +2341,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "sqlite3-parser" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44b0cfa2d88bd3288931e1af41f1e8427199458c894bed44e579e0ffd6ceb74b" -dependencies = [ - "bitflags", - "cc", - "fallible-iterator", - "indexmap", - "log", - "memchr", - "phf 0.11.3", - "phf_codegen", - "phf_shared 0.11.3", - "uncased", -] - [[package]] name = "squishy" version = "0.3.2" @@ -2455,6 +2435,32 @@ dependencies = [ "syn", ] +[[package]] +name = "tabled" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d" +dependencies = [ + "ansi-str", + "ansitok", + "papergrid", + "tabled_derive", + "testing_table", +] + +[[package]] +name = "tabled_derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea5d1b13ca6cff1f9231ffd62f15eefd72543dab5e468735f1a456728a02846" +dependencies = [ + "heck", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tap" version = "1.0.1" @@ -2482,7 +2488,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2495,6 +2501,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "testing_table" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc" +dependencies = [ + "ansitok", + "unicode-width 0.2.1", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -2757,15 +2773,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" -[[package]] -name = "uncased" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" -dependencies = [ - "version_check", -] - [[package]] name = "unicode-ident" version = "1.0.19" @@ -2889,6 +2896,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2916,35 +2933,22 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" dependencies = [ "cfg-if", "js-sys", @@ -2955,9 +2959,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2965,31 +2969,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", @@ -3014,6 +3018,41 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.1.3" @@ -3021,19 +3060,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] -name = "windows-sys" -version = "0.52.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-targets 0.52.6", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", ] [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets 0.52.6", ] @@ -3047,6 +3101,15 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -3069,7 +3132,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", diff --git a/Cargo.toml b/Cargo.toml index 3508ddd3..43fb501b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,14 @@ [workspace] resolver = "2" members = [ + "crates/soar-cli", "crates/soar-config", + "crates/soar-core", "crates/soar-db", "crates/soar-dl", "crates/soar-package", "crates/soar-registry", "crates/soar-utils", - "soar-cli", - "soar-core" ] [workspace.package] @@ -25,6 +25,7 @@ compak = "0.1.0" diesel = { version = "2.3.2", features = [ "64-column-tables", "returning_clauses_for_sqlite_3_35", + "serde_json", "sqlite" ] } diesel_migrations = { version = "2.3.0", features = ["sqlite"] } @@ -38,12 +39,13 @@ regex = { version = "1.11.2", default-features = false, features = [ "unicode-case", "unicode-perl" ] } -rusqlite = { version = "0.37.0", features = ["bundled", "rusqlite-macros"] } serde = { version = "1.0.225", features = ["derive"] } serde_json = { version = "1.0.145", features = ["indexmap"] } serial_test = "3.2.0" +soar-cli = { path = "crates/soar-cli" } soar-config = { path = "crates/soar-config" } -soar-core = { path = "soar-core" } +soar-core = { path = "crates/soar-core" } +soar-db = { path = "crates/soar-db" } soar-dl = { path = "crates/soar-dl" } soar-package = { path = "crates/soar-package" } soar-registry = { path = "crates/soar-registry" } diff --git a/soar-cli/Cargo.toml b/crates/soar-cli/Cargo.toml similarity index 89% rename from soar-cli/Cargo.toml rename to crates/soar-cli/Cargo.toml index 25732dba..176fc9ce 100644 --- a/soar-cli/Cargo.toml +++ b/crates/soar-cli/Cargo.toml @@ -21,22 +21,25 @@ self = [] [dependencies] clap = { version = "4.5.47", features = ["cargo", "derive"] } indicatif = "0.18.0" +miette = { workspace = true } minisign-verify = "0.2.4" nu-ansi-term = "0.50.1" once_cell = "1.21.3" rand = "0.9.2" rayon = { workspace = true } regex = { workspace = true } -rusqlite = { workspace = true } semver = "1.0.27" serde = { workspace = true } serde_json = { workspace = true } soar-config = { workspace = true } soar-core = { workspace = true } +soar-db = { workspace = true } soar-dl = { workspace = true } soar-package = { workspace = true } soar-registry = { workspace = true } soar-utils = { workspace = true } +tabled = { version = "0.20", features = ["ansi"] } +terminal_size = "0.4" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } toml = "0.9.6" tracing = { workspace = true } diff --git a/soar-cli/build.rs b/crates/soar-cli/build.rs similarity index 100% rename from soar-cli/build.rs rename to crates/soar-cli/build.rs diff --git a/soar-cli/src/cli.rs b/crates/soar-cli/src/cli.rs similarity index 100% rename from soar-cli/src/cli.rs rename to crates/soar-cli/src/cli.rs diff --git a/soar-cli/src/download.rs b/crates/soar-cli/src/download.rs similarity index 86% rename from soar-cli/src/download.rs rename to crates/soar-cli/src/download.rs index 3bcf3847..de92cb08 100644 --- a/soar-cli/src/download.rs +++ b/crates/soar-cli/src/download.rs @@ -3,11 +3,8 @@ use std::{sync::Arc, time::Duration}; use indicatif::HumanBytes; use regex::Regex; use soar_config::config::get_config; -use soar_core::{ - database::{models::Package, packages::PackageQueryBuilder}, - package::query::PackageQuery, - SoarResult, -}; +use soar_core::{database::models::Package, package::query::PackageQuery, SoarResult}; +use soar_db::repository::metadata::MetadataRepository; use soar_dl::{ download::Download, error::DownloadError, @@ -149,11 +146,50 @@ pub async fn handle_direct_downloads( None => { // if it's not a url, try to parse it as package let state = AppState::new(); - let repo_db = state.repo_db().await?; + let metadata_mgr = state.metadata_manager().await?; let query = PackageQuery::try_from(link.as_str())?; - let builder = PackageQueryBuilder::new(repo_db.clone()); - let builder = query.apply_filters(builder); - let packages: Vec = builder.load()?.items; + + // Query packages across all repos + let packages: Vec = if let Some(ref repo_name) = query.repo_name { + metadata_mgr + .query_repo(repo_name, |conn| { + MetadataRepository::find_filtered( + conn, + query.name.as_deref(), + query.pkg_id.as_deref(), + query.version.as_deref(), + None, + None, + ) + })? + .unwrap_or_default() + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.clone(); + pkg + }) + .collect() + } else { + metadata_mgr.query_all_flat(|repo_name, conn| { + let pkgs = MetadataRepository::find_filtered( + conn, + query.name.as_deref(), + query.pkg_id.as_deref(), + query.version.as_deref(), + None, + None, + )?; + Ok(pkgs + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.to_string(); + pkg + }) + .collect()) + })? + }; if packages.is_empty() { error!("Invalid download resource '{}'", link); diff --git a/crates/soar-cli/src/health.rs b/crates/soar-cli/src/health.rs new file mode 100644 index 00000000..63fd0938 --- /dev/null +++ b/crates/soar-cli/src/health.rs @@ -0,0 +1,165 @@ +use std::{cell::RefCell, env, path::Path, rc::Rc}; + +use nu_ansi_term::Color::{Blue, Cyan, Green, Red, Yellow}; +use soar_config::config::get_config; +use soar_core::{package::remove::PackageRemover, SoarResult}; +use soar_db::repository::core::CoreRepository; +use soar_utils::{ + error::FileSystemResult, + fs::walk_dir, + path::{desktop_dir, icons_dir}, +}; +use tabled::{builder::Builder, settings::{Panel, Style, Width, peaker::PriorityMax, themes::BorderCorrection}}; +use tracing::info; + +use crate::{state::AppState, utils::{icon_or, term_width, Colored, Icons}}; + +pub async fn display_health() -> SoarResult<()> { + let path_env = env::var("PATH")?; + let bin_path = get_config().get_bin_path()?; + let path_ok = path_env.split(':').any(|p| Path::new(p) == bin_path); + + let broken_pkgs = get_broken_packages().await?; + let broken_syms = get_broken_symlinks()?; + + let mut builder = Builder::new(); + + let path_status = if path_ok { + format!("{} Configured", Colored(Green, icon_or(Icons::CHECK, "OK"))) + } else { + format!( + "{} {} not in PATH", + Colored(Yellow, icon_or(Icons::WARNING, "!")), + Colored(Blue, bin_path.display()) + ) + }; + builder.push_record(["PATH".to_string(), path_status]); + + let pkg_status = if broken_pkgs.is_empty() { + format!("{} None", Colored(Green, icon_or(Icons::CHECK, "OK"))) + } else { + format!( + "{} {} found", + Colored(Red, icon_or(Icons::CROSS, "!")), + Colored(Red, broken_pkgs.len()) + ) + }; + builder.push_record(["Broken Packages".to_string(), pkg_status]); + + let sym_status = if broken_syms.is_empty() { + format!("{} None", Colored(Green, icon_or(Icons::CHECK, "OK"))) + } else { + format!( + "{} {} found", + Colored(Red, icon_or(Icons::CROSS, "!")), + Colored(Red, broken_syms.len()) + ) + }; + builder.push_record(["Broken Symlinks".to_string(), sym_status]); + + let table = builder.build() + .with(Panel::header("System Health Check")) + .with(Style::rounded()) + .with(BorderCorrection {}) + .with(Width::wrap(term_width()).priority(PriorityMax::default())) + .to_string(); + + info!("\n{table}"); + + if !broken_pkgs.is_empty() { + info!("\nBroken packages:"); + for pkg in &broken_pkgs { + info!( + " {} {}#{}: {}", + Icons::ARROW, + Colored(Blue, &pkg.0), + Colored(Cyan, &pkg.1), + Colored(Yellow, &pkg.2) + ); + } + info!( + "Run {} to remove", + Colored(Green, "soar clean --broken") + ); + } + + if !broken_syms.is_empty() { + info!("\nBroken symlinks:"); + for path in &broken_syms { + info!(" {} {}", Icons::ARROW, Colored(Yellow, path.display())); + } + info!( + "Run {} to remove", + Colored(Green, "soar clean --broken-symlinks") + ); + } + + Ok(()) +} + +async fn get_broken_packages() -> SoarResult> { + let state = AppState::new(); + let diesel_db = state.diesel_core_db()?; + + let broken_packages = diesel_db.with_conn(|conn| CoreRepository::list_broken(conn))?; + + Ok(broken_packages + .into_iter() + .map(|p| (p.pkg_name, p.pkg_id, p.installed_path)) + .collect()) +} + +fn get_broken_symlinks() -> SoarResult> { + let broken_symlinks = Rc::new(RefCell::new(Vec::new())); + + let broken_symlinks_clone = Rc::clone(&broken_symlinks); + let mut collect_action = |path: &Path| -> FileSystemResult<()> { + if !path.exists() { + broken_symlinks_clone.borrow_mut().push(path.to_path_buf()); + } + Ok(()) + }; + + let mut soar_files_action = |path: &Path| -> FileSystemResult<()> { + if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) { + if filename.ends_with("-soar") && !path.exists() { + broken_symlinks_clone.borrow_mut().push(path.to_path_buf()); + } + } + Ok(()) + }; + + walk_dir(&get_config().get_bin_path()?, &mut collect_action)?; + walk_dir(desktop_dir(), &mut soar_files_action)?; + walk_dir(icons_dir(), &mut soar_files_action)?; + + Ok(Rc::try_unwrap(broken_symlinks) + .unwrap_or_else(|rc| rc.borrow().clone().into()) + .into_inner()) +} + +pub async fn remove_broken_packages() -> SoarResult<()> { + let state = AppState::new(); + let diesel_db = state.diesel_core_db()?.clone(); + + let broken_packages = diesel_db.with_conn(|conn| CoreRepository::list_broken(conn))?; + + if broken_packages.is_empty() { + info!("No broken packages found."); + return Ok(()); + } + + for package in broken_packages { + let pkg_name = package.pkg_name.clone(); + let pkg_id = package.pkg_id.clone(); + let installed_pkg = package.into(); + let remover = PackageRemover::new(installed_pkg, diesel_db.clone()).await; + remover.remove().await?; + + info!("Removed {}#{}", pkg_name, pkg_id); + } + + info!("Removed all broken packages"); + + Ok(()) +} diff --git a/soar-cli/src/inspect.rs b/crates/soar-cli/src/inspect.rs similarity index 55% rename from soar-cli/src/inspect.rs rename to crates/soar-cli/src/inspect.rs index b2bbce53..b3c2c1c8 100644 --- a/soar-cli/src/inspect.rs +++ b/crates/soar-cli/src/inspect.rs @@ -1,28 +1,21 @@ -use std::{ - fmt::Display, - fs, - path::PathBuf, - sync::{Arc, Mutex}, -}; +use std::{fmt::Display, fs, path::PathBuf}; use indicatif::HumanBytes; -use rusqlite::Connection; use soar_core::{ - database::{ - models::Package, - packages::{FilterCondition, PackageQueryBuilder, PaginatedResponse}, - }, + database::{connection::DieselDatabase, models::Package}, error::ErrorContext, package::query::PackageQuery, SoarResult, }; +use soar_db::repository::{core::{CoreRepository, SortDirection}, metadata::MetadataRepository}; use soar_dl::http_client::SHARED_AGENT; use tracing::{error, info}; use ureq::http::header::CONTENT_LENGTH; use crate::{ + progress::create_spinner, state::AppState, - utils::{interactive_ask, select_package_interactively}, + utils::{display_settings, interactive_ask, select_package_interactively}, }; pub enum InspectType { @@ -39,23 +32,20 @@ impl Display for InspectType { } } -fn get_installed_path( - core_db: &Arc>, - package: &Package, -) -> SoarResult> { - let installed_pkgs = PackageQueryBuilder::new(core_db.clone()) - .where_and("repo_name", FilterCondition::Eq(package.repo_name.clone())) - .where_and("pkg_id", FilterCondition::Eq(package.pkg_id.clone())) - .where_and("pkg_name", FilterCondition::Eq(package.pkg_name.clone())) - .where_and("version", FilterCondition::Eq(package.version.clone())) - .limit(1) - .load_installed()? - .items; - - if !installed_pkgs.is_empty() { - let pkg = installed_pkgs.first().unwrap(); +fn get_installed_path(diesel_db: &DieselDatabase, package: &Package) -> SoarResult> { + let installed_pkg = diesel_db.with_conn(|conn| { + CoreRepository::find_exact( + conn, + &package.repo_name, + &package.pkg_name, + &package.pkg_id, + &package.version, + ) + })?; + + if let Some(pkg) = installed_pkg { if pkg.is_installed { - return Ok(Some(PathBuf::from(pkg.installed_path.clone()))); + return Ok(Some(PathBuf::from(pkg.installed_path))); } } Ok(None) @@ -63,29 +53,63 @@ fn get_installed_path( pub async fn inspect_log(package: &str, inspect_type: InspectType) -> SoarResult<()> { let state = AppState::new(); - let core_db = state.core_db()?; - let repo_db = state.repo_db().await?; + let metadata_mgr = state.metadata_manager().await?; + let diesel_db = state.diesel_core_db()?; let query = PackageQuery::try_from(package)?; - let builder = PackageQueryBuilder::new(repo_db.clone()); - let builder = query.apply_filters(builder); - - let packages: PaginatedResponse = builder.load()?; - if packages.items.is_empty() { + let packages: Vec = if let Some(ref repo_name) = query.repo_name { + metadata_mgr + .query_repo(repo_name, |conn| { + MetadataRepository::find_filtered( + conn, + query.name.as_deref(), + query.pkg_id.as_deref(), + query.version.as_deref(), + None, + Some(SortDirection::Asc), + ) + })? + .unwrap_or_default() + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.clone(); + pkg + }) + .collect() + } else { + metadata_mgr.query_all_flat(|repo_name, conn| { + let pkgs = MetadataRepository::find_filtered( + conn, + query.name.as_deref(), + query.pkg_id.as_deref(), + query.version.as_deref(), + None, + Some(SortDirection::Asc), + )?; + Ok(pkgs + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.to_string(); + pkg + }) + .collect()) + })? + }; + + if packages.is_empty() { error!("Package {} not found", package); } else { - let selected_pkg = if packages.total > 1 { - &select_package_interactively( - packages.items, - &query.name.unwrap_or(package.to_string()), - )? - .unwrap() + let selected_pkg = if packages.len() > 1 { + &select_package_interactively(packages, &query.name.unwrap_or(package.to_string()))? + .unwrap() } else { - packages.items.first().unwrap() + packages.first().unwrap() }; - if let Some(installed_path) = get_installed_path(core_db, selected_pkg)? { + if let Some(installed_path) = get_installed_path(diesel_db, selected_pkg)? { let file = if matches!(inspect_type, InspectType::BuildLog) { installed_path.join(format!("{}.log", selected_pkg.pkg_name)) } else { @@ -132,8 +156,20 @@ pub async fn inspect_log(package: &str, inspect_type: InspectType) -> SoarResult url }; + let settings = display_settings(); + let spinner = if settings.spinners() { + let s = create_spinner(&format!("Fetching build {inspect_type}...")); + Some(s) + } else { + None + }; + let resp = SHARED_AGENT.get(url).call()?; + if let Some(ref s) = spinner { + s.finish_and_clear(); + } + if !resp.status().is_success() { error!( "Error fetching build {inspect_type} from {} [{}]", diff --git a/soar-cli/src/install.rs b/crates/soar-cli/src/install.rs similarity index 66% rename from soar-cli/src/install.rs rename to crates/soar-cli/src/install.rs index b1532da2..401f0c80 100644 --- a/soar-cli/src/install.rs +++ b/crates/soar-cli/src/install.rs @@ -11,15 +11,11 @@ use std::{ use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use minisign_verify::{PublicKey, Signature}; -use nu_ansi_term::Color::{Blue, Green}; +use nu_ansi_term::Color::{Blue, Cyan, Green, Magenta, Red, Yellow}; use rand::{distr::Alphanumeric, Rng}; -use rusqlite::Connection; use soar_config::{config::get_config, utils::default_install_patterns}; use soar_core::{ - database::{ - models::{InstalledPackage, Package}, - packages::{FilterCondition, PackageQueryBuilder, PaginatedResponse}, - }, + database::{connection::DieselDatabase, models::Package}, error::{ErrorContext, SoarError}, package::{ install::{InstallTarget, PackageInstaller}, @@ -27,9 +23,11 @@ use soar_core::{ }, SoarResult, }; +use soar_db::repository::{core::{CoreRepository, SortDirection}, metadata::MetadataRepository}; use soar_dl::types::Progress; use soar_package::integrate_package; use soar_utils::{hash::calculate_checksum, pattern::apply_sig_variants}; +use tabled::{builder::Builder, settings::{Panel, Style, themes::BorderCorrection}}; use tokio::sync::Semaphore; use tracing::{error, info, warn}; @@ -37,8 +35,8 @@ use crate::{ progress::handle_install_progress, state::AppState, utils::{ - ask_target_action, has_desktop_integration, mangle_package_symlinks, - select_package_interactively, Colored, + ask_target_action, display_settings, has_desktop_integration, icon_or, + mangle_package_symlinks, select_package_interactively, Colored, Icons, }, }; @@ -81,8 +79,17 @@ pub fn create_install_context( ) -> InstallContext { let multi_progress = Arc::new(MultiProgress::new()); let total_progress_bar = multi_progress.add(ProgressBar::new(total_packages as u64)); - total_progress_bar - .set_style(ProgressStyle::with_template("Installing {pos}/{len} {msg}").unwrap()); + let settings = display_settings(); + let style = if settings.icons() { + ProgressStyle::with_template(&format!( + "{} Installing {{pos}}/{{len}} {{msg}}", + Icons::PACKAGE + )) + .unwrap() + } else { + ProgressStyle::with_template("Installing {pos}/{len} {msg}").unwrap() + }; + total_progress_bar.set_style(style); InstallContext { multi_progress, @@ -121,10 +128,10 @@ pub async fn install_packages( no_verify: bool, ) -> SoarResult<()> { let state = AppState::new(); - let repo_db = state.repo_db().await?; - let core_db = state.core_db()?; + let metadata_mgr = state.metadata_manager().await?; + let diesel_db = state.diesel_core_db()?.clone(); - let install_targets = resolve_packages(repo_db.clone(), core_db.clone(), packages, yes, force)?; + let install_targets = resolve_packages(&state, metadata_mgr, &diesel_db, packages, yes, force)?; if install_targets.is_empty() { info!("No packages to install"); @@ -147,54 +154,144 @@ pub async fn install_packages( no_verify, ); - perform_installation(install_context, install_targets, core_db.clone(), no_notes).await + perform_installation(install_context, install_targets, diesel_db, no_notes).await } fn resolve_packages( - db: Arc>, - core_db: Arc>, + state: &AppState, + metadata_mgr: &soar_core::database::connection::MetadataManager, + diesel_db: &DieselDatabase, packages: &[String], yes: bool, force: bool, ) -> SoarResult> { + use soar_core::database::models::InstalledPackage; + let mut install_targets = Vec::new(); for package in packages { let mut query = PackageQuery::try_from(package.as_str())?; - let builder = PackageQueryBuilder::new(db.clone()); if let Some(ref pkg_id) = query.pkg_id { if pkg_id == "all" { - let builder = query.apply_filters(builder.clone()); - let packages: PaginatedResponse = builder.load()?; + let repo_pkgs: Vec = if let Some(ref repo_name) = query.repo_name { + metadata_mgr + .query_repo(repo_name, |conn| { + MetadataRepository::find_filtered( + conn, + query.name.as_deref(), + None, // no pkg_id filter for "all" + query.version.as_deref(), + None, + Some(SortDirection::Asc), + ) + })? + .unwrap_or_default() + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.clone(); + pkg + }) + .collect() + } else { + metadata_mgr.query_all_flat(|repo_name, conn| { + let pkgs = MetadataRepository::find_filtered( + conn, + query.name.as_deref(), + None, + query.version.as_deref(), + None, + Some(SortDirection::Asc), + )?; + Ok(pkgs + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.to_string(); + pkg + }) + .collect()) + })? + }; - if packages.total == 0 { - error!("Package {} not found", query.name.unwrap()); + if repo_pkgs.is_empty() { + error!("Package {} not found", query.name.as_ref().unwrap()); continue; } - let pkg = if packages.total > 1 { - let pkgs = packages.items; - &select_package_interactively(pkgs, &query.name.unwrap_or(package.clone()))? + + let pkg = if repo_pkgs.len() > 1 { + &select_package_interactively(repo_pkgs, &query.name.unwrap_or(package.clone()))? .unwrap() } else { - packages.items.first().unwrap() + repo_pkgs.first().unwrap() }; query.pkg_id = Some(pkg.pkg_id.clone()); query.name = None; } } - let builder = query.apply_filters(builder); - - let installed_packages = builder - .clone() - .database(core_db.clone()) - .load_installed()? - .items; + let installed_packages: Vec = diesel_db + .with_conn(|conn| { + CoreRepository::list_filtered( + conn, + query.repo_name.as_deref(), + query.name.as_deref(), + query.pkg_id.as_deref(), + query.version.as_deref(), + None, + None, + None, + Some(SortDirection::Asc), + ) + })? + .into_iter() + .map(Into::into) + .collect(); if query.name.is_none() && query.pkg_id.is_some() { - let packages: PaginatedResponse = builder.load()?; - for pkg in packages.items { + let repo_pkgs: Vec = if let Some(ref repo_name) = query.repo_name { + metadata_mgr + .query_repo(repo_name, |conn| { + MetadataRepository::find_filtered( + conn, + None, + query.pkg_id.as_deref(), + query.version.as_deref(), + None, + None, + ) + })? + .unwrap_or_default() + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.clone(); + pkg + }) + .collect() + } else { + metadata_mgr.query_all_flat(|repo_name, conn| { + let pkgs = MetadataRepository::find_filtered( + conn, + None, + query.pkg_id.as_deref(), + query.version.as_deref(), + None, + None, + )?; + Ok(pkgs + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.to_string(); + pkg + }) + .collect()) + })? + }; + + for pkg in repo_pkgs { let existing_install = installed_packages .iter() .find(|ip| ip.pkg_name == pkg.pkg_name) @@ -232,7 +329,7 @@ fn resolve_packages( }; if let Some(db_pkg) = - select_package(package, builder.clear_limit(), yes, &maybe_existing)? + select_package(state, metadata_mgr, package, &query, yes, &maybe_existing)? { let is_installed = installed_packages.iter().any(|ip| ip.is_installed); @@ -267,23 +364,75 @@ fn resolve_packages( } fn select_package( + _state: &AppState, + metadata_mgr: &soar_core::database::connection::MetadataManager, package_name: &str, - builder: PackageQueryBuilder, + query: &PackageQuery, yes: bool, - existing_install: &Option, + existing_install: &Option, ) -> SoarResult> { - let builder = if let Some(existing) = existing_install { - builder - .clear_filters() - .where_and("r.name", FilterCondition::Eq(existing.repo_name.clone())) - .where_and("pkg_name", FilterCondition::Eq(existing.pkg_name.clone())) - .where_and("pkg_id", FilterCondition::Eq(existing.pkg_id.clone())) + // If we have an existing install, use its details to find the package + let packages: Vec = if let Some(existing) = existing_install { + metadata_mgr + .query_repo(&existing.repo_name, |conn| { + MetadataRepository::find_filtered( + conn, + Some(&existing.pkg_name), + Some(&existing.pkg_id), + None, + None, + None, + ) + })? + .unwrap_or_default() + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = existing.repo_name.clone(); + pkg + }) + .collect() + } else if let Some(ref repo_name) = query.repo_name { + metadata_mgr + .query_repo(repo_name, |conn| { + MetadataRepository::find_filtered( + conn, + query.name.as_deref(), + query.pkg_id.as_deref(), + query.version.as_deref(), + None, + None, + ) + })? + .unwrap_or_default() + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.clone(); + pkg + }) + .collect() } else { - builder + metadata_mgr.query_all_flat(|repo_name, conn| { + let pkgs = MetadataRepository::find_filtered( + conn, + query.name.as_deref(), + query.pkg_id.as_deref(), + query.version.as_deref(), + None, + None, + )?; + Ok(pkgs + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.to_string(); + pkg + }) + .collect()) + })? }; - let packages = builder.load()?.items; - match packages.len() { 0 => { error!("Package {package_name} not found"); @@ -298,7 +447,7 @@ fn select_package( pub async fn perform_installation( ctx: InstallContext, targets: Vec, - core_db: Arc>, + core_db: DieselDatabase, no_notes: bool, ) -> SoarResult<()> { let mut handles = Vec::new(); @@ -326,6 +475,9 @@ pub async fn perform_installation( } let installed_indices = ctx.installed_indices.lock().unwrap(); + let settings = display_settings(); + let use_icons = settings.icons(); + for (idx, target) in targets.into_iter().enumerate() { let pkg = target.package; let Some((install_dir, symlinks)) = installed_indices.get(&idx) else { @@ -333,18 +485,22 @@ pub async fn perform_installation( }; info!( - "\n* {}#{} [Installed to: {}]", - pkg.pkg_name, - pkg.pkg_id, - Colored(Blue, install_dir.display()) + "\n{} {}#{}:{} [{}]", + icon_or(Icons::CHECK, "*"), + Colored(Blue, &pkg.pkg_name), + Colored(Cyan, &pkg.pkg_id), + Colored(Green, &pkg.repo_name), + Colored(Magenta, install_dir.display()) ); if !symlinks.is_empty() { - info!(" Binaries:"); + info!(" {} Binaries:", icon_or("📂", "-")); for (target, link) in symlinks { info!( - " {} -> {}", + " {} {} {} {}", + icon_or(Icons::ARROW, "->"), Colored(Green, link.display()), + icon_or("←", "<-"), Colored(Blue, target.display()) ); } @@ -352,18 +508,57 @@ pub async fn perform_installation( if !no_notes { if let Some(notes) = pkg.notes { - info!(" Notes:\n {}", notes.join("\n ")); + info!( + " {} Notes:\n {}", + icon_or("📝", "-"), + Colored(Yellow, notes.join("\n ")) + ); } - info!("\n"); } } let installed_count = ctx.installed_count.load(Ordering::Relaxed); - if installed_count > 0 { + let failed_count = ctx.failed.load(Ordering::Relaxed); + + if use_icons { + let mut builder = Builder::new(); + + if installed_count > 0 { + builder.push_record([ + format!("{} Installed", icon_or(Icons::CHECK, "+")), + format!("{}/{}", Colored(Green, installed_count), Colored(Cyan, ctx.total_packages)), + ]); + } + if failed_count > 0 { + builder.push_record([ + format!("{} Failed", icon_or(Icons::CROSS, "!")), + format!("{}", Colored(Red, failed_count)), + ]); + } + if installed_count == 0 && failed_count == 0 { + builder.push_record([ + format!("{} Status", icon_or(Icons::WARNING, "!")), + "No packages installed".to_string(), + ]); + } + + let table = builder.build() + .with(Panel::header("Installation Summary")) + .with(Style::rounded()) + .with(BorderCorrection {}) + .to_string(); + + info!("\n{table}"); + } else if installed_count > 0 { info!( - "Installed {}/{} packages", - ctx.installed_count.load(Ordering::Relaxed), - ctx.total_packages + "Installed {}/{} packages{}", + installed_count, + ctx.total_packages, + if failed_count > 0 { + format!(", {} failed", failed_count) + } else { + String::new() + } ); } else { info!("No packages installed."); @@ -375,7 +570,7 @@ pub async fn perform_installation( async fn spawn_installation_task( ctx: &InstallContext, target: InstallTarget, - core_db: Arc>, + core_db: DieselDatabase, idx: usize, fixed_width: usize, ) -> tokio::task::JoinHandle<()> { @@ -433,7 +628,7 @@ pub async fn install_single_package( ctx: &InstallContext, target: &InstallTarget, progress_callback: Arc, - core_db: Arc>, + core_db: DieselDatabase, ) -> SoarResult<(PathBuf, Vec<(PathBuf, PathBuf)>)> { let bin_dir = get_config().get_bin_path()?; diff --git a/crates/soar-cli/src/list.rs b/crates/soar-cli/src/list.rs new file mode 100644 index 00000000..7dc910b3 --- /dev/null +++ b/crates/soar-cli/src/list.rs @@ -0,0 +1,680 @@ +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, +}; + +use indicatif::HumanBytes; +use nu_ansi_term::Color::{Blue, Cyan, Green, LightRed, Magenta, Red, Yellow}; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; +use soar_config::config::get_config; +use soar_core::{ + database::models::{InstalledPackage, Package}, + package::query::PackageQuery, + SoarResult, +}; +use soar_db::repository::{ + core::{CoreRepository, SortDirection}, + metadata::MetadataRepository, +}; +use soar_utils::fs::dir_size; +use tabled::{ + builder::Builder, + settings::{peaker::PriorityMax, themes::BorderCorrection, Panel, Style, Width}, +}; +use tracing::info; + +use crate::{ + state::AppState, + utils::{ + display_settings, icon_or, pretty_package_size, term_width, vec_string, Colored, Icons, + }, +}; + +pub async fn search_packages( + query: String, + case_sensitive: bool, + limit: Option, +) -> SoarResult<()> { + let state = AppState::new(); + let metadata_mgr = state.metadata_manager().await?; + let diesel_db = state.diesel_core_db()?; + + let search_limit = limit.or(get_config().search_limit).unwrap_or(20) as i64; + + let packages: Vec = metadata_mgr.query_all_flat(|repo_name, conn| { + let pkgs = if case_sensitive { + MetadataRepository::search_case_sensitive(conn, &query, Some(search_limit))? + } else { + MetadataRepository::search(conn, &query, Some(search_limit))? + }; + Ok(pkgs + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.to_string(); + pkg + }) + .collect()) + })?; + + let installed_pkgs: HashMap<(String, String, String), bool> = diesel_db + .with_conn(|conn| { + CoreRepository::list_filtered(conn, None, None, None, None, None, None, None, None) + })? + .into_par_iter() + .map(|pkg| ((pkg.repo_name, pkg.pkg_id, pkg.pkg_name), pkg.is_installed)) + .collect(); + + let total = packages.len(); + let display_count = std::cmp::min(search_limit as usize, total); + + let mut installed_count = 0; + let mut available_count = 0; + + for package in packages.into_iter().take(display_count) { + let key = ( + package.repo_name.clone(), + package.pkg_id.clone(), + package.pkg_name.clone(), + ); + let state_icon = match installed_pkgs.get(&key) { + Some(is_installed) => { + if *is_installed { + installed_count += 1; + icon_or(Icons::INSTALLED, "+") + } else { + "?" + } + } + None => { + available_count += 1; + icon_or(Icons::NOT_INSTALLED, "-") + } + }; + + info!( + pkg_name = package.pkg_name, + pkg_id = package.pkg_id, + repo_name = package.repo_name, + pkg_type = package.pkg_type, + version = package.version, + version_upstream = package.version_upstream, + description = package.description, + size = package.ghcr_size.or(package.size), + "[{}] {}#{}:{} | {}{} | {} - {} ({})", + state_icon, + Colored(Blue, &package.pkg_name), + Colored(Cyan, &package.pkg_id), + Colored(Green, &package.repo_name), + Colored(LightRed, &package.version), + package + .version_upstream + .as_ref() + .filter(|_| package.version.starts_with("HEAD")) + .map(|upstream| format!(":{}", Colored(Yellow, &upstream))) + .unwrap_or_default(), + package + .pkg_type + .as_ref() + .map(|pkg_type| format!("{}", Colored(Magenta, &pkg_type))) + .unwrap_or_default(), + package.description, + pretty_package_size(package.ghcr_size, package.size) + ); + } + + let settings = display_settings(); + if settings.icons() { + let mut builder = Builder::new(); + builder.push_record([ + format!("{} Found", Icons::PACKAGE), + format!( + "{} (showing {})", + Colored(Cyan, total), + Colored(Green, display_count) + ), + ]); + builder.push_record([ + format!("{} Installed", icon_or(Icons::INSTALLED, "+")), + format!("{}", Colored(Green, installed_count)), + ]); + builder.push_record([ + format!("{} Available", icon_or(Icons::NOT_INSTALLED, "-")), + format!("{}", Colored(Blue, available_count)), + ]); + + let table = builder + .build() + .with(Panel::header("Search Results")) + .with(Style::rounded()) + .with(BorderCorrection {}) + .to_string(); + + info!("\n{table}"); + } else { + info!( + "{}", + Colored( + Red, + format!( + "Showing {} of {} ({} installed, {} available)", + display_count, total, installed_count, available_count + ) + ) + ); + } + + Ok(()) +} + +pub async fn query_package(query_str: String) -> SoarResult<()> { + let state = AppState::new(); + let metadata_mgr = state.metadata_manager().await?; + + let query = PackageQuery::try_from(query_str.as_str())?; + + let packages: Vec = if let Some(ref repo_name) = query.repo_name { + metadata_mgr + .query_repo(repo_name, |conn| { + MetadataRepository::find_filtered( + conn, + query.name.as_deref(), + query.pkg_id.as_deref(), + query.version.as_deref(), + None, + Some(SortDirection::Asc), + ) + })? + .unwrap_or_default() + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.clone(); + pkg + }) + .collect() + } else { + metadata_mgr.query_all_flat(|repo_name, conn| { + let pkgs = MetadataRepository::find_filtered( + conn, + query.name.as_deref(), + query.pkg_id.as_deref(), + query.version.as_deref(), + None, + Some(SortDirection::Asc), + )?; + Ok(pkgs + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.to_string(); + pkg + }) + .collect()) + })? + }; + + for package in packages { + let mut builder = Builder::new(); + + builder.push_record([ + format!("{} Name", Icons::PACKAGE), + format!( + "{}#{}:{}", + Colored(Blue, &package.pkg_name), + Colored(Cyan, &package.pkg_id), + Colored(Green, &package.repo_name) + ), + ]); + + builder.push_record(["Description".to_string(), package.description.clone()]); + + let version = format!( + "{}{}", + Colored(Blue, &package.version), + package + .version_upstream + .as_ref() + .filter(|_| package.version.starts_with("HEAD")) + .map(|u| format!(" ({})", Colored(Yellow, u))) + .unwrap_or_default() + ); + builder.push_record([format!("{} Version", Icons::VERSION), version]); + + builder.push_record([ + format!("{} Size", Icons::SIZE), + pretty_package_size(package.ghcr_size, package.size), + ]); + + if package.bsum.is_some() || package.shasum.is_some() { + let mut checksums = Vec::new(); + if let Some(ref cs) = package.bsum { + checksums.push(format!("{} (blake3)", Colored(Blue, cs))); + } + if let Some(ref cs) = package.shasum { + checksums.push(format!("{} (sha256)", Colored(Blue, cs))); + } + builder.push_record(["Checksums".to_string(), checksums.join("\n")]); + } + + if let Some(ref homepages) = package.homepages { + builder.push_record([ + "Homepages".to_string(), + homepages + .iter() + .map(|h| Colored(Blue, h).to_string()) + .collect::>() + .join("\n"), + ]); + } + + if let Some(ref licenses) = package.licenses { + builder.push_record(["Licenses".to_string(), licenses.join(", ")]); + } + + if let Some(ref maintainers) = package.maintainers { + let maintainer_strs: Vec = maintainers.iter().map(|m| m.to_string()).collect(); + builder.push_record(["Maintainers".to_string(), maintainer_strs.join(", ")]); + } + + if let Some(ref notes) = package.notes { + builder.push_record(["Notes".to_string(), notes.join("\n")]); + } + + if let Some(ref pkg_type) = package.pkg_type { + builder.push_record(["Type".to_string(), Colored(Magenta, pkg_type).to_string()]); + } + + if let Some(ref action) = package.build_action { + let build_info = format!( + "{}{}", + Colored(Blue, action), + package + .build_id + .as_ref() + .map(|id| format!(" ({})", Colored(Yellow, id))) + .unwrap_or_default() + ); + builder.push_record(["Build CI".to_string(), build_info]); + } + + if let Some(ref date) = package.build_date { + builder.push_record(["Build Date".to_string(), date.clone()]); + } + + if let Some(ref log) = package.build_log { + builder.push_record(["Build Log".to_string(), Colored(Blue, log).to_string()]); + } + + if let Some(ref script) = package.build_script { + builder.push_record([ + "Build Script".to_string(), + Colored(Blue, script).to_string(), + ]); + } + + if let Some(ref blob) = package.ghcr_blob { + builder.push_record(["GHCR Blob".to_string(), Colored(Blue, blob).to_string()]); + } else { + builder.push_record([ + "Download URL".to_string(), + Colored(Blue, &package.download_url).to_string(), + ]); + } + + if let Some(ref pkg) = package.ghcr_pkg { + builder.push_record([ + "GHCR Package".to_string(), + Colored(Blue, format!("https://{pkg}")).to_string(), + ]); + } + + if let Some(ref webindex) = package.pkg_webpage { + builder.push_record(["Index".to_string(), Colored(Blue, webindex).to_string()]); + } + + let table = builder + .build() + .with(Style::rounded()) + .with(Width::wrap(term_width()).priority(PriorityMax::default())) + .to_string(); + + info!( + pkg_name = package.pkg_name, + pkg_id = package.pkg_id, + pkg_type = package.pkg_type, + repo_name = package.repo_name, + description = package.description, + version = package.version, + version_upstream = package.version_upstream, + bsum = package.bsum, + shasum = package.shasum, + homepages = vec_string(package.homepages), + source_urls = vec_string(package.source_urls), + licenses = vec_string(package.licenses), + maintainers = vec_string(package.maintainers), + notes = vec_string(package.notes), + snapshots = vec_string(package.snapshots), + size = package.size, + download_url = package.download_url, + build_id = package.build_id, + build_date = package.build_date, + build_action = package.build_action, + build_log = package.build_log, + build_script = package.build_script, + ghcr_blob = package.ghcr_blob, + ghcr_pkg = package.ghcr_pkg, + pkg_webpage = package.pkg_webpage, + "\n{table}" + ); + } + + Ok(()) +} + +pub async fn list_packages(repo_name: Option) -> SoarResult<()> { + let state = AppState::new(); + let metadata_mgr = state.metadata_manager().await?; + let diesel_db = state.diesel_core_db()?; + + let packages: Vec = if let Some(ref repo_name) = repo_name { + metadata_mgr + .query_repo(repo_name, |conn| { + MetadataRepository::list_paginated(conn, 1, 3000) + })? + .unwrap_or_default() + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.clone(); + pkg + }) + .collect() + } else { + metadata_mgr.query_all_flat(|repo_name, conn| { + let pkgs = MetadataRepository::list_paginated(conn, 1, 3000)?; + Ok(pkgs + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.to_string(); + pkg + }) + .collect()) + })? + }; + + let installed_pkgs: HashMap<(String, String, String), bool> = diesel_db + .with_conn(|conn| { + CoreRepository::list_filtered(conn, None, None, None, None, None, None, None, None) + })? + .into_par_iter() + .map(|pkg| ((pkg.repo_name, pkg.pkg_id, pkg.pkg_name), pkg.is_installed)) + .collect(); + + let total = packages.len(); + let mut installed_count = 0; + let mut available_count = 0; + + for package in &packages { + let key = ( + package.repo_name.clone(), + package.pkg_id.clone(), + package.pkg_name.clone(), + ); + let state_icon = match installed_pkgs.get(&key) { + Some(is_installed) => { + if *is_installed { + installed_count += 1; + icon_or(Icons::INSTALLED, "+") + } else { + "?" + } + } + None => { + available_count += 1; + icon_or(Icons::NOT_INSTALLED, "-") + } + }; + + info!( + pkg_name = package.pkg_name, + pkg_id = package.pkg_id, + repo_name = package.repo_name, + pkg_type = package.pkg_type, + version = package.version, + version_upstream = package.version_upstream, + "[{}] {}#{}:{} | {}{} | {}", + state_icon, + Colored(Blue, &package.pkg_name), + Colored(Cyan, &package.pkg_id), + Colored(Cyan, &package.repo_name), + Colored(LightRed, &package.version), + package + .version_upstream + .as_ref() + .filter(|_| package.version.starts_with("HEAD")) + .map(|upstream| format!(":{}", Colored(Yellow, &upstream))) + .unwrap_or_default(), + package + .pkg_type + .as_ref() + .map(|pkg_type| format!("{}", Colored(Magenta, &pkg_type))) + .unwrap_or_default(), + ); + } + + let settings = display_settings(); + if settings.icons() { + let mut builder = Builder::new(); + builder.push_record([ + format!("{} Total", Icons::PACKAGE), + format!("{}", Colored(Cyan, total)), + ]); + builder.push_record([ + format!("{} Installed", icon_or(Icons::INSTALLED, "+")), + format!("{}", Colored(Green, installed_count)), + ]); + builder.push_record([ + format!("{} Available", icon_or(Icons::NOT_INSTALLED, "-")), + format!("{}", Colored(Blue, available_count)), + ]); + + let table = builder + .build() + .with(Panel::header("Package List")) + .with(Style::rounded()) + .with(BorderCorrection {}) + .to_string(); + + info!("\n{table}"); + } else { + info!( + "Total: {} ({} installed, {} available)", + total, installed_count, available_count + ); + } + + Ok(()) +} + +pub async fn list_installed_packages(repo_name: Option, count: bool) -> SoarResult<()> { + let state = AppState::new(); + let diesel_db = state.diesel_core_db()?; + + if count { + let count = diesel_db.with_conn(|conn| { + CoreRepository::count_distinct_installed(conn, repo_name.as_deref()) + })?; + info!("{}", count); + return Ok(()); + } + + // Get installed packages + let packages: Vec = diesel_db + .with_conn(|conn| { + CoreRepository::list_filtered( + conn, + repo_name.as_deref(), + None, + None, + None, + None, + None, + None, + None, + ) + })? + .into_iter() + .map(Into::into) + .collect(); + + let mut unique_pkgs = HashSet::new(); + let settings = display_settings(); + let use_icons = settings.icons(); + + let (installed_count, unique_count, broken_count, installed_size, broken_size) = + packages.iter().fold( + (0, 0, 0, 0, 0), + |(installed_count, unique_count, broken_count, installed_size, broken_size), + package| { + let installed_path = PathBuf::from(&package.installed_path); + let size = dir_size(&installed_path).unwrap_or(0); + let is_installed = package.is_installed && installed_path.exists(); + + let status = if is_installed { + String::new() + } else if use_icons { + format!( + " {} {}", + icon_or(Icons::BROKEN, "!"), + Colored(Red, "Broken") + ) + } else { + Colored(Red, " [Broken]").to_string() + }; + + info!( + pkg_name = package.pkg_name, + version = package.version, + repo_name = package.repo_name, + installed_date = package.installed_date.clone(), + size = %package.size, + "{}-{}:{} ({}) ({}){}", + Colored(Blue, &package.pkg_name), + Colored(Magenta, &package.version), + Colored(Cyan, &package.repo_name), + Colored(Green, &package.installed_date.clone()), + HumanBytes(size), + status, + ); + + if is_installed { + let unique_count = unique_pkgs + .insert(format!("{}-{}", package.pkg_id, package.pkg_name)) + as u32 + + unique_count; + ( + installed_count + 1, + unique_count, + broken_count, + installed_size + size, + broken_size, + ) + } else { + ( + installed_count, + unique_count, + broken_count + 1, + installed_size, + broken_size + size, + ) + } + }, + ); + + if use_icons { + let mut builder = Builder::new(); + + builder.push_record([ + format!("{} Installed", icon_or(Icons::CHECK, "+")), + format!( + "{}{} ({})", + Colored(Green, installed_count), + if installed_count != unique_count { + format!(", {} distinct", Colored(Cyan, unique_count)) + } else { + String::new() + }, + Colored(Magenta, HumanBytes(installed_size)) + ), + ]); + + if broken_count > 0 { + builder.push_record([ + format!("{} Broken", icon_or(Icons::CROSS, "!")), + format!( + "{} ({})", + Colored(Red, broken_count), + Colored(Magenta, HumanBytes(broken_size)) + ), + ]); + + let total_count = installed_count + broken_count; + let total_size = installed_size + broken_size; + builder.push_record([ + format!("{} Total", Icons::PACKAGE), + format!( + "{} ({})", + Colored(Blue, total_count), + Colored(Magenta, HumanBytes(total_size)) + ), + ]); + } + + let table = builder + .build() + .with(Panel::header("Summary")) + .with(Style::rounded()) + .with(BorderCorrection {}) + .to_string(); + + info!(installed_count, unique_count, installed_size, "\n{table}"); + } else { + info!( + installed_count, + unique_count, + installed_size, + "Installed: {}{} ({})", + installed_count, + if installed_count != unique_count { + format!(", {unique_count} distinct") + } else { + String::new() + }, + HumanBytes(installed_size), + ); + + if broken_count > 0 { + info!( + broken_count, + broken_size, + "Broken: {} ({})", + broken_count, + HumanBytes(broken_size) + ); + + let total_count = installed_count + broken_count; + let total_size = installed_size + broken_size; + info!( + total_count, + total_size, + "Total: {} ({})", + total_count, + HumanBytes(total_size) + ); + } + } + + Ok(()) +} diff --git a/soar-cli/src/logging.rs b/crates/soar-cli/src/logging.rs similarity index 100% rename from soar-cli/src/logging.rs rename to crates/soar-cli/src/logging.rs diff --git a/soar-cli/src/main.rs b/crates/soar-cli/src/main.rs similarity index 96% rename from soar-cli/src/main.rs rename to crates/soar-cli/src/main.rs index ee355525..c8ede814 100644 --- a/soar-cli/src/main.rs +++ b/crates/soar-cli/src/main.rs @@ -23,7 +23,7 @@ use soar_core::{ use soar_dl::http_client::configure_http_client; use soar_utils::path::resolve_path; use state::AppState; -use tracing::{error, info, warn}; +use tracing::{info, warn}; use update::update_packages; use ureq::Proxy; use use_package::use_alternate_package; @@ -387,7 +387,20 @@ async fn handle_cli() -> SoarResult<()> { #[tokio::main] async fn main() { + // Install miette's fancy error handler for beautiful error output + miette::set_hook(Box::new(|_| { + Box::new( + miette::MietteHandlerOpts::new() + .terminal_links(true) + .unicode(true) + .context_lines(2) + .build(), + ) + })) + .ok(); + if let Err(err) = handle_cli().await { - error!("{}", err); - }; + // Use miette's error display for Diagnostic errors + eprintln!("{:?}", miette::Report::new(err)); + } } diff --git a/crates/soar-cli/src/nest.rs b/crates/soar-cli/src/nest.rs new file mode 100644 index 00000000..f7a1f24e --- /dev/null +++ b/crates/soar-cli/src/nest.rs @@ -0,0 +1,45 @@ +use soar_core::{error::SoarError, utils::get_nests_db_conn, SoarResult}; +use soar_db::{models::nest::NewNest, repository::nest::NestRepository}; + +pub async fn add_nest(name: &str, url: &str) -> SoarResult<()> { + let full_name = format!("nest-{name}"); + let mut conn = get_nests_db_conn()?; + + let nest = NewNest { + name: &full_name, + url, + }; + NestRepository::insert(conn.conn(), &nest) + .map_err(|e| SoarError::Custom(format!("Failed to add nest: {}", e)))?; + println!("Added nest: {}", name); + Ok(()) +} + +pub async fn remove_nest(name: &str) -> SoarResult<()> { + let full_name = format!("nest-{name}"); + let mut conn = get_nests_db_conn()?; + + let deleted = NestRepository::delete_by_name(conn.conn(), &full_name) + .map_err(|e| SoarError::Custom(format!("Failed to remove nest: {}", e)))?; + + if deleted == 0 { + return Err(SoarError::Custom(format!( + "No nest found with name `{name}`" + ))); + } + println!("Removed nest: {}", name); + Ok(()) +} + +pub async fn list_nests() -> SoarResult<()> { + let mut conn = get_nests_db_conn()?; + + let nests = NestRepository::list_all(conn.conn()) + .map_err(|e| SoarError::Custom(format!("Failed to list nests: {}", e)))?; + + for nest in nests { + let display_name = nest.name.strip_prefix("nest-").unwrap_or(&nest.name); + println!("{} - {}", display_name, nest.url); + } + Ok(()) +} diff --git a/soar-cli/src/progress.rs b/crates/soar-cli/src/progress.rs similarity index 71% rename from soar-cli/src/progress.rs rename to crates/soar-cli/src/progress.rs index 4e265862..f2b9e061 100644 --- a/soar-cli/src/progress.rs +++ b/crates/soar-cli/src/progress.rs @@ -2,23 +2,71 @@ use std::sync::atomic::Ordering; use indicatif::{HumanBytes, ProgressBar, ProgressState, ProgressStyle}; use nu_ansi_term::Color::Red; +use soar_config::display::ProgressStyle as ConfigProgressStyle; use soar_core::database::models::Package; use soar_dl::types::Progress; -use crate::{install::InstallContext, utils::Colored}; +use crate::{install::InstallContext, utils::{display_settings, Colored}}; + +const SPINNER_CHARS: &str = "⠋⠙⠹⠸â ŧâ ´â Ļ⠧⠇⠏"; pub fn create_progress_bar() -> ProgressBar { let progress_bar = ProgressBar::new(0); - let style = ProgressStyle::with_template( - "{prefix} [{wide_bar:.green/white}] {bytes_per_sec:14} {computed_bytes:22}", - ) - .unwrap() - .with_key("computed_bytes", format_bytes) - .progress_chars("━━"); + let style = get_progress_style(); progress_bar.set_style(style); progress_bar } +fn get_progress_style() -> ProgressStyle { + let settings = display_settings(); + + match settings.progress_style() { + ConfigProgressStyle::Modern => { + ProgressStyle::with_template( + "{spinner:.cyan} {prefix} [{wide_bar:.green/dim}] {bytes_per_sec:>12} {computed_bytes:>22} ETA: {eta}", + ) + .unwrap() + .with_key("computed_bytes", format_bytes) + .tick_chars(SPINNER_CHARS) + .progress_chars("━━─") + } + ConfigProgressStyle::Classic => { + ProgressStyle::with_template( + "{prefix} [{wide_bar}] {bytes_per_sec:>12} {computed_bytes:>22}", + ) + .unwrap() + .with_key("computed_bytes", format_bytes) + .progress_chars("=>-") + } + ConfigProgressStyle::Minimal => { + ProgressStyle::with_template( + "{prefix} {percent:>3}% ({computed_bytes})", + ) + .unwrap() + .with_key("computed_bytes", format_bytes) + } + } +} + +pub fn create_spinner(message: &str) -> ProgressBar { + let settings = display_settings(); + let spinner = ProgressBar::new_spinner(); + + if settings.spinners() { + spinner.set_style( + ProgressStyle::with_template("{spinner:.cyan} {msg}") + .unwrap() + .tick_chars(SPINNER_CHARS) + ); + spinner.enable_steady_tick(std::time::Duration::from_millis(80)); + } else { + spinner.set_style(ProgressStyle::with_template("{msg}").unwrap()); + } + + spinner.set_message(message.to_string()); + spinner +} + fn format_bytes(state: &ProgressState, w: &mut dyn std::fmt::Write) { write!( w, diff --git a/crates/soar-cli/src/remove.rs b/crates/soar-cli/src/remove.rs new file mode 100644 index 00000000..04cf542f --- /dev/null +++ b/crates/soar-cli/src/remove.rs @@ -0,0 +1,92 @@ +use soar_core::{ + database::models::InstalledPackage, + package::{query::PackageQuery, remove::PackageRemover}, + SoarResult, +}; +use soar_db::repository::core::{CoreRepository, SortDirection}; +use tracing::{error, info, warn}; + +use crate::{state::AppState, utils::select_package_interactively}; + +pub async fn remove_packages(packages: &[String]) -> SoarResult<()> { + let state = AppState::new(); + let diesel_db = state.diesel_core_db()?.clone(); + + for package in packages { + let mut query = PackageQuery::try_from(package.as_str())?; + + if let Some(ref pkg_id) = query.pkg_id { + if pkg_id == "all" { + let installed: Vec = diesel_db + .with_conn(|conn| { + CoreRepository::list_filtered( + conn, + query.repo_name.as_deref(), + query.name.as_deref(), + None, // no pkg_id filter for "all" + query.version.as_deref(), + None, + None, + None, + Some(SortDirection::Asc), + ) + })? + .into_iter() + .map(Into::into) + .collect(); + + if installed.is_empty() { + error!("Package {} is not installed", query.name.as_ref().unwrap()); + continue; + } + + let pkg = if installed.len() > 1 { + select_package_interactively(installed, query.name.as_ref().unwrap())?.unwrap() + } else { + installed.into_iter().next().unwrap() + }; + query.pkg_id = Some(pkg.pkg_id.clone()); + query.name = None; + } + } + + let installed_pkgs: Vec = diesel_db + .with_conn(|conn| { + CoreRepository::list_filtered( + conn, + query.repo_name.as_deref(), + query.name.as_deref(), + query.pkg_id.as_deref(), + query.version.as_deref(), + None, + None, + None, + Some(SortDirection::Asc), + ) + })? + .into_iter() + .map(Into::into) + .collect(); + + if installed_pkgs.is_empty() { + warn!("Package {} is not installed.", package); + continue; + } + + for installed_pkg in installed_pkgs { + if query.name.is_none() && !installed_pkg.with_pkg_id { + continue; + } + + let remover = PackageRemover::new(installed_pkg.clone(), diesel_db.clone()).await; + remover.remove().await?; + + info!( + "Removed {}#{}", + installed_pkg.pkg_name, installed_pkg.pkg_id + ); + } + } + + Ok(()) +} diff --git a/soar-cli/src/run.rs b/crates/soar-cli/src/run.rs similarity index 72% rename from soar-cli/src/run.rs rename to crates/soar-cli/src/run.rs index 5ab3f4c3..e42ca937 100644 --- a/soar-cli/src/run.rs +++ b/crates/soar-cli/src/run.rs @@ -1,15 +1,13 @@ use std::{fs, process::Command, sync::Arc}; use soar_core::{ - database::{ - models::Package, - packages::{FilterCondition, PackageQueryBuilder}, - }, + database::models::Package, error::{ErrorContext, SoarError}, package::query::PackageQuery, utils::get_extract_dir, SoarResult, }; +use soar_db::repository::metadata::MetadataRepository; use soar_dl::{download::Download, oci::OciDownload, types::OverwriteMode}; use soar_utils::hash::calculate_checksum; @@ -44,24 +42,48 @@ pub async fn run_package( let output_path = cache_bin.join(package_name); if !output_path.exists() { - let repo_db = state.repo_db().await?; - - let mut builder = PackageQueryBuilder::new(repo_db.clone()) - .where_and("pkg_name", FilterCondition::Eq(package_name.clone())); - - if let Some(repo_name) = repo_name { - builder = builder.where_and("repo_name", FilterCondition::Eq(repo_name.to_string())); - } - - if let Some(pkg_id) = pkg_id { - builder = builder.where_and("pkg_id", FilterCondition::Eq(pkg_id.to_string())); - } - - if let Some(version) = version { - builder = builder.where_and("version", FilterCondition::Eq(version.to_string())); - } - - let packages: Vec = builder.load()?.items; + let metadata_mgr = state.metadata_manager().await?; + + let packages: Vec = if let Some(repo_name) = repo_name { + metadata_mgr + .query_repo(repo_name, |conn| { + MetadataRepository::find_filtered( + conn, + Some(package_name), + pkg_id, + version, + None, + None, + ) + })? + .unwrap_or_default() + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.to_string(); + pkg + }) + .collect() + } else { + metadata_mgr.query_all_flat(|repo_name, conn| { + let pkgs = MetadataRepository::find_filtered( + conn, + Some(package_name), + pkg_id, + version, + None, + None, + )?; + Ok(pkgs + .into_iter() + .map(|p| { + let mut pkg: Package = p.into(); + pkg.repo_name = repo_name.to_string(); + pkg + }) + .collect()) + })? + }; let package = match packages.len() { 0 => return Err(SoarError::PackageNotFound(package_name.clone())), diff --git a/soar-cli/src/self_actions.rs b/crates/soar-cli/src/self_actions.rs similarity index 100% rename from soar-cli/src/self_actions.rs rename to crates/soar-cli/src/self_actions.rs diff --git a/crates/soar-cli/src/state.rs b/crates/soar-cli/src/state.rs new file mode 100644 index 00000000..2d34b2b3 --- /dev/null +++ b/crates/soar-cli/src/state.rs @@ -0,0 +1,323 @@ +use std::{ + fs::{self, File}, + path::Path, +}; + +use nu_ansi_term::Color::{Blue, Green, Magenta, Red}; +use once_cell::sync::OnceCell; +use soar_config::{ + config::{get_config, Config}, + repository::Repository, +}; +use soar_core::{ + database::connection::{DieselDatabase, MetadataManager}, + error::{ErrorContext, SoarError}, + utils::get_nests_db_conn, + SoarResult, +}; +use std::sync::Arc; +use soar_db::{ + connection::DbConnection, + migration::DbType, + repository::{core::CoreRepository, metadata::MetadataRepository, nest::NestRepository}, +}; +use soar_registry::{ + fetch_metadata_with_etag, fetch_nest_metadata_with_etag, write_metadata_db, MetadataContent, + RemotePackage, +}; +use tracing::{error, info}; + +use crate::utils::Colored; + +fn handle_json_metadata>( + metadata: &[RemotePackage], + metadata_db: P, + repo_name: &str, +) -> SoarResult<()> { + let metadata_db = metadata_db.as_ref(); + if metadata_db.exists() { + fs::remove_file(metadata_db) + .with_context(|| format!("removing metadata file {}", metadata_db.display()))?; + } + + let mut conn = DbConnection::open(metadata_db, DbType::Metadata) + .map_err(|e| SoarError::Custom(format!("opening metadata database: {}", e)))?; + + MetadataRepository::import_packages(conn.conn(), metadata, repo_name) + .map_err(|e| SoarError::Custom(format!("importing packages: {}", e)))?; + + Ok(()) +} + +#[derive(Clone)] +pub struct AppState { + inner: Arc, +} + +struct AppStateInner { + config: Config, + diesel_core_db: OnceCell, + metadata_manager: OnceCell, +} + +impl AppState { + pub fn new() -> Self { + let config = get_config(); + + Self { + inner: Arc::new(AppStateInner { + config, + diesel_core_db: OnceCell::new(), + metadata_manager: OnceCell::new(), + }), + } + } + + pub async fn sync(&self) -> SoarResult<()> { + self.init_repo_dbs(true).await?; + self.sync_nests(true).await + } + + async fn sync_nests(&self, force: bool) -> SoarResult<()> { + let mut nests_db = get_nests_db_conn()?; + let nests = NestRepository::list_all(nests_db.conn()) + .map_err(|e| SoarError::Custom(format!("listing nests: {}", e)))?; + + let nests_repo_path = self.config().get_repositories_path()?.join("nests"); + + let mut tasks = Vec::new(); + + for nest in nests { + let etag = self.read_nest_etag(&nest.name); + let registry_nest = soar_registry::Nest { + id: nest.id as i64, + name: nest.name.clone(), + url: nest.url.clone(), + }; + let task = tokio::task::spawn(async move { + fetch_nest_metadata_with_etag(®istry_nest, force, etag).await + }); + tasks.push((task, nest)); + } + + for (task, nest) in tasks { + match task + .await + .map_err(|err| SoarError::Custom(format!("Join handle error: {err}")))? + { + Ok(Some((etag, content))) => { + let nest_path = nests_repo_path.join(&nest.name); + let metadata_db_path = nest_path.join("metadata.db"); + let nest_name = format!("nest-{}", nest.name); + + match content { + MetadataContent::SqliteDb(db_bytes) => { + write_metadata_db(&db_bytes, &metadata_db_path) + .map_err(|e| SoarError::Custom(e.to_string()))?; + } + MetadataContent::Json(packages) => { + handle_json_metadata(&packages, &metadata_db_path, &nest_name)?; + } + } + + let db = DieselDatabase::open_metadata(&metadata_db_path)?; + db.with_conn(|conn| { + MetadataRepository::update_repo_metadata(conn, &nest_name, &etag) + })?; + info!("[{}] Nest synced", Colored(Magenta, &nest.name)) + } + Err(err) => error!("Failed to sync nest {}: {err}", nest.name), + _ => {} + } + } + + Ok(()) + } + + async fn init_repo_dbs(&self, force: bool) -> SoarResult<()> { + let mut tasks = Vec::new(); + + for repo in &self.inner.config.repositories { + let repo_clone = repo.clone(); + let etag = self.read_repo_etag(&repo_clone); + let task = tokio::task::spawn(async move { + fetch_metadata_with_etag(&repo_clone, force, etag).await + }); + tasks.push((task, repo)); + } + + for (task, repo) in tasks { + match task + .await + .map_err(|err| SoarError::Custom(format!("Join handle error: {err}")))? + { + Ok(Some((etag, content))) => { + let repo_path = repo.get_path()?; + let metadata_db_path = repo_path.join("metadata.db"); + + match content { + MetadataContent::SqliteDb(db_bytes) => { + write_metadata_db(&db_bytes, &metadata_db_path) + .map_err(|e| SoarError::Custom(e.to_string()))?; + } + MetadataContent::Json(packages) => { + handle_json_metadata(&packages, &metadata_db_path, &repo.name)?; + } + } + + self.validate_packages(repo, &etag).await?; + info!("[{}] Repository synced", Colored(Magenta, &repo.name)); + } + Err(err) => error!("Failed to sync repository {}: {err}", repo.name), + _ => {} + }; + } + + Ok(()) + } + + async fn validate_packages(&self, repo: &Repository, etag: &str) -> SoarResult<()> { + let diesel_core_db = self.diesel_core_db()?; + let repo_name = repo.name.clone(); + + let repo_path = repo.get_path()?; + let metadata_db_path = repo_path.join("metadata.db"); + + let metadata_db = DieselDatabase::open_metadata(&metadata_db_path)?; + + let installed_packages = diesel_core_db.with_conn(|conn| { + CoreRepository::list_filtered( + conn, + Some(&repo_name), + None, + None, + None, + None, + None, + None, + None, + ) + })?; + + for pkg in installed_packages { + let exists = metadata_db.with_conn(|conn| { + MetadataRepository::exists_by_pkg_id(conn, &pkg.pkg_id) + })?; + + if !exists { + let replacement = metadata_db.with_conn(|conn| { + MetadataRepository::find_replacement_pkg_id(conn, &pkg.pkg_id) + })?; + + if let Some(new_pkg_id) = replacement { + info!( + "[{}] {} is replaced by {} in {}", + Colored(Blue, "Note"), + Colored(Red, &pkg.pkg_id), + Colored(Green, &new_pkg_id), + Colored(Magenta, &repo_name) + ); + + diesel_core_db.with_conn(|conn| { + CoreRepository::update_pkg_id(conn, &repo_name, &pkg.pkg_id, &new_pkg_id) + })?; + } + } + } + + metadata_db.with_conn(|conn| { + MetadataRepository::update_repo_metadata(conn, &repo.name, etag) + })?; + + Ok(()) + } + + fn create_diesel_core_db(&self) -> SoarResult { + let core_db_file = self.config().get_db_path()?.join("soar.db"); + if !core_db_file.exists() { + File::create(&core_db_file) + .with_context(|| format!("creating database file {}", core_db_file.display()))?; + } + + DieselDatabase::open_core(&core_db_file) + } + + fn create_metadata_manager(&self) -> SoarResult { + let mut manager = MetadataManager::new(); + + for repo in &self.inner.config.repositories { + if let Ok(repo_path) = repo.get_path() { + let metadata_db = repo_path.join("metadata.db"); + if metadata_db.is_file() { + manager.add_repo(&repo.name, metadata_db)?; + } + } + } + + if let Ok(mut nests_db) = get_nests_db_conn() { + if let Ok(nests) = NestRepository::list_all(nests_db.conn()) { + if let Ok(nests_repo_path) = self.config().get_repositories_path() { + let nests_repo_path = nests_repo_path.join("nests"); + for nest in nests { + let nest_path = nests_repo_path.join(&nest.name); + let metadata_db = nest_path.join("metadata.db"); + if metadata_db.is_file() { + let nest_name = format!("nest-{}", nest.name); + manager.add_repo(&nest_name, metadata_db)?; + } + } + } + } + } + + Ok(manager) + } + + #[inline] + pub fn config(&self) -> &Config { + &self.inner.config + } + + /// Reads the etag from an existing metadata database. + fn read_repo_etag(&self, repo: &Repository) -> Option { + let repo_path = repo.get_path().ok()?; + let metadata_db = repo_path.join("metadata.db"); + + if !metadata_db.exists() { + return None; + } + + let mut conn = DbConnection::open(&metadata_db, DbType::Metadata).ok()?; + MetadataRepository::get_repo_etag(conn.conn()).ok().flatten() + } + + /// Reads the etag from an existing nest metadata database. + fn read_nest_etag(&self, nest_name: &str) -> Option { + let nests_repo_path = self.config().get_repositories_path().ok()?.join("nests"); + let nest_path = nests_repo_path.join(nest_name); + let metadata_db = nest_path.join("metadata.db"); + + if !metadata_db.exists() { + return None; + } + + let mut conn = DbConnection::open(&metadata_db, DbType::Metadata).ok()?; + MetadataRepository::get_repo_etag(conn.conn()).ok().flatten() + } + + /// Returns the diesel-based core database connection. + pub fn diesel_core_db(&self) -> SoarResult<&DieselDatabase> { + self.inner + .diesel_core_db + .get_or_try_init(|| self.create_diesel_core_db()) + } + + /// Returns the metadata manager for querying package metadata across all repos. + pub async fn metadata_manager(&self) -> SoarResult<&MetadataManager> { + self.init_repo_dbs(false).await?; + self.sync_nests(false).await?; + self.inner + .metadata_manager + .get_or_try_init(|| self.create_metadata_manager()) + } +} diff --git a/soar-cli/src/update.rs b/crates/soar-cli/src/update.rs similarity index 50% rename from soar-cli/src/update.rs rename to crates/soar-cli/src/update.rs index c716ab60..792797e4 100644 --- a/soar-cli/src/update.rs +++ b/crates/soar-cli/src/update.rs @@ -1,46 +1,41 @@ -use std::{ - fs, - path::Path, - sync::{atomic::Ordering, Arc, Mutex}, -}; +use std::sync::{atomic::Ordering, Arc}; -use rusqlite::{prepare_and_bind, Connection}; use soar_core::{ database::{ + connection::DieselDatabase, models::{InstalledPackage, Package}, - packages::{FilterCondition, PackageQueryBuilder}, }, - error::{ErrorContext, SoarError}, - package::{install::InstallTarget, query::PackageQuery}, + error::SoarError, + package::{install::InstallTarget, query::PackageQuery, update::remove_old_versions}, SoarResult, }; +use soar_db::repository::{core::{CoreRepository, SortDirection}, metadata::MetadataRepository}; +use nu_ansi_term::Color::{Cyan, Green, Red}; +use tabled::{builder::Builder, settings::{Panel, Style, themes::BorderCorrection}}; use tracing::{error, info, warn}; use crate::{ install::{create_install_context, install_single_package, InstallContext}, progress::{self, create_progress_bar}, state::AppState, - utils::ask_target_action, + utils::{ask_target_action, display_settings, icon_or, Colored, Icons}, }; fn get_existing( package: &Package, - core_db: Arc>, + diesel_db: &DieselDatabase, ) -> SoarResult> { - let existing = PackageQueryBuilder::new(core_db) - .where_and("repo_name", FilterCondition::Eq(package.repo_name.clone())) - .where_and("pkg_name", FilterCondition::Eq(package.pkg_name.clone())) - .where_and("pkg_id", FilterCondition::Eq(package.pkg_id.clone())) - .where_and("version", FilterCondition::Eq(package.version.clone())) - .limit(1) - .load_installed()? - .items; - - if existing.is_empty() { - Ok(None) - } else { - Ok(Some(existing[0].clone())) - } + let existing = diesel_db.with_conn(|conn| { + CoreRepository::find_exact( + conn, + &package.repo_name, + &package.pkg_name, + &package.pkg_id, + &package.version, + ) + })?; + + Ok(existing.map(Into::into)) } pub async fn update_packages( @@ -50,8 +45,8 @@ pub async fn update_packages( no_verify: bool, ) -> SoarResult<()> { let state = AppState::new(); - let core_db = state.core_db()?; - let repo_db = state.repo_db().await?; + let metadata_mgr = state.metadata_manager().await?; + let diesel_db = state.diesel_core_db()?.clone(); let config = state.config(); let mut update_targets = Vec::new(); @@ -59,39 +54,46 @@ pub async fn update_packages( if let Some(packages) = packages { for package in packages { let query = PackageQuery::try_from(package.as_str())?; - let builder = PackageQueryBuilder::new(core_db.clone()); - let mut builder = query.apply_filters(builder.clone()).limit(1); - let installed_pkgs = builder - .clone() - .where_and("is_installed", FilterCondition::Eq("1".to_string())) - .load_installed()? - .items; - builder = builder.database(repo_db.clone()); - for pkg in installed_pkgs { - builder = builder - .where_and("repo_name", FilterCondition::Eq(pkg.repo_name.clone())) - .where_and("version", FilterCondition::Gt(pkg.version.clone())) - .where_and( - &format!( - "(version > '{}' OR version LIKE 'HEAD-%' AND substr(version, 14) > '{}')", - pkg.version, - if pkg.version.starts_with("HEAD-") && pkg.version.len() > 13 { - &pkg.version[14..] - } else { - "" - } - ), - FilterCondition::None, + let installed_pkgs: Vec = diesel_db + .with_conn(|conn| { + CoreRepository::list_filtered( + conn, + query.repo_name.as_deref(), + query.name.as_deref(), + query.pkg_id.as_deref(), + query.version.as_deref(), + Some(true), // is_installed + None, + Some(1), + Some(SortDirection::Asc), ) - .limit(1); - let new_pkg: Vec = builder.load()?.items; + })? + .into_iter() + .map(Into::into) + .collect(); - if !new_pkg.is_empty() { + for pkg in installed_pkgs { + let new_pkg: Option = metadata_mgr + .query_repo(&pkg.repo_name, |conn| { + MetadataRepository::find_newer_version( + conn, + &pkg.pkg_name, + &pkg.pkg_id, + &pkg.version, + ) + })? + .flatten() + .map(|p| { + let mut package: Package = p.into(); + package.repo_name = pkg.repo_name.clone(); + package + }); + + if let Some(package) = new_pkg { let with_pkg_id = pkg.with_pkg_id; - let package = new_pkg.first().unwrap().clone(); - let existing_install = get_existing(&package, core_db.clone())?; + let existing_install = get_existing(&package, &diesel_db)?; if let Some(ref existing_install) = existing_install { if existing_install.is_installed { continue; @@ -113,38 +115,33 @@ pub async fn update_packages( } } } else { - let installed_packages = PackageQueryBuilder::new(core_db.clone()) - .where_and("is_installed", FilterCondition::Eq("1".to_string())) - .where_and("pinned", FilterCondition::Eq(String::from("0"))) - .load_installed()? - .items; + let installed_packages: Vec = diesel_db + .with_conn(|conn| CoreRepository::list_updatable(conn))? + .into_iter() + .map(Into::into) + .collect(); for pkg in installed_packages { - let new_pkg: Vec = PackageQueryBuilder::new(repo_db.clone()) - .where_and("repo_name", FilterCondition::Eq(pkg.repo_name.clone())) - .where_and("pkg_name", FilterCondition::Eq(pkg.pkg_name.clone())) - .where_and("pkg_id", FilterCondition::Eq(pkg.pkg_id.clone())) - .where_and( - &format!( - "(version > '{}' OR version LIKE 'HEAD-%' AND substr(version, 14) > '{}')", - pkg.version, - if pkg.version.starts_with("HEAD-") && pkg.version.len() > 13 { - &pkg.version[14..] - } else { - "" - } - ), - FilterCondition::None, - ) - .limit(1) - .load()? - .items; - - if !new_pkg.is_empty() { + let new_pkg: Option = metadata_mgr + .query_repo(&pkg.repo_name, |conn| { + MetadataRepository::find_newer_version( + conn, + &pkg.pkg_name, + &pkg.pkg_id, + &pkg.version, + ) + })? + .flatten() + .map(|p| { + let mut package: Package = p.into(); + package.repo_name = pkg.repo_name.clone(); + package + }); + + if let Some(package) = new_pkg { let with_pkg_id = pkg.with_pkg_id; - let package = new_pkg.first().unwrap().clone(); - let existing_install = get_existing(&package, core_db.clone())?; + let existing_install = get_existing(&package, &diesel_db)?; if let Some(ref existing_install) = existing_install { if existing_install.is_installed { continue; @@ -187,7 +184,7 @@ pub async fn update_packages( no_verify, ); - perform_update(ctx, update_targets, core_db.clone(), keep).await?; + perform_update(ctx, update_targets, diesel_db, keep).await?; Ok(()) } @@ -195,7 +192,7 @@ pub async fn update_packages( async fn perform_update( ctx: InstallContext, targets: Vec, - core_db: Arc>, + diesel_db: DieselDatabase, keep: bool, ) -> SoarResult<()> { let mut handles = Vec::new(); @@ -205,7 +202,7 @@ async fn perform_update( let handle = spawn_update_task( &ctx, target.clone(), - core_db.clone(), + diesel_db.clone(), idx, fixed_width, keep, @@ -228,11 +225,52 @@ async fn perform_update( for error in ctx.errors.lock().unwrap().iter() { error!("{error}"); } - info!( - "Updated {}/{} packages", - ctx.installed_count.load(Ordering::Relaxed), - ctx.total_packages - ); + + let updated_count = ctx.installed_count.load(Ordering::Relaxed); + let failed_count = ctx.failed.load(Ordering::Relaxed); + let settings = display_settings(); + + if settings.icons() { + let mut builder = Builder::new(); + + if updated_count > 0 { + builder.push_record([ + format!("{} Updated", icon_or(Icons::CHECK, "+")), + format!("{}/{}", Colored(Green, updated_count), Colored(Cyan, ctx.total_packages)), + ]); + } + if failed_count > 0 { + builder.push_record([ + format!("{} Failed", icon_or(Icons::CROSS, "!")), + format!("{}", Colored(Red, failed_count)), + ]); + } + if updated_count == 0 && failed_count == 0 { + builder.push_record([ + format!("{} Status", icon_or(Icons::WARNING, "!")), + "No packages updated".to_string(), + ]); + } + + let table = builder.build() + .with(Panel::header("Update Summary")) + .with(Style::rounded()) + .with(BorderCorrection {}) + .to_string(); + + info!("\n{table}"); + } else { + info!( + "Updated {}/{} packages{}", + updated_count, + ctx.total_packages, + if failed_count > 0 { + format!(", {} failed", failed_count) + } else { + String::new() + } + ); + } Ok(()) } @@ -240,7 +278,7 @@ async fn perform_update( async fn spawn_update_task( ctx: &InstallContext, target: InstallTarget, - core_db: Arc>, + diesel_db: DieselDatabase, idx: usize, fixed_width: usize, keep: bool, @@ -278,7 +316,7 @@ async fn spawn_update_task( tokio::spawn(async move { let result = - install_single_package(&ctx, &target, progress_callback, core_db.clone()).await; + install_single_package(&ctx, &target, progress_callback, diesel_db.clone()).await; if let Err(err) = result { match err { @@ -287,7 +325,7 @@ async fn spawn_update_task( warnings.push(err); if !keep { - let _ = remove_old_package(&target.package, core_db.clone()); + let _ = remove_old_versions(&target.package, &diesel_db); } } _ => { @@ -300,7 +338,7 @@ async fn spawn_update_task( total_pb.inc(1); if !keep { - let _ = remove_old_package(&target.package, core_db.clone()); + let _ = remove_old_versions(&target.package, &diesel_db); } } @@ -308,71 +346,3 @@ async fn spawn_update_task( }) } -fn remove_old_package(package: &Package, core_db: Arc>) -> SoarResult<()> { - let conn = core_db.lock()?; - - let Package { - pkg_id, - pkg_name, - repo_name, - .. - } = package; - - let mut stmt = conn.prepare( - "SELECT installed_path - FROM packages - WHERE - pkg_id = ? - AND pkg_name = ? - AND repo_name = ? - AND pinned = 0 - AND rowid NOT IN ( - SELECT rowid - FROM packages - WHERE - pkg_id = ? - AND pkg_name = ? - AND repo_name = ? - ORDER BY rowid DESC - LIMIT 1 - )", - )?; - - let paths: Vec = stmt - .query_map( - [pkg_id, pkg_name, repo_name, pkg_id, pkg_name, repo_name], - |row| row.get(0), - )? - .filter_map(Result::ok) - .collect(); - - for path in paths { - let path = Path::new(&path); - if path.exists() { - fs::remove_dir_all(path) - .with_context(|| format!("removing directory {}", path.display()))?; - } - } - - let mut stmt = prepare_and_bind!( - conn, - "DELETE FROM packages - WHERE rowid NOT IN ( - SELECT rowid - FROM packages - WHERE - repo_name = $repo_name - AND pkg_id = $pkg_id - AND pkg_name = $pkg_name - ORDER BY rowid DESC - LIMIT 1 - ) - AND pkg_id = $pkg_id - AND pkg_name = $pkg_name - AND pinned = 0 - " - ); - stmt.raw_execute()?; - - Ok(()) -} diff --git a/crates/soar-cli/src/use.rs b/crates/soar-cli/src/use.rs new file mode 100644 index 00000000..3068b597 --- /dev/null +++ b/crates/soar-cli/src/use.rs @@ -0,0 +1,152 @@ +use std::path::PathBuf; + +use indicatif::HumanBytes; +use nu_ansi_term::Color::{Blue, Cyan, Magenta, Red}; +use soar_config::config::get_config; +use soar_core::{database::models::Package, SoarResult}; +use soar_db::repository::{core::{CoreRepository, SortDirection}, metadata::MetadataRepository}; +use soar_package::{formats::common::setup_portable_dir, integrate_package}; +use tracing::info; + +use crate::{ + state::AppState, + utils::{get_valid_selection, has_desktop_integration, mangle_package_symlinks, Colored}, +}; + +pub async fn use_alternate_package(name: &str) -> SoarResult<()> { + let state = AppState::new(); + let diesel_db = state.diesel_core_db()?; + + let packages = diesel_db.with_conn(|conn| { + CoreRepository::list_filtered( + conn, + None, + Some(name), + None, + None, + None, + None, + None, + Some(SortDirection::Asc), + ) + })?; + + if packages.is_empty() { + info!("Package is not installed"); + return Ok(()); + } + + for (idx, package) in packages.iter().enumerate() { + info!( + active = !package.unlinked, + pkg_name = package.pkg_name, + pkg_id = package.pkg_id, + repo_name = package.repo_name, + pkg_type = package.pkg_type, + version = package.version, + size = package.size, + "[{}] {}#{}:{} ({}-{}) ({}){}", + idx + 1, + Colored(Blue, &package.pkg_name), + Colored(Cyan, &package.pkg_id), + Colored(Cyan, &package.repo_name), + package + .pkg_type + .as_ref() + .map(|pkg_type| format!(":{}", Colored(Magenta, &pkg_type))) + .unwrap_or_default(), + Colored(Magenta, &package.version), + Colored(Magenta, HumanBytes(package.size as u64)), + package + .unlinked + .then(String::new) + .unwrap_or_else(|| format!(" {}", Colored(Red, "*"))) + ); + } + + if packages.len() == 1 { + return Ok(()); + } + + let selection = get_valid_selection(packages.len())?; + let selected_package = packages.into_iter().nth(selection).unwrap(); + + let pkg_name = &selected_package.pkg_name; + let pkg_id = &selected_package.pkg_id; + let checksum = selected_package.checksum.as_deref(); + + diesel_db.transaction(|conn| { + CoreRepository::unlink_others_by_checksum(conn, pkg_name, pkg_id, checksum) + })?; + + let bin_dir = get_config().get_bin_path()?; + let install_dir = PathBuf::from(&selected_package.installed_path); + + let _ = mangle_package_symlinks( + &install_dir, + &bin_dir, + selected_package.provides.as_deref(), + ) + .await?; + + let metadata_mgr = state.metadata_manager().await?; + let pkg: Vec = metadata_mgr + .query_repo(&selected_package.repo_name, |conn| { + MetadataRepository::find_filtered( + conn, + Some(name), + Some(&selected_package.pkg_id), + None, + Some(1), + None, + ) + })? + .unwrap_or_default() + .into_iter() + .map(|p| { + let mut package: Package = p.into(); + package.repo_name = selected_package.repo_name.clone(); + package + }) + .collect(); + + let installed_pkg: soar_core::database::models::InstalledPackage = selected_package.into(); + + let has_portable = installed_pkg.portable_path.is_some() + || installed_pkg.portable_home.is_some() + || installed_pkg.portable_config.is_some() + || installed_pkg.portable_share.is_some() + || installed_pkg.portable_cache.is_some(); + + if pkg.iter().all(has_desktop_integration) { + integrate_package( + &install_dir, + &installed_pkg, + installed_pkg.portable_path.as_deref(), + installed_pkg.portable_home.as_deref(), + installed_pkg.portable_config.as_deref(), + installed_pkg.portable_share.as_deref(), + installed_pkg.portable_cache.as_deref(), + ) + .await?; + } else if has_portable { + let bin_path = install_dir.join(&installed_pkg.pkg_name); + setup_portable_dir( + &bin_path, + &installed_pkg, + installed_pkg.portable_path.as_deref(), + installed_pkg.portable_home.as_deref(), + installed_pkg.portable_config.as_deref(), + installed_pkg.portable_share.as_deref(), + installed_pkg.portable_cache.as_deref(), + )?; + } + + diesel_db.transaction(|conn| { + CoreRepository::link_by_checksum(conn, &installed_pkg.pkg_name, &installed_pkg.pkg_id, installed_pkg.checksum.as_deref()) + })?; + + info!("Switched to {}#{}", installed_pkg.pkg_name, installed_pkg.pkg_id); + + Ok(()) +} diff --git a/soar-cli/src/utils.rs b/crates/soar-cli/src/utils.rs similarity index 89% rename from soar-cli/src/utils.rs rename to crates/soar-cli/src/utils.rs index ad8c2af8..86d9416e 100644 --- a/soar-cli/src/utils.rs +++ b/crates/soar-cli/src/utils.rs @@ -11,19 +11,51 @@ use std::{ use indicatif::HumanBytes; use nu_ansi_term::Color::{self, Blue, Cyan, Green, LightRed, Magenta, Red}; use serde::Serialize; -use soar_config::{config::get_config, repository::get_platform_repositories}; +use soar_config::{config::get_config, display::DisplaySettings, repository::get_platform_repositories}; use soar_core::{ - database::{ - models::{Package, PackageExt}, - packages::{PackageProvide, ProvideStrategy}, - }, + database::models::Package, error::{ErrorContext, SoarError}, package::install::InstallTarget, SoarResult, }; +use soar_db::models::types::{PackageProvide, ProvideStrategy}; +use soar_package::PackageExt; use soar_utils::{fs::is_elf, system::platform}; use tracing::{error, info}; +pub struct Icons; + +impl Icons { + pub const PACKAGE: &str = "đŸ“Ļ"; + pub const INSTALLED: &str = "✓"; + pub const NOT_INSTALLED: &str = "○"; + pub const BROKEN: &str = "✗"; + pub const ARROW: &str = "→"; + pub const WARNING: &str = "⚠"; + pub const SIZE: &str = "💾"; + pub const VERSION: &str = "🏷"; + pub const CHECK: &str = "✓"; + pub const CROSS: &str = "✗"; +} + +pub fn icon_or<'a>(icon: &'a str, fallback: &'a str) -> &'a str { + if get_config().display().icons() { + icon + } else { + fallback + } +} + +pub fn display_settings() -> DisplaySettings { + get_config().display() +} + +pub fn term_width() -> usize { + terminal_size::terminal_size() + .map(|(w, _)| w.0 as usize) + .unwrap_or(80) +} + pub static COLOR: LazyLock> = LazyLock::new(|| RwLock::new(true)); pub fn interactive_ask(ques: &str) -> SoarResult { diff --git a/crates/soar-config/src/config.rs b/crates/soar-config/src/config.rs index bc0cb6a7..8cf2617b 100644 --- a/crates/soar-config/src/config.rs +++ b/crates/soar-config/src/config.rs @@ -17,6 +17,7 @@ use tracing::{info, warn}; use crate::{ annotations::{annotate_toml_array_of_tables, annotate_toml_table}, + display::DisplaySettings, error::{ConfigError, Result}, profile::Profile, repository::{get_platform_repositories, Repository}, @@ -90,6 +91,9 @@ pub struct Config { /// Sync interval for nests pub nests_sync_interval: Option, + + /// Display settings for output formatting + pub display: Option, } pub static CONFIG: LazyLock>> = LazyLock::new(|| RwLock::new(None)); @@ -225,6 +229,7 @@ impl Config { desktop_integration: None, sync_interval: None, nests_sync_interval: None, + display: None, } } @@ -406,6 +411,10 @@ impl Config { .is_some_and(|repo| repo.desktop_integration.unwrap_or(false)) } + pub fn display(&self) -> DisplaySettings { + self.display.clone().unwrap_or_default() + } + pub fn save(&self) -> Result<()> { let config_path = CONFIG_PATH.read().unwrap().to_path_buf(); let serialized = toml::to_string_pretty(self)?; diff --git a/crates/soar-config/src/display.rs b/crates/soar-config/src/display.rs new file mode 100644 index 00000000..17675fb4 --- /dev/null +++ b/crates/soar-config/src/display.rs @@ -0,0 +1,44 @@ +use documented::{Documented, DocumentedFields}; +use serde::{Deserialize, Serialize}; + +/// Display settings for CLI output formatting +#[derive(Clone, Debug, Default, Deserialize, Serialize, Documented, DocumentedFields)] +pub struct DisplaySettings { + /// Progress bar style: "classic", "modern", or "minimal" + /// Default: "modern" + pub progress_style: Option, + + /// Show unicode icons/symbols in output + /// Default: true + pub icons: Option, + + /// Show spinners for async operations + /// Default: true + pub spinners: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ProgressStyle { + /// Classic ASCII progress bar (=>) + Classic, + /// Modern unicode progress bar with spinner and ETA + #[default] + Modern, + /// Minimal percentage-only display + Minimal, +} + +impl DisplaySettings { + pub fn progress_style(&self) -> ProgressStyle { + self.progress_style.clone().unwrap_or_default() + } + + pub fn icons(&self) -> bool { + self.icons.unwrap_or(true) + } + + pub fn spinners(&self) -> bool { + self.spinners.unwrap_or(true) + } +} diff --git a/crates/soar-config/src/lib.rs b/crates/soar-config/src/lib.rs index bf870e74..1a79103d 100644 --- a/crates/soar-config/src/lib.rs +++ b/crates/soar-config/src/lib.rs @@ -1,5 +1,6 @@ pub mod annotations; pub mod config; +pub mod display; pub mod error; pub mod profile; pub mod repository; diff --git a/soar-core/CHANGELOG.md b/crates/soar-core/CHANGELOG.md similarity index 100% rename from soar-core/CHANGELOG.md rename to crates/soar-core/CHANGELOG.md diff --git a/soar-core/Cargo.toml b/crates/soar-core/Cargo.toml similarity index 83% rename from soar-core/Cargo.toml rename to crates/soar-core/Cargo.toml index 2b51e8a1..210ca2b1 100644 --- a/soar-core/Cargo.toml +++ b/crates/soar-core/Cargo.toml @@ -11,18 +11,20 @@ readme.workspace = true categories.workspace = true [dependencies] -include_dir = "0.7.4" +chrono = "0.4" +diesel = { workspace = true } +miette = { workspace = true } nix = { version = "0.30.1", features = ["ioctl", "term", "user"] } regex = { workspace = true } -rusqlite = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } soar-config = { workspace = true } +soar-db = { workspace = true } soar-dl = { workspace = true } soar-package = { workspace = true } soar-registry = { workspace = true } soar-utils = { workspace = true } -thiserror = "2.0.16" +thiserror = { workspace = true } toml = "0.9.6" tracing = { workspace = true } ureq = { workspace = true } diff --git a/crates/soar-core/src/constants.rs b/crates/soar-core/src/constants.rs new file mode 100644 index 00000000..cda636d9 --- /dev/null +++ b/crates/soar-core/src/constants.rs @@ -0,0 +1,10 @@ +//! Constants used throughout soar-core. + +/// Magic bytes for XML files. +pub const XML_MAGIC_BYTES: [u8; 5] = [0x3c, 0x3f, 0x78, 0x6d, 0x6c]; + +/// Linux capability for CAP_SYS_ADMIN. +pub const CAP_SYS_ADMIN: i32 = 21; + +/// Linux capability for CAP_MKNOD. +pub const CAP_MKNOD: i32 = 27; diff --git a/crates/soar-core/src/database/connection.rs b/crates/soar-core/src/database/connection.rs new file mode 100644 index 00000000..0b8956b7 --- /dev/null +++ b/crates/soar-core/src/database/connection.rs @@ -0,0 +1,176 @@ +//! Database connection management. + +use std::{ + path::Path, + sync::{Arc, Mutex}, +}; + +use diesel::Connection as DieselConnection; +use soar_db::{connection::DbConnection, migration::DbType}; + +use crate::error::SoarError; + +type Result = std::result::Result; + +/// Diesel-based database connection wrapper. +/// Provides a thread-safe wrapper around soar_db::DbConnection. +pub struct DieselDatabase { + conn: Arc>, +} + +impl DieselDatabase { + /// Opens a core database connection with migrations. + pub fn open_core>(path: P) -> Result { + let conn = DbConnection::open(path, DbType::Core) + .map_err(|e| SoarError::Custom(format!("opening core database: {}", e)))?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + /// Opens a metadata database connection with migrations. + pub fn open_metadata>(path: P) -> Result { + let conn = DbConnection::open(path, DbType::Metadata) + .map_err(|e| SoarError::Custom(format!("opening metadata database: {}", e)))?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + /// Opens a nests database connection with migrations. + pub fn open_nests>(path: P) -> Result { + let conn = DbConnection::open(path, DbType::Nest) + .map_err(|e| SoarError::Custom(format!("opening nests database: {}", e)))?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + /// Gets a mutable reference to the underlying connection. + /// Locks the mutex and returns a guard. + pub fn conn(&self) -> std::sync::MutexGuard<'_, DbConnection> { + self.conn.lock().unwrap() + } + + /// Executes a function with the connection. + pub fn with_conn(&self, f: F) -> Result + where + F: FnOnce(&mut diesel::SqliteConnection) -> diesel::QueryResult, + { + let mut conn = self.conn.lock().map_err(|_| SoarError::PoisonError)?; + f(conn.conn()).map_err(|e| SoarError::Custom(format!("database error: {}", e))) + } + + /// Executes a function within a transaction. + pub fn transaction(&self, f: F) -> Result + where + F: FnOnce(&mut diesel::SqliteConnection) -> diesel::QueryResult, + { + let mut conn = self.conn.lock().map_err(|_| SoarError::PoisonError)?; + conn.conn() + .transaction(f) + .map_err(|e| SoarError::Custom(format!("transaction error: {}", e))) + } + + /// Gets a clone of the Arc for sharing. + pub fn clone_arc(&self) -> Arc> { + self.conn.clone() + } +} + +impl Clone for DieselDatabase { + fn clone(&self) -> Self { + Self { + conn: self.conn.clone(), + } + } +} + +/// Manager for multiple metadata databases (one per repository). +/// Replaces the ATTACH DATABASE pattern with separate connections. +pub struct MetadataManager { + databases: Vec<(String, DieselDatabase)>, +} + +impl MetadataManager { + pub fn new() -> Self { + Self { + databases: Vec::new(), + } + } + + /// Adds a metadata database for a repository. + pub fn add_repo>(&mut self, repo_name: &str, path: P) -> Result<()> { + let db = DieselDatabase::open_metadata(path)?; + self.databases.push((repo_name.to_string(), db)); + Ok(()) + } + + /// Executes a query function across all repositories and collects results. + pub fn query_all(&self, f: F) -> Result> + where + F: Fn(&str, &mut diesel::SqliteConnection) -> diesel::QueryResult, + { + let mut results = Vec::new(); + for (repo_name, db) in &self.databases { + let result = db.with_conn(|conn| f(repo_name, conn))?; + results.push((repo_name.clone(), result)); + } + Ok(results) + } + + /// Queries all repositories and flattens results into a single Vec. + pub fn query_all_flat(&self, f: F) -> Result> + where + F: Fn(&str, &mut diesel::SqliteConnection) -> diesel::QueryResult>, + { + let mut results = Vec::new(); + for (repo_name, db) in &self.databases { + let items = db.with_conn(|conn| f(repo_name, conn))?; + results.extend(items); + } + Ok(results) + } + + /// Queries a specific repository. + pub fn query_repo(&self, repo_name: &str, f: F) -> Result> + where + F: FnOnce(&mut diesel::SqliteConnection) -> diesel::QueryResult, + { + for (name, db) in &self.databases { + if name == repo_name { + return db.with_conn(f).map(Some); + } + } + Ok(None) + } + + /// Gets the first match from any repository. + pub fn find_first(&self, f: F) -> Result> + where + F: Fn(&str, &mut diesel::SqliteConnection) -> diesel::QueryResult>, + { + for (repo_name, db) in &self.databases { + if let Some(result) = db.with_conn(|conn| f(repo_name, conn))? { + return Ok(Some(result)); + } + } + Ok(None) + } + + /// Returns the number of repositories. + pub fn repo_count(&self) -> usize { + self.databases.len() + } + + /// Returns the list of repository names. + pub fn repo_names(&self) -> Vec<&str> { + self.databases.iter().map(|(name, _)| name.as_str()).collect() + } +} + +impl Default for MetadataManager { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/soar-core/src/database/mod.rs b/crates/soar-core/src/database/mod.rs new file mode 100644 index 00000000..6a9dd9b0 --- /dev/null +++ b/crates/soar-core/src/database/mod.rs @@ -0,0 +1,2 @@ +pub mod connection; +pub mod models; diff --git a/crates/soar-core/src/database/models.rs b/crates/soar-core/src/database/models.rs new file mode 100644 index 00000000..96dc46e1 --- /dev/null +++ b/crates/soar-core/src/database/models.rs @@ -0,0 +1,274 @@ +//! Database models for soar-core. + +use std::fmt::Display; + +use serde::{Deserialize, Serialize}; +use soar_db::{models::types::PackageProvide, repository::core::InstalledPackageWithPortable}; +use soar_package::PackageExt; + +/// Package maintainer information. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Maintainer { + pub name: String, + pub contact: String, +} + +impl Display for Maintainer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.name, self.contact) + } +} + +/// Remote package metadata from repository. +#[derive(Debug, Clone, Default)] +pub struct Package { + pub id: u64, + pub repo_name: String, + pub disabled: Option, + pub disabled_reason: Option, + pub pkg_id: String, + pub pkg_name: String, + pub pkg_family: Option, + pub pkg_type: Option, + pub pkg_webpage: Option, + pub app_id: Option, + pub description: String, + pub version: String, + pub version_upstream: Option, + pub licenses: Option>, + pub download_url: String, + pub size: Option, + pub ghcr_pkg: Option, + pub ghcr_size: Option, + pub ghcr_files: Option>, + pub ghcr_blob: Option, + pub ghcr_url: Option, + pub bsum: Option, + pub shasum: Option, + pub homepages: Option>, + pub notes: Option>, + pub source_urls: Option>, + pub tags: Option>, + pub categories: Option>, + pub icon: Option, + pub desktop: Option, + pub appstream: Option, + pub build_id: Option, + pub build_date: Option, + pub build_action: Option, + pub build_script: Option, + pub build_log: Option, + pub provides: Option>, + pub snapshots: Option>, + pub repology: Option>, + pub maintainers: Option>, + pub replaces: Option>, + pub bundle: bool, + pub bundle_type: Option, + pub soar_syms: bool, + pub deprecated: bool, + pub desktop_integration: Option, + pub external: Option, + pub installable: Option, + pub portable: Option, + pub trusted: Option, + pub version_latest: Option, + pub version_outdated: Option, + pub recurse_provides: Option, +} + +impl PackageExt for Package { + fn pkg_name(&self) -> &str { + &self.pkg_name + } + + fn pkg_id(&self) -> &str { + &self.pkg_id + } + + fn version(&self) -> &str { + &self.version + } + + fn repo_name(&self) -> &str { + &self.repo_name + } +} + +/// Installed package record. +#[derive(Debug, Clone)] +pub struct InstalledPackage { + pub id: u64, + pub repo_name: String, + pub pkg_id: String, + pub pkg_name: String, + pub pkg_type: Option, + pub version: String, + pub size: u64, + pub checksum: Option, + pub installed_path: String, + pub installed_date: String, + pub profile: String, + pub pinned: bool, + pub is_installed: bool, + pub with_pkg_id: bool, + pub detached: bool, + pub unlinked: bool, + pub provides: Option>, + pub portable_path: Option, + pub portable_home: Option, + pub portable_config: Option, + pub portable_share: Option, + pub portable_cache: Option, + pub install_patterns: Option>, +} + +impl PackageExt for InstalledPackage { + fn pkg_name(&self) -> &str { + &self.pkg_name + } + + fn pkg_id(&self) -> &str { + &self.pkg_id + } + + fn version(&self) -> &str { + &self.version + } + + fn repo_name(&self) -> &str { + &self.repo_name + } +} + +/// Conversion from soar-db InstalledPackageWithPortable to soar-core InstalledPackage. +impl From for InstalledPackage { + fn from(pkg: InstalledPackageWithPortable) -> Self { + Self { + id: pkg.id as u64, + repo_name: pkg.repo_name, + pkg_id: pkg.pkg_id, + pkg_name: pkg.pkg_name, + pkg_type: pkg.pkg_type, + version: pkg.version, + size: pkg.size as u64, + checksum: pkg.checksum, + installed_path: pkg.installed_path, + installed_date: pkg.installed_date, + profile: pkg.profile, + pinned: pkg.pinned, + is_installed: pkg.is_installed, + with_pkg_id: pkg.with_pkg_id, + detached: pkg.detached, + unlinked: pkg.unlinked, + provides: pkg.provides, + portable_path: pkg.portable_path, + portable_home: pkg.portable_home, + portable_config: pkg.portable_config, + portable_share: pkg.portable_share, + portable_cache: pkg.portable_cache, + install_patterns: pkg.install_patterns, + } + } +} + +/// Conversion from soar-db core Package to soar-core InstalledPackage. +impl From for InstalledPackage { + fn from(pkg: soar_db::repository::core::InstalledPackage) -> Self { + Self { + id: pkg.id as u64, + repo_name: pkg.repo_name, + pkg_id: pkg.pkg_id, + pkg_name: pkg.pkg_name, + pkg_type: pkg.pkg_type, + version: pkg.version, + size: pkg.size as u64, + checksum: pkg.checksum, + installed_path: pkg.installed_path, + installed_date: pkg.installed_date, + profile: pkg.profile, + pinned: pkg.pinned, + is_installed: pkg.is_installed, + with_pkg_id: pkg.with_pkg_id, + detached: pkg.detached, + unlinked: pkg.unlinked, + provides: pkg.provides, + portable_path: None, + portable_home: None, + portable_config: None, + portable_share: None, + portable_cache: None, + install_patterns: pkg.install_patterns, + } + } +} + +/// Conversion from soar-db metadata Package to soar-core Package. +impl From for Package { + fn from(pkg: soar_db::models::metadata::Package) -> Self { + Self { + id: pkg.id as u64, + repo_name: String::new(), // Set by caller + disabled: None, + disabled_reason: None, + pkg_id: pkg.pkg_id, + pkg_name: pkg.pkg_name, + pkg_family: None, + pkg_type: pkg.pkg_type, + pkg_webpage: pkg.pkg_webpage, + app_id: pkg.app_id, + description: pkg.description.unwrap_or_default(), + version: pkg.version, + version_upstream: pkg.version_upstream, + licenses: pkg.licenses, + download_url: pkg.download_url, + size: pkg.size.map(|s| s as u64), + ghcr_pkg: pkg.ghcr_pkg, + ghcr_size: pkg.ghcr_size.map(|s| s as u64), + ghcr_files: None, + ghcr_blob: pkg.ghcr_blob, + ghcr_url: pkg.ghcr_url, + bsum: pkg.bsum.clone(), + shasum: pkg.bsum, + homepages: pkg.homepages, + notes: pkg.notes, + source_urls: pkg.source_urls, + tags: pkg.tags, + categories: pkg.categories, + icon: pkg.icon, + desktop: pkg.desktop, + appstream: pkg.appstream, + build_id: pkg.build_id, + build_date: pkg.build_date, + build_action: pkg.build_action, + build_script: pkg.build_script, + build_log: pkg.build_log, + provides: pkg.provides, + snapshots: pkg.snapshots, + repology: None, + maintainers: None, + replaces: pkg.replaces, + bundle: false, + bundle_type: None, + soar_syms: pkg.soar_syms, + deprecated: false, + desktop_integration: pkg.desktop_integration, + external: None, + installable: None, + portable: pkg.portable, + trusted: None, + version_latest: None, + version_outdated: None, + recurse_provides: pkg.recurse_provides, + } + } +} + +/// Conversion from soar-db PackageWithRepo to soar-core Package. +impl From for Package { + fn from(pkg_with_repo: soar_db::models::metadata::PackageWithRepo) -> Self { + let mut pkg: Package = pkg_with_repo.package.into(); + pkg.repo_name = pkg_with_repo.repo_name; + pkg + } +} diff --git a/crates/soar-core/src/error.rs b/crates/soar-core/src/error.rs new file mode 100644 index 00000000..4f28c9c8 --- /dev/null +++ b/crates/soar-core/src/error.rs @@ -0,0 +1,195 @@ +//! Error types for soar-core. + +use std::error::Error; + +use miette::Diagnostic; +use soar_config::error::ConfigError; +use soar_utils::error::{FileSystemError, HashError, PathError}; +use thiserror::Error; + +/// Core error type for soar package manager operations. +#[derive(Error, Diagnostic, Debug)] +pub enum SoarError { + #[error(transparent)] + #[diagnostic(transparent)] + Config(#[from] ConfigError), + + #[error("System error: {0}")] + #[diagnostic( + code(soar::system), + help("Check system permissions and resources") + )] + Errno(#[from] nix::errno::Errno), + + #[error("Environment variable '{0}' not set")] + #[diagnostic( + code(soar::env_var), + help("Set the required environment variable before running") + )] + VarError(#[from] std::env::VarError), + + #[error(transparent)] + #[diagnostic(transparent)] + FileSystemError(#[from] FileSystemError), + + #[error(transparent)] + #[diagnostic(transparent)] + HashError(#[from] HashError), + + #[error(transparent)] + #[diagnostic(transparent)] + PathError(#[from] PathError), + + #[error("IO error while {action}")] + #[diagnostic( + code(soar::io), + help("Check file permissions and disk space") + )] + IoError { + action: String, + #[source] + source: std::io::Error, + }, + + #[error("System time error: {0}")] + #[diagnostic(code(soar::time))] + SystemTimeError(#[from] std::time::SystemTimeError), + + #[error("TOML serialization error: {0}")] + #[diagnostic( + code(soar::toml), + help("Check your configuration syntax") + )] + TomlError(#[from] toml::ser::Error), + + #[error("Database operation failed: {0}")] + #[diagnostic( + code(soar::database), + help("Try running 'soar sync' to refresh the database") + )] + DatabaseError(String), + + #[error("HTTP request failed")] + #[diagnostic( + code(soar::network), + help("Check your internet connection and try again") + )] + UreqError(#[from] ureq::Error), + + #[error(transparent)] + #[diagnostic(transparent)] + DownloadError(#[from] soar_dl::error::DownloadError), + + #[error(transparent)] + #[diagnostic(transparent)] + PackageError(#[from] soar_package::PackageError), + + #[error("Package integration failed: {0}")] + #[diagnostic( + code(soar::integration), + help("Check if the package format is supported") + )] + PackageIntegrationFailed(String), + + #[error("Package '{0}' not found")] + #[diagnostic( + code(soar::package_not_found), + help("Run 'soar sync' to update package list, or check the package name") + )] + PackageNotFound(String), + + #[error("Failed to fetch from remote source: {0}")] + #[diagnostic( + code(soar::fetch), + help("Check your internet connection and repository URL") + )] + FailedToFetchRemote(String), + + #[error("Invalid path specified")] + #[diagnostic( + code(soar::invalid_path), + help("Provide a valid file or directory path") + )] + InvalidPath, + + #[error("Thread lock poison error")] + #[diagnostic( + code(soar::poison), + help("This is an internal error, please report it") + )] + PoisonError, + + #[error("Invalid checksum detected")] + #[diagnostic( + code(soar::checksum), + help("The downloaded file may be corrupted. Try downloading again.") + )] + InvalidChecksum, + + #[error("Invalid package query: {0}")] + #[diagnostic( + code(soar::invalid_query), + help("Use format: name#pkg_id@version:repo (e.g., 'curl', 'curl#bin', 'curl@8.0.0')") + )] + InvalidPackageQuery(String), + + #[error("{0}")] + #[diagnostic(code(soar::error))] + Custom(String), + + #[error("{0}")] + #[diagnostic(code(soar::warning), severity(warning))] + Warning(String), + + #[error("Regex compilation error: {0}")] + #[diagnostic( + code(soar::regex), + help("Check your regex pattern syntax") + )] + RegexError(#[from] regex::Error), +} + +impl SoarError { + pub fn message(&self) -> String { + self.to_string() + } + + pub fn root_cause(&self) -> String { + match self { + Self::UreqError(e) => { + format!( + "Root cause: {}", + e.source() + .map_or_else(|| e.to_string(), |source| source.to_string()) + ) + } + Self::Config(err) => err.to_string(), + _ => self.to_string(), + } + } +} + +impl From> for SoarError { + fn from(_: std::sync::PoisonError) -> Self { + Self::PoisonError + } +} + +/// Trait for adding context to IO errors. +pub trait ErrorContext { + fn with_context(self, context: C) -> std::result::Result + where + C: FnOnce() -> String; +} + +impl ErrorContext for std::io::Result { + fn with_context(self, context: C) -> std::result::Result + where + C: FnOnce() -> String, + { + self.map_err(|err| SoarError::IoError { + action: context(), + source: err, + }) + } +} diff --git a/soar-core/src/lib.rs b/crates/soar-core/src/lib.rs similarity index 100% rename from soar-core/src/lib.rs rename to crates/soar-core/src/lib.rs diff --git a/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs similarity index 60% rename from soar-core/src/package/install.rs rename to crates/soar-core/src/package/install.rs index b930a7dd..5f48a098 100644 --- a/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -1,13 +1,17 @@ use std::{ env, fs, path::{Path, PathBuf}, - sync::{Arc, Mutex}, thread::sleep, time::Duration, }; -use rusqlite::{params, prepare_and_bind, Connection}; +use chrono::Utc; +use serde_json::json; use soar_config::config::get_config; +use soar_db::{ + models::types::ProvideStrategy, + repository::core::{CoreRepository, InstalledPackageWithPortable, NewInstalledPackage}, +}; use soar_dl::{ download::Download, error::DownloadError, @@ -23,10 +27,7 @@ use soar_utils::{ }; use crate::{ - database::{ - models::{InstalledPackage, Package}, - packages::{FilterCondition, PackageQueryBuilder, ProvideStrategy}, - }, + database::{connection::DieselDatabase, models::Package}, error::{ErrorContext, SoarError}, utils::get_extract_dir, SoarResult, @@ -35,8 +36,8 @@ use crate::{ pub struct PackageInstaller { package: Package, install_dir: PathBuf, - progress_callback: Option>, - db: Arc>, + progress_callback: Option>, + db: DieselDatabase, with_pkg_id: bool, globs: Vec, } @@ -44,7 +45,7 @@ pub struct PackageInstaller { #[derive(Clone, Default)] pub struct InstallTarget { pub package: Package, - pub existing_install: Option, + pub existing_install: Option, pub with_pkg_id: bool, pub profile: Option, pub portable: Option, @@ -58,8 +59,8 @@ impl PackageInstaller { pub async fn new>( target: &InstallTarget, install_dir: P, - progress_callback: Option>, - db: Arc>, + progress_callback: Option>, + db: DieselDatabase, with_pkg_id: bool, globs: Vec, ) -> SoarResult { @@ -68,41 +69,43 @@ impl PackageInstaller { let profile = get_config().default_profile.clone(); if target.existing_install.is_none() { - let conn = db.lock()?; - let Package { - ref repo_name, - ref pkg, - ref pkg_id, - ref pkg_name, - ref pkg_type, - ref version, - ref ghcr_size, - ref size, - .. - } = package; + let repo_name = &package.repo_name; + let pkg_id = &package.pkg_id; + let pkg_name = &package.pkg_name; + let pkg_type = package.pkg_type.as_deref(); + let version = &package.version; + let size = package.ghcr_size.unwrap_or(package.size.unwrap_or(0)) as i64; let installed_path = install_dir.to_string_lossy(); - let size = ghcr_size.unwrap_or(size.unwrap_or(0)); - let install_patterns = serde_json::to_string(&globs).unwrap(); - let mut stmt = prepare_and_bind!( - conn, - "INSERT INTO packages ( - repo_name, pkg, pkg_id, pkg_name, pkg_type, version, size, - installed_path, installed_date, with_pkg_id, profile, install_patterns - ) - VALUES - ( - $repo_name, $pkg, $pkg_id, $pkg_name, $pkg_type, $version, $size, - $installed_path, datetime(), $with_pkg_id, $profile, $install_patterns - )" - ); - stmt.raw_execute()?; + let installed_date = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + let new_package = NewInstalledPackage { + repo_name, + pkg_id, + pkg_name, + pkg_type, + version, + size, + checksum: None, + installed_path: &installed_path, + installed_date: &installed_date, + profile: &profile, + pinned: false, + is_installed: false, + with_pkg_id, + detached: false, + unlinked: false, + provides: None, + install_patterns: Some(json!(globs)), + }; + + db.with_conn(|conn| CoreRepository::insert(conn, &new_package))?; } Ok(Self { package: package.clone(), install_dir, progress_callback, - db: db.clone(), + db, with_pkg_id, globs, }) @@ -229,59 +232,35 @@ impl PackageInstaller { portable_share: Option<&str>, portable_cache: Option<&str>, ) -> SoarResult<()> { - let mut conn = self.db.lock()?; let package = &self.package; - let Package { - repo_name, - pkg_name, - pkg_id, - version, - ghcr_size, - size, - bsum, - .. - } = package; - let provides = serde_json::to_string(&package.provides).unwrap(); - let size = ghcr_size.unwrap_or(size.unwrap_or(0)); + let repo_name = &package.repo_name; + let pkg_name = &package.pkg_name; + let pkg_id = &package.pkg_id; + let version = &package.version; + let size = package.ghcr_size.unwrap_or(package.size.unwrap_or(0)) as i64; + let checksum = package.bsum.as_deref(); + let provides = package.provides.clone(); let with_pkg_id = self.with_pkg_id; - let tx = conn.transaction()?; - - let record_id: u32 = { - tx.query_row( - r#" - UPDATE packages - SET - version = ?, - size = ?, - installed_date = datetime(), - is_installed = true, - provides = ?, - with_pkg_id = ?, - checksum = ? - WHERE - repo_name = ? - AND pkg_name = ? - AND pkg_id = ? - AND pinned = false - AND version = ? - RETURNING id - "#, - params![ - version, - size, - provides, - with_pkg_id, - bsum, - repo_name, - pkg_name, - pkg_id, - version, - ], - |row| row.get(0), + let installed_date = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + let record_id: Option = self.db.with_conn(|conn| { + CoreRepository::record_installation( + conn, + repo_name, + pkg_name, + pkg_id, + version, + version, + size, + provides, + with_pkg_id, + checksum, + &installed_date, ) - .unwrap_or_default() - }; + })?; + + let record_id = record_id.unwrap_or(0); if portable.is_some() || portable_home.is_some() @@ -292,19 +271,12 @@ impl PackageInstaller { let base_dir = env::current_dir() .map_err(|_| SoarError::Custom("Error retrieving current directory".into()))?; - let [portable, portable_home, portable_config, portable_share, portable_cache] = [ - portable, - portable_home, - portable_config, - portable_share, - portable_cache, - ] - .map(|opt| { + let resolve_path = |opt: Option<&str>| -> Option { opt.map(|p| { if p.is_empty() { String::new() } else { - let path = PathBuf::from(&p); + let path = PathBuf::from(p); let absolute = if path.is_absolute() { path } else { @@ -313,78 +285,38 @@ impl PackageInstaller { absolute.to_string_lossy().into_owned() } }) - }); - - // try to update existing record first - let mut stmt = prepare_and_bind!( - tx, - "UPDATE portable_package - SET - portable_path = $portable, - portable_home = $portable_home, - portable_config = $portable_config, - portable_share = $portable_share, - portable_cache = $portable_cache - WHERE - package_id = $record_id - " - ); - let updated = stmt.raw_execute()?; - - // if no record were updated, add a new record - if updated == 0 { - let mut stmt = prepare_and_bind!( - tx, - "INSERT INTO portable_package - ( - package_id, portable_path, portable_home, portable_config, - portable_share, portable_cache - ) - VALUES - ( - $record_id, $portable, $portable_home, $portable_config, - $portable_share, $portable_cache + }; + + let portable_path = resolve_path(portable); + let portable_home = resolve_path(portable_home); + let portable_config = resolve_path(portable_config); + let portable_share = resolve_path(portable_share); + let portable_cache = resolve_path(portable_cache); + + self.db.with_conn(|conn| { + CoreRepository::upsert_portable( + conn, + record_id, + portable_path.as_deref(), + portable_home.as_deref(), + portable_config.as_deref(), + portable_share.as_deref(), + portable_cache.as_deref(), ) - " - ); - stmt.raw_execute()?; - } + })?; } if !unlinked { - let mut stmt = prepare_and_bind!( - tx, - "UPDATE packages - SET - unlinked = true - WHERE - pkg_name = $pkg_name - AND ( - pkg_id != $pkg_id - OR - version != $version - )" - ); - stmt.raw_execute()?; - } + self.db + .with_conn(|conn| CoreRepository::unlink_others(conn, pkg_name, pkg_id, version))?; - tx.commit()?; - drop(conn); + let alternate_packages: Vec = + self.db.with_conn(|conn| { + CoreRepository::find_alternates(conn, pkg_name, pkg_id, version) + })?; - if !unlinked { - // FIXME: alternate package could be the same package but different version - // or different package but same version - // - // this makes assumption that the pkg_id and version both are different - let alternate_packages = PackageQueryBuilder::new(self.db.clone()) - .where_and("pkg_name", FilterCondition::Eq(pkg_name.to_owned())) - .where_and("pkg_id", FilterCondition::Ne(pkg_id.to_owned())) - .where_and("version", FilterCondition::Ne(version.to_owned())) - .load_installed()? - .items; - - for package in alternate_packages { - let installed_path = PathBuf::from(&package.installed_path); + for alt_pkg in alternate_packages { + let installed_path = PathBuf::from(&alt_pkg.installed_path); let mut remove_action = |path: &Path| -> FileSystemResult<()> { if let Ok(real_path) = fs::read_link(path) { @@ -406,7 +338,7 @@ impl PackageInstaller { }; walk_dir(icons_dir(), &mut remove_action)?; - if let Some(provides) = package.provides { + if let Some(ref provides) = alt_pkg.provides { for provide in provides { if let Some(ref target) = provide.target { let is_symlink = matches!( diff --git a/soar-core/src/package/mod.rs b/crates/soar-core/src/package/mod.rs similarity index 75% rename from soar-core/src/package/mod.rs rename to crates/soar-core/src/package/mod.rs index 4ad62ded..2d3fd600 100644 --- a/soar-core/src/package/mod.rs +++ b/crates/soar-core/src/package/mod.rs @@ -1,3 +1,4 @@ pub mod install; pub mod query; pub mod remove; +pub mod update; diff --git a/soar-core/src/package/query.rs b/crates/soar-core/src/package/query.rs similarity index 69% rename from soar-core/src/package/query.rs rename to crates/soar-core/src/package/query.rs index b0e347cb..9ae8df93 100644 --- a/soar-core/src/package/query.rs +++ b/crates/soar-core/src/package/query.rs @@ -2,11 +2,10 @@ use std::sync::OnceLock; use regex::Regex; -use crate::{ - database::packages::{FilterCondition, PackageQueryBuilder}, - error::SoarError, -}; +use crate::error::SoarError; +/// Parsed package query string. +/// Supports format: `name#pkg_id@version:repo` #[derive(Debug)] pub struct PackageQuery { pub name: Option, @@ -15,26 +14,6 @@ pub struct PackageQuery { pub version: Option, } -impl PackageQuery { - pub fn apply_filters(&self, mut builder: PackageQueryBuilder) -> PackageQueryBuilder { - if let Some(ref repo_name) = self.repo_name { - builder = builder.where_and("repo_name", FilterCondition::Eq(repo_name.clone())); - } - if let Some(ref name) = self.name { - builder = builder.where_and("pkg_name", FilterCondition::Eq(name.clone())); - } - if let Some(ref pkg_id) = self.pkg_id { - if pkg_id != "all" { - builder = builder.where_and("pkg_id", FilterCondition::Eq(pkg_id.clone())); - } - } - if let Some(ref version) = self.version { - builder = builder.where_and("version", FilterCondition::Eq(version.clone())); - } - builder - } -} - impl TryFrom<&str> for PackageQuery { type Error = SoarError; diff --git a/soar-core/src/package/remove.rs b/crates/soar-core/src/package/remove.rs similarity index 79% rename from soar-core/src/package/remove.rs rename to crates/soar-core/src/package/remove.rs index e99e873a..fa59f284 100644 --- a/soar-core/src/package/remove.rs +++ b/crates/soar-core/src/package/remove.rs @@ -2,36 +2,32 @@ use std::{ ffi::OsString, fs, path::{Path, PathBuf}, - sync::{Arc, Mutex}, }; -use rusqlite::{params, Connection}; use soar_config::config::get_config; +use soar_db::{ + models::types::ProvideStrategy, + repository::core::CoreRepository, +}; use soar_utils::{error::FileSystemResult, fs::walk_dir, path::desktop_dir}; use crate::{ - database::{models::InstalledPackage, packages::ProvideStrategy}, + database::{connection::DieselDatabase, models::InstalledPackage}, error::ErrorContext, SoarResult, }; pub struct PackageRemover { package: InstalledPackage, - db: Arc>, + db: DieselDatabase, } impl PackageRemover { - pub async fn new(package: InstalledPackage, db: Arc>) -> Self { - Self { - package, - db, - } + pub async fn new(package: InstalledPackage, db: DieselDatabase) -> Self { + Self { package, db } } pub async fn remove(&self) -> SoarResult<()> { - let mut conn = self.db.lock()?; - let tx = conn.transaction()?; - // to prevent accidentally removing required files by other package, // remove only if the installation was successful if self.package.is_installed { @@ -95,23 +91,11 @@ impl PackageRemover { } }; - { - let mut stmt = tx.prepare( - r#" - DELETE FROM packages WHERE id = ? - "#, - )?; - stmt.execute(params![self.package.id])?; - - let mut stmt = tx.prepare( - r#" - DELETE FROM portable_package WHERE package_id = ? - "#, - )?; - stmt.execute(params![self.package.id])?; - } - - tx.commit()?; + let package_id = self.package.id as i32; + self.db.transaction(|conn| { + CoreRepository::delete_portable(conn, package_id)?; + CoreRepository::delete(conn, package_id) + })?; Ok(()) } diff --git a/crates/soar-core/src/package/update.rs b/crates/soar-core/src/package/update.rs new file mode 100644 index 00000000..193fd5c8 --- /dev/null +++ b/crates/soar-core/src/package/update.rs @@ -0,0 +1,37 @@ +use std::{fs, path::Path}; + +use soar_db::repository::core::CoreRepository; + +use crate::{ + database::{connection::DieselDatabase, models::Package}, + error::ErrorContext, + SoarResult, +}; + +/// Removes old versions of a package after a successful update. +/// +/// This function finds all installed versions of the package (by pkg_id, pkg_name, repo_name) +/// that are older than the current version and removes them from disk and database. +pub fn remove_old_versions(package: &Package, db: &DieselDatabase) -> SoarResult<()> { + let Package { + pkg_id, + pkg_name, + repo_name, + .. + } = package; + + let old_packages = + db.with_conn(|conn| CoreRepository::get_old_package_paths(conn, pkg_id, pkg_name, repo_name))?; + + for (_id, installed_path) in &old_packages { + let path = Path::new(installed_path); + if path.exists() { + fs::remove_dir_all(path) + .with_context(|| format!("removing old package directory {}", path.display()))?; + } + } + + db.with_conn(|conn| CoreRepository::delete_old_packages(conn, pkg_id, pkg_name, repo_name))?; + + Ok(()) +} diff --git a/soar-core/src/utils.rs b/crates/soar-core/src/utils.rs similarity index 77% rename from soar-core/src/utils.rs rename to crates/soar-core/src/utils.rs index d9f83296..a373e63f 100644 --- a/soar-core/src/utils.rs +++ b/crates/soar-core/src/utils.rs @@ -1,10 +1,12 @@ +//! Utility functions for soar-core. + use std::{ fs, path::{Path, PathBuf}, }; -use rusqlite::Connection; -use soar_config::config::{get_config, Config}; +use soar_config::config::get_config; +use soar_db::{connection::DbConnection, migration::DbType}; use soar_utils::{ error::FileSystemResult, fs::{safe_remove, walk_dir}, @@ -13,13 +15,13 @@ use soar_utils::{ use tracing::info; use crate::{ - database::migration, error::{ErrorContext, SoarError}, SoarResult, }; type Result = std::result::Result; +/// Sets up required directories for soar operation. pub fn setup_required_paths() -> Result<()> { let config = get_config(); let bin_path = config.get_bin_path()?; @@ -46,6 +48,7 @@ pub fn setup_required_paths() -> Result<()> { Ok(()) } +/// Cleans up the cache directory. pub fn cleanup_cache() -> Result<()> { let cache_path = get_config().get_cache_path()?; if cache_path.exists() { @@ -60,13 +63,14 @@ pub fn cleanup_cache() -> Result<()> { } fn remove_action(path: &Path) -> FileSystemResult<()> { - if !path.exists() { + if path.is_symlink() && !path.exists() { safe_remove(path)?; info!("Removed broken symlink: {}", path.display()); } Ok(()) } +/// Removes broken symlinks from bin, desktop, and icons directories. pub fn remove_broken_symlinks() -> Result<()> { let mut soar_files_action = |path: &Path| -> FileSystemResult<()> { if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) { @@ -84,16 +88,17 @@ pub fn remove_broken_symlinks() -> Result<()> { Ok(()) } +/// Gets the extract directory path for a given base directory. pub fn get_extract_dir>(base_dir: P) -> PathBuf { let base_dir = base_dir.as_ref(); base_dir.join("SOAR_AUTOEXTRACT") } -pub fn get_nests_db_conn(config: &Config) -> SoarResult { +/// Opens a connection to the nests database with migrations applied. +pub fn get_nests_db_conn() -> SoarResult { + let config = get_config(); let path = config.get_db_path()?.join("nests.db"); - let conn = Connection::open(&path)?; - migration::run_nests(conn) - .map_err(|e| SoarError::Custom(format!("creating nests migration: {}", e)))?; - let conn = Connection::open(&path)?; + let conn = DbConnection::open(&path, DbType::Nest) + .map_err(|e| SoarError::Custom(format!("opening nests database: {}", e)))?; Ok(conn) } diff --git a/crates/soar-db/Cargo.toml b/crates/soar-db/Cargo.toml index ad67c525..1c36d672 100644 --- a/crates/soar-db/Cargo.toml +++ b/crates/soar-db/Cargo.toml @@ -13,5 +13,9 @@ categories.workspace = true [dependencies] diesel = { workspace = true } diesel_migrations = { workspace = true } +miette = { workspace = true } +regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +soar-registry = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/soar-db/migrations/core/2025-10-19-024753-0000_create_packages/up.sql b/crates/soar-db/migrations/core/2025-10-19-024753-0000_create_packages/up.sql index 006d402d..ef3afab7 100644 --- a/crates/soar-db/migrations/core/2025-10-19-024753-0000_create_packages/up.sql +++ b/crates/soar-db/migrations/core/2025-10-19-024753-0000_create_packages/up.sql @@ -1,7 +1,6 @@ CREATE TABLE packages ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, repo_name TEXT NOT NULL, - pkg TEXT COLLATE NOCASE, pkg_id TEXT NOT NULL COLLATE NOCASE, pkg_name TEXT NOT NULL COLLATE NOCASE, pkg_type TEXT COLLATE NOCASE, diff --git a/crates/soar-db/migrations/metadata/2025-10-19-095216-0000_create_packages/up.sql b/crates/soar-db/migrations/metadata/2025-10-19-095216-0000_create_packages/up.sql index 69715c5e..bac81419 100644 --- a/crates/soar-db/migrations/metadata/2025-10-19-095216-0000_create_packages/up.sql +++ b/crates/soar-db/migrations/metadata/2025-10-19-095216-0000_create_packages/up.sql @@ -1,6 +1,5 @@ CREATE TABLE packages ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - rank INT, pkg_id TEXT NOT NULL COLLATE NOCASE, pkg_name TEXT NOT NULL COLLATE NOCASE, pkg_type TEXT COLLATE NOCASE, @@ -16,7 +15,7 @@ CREATE TABLE packages ( ghcr_size BIGINT, ghcr_blob TEXT, ghcr_url TEXT, - checksum TEXT, + bsum TEXT, icon TEXT, desktop TEXT, appstream TEXT, diff --git a/crates/soar-db/src/connection.rs b/crates/soar-db/src/connection.rs new file mode 100644 index 00000000..b907f24c --- /dev/null +++ b/crates/soar-db/src/connection.rs @@ -0,0 +1,211 @@ +//! Database connection management. +//! +//! This module provides connection management for the soar database system. +//! It supports multiple database types: +//! +//! - **Core database**: Tracks installed packages +//! - **Metadata databases**: One per repository, contains package metadata +//! - **Nests database**: Tracks nest configurations + +use std::collections::HashMap; +use std::path::Path; + +use diesel::{sql_query, Connection, ConnectionError, RunQueryDsl, SqliteConnection}; + +use crate::migration::{apply_migrations, migrate_json_to_jsonb, DbType}; + +/// Database connection wrapper with migration support. +pub struct DbConnection { + conn: SqliteConnection, +} + +impl DbConnection { + /// Opens a database connection and runs migrations. + /// + /// # Arguments + /// + /// * `path` - Path to the SQLite database file + /// * `db_type` - Type of database for selecting correct migrations + /// + /// # Errors + /// + /// Returns an error if the connection fails or migrations fail. + pub fn open>(path: P, db_type: DbType) -> Result { + let path_str = path.as_ref().to_string_lossy(); + let mut conn = SqliteConnection::establish(&path_str)?; + + // WAL mode for better concurrent access + sql_query("PRAGMA journal_mode = WAL;") + .execute(&mut conn) + .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; + + apply_migrations(&mut conn, &db_type) + .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; + + // Migrate text JSON to JSONB for databases we manage (Core, Nest) + // Metadata databases are generated externally and migrated on fetch + if matches!(db_type, DbType::Core | DbType::Nest) { + migrate_json_to_jsonb(&mut conn, db_type) + .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; + } + + Ok(Self { conn }) + } + + /// Opens a database connection without running migrations. + /// + /// Use this when you know the database is already migrated. + pub fn open_without_migrations>(path: P) -> Result { + let path_str = path.as_ref().to_string_lossy(); + let conn = SqliteConnection::establish(&path_str)?; + Ok(Self { conn }) + } + + /// Opens a metadata database and migrates JSON text columns to JSONB. + /// + /// This is used for metadata databases that are generated externally (e.g., by rusqlite) + /// and may contain JSON stored as text instead of JSONB binary format. + /// + /// Does NOT run schema migrations since the schema is managed externally. + pub fn open_metadata>(path: P) -> Result { + let path_str = path.as_ref().to_string_lossy(); + let mut conn = SqliteConnection::establish(&path_str)?; + + // Migrate text JSON to JSONB binary format + migrate_json_to_jsonb(&mut conn, DbType::Metadata) + .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; + + Ok(Self { conn }) + } + + /// Gets a mutable reference to the underlying connection. + pub fn conn(&mut self) -> &mut SqliteConnection { + &mut self.conn + } +} + +impl std::ops::Deref for DbConnection { + type Target = SqliteConnection; + + fn deref(&self) -> &Self::Target { + &self.conn + } +} + +impl std::ops::DerefMut for DbConnection { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.conn + } +} + +/// Manages database connections for the soar package manager. +/// +/// This struct manages separate connections for: +/// - The core database (installed packages) +/// - Multiple metadata databases (one per repository) +/// - The nests database (nest configurations) +/// +/// # Example +/// +/// ```ignore +/// use soar_db::connection::DatabaseManager; +/// +/// let manager = DatabaseManager::new("/path/to/db")?; +/// +/// // Access installed packages +/// let installed = manager.core().list_installed()?; +/// +/// // Access repository metadata +/// if let Some(metadata_conn) = manager.metadata("pkgforge") { +/// let packages = metadata_conn.search("firefox")?; +/// } +/// ``` +pub struct DatabaseManager { + /// Core database connection (installed packages). + core: DbConnection, + /// Metadata database connections, keyed by repository name. + metadata: HashMap, + /// Nests database connection. + nests: DbConnection, +} + +impl DatabaseManager { + /// Creates a new database manager with the given base directory. + /// + /// # Arguments + /// + /// * `base_dir` - Base directory for database files + /// + /// The following databases will be created/opened: + /// - `{base_dir}/core.db` - Installed packages + /// - `{base_dir}/nests.db` - Nest configurations + /// + /// Metadata databases are added separately via `add_metadata_db`. + pub fn new>(base_dir: P) -> Result { + let base = base_dir.as_ref(); + + let core_path = base.join("core.db"); + let nests_path = base.join("nests.db"); + + let core = DbConnection::open(&core_path, DbType::Core)?; + let nests = DbConnection::open(&nests_path, DbType::Nest)?; + + Ok(Self { + core, + metadata: HashMap::new(), + nests, + }) + } + + /// Adds or opens a metadata database for a repository. + /// + /// This method opens the metadata database and migrates any JSON text columns + /// to JSONB binary format. It does NOT run schema migrations since metadata + /// databases are generated externally (e.g., by rusqlite). + /// + /// # Arguments + /// + /// * `repo_name` - Name of the repository + /// * `path` - Path to the metadata database file + pub fn add_metadata_db>( + &mut self, + repo_name: &str, + path: P, + ) -> Result<(), ConnectionError> { + let conn = DbConnection::open_metadata(path)?; + self.metadata.insert(repo_name.to_string(), conn); + Ok(()) + } + + /// Gets a mutable reference to the core database connection. + pub fn core(&mut self) -> &mut DbConnection { + &mut self.core + } + + /// Gets a mutable reference to a metadata database connection. + /// + /// Returns `None` if no metadata database exists for the given repository. + pub fn metadata(&mut self, repo_name: &str) -> Option<&mut DbConnection> { + self.metadata.get_mut(repo_name) + } + + /// Gets an iterator over all metadata database connections. + pub fn all_metadata(&mut self) -> impl Iterator { + self.metadata.iter_mut() + } + + /// Gets a mutable reference to the nests database connection. + pub fn nests(&mut self) -> &mut DbConnection { + &mut self.nests + } + + /// Returns the names of all loaded metadata databases. + pub fn metadata_names(&self) -> impl Iterator { + self.metadata.keys() + } + + /// Removes a metadata database connection. + pub fn remove_metadata_db(&mut self, repo_name: &str) -> Option { + self.metadata.remove(repo_name) + } +} diff --git a/crates/soar-db/src/error.rs b/crates/soar-db/src/error.rs new file mode 100644 index 00000000..27427da3 --- /dev/null +++ b/crates/soar-db/src/error.rs @@ -0,0 +1,78 @@ +//! Error types for soar-db. + +use miette::Diagnostic; +use thiserror::Error; + +/// Database error type for soar-db operations. +#[derive(Error, Diagnostic, Debug)] +pub enum DbError { + #[error("Database connection failed: {0}")] + #[diagnostic( + code(soar_db::connection), + help("Check if the database file exists and is accessible") + )] + ConnectionError(String), + + #[error("Database query failed: {0}")] + #[diagnostic( + code(soar_db::query), + help("Try running 'soar sync' to refresh the database") + )] + QueryError(String), + + #[error("Database migration failed: {0}")] + #[diagnostic( + code(soar_db::migration), + help("The database schema may be corrupted. Try removing and re-syncing.") + )] + MigrationError(String), + + #[error("Package not found: {0}")] + #[diagnostic( + code(soar_db::not_found), + help("Run 'soar sync' to update package list, or check the package name") + )] + NotFound(String), + + #[error("Package already installed: {0}")] + #[diagnostic( + code(soar_db::already_installed), + help("Use 'soar update' to update the package, or 'soar remove' first") + )] + AlreadyInstalled(String), + + #[error("Database integrity error: {0}")] + #[diagnostic( + code(soar_db::integrity), + help("The database may be corrupted. Try removing and re-syncing.") + )] + IntegrityError(String), + + #[error("IO error: {0}")] + #[diagnostic( + code(soar_db::io), + help("Check file permissions and disk space") + )] + IoError(#[from] std::io::Error), +} + +impl From for DbError { + fn from(err: diesel::result::Error) -> Self { + match err { + diesel::result::Error::NotFound => DbError::NotFound("Record not found".to_string()), + diesel::result::Error::DatabaseError(_, info) => { + DbError::QueryError(info.message().to_string()) + } + other => DbError::QueryError(other.to_string()), + } + } +} + +impl From for DbError { + fn from(err: diesel::result::ConnectionError) -> Self { + DbError::ConnectionError(err.to_string()) + } +} + +/// Result type alias for soar-db operations. +pub type Result = std::result::Result; diff --git a/crates/soar-db/src/lib.rs b/crates/soar-db/src/lib.rs index cd3b7e75..8c5d178f 100644 --- a/crates/soar-db/src/lib.rs +++ b/crates/soar-db/src/lib.rs @@ -1,51 +1,51 @@ -use diesel::{ - sql_query, sql_types::Text, Connection, ConnectionError, RunQueryDsl as _, SqliteConnection, -}; - +//! Database layer for the soar package manager. +//! +//! This crate provides database management for soar, including: +//! +//! - **Connection management**: Separate connections for core, metadata, and nests databases +//! - **Models**: Diesel ORM models for all database tables +//! - **Repositories**: Type-safe CRUD operations using the repository pattern +//! - **Migrations**: Automatic schema migrations using diesel_migrations +//! +//! # Database Architecture +//! +//! Soar uses three types of SQLite databases: +//! +//! - **Core database** (`core.db`): Tracks installed packages +//! - **Metadata databases** (one per repository): Contains package metadata +//! - **Nests database** (`nests.db`): Stores nest configurations +//! +//! # Example +//! +//! ```ignore +//! use soar_db::connection::DatabaseManager; +//! use soar_db::repository::{CoreRepository, MetadataRepository}; +//! +//! // Create database manager +//! let mut manager = DatabaseManager::new("/path/to/db")?; +//! +//! // Add repository metadata +//! manager.add_metadata_db("pkgforge", "/path/to/pkgforge.db")?; +//! +//! // Query installed packages +//! let installed = CoreRepository::list_all(manager.core().conn())?; +//! +//! // Search for packages +//! if let Some(metadata) = manager.metadata("pkgforge") { +//! let packages = MetadataRepository::search(metadata.conn(), "firefox")?; +//! } +//! ``` + +pub mod connection; +pub mod error; pub mod migration; pub mod models; +pub mod repository; pub mod schema; -pub struct Database { - pub conn: SqliteConnection, -} - -impl Database { - pub fn new(path: &str) -> Result { - let conn = SqliteConnection::establish(path)?; - Ok(Database { - conn, - }) - } - - pub fn new_multi(paths: &[&str]) -> Result { - let mut conn = SqliteConnection::establish(paths[0])?; - sql_query("PRAGMA case_sensitive_like = ON;") - .execute(&mut conn) - .map_err(|err| ConnectionError::BadConnection(err.to_string()))?; - for (idx, path) in paths.iter().enumerate().skip(1) { - sql_query(format!("ATTACH DATABASE ?1 AS shard{}", idx)) - .bind::(path) - .execute(&mut conn) - .map_err(|err| ConnectionError::BadConnection(err.to_string()))?; - } - - Ok(Database { - conn, - }) - } -} - -impl std::ops::Deref for Database { - type Target = SqliteConnection; - - fn deref(&self) -> &Self::Target { - &self.conn - } -} - -impl std::ops::DerefMut for Database { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.conn - } +#[macro_export] +macro_rules! json_vec { + ($val:expr) => { + $val.map(|v| serde_json::from_value(v).unwrap_or_default()) + }; } diff --git a/crates/soar-db/src/migration.rs b/crates/soar-db/src/migration.rs index b667bca5..332833bd 100644 --- a/crates/soar-db/src/migration.rs +++ b/crates/soar-db/src/migration.rs @@ -7,6 +7,7 @@ pub const CORE_MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/co pub const METADATA_MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/metadata"); pub const NEST_MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/nest"); +#[derive(Clone, Copy)] pub enum DbType { Core, Metadata, @@ -23,13 +24,13 @@ fn get_migrations(db_type: &DbType) -> EmbeddedMigrations { pub fn apply_migrations( conn: &mut SqliteConnection, - db_type: DbType, + db_type: &DbType, ) -> Result<(), Box> { loop { - match conn.run_pending_migrations(get_migrations(&db_type)) { + match conn.run_pending_migrations(get_migrations(db_type)) { Ok(_) => break, Err(e) if e.to_string().contains("already exists") => { - mark_first_pending(conn, &db_type)?; + mark_first_pending(conn, db_type)?; } Err(e) => return Err(e), } @@ -51,3 +52,96 @@ fn mark_first_pending( Ok(()) } + +/// Migrate text JSON columns to JSONB binary format. +/// +/// This is needed when migrating from rusqlite (which stores JSON as text) +/// to diesel (which uses SQLite's native JSONB format). +/// +/// Handles both: +/// - Text type columns (typeof = 'text') +/// - Blob columns containing text JSON (starts with '[' or '{') +/// +/// # Performance Note +/// +/// This runs on every database open but is essentially a no-op after the first +/// successful migration. The WHERE clause only matches rows with text-based JSON, +/// so once all rows are converted to JSONB binary format, no rows will be updated. +/// +/// TODO: Remove this migration in a future version (v0.9 or v1.0) once users +/// have had sufficient time to migrate their databases. +pub fn migrate_json_to_jsonb( + conn: &mut SqliteConnection, + db_type: DbType, +) -> Result> { + // Check for text type OR blob containing text JSON (starts with '[' or '{') + // Use hex comparison for blobs: 5B = '[', 7B = '{' + let json_condition = |col: &str| { + format!( + "{col} IS NOT NULL AND (typeof({col}) = 'text' OR (typeof({col}) = 'blob' AND hex(substr({col}, 1, 1)) IN ('5B', '7B')))" + ) + }; + + let queries: Vec = match db_type { + DbType::Core => { + vec![ + format!( + "UPDATE packages SET provides = jsonb(provides) WHERE {}", + json_condition("provides") + ), + format!( + "UPDATE packages SET install_patterns = jsonb(install_patterns) WHERE {}", + json_condition("install_patterns") + ), + ] + } + DbType::Metadata => { + vec![ + format!( + "UPDATE packages SET licenses = jsonb(licenses) WHERE {}", + json_condition("licenses") + ), + format!( + "UPDATE packages SET homepages = jsonb(homepages) WHERE {}", + json_condition("homepages") + ), + format!( + "UPDATE packages SET notes = jsonb(notes) WHERE {}", + json_condition("notes") + ), + format!( + "UPDATE packages SET source_urls = jsonb(source_urls) WHERE {}", + json_condition("source_urls") + ), + format!( + "UPDATE packages SET tags = jsonb(tags) WHERE {}", + json_condition("tags") + ), + format!( + "UPDATE packages SET categories = jsonb(categories) WHERE {}", + json_condition("categories") + ), + format!( + "UPDATE packages SET provides = jsonb(provides) WHERE {}", + json_condition("provides") + ), + format!( + "UPDATE packages SET snapshots = jsonb(snapshots) WHERE {}", + json_condition("snapshots") + ), + format!( + "UPDATE packages SET replaces = jsonb(replaces) WHERE {}", + json_condition("replaces") + ), + ] + } + DbType::Nest => vec![], + }; + + let mut total = 0; + for query in queries { + total += sql_query(&query).execute(conn)?; + } + + Ok(total) +} diff --git a/crates/soar-db/src/models/core.rs b/crates/soar-db/src/models/core.rs index b554a08e..66b8bf12 100644 --- a/crates/soar-db/src/models/core.rs +++ b/crates/soar-db/src/models/core.rs @@ -1,17 +1,12 @@ -use diesel::prelude::*; +use diesel::{prelude::*, sqlite::Sqlite}; +use serde_json::Value; -use crate::{ - models::types::{JsonValue, PackageProvide}, - schema::core::*, -}; +use crate::{models::types::PackageProvide, schema::core::*}; -#[derive(Debug, Queryable, Selectable)] -#[diesel(table_name = packages)] -#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +#[derive(Debug, Selectable)] pub struct Package { pub id: i32, pub repo_name: String, - pub pkg: Option, pub pkg_id: String, pub pkg_name: String, pub pkg_type: Option, @@ -26,8 +21,58 @@ pub struct Package { pub with_pkg_id: bool, pub detached: bool, pub unlinked: bool, - pub provides: Option>>, - pub install_patterns: Option>>, + pub provides: Option>, + pub install_patterns: Option>, +} + +impl Queryable for Package { + type Row = ( + i32, + String, + String, + String, + Option, + String, + i64, + Option, + String, + String, + String, + bool, + bool, + bool, + bool, + bool, + Option, + Option, + ); + + fn build(row: Self::Row) -> diesel::deserialize::Result { + Ok(Self { + id: row.0, + repo_name: row.1, + pkg_id: row.2, + pkg_name: row.3, + pkg_type: row.4, + version: row.5, + size: row.6, + checksum: row.7, + installed_path: row.8, + installed_date: row.9, + profile: row.10, + pinned: row.11, + is_installed: row.12, + with_pkg_id: row.13, + detached: row.14, + unlinked: row.15, + provides: row + .16 + .map(|v| serde_json::from_value(v).unwrap_or_default()), + install_patterns: row + .17 + .map(|v| serde_json::from_value(v).unwrap_or_default()), + }) + } } #[derive(Debug, Queryable, Selectable)] @@ -46,7 +91,6 @@ pub struct PortablePackage { #[diesel(table_name = packages)] pub struct NewPackage<'a> { pub repo_name: &'a str, - pub pkg: Option<&'a str>, pub pkg_id: &'a str, pub pkg_name: &'a str, pub pkg_type: Option<&'a str>, @@ -61,8 +105,8 @@ pub struct NewPackage<'a> { pub with_pkg_id: bool, pub detached: bool, pub unlinked: bool, - pub provides: Option>>, - pub install_patterns: Option>>, + pub provides: Option, + pub install_patterns: Option, } #[derive(Default, Insertable)] diff --git a/crates/soar-db/src/models/metadata.rs b/crates/soar-db/src/models/metadata.rs index eb00f85f..289315f9 100644 --- a/crates/soar-db/src/models/metadata.rs +++ b/crates/soar-db/src/models/metadata.rs @@ -1,16 +1,11 @@ -use diesel::prelude::*; +use diesel::{prelude::*, sqlite::Sqlite}; +use serde_json::Value; -use crate::{ - models::types::{JsonValue, PackageProvide}, - schema::metadata::*, -}; +use crate::{json_vec, models::types::PackageProvide, schema::metadata::*}; -#[derive(Debug, Queryable, Selectable)] -#[diesel(table_name = packages)] -#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +#[derive(Debug, Clone, Selectable)] pub struct Package { pub id: i32, - pub rank: Option, pub pkg_id: String, pub pkg_name: String, pub pkg_type: Option, @@ -19,36 +14,137 @@ pub struct Package { pub description: Option, pub version: String, pub version_upstream: Option, - pub licenses: Option>>, + pub licenses: Option>, pub download_url: String, pub size: Option, pub ghcr_pkg: Option, pub ghcr_size: Option, pub ghcr_blob: Option, pub ghcr_url: Option, - pub checksum: Option, + pub bsum: Option, pub icon: Option, pub desktop: Option, pub appstream: Option, - pub homepages: Option>>, - pub notes: Option>>, - pub source_urls: Option>>, - pub tags: Option>>, - pub categories: Option>>, + pub homepages: Option>, + pub notes: Option>, + pub source_urls: Option>, + pub tags: Option>, + pub categories: Option>, pub build_id: Option, pub build_date: Option, pub build_action: Option, pub build_script: Option, pub build_log: Option, - pub provides: Option>>, - pub snapshots: Option>>, - pub replaces: Option>>, + pub provides: Option>, + pub snapshots: Option>, + pub replaces: Option>, pub soar_syms: bool, pub desktop_integration: Option, pub portable: Option, pub recurse_provides: Option, } +impl Queryable for Package { + type Row = ( + i32, + String, + String, + Option, + Option, + Option, + Option, + String, + Option, + Option, + String, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + bool, + Option, + Option, + Option, + ); + + fn build(row: Self::Row) -> diesel::deserialize::Result { + Ok(Self { + id: row.0, + pkg_id: row.1, + pkg_name: row.2, + pkg_type: row.3, + pkg_webpage: row.4, + app_id: row.5, + description: row.6, + version: row.7, + version_upstream: row.8, + licenses: json_vec!(row.9), + download_url: row.10, + size: row.11, + ghcr_pkg: row.12, + ghcr_size: row.13, + ghcr_blob: row.14, + ghcr_url: row.15, + bsum: row.16, + icon: row.17, + desktop: row.18, + appstream: row.19, + homepages: json_vec!(row.20), + notes: json_vec!(row.21), + source_urls: json_vec!(row.22), + tags: json_vec!(row.23), + categories: json_vec!(row.24), + build_id: row.25, + build_date: row.26, + build_action: row.27, + build_script: row.28, + build_log: row.29, + provides: json_vec!(row.30), + snapshots: json_vec!(row.31), + replaces: json_vec!(row.32), + soar_syms: row.33, + desktop_integration: row.34, + portable: row.35, + recurse_provides: row.36, + }) + } +} + +/// Package with repository name attached. +/// This is used when querying across multiple repositories. +#[derive(Debug, Clone)] +pub struct PackageWithRepo { + pub repo_name: String, + pub package: Package, +} + +impl PackageWithRepo { + pub fn new(repo_name: String, package: Package) -> Self { + Self { + repo_name, + package, + } + } +} + #[derive(Debug, Queryable, Selectable)] #[diesel(table_name = maintainers)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] @@ -69,7 +165,6 @@ pub struct PackageMaintainer { #[derive(Default, Insertable)] #[diesel(table_name = packages)] pub struct NewPackage<'a> { - pub rank: Option, pub pkg_id: &'a str, pub pkg_name: &'a str, pub pkg_type: Option<&'a str>, @@ -78,29 +173,30 @@ pub struct NewPackage<'a> { pub description: Option<&'a str>, pub version: &'a str, pub version_upstream: Option<&'a str>, - pub licenses: Option>>, + pub licenses: Option, pub download_url: &'a str, pub size: Option, pub ghcr_pkg: Option<&'a str>, + pub ghcr_size: Option, pub ghcr_blob: Option<&'a str>, pub ghcr_url: Option<&'a str>, - pub checksum: Option<&'a str>, + pub bsum: Option<&'a str>, pub icon: Option<&'a str>, pub desktop: Option<&'a str>, pub appstream: Option<&'a str>, - pub homepages: Option>>, - pub notes: Option>>, - pub source_urls: Option>>, - pub tags: Option>>, - pub categories: Option>>, + pub homepages: Option, + pub notes: Option, + pub source_urls: Option, + pub tags: Option, + pub categories: Option, pub build_id: Option<&'a str>, pub build_date: Option<&'a str>, pub build_action: Option<&'a str>, pub build_script: Option<&'a str>, pub build_log: Option<&'a str>, - pub provides: Option>>, - pub snapshots: Option>>, - pub replaces: Option>>, + pub provides: Option, + pub snapshots: Option, + pub replaces: Option, pub soar_syms: bool, pub desktop_integration: Option, pub portable: Option, @@ -120,3 +216,19 @@ pub struct NewPackageMaintainer { pub maintainer_id: i32, pub package_id: i32, } + +#[derive(Debug, Queryable, Selectable)] +#[diesel(table_name = repository)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct Repository { + pub rowid: i32, + pub name: String, + pub etag: String, +} + +#[derive(Default, Insertable)] +#[diesel(table_name = repository)] +pub struct NewRepository<'a> { + pub name: &'a str, + pub etag: &'a str, +} diff --git a/crates/soar-db/src/models/types.rs b/crates/soar-db/src/models/types.rs index 864e42a0..9f43ff29 100644 --- a/crates/soar-db/src/models/types.rs +++ b/crates/soar-db/src/models/types.rs @@ -1,40 +1,5 @@ -use diesel::{ - deserialize::{self, FromSql, FromSqlRow}, - expression::AsExpression, - serialize::{self, Output, ToSql}, - sql_types::{Jsonb, Text}, - sqlite::Sqlite, -}; use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, Serialize, Deserialize, FromSqlRow, AsExpression)] -#[diesel(sql_type = Jsonb)] -pub struct JsonValue(pub T); - -impl FromSql for JsonValue -where - T: for<'de> Deserialize<'de>, -{ - fn from_sql(bytes: diesel::sqlite::SqliteValue) -> deserialize::Result { - let text = >::from_sql(bytes)?; - let value = serde_json::from_str::(&text) - .map_err(|e| Box::new(e) as Box)?; - Ok(JsonValue(value)) - } -} - -impl ToSql for JsonValue -where - T: Serialize + std::fmt::Debug, -{ - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Sqlite>) -> serialize::Result { - let json = serde_json::to_vec(&self.0) - .map_err(|e| Box::new(e) as Box)?; - out.set_value(json); - Ok(serialize::IsNull::No) - } -} - #[derive(Debug, Clone, Deserialize, Serialize)] pub enum ProvideStrategy { KeepTargetOnly, diff --git a/crates/soar-db/src/repository/core.rs b/crates/soar-db/src/repository/core.rs new file mode 100644 index 00000000..d863ed33 --- /dev/null +++ b/crates/soar-db/src/repository/core.rs @@ -0,0 +1,647 @@ +//! Core database repository for installed packages. + +use diesel::prelude::*; + +use crate::{ + models::{ + core::{NewPackage, NewPortablePackage, Package, PortablePackage}, + types::PackageProvide, + }, + schema::core::{packages, portable_package}, +}; + +/// Sort direction for queries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortDirection { + Asc, + Desc, +} + +/// Type alias for installed package (for clarity). +pub type InstalledPackage = Package; +/// Type alias for new installed package (for clarity). +pub type NewInstalledPackage<'a> = NewPackage<'a>; + +/// Installed package with portable configuration joined. +#[derive(Debug, Clone)] +pub struct InstalledPackageWithPortable { + pub id: i32, + pub repo_name: String, + pub pkg_id: String, + pub pkg_name: String, + pub pkg_type: Option, + pub version: String, + pub size: i64, + pub checksum: Option, + pub installed_path: String, + pub installed_date: String, + pub profile: String, + pub pinned: bool, + pub is_installed: bool, + pub with_pkg_id: bool, + pub detached: bool, + pub unlinked: bool, + pub provides: Option>, + pub install_patterns: Option>, + pub portable_path: Option, + pub portable_home: Option, + pub portable_config: Option, + pub portable_share: Option, + pub portable_cache: Option, +} + +impl From<(Package, Option)> for InstalledPackageWithPortable { + fn from((pkg, portable): (Package, Option)) -> Self { + Self { + id: pkg.id, + repo_name: pkg.repo_name, + pkg_id: pkg.pkg_id, + pkg_name: pkg.pkg_name, + pkg_type: pkg.pkg_type, + version: pkg.version, + size: pkg.size, + checksum: pkg.checksum, + installed_path: pkg.installed_path, + installed_date: pkg.installed_date, + profile: pkg.profile, + pinned: pkg.pinned, + is_installed: pkg.is_installed, + with_pkg_id: pkg.with_pkg_id, + detached: pkg.detached, + unlinked: pkg.unlinked, + provides: pkg.provides, + install_patterns: pkg.install_patterns, + portable_path: portable.as_ref().and_then(|p| p.portable_path.clone()), + portable_home: portable.as_ref().and_then(|p| p.portable_home.clone()), + portable_config: portable.as_ref().and_then(|p| p.portable_config.clone()), + portable_share: portable.as_ref().and_then(|p| p.portable_share.clone()), + portable_cache: portable.as_ref().and_then(|p| p.portable_cache.clone()), + } + } +} + +/// Repository for installed package operations. +pub struct CoreRepository; + +impl CoreRepository { + /// Lists all installed packages. + pub fn list_all(conn: &mut SqliteConnection) -> QueryResult> { + packages::table.select(Package::as_select()).load(conn) + } + + /// Lists installed packages with flexible filtering. + pub fn list_filtered( + conn: &mut SqliteConnection, + repo_name: Option<&str>, + pkg_name: Option<&str>, + pkg_id: Option<&str>, + version: Option<&str>, + is_installed: Option, + pinned: Option, + limit: Option, + sort_by_id: Option, + ) -> QueryResult> { + let mut query = packages::table + .left_join(portable_package::table) + .into_boxed(); + + if let Some(repo) = repo_name { + query = query.filter(packages::repo_name.eq(repo)); + } + if let Some(name) = pkg_name { + query = query.filter(packages::pkg_name.eq(name)); + } + if let Some(id) = pkg_id { + query = query.filter(packages::pkg_id.eq(id)); + } + if let Some(ver) = version { + query = query.filter(packages::version.eq(ver)); + } + if let Some(installed) = is_installed { + query = query.filter(packages::is_installed.eq(installed)); + } + if let Some(pin) = pinned { + query = query.filter(packages::pinned.eq(pin)); + } + + if let Some(direction) = sort_by_id { + query = match direction { + SortDirection::Asc => query.order(packages::id.asc()), + SortDirection::Desc => query.order(packages::id.desc()), + }; + } + + if let Some(lim) = limit { + query = query.limit(lim); + } + + let results: Vec<(Package, Option)> = query + .select((Package::as_select(), Option::::as_select())) + .load(conn)?; + + Ok(results.into_iter().map(Into::into).collect()) + } + + /// Lists broken packages (is_installed = false). + pub fn list_broken( + conn: &mut SqliteConnection, + ) -> QueryResult> { + let results: Vec<(Package, Option)> = packages::table + .left_join(portable_package::table) + .filter(packages::is_installed.eq(false)) + .select((Package::as_select(), Option::::as_select())) + .load(conn)?; + + Ok(results.into_iter().map(Into::into).collect()) + } + + /// Lists installed packages that are not pinned (for updates). + pub fn list_updatable( + conn: &mut SqliteConnection, + ) -> QueryResult> { + let results: Vec<(Package, Option)> = packages::table + .left_join(portable_package::table) + .filter(packages::is_installed.eq(true)) + .filter(packages::pinned.eq(false)) + .select((Package::as_select(), Option::::as_select())) + .load(conn)?; + + Ok(results.into_iter().map(Into::into).collect()) + } + + /// Finds an installed package by exact match on repo_name, pkg_name, pkg_id, and version. + pub fn find_exact( + conn: &mut SqliteConnection, + repo_name: &str, + pkg_name: &str, + pkg_id: &str, + version: &str, + ) -> QueryResult> { + let result: Option<(Package, Option)> = packages::table + .left_join(portable_package::table) + .filter(packages::repo_name.eq(repo_name)) + .filter(packages::pkg_name.eq(pkg_name)) + .filter(packages::pkg_id.eq(pkg_id)) + .filter(packages::version.eq(version)) + .select((Package::as_select(), Option::::as_select())) + .first(conn) + .optional()?; + + Ok(result.map(Into::into)) + } + + /// Lists all installed packages with portable configuration. + pub fn list_all_with_portable( + conn: &mut SqliteConnection, + ) -> QueryResult> { + let results: Vec<(Package, Option)> = packages::table + .left_join(portable_package::table) + .select((Package::as_select(), Option::::as_select())) + .load(conn)?; + + Ok(results.into_iter().map(Into::into).collect()) + } + + /// Lists installed packages filtered by repo_name. + pub fn list_by_repo(conn: &mut SqliteConnection, repo_name: &str) -> QueryResult> { + packages::table + .filter(packages::repo_name.eq(repo_name)) + .select(Package::as_select()) + .load(conn) + } + + /// Lists installed packages filtered by repo_name with portable configuration. + pub fn list_by_repo_with_portable( + conn: &mut SqliteConnection, + repo_name: &str, + ) -> QueryResult> { + let results: Vec<(Package, Option)> = packages::table + .left_join(portable_package::table) + .filter(packages::repo_name.eq(repo_name)) + .select((Package::as_select(), Option::::as_select())) + .load(conn)?; + + Ok(results.into_iter().map(Into::into).collect()) + } + + /// Counts installed packages. + pub fn count(conn: &mut SqliteConnection) -> QueryResult { + packages::table.count().get_result(conn) + } + + /// Counts distinct installed packages. + pub fn count_distinct_installed( + conn: &mut SqliteConnection, + repo_name: Option<&str>, + ) -> QueryResult { + use diesel::dsl::sql; + + let mut query = packages::table + .filter(packages::is_installed.eq(true)) + .into_boxed(); + + if let Some(repo) = repo_name { + query = query.filter(packages::repo_name.eq(repo)); + } + + query + .select(sql::( + "COUNT(DISTINCT pkg_id || pkg_name)", + )) + .first(conn) + } + + /// Finds an installed package by ID. + pub fn find_by_id(conn: &mut SqliteConnection, id: i32) -> QueryResult> { + packages::table + .filter(packages::id.eq(id)) + .select(Package::as_select()) + .first(conn) + .optional() + } + + /// Finds an installed package by ID with portable configuration. + pub fn find_by_id_with_portable( + conn: &mut SqliteConnection, + id: i32, + ) -> QueryResult> { + let result: Option<(Package, Option)> = packages::table + .left_join(portable_package::table) + .filter(packages::id.eq(id)) + .select((Package::as_select(), Option::::as_select())) + .first(conn) + .optional()?; + + Ok(result.map(Into::into)) + } + + /// Finds installed packages by name. + pub fn find_by_name(conn: &mut SqliteConnection, name: &str) -> QueryResult> { + packages::table + .filter(packages::pkg_name.eq(name)) + .select(Package::as_select()) + .load(conn) + } + + /// Finds installed packages by name with portable configuration. + pub fn find_by_name_with_portable( + conn: &mut SqliteConnection, + name: &str, + ) -> QueryResult> { + let results: Vec<(Package, Option)> = packages::table + .left_join(portable_package::table) + .filter(packages::pkg_name.eq(name)) + .select((Package::as_select(), Option::::as_select())) + .load(conn)?; + + Ok(results.into_iter().map(Into::into).collect()) + } + + /// Finds installed packages by name, excluding specific pkg_id and version. + pub fn find_alternates( + conn: &mut SqliteConnection, + pkg_name: &str, + exclude_pkg_id: &str, + exclude_version: &str, + ) -> QueryResult> { + let results: Vec<(Package, Option)> = packages::table + .left_join(portable_package::table) + .filter(packages::pkg_name.eq(pkg_name)) + .filter(packages::pkg_id.ne(exclude_pkg_id)) + .filter(packages::version.ne(exclude_version)) + .select((Package::as_select(), Option::::as_select())) + .load(conn)?; + + Ok(results.into_iter().map(Into::into).collect()) + } + + /// Finds an installed package by pkg_id and repo_name. + pub fn find_by_pkg_id_and_repo( + conn: &mut SqliteConnection, + pkg_id: &str, + repo_name: &str, + ) -> QueryResult> { + packages::table + .filter(packages::pkg_id.eq(pkg_id)) + .filter(packages::repo_name.eq(repo_name)) + .select(Package::as_select()) + .first(conn) + .optional() + } + + /// Finds an installed package by pkg_id, pkg_name, and repo_name. + pub fn find_by_pkg_id_name_and_repo( + conn: &mut SqliteConnection, + pkg_id: &str, + pkg_name: &str, + repo_name: &str, + ) -> QueryResult> { + packages::table + .filter(packages::pkg_id.eq(pkg_id)) + .filter(packages::pkg_name.eq(pkg_name)) + .filter(packages::repo_name.eq(repo_name)) + .select(Package::as_select()) + .first(conn) + .optional() + } + + /// Inserts a new installed package and returns the inserted ID. + pub fn insert(conn: &mut SqliteConnection, package: &NewPackage) -> QueryResult { + diesel::insert_into(packages::table) + .values(package) + .returning(packages::id) + .get_result(conn) + } + + /// Updates an installed package's version. + pub fn update_version( + conn: &mut SqliteConnection, + id: i32, + new_version: &str, + ) -> QueryResult { + diesel::update(packages::table.filter(packages::id.eq(id))) + .set(packages::version.eq(new_version)) + .execute(conn) + } + + /// Updates an installed package after successful installation. + #[allow(clippy::too_many_arguments)] + pub fn record_installation( + conn: &mut SqliteConnection, + repo_name: &str, + pkg_name: &str, + pkg_id: &str, + version: &str, + new_version: &str, + size: i64, + provides: Option>, + with_pkg_id: bool, + checksum: Option<&str>, + installed_date: &str, + ) -> QueryResult> { + let provides = provides.map(|v| serde_json::to_value(v).unwrap_or_default()); + diesel::update( + packages::table + .filter(packages::repo_name.eq(repo_name)) + .filter(packages::pkg_name.eq(pkg_name)) + .filter(packages::pkg_id.eq(pkg_id)) + .filter(packages::pinned.eq(false)) + .filter(packages::version.eq(version)), + ) + .set(( + packages::version.eq(new_version), + packages::size.eq(size), + packages::installed_date.eq(installed_date), + packages::is_installed.eq(true), + packages::provides.eq(provides), + packages::with_pkg_id.eq(with_pkg_id), + packages::checksum.eq(checksum), + )) + .returning(packages::id) + .get_result(conn) + .optional() + } + + /// Sets the pinned status of a package. + pub fn set_pinned(conn: &mut SqliteConnection, id: i32, pinned: bool) -> QueryResult { + diesel::update(packages::table.filter(packages::id.eq(id))) + .set(packages::pinned.eq(pinned)) + .execute(conn) + } + + /// Sets the unlinked status of a package. + pub fn set_unlinked( + conn: &mut SqliteConnection, + id: i32, + unlinked: bool, + ) -> QueryResult { + diesel::update(packages::table.filter(packages::id.eq(id))) + .set(packages::unlinked.eq(unlinked)) + .execute(conn) + } + + /// Unlinks all packages with a given name except those matching pkg_id and version. + pub fn unlink_others( + conn: &mut SqliteConnection, + pkg_name: &str, + keep_pkg_id: &str, + keep_version: &str, + ) -> QueryResult { + diesel::update( + packages::table + .filter(packages::pkg_name.eq(pkg_name)) + .filter( + packages::pkg_id + .ne(keep_pkg_id) + .or(packages::version.ne(keep_version)), + ), + ) + .set(packages::unlinked.eq(true)) + .execute(conn) + } + + /// Updates the pkg_id for packages matching repo_name and old pkg_id. + pub fn update_pkg_id( + conn: &mut SqliteConnection, + repo_name: &str, + old_pkg_id: &str, + new_pkg_id: &str, + ) -> QueryResult { + diesel::update( + packages::table + .filter(packages::repo_name.eq(repo_name)) + .filter(packages::pkg_id.eq(old_pkg_id)), + ) + .set(packages::pkg_id.eq(new_pkg_id)) + .execute(conn) + } + + /// Deletes an installed package by ID. + pub fn delete(conn: &mut SqliteConnection, id: i32) -> QueryResult { + diesel::delete(packages::table.filter(packages::id.eq(id))).execute(conn) + } + + /// Gets the portable package configuration for a package. + pub fn get_portable( + conn: &mut SqliteConnection, + package_id: i32, + ) -> QueryResult> { + portable_package::table + .filter(portable_package::package_id.eq(package_id)) + .select(PortablePackage::as_select()) + .first(conn) + .optional() + } + + /// Inserts portable package configuration. + pub fn insert_portable( + conn: &mut SqliteConnection, + portable: &NewPortablePackage, + ) -> QueryResult { + diesel::insert_into(portable_package::table) + .values(portable) + .execute(conn) + } + + /// Updates or inserts portable package configuration. + pub fn upsert_portable( + conn: &mut SqliteConnection, + package_id: i32, + portable_path: Option<&str>, + portable_home: Option<&str>, + portable_config: Option<&str>, + portable_share: Option<&str>, + portable_cache: Option<&str>, + ) -> QueryResult { + let updated = diesel::update( + portable_package::table.filter(portable_package::package_id.eq(package_id)), + ) + .set(( + portable_package::portable_path.eq(portable_path), + portable_package::portable_home.eq(portable_home), + portable_package::portable_config.eq(portable_config), + portable_package::portable_share.eq(portable_share), + portable_package::portable_cache.eq(portable_cache), + )) + .execute(conn)?; + + if updated == 0 { + diesel::insert_into(portable_package::table) + .values(&NewPortablePackage { + package_id, + portable_path, + portable_home, + portable_config, + portable_share, + portable_cache, + }) + .execute(conn) + } else { + Ok(updated) + } + } + + /// Deletes portable package configuration. + pub fn delete_portable(conn: &mut SqliteConnection, package_id: i32) -> QueryResult { + diesel::delete(portable_package::table.filter(portable_package::package_id.eq(package_id))) + .execute(conn) + } + + /// Gets old package versions (all except the newest unpinned one) for cleanup. + /// Returns the installed paths of packages to remove. + pub fn get_old_package_paths( + conn: &mut SqliteConnection, + pkg_id: &str, + pkg_name: &str, + repo_name: &str, + ) -> QueryResult> { + let latest_id: Option = packages::table + .filter(packages::pkg_id.eq(pkg_id)) + .filter(packages::pkg_name.eq(pkg_name)) + .filter(packages::repo_name.eq(repo_name)) + .order(packages::id.desc()) + .select(packages::id) + .first(conn) + .optional()?; + + let Some(latest_id) = latest_id else { + return Ok(Vec::new()); + }; + + packages::table + .filter(packages::pkg_id.eq(pkg_id)) + .filter(packages::pkg_name.eq(pkg_name)) + .filter(packages::repo_name.eq(repo_name)) + .filter(packages::pinned.eq(false)) + .filter(packages::id.ne(latest_id)) + .select((packages::id, packages::installed_path)) + .load(conn) + } + + /// Deletes old package versions (all except the newest unpinned one). + pub fn delete_old_packages( + conn: &mut SqliteConnection, + pkg_id: &str, + pkg_name: &str, + repo_name: &str, + ) -> QueryResult { + let latest_id: Option = packages::table + .filter(packages::pkg_id.eq(pkg_id)) + .filter(packages::pkg_name.eq(pkg_name)) + .filter(packages::repo_name.eq(repo_name)) + .order(packages::id.desc()) + .select(packages::id) + .first(conn) + .optional()?; + + let Some(latest_id) = latest_id else { + return Ok(0); + }; + + diesel::delete( + packages::table + .filter(packages::pkg_id.eq(pkg_id)) + .filter(packages::pkg_name.eq(pkg_name)) + .filter(packages::repo_name.eq(repo_name)) + .filter(packages::pinned.eq(false)) + .filter(packages::id.ne(latest_id)), + ) + .execute(conn) + } + + /// Unlinks all packages with a given name except those matching pkg_id and checksum. + /// Used when switching between alternate package versions. + pub fn unlink_others_by_checksum( + conn: &mut SqliteConnection, + pkg_name: &str, + keep_pkg_id: &str, + keep_checksum: Option<&str>, + ) -> QueryResult { + if let Some(checksum) = keep_checksum { + diesel::update( + packages::table + .filter(packages::pkg_name.eq(pkg_name)) + .filter(packages::pkg_id.ne(keep_pkg_id)) + .filter(packages::checksum.ne(checksum)), + ) + .set(packages::unlinked.eq(true)) + .execute(conn) + } else { + diesel::update( + packages::table + .filter(packages::pkg_name.eq(pkg_name)) + .filter(packages::pkg_id.ne(keep_pkg_id)), + ) + .set(packages::unlinked.eq(true)) + .execute(conn) + } + } + + /// Links a package by pkg_name, pkg_id, and checksum. + /// Used when switching to an alternate package version. + pub fn link_by_checksum( + conn: &mut SqliteConnection, + pkg_name: &str, + pkg_id: &str, + checksum: Option<&str>, + ) -> QueryResult { + if let Some(checksum) = checksum { + diesel::update( + packages::table + .filter(packages::pkg_name.eq(pkg_name)) + .filter(packages::pkg_id.eq(pkg_id)) + .filter(packages::checksum.eq(checksum)), + ) + .set(packages::unlinked.eq(false)) + .execute(conn) + } else { + diesel::update( + packages::table + .filter(packages::pkg_name.eq(pkg_name)) + .filter(packages::pkg_id.eq(pkg_id)), + ) + .set(packages::unlinked.eq(false)) + .execute(conn) + } + } +} diff --git a/crates/soar-db/src/repository/metadata.rs b/crates/soar-db/src/repository/metadata.rs new file mode 100644 index 00000000..1ccc72a8 --- /dev/null +++ b/crates/soar-db/src/repository/metadata.rs @@ -0,0 +1,492 @@ +//! Metadata database repository for package queries. + +use diesel::{dsl::sql, prelude::*, sql_types::Text}; +use regex::Regex; +use serde_json::json; +use soar_registry::RemotePackage; + +use super::core::SortDirection; +use crate::{ + models::{ + metadata::{ + Maintainer, NewMaintainer, NewPackage, NewPackageMaintainer, NewRepository, Package, + }, + types::PackageProvide, + }, + schema::metadata::{maintainers, package_maintainers, packages, repository}, +}; + +/// Helper struct for raw SQL queries returning just pkg_id. +#[derive(Debug, QueryableByName)] +struct PkgIdOnly { + #[diesel(sql_type = Text)] + pkg_id: String, +} + +/// Repository for package metadata operations. +pub struct MetadataRepository; + +impl MetadataRepository { + /// Lists all packages using Diesel DSL. + pub fn list_all(conn: &mut SqliteConnection) -> QueryResult> { + packages::table + .order(packages::pkg_name.asc()) + .select(Package::as_select()) + .load(conn) + } + + /// Lists packages with pagination and sorting using Diesel DSL. + pub fn list_paginated( + conn: &mut SqliteConnection, + page: i64, + per_page: i64, + ) -> QueryResult> { + let offset = (page - 1) * per_page; + + packages::table + .order(packages::pkg_name.asc()) + .limit(per_page) + .offset(offset) + .select(Package::as_select()) + .load(conn) + } + + /// Gets the repository name from the database. + pub fn get_repo_name(conn: &mut SqliteConnection) -> QueryResult> { + repository::table + .select(repository::name) + .first(conn) + .optional() + } + + /// Gets the repository etag from the database. + pub fn get_repo_etag(conn: &mut SqliteConnection) -> QueryResult> { + repository::table + .select(repository::etag) + .first(conn) + .optional() + } + + /// Updates the repository metadata (name and etag). + pub fn update_repo_metadata( + conn: &mut SqliteConnection, + name: &str, + etag: &str, + ) -> QueryResult { + diesel::update(repository::table) + .set((repository::name.eq(name), repository::etag.eq(etag))) + .execute(conn) + } + + /// Finds a package by ID using Diesel DSL. + pub fn find_by_id(conn: &mut SqliteConnection, id: i32) -> QueryResult> { + packages::table + .filter(packages::id.eq(id)) + .select(Package::as_select()) + .first(conn) + .optional() + } + + /// Finds packages by name (exact match) using Diesel DSL. + pub fn find_by_name(conn: &mut SqliteConnection, name: &str) -> QueryResult> { + packages::table + .filter(packages::pkg_name.eq(name)) + .select(Package::as_select()) + .load(conn) + } + + /// Finds a package by pkg_id using Diesel DSL. + pub fn find_by_pkg_id( + conn: &mut SqliteConnection, + pkg_id: &str, + ) -> QueryResult> { + packages::table + .filter(packages::pkg_id.eq(pkg_id)) + .select(Package::as_select()) + .first(conn) + .optional() + } + + /// Finds packages that match pkg_name and optionally pkg_id and version using Diesel DSL. + pub fn find_by_query( + conn: &mut SqliteConnection, + pkg_name: Option<&str>, + pkg_id: Option<&str>, + version: Option<&str>, + ) -> QueryResult> { + let mut query = packages::table.into_boxed(); + + if let Some(name) = pkg_name { + query = query.filter(packages::pkg_name.eq(name)); + } + if let Some(id) = pkg_id { + if id != "all" { + query = query.filter(packages::pkg_id.eq(id)); + } + } + if let Some(ver) = version { + query = query.filter(packages::version.eq(ver)); + } + + query.select(Package::as_select()).load(conn) + } + + /// Searches packages by pattern (case-insensitive LIKE query) using Diesel DSL. + /// Searches across pkg_name and pkg_id fields. + pub fn search( + conn: &mut SqliteConnection, + pattern: &str, + limit: Option, + ) -> QueryResult> { + let like_pattern = format!("%{}%", pattern.to_lowercase()); + + let mut query = packages::table + .filter( + sql::("LOWER(pkg_name) LIKE ") + .bind::(&like_pattern) + .sql(" OR LOWER(pkg_id) LIKE ") + .bind::(&like_pattern), + ) + .order(packages::pkg_name.asc()) + .into_boxed(); + + if let Some(lim) = limit { + query = query.limit(lim); + } + + query.select(Package::as_select()).load(conn) + } + + /// Searches packages (case-sensitive LIKE query) using Diesel DSL. + pub fn search_case_sensitive( + conn: &mut SqliteConnection, + pattern: &str, + limit: Option, + ) -> QueryResult> { + let like_pattern = format!("%{}%", pattern); + + let mut query = packages::table + .filter( + packages::pkg_name + .like(&like_pattern) + .or(packages::pkg_id.like(&like_pattern)), + ) + .order(packages::pkg_name.asc()) + .into_boxed(); + + if let Some(lim) = limit { + query = query.limit(lim); + } + + query.select(Package::as_select()).load(conn) + } + + /// Checks if a package exists that replaces the given pkg_id. + /// Returns the pkg_id of the replacement package if found. + /// Uses raw SQL for JSON array search since Diesel doesn't support json_each. + pub fn find_replacement_pkg_id( + conn: &mut SqliteConnection, + pkg_id: &str, + ) -> QueryResult> { + let query = "SELECT pkg_id FROM packages WHERE EXISTS \ + (SELECT 1 FROM json_each(replaces) WHERE json_each.value = ?) LIMIT 1"; + + diesel::sql_query(query) + .bind::(pkg_id) + .load::(conn) + .map(|mut v| v.pop().map(|p| p.pkg_id)) + } + + /// Counts total packages. + pub fn count(conn: &mut SqliteConnection) -> QueryResult { + packages::table.count().get_result(conn) + } + + /// Counts packages matching a search pattern using Diesel DSL. + pub fn count_search(conn: &mut SqliteConnection, pattern: &str) -> QueryResult { + let like_pattern = format!("%{}%", pattern.to_lowercase()); + + packages::table + .filter( + sql::("LOWER(pkg_name) LIKE ") + .bind::(&like_pattern) + .sql(" OR LOWER(pkg_id) LIKE ") + .bind::(&like_pattern), + ) + .count() + .get_result(conn) + } + + /// Inserts a new package. + pub fn insert(conn: &mut SqliteConnection, package: &NewPackage) -> QueryResult { + diesel::insert_into(packages::table) + .values(package) + .execute(conn) + } + + /// Gets the last inserted package ID. + pub fn last_insert_id(conn: &mut SqliteConnection) -> QueryResult { + diesel::select(sql::("last_insert_rowid()")).get_result(conn) + } + + /// Finds or creates a maintainer. + pub fn find_or_create_maintainer( + conn: &mut SqliteConnection, + contact: &str, + name: &str, + ) -> QueryResult { + let existing: Option = maintainers::table + .filter(maintainers::contact.eq(contact)) + .select(Maintainer::as_select()) + .first(conn) + .optional()?; + + if let Some(m) = existing { + return Ok(m.id); + } + + let new_maintainer = NewMaintainer { + contact, + name, + }; + diesel::insert_into(maintainers::table) + .values(&new_maintainer) + .execute(conn)?; + + Self::last_insert_id(conn) + } + + /// Links a maintainer to a package. + pub fn link_maintainer( + conn: &mut SqliteConnection, + package_id: i32, + maintainer_id: i32, + ) -> QueryResult { + let link = NewPackageMaintainer { + package_id, + maintainer_id, + }; + diesel::insert_into(package_maintainers::table) + .values(&link) + .on_conflict_do_nothing() + .execute(conn) + } + + /// Gets maintainers for a package. + pub fn get_maintainers( + conn: &mut SqliteConnection, + package_id: i32, + ) -> QueryResult> { + maintainers::table + .inner_join( + package_maintainers::table + .on(maintainers::id.eq(package_maintainers::maintainer_id)), + ) + .filter(package_maintainers::package_id.eq(package_id)) + .select(Maintainer::as_select()) + .load(conn) + } + + /// Deletes all packages (for reimport). + pub fn delete_all(conn: &mut SqliteConnection) -> QueryResult { + diesel::delete(packages::table).execute(conn) + } + + /// Finds packages with flexible filtering using Diesel DSL. + pub fn find_filtered( + conn: &mut SqliteConnection, + pkg_name: Option<&str>, + pkg_id: Option<&str>, + version: Option<&str>, + limit: Option, + sort_by_name: Option, + ) -> QueryResult> { + let mut query = packages::table.into_boxed(); + + if let Some(name) = pkg_name { + query = query.filter(packages::pkg_name.eq(name)); + } + if let Some(id) = pkg_id { + if id != "all" { + query = query.filter(packages::pkg_id.eq(id)); + } + } + if let Some(ver) = version { + query = query.filter(packages::version.eq(ver)); + } + + if let Some(direction) = sort_by_name { + query = match direction { + SortDirection::Asc => query.order(packages::pkg_name.asc()), + SortDirection::Desc => query.order(packages::pkg_name.desc()), + }; + } + + if let Some(lim) = limit { + query = query.limit(lim); + } + + query.select(Package::as_select()).load(conn) + } + + /// Finds packages with a newer version than the given version. + /// Used for update checking. + /// Uses Diesel DSL with raw SQL filter for version comparison. + pub fn find_newer_version( + conn: &mut SqliteConnection, + pkg_name: &str, + pkg_id: &str, + current_version: &str, + ) -> QueryResult> { + // Handle both regular versions and HEAD- versions + let head_version = if current_version.starts_with("HEAD-") && current_version.len() > 14 { + current_version[14..].to_string() + } else { + String::new() + }; + + packages::table + .filter(packages::pkg_name.eq(pkg_name)) + .filter(packages::pkg_id.eq(pkg_id)) + .filter( + sql::("version > ") + .bind::(current_version) + .sql(" OR (version LIKE 'HEAD-%' AND substr(version, 14) > ") + .bind::(&head_version) + .sql(")"), + ) + .order(packages::version.desc()) + .select(Package::as_select()) + .first(conn) + .optional() + } + + /// Checks if a package with the given pkg_id exists. + pub fn exists_by_pkg_id(conn: &mut SqliteConnection, pkg_id: &str) -> QueryResult { + diesel::select(diesel::dsl::exists( + packages::table.filter(packages::pkg_id.eq(pkg_id)), + )) + .get_result(conn) + } + + /// Imports packages from remote metadata (JSON format). + pub fn import_packages( + conn: &mut SqliteConnection, + metadata: &[RemotePackage], + repo_name: &str, + ) -> QueryResult<()> { + conn.transaction(|conn| { + diesel::insert_into(repository::table) + .values(NewRepository { + name: repo_name, + etag: "", + }) + .on_conflict(repository::name) + .do_update() + .set(repository::etag.eq("")) + .execute(conn)?; + + for package in metadata { + Self::insert_remote_package(conn, package)?; + } + Ok(()) + }) + } + + /// Inserts a single remote package. + fn insert_remote_package( + conn: &mut SqliteConnection, + package: &RemotePackage, + ) -> QueryResult<()> { + const PROVIDES_DELIMITERS: &[&str] = &["==", "=>", ":"]; + + let provides = package.provides.as_ref().map(|vec| { + vec.iter() + .filter_map(|p| { + let include = *p == package.pkg_name + || matches!(package.recurse_provides, Some(true)) + || p.strip_prefix(&package.pkg_name).is_some_and(|rest| { + PROVIDES_DELIMITERS.iter().any(|d| rest.starts_with(d)) + }); + + include.then(|| PackageProvide::from_string(p)) + }) + .collect::>() + }); + + let new_package = NewPackage { + pkg_id: &package.pkg_id, + pkg_name: &package.pkg_name, + pkg_type: package.pkg_type.as_deref(), + pkg_webpage: package.pkg_webpage.as_deref(), + app_id: package.app_id.as_deref(), + description: Some(&package.description), + version: &package.version, + version_upstream: package.version_upstream.as_deref(), + licenses: Some(json!(package.licenses)), + download_url: &package.download_url, + size: package.size_raw.map(|s| s as i64), + ghcr_pkg: package.ghcr_pkg.as_deref(), + ghcr_size: package.ghcr_size_raw.map(|s| s as i64), + ghcr_blob: package.ghcr_blob.as_deref(), + ghcr_url: package.ghcr_url.as_deref(), + bsum: package.bsum.as_deref(), + icon: package.icon.as_deref(), + desktop: package.desktop.as_deref(), + appstream: package.appstream.as_deref(), + homepages: Some(json!(package.homepages)), + notes: Some(json!(package.notes)), + source_urls: Some(json!(package.src_urls)), + tags: Some(json!(&package.tags)), + categories: Some(json!(package.categories)), + build_id: package.build_id.as_deref(), + build_date: package.build_date.as_deref(), + build_action: package.build_action.as_deref(), + build_script: package.build_script.as_deref(), + build_log: package.build_log.as_deref(), + provides: Some(json!(provides)), + snapshots: Some(json!(package.snapshots)), + replaces: Some(json!(package.replaces)), + soar_syms: package.soar_syms.unwrap_or(false), + desktop_integration: package.desktop_integration, + portable: package.portable, + recurse_provides: package.recurse_provides, + }; + + let inserted = diesel::insert_into(packages::table) + .values(&new_package) + .on_conflict((packages::pkg_id, packages::pkg_name, packages::version)) + .do_nothing() + .execute(conn)?; + + if inserted == 0 { + return Ok(()); + } + + let package_id = Self::last_insert_id(conn)?; + + if let Some(maintainers) = &package.maintainers { + for maintainer in maintainers { + if let Some((name, contact)) = Self::extract_name_and_contact(maintainer) { + let maintainer_id = Self::find_or_create_maintainer(conn, &contact, &name)?; + Self::link_maintainer(conn, package_id, maintainer_id)?; + } + } + } + + Ok(()) + } + + /// Extracts name and contact from maintainer string format "Name (contact)". + fn extract_name_and_contact(input: &str) -> Option<(String, String)> { + let re = Regex::new(r"^([^()]+) \(([^)]+)\)$").unwrap(); + + if let Some(captures) = re.captures(input) { + let name = captures.get(1).map_or("", |m| m.as_str()).to_string(); + let contact = captures.get(2).map_or("", |m| m.as_str()).to_string(); + Some((name, contact)) + } else { + None + } + } +} diff --git a/crates/soar-db/src/repository/mod.rs b/crates/soar-db/src/repository/mod.rs new file mode 100644 index 00000000..4614bf5b --- /dev/null +++ b/crates/soar-db/src/repository/mod.rs @@ -0,0 +1,12 @@ +//! Repository pattern implementations for database operations. +//! +//! This module provides type-safe database operations using the repository pattern. +//! Each repository handles CRUD operations for a specific domain: +//! +//! - [`CoreRepository`] - Installed package operations +//! - [`MetadataRepository`] - Package metadata queries +//! - [`NestRepository`] - Nest configuration management + +pub mod core; +pub mod metadata; +pub mod nest; diff --git a/crates/soar-db/src/repository/nest.rs b/crates/soar-db/src/repository/nest.rs new file mode 100644 index 00000000..5449cddf --- /dev/null +++ b/crates/soar-db/src/repository/nest.rs @@ -0,0 +1,66 @@ +//! Nest database repository for nest configuration management. + +use diesel::prelude::*; + +use crate::models::nest::{Nest, NewNest}; +use crate::schema::nest::nests; + +/// Repository for nest operations. +pub struct NestRepository; + +impl NestRepository { + /// Lists all nests. + pub fn list_all(conn: &mut SqliteConnection) -> QueryResult> { + nests::table.select(Nest::as_select()).load(conn) + } + + /// Finds a nest by ID. + pub fn find_by_id(conn: &mut SqliteConnection, id: i32) -> QueryResult> { + nests::table + .filter(nests::id.eq(id)) + .select(Nest::as_select()) + .first(conn) + .optional() + } + + /// Finds a nest by name. + pub fn find_by_name(conn: &mut SqliteConnection, name: &str) -> QueryResult> { + nests::table + .filter(nests::name.eq(name)) + .select(Nest::as_select()) + .first(conn) + .optional() + } + + /// Finds a nest by URL. + pub fn find_by_url(conn: &mut SqliteConnection, url: &str) -> QueryResult> { + nests::table + .filter(nests::url.eq(url)) + .select(Nest::as_select()) + .first(conn) + .optional() + } + + /// Inserts a new nest. + pub fn insert(conn: &mut SqliteConnection, nest: &NewNest) -> QueryResult { + diesel::insert_into(nests::table) + .values(nest) + .execute(conn) + } + + /// Deletes a nest by name. + pub fn delete_by_name(conn: &mut SqliteConnection, name: &str) -> QueryResult { + diesel::delete(nests::table.filter(nests::name.eq(name))).execute(conn) + } + + /// Deletes a nest by ID. + pub fn delete_by_id(conn: &mut SqliteConnection, id: i32) -> QueryResult { + diesel::delete(nests::table.filter(nests::id.eq(id))).execute(conn) + } + + /// Checks if a nest with the given name exists. + pub fn exists_by_name(conn: &mut SqliteConnection, name: &str) -> QueryResult { + use diesel::dsl::exists; + diesel::select(exists(nests::table.filter(nests::name.eq(name)))).get_result(conn) + } +} diff --git a/crates/soar-db/src/schema/core.rs b/crates/soar-db/src/schema/core.rs index 8603593d..f890b5ad 100644 --- a/crates/soar-db/src/schema/core.rs +++ b/crates/soar-db/src/schema/core.rs @@ -2,7 +2,6 @@ diesel::table! { packages (id) { id -> Integer, repo_name -> Text, - pkg -> Nullable, pkg_id -> Text, pkg_name -> Text, pkg_type -> Nullable, diff --git a/crates/soar-db/src/schema/metadata.rs b/crates/soar-db/src/schema/metadata.rs index b464e3a3..97e57940 100644 --- a/crates/soar-db/src/schema/metadata.rs +++ b/crates/soar-db/src/schema/metadata.rs @@ -17,8 +17,6 @@ diesel::table! { diesel::table! { packages (id) { id -> Integer, - rank -> Nullable, - pkg -> Nullable, pkg_id -> Text, pkg_name -> Text, pkg_type -> Nullable, @@ -34,7 +32,7 @@ diesel::table! { ghcr_size -> Nullable, ghcr_blob -> Nullable, ghcr_url -> Nullable, - checksum -> Nullable, + bsum -> Nullable, icon -> Nullable, desktop -> Nullable, appstream -> Nullable, diff --git a/crates/soar-registry/src/lib.rs b/crates/soar-registry/src/lib.rs index 24a1acfc..e7f7eb3d 100644 --- a/crates/soar-registry/src/lib.rs +++ b/crates/soar-registry/src/lib.rs @@ -41,8 +41,9 @@ pub mod package; pub use error::{ErrorContext, RegistryError, Result}; pub use metadata::{ - fetch_metadata, fetch_nest_metadata, fetch_public_key, process_metadata_content, - write_metadata_db, MetadataContent, SQLITE_MAGIC_BYTES, ZST_MAGIC_BYTES, + fetch_metadata, fetch_metadata_with_etag, fetch_nest_metadata, fetch_nest_metadata_with_etag, + fetch_public_key, process_metadata_content, write_metadata_db, MetadataContent, + SQLITE_MAGIC_BYTES, ZST_MAGIC_BYTES, }; pub use nest::Nest; pub use package::RemotePackage; diff --git a/crates/soar-registry/src/metadata.rs b/crates/soar-registry/src/metadata.rs index c7736f96..9781d39e 100644 --- a/crates/soar-registry/src/metadata.rs +++ b/crates/soar-registry/src/metadata.rs @@ -165,6 +165,92 @@ pub async fn fetch_nest_metadata( Ok(Some((etag, metadata_content))) } +/// Fetches nest metadata with a pre-provided etag. +/// +/// This function is similar to [`fetch_nest_metadata`] but accepts an optional etag +/// that was previously read from the database. This is useful when the caller +/// has already read the etag using soar-db. +pub async fn fetch_nest_metadata_with_etag( + nest: &Nest, + force: bool, + existing_etag: Option, +) -> Result> { + let config = get_config(); + let nests_repo_path = config + .get_repositories_path() + .map_err(|e| { + RegistryError::IoError { + action: "getting repositories path".to_string(), + source: io::Error::other(e.to_string()), + } + })? + .join("nests"); + let nest_path = nests_repo_path.join(&nest.name); + let metadata_db = nest_path.join("metadata.db"); + + if !metadata_db.exists() { + fs::create_dir_all(&nest_path) + .with_context(|| format!("creating directory {}", nest_path.display()))?; + } + + let etag = if metadata_db.exists() { + let etag = existing_etag.unwrap_or_default(); + + if !force && !etag.is_empty() { + let file_info = metadata_db + .metadata() + .with_context(|| format!("reading file metadata from {}", metadata_db.display()))?; + let sync_interval = config.get_nests_sync_interval(); + if let Ok(created) = file_info.created() { + if sync_interval >= created.elapsed()?.as_millis() { + return Ok(None); + } + } + } + etag + } else { + String::new() + }; + + let url = construct_nest_url(&nest.url)?; + + let mut req = SHARED_AGENT + .get(&url) + .header(CACHE_CONTROL, "no-cache") + .header(PRAGMA, "no-cache"); + + if !etag.is_empty() { + req = req.header(IF_NONE_MATCH, etag); + } + + let resp = req + .call() + .map_err(|err| RegistryError::FailedToFetchRemote(err.to_string()))?; + + if resp.status() == StatusCode::NOT_MODIFIED { + return Ok(None); + } + + if !resp.status().is_success() { + let msg = format!("{} [{}]", url, resp.status()); + return Err(RegistryError::FailedToFetchRemote(msg)); + } + + let etag = resp + .headers() + .get(ETAG) + .and_then(|h| h.to_str().ok()) + .map(String::from) + .ok_or(RegistryError::MissingEtag)?; + + info!("Fetching nest from {}", url); + + let content = resp.into_body().read_to_vec()?; + let metadata_content = process_metadata_content(content, &metadata_db)?; + + Ok(Some((etag, metadata_content))) +} + /// Fetches the public key for package signature verification. /// /// Downloads the minisign public key from the specified URL and saves it @@ -320,22 +406,103 @@ pub async fn fetch_metadata( Ok(Some((etag, metadata_content))) } -/// Read ETag from an existing metadata database -fn read_etag_from_db(db_path: &Path) -> Result { - let signature = soar_utils::fs::read_file_signature(db_path, 4).map_err(|e| { +/// Read ETag from an existing metadata database. +/// Note: This always returns empty string due to cyclic dependency issues with soar-db. +/// Callers should use `fetch_metadata_with_etag` with an etag read using soar-db. +fn read_etag_from_db(_db_path: &Path) -> Result { + Ok(String::new()) +} + +/// Fetches repository metadata with a pre-provided etag. +/// +/// This function is similar to [`fetch_metadata`] but accepts an optional etag +/// that was previously read from the database. This is useful when the caller +/// has already read the etag using soar-db. +/// +/// # Arguments +/// +/// * `repo` - The repository configuration +/// * `force` - If `true`, bypasses cache validation and fetches fresh metadata +/// * `existing_etag` - Optional etag from a previous fetch, read from the database +pub async fn fetch_metadata_with_etag( + repo: &Repository, + force: bool, + existing_etag: Option, +) -> Result> { + let repo_path = repo.get_path().map_err(|e| { RegistryError::IoError { - action: format!("reading signature from {}", db_path.display()), + action: "getting repository path".to_string(), source: io::Error::other(e.to_string()), } })?; + let metadata_db = repo_path.join("metadata.db"); + + if !metadata_db.exists() { + fs::create_dir_all(&repo_path) + .with_context(|| format!("creating directory {}", repo_path.display()))?; + } - if signature == SQLITE_MAGIC_BYTES { - // Return empty string - the caller should read the actual ETag from the database - // This is a simplified version; in practice the caller handles this - Ok(String::new()) + let sync_interval = repo.sync_interval(); + + let etag = if metadata_db.exists() { + let etag = existing_etag.unwrap_or_default(); + + if !force && !etag.is_empty() { + let file_info = metadata_db + .metadata() + .with_context(|| format!("reading file metadata from {}", metadata_db.display()))?; + if let Ok(created) = file_info.created() { + if sync_interval >= created.elapsed()?.as_millis() { + return Ok(None); + } + } + } + etag } else { - Ok(String::new()) + String::new() + }; + + Url::parse(&repo.url).map_err(|err| RegistryError::InvalidUrl(err.to_string()))?; + + if let Some(ref pubkey_url) = repo.pubkey { + fetch_public_key(&repo_path, pubkey_url).await?; + } + + let mut req = SHARED_AGENT + .get(&repo.url) + .header(CACHE_CONTROL, "no-cache") + .header(PRAGMA, "no-cache"); + + if !etag.is_empty() { + req = req.header(IF_NONE_MATCH, etag); + } + + let resp = req + .call() + .map_err(|err| RegistryError::FailedToFetchRemote(err.to_string()))?; + + if resp.status() == StatusCode::NOT_MODIFIED { + return Ok(None); } + + if !resp.status().is_success() { + let msg = format!("{} [{}]", repo.url, resp.status()); + return Err(RegistryError::FailedToFetchRemote(msg)); + } + + let etag = resp + .headers() + .get(ETAG) + .and_then(|h| h.to_str().ok()) + .map(String::from) + .ok_or(RegistryError::MissingEtag)?; + + info!("Fetching metadata from {}", repo.url); + + let content = resp.into_body().read_to_vec()?; + let metadata_content = process_metadata_content(content, &metadata_db)?; + + Ok(Some((etag, metadata_content))) } /// Processes raw metadata content and determines its format. diff --git a/crates/soar-registry/src/package.rs b/crates/soar-registry/src/package.rs index 473200d0..5ac2e7a7 100644 --- a/crates/soar-registry/src/package.rs +++ b/crates/soar-registry/src/package.rs @@ -181,15 +181,6 @@ pub struct RemotePackage { #[serde(default, deserialize_with = "empty_is_none")] pub app_id: Option, - #[serde(default, deserialize_with = "optional_number")] - pub download_count: Option, - - #[serde(default, deserialize_with = "optional_number")] - pub download_count_month: Option, - - #[serde(default, deserialize_with = "optional_number")] - pub download_count_week: Option, - #[serde(default, deserialize_with = "flexible_bool")] pub bundle: Option, diff --git a/crates/soar-utils/Cargo.toml b/crates/soar-utils/Cargo.toml index 2ab1751e..ef2a23a1 100644 --- a/crates/soar-utils/Cargo.toml +++ b/crates/soar-utils/Cargo.toml @@ -12,8 +12,10 @@ categories.workspace = true [dependencies] blake3 = { version = "1.8.2", features = ["mmap"] } +miette = { workspace = true } nix = { version = "0.30.1", features = ["ioctl", "term", "user"] } serial_test = { workspace = true } +thiserror = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/soar-utils/src/error.rs b/crates/soar-utils/src/error.rs index fa90a10e..fbb46921 100644 --- a/crates/soar-utils/src/error.rs +++ b/crates/soar-utils/src/error.rs @@ -1,311 +1,214 @@ -use std::{error::Error, fmt, path::PathBuf}; +//! Error types for soar-utils. -#[derive(Debug)] +use std::path::PathBuf; + +use miette::Diagnostic; +use thiserror::Error; + +/// Error type for byte parsing operations. +#[derive(Error, Diagnostic, Debug)] pub enum BytesError { + #[error("Failed to parse '{input}' as bytes: {reason}")] + #[diagnostic( + code(soar_utils::bytes::parse), + help("Use a valid byte format like '1KB', '2MB', or '3GB'") + )] ParseFailed { input: String, reason: String }, } -impl fmt::Display for BytesError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - BytesError::ParseFailed { - input, - reason, - } => { - write!(f, "Failed to parse `{input}` as bytes: {reason}") - } - } - } -} - -#[derive(Debug)] +/// Error type for hash operations. +#[derive(Error, Diagnostic, Debug)] pub enum HashError { + #[error("Failed to read file '{path}'")] + #[diagnostic( + code(soar_utils::hash::read), + help("Check if the file exists and you have read permissions") + )] ReadFailed { path: PathBuf, + #[source] source: std::io::Error, }, } -impl fmt::Display for HashError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - HashError::ReadFailed { - path, - source, - } => { - write!(f, "Failed to read file `{}`: {source}", path.display()) - } - } - } -} - -impl Error for HashError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - HashError::ReadFailed { - source, .. - } => Some(source), - } - } -} - -impl Error for BytesError {} - -#[derive(Debug)] +/// Error type for path operations. +#[derive(Error, Diagnostic, Debug)] pub enum PathError { - FailedToGetCurrentDir { source: std::io::Error }, + #[error("Failed to get current directory")] + #[diagnostic( + code(soar_utils::path::cwd), + help("Check if the current directory still exists") + )] + FailedToGetCurrentDir { + #[source] + source: std::io::Error, + }, + #[error("Path is empty")] + #[diagnostic( + code(soar_utils::path::empty), + help("Provide a non-empty path") + )] Empty, + #[error("Environment variable '{var}' not set in '{input}'")] + #[diagnostic( + code(soar_utils::path::env_var), + help("Set the environment variable or use a different path") + )] MissingEnvVar { var: String, input: String }, + #[error("Unclosed variable expression starting at '{input}'")] + #[diagnostic( + code(soar_utils::path::unclosed_var), + help("Close the variable expression with '}}'") + )] UnclosedVariable { input: String }, } -impl fmt::Display for PathError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - PathError::Empty => write!(f, "Path is empty"), - PathError::FailedToGetCurrentDir { - source, - } => { - write!(f, "Failed to get current directory: {source}") - } - PathError::UnclosedVariable { - input, - } => { - write!(f, "Unclosed variable expression starting at `{input}`") - } - PathError::MissingEnvVar { - var, - input, - } => { - write!(f, "Environment variable `{var}` not set in `{input}`") - } - } - } -} - -impl Error for PathError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - PathError::FailedToGetCurrentDir { - source, - } => Some(source), - _ => None, - } - } -} - -#[derive(Debug)] +/// Error type for filesystem operations. +#[derive(Error, Diagnostic, Debug)] pub enum FileSystemError { - // File operations + #[error("Failed to read file '{path}'")] + #[diagnostic( + code(soar_utils::fs::read_file), + help("Check if the file exists and you have read permissions") + )] ReadFile { path: PathBuf, + #[source] source: std::io::Error, }, + #[error("Failed to write file '{path}'")] + #[diagnostic( + code(soar_utils::fs::write_file), + help("Check if you have write permissions to the directory") + )] WriteFile { path: PathBuf, + #[source] source: std::io::Error, }, + #[error("Failed to create file '{path}'")] + #[diagnostic( + code(soar_utils::fs::create_file), + help("Check if the directory exists and you have write permissions") + )] CreateFile { path: PathBuf, + #[source] source: std::io::Error, }, + #[error("Failed to remove file '{path}'")] + #[diagnostic( + code(soar_utils::fs::remove_file), + help("Check if you have write permissions to the file") + )] RemoveFile { path: PathBuf, + #[source] source: std::io::Error, }, - // Directory operations + #[error("Failed to read directory '{path}'")] + #[diagnostic( + code(soar_utils::fs::read_dir), + help("Check if the directory exists and you have read permissions") + )] ReadDirectory { path: PathBuf, + #[source] source: std::io::Error, }, + #[error("Failed to create directory '{path}'")] + #[diagnostic( + code(soar_utils::fs::create_dir), + help("Check if the parent directory exists and you have write permissions") + )] CreateDirectory { path: PathBuf, + #[source] source: std::io::Error, }, + #[error("Failed to remove directory '{path}'")] + #[diagnostic( + code(soar_utils::fs::remove_dir), + help("Check if the directory is empty and you have write permissions") + )] RemoveDirectory { path: PathBuf, + #[source] source: std::io::Error, }, - // Symlink operations + #[error("Failed to create symlink from '{from}' to '{target}'")] + #[diagnostic( + code(soar_utils::fs::create_symlink), + help("Check if you have write permissions and the target doesn't already exist") + )] CreateSymlink { from: PathBuf, target: PathBuf, + #[source] source: std::io::Error, }, + #[error("Failed to remove symlink '{path}'")] + #[diagnostic( + code(soar_utils::fs::remove_symlink), + help("Check if you have write permissions") + )] RemoveSymlink { path: PathBuf, + #[source] source: std::io::Error, }, + #[error("Failed to read symlink '{path}'")] + #[diagnostic( + code(soar_utils::fs::read_symlink), + help("Check if the symlink exists") + )] ReadSymlink { path: PathBuf, + #[source] source: std::io::Error, }, - // Path validation - NotFound { - path: PathBuf, - }, - - NotADirectory { - path: PathBuf, - }, - - NotAFile { - path: PathBuf, - }, -} - -impl fmt::Display for FileSystemError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - FileSystemError::ReadFile { - path, - source, - } => { - write!(f, "Failed to read file `{}`: {source}", path.display()) - } - FileSystemError::WriteFile { - path, - source, - } => { - write!(f, "Failed to write file `{}`: {source}", path.display()) - } - FileSystemError::CreateFile { - path, - source, - } => { - write!(f, "Failed to create file `{}`: {source}", path.display()) - } - FileSystemError::RemoveFile { - path, - source, - } => { - write!(f, "Failed to remove file `{}`: {source}", path.display()) - } - FileSystemError::ReadDirectory { - path, - source, - } => { - write!(f, "Failed to read directory `{}`: {source}", path.display()) - } - FileSystemError::CreateDirectory { - path, - source, - } => { - write!( - f, - "Failed to create directory `{}`: {source}", - path.display() - ) - } - FileSystemError::RemoveDirectory { - path, - source, - } => { - write!( - f, - "Failed to remove directory `{}`: {source}", - path.display() - ) - } - FileSystemError::CreateSymlink { - from, - target, - source, - } => { - write!( - f, - "Failed to create symlink from `{}` to `{}`: {source}", - from.display(), - target.display() - ) - } - FileSystemError::RemoveSymlink { - path, - source, - } => { - write!(f, "Failed to remove symlink `{}`: {source}", path.display()) - } - FileSystemError::ReadSymlink { - path, - source, - } => { - write!(f, "Failed to read symlink `{}`: {source}", path.display()) - } - FileSystemError::NotFound { - path, - } => { - write!(f, "Path `{}` not found", path.display()) - } - FileSystemError::NotADirectory { - path, - } => { - write!(f, "`{}` is not a directory", path.display()) - } - FileSystemError::NotAFile { - path, - } => { - write!(f, "`{}` is not a file", path.display()) - } - } - } -} - -impl Error for FileSystemError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - FileSystemError::ReadFile { - source, .. - } => Some(source), - FileSystemError::WriteFile { - source, .. - } => Some(source), - FileSystemError::CreateFile { - source, .. - } => Some(source), - FileSystemError::RemoveFile { - source, .. - } => Some(source), - FileSystemError::ReadDirectory { - source, .. - } => Some(source), - FileSystemError::CreateDirectory { - source, .. - } => Some(source), - FileSystemError::RemoveDirectory { - source, .. - } => Some(source), - FileSystemError::CreateSymlink { - source, .. - } => Some(source), - FileSystemError::RemoveSymlink { - source, .. - } => Some(source), - FileSystemError::ReadSymlink { - source, .. - } => Some(source), - _ => None, - } - } + #[error("Path '{path}' not found")] + #[diagnostic( + code(soar_utils::fs::not_found), + help("Check if the path exists") + )] + NotFound { path: PathBuf }, + + #[error("'{path}' is not a directory")] + #[diagnostic( + code(soar_utils::fs::not_a_dir), + help("Provide a path to a directory") + )] + NotADirectory { path: PathBuf }, + + #[error("'{path}' is not a file")] + #[diagnostic( + code(soar_utils::fs::not_a_file), + help("Provide a path to a file") + )] + NotAFile { path: PathBuf }, } +/// Context for filesystem operations. pub struct IoContext { path: PathBuf, operation: IoOperation, } +/// Type of filesystem operation. #[derive(Debug, Clone)] pub enum IoOperation { ReadFile, @@ -322,10 +225,7 @@ pub enum IoOperation { impl IoContext { pub fn new(path: PathBuf, operation: IoOperation) -> Self { - Self { - path, - operation, - } + Self { path, operation } } pub fn read_file>(path: P) -> Self { @@ -381,73 +281,52 @@ impl IoContext { impl From<(IoContext, std::io::Error)> for FileSystemError { fn from((ctx, source): (IoContext, std::io::Error)) -> Self { match ctx.operation { - IoOperation::ReadFile => { - FileSystemError::ReadFile { - path: ctx.path, - source, - } - } - IoOperation::WriteFile => { - FileSystemError::WriteFile { - path: ctx.path, - source, - } - } - IoOperation::CreateFile => { - FileSystemError::CreateFile { - path: ctx.path, - source, - } - } - IoOperation::RemoveFile => { - FileSystemError::RemoveFile { - path: ctx.path, - source, - } - } - IoOperation::CreateDirectory => { - FileSystemError::CreateDirectory { - path: ctx.path, - source, - } - } - IoOperation::RemoveDirectory => { - FileSystemError::RemoveDirectory { - path: ctx.path, - source, - } - } - IoOperation::ReadDirectory => { - FileSystemError::ReadDirectory { - path: ctx.path, - source, - } - } - IoOperation::CreateSymlink { + IoOperation::ReadFile => FileSystemError::ReadFile { + path: ctx.path, + source, + }, + IoOperation::WriteFile => FileSystemError::WriteFile { + path: ctx.path, + source, + }, + IoOperation::CreateFile => FileSystemError::CreateFile { + path: ctx.path, + source, + }, + IoOperation::RemoveFile => FileSystemError::RemoveFile { + path: ctx.path, + source, + }, + IoOperation::CreateDirectory => FileSystemError::CreateDirectory { + path: ctx.path, + source, + }, + IoOperation::RemoveDirectory => FileSystemError::RemoveDirectory { + path: ctx.path, + source, + }, + IoOperation::ReadDirectory => FileSystemError::ReadDirectory { + path: ctx.path, + source, + }, + IoOperation::CreateSymlink { target } => FileSystemError::CreateSymlink { + from: ctx.path, target, - } => { - FileSystemError::CreateSymlink { - from: ctx.path, - target, - source, - } - } - IoOperation::RemoveSymlink => { - FileSystemError::RemoveSymlink { - path: ctx.path, - source, - } - } - IoOperation::ReadSymlink => { - FileSystemError::ReadSymlink { - path: ctx.path, - source, - } - } + source, + }, + IoOperation::RemoveSymlink => FileSystemError::RemoveSymlink { + path: ctx.path, + source, + }, + IoOperation::ReadSymlink => FileSystemError::ReadSymlink { + path: ctx.path, + source, + }, } } } +/// Extension trait for adding path context to IO results. pub trait IoResultExt { fn with_path>(self, path: P, operation: IoOperation) -> FileSystemResult; } @@ -461,56 +340,26 @@ impl IoResultExt for std::io::Result { } } -#[derive(Debug)] +/// Combined error type for all utils errors. +#[derive(Error, Diagnostic, Debug)] pub enum UtilsError { - Bytes(BytesError), - Path(PathError), - FileSystem(FileSystemError), -} - -impl fmt::Display for UtilsError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - UtilsError::Bytes(err) => write!(f, "{err}"), - UtilsError::Path(err) => write!(f, "{err}"), - UtilsError::FileSystem(err) => write!(f, "{err}"), - } - } -} + #[error(transparent)] + #[diagnostic(transparent)] + Bytes(#[from] BytesError), -impl Error for UtilsError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - UtilsError::Bytes(err) => Some(err), - UtilsError::Path(err) => Some(err), - UtilsError::FileSystem(err) => Some(err), - } - } -} - -impl From for UtilsError { - fn from(err: BytesError) -> Self { - UtilsError::Bytes(err) - } -} - -impl From for UtilsError { - fn from(err: PathError) -> Self { - UtilsError::Path(err) - } -} + #[error(transparent)] + #[diagnostic(transparent)] + Path(#[from] PathError), -impl From for UtilsError { - fn from(err: FileSystemError) -> Self { - UtilsError::FileSystem(err) - } + #[error(transparent)] + #[diagnostic(transparent)] + FileSystem(#[from] FileSystemError), } pub type BytesResult = std::result::Result; pub type FileSystemResult = std::result::Result; pub type HashResult = std::result::Result; pub type PathResult = std::result::Result; - pub type UtilsResult = std::result::Result; #[cfg(test)] @@ -527,7 +376,7 @@ mod tests { }; assert_eq!( error.to_string(), - "Failed to parse `test` as bytes: invalid" + "Failed to parse 'test' as bytes: invalid" ); } @@ -538,28 +387,13 @@ mod tests { path: PathBuf::from("/test"), source: io_error, }; - assert_eq!( - error.to_string(), - "Failed to read file `/test`: file not found" - ); - assert!(error.source().is_some()); + assert_eq!(error.to_string(), "Failed to read file '/test'"); } #[test] - fn test_path_error_display_and_source() { - let io_error = io::Error::other("some error"); - let current_dir_error = PathError::FailedToGetCurrentDir { - source: io_error, - }; - assert_eq!( - current_dir_error.to_string(), - "Failed to get current directory: some error" - ); - assert!(current_dir_error.source().is_some()); - + fn test_path_error_display() { let empty_error = PathError::Empty; assert_eq!(empty_error.to_string(), "Path is empty"); - assert!(empty_error.source().is_none()); let missing_env_var_error = PathError::MissingEnvVar { var: "VAR".to_string(), @@ -567,77 +401,30 @@ mod tests { }; assert_eq!( missing_env_var_error.to_string(), - "Environment variable `VAR` not set in `$VAR`" + "Environment variable 'VAR' not set in '$VAR'" ); - assert!(missing_env_var_error.source().is_none()); let unclosed_variable_error = PathError::UnclosedVariable { input: "${VAR".to_string(), }; assert_eq!( unclosed_variable_error.to_string(), - "Unclosed variable expression starting at `${VAR`" + "Unclosed variable expression starting at '${VAR'" ); - assert!(unclosed_variable_error.source().is_none()); } #[test] - fn test_file_system_error_display_and_source() { + fn test_file_system_error_display() { let io_error = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied"); let file_error = FileSystemError::ReadFile { path: PathBuf::from("/file"), source: io_error, }; - assert_eq!( - file_error.to_string(), - "Failed to read file `/file`: permission denied" - ); - assert!(file_error.source().is_some()); - - let io_error2 = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied"); - let dir_error = FileSystemError::CreateDirectory { - path: PathBuf::from("/dir"), - source: io_error2, - }; - assert_eq!( - dir_error.to_string(), - "Failed to create directory `/dir`: permission denied" - ); - assert!(dir_error.source().is_some()); + assert_eq!(file_error.to_string(), "Failed to read file '/file'"); let not_a_dir_error = FileSystemError::NotADirectory { path: PathBuf::from("/path"), }; - assert_eq!(not_a_dir_error.to_string(), "`/path` is not a directory"); - assert!(not_a_dir_error.source().is_none()); - } - - #[test] - fn test_utils_error_display_and_source_and_from() { - let bytes_error = BytesError::ParseFailed { - input: "test".to_string(), - reason: "invalid".to_string(), - }; - let utils_error_from_bytes = UtilsError::from(bytes_error); - assert_eq!( - utils_error_from_bytes.to_string(), - "Failed to parse `test` as bytes: invalid" - ); - assert!(utils_error_from_bytes.source().is_some()); - - let path_error = PathError::Empty; - let utils_error_from_path = UtilsError::from(path_error); - assert_eq!(utils_error_from_path.to_string(), "Path is empty"); - assert!(utils_error_from_path.source().is_some()); - - let fs_error = FileSystemError::NotADirectory { - path: PathBuf::from("/path"), - }; - let utils_error_from_fs = UtilsError::from(fs_error); - assert_eq!( - utils_error_from_fs.to_string(), - "`/path` is not a directory" - ); - assert!(utils_error_from_fs.source().is_some()); + assert_eq!(not_a_dir_error.to_string(), "'/path' is not a directory"); } } diff --git a/crates/soar-utils/src/fs.rs b/crates/soar-utils/src/fs.rs index dca64d5c..74f438e5 100644 --- a/crates/soar-utils/src/fs.rs +++ b/crates/soar-utils/src/fs.rs @@ -33,11 +33,13 @@ use crate::error::{FileSystemError, FileSystemResult, IoOperation, IoResultExt}; pub fn safe_remove>(path: P) -> FileSystemResult<()> { let path = path.as_ref(); - if !path.exists() { - return Ok(()); - } + let metadata = match fs::symlink_metadata(path) { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(FileSystemError::RemoveFile { path: path.to_path_buf(), source: e }), + }; - let result = if path.is_dir() { + let result = if metadata.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) diff --git a/soar-cli/src/health.rs b/soar-cli/src/health.rs deleted file mode 100644 index 054808aa..00000000 --- a/soar-cli/src/health.rs +++ /dev/null @@ -1,142 +0,0 @@ -use std::{cell::RefCell, env, path::Path, rc::Rc}; - -use nu_ansi_term::Color::{Blue, Green, Red}; -use soar_config::config::get_config; -use soar_core::{ - database::packages::{FilterCondition, PackageQueryBuilder}, - package::remove::PackageRemover, - SoarResult, -}; -use soar_utils::{ - error::FileSystemResult, - fs::walk_dir, - path::{desktop_dir, icons_dir}, -}; -use tracing::{info, warn}; - -use crate::{state::AppState, utils::Colored}; - -pub async fn display_health() -> SoarResult<()> { - let path_env = env::var("PATH")?; - let bin_path = get_config().get_bin_path()?; - if !path_env.split(':').any(|p| Path::new(p) == bin_path) { - warn!( - "{} is not in {1}. Please add it to {1} to use installed binaries.\n", - Colored(Blue, bin_path.display()), - Colored(Green, "PATH") - ); - } - - list_broken_packages().await?; - println!(); - list_broken_symlinks()?; - Ok(()) -} - -pub async fn list_broken_packages() -> SoarResult<()> { - let state = AppState::new(); - let core_db = state.core_db()?; - - let broken_packages = PackageQueryBuilder::new(core_db.clone()) - .where_and("is_installed", FilterCondition::Eq("0".to_string())) - .load_installed()? - .items; - - if broken_packages.is_empty() { - info!("No broken packages found."); - return Ok(()); - } - - info!("Broken Packages ({}):", broken_packages.len()); - - for package in broken_packages { - info!( - pkg_name = package.pkg_name, - pkg_id = package.pkg_id, - "{}#{}: {}", - Colored(Blue, &package.pkg_name), - Colored(Blue, &package.pkg_id), - Colored(Green, &package.installed_path) - ) - } - - info!( - "Broken packages can be uninstalled using command: {}", - Colored(Green, "soar clean --broken") - ); - - Ok(()) -} - -pub fn list_broken_symlinks() -> SoarResult<()> { - let broken_symlinks = Rc::new(RefCell::new(Vec::new())); - - let broken_symlinks_clone = Rc::clone(&broken_symlinks); - let mut collect_action = |path: &Path| -> FileSystemResult<()> { - if !path.exists() { - broken_symlinks_clone.borrow_mut().push(path.to_path_buf()); - } - Ok(()) - }; - - let mut soar_files_action = |path: &Path| -> FileSystemResult<()> { - if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) { - if filename.ends_with("-soar") && !path.exists() { - broken_symlinks_clone.borrow_mut().push(path.to_path_buf()); - } - } - Ok(()) - }; - - walk_dir(&get_config().get_bin_path()?, &mut collect_action)?; - walk_dir(desktop_dir(), &mut soar_files_action)?; - walk_dir(icons_dir(), &mut soar_files_action)?; - - let broken_symlinks = Rc::try_unwrap(broken_symlinks) - .unwrap_or_else(|rc| rc.borrow().clone().into()) - .into_inner(); - - if broken_symlinks.is_empty() { - info!("No broken symlinks found."); - return Ok(()); - } - - info!("Broken Symlinks ({}):", broken_symlinks.len()); - - for path in broken_symlinks { - info!("{}", Colored(Red, &path.display())); - } - - info!( - "Broken symlinks can be removed using command: {}", - Colored(Green, "soar clean --broken-symlinks") - ); - - Ok(()) -} - -pub async fn remove_broken_packages() -> SoarResult<()> { - let state = AppState::new(); - let core_db = state.core_db()?; - - let broken_packages = PackageQueryBuilder::new(core_db.clone()) - .where_and("is_installed", FilterCondition::Eq("0".to_string())) - .load_installed()? - .items; - - if broken_packages.is_empty() { - info!("No broken packages found."); - return Ok(()); - } - - for package in broken_packages { - let remover = PackageRemover::new(package.clone(), core_db.clone()).await; - remover.remove().await?; - - info!("Removed {}#{}", package.pkg_name, package.pkg_id); - } - - info!("Removed all broken packages"); - - Ok(()) -} diff --git a/soar-cli/src/list.rs b/soar-cli/src/list.rs deleted file mode 100644 index 448f14e8..00000000 --- a/soar-cli/src/list.rs +++ /dev/null @@ -1,640 +0,0 @@ -use std::{ - collections::{HashMap, HashSet}, - path::PathBuf, -}; - -use indicatif::HumanBytes; -use nu_ansi_term::Color::{Blue, Cyan, Green, LightRed, Magenta, Purple, Red, White, Yellow}; -use rayon::iter::{IntoParallelIterator, ParallelIterator}; -use soar_config::config::get_config; -use soar_core::{ - database::{ - models::{FromRow, Package}, - packages::{FilterCondition, PackageQueryBuilder, PaginatedResponse, SortDirection}, - }, - package::query::PackageQuery, - SoarResult, -}; -use soar_utils::fs::dir_size; -use tracing::info; - -use crate::{ - state::AppState, - utils::{pretty_package_size, vec_string, Colored}, -}; - -#[derive(Debug, Clone)] -pub struct PackageSearchList { - pkg_id: String, - pkg_name: String, - repo_name: String, - pkg_type: Option, - version: String, - version_upstream: Option, - description: String, - ghcr_size: Option, - size: Option, -} - -impl FromRow for PackageSearchList { - fn from_row(row: &rusqlite::Row) -> rusqlite::Result { - Ok(PackageSearchList { - pkg_id: row.get("pkg_id")?, - pkg_name: row.get("pkg_name")?, - repo_name: row.get("repo_name")?, - pkg_type: row.get("pkg_type")?, - version: row.get("version")?, - version_upstream: row.get("version_upstream")?, - description: row.get("description")?, - ghcr_size: row.get("ghcr_size")?, - size: row.get("size")?, - }) - } -} - -pub async fn search_packages( - query: String, - case_sensitive: bool, - limit: Option, -) -> SoarResult<()> { - let state = AppState::new(); - let repo_db = state.repo_db().await?; - let core_db = state.core_db()?; - - let filter_condition = if case_sensitive { - FilterCondition::Like(query) - } else { - FilterCondition::ILike(query) - }; - let packages: PaginatedResponse = PackageQueryBuilder::new(repo_db.clone()) - .where_or("pkg_name", filter_condition.clone()) - .where_or("pkg_id", filter_condition.clone()) - .where_or("pkg", filter_condition.clone()) - .sort_by("pkg_name", SortDirection::Asc) - .json_where_or("provides", "target_name", filter_condition.clone()) - .limit(limit.or(get_config().search_limit).unwrap_or(20) as u32) - .select(&[ - "pkg_id", - "pkg_name", - "pkg_type", - "version", - "version_upstream", - "description", - "ghcr_size", - "size", - ]) - .load()?; - - let installed_pkgs: HashMap<(String, String, String), bool> = - PackageQueryBuilder::new(core_db.clone()) - .load_installed()? - .items - .into_par_iter() - .map(|pkg| ((pkg.repo_name, pkg.pkg_id, pkg.pkg_name), pkg.is_installed)) - .collect(); - - for package in packages.items { - let key = ( - package.repo_name.clone(), - package.pkg_id.clone(), - package.pkg_name.clone(), - ); - let install_state = match installed_pkgs.get(&key) { - Some(is_installed) => { - match is_installed { - true => "+", - false => "?", - } - } - None => "-", - }; - - info!( - pkg_name = package.pkg_name, - pkg_id = package.pkg_id, - repo_name = package.repo_name, - pkg_type = package.pkg_type, - version = package.version, - version_upstream = package.version_upstream, - description = package.description, - size = package.ghcr_size.or(package.size), - "[{}] {}#{}:{} | {}{} | {} - {} ({})", - install_state, - Colored(Blue, &package.pkg_name), - Colored(Cyan, &package.pkg_id), - Colored(Green, &package.repo_name), - Colored(LightRed, &package.version), - package - .version_upstream - .as_ref() - .filter(|_| package.version.starts_with("HEAD")) - .map(|upstream| format!(":{}", Colored(Yellow, &upstream))) - .unwrap_or_default(), - package - .pkg_type - .as_ref() - .map(|pkg_type| format!("{}", Colored(Magenta, &pkg_type))) - .unwrap_or_default(), - package.description, - pretty_package_size(package.ghcr_size, package.size) - ); - } - - info!( - "{}", - Colored( - Red, - format!( - "Showing {} of {}", - std::cmp::min(packages.limit.unwrap_or(0) as u64, packages.total), - packages.total - ) - ) - ); - - Ok(()) -} - -pub async fn query_package(query: String) -> SoarResult<()> { - let state = AppState::new(); - let repo_db = state.repo_db().await?; - - let query = PackageQuery::try_from(query.as_str())?; - let builder = PackageQueryBuilder::new(repo_db.clone()); - let builder = query.apply_filters(builder); - let packages: Vec = builder.load()?.items; - - for package in packages { - let fields = [ - format!( - "\n{}: {} ({1}#{}:{})", - Colored(Purple, "Name"), - Colored(Cyan, &package.pkg_name), - Colored(Blue, &package.pkg_id), - Colored(Green, &package.repo_name), - ), - format!( - "{}: {}", - Colored(Purple, "Description"), - Colored(White, &package.description) - ), - package - .rank - .map(|rank| { - format!( - "{}: #{}{}", - Colored(Purple, "Rank"), - Colored(Yellow, &rank), - package - .download_count_week - .map(|count| format!(" ({count} weekly downloads)")) - .unwrap_or_default() - ) - }) - .unwrap_or_default(), - format!( - "{}: {}{}", - Colored(Purple, "Version"), - Colored(Blue, &package.version), - package - .version_upstream - .as_ref() - .filter(|_| package.version.starts_with("HEAD")) - .map(|upstream| format!(" ({})", Colored(Yellow, &upstream))) - .unwrap_or_default() - ), - format!( - "{}: {}", - Colored(Purple, "Size"), - pretty_package_size(package.ghcr_size, package.size) - ), - format!("{}:", Colored(Purple, "Checksums")), - package - .bsum - .as_ref() - .map(|cs| format!(" - {} (blake3)", Colored(Blue, cs))) - .unwrap_or_default(), - package - .shasum - .as_ref() - .map(|cs| format!(" - {} (sha256)", Colored(Blue, cs))) - .unwrap_or_default(), - package - .homepages - .as_ref() - .map(|homepages| { - let key = format!("{}:", Colored(Purple, "Homepages")); - let values = homepages - .iter() - .map(|homepage| format!(" - {}", Colored(Blue, homepage))) - .collect::>() - .join("\n"); - format!("{key}\n{values}") - }) - .unwrap_or_default(), - package - .licenses - .as_ref() - .map(|licenses| { - let key = format!("{}:", Colored(Purple, "Licenses")); - let values = licenses - .iter() - .map(|license| format!(" - {}", Colored(Blue, license))) - .collect::>() - .join("\n"); - format!("{key}\n{values}") - }) - .unwrap_or_default(), - package - .maintainers - .as_ref() - .map(|maintainers| { - let key = format!("{}:", Colored(Purple, "Maintainers")); - let values = maintainers - .iter() - .map(|maintainer| format!(" - {}", Colored(Blue, maintainer))) - .collect::>() - .join("\n"); - format!("{key}\n{values}") - }) - .unwrap_or_default(), - package - .notes - .as_ref() - .map(|notes| { - let key = format!("{}:", Colored(Purple, "Notes")); - let values = notes - .iter() - .map(|note| format!(" - {}", Colored(Blue, note))) - .collect::>() - .join("\n"); - format!("{key}\n{values}") - }) - .unwrap_or_default(), - package - .snapshots - .as_ref() - .map(|snapshots| { - let key = format!("{}:", Colored(Purple, "Snapshots")); - let values = snapshots - .iter() - .map(|snapshot| format!(" - {}", Colored(Blue, snapshot))) - .collect::>() - .join("\n"); - format!("{key}\n{values}") - }) - .unwrap_or_default(), - package - .source_urls - .as_ref() - .map(|sources| { - let key = format!("{}:", Colored(Purple, "Sources")); - let values = sources - .iter() - .map(|source| format!(" - {}", Colored(Blue, source))) - .collect::>() - .join("\n"); - format!("{key}\n{values}") - }) - .unwrap_or_default(), - package - .pkg_type - .as_ref() - .map(|pkg_type| format!("{}: {}", Colored(Purple, "Type"), Colored(Blue, pkg_type))) - .unwrap_or_default(), - package - .build_action - .as_ref() - .map(|action| { - format!( - "{}: {}{}", - Colored(Purple, "Build CI"), - Colored(Blue, &action), - package - .build_id - .as_ref() - .map(|id| format!(" ({})", Colored(Yellow, id))) - .unwrap_or_default() - ) - }) - .unwrap_or_default(), - package - .build_date - .as_ref() - .map(|date| format!("{}: {}", Colored(Purple, "Build Date"), Colored(Blue, date))) - .unwrap_or_default(), - package - .build_log - .as_ref() - .map(|log| format!("{}: {}", Colored(Purple, "Build Log"), Colored(Blue, log))) - .unwrap_or_default(), - package - .build_script - .as_ref() - .map(|script| { - format!( - "{}: {}", - Colored(Purple, "Build Script"), - Colored(Blue, script) - ) - }) - .unwrap_or_default(), - package - .ghcr_blob - .as_ref() - .map(|blob| format!("{}: {}", Colored(Purple, "GHCR Blob"), Colored(Blue, blob))) - .unwrap_or_else(|| { - format!( - "{}: {}", - Colored(Purple, "Download URL"), - Colored(Blue, &package.download_url) - ) - }), - package - .ghcr_pkg - .as_ref() - .map(|pkg| { - let url = format!("https://{pkg}"); - format!( - "{}: {}", - Colored(Purple, "GHCR Package"), - Colored(Blue, url) - ) - }) - .unwrap_or_default(), - package - .pkg_webpage - .as_ref() - .map(|webindex| { - format!("{}: {}", Colored(Purple, "Index"), Colored(Blue, webindex)) - }) - .unwrap_or_default(), - ]; - - info!( - pkg_name = package.pkg_name, - pkg_id = package.pkg_id, - pkg_type = package.pkg_type, - repo_name = package.repo_name, - description = package.description, - rank = package.rank, - version = package.version, - version_upstream = package.version_upstream, - bsum = package.bsum, - shasum = package.shasum, - homepages = vec_string(package.homepages), - source_urls = vec_string(package.source_urls), - licenses = vec_string(package.licenses), - maintainers = vec_string(package.maintainers), - notes = vec_string(package.notes), - snapshots = vec_string(package.snapshots), - size = package.size, - download_url = package.download_url, - build_id = package.build_id, - build_date = package.build_date, - build_action = package.build_action, - build_log = package.build_log, - build_script = package.build_script, - ghcr_blob = package.ghcr_blob, - ghcr_pkg = package.ghcr_pkg, - pkg_webpage = package.pkg_webpage, - "{}", - fields - .iter() - .filter(|s| !s.is_empty()) - .map(|s| s.as_str()) - .collect::>() - .join("\n") - ); - } - - Ok(()) -} - -#[derive(Debug, Clone)] -pub struct PackageList { - pkg_id: String, - pkg_name: String, - repo_name: String, - pkg_type: Option, - version: String, - version_upstream: Option, -} - -impl FromRow for PackageList { - fn from_row(row: &rusqlite::Row) -> rusqlite::Result { - Ok(PackageList { - pkg_id: row.get("pkg_id")?, - pkg_name: row.get("pkg_name")?, - repo_name: row.get("repo_name")?, - pkg_type: row.get("pkg_type")?, - version: row.get("version")?, - version_upstream: row.get("version_upstream")?, - }) - } -} - -pub async fn list_packages(repo_name: Option) -> SoarResult<()> { - let state = AppState::new(); - let repo_db = state.repo_db().await?; - let core_db = state.core_db()?; - - let mut builder = PackageQueryBuilder::new(repo_db.clone()) - .sort_by("pkg_name", SortDirection::Asc) - .limit(3000); - - if let Some(repo_name) = repo_name { - builder = builder.where_and("repo_name", FilterCondition::Eq(repo_name)); - } - - builder = builder.select(&[ - "pkg_id", - "pkg_name", - "pkg_type", - "version", - "version_upstream", - ]); - - let installed_pkgs: HashMap<(String, String, String), bool> = - PackageQueryBuilder::new(core_db.clone()) - .load_installed()? - .items - .into_par_iter() - .map(|pkg| ((pkg.repo_name, pkg.pkg_id, pkg.pkg_name), pkg.is_installed)) - .collect(); - - loop { - let packages: PaginatedResponse = builder.load()?; - - for package in &packages.items { - let key = ( - package.repo_name.clone(), - package.pkg_id.clone(), - package.pkg_name.clone(), - ); - let install_state = match installed_pkgs.get(&key) { - Some(is_installed) => { - match is_installed { - true => "+", - false => "?", - } - } - None => "-", - }; - - info!( - pkg_name = package.pkg_name, - pkg_id = package.pkg_id, - repo_name = package.repo_name, - pkg_type = package.pkg_type, - version = package.version, - version_upstream = package.version_upstream, - "[{}] {}#{}:{} | {}{} | {}", - install_state, - Colored(Blue, &package.pkg_name), - Colored(Cyan, &package.pkg_id), - Colored(Cyan, &package.repo_name), - Colored(LightRed, &package.version), - package - .version_upstream - .as_ref() - .filter(|_| package.version.starts_with("HEAD")) - .map(|upstream| format!(":{}", Colored(Yellow, &upstream))) - .unwrap_or_default(), - package - .pkg_type - .as_ref() - .map(|pkg_type| format!("{}", Colored(Magenta, &pkg_type))) - .unwrap_or_default(), - ); - } - - if !packages.has_next { - break; - } - - builder = builder.page(packages.page + 1); - } - - Ok(()) -} - -pub async fn list_installed_packages(repo_name: Option, count: bool) -> SoarResult<()> { - let state = AppState::new(); - let core_db = state.core_db()?; - - if count { - let conn = core_db.lock().unwrap(); - let mut query = String::from( - "SELECT COUNT(DISTINCT pkg_id || pkg_name) FROM packages WHERE is_installed = true", - ); - let mut params: [&dyn rusqlite::ToSql; 1] = [&""]; - - let param_slice: &[&dyn rusqlite::ToSql] = if let Some(ref repo_name) = repo_name { - query.push_str(" AND repo_name = ?"); - params[0] = repo_name; - ¶ms - } else { - &[] - }; - - let count: u32 = conn.query_row(&query, param_slice, |row| row.get(0))?; - info!("{}", count); - return Ok(()); - } - - let mut builder = PackageQueryBuilder::new(core_db.clone()); - if let Some(repo_name) = repo_name { - builder = builder.where_and("repo_name", FilterCondition::Eq(repo_name)); - } - - let packages = builder.load_installed()?.items; - let mut unique_pkgs = HashSet::new(); - - let (installed_count, unique_count, broken_count, installed_size, broken_size) = - packages.iter().fold( - (0, 0, 0, 0, 0), - |(installed_count, unique_count, broken_count, installed_size, broken_size), - package| { - let installed_path = PathBuf::from(&package.installed_path); - let size = dir_size(&installed_path).unwrap_or(0); - let is_installed = package.is_installed && installed_path.exists(); - info!( - pkg_name = package.pkg_name, - version = package.version, - repo_name = package.repo_name, - installed_date = package.installed_date.clone(), - size = %package.size, - "{}-{}:{} ({}) ({}){}", - Colored(Red, &package.pkg_name), - Colored(Magenta, &package.version), - Colored(Cyan, &package.repo_name), - Colored(Blue, &package.installed_date.clone()), - HumanBytes(size), - if is_installed { - "".to_string() - } else { - Colored(Red, " [Broken]").to_string() - }, - ); - - if is_installed { - let unique_count = unique_pkgs - .insert(format!("{}-{}", package.pkg_id, package.pkg_name)) - as u32 - + unique_count; - ( - installed_count + 1, - unique_count, - broken_count, - installed_size + size, - broken_size, - ) - } else { - ( - installed_count, - unique_count, - broken_count + 1, - installed_size, - broken_size + size, - ) - } - }, - ); - - info!( - installed_count, - unique_count, - installed_size, - "Installed: {}{} ({})", - installed_count, - if installed_count != unique_count { - format!(", {unique_count} distinct") - } else { - String::new() - }, - HumanBytes(installed_size), - ); - - if broken_count > 0 { - info!( - broken_count, - broken_size, - "Broken: {} ({})", - broken_count, - HumanBytes(broken_size) - ); - - let total_count = installed_count + broken_count; - let total_size = installed_size + broken_size; - info!( - total_count, - total_size, - "Total: {} ({})", - total_count, - HumanBytes(total_size) - ); - } - - Ok(()) -} diff --git a/soar-cli/src/nest.rs b/soar-cli/src/nest.rs deleted file mode 100644 index 482d7abb..00000000 --- a/soar-cli/src/nest.rs +++ /dev/null @@ -1,40 +0,0 @@ -use soar_config::config::get_config; -use soar_core::{database::nests::repository, utils::get_nests_db_conn, SoarResult}; -use soar_registry::Nest; - -pub async fn add_nest(name: &str, url: &str) -> SoarResult<()> { - let name = format!("nest-{name}"); - let config = get_config(); - let mut conn = get_nests_db_conn(&config)?; - let tx = conn.transaction()?; - let nest = Nest { - id: 0, - name: name.to_string(), - url: url.to_string(), - }; - repository::add(&tx, &nest)?; - tx.commit()?; - println!("Added nest: {}", name); - Ok(()) -} - -pub async fn remove_nest(name: &str) -> SoarResult<()> { - let config = get_config(); - let mut conn = get_nests_db_conn(&config)?; - let tx = conn.transaction()?; - repository::remove(&tx, name)?; - tx.commit()?; - println!("Removed nest: {}", name); - Ok(()) -} - -pub async fn list_nests() -> SoarResult<()> { - let config = get_config(); - let mut conn = get_nests_db_conn(&config)?; - let tx = conn.transaction()?; - let nests = repository::list(&tx)?; - for nest in nests { - println!("{} - {}", nest.name, nest.url); - } - Ok(()) -} diff --git a/soar-cli/src/remove.rs b/soar-cli/src/remove.rs deleted file mode 100644 index a6466597..00000000 --- a/soar-cli/src/remove.rs +++ /dev/null @@ -1,67 +0,0 @@ -use soar_core::{ - database::packages::PackageQueryBuilder, - package::{query::PackageQuery, remove::PackageRemover}, - SoarResult, -}; -use tracing::{error, info, warn}; - -use crate::{state::AppState, utils::select_package_interactively}; - -pub async fn remove_packages(packages: &[String]) -> SoarResult<()> { - let state = AppState::new(); - - for package in packages { - let core_db = state.core_db()?; - - let mut query = PackageQuery::try_from(package.as_str())?; - let builder = PackageQueryBuilder::new(core_db.clone()); - - if let Some(ref pkg_id) = query.pkg_id { - if pkg_id == "all" { - let builder = query.apply_filters(builder.clone()); - let packages = builder.load_installed()?; - - if packages.total == 0 { - error!("Package {} is not installed", query.name.unwrap()); - continue; - } - let pkg = if packages.total > 1 { - let pkgs = packages.items.clone(); - select_package_interactively(pkgs, &query.name.unwrap())?.unwrap() - } else { - packages.items.first().unwrap().clone() - }; - query.pkg_id = Some(pkg.pkg_id.clone()); - query.name = None; - } - } - - let builder = query.apply_filters(builder); - let installed_pkgs = builder - .clone() - .database(core_db.clone()) - .load_installed()? - .items; - - if installed_pkgs.is_empty() { - warn!("Package {} is not installed.", package); - continue; - } - - for installed_pkg in installed_pkgs { - if query.name.is_none() && !installed_pkg.with_pkg_id { - continue; - } - - let remover = PackageRemover::new(installed_pkg.clone(), core_db.clone()).await; - remover.remove().await?; - - info!( - "Removed {}#{}", - installed_pkg.pkg_name, installed_pkg.pkg_id - ); - } - } - - Ok(()) -} diff --git a/soar-cli/src/state.rs b/soar-cli/src/state.rs deleted file mode 100644 index 0f6b598b..00000000 --- a/soar-cli/src/state.rs +++ /dev/null @@ -1,314 +0,0 @@ -use std::{ - fs::{self, File}, - path::{Path, PathBuf}, - sync::{Arc, Mutex}, -}; - -use nu_ansi_term::Color::{Blue, Green, Magenta, Red}; -use once_cell::sync::OnceCell; -use rusqlite::{params, Connection}; -use soar_config::{ - config::{get_config, Config}, - repository::Repository, -}; -use soar_core::{ - constants::{CORE_MIGRATIONS, METADATA_MIGRATIONS}, - database::{ - connection::Database, - migration::{DbKind, MigrationManager}, - models::FromRow, - nests, - packages::{FilterCondition, PackageQueryBuilder}, - }, - error::{ErrorContext, SoarError}, - utils::get_nests_db_conn, - SoarResult, -}; -use soar_registry::{ - fetch_metadata, fetch_nest_metadata, write_metadata_db, MetadataContent, RemotePackage, -}; -use tracing::{error, info}; - -use crate::utils::Colored; - -fn handle_json_metadata>( - metadata: &[RemotePackage], - metadata_db: P, - repo_name: &str, -) -> SoarResult<()> { - let metadata_db = metadata_db.as_ref(); - if metadata_db.exists() { - fs::remove_file(metadata_db) - .with_context(|| format!("removing metadata file {}", metadata_db.display()))?; - } - - let conn = Connection::open(metadata_db)?; - let mut manager = MigrationManager::new(conn)?; - manager.migrate_from_dir(METADATA_MIGRATIONS, DbKind::Metadata)?; - - let db = Database::new(metadata_db)?; - db.from_remote_metadata(metadata, repo_name)?; - - Ok(()) -} - -#[derive(Clone)] -pub struct AppState { - inner: Arc, -} - -struct AppStateInner { - config: Config, - repo_db: OnceCell, - core_db: OnceCell, -} - -impl AppState { - pub fn new() -> Self { - let config = get_config(); - - Self { - inner: Arc::new(AppStateInner { - config, - repo_db: OnceCell::new(), - core_db: OnceCell::new(), - }), - } - } - - pub async fn sync(&self) -> SoarResult<()> { - self.init_repo_dbs(true).await?; - self.sync_nests(true).await - } - - async fn sync_nests(&self, force: bool) -> SoarResult<()> { - let mut nests_db = get_nests_db_conn(self.config())?; - let tx = nests_db.transaction()?; - let nests = nests::repository::list(&tx)?; - tx.commit()?; - - let nests_repo_path = self.config().get_repositories_path()?.join("nests"); - - let mut tasks = Vec::new(); - - for nest in nests { - let nest_clone = nest.clone(); - let task = - tokio::task::spawn(async move { fetch_nest_metadata(&nest_clone, force).await }); - tasks.push((task, nest)); - } - - for (task, nest) in tasks { - match task - .await - .map_err(|err| SoarError::Custom(format!("Join handle error: {err}")))? - { - Ok(Some((etag, content))) => { - let nest_path = nests_repo_path.join(&nest.name); - let metadata_db_path = nest_path.join("metadata.db"); - let nest_name = format!("nest-{}", nest.name); - - match content { - MetadataContent::SqliteDb(db_bytes) => { - write_metadata_db(&db_bytes, &metadata_db_path) - .map_err(|e| SoarError::Custom(e.to_string()))?; - } - MetadataContent::Json(packages) => { - handle_json_metadata(&packages, &metadata_db_path, &nest_name)?; - } - } - - let conn = Connection::open(&metadata_db_path)?; - conn.execute( - "UPDATE repository SET name = ?, etag = ?", - params![nest_name, etag], - )?; - info!("[{}] Nest synced", Colored(Magenta, &nest.name)) - } - Err(err) => error!("Failed to sync nest {}: {err}", nest.name), - _ => {} - } - } - - Ok(()) - } - - async fn init_repo_dbs(&self, force: bool) -> SoarResult<()> { - let mut tasks = Vec::new(); - - for repo in &self.inner.config.repositories { - let repo_clone = repo.clone(); - let task = tokio::task::spawn(async move { fetch_metadata(&repo_clone, force).await }); - tasks.push((task, repo)); - } - - for (task, repo) in tasks { - match task - .await - .map_err(|err| SoarError::Custom(format!("Join handle error: {err}")))? - { - Ok(Some((etag, content))) => { - let repo_path = repo.get_path()?; - let metadata_db_path = repo_path.join("metadata.db"); - - match content { - MetadataContent::SqliteDb(db_bytes) => { - write_metadata_db(&db_bytes, &metadata_db_path) - .map_err(|e| SoarError::Custom(e.to_string()))?; - } - MetadataContent::Json(packages) => { - handle_json_metadata(&packages, &metadata_db_path, &repo.name)?; - } - } - - self.validate_packages(repo, &etag).await?; - info!("[{}] Repository synced", Colored(Magenta, &repo.name)); - } - Err(err) => error!("Failed to sync repository {}: {err}", repo.name), - _ => {} - }; - } - - Ok(()) - } - - async fn validate_packages(&self, repo: &Repository, etag: &str) -> SoarResult<()> { - let core_db = self.core_db()?; - let repo_name = repo.name.clone(); - - let repo_path = repo.get_path()?; - let metadata_db = repo_path.join("metadata.db"); - - let repo_db = Arc::new(Mutex::new(Connection::open(&metadata_db)?)); - - let installed_packages = PackageQueryBuilder::new(core_db.clone()) - .where_and("repo_name", FilterCondition::Eq(repo_name.to_string())) - .load_installed()?; - - struct RepoPackage { - pkg_id: String, - } - - impl FromRow for RepoPackage { - fn from_row(row: &rusqlite::Row) -> rusqlite::Result { - Ok(Self { - pkg_id: row.get("pkg_id")?, - }) - } - } - - for pkg in installed_packages.items { - let repo_package: Vec = PackageQueryBuilder::new(repo_db.clone()) - .select(&["pkg_id"]) - .where_and("pkg_id", FilterCondition::Eq(pkg.pkg_id.clone())) - .where_and("repo_name", FilterCondition::Eq(pkg.repo_name.clone())) - .load()? - .items; - - if repo_package.is_empty() { - let replaced_by: Vec = PackageQueryBuilder::new(repo_db.clone()) - .select(&["pkg_id"]) - .where_and("repo_name", FilterCondition::Eq(pkg.repo_name)) - // there's no easy way to do this, could create scalar SQL - // function, but this is enough for now - .where_and( - &format!("EXISTS (SELECT 1 FROM json_each(p.replaces) WHERE json_each.value = '{}')", pkg.pkg_id), - FilterCondition::None, - ) - .limit(1) - .load()? - .items; - - if !replaced_by.is_empty() { - let new_pkg_id = &replaced_by.first().unwrap().pkg_id; - info!( - "[{}] {} is replaced by {} in {}", - Colored(Blue, "Note"), - Colored(Red, &pkg.pkg_id), - Colored(Green, new_pkg_id), - Colored(Magenta, &repo_name) - ); - - let conn = core_db.lock()?; - conn.execute( - "UPDATE packages SET pkg_id = ? WHERE pkg_id = ? AND repo_name = ?", - params![new_pkg_id, pkg.pkg_id, repo_name], - )?; - } - } - } - - let conn = repo_db.lock()?; - conn.execute( - "UPDATE repository SET name = ?, etag = ?", - params![repo.name, etag], - )?; - - Ok(()) - } - - fn create_repo_db(&self) -> SoarResult { - let mut repo_paths: Vec = self - .config() - .repositories - .iter() - .filter_map(|r| { - r.get_path() - .ok() - .map(|path| path.join("metadata.db")) - .filter(|db_path| db_path.is_file()) - }) - .collect(); - - let mut nests_db = get_nests_db_conn(self.config())?; - let tx = nests_db.transaction()?; - let nests = nests::repository::list(&tx)?; - tx.commit()?; - - let nests_repo_path = self.config().get_repositories_path()?.join("nests"); - - for nest in nests { - let nest_path = nests_repo_path.join(&nest.name); - let metadata_db = nest_path.join("metadata.db"); - if metadata_db.is_file() { - repo_paths.push(metadata_db); - } - } - - Database::new_multi(repo_paths.as_ref()) - } - - fn create_core_db(&self) -> SoarResult { - let core_db_file = self.config().get_db_path()?.join("soar.db"); - if !core_db_file.exists() { - File::create(&core_db_file) - .with_context(|| format!("creating database file {}", core_db_file.display()))?; - } - - let conn = Connection::open(&core_db_file)?; - let mut manager = MigrationManager::new(conn)?; - manager.migrate_from_dir(CORE_MIGRATIONS, DbKind::Core)?; - Database::new(&core_db_file) - } - - #[inline] - pub fn config(&self) -> &Config { - &self.inner.config - } - - pub async fn repo_db(&self) -> SoarResult<&Arc>> { - self.init_repo_dbs(false).await?; - self.sync_nests(false).await?; - self.inner - .repo_db - .get_or_try_init(|| self.create_repo_db()) - .map(|db| &db.conn) - } - - pub fn core_db(&self) -> SoarResult<&Arc>> { - self.inner - .core_db - .get_or_try_init(|| self.create_core_db()) - .map(|db| &db.conn) - } -} diff --git a/soar-cli/src/use.rs b/soar-cli/src/use.rs deleted file mode 100644 index ec5a62dd..00000000 --- a/soar-cli/src/use.rs +++ /dev/null @@ -1,152 +0,0 @@ -use std::path::PathBuf; - -use indicatif::HumanBytes; -use nu_ansi_term::Color::{Blue, Cyan, Magenta, Red}; -use rusqlite::prepare_and_bind; -use soar_config::config::get_config; -use soar_core::{ - database::{ - models::{InstalledPackage, Package}, - packages::{FilterCondition, PackageQueryBuilder, SortDirection}, - }, - SoarResult, -}; -use soar_package::integrate_package; -use tracing::info; - -use crate::{ - state::AppState, - utils::{get_valid_selection, has_desktop_integration, mangle_package_symlinks, Colored}, -}; - -pub async fn use_alternate_package(name: &str) -> SoarResult<()> { - let state = AppState::new(); - let db = state.core_db()?; - - let packages = PackageQueryBuilder::new(db.clone()) - .where_and("pkg_name", FilterCondition::Eq(name.to_string())) - .sort_by("id", SortDirection::Asc) - .load_installed()? - .items; - - if packages.is_empty() { - info!("Package is not installed"); - return Ok(()); - } - - for (idx, package) in packages.iter().enumerate() { - info!( - active = !package.unlinked, - pkg_name = package.pkg_name, - pkg_id = package.pkg_id, - repo_name = package.repo_name, - pkg_type = package.pkg_type, - version = package.version, - size = package.size, - "[{}] {}#{}:{} ({}-{}) ({}){}", - idx + 1, - Colored(Blue, &package.pkg_name), - Colored(Cyan, &package.pkg_id), - Colored(Cyan, &package.repo_name), - package - .pkg_type - .as_ref() - .map(|pkg_type| format!(":{}", Colored(Magenta, &pkg_type))) - .unwrap_or_default(), - Colored(Magenta, &package.version), - Colored(Magenta, HumanBytes(package.size)), - package - .unlinked - .then(String::new) - .unwrap_or_else(|| format!(" {}", Colored(Red, "*"))) - ); - } - - if packages.len() == 1 { - return Ok(()); - } - - let selection = get_valid_selection(packages.len())?; - let selected_package = packages.into_iter().nth(selection).unwrap(); - - let InstalledPackage { - pkg_name, - pkg_id, - checksum, - .. - } = &selected_package; - - let mut conn = db.lock().unwrap(); - - let tx = conn.transaction()?; - - { - let mut stmt = prepare_and_bind!( - tx, - "UPDATE packages - SET - unlinked = true - WHERE - pkg_name = $pkg_name - AND pkg_id != $pkg_id - AND checksum != $checksum - " - ); - stmt.raw_execute()?; - } - - let bin_dir = get_config().get_bin_path()?; - let install_dir = PathBuf::from(&selected_package.installed_path); - - let _ = mangle_package_symlinks(&install_dir, &bin_dir, selected_package.provides.as_deref()) - .await?; - - // TODO: handle portable_dirs - let repo_db = state.repo_db().await?; - let pkg: Vec = PackageQueryBuilder::new(repo_db.clone()) - .where_and( - "repo_name", - FilterCondition::Eq(selected_package.repo_name.clone()), - ) - .where_and("pkg_name", FilterCondition::Eq(name.to_string())) - .where_and( - "pkg_id", - FilterCondition::Eq(selected_package.pkg_id.clone()), - ) - .limit(1) - .load()? - .items; - - if pkg.iter().all(has_desktop_integration) { - integrate_package( - &install_dir, - &selected_package, - None, - None, - None, - None, - None, - ) - .await?; - } - - { - let mut stmt = prepare_and_bind!( - tx, - "UPDATE packages - SET - unlinked = false - WHERE - pkg_name = $pkg_name - AND pkg_id == $pkg_id - AND checksum == $checksum" - ); - stmt.raw_execute()?; - } - - tx.commit()?; - - info!("Switched to {}#{}", pkg_name, pkg_id); - - Ok(()) -} diff --git a/soar-core/migrations/core/V5_baseline.sql b/soar-core/migrations/core/V5_baseline.sql deleted file mode 100644 index ccc6a941..00000000 --- a/soar-core/migrations/core/V5_baseline.sql +++ /dev/null @@ -1,30 +0,0 @@ -CREATE TABLE portable_package ( - package_id INTEGER NOT NULL, - portable_path TEXT, - portable_home TEXT, - portable_config TEXT, - portable_share TEXT, - FOREIGN KEY (package_id) REFERENCES packages (id) ON DELETE CASCADE -); - -CREATE TABLE packages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - repo_name TEXT NOT NULL, - pkg TEXT COLLATE NOCASE, - pkg_id TEXT NOT NULL COLLATE NOCASE, - pkg_name TEXT NOT NULL COLLATE NOCASE, - pkg_type TEXT COLLATE NOCASE, - version TEXT NOT NULL, - size BIGINT NOT NULL, - checksum TEXT, - installed_path TEXT NOT NULL, - installed_date TEXT NOT NULL, - profile TEXT NOT NULL, - pinned BOOLEAN NOT NULL DEFAULT false, - is_installed BOOLEAN NOT NULL DEFAULT false, - with_pkg_id BOOLEAN NOT NULL DEFAULT false, - detached BOOLEAN NOT NULL DEFAULT false, - unlinked BOOLEAN NOT NULL DEFAULT false, - provides JSONB, - install_patterns JSONB -); diff --git a/soar-core/migrations/core/V6_add_portable_cache.sql b/soar-core/migrations/core/V6_add_portable_cache.sql deleted file mode 100644 index 1f7ff718..00000000 --- a/soar-core/migrations/core/V6_add_portable_cache.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE portable_package ADD COLUMN portable_cache TEXT; diff --git a/soar-core/migrations/metadata/V1_initial.sql b/soar-core/migrations/metadata/V1_initial.sql deleted file mode 100644 index 20f29c1a..00000000 --- a/soar-core/migrations/metadata/V1_initial.sql +++ /dev/null @@ -1,78 +0,0 @@ -CREATE TABLE repository ( - name TEXT NOT NULL UNIQUE COLLATE NOCASE, - etag TEXT NOT NULL UNIQUE -); - -CREATE TABLE maintainers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - contact TEXT NOT NULL COLLATE NOCASE, - name TEXT NOT NULL COLLATE NOCASE -); - -CREATE TABLE package_maintainers ( - maintainer_id INTEGER NOT NULL, - package_id INTEGER NOT NULL, - FOREIGN KEY (maintainer_id) REFERENCES packages (id), - FOREIGN KEY (package_id) REFERENCES packages (id), - UNIQUE (maintainer_id, package_id) -); - -CREATE TABLE packages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - disabled BOOLEAN NOT NULL DEFAULT false, - disabled_reason JSONB, - rank INT, - pkg TEXT COLLATE NOCASE, - pkg_id TEXT NOT NULL COLLATE NOCASE, - pkg_name TEXT NOT NULL COLLATE NOCASE, - pkg_family TEXT COLLATE NOCASE, - pkg_type TEXT COLLATE NOCASE, - pkg_webpage TEXT, - app_id TEXT COLLATE NOCASE, - description TEXT, - version TEXT NOT NULL, - version_upstream TEXT, - licenses JSONB, - download_url TEXT NOT NULL, - size BIGINT, - ghcr_pkg TEXT, - ghcr_size BIGINT, - ghcr_files JSONB, - ghcr_blob TEXT, - ghcr_url TEXT, - bsum TEXT, - shasum TEXT, - icon TEXT, - desktop TEXT, - appstream TEXT, - homepages JSONB, - notes JSONB, - source_urls JSONB, - tags JSONB, - categories JSONB, - build_id TEXT, - build_date TEXT, - build_action TEXT, - build_script TEXT, - build_log TEXT, - provides JSONB, - snapshots JSONB, - repology JSONB, - replaces JSONB, - download_count INTEGER, - download_count_week INTEGER, - download_count_month INTEGER, - bundle BOOLEAN NOT NULL DEFAULT false, - bundle_type TEXT, - soar_syms BOOLEAN NOT NULL DEFAULT false, - deprecated BOOLEAN NOT NULL DEFAULT false, - desktop_integration BOOLEAN, - external BOOLEAN, - installable BOOLEAN, - portable BOOLEAN, - recurse_provides BOOLEAN, - trusted BOOLEAN, - version_latest TEXT, - version_outdated BOOLEAN, - UNIQUE (pkg_id, pkg_name, version) -); diff --git a/soar-core/migrations/nests/V1_initial.sql b/soar-core/migrations/nests/V1_initial.sql deleted file mode 100644 index 1e777ba9..00000000 --- a/soar-core/migrations/nests/V1_initial.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE nests ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - url TEXT NOT NULL UNIQUE -); diff --git a/soar-core/src/constants.rs b/soar-core/src/constants.rs deleted file mode 100644 index ef3b5556..00000000 --- a/soar-core/src/constants.rs +++ /dev/null @@ -1,10 +0,0 @@ -use include_dir::{include_dir, Dir}; - -pub const XML_MAGIC_BYTES: [u8; 5] = [0x3c, 0x3f, 0x78, 0x6d, 0x6c]; - -pub const CAP_SYS_ADMIN: i32 = 21; -pub const CAP_MKNOD: i32 = 27; - -pub const METADATA_MIGRATIONS: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations/metadata"); -pub const CORE_MIGRATIONS: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations/core"); -pub const NESTS_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations/nests"); diff --git a/soar-core/src/database/connection.rs b/soar-core/src/database/connection.rs deleted file mode 100644 index 260ef348..00000000 --- a/soar-core/src/database/connection.rs +++ /dev/null @@ -1,59 +0,0 @@ -use std::{ - path::Path, - sync::{Arc, Mutex}, -}; - -use rusqlite::Connection; -use soar_registry::RemotePackage; - -use super::{repository::PackageRepository, statements::DbStatements}; -use crate::error::SoarError; - -type Result = std::result::Result; - -pub struct Database { - pub conn: Arc>, -} - -impl Database { - pub fn new>(path: P) -> Result { - let path = path.as_ref(); - let conn = Connection::open(path)?; - let conn = Arc::new(Mutex::new(conn)); - Ok(Database { - conn, - }) - } - - pub fn new_multi>(paths: &[P]) -> Result { - let conn = Connection::open(&paths[0])?; - conn.execute("PRAGMA case_sensitive_like = ON;", [])?; - - for (idx, path) in paths.iter().enumerate().skip(1) { - let path = path.as_ref(); - conn.execute( - &format!("ATTACH DATABASE '{}' AS shard{}", path.display(), idx), - [], - )?; - conn.execute(&format!("PRAGMA shard{idx}.case_sensitive_like = ON;"), [])?; - } - let conn = Arc::new(Mutex::new(conn)); - Ok(Database { - conn, - }) - } - - pub fn from_remote_metadata(&self, metadata: &[RemotePackage], repo_name: &str) -> Result<()> { - let mut guard = self.conn.lock().unwrap(); - let _: String = guard.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?; - - let tx = guard.transaction()?; - { - let statements = DbStatements::new(&tx)?; - let mut repo = PackageRepository::new(&tx, statements, repo_name); - repo.import_packages(metadata)?; - } - tx.commit()?; - Ok(()) - } -} diff --git a/soar-core/src/database/migration.rs b/soar-core/src/database/migration.rs deleted file mode 100644 index e23ef6a9..00000000 --- a/soar-core/src/database/migration.rs +++ /dev/null @@ -1,116 +0,0 @@ -use include_dir::Dir; -use rusqlite::Connection; - -use crate::{constants::NESTS_MIGRATIONS_DIR, error::SoarError, SoarResult}; - -pub struct Migration { - version: i32, - sql: String, -} - -pub struct MigrationManager { - conn: Connection, -} - -#[derive(PartialEq)] -pub enum DbKind { - Core, - Metadata, - Nest, -} - -impl MigrationManager { - pub fn new(conn: Connection) -> rusqlite::Result { - Ok(Self { - conn, - }) - } - - fn get_current_version(&self) -> rusqlite::Result { - self.conn - .query_row("PRAGMA user_version", [], |row| row.get(0)) - } - - fn run_migration(&mut self, migration: &Migration) -> rusqlite::Result<()> { - let tx = self.conn.transaction()?; - - match tx.execute_batch(&migration.sql) { - Ok(_) => { - tx.pragma_update(None, "user_version", migration.version)?; - tx.commit()?; - Ok(()) - } - Err(err) => Err(err), - } - } - - fn load_migrations_from_dir(dir: Dir) -> SoarResult> { - let mut migrations = Vec::new(); - - for entry in dir.files() { - let path = entry.path(); - - if path.extension().and_then(|s| s.to_str()) == Some("sql") { - let filename = path - .file_stem() - .and_then(|s| s.to_str()) - .ok_or_else(|| SoarError::Custom("Invalid filename".into()))?; - - if !filename.starts_with('V') { - continue; - } - - let parts: Vec<&str> = filename[1..].splitn(2, '_').collect(); - if parts.len() != 2 { - continue; - } - - let version = parts[0].parse::().map_err(|_| { - SoarError::Custom(format!("Invalid version number in filename: {filename}")) - })?; - - let sql = entry.contents_utf8().unwrap().to_string(); - - migrations.push(Migration { - version, - sql, - }); - } - } - - migrations.sort_by_key(|m| m.version); - - Ok(migrations) - } - - pub fn migrate_from_dir(&mut self, dir: Dir, db_kind: DbKind) -> SoarResult<()> { - let migrations = Self::load_migrations_from_dir(dir)?; - let current_version = self.get_current_version()?; - - if db_kind == DbKind::Core && current_version > 0 && current_version < 5 { - return Err(SoarError::Custom( - "Database schema v{current_version} is too old for this soar release (requires v5).\n\ - Please temporarily downgrade to v0.7.0 and run any normal command that invokes database\n\ - (e.g. `soar ls` or `soar health`) once to let it auto-migrate.\n\ - After that completes, upgrade back to the latest soar".into(), - )); - } - - let pending: Vec<&Migration> = migrations - .iter() - .filter(|m| m.version > current_version) - .collect(); - - for migration in pending { - self.run_migration(migration)?; - } - - Ok(()) - } -} - -pub fn run_nests(conn: Connection) -> SoarResult<()> { - let mut manager = MigrationManager::new(conn)?; - manager.migrate_from_dir(NESTS_MIGRATIONS_DIR, DbKind::Nest)?; - Ok(()) -} diff --git a/soar-core/src/database/mod.rs b/soar-core/src/database/mod.rs deleted file mode 100644 index ebb3ff76..00000000 --- a/soar-core/src/database/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod connection; -pub mod migration; -pub mod models; -pub mod nests; -pub mod packages; -pub mod repository; -pub mod statements; diff --git a/soar-core/src/database/models.rs b/soar-core/src/database/models.rs deleted file mode 100644 index f04bf9f4..00000000 --- a/soar-core/src/database/models.rs +++ /dev/null @@ -1,285 +0,0 @@ -use std::fmt::Display; - -use rusqlite::types::Value; -use serde::{Deserialize, Serialize}; - -use super::packages::PackageProvide; - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Maintainer { - pub name: String, - pub contact: String, -} - -impl Display for Maintainer { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} ({})", self.name, self.contact) - } -} - -pub use soar_package::PackageExt; - -pub trait FromRow: Sized { - fn from_row(row: &rusqlite::Row) -> rusqlite::Result; -} - -#[derive(Debug, Clone, Default)] -pub struct Package { - pub id: u64, - pub repo_name: String, - pub disabled: Option, - pub disabled_reason: Option, - pub rank: Option, - pub pkg: Option, - pub pkg_id: String, - pub pkg_name: String, - pub pkg_family: Option, - pub pkg_type: Option, - pub pkg_webpage: Option, - pub app_id: Option, - pub description: String, - pub version: String, - pub version_upstream: Option, - pub licenses: Option>, - pub download_url: String, - pub size: Option, - pub ghcr_pkg: Option, - pub ghcr_size: Option, - pub ghcr_files: Option>, - pub ghcr_blob: Option, - pub ghcr_url: Option, - pub bsum: Option, - pub shasum: Option, - pub homepages: Option>, - pub notes: Option>, - pub source_urls: Option>, - pub tags: Option>, - pub categories: Option>, - pub icon: Option, - pub desktop: Option, - pub appstream: Option, - pub build_id: Option, - pub build_date: Option, - pub build_action: Option, - pub build_script: Option, - pub build_log: Option, - pub provides: Option>, - pub snapshots: Option>, - pub repology: Option>, - pub download_count: Option, - pub download_count_month: Option, - pub download_count_week: Option, - pub maintainers: Option>, - pub replaces: Option>, - pub bundle: bool, - pub bundle_type: Option, - pub soar_syms: bool, - pub deprecated: bool, - pub desktop_integration: Option, - pub external: Option, - pub installable: Option, - pub portable: Option, - pub trusted: Option, - pub version_latest: Option, - pub version_outdated: Option, -} - -impl FromRow for Package { - fn from_row(row: &rusqlite::Row) -> rusqlite::Result { - let parse_json_vec = |idx: &str| -> rusqlite::Result>> { - Ok(row - .get::<_, Option>(idx)? - .and_then(|json| serde_json::from_str(&json).ok())) - }; - - let parse_provides = |idx: &str| -> rusqlite::Result>> { - Ok(row - .get::<_, Option>(idx)? - .and_then(|json| serde_json::from_str(&json).ok())) - }; - - let maintainers: Option> = row - .get::<_, Option>("maintainers")? - .and_then(|json| serde_json::from_str(&json).ok()); - - let licenses = parse_json_vec("licenses")?; - let ghcr_files = parse_json_vec("ghcr_files")?; - let homepages = parse_json_vec("homepages")?; - let notes = parse_json_vec("notes")?; - let source_urls = parse_json_vec("source_urls")?; - let tags = parse_json_vec("tags")?; - let categories = parse_json_vec("categories")?; - let provides = parse_provides("provides")?; - let snapshots = parse_json_vec("snapshots")?; - let repology = parse_json_vec("repology")?; - let replaces = parse_json_vec("replaces")?; - - Ok(Package { - id: row.get("id")?, - disabled: row.get("disabled")?, - disabled_reason: row.get("disabled_reason")?, - rank: row.get("rank")?, - pkg: row.get("pkg")?, - pkg_id: row.get("pkg_id")?, - pkg_name: row.get("pkg_name")?, - pkg_family: row.get("pkg_family")?, - pkg_type: row.get("pkg_type")?, - pkg_webpage: row.get("pkg_webpage")?, - app_id: row.get("app_id")?, - description: row.get("description")?, - version: row.get("version")?, - version_upstream: row.get("version_upstream")?, - licenses, - download_url: row.get("download_url")?, - size: row.get("size")?, - ghcr_pkg: row.get("ghcr_pkg")?, - ghcr_size: row.get("ghcr_size")?, - ghcr_files, - ghcr_blob: row.get("ghcr_blob")?, - ghcr_url: row.get("ghcr_url")?, - bsum: row.get("bsum")?, - shasum: row.get("shasum")?, - icon: row.get("icon")?, - desktop: row.get("desktop")?, - appstream: row.get("appstream")?, - homepages, - notes, - source_urls, - tags, - categories, - build_id: row.get("build_id")?, - build_date: row.get("build_date")?, - build_action: row.get("build_action")?, - build_script: row.get("build_script")?, - build_log: row.get("build_log")?, - provides, - snapshots, - repology, - download_count: row.get("download_count")?, - download_count_week: row.get("download_count_week")?, - download_count_month: row.get("download_count_month")?, - repo_name: row.get("repo_name")?, - maintainers, - replaces, - bundle: row.get("bundle")?, - bundle_type: row.get("bundle_type")?, - soar_syms: row.get("soar_syms")?, - deprecated: row.get("deprecated")?, - desktop_integration: row.get("desktop_integration")?, - external: row.get("external")?, - installable: row.get("installable")?, - portable: row.get("portable")?, - trusted: row.get("trusted")?, - version_latest: row.get("version_latest")?, - version_outdated: row.get("version_outdated")?, - }) - } -} - -#[derive(Debug, Clone)] -pub struct InstalledPackage { - pub id: u64, - pub repo_name: String, - pub pkg: Option, - pub pkg_id: String, - pub pkg_name: String, - pub pkg_type: Option, - pub version: String, - pub size: u64, - pub checksum: Option, - pub installed_path: String, - pub installed_date: String, - pub profile: String, - pub pinned: bool, - pub is_installed: bool, - pub with_pkg_id: bool, - pub detached: bool, - pub unlinked: bool, - pub provides: Option>, - pub portable_path: Option, - pub portable_home: Option, - pub portable_config: Option, - pub portable_share: Option, - pub portable_cache: Option, - pub install_patterns: Option>, -} - -impl FromRow for InstalledPackage { - fn from_row(row: &rusqlite::Row) -> rusqlite::Result { - let parse_provides = |idx: &str| -> rusqlite::Result>> { - let value: Option = row.get(idx)?; - Ok(value.and_then(|s| serde_json::from_str(&s).ok())) - }; - - let parse_install_patterns = |idx: &str| -> rusqlite::Result>> { - let value: Option = row.get(idx)?; - Ok(value.and_then(|s| serde_json::from_str(&s).ok())) - }; - - let provides = parse_provides("provides")?; - let install_patterns = parse_install_patterns("install_patterns")?; - - Ok(InstalledPackage { - id: row.get("id")?, - repo_name: row.get("repo_name")?, - pkg: row.get("pkg")?, - pkg_id: row.get("pkg_id")?, - pkg_name: row.get("pkg_name")?, - pkg_type: row.get("pkg_type")?, - version: row.get("version")?, - size: row.get("size")?, - checksum: row.get("checksum")?, - installed_path: row.get("installed_path")?, - installed_date: row.get("installed_date")?, - profile: row.get("profile")?, - pinned: row.get("pinned")?, - is_installed: row.get("is_installed")?, - with_pkg_id: row.get("with_pkg_id")?, - detached: row.get("detached")?, - unlinked: row.get("unlinked")?, - provides, - portable_path: row.get("portable_path")?, - portable_home: row.get("portable_home")?, - portable_config: row.get("portable_config")?, - portable_share: row.get("portable_share")?, - portable_cache: row.get("portable_cache")?, - install_patterns, - }) - } -} - -impl PackageExt for Package { - fn pkg_name(&self) -> &str { - &self.pkg_name - } - - fn pkg_id(&self) -> &str { - &self.pkg_id - } - - fn version(&self) -> &str { - &self.version - } - - fn repo_name(&self) -> &str { - &self.repo_name - } -} - -impl PackageExt for InstalledPackage { - fn pkg_name(&self) -> &str { - &self.pkg_name - } - - fn pkg_id(&self) -> &str { - &self.pkg_id - } - - fn version(&self) -> &str { - &self.version - } - - fn repo_name(&self) -> &str { - &self.repo_name - } -} diff --git a/soar-core/src/database/nests/mod.rs b/soar-core/src/database/nests/mod.rs deleted file mode 100644 index 01255127..00000000 --- a/soar-core/src/database/nests/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod models; -pub mod repository; diff --git a/soar-core/src/database/nests/models.rs b/soar-core/src/database/nests/models.rs deleted file mode 100644 index 0e8a18dd..00000000 --- a/soar-core/src/database/nests/models.rs +++ /dev/null @@ -1,17 +0,0 @@ -use soar_registry::Nest; - -use crate::database::models::FromRow; - -impl FromRow for Nest { - fn from_row(row: &rusqlite::Row) -> rusqlite::Result { - Ok(Nest { - id: row.get("id")?, - name: row - .get::<_, String>("name")? - .strip_prefix("nest-") - .unwrap() - .to_string(), - url: row.get("url")?, - }) - } -} diff --git a/soar-core/src/database/nests/repository.rs b/soar-core/src/database/nests/repository.rs deleted file mode 100644 index b8cc4954..00000000 --- a/soar-core/src/database/nests/repository.rs +++ /dev/null @@ -1,40 +0,0 @@ -use rusqlite::{params, Result}; -use soar_registry::Nest; - -use crate::{database::models::FromRow, error::SoarError, SoarResult}; - -pub fn add(tx: &rusqlite::Transaction, nest: &Nest) -> Result<()> { - tx.execute( - "INSERT INTO nests (name, url) VALUES (?1, ?2)", - params![nest.name, nest.url], - )?; - Ok(()) -} - -pub fn list(tx: &rusqlite::Transaction) -> Result> { - let mut stmt = tx.prepare("SELECT id, name, url FROM nests")?; - let nests = stmt - .query_map([], Nest::from_row)? - .filter_map(|n| { - match n { - Ok(nest) => Some(nest), - Err(err) => { - eprintln!("Nest map error: {err:#?}"); - None - } - } - }) - .collect(); - Ok(nests) -} - -pub fn remove(tx: &rusqlite::Transaction, name: &str) -> SoarResult<()> { - let full_name = format!("nest-{name}"); - let result = tx.execute("DELETE FROM nests WHERE name = ?1", params![full_name])?; - if result == 0 { - return Err(SoarError::Custom(format!( - "No nest found with name `{name}`", - ))); - } - Ok(()) -} diff --git a/soar-core/src/database/packages/mod.rs b/soar-core/src/database/packages/mod.rs deleted file mode 100644 index 2d76a20e..00000000 --- a/soar-core/src/database/packages/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod models; -mod query; - -pub use models::*; -pub use query::*; diff --git a/soar-core/src/database/packages/models.rs b/soar-core/src/database/packages/models.rs deleted file mode 100644 index cd533d87..00000000 --- a/soar-core/src/database/packages/models.rs +++ /dev/null @@ -1,105 +0,0 @@ -use std::fmt::Display; - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone)] -pub enum FilterCondition { - Eq(String), - Ne(String), - Gt(String), - Gte(String), - Lt(String), - Lte(String), - Like(String), - ILike(String), - In(Vec), - NotIn(Vec), - Between(String, String), - IsNull, - IsNotNull, - None, -} - -#[derive(Debug, Default, Clone)] -pub enum SortDirection { - #[default] - Asc, - Desc, -} - -#[derive(Clone, Debug)] -pub enum LogicalOp { - And, - Or, -} - -#[derive(Clone, Debug)] -pub struct QueryFilter { - pub field: String, - pub condition: FilterCondition, - pub logical_op: Option, -} - -#[derive(Debug)] -pub struct PaginatedResponse { - pub items: Vec, - pub page: u32, - pub limit: Option, - pub total: u64, - pub has_next: bool, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub enum ProvideStrategy { - KeepTargetOnly, - KeepBoth, - Alias, -} - -impl Display for ProvideStrategy { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let msg = match self { - ProvideStrategy::KeepTargetOnly => "=>", - ProvideStrategy::KeepBoth => "==", - ProvideStrategy::Alias => ":", - }; - write!(f, "{msg}") - } -} - -#[derive(Debug, Default, Deserialize, Serialize, Clone)] -pub struct PackageProvide { - pub name: String, - pub target: Option, - pub strategy: Option, -} - -impl PackageProvide { - pub fn from_string(provide: &str) -> Self { - if let Some((name, target_name)) = provide.split_once("==") { - Self { - name: name.to_string(), - target: Some(target_name.to_string()), - strategy: Some(ProvideStrategy::KeepBoth), - } - } else if let Some((name, target_name)) = provide.split_once("=>") { - Self { - name: name.to_string(), - target: Some(target_name.to_string()), - strategy: Some(ProvideStrategy::KeepTargetOnly), - } - } else if let Some((name, target_name)) = provide.split_once(":") { - Self { - name: name.to_string(), - target: Some(target_name.to_string()), - strategy: Some(ProvideStrategy::Alias), - } - } else { - Self { - name: provide.to_string(), - target: None, - strategy: None, - } - } - } -} diff --git a/soar-core/src/database/packages/query.rs b/soar-core/src/database/packages/query.rs deleted file mode 100644 index 136760b9..00000000 --- a/soar-core/src/database/packages/query.rs +++ /dev/null @@ -1,582 +0,0 @@ -use std::sync::{Arc, Mutex}; - -use rusqlite::{Connection, ToSql}; - -use super::{FilterCondition, LogicalOp, PaginatedResponse, QueryFilter, SortDirection}; -use crate::{ - database::models::{FromRow, InstalledPackage}, - error::SoarError, - SoarResult, -}; - -#[derive(Debug, Clone)] -pub struct PackageQueryBuilder { - db: Arc>, - filters: Vec, - sort_fields: Vec<(String, SortDirection)>, - limit: Option, - shards: Option>, - page: u32, - select_columns: Vec, -} - -impl PackageQueryBuilder { - pub fn new(db: Arc>) -> Self { - Self { - db, - filters: Vec::new(), - sort_fields: Vec::new(), - limit: None, - shards: None, - page: 1, - select_columns: Vec::new(), - } - } - - pub fn select(mut self, columns: &[&str]) -> Self { - self.select_columns - .extend(columns.iter().map(|&col| col.to_string())); - self - } - - pub fn clear_filters(mut self) -> Self { - self.filters = Vec::new(); - self - } - - pub fn where_and(mut self, field: &str, condition: FilterCondition) -> Self { - self.filters.push(QueryFilter { - field: field.to_string(), - condition, - logical_op: Some(LogicalOp::And), - }); - self - } - - pub fn where_or(mut self, field: &str, condition: FilterCondition) -> Self { - self.filters.push(QueryFilter { - field: field.to_string(), - condition, - logical_op: Some(LogicalOp::Or), - }); - self - } - - pub fn json_where_or( - mut self, - field: &str, - json_field: &str, - condition: FilterCondition, - ) -> Self { - let select_clause = format!("SELECT 1 FROM json_each({field})"); - let extract_value = format!("json_extract(value, '$.{json_field}')"); - let where_clause = self.build_subquery_where_clause(&extract_value, condition); - - let query = format!("EXISTS ({select_clause} WHERE {where_clause})"); - - self.filters.push(QueryFilter { - field: query, - condition: FilterCondition::None, - logical_op: Some(LogicalOp::Or), - }); - self - } - - pub fn json_where_and( - mut self, - field: &str, - json_field: &str, - condition: FilterCondition, - ) -> Self { - let select_clause = format!("SELECT 1 FROM json_each({field})"); - let extract_value = format!("json_extract(value, '$.{json_field}')"); - let where_clause = self.build_subquery_where_clause(&extract_value, condition); - - let query = format!("EXISTS ({select_clause} WHERE {where_clause})"); - - self.filters.push(QueryFilter { - field: query, - condition: FilterCondition::None, - logical_op: Some(LogicalOp::And), - }); - self - } - - pub fn database(mut self, db: Arc>) -> Self { - self.db = db; - self - } - - pub fn sort_by(mut self, field: &str, direction: SortDirection) -> Self { - self.sort_fields.push((field.to_string(), direction)); - self - } - - pub fn limit(mut self, limit: u32) -> Self { - self.limit = Some(limit); - self - } - - pub fn clear_limit(mut self) -> Self { - self.limit = None; - self - } - - pub fn page(mut self, page: u32) -> Self { - self.page = page; - self - } - - pub fn shards(mut self, shards: Vec) -> Self { - self.shards = Some(shards); - self - } - - pub fn load(&self) -> SoarResult> { - let conn = self.db.lock().map_err(|_| SoarError::PoisonError)?; - let shards = self.get_shards(&conn)?; - - let (query, params) = self.build_query(&shards)?; - let mut stmt = conn.prepare(&query)?; - - let params_ref: Vec<&dyn rusqlite::ToSql> = params - .iter() - .map(|p| p.as_ref() as &dyn rusqlite::ToSql) - .collect(); - - let items = stmt - .query_map(params_ref.as_slice(), T::from_row)? - .filter_map(|r| { - match r { - Ok(pkg) => Some(pkg), - Err(err) => { - eprintln!("Package map error: {err:#?}"); - None - } - } - }) - .collect(); - - let (count_query, count_params) = self.build_count_query(&shards); - let mut count_stmt = conn.prepare(&count_query)?; - let count_params_ref: Vec<&dyn rusqlite::ToSql> = count_params - .iter() - .map(|p| p.as_ref() as &dyn rusqlite::ToSql) - .collect(); - let total: u64 = count_stmt.query_row(count_params_ref.as_slice(), |row| row.get(0))?; - - let page = self.page; - let limit = self.limit; - - let has_next = limit.map_or_else(|| false, |v| (self.page as u64 * v as u64) < total); - - Ok(PaginatedResponse { - items, - page, - limit, - total, - has_next, - }) - } - - fn get_shards(&self, conn: &Connection) -> SoarResult> { - let shards = self.shards.clone().unwrap_or_else(|| { - let mut stmt = conn.prepare("PRAGMA database_list").unwrap(); - stmt.query_map([], |row| row.get::<_, String>(1)) - .unwrap() - .filter_map(Result::ok) - .collect() - }); - Ok(shards) - } - - fn build_query( - &self, - shards: &[String], - ) -> SoarResult<(String, Vec>)> { - let mut params: Vec> = Vec::new(); - - let shard_queries: Vec = shards - .iter() - .map(|shard| { - let cols = if self.select_columns.is_empty() { - vec![ - "p.id", - "disabled", - "json(disabled_reason) AS disabled_reason", - "rank", - "pkg", - "pkg_id", - "pkg_name", - "pkg_family", - "pkg_type", - "pkg_webpage", - "app_id", - "description", - "version", - "version_upstream", - "json(licenses) AS licenses", - "download_url", - "size", - "ghcr_pkg", - "ghcr_size", - "json(ghcr_files) AS ghcr_files", - "ghcr_blob", - "ghcr_url", - "bsum", - "shasum", - "icon", - "desktop", - "appstream", - "json(homepages) AS homepages", - "json(notes) AS notes", - "json(source_urls) AS source_urls", - "json(tags) AS tags", - "json(categories) AS categories", - "build_id", - "build_date", - "build_action", - "build_script", - "build_log", - "json(provides) AS provides", - "json(snapshots) AS snapshots", - "json(repology) AS repology", - "json(replaces) AS replaces", - "download_count", - "download_count_week", - "download_count_month", - "bundle", - "bundle_type", - "soar_syms", - "deprecated", - "desktop_integration", - "external", - "installable", - "portable", - "trusted", - "version_latest", - "version_outdated", - ] - .join(",") - } else { - self.select_columns.join(",") - }; - let select_clause = format!( - "SELECT - {cols}, r.name AS repo_name, - json_group_array( - json_object( - 'name', m.name, - 'contact', m.contact - ) - ) FILTER (WHERE m.id IS NOT NULL) as maintainers - FROM - {shard}.packages p - JOIN {shard}.repository r - LEFT JOIN {shard}.package_maintainers pm ON p.id = pm.package_id - LEFT JOIN {shard}.maintainers m ON m.id = pm.maintainer_id - ", - ); - - let where_clause = self.build_where_clause(&mut params); - - let mut query = format!("{select_clause} {where_clause}"); - query.push_str(" GROUP BY p.id, repo_name"); - query - }) - .collect(); - - let combined_query = shard_queries.join("\nUNION ALL\n"); - let mut final_query = format!("WITH results AS ({combined_query}) SELECT * FROM results"); - - if !self.sort_fields.is_empty() { - let sort_clauses: Vec = self - .sort_fields - .iter() - .map(|(field, direction)| { - format!( - "{} {}", - field, - match direction { - SortDirection::Asc => "ASC", - SortDirection::Desc => "DESC", - } - ) - }) - .collect(); - final_query.push_str(" ORDER BY "); - final_query.push_str(&sort_clauses.join(", ")); - } - - if let Some(limit) = self.limit { - final_query.push_str(" LIMIT ?"); - params.push(Box::new(limit)); - - let offset = self.limit.map(|limit| (self.page - 1) * limit); - if let Some(offset) = offset { - final_query.push_str(" OFFSET ?"); - params.push(Box::new(offset)); - } - } - - Ok((final_query, params)) - } - - fn build_count_query(&self, shards: &[String]) -> (String, Vec>) { - let mut params: Vec> = Vec::new(); - - let shard_queries: Vec = shards - .iter() - .map(|shard| { - let select_clause = format!( - "SELECT COUNT(1) as cnt, r.name as repo_name FROM {shard}.packages p JOIN {shard}.repository r", - ); - - let where_clause = self.build_where_clause(&mut params); - format!("{select_clause} {where_clause}") - }) - .collect(); - - let query = format!( - "SELECT SUM(cnt) FROM ({})", - shard_queries.join("\nUNION ALL\n") - ); - - (query, params) - } - - pub fn load_installed(&self) -> SoarResult> { - let conn = self.db.lock().map_err(|_| SoarError::PoisonError)?; - let (query, params) = self.build_installed_query()?; - let mut stmt = conn.prepare(&query)?; - - let params_ref: Vec<&dyn rusqlite::ToSql> = params - .iter() - .map(|p| p.as_ref() as &dyn rusqlite::ToSql) - .collect(); - let items = stmt - .query_map(params_ref.as_slice(), InstalledPackage::from_row)? - .filter_map(|r| { - match r { - Ok(pkg) => Some(pkg), - Err(err) => { - eprintln!("Installed package map error: {err:#?}"); - None - } - } - }) - .collect(); - - let (count_query, count_params) = { - let mut params: Vec> = Vec::new(); - let select_clause = "SELECT COUNT(1) FROM packages p"; - let where_clause = self.build_where_clause(&mut params); - let query = format!("{select_clause} {where_clause}"); - (query, params) - }; - let mut count_stmt = conn.prepare(&count_query)?; - let count_params_ref: Vec<&dyn rusqlite::ToSql> = count_params - .iter() - .map(|p| p.as_ref() as &dyn rusqlite::ToSql) - .collect(); - let total: u64 = count_stmt.query_row(count_params_ref.as_slice(), |row| row.get(0))?; - - let page = self.page; - let limit = self.limit; - - let has_next = limit.map_or_else(|| false, |v| (self.page as u64 * v as u64) < total); - - Ok(PaginatedResponse { - items, - page, - limit, - total, - has_next, - }) - } - - fn build_installed_query(&self) -> SoarResult<(String, Vec>)> { - let mut params: Vec> = Vec::new(); - let select_clause = "SELECT p.*, pp.* FROM packages p - LEFT JOIN portable_package pp - ON pp.package_id = p.id"; - let where_clause = self.build_where_clause(&mut params); - let mut query = format!("{select_clause} {where_clause}"); - - if !self.sort_fields.is_empty() { - let sort_clauses: Vec = self - .sort_fields - .iter() - .map(|(field, direction)| { - format!( - "{} {}", - field, - match direction { - SortDirection::Asc => "ASC", - SortDirection::Desc => "DESC", - } - ) - }) - .collect(); - query.push_str(" ORDER BY "); - query.push_str(&sort_clauses.join(", ")); - } - - if let Some(limit) = self.limit { - query.push_str(" LIMIT ?"); - params.push(Box::new(limit)); - - let offset = self.limit.map(|limit| (self.page - 1) * limit); - if let Some(offset) = offset { - query.push_str(" OFFSET ?"); - params.push(Box::new(offset)); - } - } - - Ok((query, params)) - } - - fn build_where_clause(&self, params: &mut Vec>) -> String { - if self.filters.is_empty() { - return String::new(); - } - - let conditions: Vec = self - .filters - .iter() - .enumerate() - .map(|(idx, filter)| { - let condition = match &filter.condition { - FilterCondition::Eq(val) => { - params.push(Box::new(val.clone())); - format!("{} = ?", filter.field) - } - FilterCondition::Ne(val) => { - params.push(Box::new(val.clone())); - format!("{} != ?", filter.field) - } - FilterCondition::Gt(val) => { - params.push(Box::new(val.clone())); - format!("{} > ?", filter.field) - } - FilterCondition::Gte(val) => { - params.push(Box::new(val.clone())); - format!("{} >= ?", filter.field) - } - FilterCondition::Lt(val) => { - params.push(Box::new(val.clone())); - format!("{} < ?", filter.field) - } - FilterCondition::Lte(val) => { - params.push(Box::new(val.clone())); - format!("{} <= ?", filter.field) - } - FilterCondition::Like(val) => { - params.push(Box::new(format!("%{val}%"))); - format!("{} LIKE ?", filter.field) - } - FilterCondition::ILike(val) => { - params.push(Box::new(format!("%{val}%"))); - format!("LOWER({}) LIKE LOWER(?)", filter.field) - } - FilterCondition::In(vals) => { - let placeholders = vec!["?"; vals.len()].join(", "); - for val in vals { - params.push(Box::new(val.clone())); - } - format!("{} IN ({})", filter.field, placeholders) - } - FilterCondition::NotIn(vals) => { - let placeholders = vec!["?"; vals.len()].join(", "); - for val in vals { - params.push(Box::new(val.clone())); - } - format!("{} NOT IN ({})", filter.field, placeholders) - } - FilterCondition::Between(start, end) => { - params.push(Box::new(start.clone())); - params.push(Box::new(end.clone())); - format!("{} BETWEEN ? AND ?", filter.field) - } - FilterCondition::IsNull => { - format!("{} IS NULL", filter.field) - } - FilterCondition::IsNotNull => { - format!("{} IS NOT NULL", filter.field) - } - FilterCondition::None => filter.field.to_string(), - }; - - if idx > 0 { - match filter.logical_op { - Some(LogicalOp::And) => format!("AND {condition}"), - Some(LogicalOp::Or) => format!("OR {condition}"), - None => condition, - } - } else { - condition - } - }) - .collect(); - format!("WHERE {}", conditions.join(" ")) - } - - fn build_subquery_where_clause(&self, value: &str, condition: FilterCondition) -> String { - match condition { - FilterCondition::Eq(val) => { - format!("{value} = '{val}'") - } - FilterCondition::Ne(val) => { - format!("{value} != '{val}'") - } - FilterCondition::Gt(val) => { - format!("{value} > '{val}'") - } - FilterCondition::Gte(val) => { - format!("{value} >= '{val}'") - } - FilterCondition::Lt(val) => { - format!("{value} < '{val}'") - } - FilterCondition::Lte(val) => { - format!("{value} <= '{val}'") - } - FilterCondition::Like(val) => { - format!("{value} LIKE '%{val}%'") - } - FilterCondition::ILike(val) => { - format!("LOWER({value}) LIKE LOWER('%{val}%')") - } - FilterCondition::In(vals) => { - format!( - "{} IN ({})", - value, - vals.iter() - .map(|v| format!("'{v}'")) - .collect::>() - .join(",") - ) - } - FilterCondition::NotIn(vals) => { - format!( - "{} NOT IN ({})", - value, - vals.iter() - .map(|v| format!("'{v}'")) - .collect::>() - .join(",") - ) - } - FilterCondition::Between(start, end) => { - format!("{value} BETWEEN '{start}' AND '{end}'") - } - FilterCondition::IsNull => { - format!("{value} IS NULL") - } - FilterCondition::IsNotNull => { - format!("{value} IS NOT NULL") - } - FilterCondition::None => String::new(), - } - } -} diff --git a/soar-core/src/database/repository.rs b/soar-core/src/database/repository.rs deleted file mode 100644 index f9462e92..00000000 --- a/soar-core/src/database/repository.rs +++ /dev/null @@ -1,162 +0,0 @@ -use regex::Regex; -use rusqlite::{params, Result, Transaction}; -use soar_registry::RemotePackage; - -use super::{packages::PackageProvide, statements::DbStatements}; - -pub struct PackageRepository<'a> { - tx: &'a Transaction<'a>, - statements: DbStatements<'a>, - repo_name: &'a str, -} - -impl<'a> PackageRepository<'a> { - pub fn new(tx: &'a Transaction<'a>, statements: DbStatements<'a>, repo_name: &'a str) -> Self { - Self { - tx, - statements, - repo_name, - } - } - - pub fn import_packages(&mut self, metadata: &[RemotePackage]) -> Result<()> { - self.statements - .repo_insert - // to prevent incomplete sync, etag should only be updated once - // all checks are done - .execute(params![self.repo_name, ""])?; - - for package in metadata { - self.insert_package(package)?; - } - Ok(()) - } - - fn get_or_create_maintainer(&mut self, name: &str, contact: &str) -> Result { - self.statements - .maintainer_check - .query_row(params![contact], |row| row.get(0)) - .or_else(|_| { - self.statements - .maintainer_insert - .execute(params![name, contact])?; - Ok(self.tx.last_insert_rowid()) - }) - } - - fn extract_name_and_contact(&self, input: &str) -> Option<(String, String)> { - let re = Regex::new(r"^([^()]+) \(([^)]+)\)$").unwrap(); - - if let Some(captures) = re.captures(input) { - let name = captures.get(1).map_or("", |m| m.as_str()).to_string(); - let contact = captures.get(2).map_or("", |m| m.as_str()).to_string(); - Some((name, contact)) - } else { - None - } - } - - fn insert_package(&mut self, package: &RemotePackage) -> Result<()> { - let disabled_reason = serde_json::to_string(&package.disabled_reason).unwrap(); - let licenses = serde_json::to_string(&package.licenses).unwrap(); - let ghcr_files = serde_json::to_string(&package.ghcr_files).unwrap(); - let homepages = serde_json::to_string(&package.homepages).unwrap(); - let notes = serde_json::to_string(&package.notes).unwrap(); - let source_urls = serde_json::to_string(&package.src_urls).unwrap(); - let tags = serde_json::to_string(&package.tags).unwrap(); - let categories = serde_json::to_string(&package.categories).unwrap(); - let snapshots = serde_json::to_string(&package.snapshots).unwrap(); - let repology = serde_json::to_string(&package.repology).unwrap(); - let replaces = serde_json::to_string(&package.replaces).unwrap(); - - const PROVIDES_DELIMITERS: &[&str] = &["==", "=>", ":"]; - - let provides = package.provides.as_ref().map(|vec| { - vec.iter() - .filter_map(|p| { - let include = *p == package.pkg_name - || matches!(package.recurse_provides, Some(true)) - || p.strip_prefix(&package.pkg_name).is_some_and(|rest| { - PROVIDES_DELIMITERS.iter().any(|d| rest.starts_with(d)) - }); - - include.then(|| PackageProvide::from_string(p)) - }) - .collect::>() - }); - - let provides = serde_json::to_string(&provides).unwrap(); - let inserted = self.statements.package_insert.execute(params![ - package.disabled, - disabled_reason, - package.rank, - package.pkg, - package.pkg_id, - package.pkg_name, - package.pkg_family, - package.pkg_type, - package.pkg_webpage, - package.app_id, - package.description, - package.version, - package.version_upstream, - licenses, - package.download_url, - package.size_raw, - package.ghcr_pkg, - package.ghcr_size_raw, - ghcr_files, - package.ghcr_blob, - package.ghcr_url, - package.bsum, - package.shasum, - package.icon, - package.desktop, - package.appstream, - homepages, - notes, - source_urls, - tags, - categories, - package.build_id, - package.build_date, - package.build_action, - package.build_script, - package.build_log, - provides, - snapshots, - repology, - replaces, - package.download_count, - package.download_count_week, - package.download_count_month, - package.bundle.unwrap_or(false), - package.bundle_type, - package.soar_syms.unwrap_or(false), - package.deprecated.unwrap_or(false), - package.desktop_integration, - package.external, - package.installable, - package.portable, - package.recurse_provides, - package.trusted, - package.version_latest, - package.version_outdated - ])?; - if inserted == 0 { - return Ok(()); - } - let package_id = self.tx.last_insert_rowid(); - for maintainer in &package.maintainers.clone().unwrap_or_default() { - let typed = self.extract_name_and_contact(maintainer); - if let Some((name, contact)) = typed { - let maintainer_id = self.get_or_create_maintainer(&name, &contact)?; - self.statements - .pkg_maintainer_insert - .execute(params![maintainer_id, package_id])?; - } - } - - Ok(()) - } -} diff --git a/soar-core/src/database/statements.rs b/soar-core/src/database/statements.rs deleted file mode 100644 index f0b71efe..00000000 --- a/soar-core/src/database/statements.rs +++ /dev/null @@ -1,56 +0,0 @@ -use rusqlite::{Statement, Transaction}; - -pub struct DbStatements<'a> { - pub repo_insert: Statement<'a>, - pub package_insert: Statement<'a>, - pub maintainer_insert: Statement<'a>, - pub maintainer_check: Statement<'a>, - pub pkg_maintainer_insert: Statement<'a>, -} - -impl<'a> DbStatements<'a> { - pub fn new(tx: &'a Transaction) -> rusqlite::Result { - Ok(Self { - repo_insert: tx.prepare( - "INSERT INTO repository (name, etag) - VALUES (?1, ?2) - ON CONFLICT (name) DO UPDATE SET etag = ?2", - )?, - maintainer_insert: tx - .prepare("INSERT INTO maintainers (name, contact) VALUES (?1, ?2)")?, - maintainer_check: tx.prepare("SELECT id FROM maintainers WHERE contact=?1 LIMIT 1")?, - pkg_maintainer_insert: tx.prepare( - "INSERT INTO package_maintainers ( - maintainer_id, package_id - ) VALUES (?1, ?2) - ON CONFLICT (maintainer_id, package_id) DO NOTHING", - )?, - package_insert: tx.prepare( - "INSERT INTO packages ( - disabled, disabled_reason, rank, pkg, pkg_id, pkg_name, - pkg_family, pkg_type, pkg_webpage, app_id, description, - version, version_upstream, licenses, download_url, - size, ghcr_pkg, ghcr_size, ghcr_files, ghcr_blob, ghcr_url, - bsum, shasum, icon, desktop, appstream, homepages, notes, - source_urls, tags, categories, build_id, build_date, - build_action, build_script, build_log, provides, snapshots, - repology, replaces, download_count, download_count_week, - download_count_month, bundle, bundle_type, soar_syms, - deprecated, desktop_integration, external, installable, - portable, recurse_provides, trusted, version_latest, - version_outdated - ) - VALUES - ( - ?1, jsonb(?2), ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, - ?13, jsonb(?14), ?15, ?16, ?17, ?18, jsonb(?19), ?20, ?21, - ?22, ?23, ?24, ?25, ?26, jsonb(?27), jsonb(?28), jsonb(?29), - jsonb(?30), jsonb(?31), ?32, ?33, ?34, ?35, ?36, jsonb(?37), - jsonb(?38), jsonb(?39), jsonb(?40), ?41, ?42, ?43, ?44, - ?45, ?46, ?47, ?48, ?49, ?50, ?51, ?52, ?53, ?54, ?55 - ) - ON CONFLICT (pkg_id, pkg_name, version) DO NOTHING", - )?, - }) - } -} diff --git a/soar-core/src/error.rs b/soar-core/src/error.rs deleted file mode 100644 index adbc96b4..00000000 --- a/soar-core/src/error.rs +++ /dev/null @@ -1,136 +0,0 @@ -use std::error::Error; - -use soar_config::error::ConfigError; -use soar_utils::error::{FileSystemError, HashError, PathError}; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum SoarError { - #[error(transparent)] - Config(#[from] ConfigError), - - #[error("System error: {0}")] - Errno(#[from] nix::errno::Errno), - - #[error("Environment variable error: {0}")] - VarError(#[from] std::env::VarError), - - #[error("{0}")] - FileSystemError(#[from] FileSystemError), - - #[error("{0}")] - HashError(#[from] HashError), - - #[error("{0}")] - PathError(#[from] PathError), - - #[error("IO error while {action}: {source}")] - IoError { - action: String, - source: std::io::Error, - }, - - #[error("System time error: {0}")] - SystemTimeError(#[from] std::time::SystemTimeError), - - #[error("TOML serialization error: {0}")] - TomlError(#[from] toml::ser::Error), - - #[error("SQLite database error: {0}")] - RusqliteError(#[from] rusqlite::Error), - - #[error("Database operation failed: {0}")] - DatabaseError(String), - - #[error("HTTP request error: {0:?}")] - UreqError(#[from] ureq::Error), - - #[error("Download failed: {0}")] - DownloadError(#[from] soar_dl::error::DownloadError), - - #[error("Package error: {0}")] - PackageError(#[from] soar_package::PackageError), - - #[error("Package integration failed: {0}")] - PackageIntegrationFailed(String), - - #[error("Package {0} not found")] - PackageNotFound(String), - - #[error("Failed to fetch from remote source: {0}")] - FailedToFetchRemote(String), - - #[error("Invalid path specified")] - InvalidPath, - - #[error("Thread lock poison error")] - PoisonError, - - #[error("Invalid checksum detected")] - InvalidChecksum, - - #[error("Invalid package query: {0}")] - InvalidPackageQuery(String), - - #[error("{0}")] - Custom(String), - - #[error("{0}")] - Warning(String), - - #[error("Regex compilation error: {0}")] - RegexError(#[from] regex::Error), -} - -impl SoarError { - pub fn message(&self) -> String { - self.to_string() - } - - pub fn root_cause(&self) -> String { - match self { - Self::UreqError(e) => { - format!( - "Root cause: {}", - e.source() - .map_or_else(|| e.to_string(), |source| source.to_string()) - ) - } - Self::RusqliteError(e) => { - format!( - "Root cause: {}", - e.source() - .map_or_else(|| e.to_string(), |source| source.to_string()) - ) - } - Self::Config(err) => err.to_string(), - _ => self.to_string(), - } - } -} - -impl From> for SoarError { - fn from(_: std::sync::PoisonError) -> Self { - Self::PoisonError - } -} - -pub trait ErrorContext { - fn with_context(self, context: C) -> Result - where - C: FnOnce() -> String; -} - -impl ErrorContext for std::io::Result { - fn with_context(self, context: C) -> Result - where - C: FnOnce() -> String, - { - self.map_err(|err| { - SoarError::IoError { - action: context(), - source: err, - } - }) - } -} diff --git a/soar-core/src/package/update.rs b/soar-core/src/package/update.rs deleted file mode 100644 index e69de29b..00000000 From 777381bc232166b3176537298cf75193eb34ca92 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Dec 2025 21:45:22 +0545 Subject: [PATCH 02/14] fix version pinning --- Cargo.lock | 1 - crates/soar-cli/src/install.rs | 2 ++ crates/soar-cli/src/update.rs | 2 ++ crates/soar-core/Cargo.toml | 1 - crates/soar-core/src/package/install.rs | 3 ++- 5 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cddb731e..979790b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2240,7 +2240,6 @@ dependencies = [ "soar-db", "soar-dl", "soar-package", - "soar-registry", "soar-utils", "thiserror 2.0.17", "toml", diff --git a/crates/soar-cli/src/install.rs b/crates/soar-cli/src/install.rs index 401f0c80..2f156755 100644 --- a/crates/soar-cli/src/install.rs +++ b/crates/soar-cli/src/install.rs @@ -317,6 +317,7 @@ fn resolve_packages( package: pkg, existing_install, with_pkg_id: true, + pinned: query.version.is_some(), profile: None, ..Default::default() }); @@ -353,6 +354,7 @@ fn resolve_packages( package: db_pkg, existing_install, with_pkg_id: false, + pinned: query.version.is_some(), profile: None, ..Default::default() }); diff --git a/crates/soar-cli/src/update.rs b/crates/soar-cli/src/update.rs index 792797e4..8541d8ef 100644 --- a/crates/soar-cli/src/update.rs +++ b/crates/soar-cli/src/update.rs @@ -104,6 +104,7 @@ pub async fn update_packages( package, existing_install, with_pkg_id, + pinned: pkg.pinned, profile: Some(pkg.profile), portable: pkg.portable_path, portable_home: pkg.portable_home, @@ -152,6 +153,7 @@ pub async fn update_packages( package, existing_install, with_pkg_id, + pinned: pkg.pinned, profile: Some(pkg.profile), portable: pkg.portable_path, portable_home: pkg.portable_home, diff --git a/crates/soar-core/Cargo.toml b/crates/soar-core/Cargo.toml index 210ca2b1..55c8f2d5 100644 --- a/crates/soar-core/Cargo.toml +++ b/crates/soar-core/Cargo.toml @@ -22,7 +22,6 @@ soar-config = { workspace = true } soar-db = { workspace = true } soar-dl = { workspace = true } soar-package = { workspace = true } -soar-registry = { workspace = true } soar-utils = { workspace = true } thiserror = { workspace = true } toml = "0.9.6" diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index 5f48a098..ba1f6560 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -47,6 +47,7 @@ pub struct InstallTarget { pub package: Package, pub existing_install: Option, pub with_pkg_id: bool, + pub pinned: bool, pub profile: Option, pub portable: Option, pub portable_home: Option, @@ -89,7 +90,7 @@ impl PackageInstaller { installed_path: &installed_path, installed_date: &installed_date, profile: &profile, - pinned: false, + pinned: target.pinned, is_installed: false, with_pkg_id, detached: false, From 07c16207b9d8e7b3011273ffe554439fd5a3b1f2 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Dec 2025 22:25:58 +0545 Subject: [PATCH 03/14] fix issues --- .github/workflows/ci.yaml | 2 +- Cargo.toml | 2 +- crates/soar-cli/src/list.rs | 6 +- crates/soar-cli/src/state.rs | 7 +- crates/soar-cli/src/use.rs | 2 +- crates/soar-core/src/database/connection.rs | 13 +- crates/soar-core/src/package/install.rs | 8 +- crates/soar-db/src/lib.rs | 10 +- crates/soar-db/src/models/core.rs | 10 +- crates/soar-db/src/repository/core.rs | 2 - crates/soar-db/src/repository/metadata.rs | 7 +- crates/soar-registry/src/lib.rs | 9 +- crates/soar-registry/src/metadata.rs | 189 +------------------- 13 files changed, 45 insertions(+), 222 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f4cdd222..3b2057cb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,7 +34,7 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 with: - toolchain: stable + toolchain: nightly components: rustfmt,clippy - name: Format diff --git a/Cargo.toml b/Cargo.toml index 43fb501b..493a799c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,7 +61,7 @@ xattr = "1.6.1" zstd = "0.13.3" [profile.release] -opt-level = 3 +opt-level = "z" strip = true lto = true panic = "abort" diff --git a/crates/soar-cli/src/list.rs b/crates/soar-cli/src/list.rs index 7dc910b3..ea630c95 100644 --- a/crates/soar-cli/src/list.rs +++ b/crates/soar-cli/src/list.rs @@ -379,9 +379,7 @@ pub async fn list_packages(repo_name: Option) -> SoarResult<()> { let packages: Vec = if let Some(ref repo_name) = repo_name { metadata_mgr - .query_repo(repo_name, |conn| { - MetadataRepository::list_paginated(conn, 1, 3000) - })? + .query_repo(repo_name, |conn| MetadataRepository::list_all(conn))? .unwrap_or_default() .into_iter() .map(|p| { @@ -392,7 +390,7 @@ pub async fn list_packages(repo_name: Option) -> SoarResult<()> { .collect() } else { metadata_mgr.query_all_flat(|repo_name, conn| { - let pkgs = MetadataRepository::list_paginated(conn, 1, 3000)?; + let pkgs = MetadataRepository::list_all(conn)?; Ok(pkgs .into_iter() .map(|p| { diff --git a/crates/soar-cli/src/state.rs b/crates/soar-cli/src/state.rs index 2d34b2b3..f129850f 100644 --- a/crates/soar-cli/src/state.rs +++ b/crates/soar-cli/src/state.rs @@ -22,8 +22,7 @@ use soar_db::{ repository::{core::CoreRepository, metadata::MetadataRepository, nest::NestRepository}, }; use soar_registry::{ - fetch_metadata_with_etag, fetch_nest_metadata_with_etag, write_metadata_db, MetadataContent, - RemotePackage, + fetch_metadata, fetch_nest_metadata, write_metadata_db, MetadataContent, RemotePackage, }; use tracing::{error, info}; @@ -95,7 +94,7 @@ impl AppState { url: nest.url.clone(), }; let task = tokio::task::spawn(async move { - fetch_nest_metadata_with_etag(®istry_nest, force, etag).await + fetch_nest_metadata(®istry_nest, force, etag).await }); tasks.push((task, nest)); } @@ -141,7 +140,7 @@ impl AppState { let repo_clone = repo.clone(); let etag = self.read_repo_etag(&repo_clone); let task = tokio::task::spawn(async move { - fetch_metadata_with_etag(&repo_clone, force, etag).await + fetch_metadata(&repo_clone, force, etag).await }); tasks.push((task, repo)); } diff --git a/crates/soar-cli/src/use.rs b/crates/soar-cli/src/use.rs index 3068b597..dad7efde 100644 --- a/crates/soar-cli/src/use.rs +++ b/crates/soar-cli/src/use.rs @@ -118,7 +118,7 @@ pub async fn use_alternate_package(name: &str) -> SoarResult<()> { || installed_pkg.portable_share.is_some() || installed_pkg.portable_cache.is_some(); - if pkg.iter().all(has_desktop_integration) { + if !pkg.is_empty() && pkg.iter().all(has_desktop_integration) { integrate_package( &install_dir, &installed_pkg, diff --git a/crates/soar-core/src/database/connection.rs b/crates/soar-core/src/database/connection.rs index 0b8956b7..86337b0c 100644 --- a/crates/soar-core/src/database/connection.rs +++ b/crates/soar-core/src/database/connection.rs @@ -28,9 +28,9 @@ impl DieselDatabase { }) } - /// Opens a metadata database connection with migrations. + /// Opens a metadata database connection. pub fn open_metadata>(path: P) -> Result { - let conn = DbConnection::open(path, DbType::Metadata) + let conn = DbConnection::open_metadata(path) .map_err(|e| SoarError::Custom(format!("opening metadata database: {}", e)))?; Ok(Self { conn: Arc::new(Mutex::new(conn)), @@ -48,8 +48,8 @@ impl DieselDatabase { /// Gets a mutable reference to the underlying connection. /// Locks the mutex and returns a guard. - pub fn conn(&self) -> std::sync::MutexGuard<'_, DbConnection> { - self.conn.lock().unwrap() + pub fn conn(&self) -> Result> { + self.conn.lock().map_err(|_| SoarError::PoisonError) } /// Executes a function with the connection. @@ -165,7 +165,10 @@ impl MetadataManager { /// Returns the list of repository names. pub fn repo_names(&self) -> Vec<&str> { - self.databases.iter().map(|(name, _)| name.as_str()).collect() + self.databases + .iter() + .map(|(name, _)| name.as_str()) + .collect() } } diff --git a/crates/soar-core/src/package/install.rs b/crates/soar-core/src/package/install.rs index ba1f6560..81a341de 100644 --- a/crates/soar-core/src/package/install.rs +++ b/crates/soar-core/src/package/install.rs @@ -252,7 +252,6 @@ impl PackageInstaller { pkg_name, pkg_id, version, - version, size, provides, with_pkg_id, @@ -261,7 +260,12 @@ impl PackageInstaller { ) })?; - let record_id = record_id.unwrap_or(0); + let record_id = record_id.ok_or_else(|| { + SoarError::Custom(format!( + "Failed to record installation for {}#{}: package not found in database", + pkg_name, pkg_id + )) + })?; if portable.is_some() || portable_home.is_some() diff --git a/crates/soar-db/src/lib.rs b/crates/soar-db/src/lib.rs index 8c5d178f..800fce00 100644 --- a/crates/soar-db/src/lib.rs +++ b/crates/soar-db/src/lib.rs @@ -19,7 +19,8 @@ //! //! ```ignore //! use soar_db::connection::DatabaseManager; -//! use soar_db::repository::{CoreRepository, MetadataRepository}; +//! use soar_db::repository::core::CoreRepository; +//! use soar_db::repository::metadata::MetadataRepository; //! //! // Create database manager //! let mut manager = DatabaseManager::new("/path/to/db")?; @@ -27,12 +28,12 @@ //! // Add repository metadata //! manager.add_metadata_db("pkgforge", "/path/to/pkgforge.db")?; //! -//! // Query installed packages -//! let installed = CoreRepository::list_all(manager.core().conn())?; +//! // Query installed packages (DbConnection derefs to SqliteConnection) +//! let installed = CoreRepository::list_all(manager.core())?; //! //! // Search for packages //! if let Some(metadata) = manager.metadata("pkgforge") { -//! let packages = MetadataRepository::search(metadata.conn(), "firefox")?; +//! let packages = MetadataRepository::search(metadata, "firefox")?; //! } //! ``` @@ -43,6 +44,7 @@ pub mod models; pub mod repository; pub mod schema; +/// Helper macro to convert `Option` to `Option>`. #[macro_export] macro_rules! json_vec { ($val:expr) => { diff --git a/crates/soar-db/src/models/core.rs b/crates/soar-db/src/models/core.rs index 66b8bf12..c5c593de 100644 --- a/crates/soar-db/src/models/core.rs +++ b/crates/soar-db/src/models/core.rs @@ -1,7 +1,7 @@ use diesel::{prelude::*, sqlite::Sqlite}; use serde_json::Value; -use crate::{models::types::PackageProvide, schema::core::*}; +use crate::{json_vec, models::types::PackageProvide, schema::core::*}; #[derive(Debug, Selectable)] pub struct Package { @@ -65,12 +65,8 @@ impl Queryable for Package { with_pkg_id: row.13, detached: row.14, unlinked: row.15, - provides: row - .16 - .map(|v| serde_json::from_value(v).unwrap_or_default()), - install_patterns: row - .17 - .map(|v| serde_json::from_value(v).unwrap_or_default()), + provides: json_vec!(row.16), + install_patterns: json_vec!(row.17), }) } } diff --git a/crates/soar-db/src/repository/core.rs b/crates/soar-db/src/repository/core.rs index d863ed33..01e89afa 100644 --- a/crates/soar-db/src/repository/core.rs +++ b/crates/soar-db/src/repository/core.rs @@ -372,7 +372,6 @@ impl CoreRepository { pkg_name: &str, pkg_id: &str, version: &str, - new_version: &str, size: i64, provides: Option>, with_pkg_id: bool, @@ -389,7 +388,6 @@ impl CoreRepository { .filter(packages::version.eq(version)), ) .set(( - packages::version.eq(new_version), packages::size.eq(size), packages::installed_date.eq(installed_date), packages::is_installed.eq(true), diff --git a/crates/soar-db/src/repository/metadata.rs b/crates/soar-db/src/repository/metadata.rs index 1ccc72a8..8459ad57 100644 --- a/crates/soar-db/src/repository/metadata.rs +++ b/crates/soar-db/src/repository/metadata.rs @@ -1,10 +1,15 @@ //! Metadata database repository for package queries. +use std::sync::OnceLock; + use diesel::{dsl::sql, prelude::*, sql_types::Text}; use regex::Regex; use serde_json::json; use soar_registry::RemotePackage; +/// Regex for extracting name and contact from maintainer string format "Name (contact)". +static MAINTAINER_RE: OnceLock = OnceLock::new(); + use super::core::SortDirection; use crate::{ models::{ @@ -479,7 +484,7 @@ impl MetadataRepository { /// Extracts name and contact from maintainer string format "Name (contact)". fn extract_name_and_contact(input: &str) -> Option<(String, String)> { - let re = Regex::new(r"^([^()]+) \(([^)]+)\)$").unwrap(); + let re = MAINTAINER_RE.get_or_init(|| Regex::new(r"^([^()]+) \(([^)]+)\)$").unwrap()); if let Some(captures) = re.captures(input) { let name = captures.get(1).map_or("", |m| m.as_str()).to_string(); diff --git a/crates/soar-registry/src/lib.rs b/crates/soar-registry/src/lib.rs index e7f7eb3d..b3810695 100644 --- a/crates/soar-registry/src/lib.rs +++ b/crates/soar-registry/src/lib.rs @@ -19,8 +19,8 @@ //! use soar_registry::{fetch_metadata, MetadataContent}; //! use soar_config::repository::Repository; //! -//! async fn sync_repo(repo: &Repository) -> soar_registry::Result<()> { -//! if let Some((etag, content)) = fetch_metadata(repo, false).await? { +//! async fn sync_repo(repo: &Repository, existing_etag: Option) -> soar_registry::Result<()> { +//! if let Some((etag, content)) = fetch_metadata(repo, false, existing_etag).await? { //! match content { //! MetadataContent::SqliteDb(bytes) => { //! // Write SQLite database to disk @@ -41,9 +41,8 @@ pub mod package; pub use error::{ErrorContext, RegistryError, Result}; pub use metadata::{ - fetch_metadata, fetch_metadata_with_etag, fetch_nest_metadata, fetch_nest_metadata_with_etag, - fetch_public_key, process_metadata_content, write_metadata_db, MetadataContent, - SQLITE_MAGIC_BYTES, ZST_MAGIC_BYTES, + fetch_metadata, fetch_nest_metadata, fetch_public_key, process_metadata_content, + write_metadata_db, MetadataContent, SQLITE_MAGIC_BYTES, ZST_MAGIC_BYTES, }; pub use nest::Nest; pub use package::RemotePackage; diff --git a/crates/soar-registry/src/metadata.rs b/crates/soar-registry/src/metadata.rs index 9781d39e..8a8318b4 100644 --- a/crates/soar-registry/src/metadata.rs +++ b/crates/soar-registry/src/metadata.rs @@ -70,6 +70,7 @@ fn construct_nest_url(url: &str) -> Result { /// /// * `nest` - The nest configuration containing the name and URL /// * `force` - If `true`, bypasses cache validation and fetches fresh metadata +/// * `existing_etag` - Optional etag from a previous fetch, read from the database /// /// # Returns /// @@ -88,91 +89,6 @@ fn construct_nest_url(url: &str) -> Result { pub async fn fetch_nest_metadata( nest: &Nest, force: bool, -) -> Result> { - let config = get_config(); - let nests_repo_path = config - .get_repositories_path() - .map_err(|e| { - RegistryError::IoError { - action: "getting repositories path".to_string(), - source: io::Error::other(e.to_string()), - } - })? - .join("nests"); - let nest_path = nests_repo_path.join(&nest.name); - let metadata_db = nest_path.join("metadata.db"); - - if !metadata_db.exists() { - fs::create_dir_all(&nest_path) - .with_context(|| format!("creating directory {}", nest_path.display()))?; - } - - let etag = if metadata_db.exists() { - let etag = read_etag_from_db(&metadata_db)?; - - if !force && !etag.is_empty() { - let file_info = metadata_db - .metadata() - .with_context(|| format!("reading file metadata from {}", metadata_db.display()))?; - let sync_interval = config.get_nests_sync_interval(); - if let Ok(created) = file_info.created() { - if sync_interval >= created.elapsed()?.as_millis() { - return Ok(None); - } - } - } - etag - } else { - String::new() - }; - - let url = construct_nest_url(&nest.url)?; - - let mut req = SHARED_AGENT - .get(&url) - .header(CACHE_CONTROL, "no-cache") - .header(PRAGMA, "no-cache"); - - if !etag.is_empty() { - req = req.header(IF_NONE_MATCH, etag); - } - - let resp = req - .call() - .map_err(|err| RegistryError::FailedToFetchRemote(err.to_string()))?; - - if resp.status() == StatusCode::NOT_MODIFIED { - return Ok(None); - } - - if !resp.status().is_success() { - let msg = format!("{} [{}]", url, resp.status()); - return Err(RegistryError::FailedToFetchRemote(msg)); - } - - let etag = resp - .headers() - .get(ETAG) - .and_then(|h| h.to_str().ok()) - .map(String::from) - .ok_or(RegistryError::MissingEtag)?; - - info!("Fetching nest from {}", url); - - let content = resp.into_body().read_to_vec()?; - let metadata_content = process_metadata_content(content, &metadata_db)?; - - Ok(Some((etag, metadata_content))) -} - -/// Fetches nest metadata with a pre-provided etag. -/// -/// This function is similar to [`fetch_nest_metadata`] but accepts an optional etag -/// that was previously read from the database. This is useful when the caller -/// has already read the etag using soar-db. -pub async fn fetch_nest_metadata_with_etag( - nest: &Nest, - force: bool, existing_etag: Option, ) -> Result> { let config = get_config(); @@ -293,6 +209,7 @@ pub async fn fetch_public_key>(repo_path: P, pubkey_url: &str) -> /// /// * `repo` - The repository configuration /// * `force` - If `true`, bypasses cache validation and fetches fresh metadata +/// * `existing_etag` - Optional etag from a previous fetch, read from the database /// /// # Returns /// @@ -316,8 +233,8 @@ pub async fn fetch_public_key>(repo_path: P, pubkey_url: &str) -> /// use soar_registry::{fetch_metadata, MetadataContent, write_metadata_db}; /// use soar_config::repository::Repository; /// -/// async fn sync(repo: &Repository) -> soar_registry::Result<()> { -/// if let Some((etag, content)) = fetch_metadata(repo, false).await? { +/// async fn sync(repo: &Repository, etag: Option) -> soar_registry::Result<()> { +/// if let Some((new_etag, content)) = fetch_metadata(repo, false, etag).await? { /// let db_path = repo.get_path().unwrap().join("metadata.db"); /// if let MetadataContent::SqliteDb(bytes) = content { /// write_metadata_db(&bytes, &db_path)?; @@ -329,104 +246,6 @@ pub async fn fetch_public_key>(repo_path: P, pubkey_url: &str) -> pub async fn fetch_metadata( repo: &Repository, force: bool, -) -> Result> { - let repo_path = repo.get_path().map_err(|e| { - RegistryError::IoError { - action: "getting repository path".to_string(), - source: io::Error::other(e.to_string()), - } - })?; - let metadata_db = repo_path.join("metadata.db"); - - if !metadata_db.exists() { - fs::create_dir_all(&repo_path) - .with_context(|| format!("creating directory {}", repo_path.display()))?; - } - - let sync_interval = repo.sync_interval(); - - let etag = if metadata_db.exists() { - let etag = read_etag_from_db(&metadata_db)?; - - if !force && !etag.is_empty() { - let file_info = metadata_db - .metadata() - .with_context(|| format!("reading file metadata from {}", metadata_db.display()))?; - if let Ok(created) = file_info.created() { - if sync_interval >= created.elapsed()?.as_millis() { - return Ok(None); - } - } - } - etag - } else { - String::new() - }; - - Url::parse(&repo.url).map_err(|err| RegistryError::InvalidUrl(err.to_string()))?; - - if let Some(ref pubkey_url) = repo.pubkey { - fetch_public_key(&repo_path, pubkey_url).await?; - } - - let mut req = SHARED_AGENT - .get(&repo.url) - .header(CACHE_CONTROL, "no-cache") - .header(PRAGMA, "no-cache"); - - if !etag.is_empty() { - req = req.header(IF_NONE_MATCH, etag); - } - - let resp = req - .call() - .map_err(|err| RegistryError::FailedToFetchRemote(err.to_string()))?; - - if resp.status() == StatusCode::NOT_MODIFIED { - return Ok(None); - } - - if !resp.status().is_success() { - let msg = format!("{} [{}]", repo.url, resp.status()); - return Err(RegistryError::FailedToFetchRemote(msg)); - } - - let etag = resp - .headers() - .get(ETAG) - .and_then(|h| h.to_str().ok()) - .map(String::from) - .ok_or(RegistryError::MissingEtag)?; - - info!("Fetching metadata from {}", repo.url); - - let content = resp.into_body().read_to_vec()?; - let metadata_content = process_metadata_content(content, &metadata_db)?; - - Ok(Some((etag, metadata_content))) -} - -/// Read ETag from an existing metadata database. -/// Note: This always returns empty string due to cyclic dependency issues with soar-db. -/// Callers should use `fetch_metadata_with_etag` with an etag read using soar-db. -fn read_etag_from_db(_db_path: &Path) -> Result { - Ok(String::new()) -} - -/// Fetches repository metadata with a pre-provided etag. -/// -/// This function is similar to [`fetch_metadata`] but accepts an optional etag -/// that was previously read from the database. This is useful when the caller -/// has already read the etag using soar-db. -/// -/// # Arguments -/// -/// * `repo` - The repository configuration -/// * `force` - If `true`, bypasses cache validation and fetches fresh metadata -/// * `existing_etag` - Optional etag from a previous fetch, read from the database -pub async fn fetch_metadata_with_etag( - repo: &Repository, - force: bool, existing_etag: Option, ) -> Result> { let repo_path = repo.get_path().map_err(|e| { From 0f1f8c170edae323ff997c04bda1ff9265267d9b Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Dec 2025 22:30:54 +0545 Subject: [PATCH 04/14] fix substr --- crates/soar-db/src/repository/metadata.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/soar-db/src/repository/metadata.rs b/crates/soar-db/src/repository/metadata.rs index 8459ad57..0c953a87 100644 --- a/crates/soar-db/src/repository/metadata.rs +++ b/crates/soar-db/src/repository/metadata.rs @@ -356,7 +356,7 @@ impl MetadataRepository { .filter( sql::("version > ") .bind::(current_version) - .sql(" OR (version LIKE 'HEAD-%' AND substr(version, 14) > ") + .sql(" OR (version LIKE 'HEAD-%' AND substr(version, 15) > ") .bind::(&head_version) .sql(")"), ) From 77bebe4e12b8895205655bca6fa4a5236e12e69b Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Dec 2025 22:33:54 +0545 Subject: [PATCH 05/14] fmt --- crates/soar-cli/src/health.rs | 18 ++-- crates/soar-cli/src/inspect.rs | 10 +- crates/soar-cli/src/install.rs | 26 +++-- crates/soar-cli/src/progress.rs | 7 +- crates/soar-cli/src/state.rs | 25 ++--- crates/soar-cli/src/update.rs | 22 +++-- crates/soar-cli/src/use.rs | 25 +++-- crates/soar-cli/src/utils.rs | 16 ++-- crates/soar-core/src/error.rs | 28 ++---- crates/soar-core/src/package/remove.rs | 10 +- crates/soar-core/src/package/update.rs | 5 +- crates/soar-db/src/connection.rs | 15 ++- crates/soar-db/src/error.rs | 5 +- crates/soar-db/src/repository/nest.rs | 10 +- crates/soar-utils/src/error.rs | 127 ++++++++++++++----------- crates/soar-utils/src/fs.rs | 7 +- 16 files changed, 207 insertions(+), 149 deletions(-) diff --git a/crates/soar-cli/src/health.rs b/crates/soar-cli/src/health.rs index 63fd0938..308f2974 100644 --- a/crates/soar-cli/src/health.rs +++ b/crates/soar-cli/src/health.rs @@ -9,10 +9,16 @@ use soar_utils::{ fs::walk_dir, path::{desktop_dir, icons_dir}, }; -use tabled::{builder::Builder, settings::{Panel, Style, Width, peaker::PriorityMax, themes::BorderCorrection}}; +use tabled::{ + builder::Builder, + settings::{peaker::PriorityMax, themes::BorderCorrection, Panel, Style, Width}, +}; use tracing::info; -use crate::{state::AppState, utils::{icon_or, term_width, Colored, Icons}}; +use crate::{ + state::AppState, + utils::{icon_or, term_width, Colored, Icons}, +}; pub async fn display_health() -> SoarResult<()> { let path_env = env::var("PATH")?; @@ -57,7 +63,8 @@ pub async fn display_health() -> SoarResult<()> { }; builder.push_record(["Broken Symlinks".to_string(), sym_status]); - let table = builder.build() + let table = builder + .build() .with(Panel::header("System Health Check")) .with(Style::rounded()) .with(BorderCorrection {}) @@ -77,10 +84,7 @@ pub async fn display_health() -> SoarResult<()> { Colored(Yellow, &pkg.2) ); } - info!( - "Run {} to remove", - Colored(Green, "soar clean --broken") - ); + info!("Run {} to remove", Colored(Green, "soar clean --broken")); } if !broken_syms.is_empty() { diff --git a/crates/soar-cli/src/inspect.rs b/crates/soar-cli/src/inspect.rs index b3c2c1c8..4cf2878b 100644 --- a/crates/soar-cli/src/inspect.rs +++ b/crates/soar-cli/src/inspect.rs @@ -7,7 +7,10 @@ use soar_core::{ package::query::PackageQuery, SoarResult, }; -use soar_db::repository::{core::{CoreRepository, SortDirection}, metadata::MetadataRepository}; +use soar_db::repository::{ + core::{CoreRepository, SortDirection}, + metadata::MetadataRepository, +}; use soar_dl::http_client::SHARED_AGENT; use tracing::{error, info}; use ureq::http::header::CONTENT_LENGTH; @@ -32,7 +35,10 @@ impl Display for InspectType { } } -fn get_installed_path(diesel_db: &DieselDatabase, package: &Package) -> SoarResult> { +fn get_installed_path( + diesel_db: &DieselDatabase, + package: &Package, +) -> SoarResult> { let installed_pkg = diesel_db.with_conn(|conn| { CoreRepository::find_exact( conn, diff --git a/crates/soar-cli/src/install.rs b/crates/soar-cli/src/install.rs index 2f156755..79616eb2 100644 --- a/crates/soar-cli/src/install.rs +++ b/crates/soar-cli/src/install.rs @@ -23,11 +23,17 @@ use soar_core::{ }, SoarResult, }; -use soar_db::repository::{core::{CoreRepository, SortDirection}, metadata::MetadataRepository}; +use soar_db::repository::{ + core::{CoreRepository, SortDirection}, + metadata::MetadataRepository, +}; use soar_dl::types::Progress; use soar_package::integrate_package; use soar_utils::{hash::calculate_checksum, pattern::apply_sig_variants}; -use tabled::{builder::Builder, settings::{Panel, Style, themes::BorderCorrection}}; +use tabled::{ + builder::Builder, + settings::{themes::BorderCorrection, Panel, Style}, +}; use tokio::sync::Semaphore; use tracing::{error, info, warn}; @@ -221,8 +227,11 @@ fn resolve_packages( } let pkg = if repo_pkgs.len() > 1 { - &select_package_interactively(repo_pkgs, &query.name.unwrap_or(package.clone()))? - .unwrap() + &select_package_interactively( + repo_pkgs, + &query.name.unwrap_or(package.clone()), + )? + .unwrap() } else { repo_pkgs.first().unwrap() }; @@ -528,7 +537,11 @@ pub async fn perform_installation( if installed_count > 0 { builder.push_record([ format!("{} Installed", icon_or(Icons::CHECK, "+")), - format!("{}/{}", Colored(Green, installed_count), Colored(Cyan, ctx.total_packages)), + format!( + "{}/{}", + Colored(Green, installed_count), + Colored(Cyan, ctx.total_packages) + ), ]); } if failed_count > 0 { @@ -544,7 +557,8 @@ pub async fn perform_installation( ]); } - let table = builder.build() + let table = builder + .build() .with(Panel::header("Installation Summary")) .with(Style::rounded()) .with(BorderCorrection {}) diff --git a/crates/soar-cli/src/progress.rs b/crates/soar-cli/src/progress.rs index f2b9e061..74d04c06 100644 --- a/crates/soar-cli/src/progress.rs +++ b/crates/soar-cli/src/progress.rs @@ -6,7 +6,10 @@ use soar_config::display::ProgressStyle as ConfigProgressStyle; use soar_core::database::models::Package; use soar_dl::types::Progress; -use crate::{install::InstallContext, utils::{display_settings, Colored}}; +use crate::{ + install::InstallContext, + utils::{display_settings, Colored}, +}; const SPINNER_CHARS: &str = "⠋⠙⠹⠸â ŧâ ´â Ļ⠧⠇⠏"; @@ -56,7 +59,7 @@ pub fn create_spinner(message: &str) -> ProgressBar { spinner.set_style( ProgressStyle::with_template("{spinner:.cyan} {msg}") .unwrap() - .tick_chars(SPINNER_CHARS) + .tick_chars(SPINNER_CHARS), ); spinner.enable_steady_tick(std::time::Duration::from_millis(80)); } else { diff --git a/crates/soar-cli/src/state.rs b/crates/soar-cli/src/state.rs index f129850f..6aba71b6 100644 --- a/crates/soar-cli/src/state.rs +++ b/crates/soar-cli/src/state.rs @@ -1,6 +1,7 @@ use std::{ fs::{self, File}, path::Path, + sync::Arc, }; use nu_ansi_term::Color::{Blue, Green, Magenta, Red}; @@ -15,7 +16,6 @@ use soar_core::{ utils::get_nests_db_conn, SoarResult, }; -use std::sync::Arc; use soar_db::{ connection::DbConnection, migration::DbType, @@ -139,9 +139,8 @@ impl AppState { for repo in &self.inner.config.repositories { let repo_clone = repo.clone(); let etag = self.read_repo_etag(&repo_clone); - let task = tokio::task::spawn(async move { - fetch_metadata(&repo_clone, force, etag).await - }); + let task = + tokio::task::spawn(async move { fetch_metadata(&repo_clone, force, etag).await }); tasks.push((task, repo)); } @@ -199,9 +198,8 @@ impl AppState { })?; for pkg in installed_packages { - let exists = metadata_db.with_conn(|conn| { - MetadataRepository::exists_by_pkg_id(conn, &pkg.pkg_id) - })?; + let exists = metadata_db + .with_conn(|conn| MetadataRepository::exists_by_pkg_id(conn, &pkg.pkg_id))?; if !exists { let replacement = metadata_db.with_conn(|conn| { @@ -224,9 +222,8 @@ impl AppState { } } - metadata_db.with_conn(|conn| { - MetadataRepository::update_repo_metadata(conn, &repo.name, etag) - })?; + metadata_db + .with_conn(|conn| MetadataRepository::update_repo_metadata(conn, &repo.name, etag))?; Ok(()) } @@ -287,7 +284,9 @@ impl AppState { } let mut conn = DbConnection::open(&metadata_db, DbType::Metadata).ok()?; - MetadataRepository::get_repo_etag(conn.conn()).ok().flatten() + MetadataRepository::get_repo_etag(conn.conn()) + .ok() + .flatten() } /// Reads the etag from an existing nest metadata database. @@ -301,7 +300,9 @@ impl AppState { } let mut conn = DbConnection::open(&metadata_db, DbType::Metadata).ok()?; - MetadataRepository::get_repo_etag(conn.conn()).ok().flatten() + MetadataRepository::get_repo_etag(conn.conn()) + .ok() + .flatten() } /// Returns the diesel-based core database connection. diff --git a/crates/soar-cli/src/update.rs b/crates/soar-cli/src/update.rs index 8541d8ef..66258353 100644 --- a/crates/soar-cli/src/update.rs +++ b/crates/soar-cli/src/update.rs @@ -1,5 +1,6 @@ use std::sync::{atomic::Ordering, Arc}; +use nu_ansi_term::Color::{Cyan, Green, Red}; use soar_core::{ database::{ connection::DieselDatabase, @@ -9,9 +10,14 @@ use soar_core::{ package::{install::InstallTarget, query::PackageQuery, update::remove_old_versions}, SoarResult, }; -use soar_db::repository::{core::{CoreRepository, SortDirection}, metadata::MetadataRepository}; -use nu_ansi_term::Color::{Cyan, Green, Red}; -use tabled::{builder::Builder, settings::{Panel, Style, themes::BorderCorrection}}; +use soar_db::repository::{ + core::{CoreRepository, SortDirection}, + metadata::MetadataRepository, +}; +use tabled::{ + builder::Builder, + settings::{themes::BorderCorrection, Panel, Style}, +}; use tracing::{error, info, warn}; use crate::{ @@ -238,7 +244,11 @@ async fn perform_update( if updated_count > 0 { builder.push_record([ format!("{} Updated", icon_or(Icons::CHECK, "+")), - format!("{}/{}", Colored(Green, updated_count), Colored(Cyan, ctx.total_packages)), + format!( + "{}/{}", + Colored(Green, updated_count), + Colored(Cyan, ctx.total_packages) + ), ]); } if failed_count > 0 { @@ -254,7 +264,8 @@ async fn perform_update( ]); } - let table = builder.build() + let table = builder + .build() .with(Panel::header("Update Summary")) .with(Style::rounded()) .with(BorderCorrection {}) @@ -347,4 +358,3 @@ async fn spawn_update_task( drop(permit); }) } - diff --git a/crates/soar-cli/src/use.rs b/crates/soar-cli/src/use.rs index dad7efde..84d22ae7 100644 --- a/crates/soar-cli/src/use.rs +++ b/crates/soar-cli/src/use.rs @@ -4,7 +4,10 @@ use indicatif::HumanBytes; use nu_ansi_term::Color::{Blue, Cyan, Magenta, Red}; use soar_config::config::get_config; use soar_core::{database::models::Package, SoarResult}; -use soar_db::repository::{core::{CoreRepository, SortDirection}, metadata::MetadataRepository}; +use soar_db::repository::{ + core::{CoreRepository, SortDirection}, + metadata::MetadataRepository, +}; use soar_package::{formats::common::setup_portable_dir, integrate_package}; use tracing::info; @@ -82,12 +85,8 @@ pub async fn use_alternate_package(name: &str) -> SoarResult<()> { let bin_dir = get_config().get_bin_path()?; let install_dir = PathBuf::from(&selected_package.installed_path); - let _ = mangle_package_symlinks( - &install_dir, - &bin_dir, - selected_package.provides.as_deref(), - ) - .await?; + let _ = mangle_package_symlinks(&install_dir, &bin_dir, selected_package.provides.as_deref()) + .await?; let metadata_mgr = state.metadata_manager().await?; let pkg: Vec = metadata_mgr @@ -143,10 +142,18 @@ pub async fn use_alternate_package(name: &str) -> SoarResult<()> { } diesel_db.transaction(|conn| { - CoreRepository::link_by_checksum(conn, &installed_pkg.pkg_name, &installed_pkg.pkg_id, installed_pkg.checksum.as_deref()) + CoreRepository::link_by_checksum( + conn, + &installed_pkg.pkg_name, + &installed_pkg.pkg_id, + installed_pkg.checksum.as_deref(), + ) })?; - info!("Switched to {}#{}", installed_pkg.pkg_name, installed_pkg.pkg_id); + info!( + "Switched to {}#{}", + installed_pkg.pkg_name, installed_pkg.pkg_id + ); Ok(()) } diff --git a/crates/soar-cli/src/utils.rs b/crates/soar-cli/src/utils.rs index 86d9416e..19ec1c92 100644 --- a/crates/soar-cli/src/utils.rs +++ b/crates/soar-cli/src/utils.rs @@ -11,7 +11,9 @@ use std::{ use indicatif::HumanBytes; use nu_ansi_term::Color::{self, Blue, Cyan, Green, LightRed, Magenta, Red}; use serde::Serialize; -use soar_config::{config::get_config, display::DisplaySettings, repository::get_platform_repositories}; +use soar_config::{ + config::get_config, display::DisplaySettings, repository::get_platform_repositories, +}; use soar_core::{ database::models::Package, error::{ErrorContext, SoarError}, @@ -26,16 +28,16 @@ use tracing::{error, info}; pub struct Icons; impl Icons { - pub const PACKAGE: &str = "đŸ“Ļ"; + pub const ARROW: &str = "→"; + pub const BROKEN: &str = "✗"; + pub const CHECK: &str = "✓"; + pub const CROSS: &str = "✗"; pub const INSTALLED: &str = "✓"; pub const NOT_INSTALLED: &str = "○"; - pub const BROKEN: &str = "✗"; - pub const ARROW: &str = "→"; - pub const WARNING: &str = "⚠"; + pub const PACKAGE: &str = "đŸ“Ļ"; pub const SIZE: &str = "💾"; pub const VERSION: &str = "🏷"; - pub const CHECK: &str = "✓"; - pub const CROSS: &str = "✗"; + pub const WARNING: &str = "⚠"; } pub fn icon_or<'a>(icon: &'a str, fallback: &'a str) -> &'a str { diff --git a/crates/soar-core/src/error.rs b/crates/soar-core/src/error.rs index 4f28c9c8..a8866491 100644 --- a/crates/soar-core/src/error.rs +++ b/crates/soar-core/src/error.rs @@ -15,10 +15,7 @@ pub enum SoarError { Config(#[from] ConfigError), #[error("System error: {0}")] - #[diagnostic( - code(soar::system), - help("Check system permissions and resources") - )] + #[diagnostic(code(soar::system), help("Check system permissions and resources"))] Errno(#[from] nix::errno::Errno), #[error("Environment variable '{0}' not set")] @@ -41,10 +38,7 @@ pub enum SoarError { PathError(#[from] PathError), #[error("IO error while {action}")] - #[diagnostic( - code(soar::io), - help("Check file permissions and disk space") - )] + #[diagnostic(code(soar::io), help("Check file permissions and disk space"))] IoError { action: String, #[source] @@ -56,10 +50,7 @@ pub enum SoarError { SystemTimeError(#[from] std::time::SystemTimeError), #[error("TOML serialization error: {0}")] - #[diagnostic( - code(soar::toml), - help("Check your configuration syntax") - )] + #[diagnostic(code(soar::toml), help("Check your configuration syntax"))] TomlError(#[from] toml::ser::Error), #[error("Database operation failed: {0}")] @@ -142,10 +133,7 @@ pub enum SoarError { Warning(String), #[error("Regex compilation error: {0}")] - #[diagnostic( - code(soar::regex), - help("Check your regex pattern syntax") - )] + #[diagnostic(code(soar::regex), help("Check your regex pattern syntax"))] RegexError(#[from] regex::Error), } @@ -187,9 +175,11 @@ impl ErrorContext for std::io::Result { where C: FnOnce() -> String, { - self.map_err(|err| SoarError::IoError { - action: context(), - source: err, + self.map_err(|err| { + SoarError::IoError { + action: context(), + source: err, + } }) } } diff --git a/crates/soar-core/src/package/remove.rs b/crates/soar-core/src/package/remove.rs index fa59f284..33a32ec7 100644 --- a/crates/soar-core/src/package/remove.rs +++ b/crates/soar-core/src/package/remove.rs @@ -5,10 +5,7 @@ use std::{ }; use soar_config::config::get_config; -use soar_db::{ - models::types::ProvideStrategy, - repository::core::CoreRepository, -}; +use soar_db::{models::types::ProvideStrategy, repository::core::CoreRepository}; use soar_utils::{error::FileSystemResult, fs::walk_dir, path::desktop_dir}; use crate::{ @@ -24,7 +21,10 @@ pub struct PackageRemover { impl PackageRemover { pub async fn new(package: InstalledPackage, db: DieselDatabase) -> Self { - Self { package, db } + Self { + package, + db, + } } pub async fn remove(&self) -> SoarResult<()> { diff --git a/crates/soar-core/src/package/update.rs b/crates/soar-core/src/package/update.rs index 193fd5c8..a95c06ec 100644 --- a/crates/soar-core/src/package/update.rs +++ b/crates/soar-core/src/package/update.rs @@ -20,8 +20,9 @@ pub fn remove_old_versions(package: &Package, db: &DieselDatabase) -> SoarResult .. } = package; - let old_packages = - db.with_conn(|conn| CoreRepository::get_old_package_paths(conn, pkg_id, pkg_name, repo_name))?; + let old_packages = db.with_conn(|conn| { + CoreRepository::get_old_package_paths(conn, pkg_id, pkg_name, repo_name) + })?; for (_id, installed_path) in &old_packages { let path = Path::new(installed_path); diff --git a/crates/soar-db/src/connection.rs b/crates/soar-db/src/connection.rs index b907f24c..da71a778 100644 --- a/crates/soar-db/src/connection.rs +++ b/crates/soar-db/src/connection.rs @@ -7,8 +7,7 @@ //! - **Metadata databases**: One per repository, contains package metadata //! - **Nests database**: Tracks nest configurations -use std::collections::HashMap; -use std::path::Path; +use std::{collections::HashMap, path::Path}; use diesel::{sql_query, Connection, ConnectionError, RunQueryDsl, SqliteConnection}; @@ -49,7 +48,9 @@ impl DbConnection { .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; } - Ok(Self { conn }) + Ok(Self { + conn, + }) } /// Opens a database connection without running migrations. @@ -58,7 +59,9 @@ impl DbConnection { pub fn open_without_migrations>(path: P) -> Result { let path_str = path.as_ref().to_string_lossy(); let conn = SqliteConnection::establish(&path_str)?; - Ok(Self { conn }) + Ok(Self { + conn, + }) } /// Opens a metadata database and migrates JSON text columns to JSONB. @@ -75,7 +78,9 @@ impl DbConnection { migrate_json_to_jsonb(&mut conn, DbType::Metadata) .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; - Ok(Self { conn }) + Ok(Self { + conn, + }) } /// Gets a mutable reference to the underlying connection. diff --git a/crates/soar-db/src/error.rs b/crates/soar-db/src/error.rs index 27427da3..942b75ca 100644 --- a/crates/soar-db/src/error.rs +++ b/crates/soar-db/src/error.rs @@ -49,10 +49,7 @@ pub enum DbError { IntegrityError(String), #[error("IO error: {0}")] - #[diagnostic( - code(soar_db::io), - help("Check file permissions and disk space") - )] + #[diagnostic(code(soar_db::io), help("Check file permissions and disk space"))] IoError(#[from] std::io::Error), } diff --git a/crates/soar-db/src/repository/nest.rs b/crates/soar-db/src/repository/nest.rs index 5449cddf..c7b87f26 100644 --- a/crates/soar-db/src/repository/nest.rs +++ b/crates/soar-db/src/repository/nest.rs @@ -2,8 +2,10 @@ use diesel::prelude::*; -use crate::models::nest::{Nest, NewNest}; -use crate::schema::nest::nests; +use crate::{ + models::nest::{Nest, NewNest}, + schema::nest::nests, +}; /// Repository for nest operations. pub struct NestRepository; @@ -43,9 +45,7 @@ impl NestRepository { /// Inserts a new nest. pub fn insert(conn: &mut SqliteConnection, nest: &NewNest) -> QueryResult { - diesel::insert_into(nests::table) - .values(nest) - .execute(conn) + diesel::insert_into(nests::table).values(nest).execute(conn) } /// Deletes a nest by name. diff --git a/crates/soar-utils/src/error.rs b/crates/soar-utils/src/error.rs index fbb46921..0ee33ed7 100644 --- a/crates/soar-utils/src/error.rs +++ b/crates/soar-utils/src/error.rs @@ -45,10 +45,7 @@ pub enum PathError { }, #[error("Path is empty")] - #[diagnostic( - code(soar_utils::path::empty), - help("Provide a non-empty path") - )] + #[diagnostic(code(soar_utils::path::empty), help("Provide a non-empty path"))] Empty, #[error("Environment variable '{var}' not set in '{input}'")] @@ -181,24 +178,15 @@ pub enum FileSystemError { }, #[error("Path '{path}' not found")] - #[diagnostic( - code(soar_utils::fs::not_found), - help("Check if the path exists") - )] + #[diagnostic(code(soar_utils::fs::not_found), help("Check if the path exists"))] NotFound { path: PathBuf }, #[error("'{path}' is not a directory")] - #[diagnostic( - code(soar_utils::fs::not_a_dir), - help("Provide a path to a directory") - )] + #[diagnostic(code(soar_utils::fs::not_a_dir), help("Provide a path to a directory"))] NotADirectory { path: PathBuf }, #[error("'{path}' is not a file")] - #[diagnostic( - code(soar_utils::fs::not_a_file), - help("Provide a path to a file") - )] + #[diagnostic(code(soar_utils::fs::not_a_file), help("Provide a path to a file"))] NotAFile { path: PathBuf }, } @@ -225,7 +213,10 @@ pub enum IoOperation { impl IoContext { pub fn new(path: PathBuf, operation: IoOperation) -> Self { - Self { path, operation } + Self { + path, + operation, + } } pub fn read_file>(path: P) -> Self { @@ -281,47 +272,69 @@ impl IoContext { impl From<(IoContext, std::io::Error)> for FileSystemError { fn from((ctx, source): (IoContext, std::io::Error)) -> Self { match ctx.operation { - IoOperation::ReadFile => FileSystemError::ReadFile { - path: ctx.path, - source, - }, - IoOperation::WriteFile => FileSystemError::WriteFile { - path: ctx.path, - source, - }, - IoOperation::CreateFile => FileSystemError::CreateFile { - path: ctx.path, - source, - }, - IoOperation::RemoveFile => FileSystemError::RemoveFile { - path: ctx.path, - source, - }, - IoOperation::CreateDirectory => FileSystemError::CreateDirectory { - path: ctx.path, - source, - }, - IoOperation::RemoveDirectory => FileSystemError::RemoveDirectory { - path: ctx.path, - source, - }, - IoOperation::ReadDirectory => FileSystemError::ReadDirectory { - path: ctx.path, - source, - }, - IoOperation::CreateSymlink { target } => FileSystemError::CreateSymlink { - from: ctx.path, + IoOperation::ReadFile => { + FileSystemError::ReadFile { + path: ctx.path, + source, + } + } + IoOperation::WriteFile => { + FileSystemError::WriteFile { + path: ctx.path, + source, + } + } + IoOperation::CreateFile => { + FileSystemError::CreateFile { + path: ctx.path, + source, + } + } + IoOperation::RemoveFile => { + FileSystemError::RemoveFile { + path: ctx.path, + source, + } + } + IoOperation::CreateDirectory => { + FileSystemError::CreateDirectory { + path: ctx.path, + source, + } + } + IoOperation::RemoveDirectory => { + FileSystemError::RemoveDirectory { + path: ctx.path, + source, + } + } + IoOperation::ReadDirectory => { + FileSystemError::ReadDirectory { + path: ctx.path, + source, + } + } + IoOperation::CreateSymlink { target, - source, - }, - IoOperation::RemoveSymlink => FileSystemError::RemoveSymlink { - path: ctx.path, - source, - }, - IoOperation::ReadSymlink => FileSystemError::ReadSymlink { - path: ctx.path, - source, - }, + } => { + FileSystemError::CreateSymlink { + from: ctx.path, + target, + source, + } + } + IoOperation::RemoveSymlink => { + FileSystemError::RemoveSymlink { + path: ctx.path, + source, + } + } + IoOperation::ReadSymlink => { + FileSystemError::ReadSymlink { + path: ctx.path, + source, + } + } } } } diff --git a/crates/soar-utils/src/fs.rs b/crates/soar-utils/src/fs.rs index 74f438e5..1ee778d9 100644 --- a/crates/soar-utils/src/fs.rs +++ b/crates/soar-utils/src/fs.rs @@ -36,7 +36,12 @@ pub fn safe_remove>(path: P) -> FileSystemResult<()> { let metadata = match fs::symlink_metadata(path) { Ok(m) => m, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(e) => return Err(FileSystemError::RemoveFile { path: path.to_path_buf(), source: e }), + Err(e) => { + return Err(FileSystemError::RemoveFile { + path: path.to_path_buf(), + source: e, + }) + } }; let result = if metadata.is_dir() { From 603cdceeac1f2cf9e4163cb2748a245ad0df5976 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Dec 2025 22:42:31 +0545 Subject: [PATCH 06/14] add separator for distinct pkg --- crates/soar-db/src/repository/core.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/soar-db/src/repository/core.rs b/crates/soar-db/src/repository/core.rs index 01e89afa..b459dc67 100644 --- a/crates/soar-db/src/repository/core.rs +++ b/crates/soar-db/src/repository/core.rs @@ -90,6 +90,7 @@ impl CoreRepository { } /// Lists installed packages with flexible filtering. + #[allow(clippy::too_many_arguments)] pub fn list_filtered( conn: &mut SqliteConnection, repo_name: Option<&str>, @@ -246,7 +247,7 @@ impl CoreRepository { query .select(sql::( - "COUNT(DISTINCT pkg_id || pkg_name)", + "COUNT(DISTINCT pkg_id || '\x00' || pkg_name)", )) .first(conn) } From 2cd487b59bd88bbaabea4800535c26fb32d20808 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Dec 2025 22:47:50 +0545 Subject: [PATCH 07/14] upgrade dependencies --- Cargo.lock | 473 +++++++++++++++----------------- Cargo.toml | 20 +- crates/soar-cli/Cargo.toml | 12 +- crates/soar-core/Cargo.toml | 2 +- crates/soar-db/src/migration.rs | 2 +- crates/soar-package/Cargo.toml | 2 +- 6 files changed, 235 insertions(+), 276 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 979790b9..805e8b46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,9 +30,9 @@ dependencies = [ [[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", ] @@ -67,9 +67,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.20" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -82,9 +82,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.11" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" @@ -97,11 +97,11 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -190,9 +190,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "bitvec" @@ -252,9 +252,9 @@ checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytemuck" -version = "1.23.2" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" [[package]] name = "byteorder-lite" @@ -264,9 +264,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "bzip2" @@ -288,10 +288,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.33" +version = "1.2.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ee0f8803222ba5a7e2777dd72ca451868909b1ac410621b676adf07280e9b5f" +checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -299,9 +300,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -334,9 +335,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.47" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", "clap_derive", @@ -344,9 +345,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.47" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", @@ -356,9 +357,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.47" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck", "proc-macro2", @@ -368,9 +369,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "colorchoice" @@ -402,7 +403,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width 0.2.1", + "unicode-width 0.2.2", "windows-sys 0.60.2", ] @@ -606,9 +607,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", ] @@ -626,9 +627,9 @@ dependencies = [ [[package]] name = "diesel" -version = "2.3.2" +version = "2.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8496eeb328dce26ee9d9b73275d396d9bddb433fa30106cf6056dd8c3c2764c" +checksum = "e130c806dccc85428c564f2dc5a96e05b6615a27c9a28776bd7761a9af4bb552" dependencies = [ "diesel_derives", "downcast-rs", @@ -653,9 +654,9 @@ dependencies = [ [[package]] name = "diesel_migrations" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee060f709c3e3b1cadd83fcd0f61711f7a8cf493348f758d3a1c1147d70b3c97" +checksum = "745fd255645f0f1135f9ec55c7b00e0882192af9683ab4731e4bba3da82b8f9c" dependencies = [ "diesel", "migrations_internals", @@ -768,12 +769,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -812,11 +813,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + [[package]] name = "flate2" -version = "1.1.2" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", "miniz_oxide", @@ -944,19 +951,19 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasip2", ] [[package]] @@ -978,9 +985,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" @@ -999,12 +1006,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1040,9 +1046,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", @@ -1053,9 +1059,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -1066,11 +1072,10 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -1081,42 +1086,38 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", - "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -1153,9 +1154,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.8" +version = "0.25.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", "byteorder-lite", @@ -1166,9 +1167,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", "hashbrown", @@ -1176,13 +1177,13 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.0" +version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a646d946d06bedbbc4cac4c218acf4bbf2d87757a784857025f4d447e4e1cd" +checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" dependencies = [ "console", "portable-atomic", - "unicode-width 0.2.1", + "unicode-width 0.2.2", "unit-prefix", "web-time", ] @@ -1197,17 +1198,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "io-uring" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" -dependencies = [ - "bitflags", - "cfg-if", - "libc", -] - [[package]] name = "is_ci" version = "1.2.0" @@ -1216,9 +1206,9 @@ checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" @@ -1231,17 +1221,17 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" [[package]] name = "jobserver" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "libc", ] @@ -1296,15 +1286,15 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "litrs" @@ -1314,19 +1304,18 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.27" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lzma-rust2" @@ -1356,9 +1345,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.5" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memmap2" @@ -1442,22 +1431,11 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.61.2", -] - [[package]] name = "moxcms" -version = "0.7.5" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd32fa8935aeadb8a8a6b6b351e40225570a37c43de67690383d87ef170cd08" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" dependencies = [ "num-traits", "pxfm", @@ -1496,11 +1474,11 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.50.1" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1566,14 +1544,14 @@ dependencies = [ "ansitok", "bytecount", "fnv", - "unicode-width 0.2.1", + "unicode-width 0.2.2", ] [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -1581,15 +1559,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] @@ -1695,9 +1673,9 @@ checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] @@ -1756,27 +1734,27 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] [[package]] name = "pxfm" -version = "0.1.23" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55f4fedc84ed39cb7a489322318976425e42a147e2be79d8f878e2884f94e84" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" dependencies = [ "num-traits", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -1819,7 +1797,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", ] [[package]] @@ -1853,9 +1831,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.2" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", @@ -1865,9 +1843,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.10" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -1876,9 +1854,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "ring" @@ -1908,22 +1886,22 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.31" +version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "log", "once_cell", @@ -1934,29 +1912,20 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.4" +version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ "ring", "rustls-pki-types", @@ -1971,9 +1940,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" [[package]] name = "scc" @@ -2024,9 +1993,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.225" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -2034,18 +2003,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.225" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.225" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -2054,9 +2023,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.146" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "217ca874ae0207aac254aa02c957ded05585a90892cc8d87f9e5fa49669dadd8" dependencies = [ "indexmap", "itoa", @@ -2068,9 +2037,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ "serde_core", ] @@ -2110,7 +2079,7 @@ dependencies = [ "bzip2", "cbc", "crc32fast", - "getrandom 0.3.3", + "getrandom 0.3.4", "js-sys", "lzma-rust2", "ppmd-rust", @@ -2157,9 +2126,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "siphasher" @@ -2221,7 +2190,7 @@ dependencies = [ "soar-utils", "thiserror 2.0.17", "toml", - "toml_edit 0.23.7", + "toml_edit 0.23.10+spec-1.0.0", "tracing", ] @@ -2354,9 +2323,9 @@ dependencies = [ [[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" @@ -2414,9 +2383,9 @@ checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" [[package]] name = "syn" -version = "2.0.106" +version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", @@ -2479,12 +2448,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.22.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2507,7 +2476,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc" dependencies = [ "ansitok", - "unicode-width 0.2.1", + "unicode-width 0.2.2", ] [[package]] @@ -2517,7 +2486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "unicode-linebreak", - "unicode-width 0.2.1", + "unicode-width 0.2.2", ] [[package]] @@ -2571,9 +2540,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", "itoa", @@ -2586,15 +2555,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", @@ -2602,9 +2571,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -2612,24 +2581,19 @@ dependencies = [ [[package]] name = "tokio" -version = "1.47.1" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", - "io-uring", - "libc", - "mio", "pin-project-lite", - "slab", "tokio-macros", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", @@ -2638,14 +2602,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.8" +version = "0.9.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime 0.7.3", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow", @@ -2659,9 +2623,9 @@ checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] @@ -2679,12 +2643,12 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", - "toml_datetime 0.7.3", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow", @@ -2692,24 +2656,24 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[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 = [ "pin-project-lite", "tracing-attributes", @@ -2718,9 +2682,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -2729,9 +2693,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -2749,9 +2713,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -2774,9 +2738,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-linebreak" @@ -2798,9 +2762,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unit-prefix" @@ -2816,9 +2780,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.1.2" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99ba1025f18a4a3fc3e9b48c868e9beb4f24f4b4b1a325bada26bd4119f46537" +checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" dependencies = [ "base64", "cookie_store", @@ -2826,7 +2790,6 @@ dependencies = [ "log", "percent-encoding", "rustls", - "rustls-pemfile", "rustls-pki-types", "serde", "serde_json", @@ -2912,12 +2875,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] @@ -3010,9 +2973,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.2" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" dependencies = [ "rustls-pki-types", ] @@ -3240,27 +3203,24 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wyz" @@ -3292,11 +3252,10 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -3304,9 +3263,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", @@ -3357,9 +3316,9 @@ 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" dependencies = [ "zeroize_derive", ] @@ -3377,9 +3336,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", @@ -3388,9 +3347,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -3399,9 +3358,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", @@ -3418,7 +3377,7 @@ dependencies = [ "arbitrary", "constant_time_eq", "crc32fast", - "getrandom 0.3.3", + "getrandom 0.3.4", "hmac", "indexmap", "memchr", diff --git a/Cargo.toml b/Cargo.toml index 493a799c..95b2fc0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,25 +22,25 @@ categories = ["command-line-utilities"] [workspace.dependencies] compak = "0.1.0" -diesel = { version = "2.3.2", features = [ +diesel = { version = "2.3.5", features = [ "64-column-tables", "returning_clauses_for_sqlite_3_35", "serde_json", "sqlite" ] } -diesel_migrations = { version = "2.3.0", features = ["sqlite"] } +diesel_migrations = { version = "2.3.1", features = ["sqlite"] } documented = "0.9.2" fast-glob = "1.0.0" miette = { version = "7.6.0", features = ["fancy"] } percent-encoding = "2.3.2" rayon = "1.11.0" -regex = { version = "1.11.2", default-features = false, features = [ +regex = { version = "1.12.2", default-features = false, features = [ "std", "unicode-case", "unicode-perl" ] } -serde = { version = "1.0.225", features = ["derive"] } -serde_json = { version = "1.0.145", features = ["indexmap"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = { version = "1.0.146", features = ["indexmap"] } serial_test = "3.2.0" soar-cli = { path = "crates/soar-cli" } soar-config = { path = "crates/soar-config" } @@ -50,12 +50,12 @@ soar-dl = { path = "crates/soar-dl" } soar-package = { path = "crates/soar-package" } soar-registry = { path = "crates/soar-registry" } soar-utils = { path = "crates/soar-utils" } -tempfile = "3.10.1" +tempfile = "3.23.0" thiserror = "2.0.17" -toml = "0.9.8" -toml_edit = "0.23.7" -tracing = { version = "0.1.41", default-features = false } -ureq = { version = "3.1.2", features = ["json"] } +toml = "0.9.10" +toml_edit = "0.23.10" +tracing = { version = "0.1.44", default-features = false } +ureq = { version = "3.1.4", features = ["json"] } url = "2.5.7" xattr = "1.6.1" zstd = "0.13.3" diff --git a/crates/soar-cli/Cargo.toml b/crates/soar-cli/Cargo.toml index 176fc9ce..21f72237 100644 --- a/crates/soar-cli/Cargo.toml +++ b/crates/soar-cli/Cargo.toml @@ -19,11 +19,11 @@ path = "src/main.rs" self = [] [dependencies] -clap = { version = "4.5.47", features = ["cargo", "derive"] } -indicatif = "0.18.0" +clap = { version = "4.5.53", features = ["cargo", "derive"] } +indicatif = "0.18.3" miette = { workspace = true } minisign-verify = "0.2.4" -nu-ansi-term = "0.50.1" +nu-ansi-term = "0.50.3" once_cell = "1.21.3" rand = "0.9.2" rayon = { workspace = true } @@ -40,8 +40,8 @@ soar-registry = { workspace = true } soar-utils = { workspace = true } tabled = { version = "0.20", features = ["ansi"] } terminal_size = "0.4" -tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } -toml = "0.9.6" +tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "sync", "time"] } +toml = "0.9.10" tracing = { workspace = true } -tracing-subscriber = { version = "0.3.20", default-features = false, features = ["env-filter", "fmt", "json", "nu-ansi-term"] } +tracing-subscriber = { version = "0.3.22", default-features = false, features = ["env-filter", "fmt", "json", "nu-ansi-term"] } ureq = { workspace = true } diff --git a/crates/soar-core/Cargo.toml b/crates/soar-core/Cargo.toml index 55c8f2d5..06958083 100644 --- a/crates/soar-core/Cargo.toml +++ b/crates/soar-core/Cargo.toml @@ -24,6 +24,6 @@ soar-dl = { workspace = true } soar-package = { workspace = true } soar-utils = { workspace = true } thiserror = { workspace = true } -toml = "0.9.6" +toml = "0.9.10" tracing = { workspace = true } ureq = { workspace = true } diff --git a/crates/soar-db/src/migration.rs b/crates/soar-db/src/migration.rs index 332833bd..deeda28f 100644 --- a/crates/soar-db/src/migration.rs +++ b/crates/soar-db/src/migration.rs @@ -68,7 +68,7 @@ fn mark_first_pending( /// successful migration. The WHERE clause only matches rows with text-based JSON, /// so once all rows are converted to JSONB binary format, no rows will be updated. /// -/// TODO: Remove this migration in a future version (v0.9 or v1.0) once users +/// TODO: Remove this migration in a future version (v0.10 or v1.0) once users /// have had sufficient time to migrate their databases. pub fn migrate_json_to_jsonb( conn: &mut SqliteConnection, diff --git a/crates/soar-package/Cargo.toml b/crates/soar-package/Cargo.toml index 50c166aa..c48b96f5 100644 --- a/crates/soar-package/Cargo.toml +++ b/crates/soar-package/Cargo.toml @@ -11,7 +11,7 @@ keywords.workspace = true categories.workspace = true [dependencies] -image = { version = "0.25.8", default-features = false, features = ["png"] } +image = { version = "0.25.9", default-features = false, features = ["png"] } miette = { workspace = true } regex = { workspace = true } soar-config = { workspace = true } From 461a6888b90073d3cb31e3cd6b608d1cdee6fd5d Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Dec 2025 22:58:12 +0545 Subject: [PATCH 08/14] clippy --- crates/soar-cli/src/health.rs | 4 ++-- crates/soar-cli/src/install.rs | 1 + crates/soar-cli/src/list.rs | 2 +- crates/soar-cli/src/update.rs | 2 +- crates/soar-utils/src/error.rs | 4 ++++ 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/soar-cli/src/health.rs b/crates/soar-cli/src/health.rs index 308f2974..3795d3ce 100644 --- a/crates/soar-cli/src/health.rs +++ b/crates/soar-cli/src/health.rs @@ -105,7 +105,7 @@ async fn get_broken_packages() -> SoarResult> { let state = AppState::new(); let diesel_db = state.diesel_core_db()?; - let broken_packages = diesel_db.with_conn(|conn| CoreRepository::list_broken(conn))?; + let broken_packages = diesel_db.with_conn(CoreRepository::list_broken)?; Ok(broken_packages .into_iter() @@ -146,7 +146,7 @@ pub async fn remove_broken_packages() -> SoarResult<()> { let state = AppState::new(); let diesel_db = state.diesel_core_db()?.clone(); - let broken_packages = diesel_db.with_conn(|conn| CoreRepository::list_broken(conn))?; + let broken_packages = diesel_db.with_conn(CoreRepository::list_broken)?; if broken_packages.is_empty() { info!("No broken packages found."); diff --git a/crates/soar-cli/src/install.rs b/crates/soar-cli/src/install.rs index 79616eb2..2793bc36 100644 --- a/crates/soar-cli/src/install.rs +++ b/crates/soar-cli/src/install.rs @@ -72,6 +72,7 @@ pub struct InstallContext { pub no_verify: bool, } +#[allow(clippy::too_many_arguments)] pub fn create_install_context( total_packages: usize, parallel_limit: u32, diff --git a/crates/soar-cli/src/list.rs b/crates/soar-cli/src/list.rs index ea630c95..fe19c704 100644 --- a/crates/soar-cli/src/list.rs +++ b/crates/soar-cli/src/list.rs @@ -379,7 +379,7 @@ pub async fn list_packages(repo_name: Option) -> SoarResult<()> { let packages: Vec = if let Some(ref repo_name) = repo_name { metadata_mgr - .query_repo(repo_name, |conn| MetadataRepository::list_all(conn))? + .query_repo(repo_name, MetadataRepository::list_all)? .unwrap_or_default() .into_iter() .map(|p| { diff --git a/crates/soar-cli/src/update.rs b/crates/soar-cli/src/update.rs index 66258353..42031bd5 100644 --- a/crates/soar-cli/src/update.rs +++ b/crates/soar-cli/src/update.rs @@ -123,7 +123,7 @@ pub async fn update_packages( } } else { let installed_packages: Vec = diesel_db - .with_conn(|conn| CoreRepository::list_updatable(conn))? + .with_conn(CoreRepository::list_updatable)? .into_iter() .map(Into::into) .collect(); diff --git a/crates/soar-utils/src/error.rs b/crates/soar-utils/src/error.rs index 0ee33ed7..f8866471 100644 --- a/crates/soar-utils/src/error.rs +++ b/crates/soar-utils/src/error.rs @@ -367,6 +367,10 @@ pub enum UtilsError { #[error(transparent)] #[diagnostic(transparent)] FileSystem(#[from] FileSystemError), + + #[error(transparent)] + #[diagnostic(transparent)] + Hash(#[from] HashError), } pub type BytesResult = std::result::Result; From 73fad60feb55d0480a8d2a5e35c9d19c75db5bb7 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Dec 2025 23:13:43 +0545 Subject: [PATCH 09/14] correctness --- crates/soar-cli/src/state.rs | 16 +++++++++++----- crates/soar-db/src/connection.rs | 14 ++++++++++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/soar-cli/src/state.rs b/crates/soar-cli/src/state.rs index 6aba71b6..8feaa1d3 100644 --- a/crates/soar-cli/src/state.rs +++ b/crates/soar-cli/src/state.rs @@ -24,6 +24,7 @@ use soar_db::{ use soar_registry::{ fetch_metadata, fetch_nest_metadata, write_metadata_db, MetadataContent, RemotePackage, }; +use tokio::sync::OnceCell as AsyncOnceCell; use tracing::{error, info}; use crate::utils::Colored; @@ -56,7 +57,7 @@ pub struct AppState { struct AppStateInner { config: Config, diesel_core_db: OnceCell, - metadata_manager: OnceCell, + metadata_manager: AsyncOnceCell, } impl AppState { @@ -67,7 +68,7 @@ impl AppState { inner: Arc::new(AppStateInner { config, diesel_core_db: OnceCell::new(), - metadata_manager: OnceCell::new(), + metadata_manager: AsyncOnceCell::new(), }), } } @@ -314,10 +315,15 @@ impl AppState { /// Returns the metadata manager for querying package metadata across all repos. pub async fn metadata_manager(&self) -> SoarResult<&MetadataManager> { - self.init_repo_dbs(false).await?; - self.sync_nests(false).await?; self.inner .metadata_manager - .get_or_try_init(|| self.create_metadata_manager()) + .get_or_try_init(|| { + async { + self.init_repo_dbs(false).await?; + self.sync_nests(false).await?; + self.create_metadata_manager() + } + }) + .await } } diff --git a/crates/soar-db/src/connection.rs b/crates/soar-db/src/connection.rs index da71a778..926a5d3b 100644 --- a/crates/soar-db/src/connection.rs +++ b/crates/soar-db/src/connection.rs @@ -33,7 +33,6 @@ impl DbConnection { let path_str = path.as_ref().to_string_lossy(); let mut conn = SqliteConnection::establish(&path_str)?; - // WAL mode for better concurrent access sql_query("PRAGMA journal_mode = WAL;") .execute(&mut conn) .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; @@ -58,7 +57,13 @@ impl DbConnection { /// Use this when you know the database is already migrated. pub fn open_without_migrations>(path: P) -> Result { let path_str = path.as_ref().to_string_lossy(); - let conn = SqliteConnection::establish(&path_str)?; + let mut conn = SqliteConnection::establish(&path_str)?; + + // WAL mode for better concurrent access + sql_query("PRAGMA journal_mode = WAL;") + .execute(&mut conn) + .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; + Ok(Self { conn, }) @@ -74,6 +79,11 @@ impl DbConnection { let path_str = path.as_ref().to_string_lossy(); let mut conn = SqliteConnection::establish(&path_str)?; + // WAL mode for better concurrent access + sql_query("PRAGMA journal_mode = WAL;") + .execute(&mut conn) + .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; + // Migrate text JSON to JSONB binary format migrate_json_to_jsonb(&mut conn, DbType::Metadata) .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; From 7dceaa9a4ba7f2cf58a17b481fdcd31c310b0890 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Dec 2025 23:17:12 +0545 Subject: [PATCH 10/14] remove shasum --- crates/soar-cli/src/list.rs | 15 +++++---------- crates/soar-core/src/database/models.rs | 4 +--- crates/soar-registry/src/package.rs | 3 --- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/crates/soar-cli/src/list.rs b/crates/soar-cli/src/list.rs index fe19c704..e218321b 100644 --- a/crates/soar-cli/src/list.rs +++ b/crates/soar-cli/src/list.rs @@ -246,15 +246,11 @@ pub async fn query_package(query_str: String) -> SoarResult<()> { pretty_package_size(package.ghcr_size, package.size), ]); - if package.bsum.is_some() || package.shasum.is_some() { - let mut checksums = Vec::new(); - if let Some(ref cs) = package.bsum { - checksums.push(format!("{} (blake3)", Colored(Blue, cs))); - } - if let Some(ref cs) = package.shasum { - checksums.push(format!("{} (sha256)", Colored(Blue, cs))); - } - builder.push_record(["Checksums".to_string(), checksums.join("\n")]); + if let Some(ref cs) = package.bsum { + builder.push_record([ + "Checksum".to_string(), + format!("{} (blake3)", Colored(Blue, cs)), + ]); } if let Some(ref homepages) = package.homepages { @@ -348,7 +344,6 @@ pub async fn query_package(query_str: String) -> SoarResult<()> { version = package.version, version_upstream = package.version_upstream, bsum = package.bsum, - shasum = package.shasum, homepages = vec_string(package.homepages), source_urls = vec_string(package.source_urls), licenses = vec_string(package.licenses), diff --git a/crates/soar-core/src/database/models.rs b/crates/soar-core/src/database/models.rs index 96dc46e1..3413fedc 100644 --- a/crates/soar-core/src/database/models.rs +++ b/crates/soar-core/src/database/models.rs @@ -44,7 +44,6 @@ pub struct Package { pub ghcr_blob: Option, pub ghcr_url: Option, pub bsum: Option, - pub shasum: Option, pub homepages: Option>, pub notes: Option>, pub source_urls: Option>, @@ -228,8 +227,7 @@ impl From for Package { ghcr_files: None, ghcr_blob: pkg.ghcr_blob, ghcr_url: pkg.ghcr_url, - bsum: pkg.bsum.clone(), - shasum: pkg.bsum, + bsum: pkg.bsum, homepages: pkg.homepages, notes: pkg.notes, source_urls: pkg.source_urls, diff --git a/crates/soar-registry/src/package.rs b/crates/soar-registry/src/package.rs index 5ac2e7a7..a207b20a 100644 --- a/crates/soar-registry/src/package.rs +++ b/crates/soar-registry/src/package.rs @@ -146,9 +146,6 @@ pub struct RemotePackage { #[serde(default, deserialize_with = "empty_is_none")] pub bsum: Option, - #[serde(default, deserialize_with = "empty_is_none")] - pub shasum: Option, - #[serde(default, deserialize_with = "empty_is_none")] pub build_id: Option, From e4a7bee1c840442792e350abd36ecac219baccfc Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Dec 2025 23:24:21 +0545 Subject: [PATCH 11/14] remove external repos --- README.md | 7 ++--- crates/soar-config/src/config.rs | 15 +--------- crates/soar-config/src/repository.rs | 42 ---------------------------- 3 files changed, 4 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 9577c257..12c6555d 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,9 @@ wget -qO- "https://raw.githubusercontent.com/pkgforge/soar/main/install.sh" | sh | 🏆 Tier | 🤖 Host | đŸ“Ļ Repos | â„šī¸ Status | |---------|---------|---------------------------|-------------------| -| **Tier 1** | **`aarch64-Linux`** | [bincache✅](https://docs.pkgforge.dev/repositories/bincache), [pkgcache✅](https://docs.pkgforge.dev/repositories/pkgcache), [pkgforge-cargo✅](https://docs.pkgforge.dev/repositories/external/pkgforge-cargo), [pkgforge-go✅](https://docs.pkgforge.dev/repositories/external/pkgforge-go), [cargo-bins](https://docs.pkgforge.dev/repositories/external/cargo-bins), [appimage-github-io](https://docs.pkgforge.dev/repositories/external/appimage-github-io), [appimagehub](https://docs.pkgforge.dev/repositories/external/appimagehub) | Almost as many packages as `x86_64-Linux`, fully supported | -| **Tier 1** | **`x86_64-Linux`** | [bincache✅](https://docs.pkgforge.dev/repositories/bincache), [pkgcache✅](https://docs.pkgforge.dev/repositories/pkgcache), [pkgforge-cargo✅](https://docs.pkgforge.dev/repositories/external/pkgforge-cargo), [pkgforge-go✅](https://docs.pkgforge.dev/repositories/external/pkgforge-go), [cargo-bins](https://docs.pkgforge.dev/repositories/external/cargo-bins), [ivan-hc-am](https://docs.pkgforge.dev/repositories/external/ivan-hc-am), [appimage-github-io](https://docs.pkgforge.dev/repositories/external/appimage-github-io), [appimagehub](https://docs.pkgforge.dev/repositories/external/appimagehub) | Primary target & most supported | -| **Tier 2** | **`loongarch64-Linux`** | [pkgforge-cargo✅](https://docs.pkgforge.dev/repositories/external/pkgforge-cargo), [pkgforge-go✅](https://docs.pkgforge.dev/repositories/external/pkgforge-go) | Experimental & least supported | -| **Tier 2** | **`riscv64-Linux`** | [bincache✅](https://docs.pkgforge.dev/repositories/bincache), [pkgcache✅](https://docs.pkgforge.dev/repositories/pkgcache), [pkgforge-cargo✅](https://docs.pkgforge.dev/repositories/external/pkgforge-cargo), [pkgforge-go✅](https://docs.pkgforge.dev/repositories/external/pkgforge-go) | Experimental, with [gradual progress](https://github.com/pkgforge/soarpkgs/issues/198) | +| **Tier 1** | **`aarch64-Linux`** | [bincache✅](https://docs.pkgforge.dev/repositories/bincache), [pkgcache✅](https://docs.pkgforge.dev/repositories/pkgcache) | Almost as many packages as `x86_64-Linux`, fully supported | +| **Tier 1** | **`x86_64-Linux`** | [bincache✅](https://docs.pkgforge.dev/repositories/bincache), [pkgcache✅](https://docs.pkgforge.dev/repositories/pkgcache) | Primary target & most supported | +| **Tier 2** | **`riscv64-Linux`** | [bincache✅](https://docs.pkgforge.dev/repositories/bincache), [pkgcache✅](https://docs.pkgforge.dev/repositories/pkgcache) | Experimental, with [gradual progress](https://github.com/pkgforge/soarpkgs/issues/198) | ## 🤝 Contributing diff --git a/crates/soar-config/src/config.rs b/crates/soar-config/src/config.rs index 8cf2617b..4a67d685 100644 --- a/crates/soar-config/src/config.rs +++ b/crates/soar-config/src/config.rs @@ -292,9 +292,7 @@ impl Config { if repo.desktop_integration.is_none() { match repo.name.as_str() { "bincache" => repo.desktop_integration = Some(false), - "pkgcache" | "ivan-hc-am" | "appimage.github.io" => { - repo.desktop_integration = Some(true) - } + "pkgcache" => repo.desktop_integration = Some(true), _ => {} } } @@ -502,17 +500,6 @@ mod tests { assert!(config.repositories.iter().any(|r| r.name == "bincache")); } - #[test] - fn test_default_config_external_repos() { - let config = Config::default_config::<&str>(true, &[]); - - let has_external = config - .repositories - .iter() - .any(|r| r.name == "ivan-hc-am" || r.name == "appimage-github-io"); - assert!(has_external || config.repositories.is_empty()); // depends on platform - } - #[test] fn test_config_resolve_missing_default_profile() { let mut config = Config::default_config::<&str>(false, &[]); diff --git a/crates/soar-config/src/repository.rs b/crates/soar-config/src/repository.rs index dfc30829..4600c024 100644 --- a/crates/soar-config/src/repository.rs +++ b/crates/soar-config/src/repository.rs @@ -106,48 +106,6 @@ pub fn get_platform_repositories() -> Vec { is_core: true, ..DefaultRepositoryInfo::default() }, - DefaultRepositoryInfo { - name: "pkgforge-cargo", - url_template: "https://meta.pkgforge.dev/external/pkgforge-cargo/{}.sdb.zstd", - desktop_integration: Some(false), - platforms: vec![ - "aarch64-Linux", - "loongarch64-Linux", - "riscv64-Linux", - "x86_64-Linux", - ], - is_core: true, - ..DefaultRepositoryInfo::default() - }, - DefaultRepositoryInfo { - name: "pkgforge-go", - url_template: "https://meta.pkgforge.dev/external/pkgforge-go/{}.sdb.zstd", - desktop_integration: Some(false), - platforms: vec![ - "aarch64-Linux", - "loongarch64-Linux", - "riscv64-Linux", - "x86_64-Linux", - ], - is_core: true, - ..DefaultRepositoryInfo::default() - }, - DefaultRepositoryInfo { - name: "ivan-hc-am", - url_template: "https://meta.pkgforge.dev/external/am/{}.sdb.zstd", - desktop_integration: Some(true), - platforms: vec!["x86_64-Linux"], - is_core: false, - ..DefaultRepositoryInfo::default() - }, - DefaultRepositoryInfo { - name: "appimage-github-io", - url_template: "https://meta.pkgforge.dev/external/appimage.github.io/{}.sdb.zstd", - desktop_integration: Some(true), - platforms: vec!["aarch64-Linux", "x86_64-Linux"], - is_core: false, - ..DefaultRepositoryInfo::default() - }, ] } From 66a7512e76599250ec038b4e7053e5eb11ee2bbd Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Dec 2025 23:34:42 +0545 Subject: [PATCH 12/14] update readme --- README.md | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 12c6555d..a85c99ac 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,16 @@ Supports Static Binaries, AppImages, and other Portable formats on any *Unix-based distro

+## đŸ“Ļ What is Soar? + +Soar is a **package manager** - it doesn't build or host packages itself. Instead, it consumes package metadata from repositories and handles downloading, installing, and managing packages on your system. + +**How it works:** +- **Repositories** (like [pkgforge](https://docs.pkgforge.dev/repositories)) build and host packages, providing metadata in a [standard format](https://docs.pkgforge.dev/repositories/bincache/metadata) +- **Soar** fetches this metadata, lets you search/install packages, and manages your local installations +- **You** can use the default pkgforge repositories, add third-party ones, or even create your own + +This separation means Soar works with any compatible repository - it's not tied to a specific package source. ## đŸĒ„ Quickstart @@ -37,10 +47,10 @@ > - The [install script](https://github.com/pkgforge/soar/blob/main/install.sh) does this & more automatically for you. ```bash -❯ cURL +# cURL curl -fsSL "https://raw.githubusercontent.com/pkgforge/soar/main/install.sh" | sh -❯ wget +# wget wget -qO- "https://raw.githubusercontent.com/pkgforge/soar/main/install.sh" | sh ``` @@ -48,26 +58,28 @@ wget -qO- "https://raw.githubusercontent.com/pkgforge/soar/main/install.sh" | sh > - Please read & verify what's inside the script before running it > - The script is also available through https://soar.qaidvoid.dev/install.sh & https://soar.pkgforge.dev/install.sh > - Additionally, if you want to customize your installation, please read the docs @ https://soar.qaidvoid.dev/installation.html -> - For, extra Guide & Information on infra backends & adding more repos: https://docs.pkgforge.dev -> - Next, Check [Configuration](https://soar.qaidvoid.dev/configuration) & [Usage](https://soar.qaidvoid.dev/package-management) +> - For extra guide & information on infra backends & adding more repos: https://docs.pkgforge.dev +> - Next, check [Configuration](https://soar.qaidvoid.dev/configuration) & [Usage](https://soar.qaidvoid.dev/package-management) ## 🌟 Key Features | Feature | Description | |:--:|:--| -| **Universal** | Single binary, no dependencies, works on any Unix-like system with no superuser privileges. | -| **Portable Formats** | Install static [static binaries](https://docs.pkgforge.dev/formats/binaries/static), [AppImages](https://docs.pkgforge.dev/formats/packages/appimage), and other [self-contained archives](https://docs.pkgforge.dev/formats/packages/archive) with ease. | -| **System Integration** | Automatically adds desktop entries and system integration for a native feel. | -| **Flexible Repository System** | Use [official](https://docs.pkgforge.dev/repositories), or [custom](https://soar.qaidvoid.dev/configuration#custom-repository-support) repositories with simple metadata. No special build format is needed. | -| **Security First** | Enforces security through checksums and signing verification for package installations. | -| **Fast Package Operations** | Efficient package searching, installation, and management with minimal overhead. | +| **Universal** | Single binary, no dependencies, works on any Unix-like system without superuser privileges. | +| **Portable Formats** | Install [static binaries](https://docs.pkgforge.dev/formats/binaries/static), [AppImages](https://docs.pkgforge.dev/formats/packages/appimage), and other [self-contained archives](https://docs.pkgforge.dev/formats/packages/archive) with ease. | +| **System Integration** | Automatically adds desktop entries and icons for a native feel. | +| **Repository Agnostic** | Works with any repository that provides compatible metadata. Use [official pkgforge repos](https://docs.pkgforge.dev/repositories), third-party sources, or [create your own](https://soar.qaidvoid.dev/configuration#custom-repository-support). | +| **Security First** | Enforces security through checksums and signature verification for package installations. | +| **Fast & Efficient** | Minimal overhead with parallel downloads and efficient package operations. | + +## 📀 Default Repositories -### 📀 Default Hosts +Soar comes pre-configured with `pkgforge` repositories. These are the default package sources, but you can add or replace them with any compatible repository. > **Note:** _✅ --> Enabled by Default_ -| 🏆 Tier | 🤖 Host | đŸ“Ļ Repos | â„šī¸ Status | +| 🏆 Tier | 🤖 Architecture | đŸ“Ļ Repositories | â„šī¸ Status | |---------|---------|---------------------------|-------------------| | **Tier 1** | **`aarch64-Linux`** | [bincache✅](https://docs.pkgforge.dev/repositories/bincache), [pkgcache✅](https://docs.pkgforge.dev/repositories/pkgcache) | Almost as many packages as `x86_64-Linux`, fully supported | | **Tier 1** | **`x86_64-Linux`** | [bincache✅](https://docs.pkgforge.dev/repositories/bincache), [pkgcache✅](https://docs.pkgforge.dev/repositories/pkgcache) | Primary target & most supported | From 1a80796116013febe22380fb1bf0ff9d16e18c05 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Dec 2025 23:43:33 +0545 Subject: [PATCH 13/14] remove unnecessary fields --- README.md | 2 -- crates/soar-core/src/database/models.rs | 14 -------------- crates/soar-registry/src/package.rs | 21 --------------------- 3 files changed, 37 deletions(-) diff --git a/README.md b/README.md index a85c99ac..173c7788 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,6 @@ [doc-url]: https://soar.qaidvoid.dev [license-shield]: https://img.shields.io/github/license/pkgforge/soar.svg [license-url]: https://github.com/pkgforge/soar/blob/main/LICENSE -[packages-shield]: https://img.shields.io/badge/dynamic/json?url=https://raw.githubusercontent.com/pkgforge/metadata/refs/heads/main/TOTAL_INSTALLABLE.json&query=$[6].total&label=packages&labelColor=grey&style=flat&link=https://pkgs.pkgforge.dev -[packages-url]: https://pkgs.pkgforge.dev [![Crates.io][crates-shield]][crates-url] [![Discord][discord-shield]][discord-url] diff --git a/crates/soar-core/src/database/models.rs b/crates/soar-core/src/database/models.rs index 3413fedc..4cbf7a00 100644 --- a/crates/soar-core/src/database/models.rs +++ b/crates/soar-core/src/database/models.rs @@ -62,17 +62,10 @@ pub struct Package { pub repology: Option>, pub maintainers: Option>, pub replaces: Option>, - pub bundle: bool, - pub bundle_type: Option, pub soar_syms: bool, pub deprecated: bool, pub desktop_integration: Option, - pub external: Option, - pub installable: Option, pub portable: Option, - pub trusted: Option, - pub version_latest: Option, - pub version_outdated: Option, pub recurse_provides: Option, } @@ -246,17 +239,10 @@ impl From for Package { repology: None, maintainers: None, replaces: pkg.replaces, - bundle: false, - bundle_type: None, soar_syms: pkg.soar_syms, deprecated: false, desktop_integration: pkg.desktop_integration, - external: None, - installable: None, portable: pkg.portable, - trusted: None, - version_latest: None, - version_outdated: None, recurse_provides: pkg.recurse_provides, } } diff --git a/crates/soar-registry/src/package.rs b/crates/soar-registry/src/package.rs index a207b20a..9366c633 100644 --- a/crates/soar-registry/src/package.rs +++ b/crates/soar-registry/src/package.rs @@ -178,12 +178,6 @@ pub struct RemotePackage { #[serde(default, deserialize_with = "empty_is_none")] pub app_id: Option, - #[serde(default, deserialize_with = "flexible_bool")] - pub bundle: Option, - - #[serde(default, deserialize_with = "empty_is_none")] - pub bundle_type: Option, - #[serde(default, deserialize_with = "flexible_bool")] pub soar_syms: Option, @@ -193,27 +187,12 @@ pub struct RemotePackage { #[serde(default, deserialize_with = "flexible_bool")] pub desktop_integration: Option, - #[serde(default, deserialize_with = "flexible_bool")] - pub external: Option, - - #[serde(default, deserialize_with = "flexible_bool")] - pub installable: Option, - #[serde(default, deserialize_with = "flexible_bool")] pub portable: Option, #[serde(default, deserialize_with = "flexible_bool")] pub recurse_provides: Option, - #[serde(default, deserialize_with = "flexible_bool")] - pub trusted: Option, - - #[serde(default, deserialize_with = "empty_is_none")] - pub version_latest: Option, - - #[serde(default, deserialize_with = "flexible_bool")] - pub version_outdated: Option, - pub repology: Option>, pub snapshots: Option>, pub replaces: Option>, From b2317b94c565f69d980196e535583c0bc1c3c9e0 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Dec 2025 23:46:38 +0545 Subject: [PATCH 14/14] fix tests --- crates/soar-registry/src/package.rs | 4 +--- crates/soar-utils/src/fs.rs | 5 ++++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/soar-registry/src/package.rs b/crates/soar-registry/src/package.rs index 9366c633..fb4ab980 100644 --- a/crates/soar-registry/src/package.rs +++ b/crates/soar-registry/src/package.rs @@ -226,12 +226,10 @@ mod tests { "description": "test", "version": "1.0.0", "download_url": "https://example.com", - "disabled": "true", - "bundle": false + "disabled": "true" }"#; let pkg: RemotePackage = serde_json::from_str(json).unwrap(); assert_eq!(pkg.disabled, Some(true)); - assert_eq!(pkg.bundle, Some(false)); } } diff --git a/crates/soar-utils/src/fs.rs b/crates/soar-utils/src/fs.rs index 1ee778d9..0a4e2bbd 100644 --- a/crates/soar-utils/src/fs.rs +++ b/crates/soar-utils/src/fs.rs @@ -523,7 +523,10 @@ mod tests { }) .unwrap(); - assert_eq!(results, vec![file, nested_file]); + results.sort(); + let mut expected = vec![file, nested_file]; + expected.sort(); + assert_eq!(results, expected); } #[test]