From 277d0437b5fbf2f9a99d8a10a3fee7458fdc7a4c Mon Sep 17 00:00:00 2001 From: sanchxt Date: Mon, 12 Jan 2026 01:42:50 +0530 Subject: [PATCH 1/6] feat: add self-update command with automatic migrations --- Cargo.lock | 528 +++++++++++++++++- Cargo.toml | 9 + crates/yoop-cli/Cargo.toml | 9 +- crates/yoop-cli/src/commands/clipboard.rs | 2 + crates/yoop-cli/src/commands/config.rs | 64 +++ crates/yoop-cli/src/commands/mod.rs | 78 +++ crates/yoop-cli/src/commands/receive.rs | 2 + crates/yoop-cli/src/commands/share.rs | 2 + crates/yoop-cli/src/commands/update.rs | 388 +++++++++++++ crates/yoop-cli/src/main.rs | 2 + crates/yoop-core/Cargo.toml | 8 +- crates/yoop-core/src/config/mod.rs | 88 +++ crates/yoop-core/src/error.rs | 74 +++ crates/yoop-core/src/lib.rs | 6 + crates/yoop-core/src/migration/backup.rs | 392 +++++++++++++ .../yoop-core/src/migration/migrations/mod.rs | 6 + .../src/migration/migrations/v0_1_to_v0_2.rs | 189 +++++++ crates/yoop-core/src/migration/mod.rs | 353 ++++++++++++ crates/yoop-core/src/migration/version.rs | 202 +++++++ crates/yoop-core/src/update/mod.rs | 200 +++++++ .../yoop-core/src/update/package_manager.rs | 271 +++++++++ crates/yoop-core/src/update/version_check.rs | 131 +++++ crates/yoop-core/tests/update_tests.rs | 282 ++++++++++ 23 files changed, 3275 insertions(+), 11 deletions(-) create mode 100644 crates/yoop-cli/src/commands/update.rs create mode 100644 crates/yoop-core/src/migration/backup.rs create mode 100644 crates/yoop-core/src/migration/migrations/mod.rs create mode 100644 crates/yoop-core/src/migration/migrations/v0_1_to_v0_2.rs create mode 100644 crates/yoop-core/src/migration/mod.rs create mode 100644 crates/yoop-core/src/migration/version.rs create mode 100644 crates/yoop-core/src/update/mod.rs create mode 100644 crates/yoop-core/src/update/package_manager.rs create mode 100644 crates/yoop-core/src/update/version_check.rs create mode 100644 crates/yoop-core/tests/update_tests.rs diff --git a/Cargo.lock b/Cargo.lock index d4374e1..d7dfa18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -725,7 +725,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -1000,9 +1000,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1152,6 +1154,24 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -1160,14 +1180,22 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ + "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2 0.6.1", "tokio", "tower-service", + "tracing", ] [[package]] @@ -1194,12 +1222,114 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "if-addrs" version = "0.13.4" @@ -1269,6 +1399,22 @@ dependencies = [ "syn", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1344,6 +1490,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "lock_api" version = "0.4.14" @@ -1368,6 +1520,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1736,6 +1894,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1793,6 +1960,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.1", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.1", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.42" @@ -1815,8 +2037,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -1826,7 +2058,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -1838,6 +2080,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "ratatui" version = "0.29.0" @@ -1909,6 +2160,44 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "ring" version = "0.17.14" @@ -1958,6 +2247,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2002,6 +2297,7 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2023,6 +2319,7 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" dependencies = [ + "web-time", "zeroize", ] @@ -2070,6 +2367,10 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -2208,7 +2509,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2268,6 +2569,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -2324,6 +2631,20 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "tempfile" @@ -2430,6 +2751,31 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.48.0" @@ -2564,12 +2910,14 @@ dependencies = [ "http-body-util", "http-range-header", "httpdate", + "iri-string", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", @@ -2660,6 +3008,12 @@ dependencies = [ "petgraph", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.19.0" @@ -2713,6 +3067,24 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2753,6 +3125,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2781,6 +3162,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.106" @@ -2883,6 +3277,35 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -3243,6 +3666,12 @@ dependencies = [ "wayland-protocols-wlr", ] +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + [[package]] name = "x11rb" version = "0.13.2" @@ -3267,7 +3696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "serde", "zeroize", ] @@ -3287,12 +3716,36 @@ dependencies = [ "time", ] +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "yoop" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "arboard", + "chrono", "clap", "clap_complete", "crossterm", @@ -3309,7 +3762,7 @@ dependencies = [ [[package]] name = "yoop-core" -version = "0.1.2" +version = "0.1.3" dependencies = [ "arboard", "async-stream", @@ -3328,11 +3781,13 @@ dependencies = [ "mime_guess", "nix", "qrcode", - "rand", + "rand 0.8.5", "rcgen", + "reqwest", "rust-embed", "rustls", "rustls-pemfile", + "semver", "serde", "serde_json", "sha2", @@ -3344,6 +3799,7 @@ dependencies = [ "tokio-stream", "tokio-util", "toml", + "toml_edit", "tower", "tower-http", "tracing", @@ -3374,6 +3830,27 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" @@ -3394,6 +3871,39 @@ dependencies = [ "syn", ] +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zip" version = "2.4.2" diff --git a/Cargo.toml b/Cargo.toml index 8e4788f..b2031e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,6 +110,15 @@ zip = { version = "2", default-features = false, features = ["deflate"] } # POSIX utilities (for Linux clipboard holder) nix = { version = "0.29", features = ["process", "signal"] } +# HTTP client for version checking +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } + +# Semantic versioning +semver = { version = "1", features = ["serde"] } + +# TOML editing (preserve formatting during migrations) +toml_edit = "0.22" + # FFI (for future mobile bindings) # uniffi = "0.28" diff --git a/crates/yoop-cli/Cargo.toml b/crates/yoop-cli/Cargo.toml index c62b01e..8746bcc 100644 --- a/crates/yoop-cli/Cargo.toml +++ b/crates/yoop-cli/Cargo.toml @@ -15,7 +15,7 @@ name = "yoop" path = "src/main.rs" [dependencies] -yoop-core = { path = "../yoop-core", features = ["full"] } +yoop-core = { path = "../yoop-core", features = ["mdns", "web"] } # CLI clap = { workspace = true } @@ -41,6 +41,9 @@ serde_json = { workspace = true } # UUID uuid = { workspace = true } +# Time +chrono = { workspace = true } + # Clipboard (for internal holder command) arboard = { workspace = true } image = { workspace = true } @@ -48,5 +51,9 @@ image = { workspace = true } [dev-dependencies] tempfile = { workspace = true } +[features] +default = ["update"] +update = ["yoop-core/update"] + [lints] workspace = true diff --git a/crates/yoop-cli/src/commands/clipboard.rs b/crates/yoop-cli/src/commands/clipboard.rs index 1fcbc83..b7c25a7 100644 --- a/crates/yoop-cli/src/commands/clipboard.rs +++ b/crates/yoop-cli/src/commands/clipboard.rs @@ -29,6 +29,8 @@ fn create_transfer_config(global_config: &Config) -> TransferConfig { /// Run the clipboard command. pub async fn run(args: ClipboardArgs) -> Result<()> { + super::spawn_update_check(); + match args.action { ClipboardAction::Share(share_args) => run_share(share_args, args.quiet, args.json).await, ClipboardAction::Receive(recv_args) => run_receive(recv_args, args.quiet, args.json).await, diff --git a/crates/yoop-cli/src/commands/config.rs b/crates/yoop-cli/src/commands/config.rs index d058f8f..defd0e3 100644 --- a/crates/yoop-cli/src/commands/config.rs +++ b/crates/yoop-cli/src/commands/config.rs @@ -139,6 +139,23 @@ pub async fn run(args: ConfigArgs) -> Result<()> { println!(" notifications = {}", config.ui.notifications); println!(" sound = {}", config.ui.sound); println!(); + + // [update] + println!("[update]"); + println!(" auto_check = {}", config.update.auto_check); + println!( + " check_interval = \"{}s\"", + config.update.check_interval.as_secs() + ); + println!( + " package_manager = {}", + config.update.package_manager.map_or_else( + || "auto".to_string(), + |pm| format!("{:?}", pm).to_lowercase() + ) + ); + println!(" notify = {}", config.update.notify); + println!(); } ConfigAction::List => { @@ -198,6 +215,14 @@ pub async fn run(args: ConfigArgs) -> Result<()> { println!(" ui.notifications Enable notifications (true/false)"); println!(" ui.sound Play sound on complete (true/false)"); println!(); + println!("[update]"); + println!(" update.auto_check Enable automatic update checks (true/false)"); + println!(" update.check_interval Interval between checks (e.g., 24h, 7d)"); + println!( + " update.package_manager Preferred package manager (npm, pnpm, yarn, bun, auto)" + ); + println!(" update.notify Show update notifications (true/false)"); + println!(); } ConfigAction::Path => { @@ -285,6 +310,15 @@ fn get_config_value(config: &yoop_core::config::Config, key: &str) -> Option Some(config.ui.notifications.to_string()), "ui.sound" => Some(config.ui.sound.to_string()), + // update + "update.auto_check" => Some(config.update.auto_check.to_string()), + "update.check_interval" => Some(format!("{}s", config.update.check_interval.as_secs())), + "update.package_manager" => Some(config.update.package_manager.map_or_else( + || "auto".to_string(), + |pm| format!("{:?}", pm).to_lowercase(), + )), + "update.notify" => Some(config.update.notify.to_string()), + _ => None, } } @@ -481,6 +515,36 @@ fn set_config_value( Ok(true) } + // update + "update.auto_check" => { + config.update.auto_check = value.parse()?; + Ok(true) + } + "update.check_interval" => { + config.update.check_interval = parse_duration(value)?; + Ok(true) + } + "update.package_manager" => { + if value == "auto" || value.is_empty() { + config.update.package_manager = None; + } else { + config.update.package_manager = Some(match value.to_lowercase().as_str() { + "npm" => yoop_core::config::PackageManagerKind::Npm, + "pnpm" => yoop_core::config::PackageManagerKind::Pnpm, + "yarn" => yoop_core::config::PackageManagerKind::Yarn, + "bun" => yoop_core::config::PackageManagerKind::Bun, + _ => { + anyhow::bail!("Invalid package manager. Use: npm, pnpm, yarn, bun, or auto") + } + }); + } + Ok(true) + } + "update.notify" => { + config.update.notify = value.parse()?; + Ok(true) + } + _ => Ok(false), } } diff --git a/crates/yoop-cli/src/commands/mod.rs b/crates/yoop-cli/src/commands/mod.rs index 12add76..bb066d3 100644 --- a/crates/yoop-cli/src/commands/mod.rs +++ b/crates/yoop-cli/src/commands/mod.rs @@ -11,6 +11,49 @@ pub fn load_config() -> yoop_core::config::Config { yoop_core::config::Config::load().unwrap_or_default() } +/// Spawn a background task to check for updates. +/// +/// This function spawns a non-blocking background task that: +/// - Checks if auto_check and notify are enabled in config +/// - Respects the check_interval to avoid excessive API calls +/// - Displays a message to stderr if an update is available +/// - Silently ignores errors and no-update cases +/// +/// This should be called by long-running commands (share, receive, clipboard). +#[cfg(feature = "update")] +pub fn spawn_update_check() { + use yoop_core::config::Config; + use yoop_core::update::version_check::VersionChecker; + + tokio::spawn(async move { + let Ok(mut config) = Config::load() else { + return; + }; + + if !config.update.auto_check || !config.update.notify { + return; + } + + let checker = VersionChecker::new(); + + match checker.check_with_cache(&mut config).await { + Ok(Some(status)) if status.update_available => { + eprintln!(); + eprintln!( + " Update available: {} -> {}", + status.current_version, status.latest_version + ); + eprintln!(" Run 'yoop update' to upgrade."); + eprintln!(); + } + _ => {} + } + }); +} + +#[cfg(not(feature = "update"))] +pub fn spawn_update_check() {} + pub mod clipboard; pub mod completions; pub mod config; @@ -22,6 +65,8 @@ pub mod scan; pub mod send; pub mod share; pub mod trust; +#[cfg(feature = "update")] +pub mod update; pub mod web; /// Yoop - Cross-platform local network file sharing @@ -71,6 +116,10 @@ pub enum Command { /// Generate shell completions Completions(CompletionsArgs), + /// Check for and install updates + #[cfg(feature = "update")] + Update(UpdateArgs), + /// Internal: hold clipboard content (not user-facing, used by spawn) #[command(hide = true)] InternalClipboardHold(InternalClipboardHoldArgs), @@ -415,3 +464,32 @@ pub struct InternalClipboardHoldArgs { #[arg(long, default_value = "300")] pub timeout: u64, } + +/// Arguments for the update command +#[cfg(feature = "update")] +#[derive(Parser)] +pub struct UpdateArgs { + /// Only check for updates, don't install + #[arg(long)] + pub check: bool, + + /// Rollback to previous version (restores backup) + #[arg(long)] + pub rollback: bool, + + /// Force update even if already on latest version + #[arg(long)] + pub force: bool, + + /// Specify package manager: npm, pnpm, yarn, or bun + #[arg(long)] + pub package_manager: Option, + + /// Output in JSON format + #[arg(long)] + pub json: bool, + + /// Minimal output + #[arg(short, long)] + pub quiet: bool, +} diff --git a/crates/yoop-cli/src/commands/receive.rs b/crates/yoop-cli/src/commands/receive.rs index 35b0761..eb38ee7 100644 --- a/crates/yoop-cli/src/commands/receive.rs +++ b/crates/yoop-cli/src/commands/receive.rs @@ -26,6 +26,8 @@ use super::ReceiveArgs; pub async fn run(args: ReceiveArgs) -> Result<()> { let global_config = super::load_config(); + super::spawn_update_check(); + let code = yoop_core::code::ShareCode::parse(&args.code)?; if !args.quiet && !args.json { diff --git a/crates/yoop-cli/src/commands/share.rs b/crates/yoop-cli/src/commands/share.rs index b1ba46e..f2c64f1 100644 --- a/crates/yoop-cli/src/commands/share.rs +++ b/crates/yoop-cli/src/commands/share.rs @@ -24,6 +24,8 @@ use crate::ui::{format_remaining, parse_duration, CodeBox}; pub async fn run(args: ShareArgs) -> Result<()> { let global_config = super::load_config(); + super::spawn_update_check(); + let compress = args.compress || matches!(global_config.transfer.compression, CompressionMode::Always); diff --git a/crates/yoop-cli/src/commands/update.rs b/crates/yoop-cli/src/commands/update.rs new file mode 100644 index 0000000..78f9594 --- /dev/null +++ b/crates/yoop-cli/src/commands/update.rs @@ -0,0 +1,388 @@ +//! Update command implementation. + +use anyhow::Result; +use serde_json::json; + +use super::UpdateArgs; +use yoop_core::config::Config; +use yoop_core::error::Error as YoopError; +use yoop_core::migration::MigrationManager; +use yoop_core::update::package_manager::PackageManager; +use yoop_core::update::version_check::VersionChecker; +use yoop_core::update::SchemaVersion; + +#[allow(clippy::too_many_lines)] +pub async fn run(args: UpdateArgs) -> Result<()> { + if args.rollback { + return handle_rollback(args).await; + } + + let mut config = Config::load()?; + + let checker = VersionChecker::new(); + let status = checker + .check() + .await + .inspect_err(|e| handle_error(&YoopError::UpdateCheckFailed(e.to_string())))?; + + if args.check { + return handle_check(&status, &args); + } + + if !status.update_available && !args.force { + if args.json { + let output = json!({ + "current_version": status.current_version.to_string(), + "latest_version": status.latest_version.to_string(), + "update_available": false, + "message": "Already on the latest version", + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else if !args.quiet { + println!("Already on the latest version ({})", status.current_version); + } + return Ok(()); + } + + if !args.quiet { + println!(); + println!("Yoop Update"); + println!("{}", "═".repeat(60)); + println!(); + println!("Checking for updates..."); + println!(" Current: {}", status.current_version); + println!(" Latest: {}", status.latest_version); + println!(); + } + + if args.package_manager.is_some() { + let pm_str = args.package_manager.as_ref().unwrap(); + let pm_kind = match pm_str.to_lowercase().as_str() { + "npm" => yoop_core::config::PackageManagerKind::Npm, + "pnpm" => yoop_core::config::PackageManagerKind::Pnpm, + "yarn" => yoop_core::config::PackageManagerKind::Yarn, + "bun" => yoop_core::config::PackageManagerKind::Bun, + _ => anyhow::bail!("Invalid package manager. Use: npm, pnpm, yarn, or bun"), + }; + config.update.package_manager = Some(pm_kind); + } + + let pm = PackageManager::detect(&config.update).map_err(|e| { + if let YoopError::Internal(ref msg) = e { + if msg.contains("not found in PATH") { + handle_error(&YoopError::PackageManagerNotFound("npm".to_string())); + } + } + e + })?; + + if !args.quiet { + println!("Creating backup..."); + } + + let data_dir = Config::config_path() + .parent() + .ok_or_else(|| anyhow::anyhow!("unable to determine config directory"))? + .to_path_buf(); + + let migration_manager = MigrationManager::new(data_dir.clone()); + + let backup_manager = yoop_core::migration::BackupManager::new(data_dir); + let backup_id = backup_manager + .create_backup(&status.current_version.to_string()) + .inspect_err(|e| handle_error(&YoopError::BackupFailed(e.to_string())))?; + + if !args.quiet { + println!(" Backup ID: {backup_id}"); + println!(); + } + + let current_schema = SchemaVersion::parse(&status.current_version.to_string())?; + let target_schema = SchemaVersion::parse(&status.latest_version.to_string())?; + + let pending_migrations = migration_manager.get_pending(¤t_schema, &target_schema); + + if !pending_migrations.is_empty() { + if !args.quiet { + println!("Running migrations..."); + } + + migration_manager + .run(¤t_schema, &target_schema, false) + .inspect_err(|e| { + handle_error(&YoopError::MigrationFailed { + from: current_schema.to_string(), + to: target_schema.to_string(), + reason: e.to_string(), + }); + })?; + + if !args.quiet { + for migration in pending_migrations { + println!( + " ✓ {} → {}: {}", + migration.from_version(), + migration.to_version(), + migration.description() + ); + } + println!(); + } + } + + if !args.quiet { + println!("Updating via {}...", pm); + } + + let cmd_parts = pm.update_command("yoop", None); + let mut cmd = std::process::Command::new(&cmd_parts[0]); + for arg in &cmd_parts[1..] { + cmd.arg(arg); + } + + if !args.quiet { + println!(" $ {}", cmd_parts.join(" ")); + println!(); + } + + let output = cmd + .output() + .inspect_err(|e| handle_error(&YoopError::UpdateCommandFailed(e.to_string())))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + handle_error(&YoopError::UpdateCommandFailed(stderr.to_string())); + anyhow::bail!("Update command failed: {}", stderr); + } + + if !args.quiet { + println!("Verifying installation..."); + } + + let verify = std::process::Command::new("yoop") + .arg("--version") + .output()?; + + if verify.status.success() { + let version_output = String::from_utf8_lossy(&verify.stdout); + let new_version = version_output.trim().trim_start_matches("yoop "); + + if !args.quiet { + println!(" ✓ yoop {new_version} installed successfully"); + println!(); + println!("Update complete!"); + println!(); + } else if args.json { + let output = json!({ + "success": true, + "previous_version": status.current_version.to_string(), + "new_version": new_version, + "backup_id": backup_id, + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } else { + handle_error(&YoopError::UpdateCommandFailed( + "verification failed".to_string(), + )); + anyhow::bail!("Failed to verify installation"); + } + + Ok(()) +} + +#[allow(clippy::too_many_lines)] +async fn handle_rollback(args: UpdateArgs) -> Result<()> { + if !args.quiet { + println!(); + println!("Yoop Rollback"); + println!("{}", "═".repeat(60)); + println!(); + } + + let data_dir = Config::config_path() + .parent() + .ok_or_else(|| anyhow::anyhow!("unable to determine config directory"))? + .to_path_buf(); + + let migration_manager = MigrationManager::new(data_dir); + + let backups = migration_manager.list_backups().inspect_err(|e| { + handle_error(&YoopError::RollbackFailed(format!( + "failed to list backups: {e}" + ))); + })?; + + if backups.is_empty() { + handle_error(&YoopError::NoBackupAvailable); + anyhow::bail!("No backup available for rollback"); + } + + if !args.quiet { + println!("Available backups:"); + for (i, backup) in backups.iter().enumerate() { + let time_ago = format_time_ago(backup.timestamp); + println!( + " {}. {} ({}, {})", + i + 1, + backup.app_version, + backup.timestamp.format("%Y-%m-%d %H:%M"), + time_ago + ); + } + println!(); + } + + let latest_backup = migration_manager + .latest_backup() + .inspect_err(|e| { + handle_error(&YoopError::RollbackFailed(format!( + "failed to get latest backup: {e}" + ))); + })? + .ok_or_else(|| { + handle_error(&YoopError::NoBackupAvailable); + anyhow::anyhow!("No backup available") + })?; + + if !args.quiet { + println!("Restoring from backup {}...", latest_backup.id); + } + + migration_manager + .rollback(&latest_backup.id) + .inspect_err(|e| handle_error(&YoopError::RollbackFailed(e.to_string())))?; + + if !args.quiet { + for file in &latest_backup.files { + println!(" ✓ {file}"); + } + println!(); + } + + let target_version = &latest_backup.app_version.to_string(); + + let config = Config::load()?; + let pm = PackageManager::detect(&config.update)?; + + if !args.quiet { + println!("Installing yoop@{target_version}..."); + } + + let cmd_parts = pm.install_command("yoop", target_version); + let mut cmd = std::process::Command::new(&cmd_parts[0]); + for arg in &cmd_parts[1..] { + cmd.arg(arg); + } + + if !args.quiet { + println!(" $ {}", cmd_parts.join(" ")); + println!(); + } + + let output = cmd.output().inspect_err(|e| { + handle_error(&YoopError::RollbackFailed(format!( + "failed to install previous version: {e}" + ))); + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + handle_error(&YoopError::RollbackFailed(format!( + "installation failed: {stderr}" + ))); + anyhow::bail!("Failed to install previous version: {}", stderr); + } + + if !args.quiet { + println!("Rollback complete! Now running yoop {target_version}"); + println!(); + } else if args.json { + let output = json!({ + "success": true, + "rolled_back_to": target_version, + "backup_id": latest_backup.id, + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + + Ok(()) +} + +fn handle_check( + status: &yoop_core::update::version_check::UpdateStatus, + args: &UpdateArgs, +) -> Result<()> { + if args.json { + let output = json!({ + "current_version": status.current_version.to_string(), + "latest_version": status.latest_version.to_string(), + "update_available": status.update_available, + "release_url": status.release_url, + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else if args.quiet { + if status.update_available { + println!("{}", status.latest_version); + } + } else { + println!(); + println!("Yoop Update Check"); + println!("{}", "═".repeat(60)); + println!(); + println!(" Current version: {}", status.current_version); + println!(" Latest version: {}", status.latest_version); + println!(); + if status.update_available { + println!(" Status: Update available"); + println!(); + println!("Run 'yoop update' to upgrade."); + } else { + println!(" Status: Up to date"); + } + println!(); + } + Ok(()) +} + +fn handle_error(err: &YoopError) { + eprintln!("Error: {err}"); + + if let Some(suggestion) = err.suggestion() { + eprintln!(); + eprintln!("Suggestion:"); + for line in suggestion.lines() { + eprintln!(" {line}"); + } + } +} + +fn format_time_ago(timestamp: chrono::DateTime) -> String { + let now = chrono::Utc::now(); + let duration = now.signed_duration_since(timestamp); + + if duration.num_days() > 0 { + let days = duration.num_days(); + if days == 1 { + "1 day ago".to_string() + } else { + format!("{days} days ago") + } + } else if duration.num_hours() > 0 { + let hours = duration.num_hours(); + if hours == 1 { + "1 hour ago".to_string() + } else { + format!("{hours} hours ago") + } + } else if duration.num_minutes() > 0 { + let minutes = duration.num_minutes(); + if minutes == 1 { + "1 minute ago".to_string() + } else { + format!("{minutes} minutes ago") + } + } else { + "just now".to_string() + } +} diff --git a/crates/yoop-cli/src/main.rs b/crates/yoop-cli/src/main.rs index 0dd09aa..79284f4 100644 --- a/crates/yoop-cli/src/main.rs +++ b/crates/yoop-cli/src/main.rs @@ -44,6 +44,8 @@ async fn main() -> Result<()> { Command::Diagnose(args) => commands::diagnose::run(args).await, Command::History(args) => commands::history::run(args).await, Command::Completions(args) => commands::completions::run(args.action), + #[cfg(feature = "update")] + Command::Update(args) => commands::update::run(args).await, Command::InternalClipboardHold(args) => { commands::internal::run_clipboard_hold(&args.content_type, args.timeout) } diff --git a/crates/yoop-core/Cargo.toml b/crates/yoop-core/Cargo.toml index 67ea584..640345d 100644 --- a/crates/yoop-core/Cargo.toml +++ b/crates/yoop-core/Cargo.toml @@ -82,6 +82,11 @@ tokio-stream = { workspace = true, optional = true } tokio-util = { workspace = true, optional = true } zip = { workspace = true, optional = true } +# Update feature +reqwest = { workspace = true, optional = true } +semver = { workspace = true } +toml_edit = { workspace = true, optional = true } + # Linux-specific dependencies for clipboard holder [target.'cfg(target_os = "linux")'.dependencies] nix = { workspace = true } @@ -96,7 +101,8 @@ walkdir = { workspace = true } default = ["mdns"] mdns = ["dep:mdns-sd", "dep:flume"] web = ["dep:axum", "dep:axum-extra", "dep:tower", "dep:tower-http", "dep:rust-embed", "dep:async-stream", "dep:futures", "dep:tokio-stream", "dep:tokio-util", "dep:zip"] -full = ["mdns", "web"] +update = ["dep:reqwest", "dep:toml_edit"] +full = ["mdns", "web", "update"] [lints] workspace = true diff --git a/crates/yoop-core/src/config/mod.rs b/crates/yoop-core/src/config/mod.rs index 7ff8405..40ac409 100644 --- a/crates/yoop-core/src/config/mod.rs +++ b/crates/yoop-core/src/config/mod.rs @@ -48,6 +48,8 @@ pub struct Config { pub web: WebConfig, /// UI settings pub ui: UiConfig, + /// Update settings + pub update: UpdateConfig, } impl Default for Config { @@ -62,6 +64,7 @@ impl Default for Config { trust: TrustConfig::default(), web: WebConfig::default(), ui: UiConfig::default(), + update: UpdateConfig::default(), } } } @@ -317,6 +320,91 @@ impl Default for UiConfig { } } +/// Update configuration options. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct UpdateConfig { + /// Enable automatic update checks + pub auto_check: bool, + /// Interval between automatic checks + #[serde(with = "humantime_serde")] + pub check_interval: Duration, + /// Preferred package manager (None = auto-detect) + pub package_manager: Option, + /// Whether to show update notifications + pub notify: bool, + /// Timestamp of last update check (seconds since UNIX epoch) + #[serde(skip_serializing_if = "Option::is_none")] + pub last_check: Option, +} + +impl Default for UpdateConfig { + fn default() -> Self { + Self { + auto_check: true, + check_interval: Duration::from_secs(24 * 60 * 60), + package_manager: None, + notify: true, + last_check: None, + } + } +} + +/// Package manager kind for update configuration. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum PackageManagerKind { + /// npm package manager + Npm, + /// pnpm package manager + Pnpm, + /// Yarn package manager + Yarn, + /// Bun package manager + Bun, +} + +impl PackageManagerKind { + /// Check if this package manager is available in PATH. + #[must_use] + pub fn is_available(self) -> bool { + #[cfg(feature = "update")] + { + use crate::update::package_manager::PackageManager; + let pm: PackageManager = self.into(); + pm.is_available() + } + #[cfg(not(feature = "update"))] + { + false + } + } +} + +#[cfg(feature = "update")] +impl From for crate::update::package_manager::PackageManager { + fn from(kind: PackageManagerKind) -> Self { + match kind { + PackageManagerKind::Npm => Self::Npm, + PackageManagerKind::Pnpm => Self::Pnpm, + PackageManagerKind::Yarn => Self::Yarn, + PackageManagerKind::Bun => Self::Bun, + } + } +} + +#[cfg(feature = "update")] +impl From for PackageManagerKind { + fn from(pm: crate::update::package_manager::PackageManager) -> Self { + match pm { + crate::update::package_manager::PackageManager::Npm => Self::Npm, + crate::update::package_manager::PackageManager::Pnpm => Self::Pnpm, + crate::update::package_manager::PackageManager::Yarn => Self::Yarn, + crate::update::package_manager::PackageManager::Bun => Self::Bun, + } + } +} + impl Config { /// Load configuration from the default location. /// diff --git a/crates/yoop-core/src/error.rs b/crates/yoop-core/src/error.rs index 4fca80f..af3f0e5 100644 --- a/crates/yoop-core/src/error.rs +++ b/crates/yoop-core/src/error.rs @@ -187,6 +187,45 @@ pub enum Error { /// Unsupported clipboard content type #[error("unsupported clipboard content type: {0}")] UnsupportedClipboardType(String), + + /// Migration failed + #[error("migration failed from {from} to {to}: {reason}")] + MigrationFailed { + /// Version migrating from + from: String, + /// Version migrating to + to: String, + /// Reason for failure + reason: String, + }, + + /// Backup operation failed + #[error("backup failed: {0}")] + BackupFailed(String), + + /// Rollback operation failed + #[error("rollback failed: {0}")] + RollbackFailed(String), + + /// No backup available + #[error("no backup available for rollback")] + NoBackupAvailable, + + /// Update check failed + #[error("failed to check for updates: {0}")] + UpdateCheckFailed(String), + + /// Already on latest version + #[error("already on the latest version ({0})")] + AlreadyLatest(String), + + /// Package manager not found + #[error("package manager '{0}' not found in PATH")] + PackageManagerNotFound(String), + + /// Update command execution failed + #[error("update command failed: {0}")] + UpdateCommandFailed(String), } impl Error { @@ -222,4 +261,39 @@ impl Error { | Self::KeepAliveFailed(_) ) } + + /// Returns a helpful suggestion for resolving the error, if applicable. + #[must_use] + pub fn suggestion(&self) -> Option<&'static str> { + match self { + Self::PackageManagerNotFound(_) => Some( + "Install Node.js (includes npm) from https://nodejs.org\n\ + Or install pnpm: npm install -g pnpm\n\ + Or install yarn: npm install -g yarn\n\ + Or install bun: curl -fsSL https://bun.sh/install | bash", + ), + Self::UpdateCheckFailed(_) => Some( + "Check your internet connection and try again.\n\ + You can also manually check: https://www.npmjs.com/package/yoop", + ), + Self::MigrationFailed { .. } => Some( + "Your data has been backed up. Try:\n\ + yoop update --rollback", + ), + Self::UpdateCommandFailed(_) => Some( + "Try running the update manually:\n\ + npm install -g yoop\n\ + Or check permissions (may need sudo on some systems)", + ), + Self::NoBackupAvailable => Some( + "No backup found to rollback to. You may need to reinstall manually:\n\ + npm install -g yoop", + ), + Self::RollbackFailed(_) => Some( + "Failed to rollback. You may need to manually reinstall:\n\ + npm install -g yoop", + ), + _ => None, + } + } } diff --git a/crates/yoop-core/src/lib.rs b/crates/yoop-core/src/lib.rs index f3605a9..b7c6251 100644 --- a/crates/yoop-core/src/lib.rs +++ b/crates/yoop-core/src/lib.rs @@ -71,6 +71,12 @@ pub mod trust; #[cfg(feature = "web")] pub mod web; +#[cfg(feature = "update")] +pub mod update; + +#[cfg(feature = "update")] +pub mod migration; + pub use error::{Error, Result}; /// Library version diff --git a/crates/yoop-core/src/migration/backup.rs b/crates/yoop-core/src/migration/backup.rs new file mode 100644 index 0000000..ec049a2 --- /dev/null +++ b/crates/yoop-core/src/migration/backup.rs @@ -0,0 +1,392 @@ +//! Backup and restore functionality for migrations. + +use std::fs; +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::error::Result; +use crate::update::SchemaVersion; + +/// Unique identifier for a backup. +pub type BackupId = String; + +/// Information about a backup including version, timestamp, and files. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackupInfo { + /// Unique identifier for this backup. + pub id: BackupId, + /// Application version at the time of backup. + pub app_version: SchemaVersion, + /// Schema version at the time of backup. + pub schema_version: SchemaVersion, + /// When the backup was created. + pub timestamp: DateTime, + /// List of files included in the backup. + pub files: Vec, + /// Total size of the backup in bytes. + pub size_bytes: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BackupManifest { + version: u32, + backups: Vec, + max_backups: usize, +} + +impl Default for BackupManifest { + fn default() -> Self { + Self { + version: 1, + backups: Vec::new(), + max_backups: 5, + } + } +} + +/// Manages backup creation, restoration, and cleanup for safe migrations. +pub struct BackupManager { + /// Directory where backups are stored. + backup_dir: PathBuf, + /// Application data directory to backup. + data_dir: PathBuf, + /// Maximum number of backups to retain. + max_backups: usize, +} + +impl BackupManager { + const FILES_TO_BACKUP: &'static [&'static str] = &[ + "config.toml", + "history.json", + "trust.json", + "migration_state.json", + ]; + + /// Create a new backup manager for the given data directory. + #[must_use] + pub fn new(data_dir: PathBuf) -> Self { + let backup_dir = Self::get_backup_dir().unwrap_or_else(|| data_dir.join("backups")); + + Self { + backup_dir, + data_dir, + max_backups: 5, + } + } + + /// Create a backup manager with custom backup directory (for testing). + #[must_use] + #[doc(hidden)] + pub fn new_with_backup_dir(data_dir: PathBuf, backup_dir: PathBuf) -> Self { + Self { + backup_dir, + data_dir, + max_backups: 5, + } + } + + fn get_backup_dir() -> Option { + directories::ProjectDirs::from("com", "yoop", "Yoop") + .map(|dirs| dirs.data_dir().join("backups")) + } + + /// Create a backup of all critical application files. + /// + /// # Errors + /// + /// Returns an error if the backup directory cannot be created or files cannot be copied. + pub fn create_backup(&self, version: &str) -> Result { + let timestamp = Utc::now(); + let backup_id = format!( + "{}_{}_{}", + version, + timestamp.format("%Y%m%d"), + timestamp.format("%H%M%S") + ); + + let backup_path = self.backup_dir.join(&backup_id); + fs::create_dir_all(&backup_path).map_err(|e| { + crate::error::Error::Internal(format!("failed to create backup directory: {e}")) + })?; + + let mut backed_up_files = Vec::new(); + let mut total_size = 0u64; + + for file_name in Self::FILES_TO_BACKUP { + let src = if *file_name == "config.toml" && !cfg!(test) { + crate::config::Config::config_path() + } else { + self.data_dir.join(file_name) + }; + + if src.exists() { + let dest = backup_path.join(file_name); + fs::copy(&src, &dest).map_err(|e| { + crate::error::Error::Internal(format!("failed to backup {file_name}: {e}")) + })?; + + backed_up_files.push((*file_name).to_string()); + total_size += fs::metadata(&dest).map(|m| m.len()).unwrap_or(0); + } + } + + let current_version = SchemaVersion::parse(crate::VERSION)?; + let backup_info = BackupInfo { + id: backup_id.clone(), + app_version: current_version.clone(), + schema_version: current_version, + timestamp, + files: backed_up_files, + size_bytes: total_size, + }; + + self.add_to_manifest(backup_info)?; + self.cleanup()?; + + Ok(backup_id) + } + + /// Restore files from a backup by its ID. + /// + /// # Errors + /// + /// Returns an error if the backup doesn't exist or files cannot be restored. + pub fn restore_backup(&self, backup_id: &BackupId) -> Result<()> { + let backup_path = self.backup_dir.join(backup_id); + + if !backup_path.exists() { + return Err(crate::error::Error::Internal(format!( + "backup not found: {backup_id}" + ))); + } + + for file_name in Self::FILES_TO_BACKUP { + let src = backup_path.join(file_name); + + if src.exists() { + let dest = if *file_name == "config.toml" && !cfg!(test) { + crate::config::Config::config_path() + } else { + self.data_dir.join(file_name) + }; + + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).map_err(|e| { + crate::error::Error::Internal(format!( + "failed to create directory for restore: {e}" + )) + })?; + } + + fs::copy(&src, &dest).map_err(|e| { + crate::error::Error::Internal(format!("failed to restore {file_name}: {e}")) + })?; + } + } + + Ok(()) + } + + /// List all available backups. + /// + /// # Errors + /// + /// Returns an error if the backup manifest cannot be read. + pub fn list_backups(&self) -> Result> { + let manifest = self.load_manifest()?; + Ok(manifest.backups) + } + + /// Get information about the most recent backup. + /// + /// # Errors + /// + /// Returns an error if the backup manifest cannot be read. + pub fn latest_backup(&self) -> Result> { + let manifest = self.load_manifest()?; + Ok(manifest.backups.into_iter().next_back()) + } + + /// Remove old backups beyond the maximum retention limit. + /// + /// # Errors + /// + /// Returns an error if backups cannot be deleted or the manifest cannot be updated. + pub fn cleanup(&self) -> Result { + let mut manifest = self.load_manifest()?; + + if manifest.backups.len() <= self.max_backups { + return Ok(0); + } + + manifest + .backups + .sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); + + let to_remove = manifest.backups.len() - self.max_backups; + let removed_backups: Vec<_> = manifest.backups.drain(..to_remove).collect(); + + for backup in &removed_backups { + let backup_path = self.backup_dir.join(&backup.id); + if backup_path.exists() { + fs::remove_dir_all(&backup_path).map_err(|e| { + crate::error::Error::Internal(format!( + "failed to remove old backup {}: {e}", + backup.id + )) + })?; + } + } + + self.save_manifest(&manifest)?; + + Ok(removed_backups.len()) + } + + fn manifest_path(&self) -> PathBuf { + self.backup_dir.join("manifest.json") + } + + /// Load the backup manifest or create a default one if it doesn't exist. + /// + /// # Errors + /// + /// Returns an error if the manifest file exists but cannot be read or parsed. + fn load_manifest(&self) -> Result { + let path = self.manifest_path(); + + if !path.exists() { + return Ok(BackupManifest::default()); + } + + let content = fs::read_to_string(&path).map_err(|e| { + crate::error::Error::Internal(format!("failed to read backup manifest: {e}")) + })?; + + serde_json::from_str(&content).map_err(|e| { + crate::error::Error::Internal(format!("failed to parse backup manifest: {e}")) + }) + } + + /// Save the backup manifest to disk. + /// + /// # Errors + /// + /// Returns an error if the manifest directory cannot be created or the file cannot be written. + fn save_manifest(&self, manifest: &BackupManifest) -> Result<()> { + let path = self.manifest_path(); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| { + crate::error::Error::Internal(format!("failed to create backup directory: {e}")) + })?; + } + + let content = serde_json::to_string_pretty(manifest).map_err(|e| { + crate::error::Error::Internal(format!("failed to serialize backup manifest: {e}")) + })?; + + fs::write(&path, content).map_err(|e| { + crate::error::Error::Internal(format!("failed to write backup manifest: {e}")) + }) + } + + /// Add a backup entry to the manifest. + /// + /// # Errors + /// + /// Returns an error if the manifest cannot be loaded or saved. + fn add_to_manifest(&self, backup_info: BackupInfo) -> Result<()> { + let mut manifest = self.load_manifest()?; + manifest.backups.push(backup_info); + self.save_manifest(&manifest) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_backup_manager_new() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path().to_path_buf(); + + let manager = BackupManager::new(data_dir.clone()); + + assert!(manager.backup_dir.ends_with("backups")); + assert_eq!(manager.data_dir, data_dir); + assert_eq!(manager.max_backups, 5); + } + + #[test] + fn test_create_and_list_backups() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path().to_path_buf(); + + fs::write(data_dir.join("config.toml"), "test config").unwrap(); + fs::write(data_dir.join("history.json"), "{}").unwrap(); + + let manager = BackupManager { + backup_dir: data_dir.join("backups"), + data_dir: data_dir.clone(), + max_backups: 5, + }; + + let backup_id = manager.create_backup("0.1.0").expect("create backup"); + + assert!(backup_id.contains("0.1.0")); + + let backups = manager.list_backups().expect("list backups"); + assert_eq!(backups.len(), 1); + assert_eq!(backups[0].id, backup_id); + } + + #[test] + fn test_restore_backup() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path().to_path_buf(); + + let original_config = "device_name = \"test\""; + fs::write(data_dir.join("config.toml"), original_config).unwrap(); + + let manager = BackupManager { + backup_dir: data_dir.join("backups"), + data_dir: data_dir.clone(), + max_backups: 5, + }; + + let backup_id = manager.create_backup("0.1.0").expect("create backup"); + + fs::write(data_dir.join("config.toml"), "device_name = \"modified\"").unwrap(); + + manager.restore_backup(&backup_id).expect("restore backup"); + + let restored = fs::read_to_string(data_dir.join("config.toml")).unwrap(); + assert_eq!(restored, original_config); + } + + #[test] + fn test_cleanup_old_backups() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path().to_path_buf(); + + fs::write(data_dir.join("config.toml"), "test").unwrap(); + + let manager = BackupManager { + backup_dir: data_dir.join("backups"), + data_dir: data_dir.clone(), + max_backups: 2, + }; + + let _b1 = manager.create_backup("0.1.0").expect("backup 1"); + let _b2 = manager.create_backup("0.1.1").expect("backup 2"); + let _b3 = manager.create_backup("0.1.2").expect("backup 3"); + + let backups = manager.list_backups().expect("list"); + assert_eq!(backups.len(), 2); + } +} diff --git a/crates/yoop-core/src/migration/migrations/mod.rs b/crates/yoop-core/src/migration/migrations/mod.rs new file mode 100644 index 0000000..48d965b --- /dev/null +++ b/crates/yoop-core/src/migration/migrations/mod.rs @@ -0,0 +1,6 @@ +//! Migration registry. +//! +//! This module contains all registered migrations. + +#[cfg(feature = "update")] +pub mod v0_1_to_v0_2; diff --git a/crates/yoop-core/src/migration/migrations/v0_1_to_v0_2.rs b/crates/yoop-core/src/migration/migrations/v0_1_to_v0_2.rs new file mode 100644 index 0000000..82edff6 --- /dev/null +++ b/crates/yoop-core/src/migration/migrations/v0_1_to_v0_2.rs @@ -0,0 +1,189 @@ +//! Migration from version 0.1.x to 0.2.x. +//! +//! This migration adds the [update] section to the configuration file. + +use std::fs; +use std::path::Path; + +use crate::config::Config; +use crate::error::Result; +use crate::migration::Migration; +use crate::update::SchemaVersion; + +/// Migration from v0.1.x to v0.2.x adding [update] configuration section. +#[allow(non_camel_case_types)] +pub struct V0_1ToV0_2; + +impl Migration for V0_1ToV0_2 { + fn from_version(&self) -> SchemaVersion { + SchemaVersion::new(0, 1, 0) + } + + fn to_version(&self) -> SchemaVersion { + SchemaVersion::new(0, 2, 0) + } + + fn description(&self) -> &'static str { + "Add [update] config section" + } + + fn up(&self, data_dir: &Path) -> Result<()> { + let config_path = if cfg!(test) { + data_dir.join("config.toml") + } else { + Config::config_path() + }; + + if !config_path.exists() { + return Ok(()); + } + + let content = fs::read_to_string(&config_path) + .map_err(|e| crate::error::Error::ConfigError(format!("failed to read config: {e}")))?; + + let mut doc = content.parse::().map_err(|e| { + crate::error::Error::ConfigError(format!("failed to parse config: {e}")) + })?; + + if doc.get("update").is_none() { + let mut update_table = toml_edit::Table::new(); + update_table.insert("auto_check", toml_edit::value(false)); + update_table.insert("check_interval", toml_edit::value("86400s")); + update_table.insert("notify", toml_edit::value(true)); + + doc.insert("update", toml_edit::Item::Table(update_table)); + + fs::write(&config_path, doc.to_string()).map_err(|e| { + crate::error::Error::ConfigError(format!("failed to write config: {e}")) + })?; + } + + Ok(()) + } + + fn down(&self, data_dir: &Path) -> Result<()> { + let config_path = if cfg!(test) { + data_dir.join("config.toml") + } else { + Config::config_path() + }; + + if !config_path.exists() { + return Ok(()); + } + + let content = fs::read_to_string(&config_path) + .map_err(|e| crate::error::Error::ConfigError(format!("failed to read config: {e}")))?; + + let mut doc = content.parse::().map_err(|e| { + crate::error::Error::ConfigError(format!("failed to parse config: {e}")) + })?; + + if doc.get("update").is_some() { + doc.remove("update"); + + fs::write(&config_path, doc.to_string()).map_err(|e| { + crate::error::Error::ConfigError(format!("failed to write config: {e}")) + })?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_v0_1_to_v0_2_up() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let initial_config = r#" +[general] +device_name = "Test Device" + +[network] +port = 52525 +"#; + + fs::write(&config_path, initial_config).unwrap(); + + let migration = V0_1ToV0_2; + + let result = migration.up(temp_dir.path()); + assert!(result.is_ok()); + + let updated = fs::read_to_string(&config_path).unwrap(); + assert!(updated.contains("[update]")); + assert!(updated.contains("auto_check")); + assert!(updated.contains("check_interval")); + assert!(updated.contains("notify")); + } + + #[test] + fn test_v0_1_to_v0_2_down() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let config_with_update = r#" +[general] +device_name = "Test Device" + +[update] +auto_check = false +check_interval = "86400s" +notify = true +"#; + + fs::write(&config_path, config_with_update).unwrap(); + + let migration = V0_1ToV0_2; + + let result = migration.down(temp_dir.path()); + assert!(result.is_ok()); + + let updated = fs::read_to_string(&config_path).unwrap(); + assert!(!updated.contains("[update]")); + } + + #[test] + fn test_v0_1_to_v0_2_idempotent() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let config_with_update = r#" +[general] +device_name = "Test Device" + +[update] +auto_check = true +check_interval = "3600s" +notify = false +"#; + + fs::write(&config_path, config_with_update).unwrap(); + + let migration = V0_1ToV0_2; + + let result = migration.up(temp_dir.path()); + assert!(result.is_ok()); + + let updated = fs::read_to_string(&config_path).unwrap(); + assert!(updated.contains("auto_check = true")); + assert!(updated.contains("3600s")); + } + + #[test] + fn test_migration_metadata() { + let migration = V0_1ToV0_2; + + assert_eq!(migration.from_version(), SchemaVersion::new(0, 1, 0)); + assert_eq!(migration.to_version(), SchemaVersion::new(0, 2, 0)); + assert_eq!(migration.description(), "Add [update] config section"); + assert_eq!(migration.id(), "0_1_to_0_2"); + } +} diff --git a/crates/yoop-core/src/migration/mod.rs b/crates/yoop-core/src/migration/mod.rs new file mode 100644 index 0000000..ce248aa --- /dev/null +++ b/crates/yoop-core/src/migration/mod.rs @@ -0,0 +1,353 @@ +//! Migration framework for schema and data transformations across versions. +//! +//! This module provides a framework for handling database migrations, schema changes, +//! and data transformations when updating between different versions of Yoop. + +pub mod backup; +pub mod version; + +#[cfg(feature = "update")] +pub mod migrations; + +use std::path::Path; + +use crate::error::Result; +use crate::update::SchemaVersion; + +pub use backup::{BackupId, BackupInfo, BackupManager}; +pub use version::{MigrationHistoryEntry, MigrationState}; + +/// Trait for implementing database/schema migrations between versions. +#[allow(clippy::wrong_self_convention)] +pub trait Migration: Send + Sync { + /// Get the version this migration starts from. + fn from_version(&self) -> SchemaVersion; + + /// Get the version this migration upgrades to. + fn to_version(&self) -> SchemaVersion; + + /// Apply the migration forward. + /// + /// # Errors + /// + /// Returns an error if the migration cannot be applied. + fn up(&self, data_dir: &Path) -> Result<()>; + + /// Rollback the migration. + /// + /// # Errors + /// + /// Returns an error if the rollback cannot be performed. + fn down(&self, data_dir: &Path) -> Result<()>; + + /// Get a human-readable description of what this migration does. + fn description(&self) -> &'static str; + + /// Generate a unique identifier for this migration. + #[must_use] + fn id(&self) -> String { + format!( + "{}_{}_to_{}_{}", + self.from_version().major, + self.from_version().minor, + self.to_version().major, + self.to_version().minor + ) + } +} + +/// Orchestrates migration execution with backup/restore capabilities. +pub struct MigrationManager { + /// Registered migrations available for execution. + migrations: Vec>, + /// Backup manager for creating and restoring backups. + backup_manager: BackupManager, + /// Application data directory. + data_dir: std::path::PathBuf, +} + +impl MigrationManager { + /// Create a new migration manager for the given data directory. + #[must_use] + pub fn new(data_dir: std::path::PathBuf) -> Self { + let migrations = Self::register_migrations(); + let backup_manager = BackupManager::new(data_dir.clone()); + + Self { + migrations, + backup_manager, + data_dir, + } + } + + #[cfg(feature = "update")] + fn register_migrations() -> Vec> { + vec![Box::new(migrations::v0_1_to_v0_2::V0_1ToV0_2)] + } + + #[cfg(not(feature = "update"))] + fn register_migrations() -> Vec> { + vec![] + } + + /// Get the list of migrations needed to upgrade from one version to another. + #[must_use] + pub fn get_pending(&self, from: &SchemaVersion, to: &SchemaVersion) -> Vec<&dyn Migration> { + if from >= to { + return vec![]; + } + + let mut pending = Vec::new(); + let mut current = from.clone(); + + while current < *to { + if let Some(migration) = self + .migrations + .iter() + .find(|m| m.from_version() == current && m.to_version() <= *to) + { + pending.push(migration.as_ref()); + current = migration.to_version(); + } else { + break; + } + } + + pending + } + + /// Run all pending migrations from one version to another. + /// + /// # Errors + /// + /// Returns an error if backup creation fails or any migration fails. On failure, attempts to restore from backup. + pub fn run(&self, from: &SchemaVersion, to: &SchemaVersion, create_backup: bool) -> Result<()> { + let pending = self.get_pending(from, to); + + if pending.is_empty() { + return Ok(()); + } + + let backup_id = if create_backup { + Some(self.backup_manager.create_backup(&from.to_string())?) + } else { + None + }; + + let mut applied = Vec::new(); + let mut state = MigrationState::load(&self.data_dir)?; + + for migration in &pending { + migration.up(&self.data_dir).map_err(|e| { + if let Some(backup_id) = &backup_id { + let _ = self.backup_manager.restore_backup(backup_id); + } + crate::error::Error::Internal(format!("migration {} failed: {e}", migration.id())) + })?; + + applied.push(migration.id()); + } + + state.add_history_entry(MigrationHistoryEntry { + from_version: from.clone(), + to_version: to.clone(), + timestamp: chrono::Utc::now(), + backup_id: backup_id.unwrap_or_else(|| "none".to_string()), + success: true, + migrations_applied: applied, + }); + + state.save(&self.data_dir)?; + + Ok(()) + } + + /// Rollback to a previous state using a backup. + /// + /// # Errors + /// + /// Returns an error if the backup cannot be restored or the migration state cannot be updated. + pub fn rollback(&self, backup_id: &str) -> Result<()> { + self.backup_manager.restore_backup(&backup_id.to_string())?; + + let mut state = MigrationState::load(&self.data_dir)?; + + if let Some(entry) = state.history.iter().find(|e| e.backup_id == backup_id) { + state.schema_version = entry.from_version.clone(); + state.save(&self.data_dir)?; + } + + Ok(()) + } + + /// List all available backups. + /// + /// # Errors + /// + /// Returns an error if the backup manifest cannot be read. + pub fn list_backups(&self) -> Result> { + self.backup_manager.list_backups() + } + + /// Get information about the most recent backup. + /// + /// # Errors + /// + /// Returns an error if the backup manifest cannot be read. + pub fn latest_backup(&self) -> Result> { + self.backup_manager.latest_backup() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + struct TestMigration { + from: SchemaVersion, + to: SchemaVersion, + } + + impl Migration for TestMigration { + fn from_version(&self) -> SchemaVersion { + self.from.clone() + } + + fn to_version(&self) -> SchemaVersion { + self.to.clone() + } + + fn up(&self, _data_dir: &Path) -> Result<()> { + Ok(()) + } + + fn down(&self, _data_dir: &Path) -> Result<()> { + Ok(()) + } + + fn description(&self) -> &'static str { + "Test migration" + } + } + + #[test] + fn test_migration_manager_get_pending() { + let temp_dir = TempDir::new().unwrap(); + + let mut manager = MigrationManager::new(temp_dir.path().to_path_buf()); + + manager.migrations = vec![ + Box::new(TestMigration { + from: SchemaVersion::new(0, 1, 0), + to: SchemaVersion::new(0, 2, 0), + }), + Box::new(TestMigration { + from: SchemaVersion::new(0, 2, 0), + to: SchemaVersion::new(0, 3, 0), + }), + ]; + + let from = SchemaVersion::new(0, 1, 0); + let to = SchemaVersion::new(0, 3, 0); + let pending = manager.get_pending(&from, &to); + + assert_eq!(pending.len(), 2); + assert_eq!(pending[0].from_version(), SchemaVersion::new(0, 1, 0)); + assert_eq!(pending[1].from_version(), SchemaVersion::new(0, 2, 0)); + } + + #[test] + fn test_migration_manager_no_pending() { + let temp_dir = TempDir::new().unwrap(); + let manager = MigrationManager::new(temp_dir.path().to_path_buf()); + + let from = SchemaVersion::new(0, 2, 0); + let to = SchemaVersion::new(0, 1, 0); + let pending = manager.get_pending(&from, &to); + + assert_eq!(pending.len(), 0); + } + + #[test] + fn test_migration_state_persistence() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path(); + + let mut state = + MigrationState::new(SchemaVersion::new(0, 1, 0), SchemaVersion::new(0, 1, 0)); + + state.add_history_entry(MigrationHistoryEntry { + from_version: SchemaVersion::new(0, 1, 0), + to_version: SchemaVersion::new(0, 2, 0), + timestamp: chrono::Utc::now(), + backup_id: "test_backup".to_string(), + success: true, + migrations_applied: vec!["test_migration".to_string()], + }); + + state.save(data_dir).expect("save state"); + + let loaded = MigrationState::load(data_dir).expect("load state"); + + assert_eq!(loaded.schema_version, SchemaVersion::new(0, 2, 0)); + assert_eq!(loaded.history.len(), 1); + } + + #[test] + fn test_migration_manager_with_gap() { + let temp_dir = TempDir::new().unwrap(); + let mut manager = MigrationManager::new(temp_dir.path().to_path_buf()); + + manager.migrations = vec![ + Box::new(TestMigration { + from: SchemaVersion::new(0, 1, 0), + to: SchemaVersion::new(0, 2, 0), + }), + Box::new(TestMigration { + from: SchemaVersion::new(0, 3, 0), + to: SchemaVersion::new(0, 4, 0), + }), + ]; + + let from = SchemaVersion::new(0, 1, 0); + let to = SchemaVersion::new(0, 4, 0); + let pending = manager.get_pending(&from, &to); + + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].to_version(), SchemaVersion::new(0, 2, 0)); + } + + #[test] + fn test_migration_manager_same_version() { + let temp_dir = TempDir::new().unwrap(); + let manager = MigrationManager::new(temp_dir.path().to_path_buf()); + + let version = SchemaVersion::new(0, 1, 0); + let pending = manager.get_pending(&version, &version); + + assert_eq!(pending.len(), 0); + } + + #[test] + fn test_migration_manager_downgrade() { + let temp_dir = TempDir::new().unwrap(); + let manager = MigrationManager::new(temp_dir.path().to_path_buf()); + + let from = SchemaVersion::new(0, 2, 0); + let to = SchemaVersion::new(0, 1, 0); + let pending = manager.get_pending(&from, &to); + + assert_eq!(pending.len(), 0); + } + + #[test] + fn test_migration_id_generation() { + let migration = TestMigration { + from: SchemaVersion::new(0, 1, 0), + to: SchemaVersion::new(0, 2, 0), + }; + + assert_eq!(migration.id(), "0_1_to_0_2"); + } +} diff --git a/crates/yoop-core/src/migration/version.rs b/crates/yoop-core/src/migration/version.rs new file mode 100644 index 0000000..913eadf --- /dev/null +++ b/crates/yoop-core/src/migration/version.rs @@ -0,0 +1,202 @@ +//! Version tracking and migration state management. + +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::error::Result; +use crate::update::SchemaVersion; + +/// State tracking for migrations including version history and backup information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MigrationState { + /// State format version for future compatibility. + pub version: u32, + /// Current schema version after migrations. + pub schema_version: SchemaVersion, + /// Application version that created this state. + pub app_version: SchemaVersion, + /// Timestamp of the last migration execution. + pub last_migration: Option>, + /// History of all migration executions. + #[serde(default)] + pub history: Vec, +} + +/// Record of a single migration execution including success status and backup information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MigrationHistoryEntry { + /// Schema version before the migration. + pub from_version: SchemaVersion, + /// Schema version after the migration. + pub to_version: SchemaVersion, + /// When the migration was executed. + pub timestamp: DateTime, + /// ID of the backup created before migration. + pub backup_id: String, + /// Whether the migration completed successfully. + pub success: bool, + /// List of migration IDs that were applied. + pub migrations_applied: Vec, +} + +impl MigrationState { + /// Create a new migration state with the given schema and app versions. + #[must_use] + pub fn new(schema_version: SchemaVersion, app_version: SchemaVersion) -> Self { + Self { + version: 1, + schema_version, + app_version, + last_migration: None, + history: Vec::new(), + } + } + + /// Load migration state from disk or create a new one if it doesn't exist. + /// + /// # Errors + /// + /// Returns an error if the state file exists but cannot be read or parsed. + pub fn load(data_dir: &Path) -> Result { + let path = Self::state_path(data_dir); + + if !path.exists() { + let current_version = SchemaVersion::parse(crate::VERSION)?; + return Ok(Self::new(current_version.clone(), current_version)); + } + + let content = std::fs::read_to_string(&path).map_err(|e| { + crate::error::Error::ConfigError(format!("failed to read migration state: {e}")) + })?; + + serde_json::from_str(&content).map_err(|e| { + crate::error::Error::ConfigError(format!("failed to parse migration state: {e}")) + }) + } + + /// Save migration state to disk. + /// + /// # Errors + /// + /// Returns an error if the state directory cannot be created or the file cannot be written. + pub fn save(&self, data_dir: &Path) -> Result<()> { + let path = Self::state_path(data_dir); + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + crate::error::Error::ConfigError(format!( + "failed to create migration state directory: {e}" + )) + })?; + } + + let content = serde_json::to_string_pretty(self).map_err(|e| { + crate::error::Error::Internal(format!("failed to serialize migration state: {e}")) + })?; + + std::fs::write(&path, content).map_err(|e| { + crate::error::Error::ConfigError(format!("failed to write migration state: {e}")) + }) + } + + /// Get the path where migration state is stored. + #[must_use] + pub fn state_path(data_dir: &Path) -> PathBuf { + data_dir.join("migration_state.json") + } + + /// Add a migration history entry and update state accordingly. + pub fn add_history_entry(&mut self, entry: MigrationHistoryEntry) { + self.last_migration = Some(entry.timestamp); + self.schema_version = entry.to_version.clone(); + self.history.push(entry); + } + + /// Get the backup ID of the most recent successful migration. + #[must_use] + pub fn latest_backup(&self) -> Option<&str> { + self.history + .iter() + .rev() + .find(|entry| entry.success) + .map(|entry| entry.backup_id.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_migration_state_new() { + let v1 = SchemaVersion::new(0, 1, 0); + let v2 = SchemaVersion::new(0, 2, 0); + let state = MigrationState::new(v1.clone(), v2.clone()); + + assert_eq!(state.version, 1); + assert_eq!(state.schema_version, v1); + assert_eq!(state.app_version, v2); + assert!(state.last_migration.is_none()); + assert!(state.history.is_empty()); + } + + #[test] + fn test_migration_state_roundtrip() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path(); + + let mut state = + MigrationState::new(SchemaVersion::new(0, 1, 0), SchemaVersion::new(0, 1, 0)); + + state.add_history_entry(MigrationHistoryEntry { + from_version: SchemaVersion::new(0, 1, 0), + to_version: SchemaVersion::new(0, 2, 0), + timestamp: Utc::now(), + backup_id: "test_backup".to_string(), + success: true, + migrations_applied: vec!["migration1".to_string()], + }); + + state.save(data_dir).expect("save failed"); + + let loaded = MigrationState::load(data_dir).expect("load failed"); + + assert_eq!(loaded.version, state.version); + assert_eq!(loaded.schema_version, state.schema_version); + assert_eq!(loaded.history.len(), 1); + assert_eq!(loaded.history[0].backup_id, "test_backup"); + } + + #[test] + fn test_latest_backup() { + let mut state = + MigrationState::new(SchemaVersion::new(0, 1, 0), SchemaVersion::new(0, 1, 0)); + + assert!(state.latest_backup().is_none()); + + state.add_history_entry(MigrationHistoryEntry { + from_version: SchemaVersion::new(0, 1, 0), + to_version: SchemaVersion::new(0, 2, 0), + timestamp: Utc::now(), + backup_id: "backup1".to_string(), + success: true, + migrations_applied: vec![], + }); + + assert_eq!(state.latest_backup(), Some("backup1")); + + state.add_history_entry(MigrationHistoryEntry { + from_version: SchemaVersion::new(0, 2, 0), + to_version: SchemaVersion::new(0, 3, 0), + timestamp: Utc::now(), + backup_id: "backup2".to_string(), + success: true, + migrations_applied: vec![], + }); + + assert_eq!(state.latest_backup(), Some("backup2")); + } +} diff --git a/crates/yoop-core/src/update/mod.rs b/crates/yoop-core/src/update/mod.rs new file mode 100644 index 0000000..5d2f509 --- /dev/null +++ b/crates/yoop-core/src/update/mod.rs @@ -0,0 +1,200 @@ +//! Update functionality for Yoop. +//! +//! This module provides functionality for checking and installing updates. + +#[cfg(feature = "update")] +pub mod package_manager; +#[cfg(feature = "update")] +pub mod version_check; + +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::fmt; + +use crate::error::Result; + +/// Semantic version number for schema versioning. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SchemaVersion { + /// Major version number. + pub major: u32, + /// Minor version number. + pub minor: u32, + /// Patch version number. + pub patch: u32, +} + +impl SchemaVersion { + /// Create a new schema version. + #[must_use] + pub const fn new(major: u32, minor: u32, patch: u32) -> Self { + Self { + major, + minor, + patch, + } + } + + /// Parse a version string in the format "major.minor.patch" or "vmajor.minor.patch". + /// + /// # Errors + /// + /// Returns an error if the string is not in the correct format or contains invalid numbers. + pub fn parse(s: &str) -> Result { + let parts: Vec<&str> = s.trim().trim_start_matches('v').split('.').collect(); + + if parts.len() != 3 { + return Err(crate::error::Error::Internal(format!( + "invalid version format: {s}" + ))); + } + + let major = parts[0] + .parse() + .map_err(|e| crate::error::Error::Internal(format!("invalid major version: {e}")))?; + let minor = parts[1] + .parse() + .map_err(|e| crate::error::Error::Internal(format!("invalid minor version: {e}")))?; + let patch = parts[2] + .parse() + .map_err(|e| crate::error::Error::Internal(format!("invalid patch version: {e}")))?; + + Ok(Self { + major, + minor, + patch, + }) + } +} + +impl fmt::Display for SchemaVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +impl PartialOrd for SchemaVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for SchemaVersion { + fn cmp(&self, other: &Self) -> Ordering { + match self.major.cmp(&other.major) { + Ordering::Equal => match self.minor.cmp(&other.minor) { + Ordering::Equal => self.patch.cmp(&other.patch), + other => other, + }, + other => other, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_schema_version_parse() { + let v = SchemaVersion::parse("1.2.3").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + + let v = SchemaVersion::parse("v0.1.3").unwrap(); + assert_eq!(v.major, 0); + assert_eq!(v.minor, 1); + assert_eq!(v.patch, 3); + } + + #[test] + fn test_schema_version_display() { + let v = SchemaVersion::new(1, 2, 3); + assert_eq!(v.to_string(), "1.2.3"); + } + + #[test] + fn test_schema_version_ord() { + let v1 = SchemaVersion::new(1, 0, 0); + let v2 = SchemaVersion::new(2, 0, 0); + assert!(v1 < v2); + + let v1 = SchemaVersion::new(1, 1, 0); + let v2 = SchemaVersion::new(1, 2, 0); + assert!(v1 < v2); + + let v1 = SchemaVersion::new(1, 0, 1); + let v2 = SchemaVersion::new(1, 0, 2); + assert!(v1 < v2); + + let v1 = SchemaVersion::new(1, 2, 3); + let v2 = SchemaVersion::new(1, 2, 3); + assert_eq!(v1, v2); + } + + #[test] + fn test_schema_version_parse_with_v_prefix() { + let v = SchemaVersion::parse("v1.2.3").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + } + + #[test] + fn test_schema_version_parse_invalid_format() { + assert!(SchemaVersion::parse("1.2").is_err()); + assert!(SchemaVersion::parse("1").is_err()); + assert!(SchemaVersion::parse("1.2.3.4").is_err()); + assert!(SchemaVersion::parse("abc.def.ghi").is_err()); + assert!(SchemaVersion::parse("").is_err()); + } + + #[test] + fn test_schema_version_parse_edge_cases() { + let v = SchemaVersion::parse("0.0.0").unwrap(); + assert_eq!(v.major, 0); + assert_eq!(v.minor, 0); + assert_eq!(v.patch, 0); + + let v = SchemaVersion::parse("999.999.999").unwrap(); + assert_eq!(v.major, 999); + assert_eq!(v.minor, 999); + assert_eq!(v.patch, 999); + } + + #[test] + fn test_schema_version_ord_edge_cases() { + let v1 = SchemaVersion::new(0, 0, 0); + let v2 = SchemaVersion::new(0, 0, 1); + assert!(v1 < v2); + + let v1 = SchemaVersion::new(1, 0, 0); + let v2 = SchemaVersion::new(1, 0, 0); + assert!(v1 <= v2); + assert!(v1 >= v2); + + let v1 = SchemaVersion::new(2, 0, 0); + let v2 = SchemaVersion::new(1, 999, 999); + assert!(v1 > v2); + } + + #[test] + fn test_schema_version_clone_and_equality() { + let v1 = SchemaVersion::new(1, 2, 3); + let v2 = &v1; + + assert_eq!(v1, *v2); + assert!(v1 >= *v2); + assert!(v1 <= *v2); + } + + #[test] + fn test_schema_version_roundtrip() { + let original = SchemaVersion::new(1, 2, 3); + let string = original.to_string(); + let parsed = SchemaVersion::parse(&string).unwrap(); + + assert_eq!(original, parsed); + } +} diff --git a/crates/yoop-core/src/update/package_manager.rs b/crates/yoop-core/src/update/package_manager.rs new file mode 100644 index 0000000..7dfa636 --- /dev/null +++ b/crates/yoop-core/src/update/package_manager.rs @@ -0,0 +1,271 @@ +//! Package manager detection and execution. + +use serde::{Deserialize, Serialize}; +use std::process::Command; + +use crate::config::UpdateConfig; +use crate::error::{Error, Result}; + +/// Supported package managers for installing Yoop updates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PackageManager { + /// Node Package Manager (npm). + Npm, + /// Performant npm (pnpm). + Pnpm, + /// Yarn package manager. + Yarn, + /// Bun JavaScript runtime and package manager. + Bun, +} + +impl PackageManager { + /// Detect the best package manager to use based on configuration and environment. + /// + /// # Errors + /// + /// Returns an error if the configured package manager is not found in PATH. + pub fn detect(config: &UpdateConfig) -> Result { + if let Some(pm_kind) = config.package_manager { + let pm: Self = pm_kind.into(); + if !pm.is_available() { + return Err(Error::Internal(format!( + "configured package manager '{pm}' not found in PATH" + ))); + } + return Ok(pm); + } + + if let Some(pm) = Self::detect_from_environment() { + return Ok(pm); + } + + Self::detect_from_availability() + } + + fn detect_from_environment() -> Option { + if let Ok(agent) = std::env::var("npm_config_user_agent") { + if agent.contains("pnpm") { + return Some(Self::Pnpm); + } + if agent.contains("yarn") { + return Some(Self::Yarn); + } + if agent.contains("bun") { + return Some(Self::Bun); + } + if agent.contains("npm") { + return Some(Self::Npm); + } + } + + if let Ok(pm) = std::env::var("YOOP_PACKAGE_MANAGER") { + match pm.to_lowercase().as_str() { + "npm" => return Some(Self::Npm), + "pnpm" => return Some(Self::Pnpm), + "yarn" => return Some(Self::Yarn), + "bun" => return Some(Self::Bun), + _ => {} + } + } + + None + } + + fn detect_from_availability() -> Result { + for pm in [Self::Pnpm, Self::Bun, Self::Yarn, Self::Npm] { + if pm.is_available() { + return Ok(pm); + } + } + + Err(Error::Internal( + "no package manager found in PATH (npm, pnpm, yarn, or bun)".to_string(), + )) + } + + /// Check if this package manager is available in PATH. + #[must_use] + pub fn is_available(self) -> bool { + Command::new(self.command_name()) + .arg("--version") + .output() + .is_ok() + } + + /// Get the command name for this package manager. + #[must_use] + pub fn command_name(self) -> &'static str { + match self { + Self::Npm => "npm", + Self::Pnpm => "pnpm", + Self::Yarn => "yarn", + Self::Bun => "bun", + } + } + + /// Build the command to update/install a package globally. + #[must_use] + pub fn update_command(self, package: &str, version: Option<&str>) -> Vec { + let pkg = version.map_or_else(|| package.to_string(), |v| format!("{package}@{v}")); + + match self { + Self::Npm => vec![ + "npm".to_string(), + "install".to_string(), + "-g".to_string(), + pkg, + ], + Self::Pnpm => vec!["pnpm".to_string(), "add".to_string(), "-g".to_string(), pkg], + Self::Yarn => vec![ + "yarn".to_string(), + "global".to_string(), + "add".to_string(), + pkg, + ], + Self::Bun => vec!["bun".to_string(), "add".to_string(), "-g".to_string(), pkg], + } + } + + /// Build the command to install a specific version of a package globally. + #[must_use] + pub fn install_command(self, package: &str, version: &str) -> Vec { + self.update_command(package, Some(version)) + } +} + +impl std::fmt::Display for PackageManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.command_name()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_package_manager_command_name() { + assert_eq!(PackageManager::Npm.command_name(), "npm"); + assert_eq!(PackageManager::Pnpm.command_name(), "pnpm"); + assert_eq!(PackageManager::Yarn.command_name(), "yarn"); + assert_eq!(PackageManager::Bun.command_name(), "bun"); + } + + #[test] + fn test_update_command() { + let npm = PackageManager::Npm; + assert_eq!( + npm.update_command("yoop", None), + vec!["npm", "install", "-g", "yoop"] + ); + assert_eq!( + npm.update_command("yoop", Some("0.2.0")), + vec!["npm", "install", "-g", "yoop@0.2.0"] + ); + + let pnpm = PackageManager::Pnpm; + assert_eq!( + pnpm.update_command("yoop", None), + vec!["pnpm", "add", "-g", "yoop"] + ); + + let yarn = PackageManager::Yarn; + assert_eq!( + yarn.update_command("yoop", None), + vec!["yarn", "global", "add", "yoop"] + ); + + let bun = PackageManager::Bun; + assert_eq!( + bun.update_command("yoop", None), + vec!["bun", "add", "-g", "yoop"] + ); + } + + #[test] + fn test_install_command() { + let npm = PackageManager::Npm; + assert_eq!( + npm.install_command("yoop", "0.1.3"), + vec!["npm", "install", "-g", "yoop@0.1.3"] + ); + + let pnpm = PackageManager::Pnpm; + assert_eq!( + pnpm.install_command("yoop", "0.1.3"), + vec!["pnpm", "add", "-g", "yoop@0.1.3"] + ); + + let yarn = PackageManager::Yarn; + assert_eq!( + yarn.install_command("yoop", "0.1.3"), + vec!["yarn", "global", "add", "yoop@0.1.3"] + ); + + let bun = PackageManager::Bun; + assert_eq!( + bun.install_command("yoop", "0.1.3"), + vec!["bun", "add", "-g", "yoop@0.1.3"] + ); + } + + #[test] + fn test_package_manager_display() { + assert_eq!(PackageManager::Npm.to_string(), "npm"); + assert_eq!(PackageManager::Pnpm.to_string(), "pnpm"); + assert_eq!(PackageManager::Yarn.to_string(), "yarn"); + assert_eq!(PackageManager::Bun.to_string(), "bun"); + } + + #[test] + fn test_detect_with_env_var() { + use std::env; + + let config = UpdateConfig::default(); + + env::set_var("YOOP_PACKAGE_MANAGER", "pnpm"); + let result = PackageManager::detect(&config); + env::remove_var("YOOP_PACKAGE_MANAGER"); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PackageManager::Pnpm); + } + + #[test] + fn test_detect_with_config() { + use crate::config::PackageManagerKind; + + let config = UpdateConfig { + package_manager: Some(PackageManagerKind::Npm), + ..Default::default() + }; + + let result = PackageManager::detect(&config); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PackageManager::Npm); + } + + #[test] + fn test_detect_from_user_agent() { + use std::env; + + let config = UpdateConfig::default(); + + env::set_var("npm_config_user_agent", "yarn/1.22.19 npm/? node/v18.0.0"); + let result = PackageManager::detect(&config); + env::remove_var("npm_config_user_agent"); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PackageManager::Yarn); + } + + #[test] + fn test_package_manager_equality() { + assert_eq!(PackageManager::Npm, PackageManager::Npm); + assert_ne!(PackageManager::Npm, PackageManager::Pnpm); + assert_ne!(PackageManager::Yarn, PackageManager::Bun); + } +} diff --git a/crates/yoop-core/src/update/version_check.rs b/crates/yoop-core/src/update/version_check.rs new file mode 100644 index 0000000..e0d0142 --- /dev/null +++ b/crates/yoop-core/src/update/version_check.rs @@ -0,0 +1,131 @@ +//! Version checking against npm registry. + +use reqwest::Client; +use semver::Version; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +use crate::config::Config; +use crate::error::{Error, Result}; + +const REGISTRY_URL: &str = "https://registry.npmjs.org/yoop/latest"; +const REQUEST_TIMEOUT_SECS: u64 = 10; + +/// Status of an update check. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateStatus { + /// Current installed version + pub current_version: Version, + /// Latest available version + pub latest_version: Version, + /// Whether an update is available + pub update_available: bool, + /// URL to the release page + pub release_url: String, +} + +/// Version checker for querying npm registry. +pub struct VersionChecker { + client: Client, + registry_url: String, +} + +impl VersionChecker { + /// Create a new version checker instance. + #[must_use] + pub fn new() -> Self { + Self { + client: Client::new(), + registry_url: REGISTRY_URL.to_string(), + } + } + + /// Check for updates by querying the npm registry. + /// + /// # Errors + /// + /// Returns an error if the version cannot be parsed, network request fails, or registry response is invalid. + pub async fn check(&self) -> Result { + let current = Version::parse(crate::VERSION) + .map_err(|e| Error::Internal(format!("failed to parse current version: {e}")))?; + + let resp: NpmPackageInfo = self + .client + .get(&self.registry_url) + .header("Accept", "application/json") + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) + .send() + .await + .map_err(|e| Error::Internal(format!("failed to check for updates: {e}")))? + .json() + .await + .map_err(|e| Error::Internal(format!("failed to parse registry response: {e}")))?; + + let latest = Version::parse(&resp.version) + .map_err(|e| Error::Internal(format!("failed to parse latest version: {e}")))?; + + let update_available = latest > current; + + Ok(UpdateStatus { + current_version: current, + latest_version: latest.clone(), + update_available, + release_url: format!("https://github.com/sanchxt/yoop/releases/tag/v{latest}"), + }) + } + + /// Check for updates with caching based on check interval. + /// + /// # Errors + /// + /// Returns an error if system time cannot be retrieved or update check fails. + pub async fn check_with_cache(&self, config: &mut Config) -> Result> { + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| Error::Internal(format!("system time error: {e}")))? + .as_secs(); + + if let Some(last_check) = config.update.last_check { + let elapsed = now.saturating_sub(last_check); + if elapsed < config.update.check_interval.as_secs() { + return Ok(None); + } + } + + let status = self.check().await?; + + config.update.last_check = Some(now); + config.save()?; + + Ok(Some(status)) + } +} + +impl Default for VersionChecker { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Deserialize)] +struct NpmPackageInfo { + version: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version_comparison() { + let v1 = Version::parse("0.1.3").unwrap(); + let v2 = Version::parse("0.2.0").unwrap(); + assert!(v2 > v1); + + let v1 = Version::parse("1.0.0").unwrap(); + let v2 = Version::parse("1.0.0").unwrap(); + assert_eq!(v1, v2); + } +} diff --git a/crates/yoop-core/tests/update_tests.rs b/crates/yoop-core/tests/update_tests.rs new file mode 100644 index 0000000..ea0afa5 --- /dev/null +++ b/crates/yoop-core/tests/update_tests.rs @@ -0,0 +1,282 @@ +//! Integration tests for the update functionality. + +#![cfg(feature = "update")] + +use tempfile::TempDir; +use yoop_core::config::{PackageManagerKind, UpdateConfig}; +use yoop_core::migration::{BackupManager, MigrationManager, MigrationState}; +use yoop_core::update::{package_manager::PackageManager, SchemaVersion}; + +#[test] +fn test_update_config_default() { + let config = UpdateConfig::default(); + + assert!(config.auto_check); + assert!(config.notify); + assert_eq!(config.check_interval.as_secs(), 24 * 60 * 60); + assert!(config.package_manager.is_none()); + assert!(config.last_check.is_none()); +} + +#[test] +fn test_update_config_with_package_manager() { + let config = UpdateConfig { + package_manager: Some(PackageManagerKind::Pnpm), + ..Default::default() + }; + + let result = PackageManager::detect(&config); + assert!(result.is_ok()); +} + +#[test] +fn test_backup_and_migration_workflow() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path().to_path_buf(); + + std::fs::write(data_dir.join("history.json"), r#"{"test": true}"#).unwrap(); + std::fs::write(data_dir.join("trust.json"), "{}").unwrap(); + + let manager = BackupManager::new_with_backup_dir(data_dir.clone(), data_dir.join("backups")); + + let backup_id = manager.create_backup("0.1.0").expect("create backup"); + + assert!(backup_id.contains("0.1.0")); + + let backups = manager.list_backups().expect("list backups"); + assert_eq!(backups.len(), 1); + assert_eq!(backups[0].id, backup_id); + + let backup_path = data_dir.join("backups").join(&backup_id); + assert!(backup_path.exists(), "Backup directory should exist"); + assert!( + backup_path.join("history.json").exists(), + "Backed up history should exist" + ); + + std::fs::write(data_dir.join("history.json"), r#"{"test": false}"#).unwrap(); + + let modified = std::fs::read_to_string(data_dir.join("history.json")).unwrap(); + assert_eq!(modified, r#"{"test": false}"#, "File should be modified"); + + manager.restore_backup(&backup_id).expect("restore backup"); + + let content = std::fs::read_to_string(data_dir.join("history.json")).unwrap(); + assert_eq!(content, r#"{"test": true}"#, "File should be restored"); +} + +#[test] +fn test_migration_state_tracking() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path(); + + let mut state = MigrationState::new(SchemaVersion::new(0, 1, 0), SchemaVersion::new(0, 1, 0)); + + assert_eq!(state.schema_version, SchemaVersion::new(0, 1, 0)); + assert!(state.history.is_empty()); + + state.add_history_entry(yoop_core::migration::MigrationHistoryEntry { + from_version: SchemaVersion::new(0, 1, 0), + to_version: SchemaVersion::new(0, 2, 0), + timestamp: chrono::Utc::now(), + backup_id: "backup1".to_string(), + success: true, + migrations_applied: vec!["migration1".to_string()], + }); + + assert_eq!(state.schema_version, SchemaVersion::new(0, 2, 0)); + assert_eq!(state.history.len(), 1); + assert_eq!(state.latest_backup(), Some("backup1")); + + state.save(data_dir).expect("save state"); + + let loaded = MigrationState::load(data_dir).expect("load state"); + + assert_eq!(loaded.schema_version, SchemaVersion::new(0, 2, 0)); + assert_eq!(loaded.history.len(), 1); +} + +#[test] +fn test_migration_manager_integration() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path().to_path_buf(); + + let manager = MigrationManager::new(data_dir); + + let from = SchemaVersion::new(0, 1, 0); + let to = SchemaVersion::new(0, 2, 0); + + let pending = manager.get_pending(&from, &to); + + #[cfg(feature = "update")] + { + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].from_version(), SchemaVersion::new(0, 1, 0)); + assert_eq!(pending[0].to_version(), SchemaVersion::new(0, 2, 0)); + assert_eq!(pending[0].description(), "Add [update] config section"); + } + + #[cfg(not(feature = "update"))] + { + assert_eq!(pending.len(), 0); + } +} + +#[test] +fn test_backup_cleanup_integration() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path().to_path_buf(); + + std::fs::write(data_dir.join("history.json"), "{}").unwrap(); + + let manager = BackupManager::new_with_backup_dir(data_dir.clone(), data_dir.join("backups")); + + let mut created_ids = Vec::new(); + for i in 0..7 { + let version = format!("0.1.{i}"); + let backup_id = manager.create_backup(&version).expect("create backup"); + created_ids.push(backup_id); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + + let backups = manager.list_backups().expect("list backups"); + + assert_eq!( + backups.len(), + 5, + "Should keep only the 5 most recent backups" + ); + + let backup_ids: Vec<&String> = backups.iter().map(|b| &b.id).collect(); + + for expected_id in &created_ids[2..] { + assert!( + backup_ids.contains(&expected_id), + "Should contain backup {expected_id}, but found: {backup_ids:?}" + ); + } + + for old_id in &created_ids[..2] { + assert!( + !backup_ids.contains(&old_id), + "Should not contain old backup {old_id}" + ); + } +} + +#[test] +fn test_rollback_integration() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path().to_path_buf(); + + std::fs::write(data_dir.join("history.json"), r#"{"version": 1}"#).unwrap(); + + let backup_manager = + BackupManager::new_with_backup_dir(data_dir.clone(), data_dir.join("backups")); + + let backup_id = backup_manager + .create_backup("0.1.0") + .expect("create backup"); + + std::fs::write(data_dir.join("history.json"), r#"{"version": 2}"#).unwrap(); + + backup_manager + .restore_backup(&backup_id) + .expect("restore failed"); + + let content = std::fs::read_to_string(data_dir.join("history.json")).unwrap(); + assert_eq!(content, r#"{"version": 1}"#); +} + +#[test] +fn test_multiple_migrations_in_sequence() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path().to_path_buf(); + + let manager = MigrationManager::new(data_dir); + + let v1 = SchemaVersion::new(0, 1, 0); + let v2 = SchemaVersion::new(0, 2, 0); + + let pending = manager.get_pending(&v1, &v2); + + #[cfg(feature = "update")] + { + assert!(!pending.is_empty()); + + for migration in &pending { + assert_eq!(migration.from_version(), v1); + assert_eq!(migration.to_version(), v2); + } + } + + #[cfg(not(feature = "update"))] + { + assert_eq!(pending.len(), 0); + } +} + +#[test] +fn test_schema_version_comparison_integration() { + let versions = [ + SchemaVersion::new(0, 1, 0), + SchemaVersion::new(0, 2, 0), + SchemaVersion::new(1, 0, 0), + SchemaVersion::new(1, 1, 0), + SchemaVersion::new(2, 0, 0), + ]; + + for i in 0..versions.len() - 1 { + assert!(versions[i] < versions[i + 1]); + assert!(versions[i + 1] > versions[i]); + } + + let v1 = SchemaVersion::new(1, 0, 0); + let v2 = SchemaVersion::new(1, 0, 0); + assert_eq!(v1, v2); +} + +#[test] +fn test_backup_with_missing_files() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path().to_path_buf(); + + std::fs::write(data_dir.join("history.json"), "{}").unwrap(); + + let manager = BackupManager::new_with_backup_dir(data_dir.clone(), data_dir.join("backups")); + + let result = manager.create_backup("0.1.0"); + + assert!(result.is_ok()); + + let backup_id = result.unwrap(); + let backups = manager.list_backups().expect("list backups"); + + assert_eq!(backups.len(), 1); + assert_eq!(backups[0].id, backup_id); + assert!(backups[0].files.contains(&"history.json".to_string())); + assert!(!backups[0].files.contains(&"trust.json".to_string())); +} + +#[test] +fn test_package_manager_detection_priority() { + use std::env; + + let config = UpdateConfig { + package_manager: Some(PackageManagerKind::Pnpm), + ..Default::default() + }; + let result = PackageManager::detect(&config); + assert!(result.is_ok()); + + let config = UpdateConfig::default(); + env::set_var("YOOP_PACKAGE_MANAGER", "yarn"); + let result = PackageManager::detect(&config); + env::remove_var("YOOP_PACKAGE_MANAGER"); + assert!(result.is_ok()); + + env::set_var("npm_config_user_agent", "bun/1.0.0"); + let result = PackageManager::detect(&config); + env::remove_var("npm_config_user_agent"); + assert!(result.is_ok()); +} From 030882a0ec49977c46daee6dd1e605d96541342e Mon Sep 17 00:00:00 2001 From: sanchxt Date: Mon, 12 Jan 2026 02:02:30 +0530 Subject: [PATCH 2/6] feat(migration): run migrations automatically at startup --- crates/yoop-core/src/config/mod.rs | 13 ++++++++- crates/yoop-core/src/migration/mod.rs | 39 ++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/yoop-core/src/config/mod.rs b/crates/yoop-core/src/config/mod.rs index 40ac409..e9836d1 100644 --- a/crates/yoop-core/src/config/mod.rs +++ b/crates/yoop-core/src/config/mod.rs @@ -410,10 +410,21 @@ impl Config { /// /// If the configuration file doesn't exist, returns the default configuration. /// + /// When the `update` feature is enabled, this function also runs any pending + /// migrations before loading the configuration. This ensures that config files + /// are migrated to the current version regardless of how the user updated + /// (npm install, yoop update, etc.). + /// /// # Errors /// - /// Returns an error if the configuration file exists but cannot be read or parsed. + /// Returns an error if the configuration file exists but cannot be read or parsed, + /// or if migrations fail. pub fn load() -> Result { + #[cfg(feature = "update")] + { + crate::migration::migrate_if_needed()?; + } + let path = Self::config_path(); if !path.exists() { return Ok(Self::default()); diff --git a/crates/yoop-core/src/migration/mod.rs b/crates/yoop-core/src/migration/mod.rs index ce248aa..3bba9fe 100644 --- a/crates/yoop-core/src/migration/mod.rs +++ b/crates/yoop-core/src/migration/mod.rs @@ -9,7 +9,7 @@ pub mod version; #[cfg(feature = "update")] pub mod migrations; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::error::Result; use crate::update::SchemaVersion; @@ -17,6 +17,43 @@ use crate::update::SchemaVersion; pub use backup::{BackupId, BackupInfo, BackupManager}; pub use version::{MigrationHistoryEntry, MigrationState}; +/// Get the application data directory. +#[must_use] +pub fn data_dir() -> Option { + directories::ProjectDirs::from("com", "yoop", "Yoop").map(|dirs| dirs.data_dir().to_path_buf()) +} + +/// Run any pending migrations if the app version is newer than the schema version. +/// +/// This function should be called at application startup (e.g., during config loading) +/// to ensure data files are migrated to the current version regardless of how the +/// user updated (npm install, yoop update, etc.). +/// +/// The function operates silently - it only returns an error if migration fails. +/// Successful migrations and "no migration needed" cases return `Ok(())`. +/// +/// # Errors +/// +/// Returns an error if migrations fail. On failure, attempts to restore from backup. +pub fn migrate_if_needed() -> Result<()> { + let data_dir = match data_dir() { + Some(dir) => dir, + None => return Ok(()), + }; + + let app_version = SchemaVersion::parse(crate::VERSION)?; + let state = MigrationState::load(&data_dir)?; + + if state.schema_version >= app_version { + return Ok(()); + } + + let manager = MigrationManager::new(data_dir); + manager.run(&state.schema_version, &app_version, true)?; + + Ok(()) +} + /// Trait for implementing database/schema migrations between versions. #[allow(clippy::wrong_self_convention)] pub trait Migration: Send + Sync { From 8612bf3382fff0a0747ada3846dfea1727c147f8 Mon Sep 17 00:00:00 2001 From: sanchxt Date: Mon, 12 Jan 2026 02:07:56 +0530 Subject: [PATCH 3/6] fix(migration): use idiomatic let...else pattern --- crates/yoop-core/src/migration/mod.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/yoop-core/src/migration/mod.rs b/crates/yoop-core/src/migration/mod.rs index 3bba9fe..e47e92f 100644 --- a/crates/yoop-core/src/migration/mod.rs +++ b/crates/yoop-core/src/migration/mod.rs @@ -36,9 +36,8 @@ pub fn data_dir() -> Option { /// /// Returns an error if migrations fail. On failure, attempts to restore from backup. pub fn migrate_if_needed() -> Result<()> { - let data_dir = match data_dir() { - Some(dir) => dir, - None => return Ok(()), + let Some(data_dir) = data_dir() else { + return Ok(()); }; let app_version = SchemaVersion::parse(crate::VERSION)?; From 8fbe4b1b7189f04c7ec777d5129bc9b7eca3f2c5 Mon Sep 17 00:00:00 2001 From: sanchxt Date: Mon, 12 Jan 2026 13:21:08 +0530 Subject: [PATCH 4/6] fix(ci): resolve documentation, rustls, and test failures - Fix rustdoc broken intra-doc links in migration docs by escaping [update] brackets - Fix rustls CryptoProvider panic by using only ring crypto provider - Disable default features that enable aws-lc-rs - Explicitly enable ring for tokio-rustls and rustls - Fix package_manager test to handle missing npm on CI runners Resolves CI failures in documentation build, cross-platform tests, and Windows tests. --- Cargo.lock | 57 ------------------- Cargo.toml | 4 +- .../src/migration/migrations/v0_1_to_v0_2.rs | 4 +- .../yoop-core/src/update/package_manager.rs | 8 ++- 4 files changed, 10 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d7dfa18..e2a97ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,28 +152,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" version = "0.8.7" @@ -339,8 +317,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -429,15 +405,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "cmake" -version = "0.1.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b042e5d8a74ae91bb0961acd039822472ec99f8ab0948cbf6d1369588f8be586" -dependencies = [ - "cc", -] - [[package]] name = "color_quant" version = "1.1.0" @@ -701,12 +668,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "ed25519" version = "2.2.3" @@ -865,12 +826,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures" version = "0.3.31" @@ -1436,16 +1391,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.83" @@ -2294,7 +2239,6 @@ version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ - "aws-lc-rs", "log", "once_cell", "ring", @@ -2329,7 +2273,6 @@ version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", diff --git a/Cargo.toml b/Cargo.toml index b2031e8..887ca98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,8 +20,8 @@ categories = ["command-line-utilities", "network-programming"] tokio = { version = "1.43", features = ["full"] } # TLS and crypto -tokio-rustls = "0.26" -rustls = "0.23" +tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "tls12", "ring"] } +rustls = { version = "0.23", default-features = false, features = ["logging", "std", "tls12", "ring"] } rustls-pemfile = "2" rcgen = "0.13" ed25519-dalek = { version = "2", features = ["rand_core"] } diff --git a/crates/yoop-core/src/migration/migrations/v0_1_to_v0_2.rs b/crates/yoop-core/src/migration/migrations/v0_1_to_v0_2.rs index 82edff6..0ef10d9 100644 --- a/crates/yoop-core/src/migration/migrations/v0_1_to_v0_2.rs +++ b/crates/yoop-core/src/migration/migrations/v0_1_to_v0_2.rs @@ -1,6 +1,6 @@ //! Migration from version 0.1.x to 0.2.x. //! -//! This migration adds the [update] section to the configuration file. +//! This migration adds the `[update]` section to the configuration file. use std::fs; use std::path::Path; @@ -10,7 +10,7 @@ use crate::error::Result; use crate::migration::Migration; use crate::update::SchemaVersion; -/// Migration from v0.1.x to v0.2.x adding [update] configuration section. +/// Migration from v0.1.x to v0.2.x adding `[update]` configuration section. #[allow(non_camel_case_types)] pub struct V0_1ToV0_2; diff --git a/crates/yoop-core/src/update/package_manager.rs b/crates/yoop-core/src/update/package_manager.rs index 7dfa636..226cc13 100644 --- a/crates/yoop-core/src/update/package_manager.rs +++ b/crates/yoop-core/src/update/package_manager.rs @@ -244,8 +244,12 @@ mod tests { let result = PackageManager::detect(&config); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), PackageManager::Npm); + if PackageManager::Npm.is_available() { + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PackageManager::Npm); + } else { + assert!(result.is_err()); + } } #[test] From fb86d0c46bf96a7383d5310a0b618c01686f2a4d Mon Sep 17 00:00:00 2001 From: sanchxt Date: Mon, 12 Jan 2026 13:39:36 +0530 Subject: [PATCH 5/6] fix(ci): handle missing package managers in integration tests Update test_update_config_with_package_manager and test_package_manager_detection_priority to check if package managers are available before asserting success. --- crates/yoop-core/tests/update_tests.rs | 29 ++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/yoop-core/tests/update_tests.rs b/crates/yoop-core/tests/update_tests.rs index ea0afa5..f24fd58 100644 --- a/crates/yoop-core/tests/update_tests.rs +++ b/crates/yoop-core/tests/update_tests.rs @@ -26,7 +26,13 @@ fn test_update_config_with_package_manager() { }; let result = PackageManager::detect(&config); - assert!(result.is_ok()); + + if PackageManager::Pnpm.is_available() { + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PackageManager::Pnpm); + } else { + assert!(result.is_err()); + } } #[test] @@ -267,16 +273,31 @@ fn test_package_manager_detection_priority() { ..Default::default() }; let result = PackageManager::detect(&config); - assert!(result.is_ok()); + if PackageManager::Pnpm.is_available() { + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PackageManager::Pnpm); + } else { + assert!(result.is_err()); + } let config = UpdateConfig::default(); env::set_var("YOOP_PACKAGE_MANAGER", "yarn"); let result = PackageManager::detect(&config); env::remove_var("YOOP_PACKAGE_MANAGER"); - assert!(result.is_ok()); + if PackageManager::Yarn.is_available() { + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PackageManager::Yarn); + } else { + assert!(result.is_err()); + } env::set_var("npm_config_user_agent", "bun/1.0.0"); let result = PackageManager::detect(&config); env::remove_var("npm_config_user_agent"); - assert!(result.is_ok()); + if PackageManager::Bun.is_available() { + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PackageManager::Bun); + } else { + assert!(result.is_err()); + } } From fa6ee7f685a44be252b1f11035898780bafb91d5 Mon Sep 17 00:00:00 2001 From: sanchxt Date: Mon, 12 Jan 2026 13:53:51 +0530 Subject: [PATCH 6/6] fix(ci): validate env-detected package managers and fix test races - Add availability check for environment-detected package managers in PackageManager::detect() for consistent behavior - Clear conflicting env vars in tests to prevent race conditions when tests run in parallel --- .../yoop-core/src/update/package_manager.rs | 23 +++++++++++++++---- crates/yoop-core/tests/update_tests.rs | 5 ++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/yoop-core/src/update/package_manager.rs b/crates/yoop-core/src/update/package_manager.rs index 226cc13..e3ada62 100644 --- a/crates/yoop-core/src/update/package_manager.rs +++ b/crates/yoop-core/src/update/package_manager.rs @@ -38,6 +38,11 @@ impl PackageManager { } if let Some(pm) = Self::detect_from_environment() { + if !pm.is_available() { + return Err(Error::Internal(format!( + "package manager '{pm}' from environment not found in PATH" + ))); + } return Ok(pm); } @@ -225,12 +230,17 @@ mod tests { let config = UpdateConfig::default(); + env::remove_var("npm_config_user_agent"); env::set_var("YOOP_PACKAGE_MANAGER", "pnpm"); let result = PackageManager::detect(&config); env::remove_var("YOOP_PACKAGE_MANAGER"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), PackageManager::Pnpm); + if PackageManager::Pnpm.is_available() { + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PackageManager::Pnpm); + } else { + assert!(result.is_err()); + } } #[test] @@ -258,12 +268,17 @@ mod tests { let config = UpdateConfig::default(); + env::remove_var("YOOP_PACKAGE_MANAGER"); env::set_var("npm_config_user_agent", "yarn/1.22.19 npm/? node/v18.0.0"); let result = PackageManager::detect(&config); env::remove_var("npm_config_user_agent"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), PackageManager::Yarn); + if PackageManager::Yarn.is_available() { + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PackageManager::Yarn); + } else { + assert!(result.is_err()); + } } #[test] diff --git a/crates/yoop-core/tests/update_tests.rs b/crates/yoop-core/tests/update_tests.rs index f24fd58..1d1c543 100644 --- a/crates/yoop-core/tests/update_tests.rs +++ b/crates/yoop-core/tests/update_tests.rs @@ -268,6 +268,9 @@ fn test_backup_with_missing_files() { fn test_package_manager_detection_priority() { use std::env; + env::remove_var("YOOP_PACKAGE_MANAGER"); + env::remove_var("npm_config_user_agent"); + let config = UpdateConfig { package_manager: Some(PackageManagerKind::Pnpm), ..Default::default() @@ -281,6 +284,7 @@ fn test_package_manager_detection_priority() { } let config = UpdateConfig::default(); + env::remove_var("npm_config_user_agent"); env::set_var("YOOP_PACKAGE_MANAGER", "yarn"); let result = PackageManager::detect(&config); env::remove_var("YOOP_PACKAGE_MANAGER"); @@ -291,6 +295,7 @@ fn test_package_manager_detection_priority() { assert!(result.is_err()); } + env::remove_var("YOOP_PACKAGE_MANAGER"); env::set_var("npm_config_user_agent", "bun/1.0.0"); let result = PackageManager::detect(&config); env::remove_var("npm_config_user_agent");