From f92365dc6c97c95965c2b1aad30395bd7bee5c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Fri, 18 Jul 2025 20:12:16 +0200 Subject: [PATCH 01/42] CLI Design Pattern (#1) --- Cargo.lock | 472 +++++++++++++++++++ Cargo.toml | 2 + README.md | 16 +- cli/Cargo.toml | 7 + cli/src/commands.rs | 5 + cli/src/commands/init.rs | 172 +++++++ cli/src/commands/meva_command.rs | 58 +++ cli/src/main.rs | 15 +- cli/src/meva_cli.rs | 84 ++++ engine/Cargo.toml | 2 + engine/src/errors.rs | 6 + engine/src/errors/engine_error.rs | 28 ++ engine/src/errors/init_error.rs | 13 + engine/src/lib.rs | 19 +- engine/src/repositories.rs | 5 + engine/src/repositories/meva_repository.rs | 230 +++++++++ engine/src/repositories/repository_layout.rs | 180 +++++++ shared/Cargo.toml | 3 + shared/src/extensions.rs | 3 + shared/src/extensions/fs.rs | 38 ++ shared/src/lib.rs | 16 +- 21 files changed, 1332 insertions(+), 42 deletions(-) create mode 100644 cli/src/commands.rs create mode 100644 cli/src/commands/init.rs create mode 100644 cli/src/commands/meva_command.rs create mode 100644 cli/src/meva_cli.rs create mode 100644 engine/src/errors.rs create mode 100644 engine/src/errors/engine_error.rs create mode 100644 engine/src/errors/init_error.rs create mode 100644 engine/src/repositories.rs create mode 100644 engine/src/repositories/meva_repository.rs create mode 100644 engine/src/repositories/repository_layout.rs create mode 100644 shared/src/extensions.rs create mode 100644 shared/src/extensions/fs.rs diff --git a/Cargo.lock b/Cargo.lock index 8bf84e6..60eb553 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.3" @@ -11,30 +26,140 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + [[package]] name = "cfg-if" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "clap" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + [[package]] name = "cli" version = "0.1.0" dependencies = [ + "clap", "engine", + "miette", "mockall", "plugins", "pretty_assertions", "rstest", "shared", + "thiserror", ] +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "diff" version = "0.1.13" @@ -55,6 +180,8 @@ dependencies = [ "pretty_assertions", "rstest", "shared", + "tempfile", + "thiserror", ] [[package]] @@ -63,6 +190,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fragile" version = "2.0.1" @@ -112,6 +255,24 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + [[package]] name = "glob" version = "0.3.2" @@ -146,12 +307,75 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "memchr" version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + [[package]] name = "mockall" version = "0.13.1" @@ -178,6 +402,33 @@ dependencies = [ "syn", ] +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "owo-colors" +version = "4.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -263,6 +514,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "regex" version = "1.11.1" @@ -328,6 +585,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rustc-demangle" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" + [[package]] name = "rustc_version" version = "0.4.1" @@ -337,6 +600,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "semver" version = "1.0.26" @@ -350,6 +626,7 @@ dependencies = [ "mockall", "pretty_assertions", "rstest", + "tempfile", ] [[package]] @@ -358,6 +635,33 @@ version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "syn" version = "2.0.104" @@ -369,12 +673,65 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "terminal_size" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" +dependencies = [ + "rustix", + "windows-sys", +] + [[package]] name = "termtree" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.1", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -398,6 +755,112 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.12" @@ -407,6 +870,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 4f0d42c..7c3b31b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,8 @@ members = ["engine", "cli", "gui", "plugins", "shared"] authors = ["Mikołaj Karbowski", "Adam Grącikowski"] [workspace.dependencies] +thiserror = "2" rstest = "0.25.0" mockall = "0.13.1" pretty_assertions = "1.4.1" +tempfile = "3.20.0" diff --git a/README.md b/README.md index f270385..ad9c610 100644 --- a/README.md +++ b/README.md @@ -36,16 +36,6 @@ meva/ └── ... ``` -It consists of 5 crates: - -- Library crates: - - `shared`, - - `engine`, - - `plugins`. -- Binary crates: - - `cli`, - - `gui`. - ## Getting Started > The installation guide assumes you have already installed [Rust](https://www.rust-lang.org/learn/get-started). @@ -128,17 +118,17 @@ To share a dependency (or dev-dependency) version across multiple crates, declar ```toml [workspace.dependencies] - = +regex = "1.11.1" ``` In any crate that should use a workspace-provided dependency, reference it like so in its own `Cargo.toml`: ```toml [dependencies] - = { workspace = true } +regex = { workspace = true } [dev-dependencies] -.workspace = true # alternative syntax +rstest.workspace = true # alternative syntax ``` ### Generating docs diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 0917a37..9d1c2ed 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -4,10 +4,17 @@ version = "0.1.0" edition = "2024" authors.workspace = true +[[bin]] +name = "meva" +path = "src/main.rs" + [dependencies] shared = { path = "../shared" } engine = { path = "../engine" } plugins = { path = "../plugins" } +clap = "4.5.41" +thiserror.workspace = true +miette = { version = "7.6.0", features = ["fancy"] } [dev-dependencies] rstest.workspace = true diff --git a/cli/src/commands.rs b/cli/src/commands.rs new file mode 100644 index 0000000..65a828b --- /dev/null +++ b/cli/src/commands.rs @@ -0,0 +1,5 @@ +pub mod init; +pub mod meva_command; + +pub use init::InitCommand; +pub use meva_command::MevaCommand; diff --git a/cli/src/commands/init.rs b/cli/src/commands/init.rs new file mode 100644 index 0000000..e6e1b3d --- /dev/null +++ b/cli/src/commands/init.rs @@ -0,0 +1,172 @@ +use clap::{Arg, ArgMatches, Command}; +use miette::{IntoDiagnostic, Result}; +use std::path::PathBuf; + +use crate::commands::MevaCommand; + +use engine::MevaRepository; + +/// Represents the `init` command for Meva DVCS. +/// +/// This command initializes a new empty repository at the specified path, +/// optionally setting the initial branch name. +pub struct InitCommand; + +impl InitCommand { + /// Creates a new instance of the `InitCommand`. + pub fn new() -> Self { + Self + } + + /// Argument name for specifying the initial branch. + const ARG_BRANCH: &'static str = "initial-branch"; + + /// Argument name for specifying the repository path. + const ARG_PATH: &'static str = "path"; +} + +impl MevaCommand for InitCommand { + fn name(&self) -> &'static str { + "init" + } + + fn about(&self) -> &'static str { + "Create an empty Meva repository" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `init` command using `clap`. + /// + /// Adds two arguments: + /// - `-b, --initial-branch `: Name of the initial branch (default: "master") + /// - ``: Path to initialize the repository (default: current directory) + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_BRANCH) + .short('b') + .long("initial-branch") + .value_name("BRANCH") + .help("Name of the initial branch") + .default_value("master") + .require_equals(false), + ) + .arg( + Arg::new(Self::ARG_PATH) + .value_name("PATH") + .help("Path to initialize repository") + .default_value(".") + .value_parser(clap::value_parser!(PathBuf)) + .index(1), + ) + } + + /// Executes the `init` command. + /// + /// Initializes a new Meva repository at the specified path using the provided + /// initial branch name. If the repository already exists or an error occurs + /// during initialization, the error is reported. + /// + /// # Parameters + /// - `matches`: Parsed command-line arguments containing: + /// - `initial-branch`: The name of the initial branch (defaults to "master"). + /// - `path`: The target directory to initialize the repository (defaults to current dir). + /// + /// # Returns + /// - `Result<()>`: Indicates success or detailed error if initialization fails. + fn execute(&self, matches: &ArgMatches) -> Result<()> { + let branch = matches.get_one::(Self::ARG_BRANCH).unwrap(); + let target = matches.get_one::(Self::ARG_PATH).unwrap(); + + let repository = MevaRepository::new(target); + repository.init(branch).into_diagnostic()?; + + println!("Repository initialized successfully!"); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + use std::path::PathBuf; + + fn get_matches_from(args: &[&str]) -> ArgMatches { + let cmd = InitCommand::new(); + cmd.build_command().try_get_matches_from(args).unwrap() + } + + #[rstest] + fn test_command_name_about_version() { + let cmd = InitCommand::new(); + assert_eq!(cmd.name(), "init"); + assert_eq!(cmd.about(), "Create an empty Meva repository"); + assert_eq!(cmd.version(), "1.0.0"); + } + + #[rstest] + fn test_command_builds_with_expected_args() { + let cmd = InitCommand::new(); + let clap_cmd = cmd.build_command(); + + // Check command name + assert_eq!(clap_cmd.get_name(), "init"); + + // Check arguments exist + assert!( + clap_cmd + .get_arguments() + .any(|a| a.get_id() == InitCommand::ARG_BRANCH) + ); + assert!( + clap_cmd + .get_arguments() + .any(|a| a.get_id() == InitCommand::ARG_PATH) + ); + + // Check default values for args + let branch_arg = clap_cmd + .get_arguments() + .find(|a| a.get_id() == InitCommand::ARG_BRANCH) + .unwrap(); + assert_eq!( + branch_arg.get_default_values().first().map(|v| v.to_str()), + Some(Some("master")) + ); + + let path_arg = clap_cmd + .get_arguments() + .find(|a| a.get_id() == InitCommand::ARG_PATH) + .unwrap(); + assert_eq!( + path_arg.get_default_values().first().map(|v| v.to_str()), + Some(Some(".")) + ); + } + + #[rstest] + #[case(&["init"], "master", ".")] + #[case(&["init", "-b", "develop"], "develop", ".")] + #[case(&["init", "--initial-branch=feature"], "feature", ".")] + #[case(&["init", "-b", "dev", "./repo_path"], "dev", "./repo_path")] + #[case(&["init", "./some_path"], "master", "./some_path")] + fn test_execute_parses_args_correctly( + #[case] args: &[&str], + #[case] expected_branch: &str, + #[case] expected_path: &str, + ) { + let matches = get_matches_from(args); + + let branch = matches.get_one::(InitCommand::ARG_BRANCH).unwrap(); + let path = matches.get_one::(InitCommand::ARG_PATH).unwrap(); + + assert_eq!(branch, expected_branch); + assert_eq!(path.to_str().unwrap(), expected_path); + } +} diff --git a/cli/src/commands/meva_command.rs b/cli/src/commands/meva_command.rs new file mode 100644 index 0000000..ffaa470 --- /dev/null +++ b/cli/src/commands/meva_command.rs @@ -0,0 +1,58 @@ +use clap::{ArgMatches, Command}; +use miette::Result; + +/// A trait representing a top-level command in the Meva CLI. +pub trait MevaCommand { + /// Returns the unique name of the command. + fn name(&self) -> &'static str; + + /// Returns a brief description of what the command does. + fn about(&self) -> &'static str; + + /// Returns the version string for the command. + fn version(&self) -> &'static str; + + /// Builds and returns the `clap::Command` for this command. + fn build_command(&self) -> Command { + self.build_base_command() + } + + /// Builds a basic `clap::Command` with name, description, and version. + fn build_base_command(&self) -> Command { + Command::new(self.name()) + .about(self.about()) + .version(self.version()) + } + + /// Executes this command or delegates to a matching subcommand if present. + /// + /// This method inspects the parsed `ArgMatches` to determine whether a subcommand + /// was invoked. If so, it looks for a matching registered subcommand and calls its + /// `execute` method with the corresponding argument matches. + /// + /// If no subcommand is matched, it returns `Ok(())` by default, meaning no operation was performed. + /// + /// # Parameters + /// - `matches`: The parsed CLI arguments for this command, including any subcommand matches. + /// + /// # Returns + /// - `Result<()>`: Indicates whether the execution succeeded or an error occurred during dispatch. + fn execute(&self, matches: &ArgMatches) -> Result<()> { + if let Some((name, sub_matches)) = matches.subcommand() { + for sub_command in self.subcommands() { + if sub_command.name() == name { + return sub_command.execute(sub_matches); + } + } + } + + Ok(()) + } + + /// Returns a vector of boxed subcommands for this command. + /// + /// Default implementation returns an empty vector. + fn subcommands(&self) -> Vec> { + Vec::new() + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index e7a11a9..d043f6e 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,3 +1,14 @@ -fn main() { - println!("Hello, world!"); +mod commands; +mod meva_cli; + +use crate::meva_cli::MevaCli; +use commands::InitCommand; +use miette::Result; + +fn main() -> Result<()> { + miette::set_panic_hook(); + + let mut cli = MevaCli::new(); + cli.add_command(Box::new(InitCommand::new())); + cli.run() } diff --git a/cli/src/meva_cli.rs b/cli/src/meva_cli.rs new file mode 100644 index 0000000..54c45be --- /dev/null +++ b/cli/src/meva_cli.rs @@ -0,0 +1,84 @@ +use clap::{Command, error::ErrorKind}; +use miette::{IntoDiagnostic, Result, WrapErr, miette}; + +use crate::commands::MevaCommand; + +/// Represents the top-level CLI application for Meva DVCS. +/// +/// This struct manages registration of commands, building the CLI parser, +/// and dispatching command execution based on user input. +pub struct MevaCli { + /// Registered commands available in the CLI. + commands: Vec>, +} + +impl MevaCli { + /// Creates a new instance of the Meva CLI application with no commands registered. + pub fn new() -> Self { + Self { + commands: Vec::new(), + } + } + + /// Adds a new command to the CLI application. + /// + /// Commands must implement the `MevaCommand` trait and are stored as trait objects. + /// + /// # Parameters + /// - `command`: The boxed command to register. + pub fn add_command(&mut self, command: Box) { + self.commands.push(command); + } + + /// Builds the complete CLI parser using `clap::Command`. + /// + /// Registers all added commands as subcommands, + /// configures top-level metadata like name, version, and help behavior. + /// + /// # Returns + /// - A fully configured `clap::Command` ready to parse CLI arguments. + fn build_cli(&self) -> Command { + let mut cli = Command::new("meva") + .about("Meva distributed version control system") + .version("1.0.0") + .subcommand_required(true) + .arg_required_else_help(true); + + for command in &self.commands { + cli = cli.subcommand(command.build_command()); + } + + cli + } + + /// Runs the CLI application. + /// + /// This method builds the CLI parser, parses the command-line arguments, + /// dispatches execution to the matched command, and returns the result. + pub fn run(&self) -> Result<()> { + let matches = match self.build_cli().try_get_matches() { + Ok(m) => m, + Err(err) => { + return match err.kind() { + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => { + err.print().into_diagnostic()?; + Ok(()) + } + _ => Err(miette!("Failed to parse command-line arguments:\n{}", err)), + }; + } + }; + + if let Some((name, sub_matches)) = matches.subcommand() { + for cmd in &self.commands { + if cmd.name() == name { + return cmd + .execute(sub_matches) + .wrap_err_with(|| format!("Error running `{name}` command")); + } + } + } + + Ok(()) + } +} diff --git a/engine/Cargo.toml b/engine/Cargo.toml index d29147f..deda1a6 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -6,6 +6,8 @@ authors.workspace = true [dependencies] shared = { path = "../shared" } +tempfile.workspace = true +thiserror.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/engine/src/errors.rs b/engine/src/errors.rs new file mode 100644 index 0000000..abb79fc --- /dev/null +++ b/engine/src/errors.rs @@ -0,0 +1,6 @@ +pub mod engine_error; +pub mod init_error; + +pub use engine_error::EngineError; +pub use engine_error::Result as EngineResult; +pub use init_error::InitError; diff --git a/engine/src/errors/engine_error.rs b/engine/src/errors/engine_error.rs new file mode 100644 index 0000000..84a0864 --- /dev/null +++ b/engine/src/errors/engine_error.rs @@ -0,0 +1,28 @@ +use std::io; +use thiserror::Error; + +use crate::errors::InitError; + +/// A convenient result type alias for engine-related operations. +pub type Result = std::result::Result; + +/// Represents all possible errors that can occur in the engine. +/// +/// These errors are unified under a single type to simplify error handling +/// across components. +#[derive(Error, Debug)] +pub enum EngineError { + /// An I/O error occurred, such as failing to read from or write to disk. + /// Automatically converted from `std::io::Error`. + #[error(transparent)] + Io(#[from] io::Error), + + /// An error occurred during the initialization of the Meva repository. + #[error(transparent)] + Init(#[from] InitError), + + /// A catch-all variant for any unknown or unexpected engine error. + /// Accepts a descriptive string message. + #[error("Unknown Engine error: {0}")] + Unknown(String), +} diff --git a/engine/src/errors/init_error.rs b/engine/src/errors/init_error.rs new file mode 100644 index 0000000..6c65a1c --- /dev/null +++ b/engine/src/errors/init_error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +/// Represents errors that can occur during Meva repository initialization. +#[derive(Error, Debug)] +pub enum InitError { + /// Attempted to initialize a repository at a location that already contains one. + #[error("Repository already initialized at `{path}`")] + AlreadyInitialized { path: String }, + + /// The provided working directory either does not exist or is not a directory. + #[error("Invalid working directory: `{path}`")] + InvalidWorkingDir { path: String }, +} diff --git a/engine/src/lib.rs b/engine/src/lib.rs index ab976c8..944d4d5 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,15 +1,8 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +pub mod errors; +pub mod repositories; -#[cfg(test)] -mod tests { - use super::*; - use rstest::*; +pub use errors::EngineError; +pub use errors::EngineResult; +use errors::InitError; - #[rstest] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub use repositories::MevaRepository; diff --git a/engine/src/repositories.rs b/engine/src/repositories.rs new file mode 100644 index 0000000..906a8da --- /dev/null +++ b/engine/src/repositories.rs @@ -0,0 +1,5 @@ +pub mod meva_repository; +mod repository_layout; + +pub use meva_repository::MevaRepository; +use repository_layout::RepositoryLayout; diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs new file mode 100644 index 0000000..65e8346 --- /dev/null +++ b/engine/src/repositories/meva_repository.rs @@ -0,0 +1,230 @@ +use shared::fs::create_file_with_dirs; +use tempfile::TempDir; + +use crate::repositories::RepositoryLayout; +use crate::{EngineError, InitError}; + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use crate::EngineResult; + +/// Represents a Meva repository on disk. +/// +/// Provides functionality to initialize repository layout and manage +/// directory structure and configuration files. +pub struct MevaRepository { + working_dir: PathBuf, +} + +impl MevaRepository { + /// Creates a new `MevaRepository` for the given working directory. + pub fn new>(working_dir: P) -> Self { + Self { + working_dir: working_dir.into(), + } + } + + /// Initializes a new repository with the given initial branch. + /// + /// Creates required directories and files under `.meva/`. + /// Returns an error if the repository is already initialized + /// or the working directory is invalid. + pub fn init(&self, initial_branch: &str) -> EngineResult<()> { + if !self.working_dir.exists() || !self.working_dir.is_dir() { + return Err(InitError::InvalidWorkingDir { + path: self.working_dir.to_string_lossy().into(), + } + .into()); + } + + let repository_dir = self.repository_dir(); + + if repository_dir.exists() { + return Err(InitError::AlreadyInitialized { + path: repository_dir.to_string_lossy().into(), + } + .into()); + } + + let tmp_parent = &self.working_dir; + let tmp_dir = TempDir::new_in(tmp_parent).map_err(EngineError::Io)?; + + let tmp_repo = tmp_dir.path().join(Self::REPOSITORY_DIR); + + self.create_dirs_at(&tmp_dir)?; + self.create_files_at(&tmp_dir, initial_branch)?; + + let final_repo = self.repository_dir(); + + fs::rename(&tmp_repo, &final_repo).map_err(EngineError::Io)?; + + Ok(()) + } + + /// Creates the internal repository directories. + fn create_dirs_at>(&self, root: P) -> EngineResult<()> { + fs::create_dir_all(root.as_ref().join(self.objects_dir_rel()))?; + fs::create_dir_all(root.as_ref().join(self.refs_dir_rel()))?; + fs::create_dir_all(root.as_ref().join(self.heads_refs_dir_rel()))?; + fs::create_dir_all(root.as_ref().join(self.logs_dir_rel()))?; + fs::create_dir_all(root.as_ref().join(self.heads_logs_dir_rel()))?; + Ok(()) + } + + /// Creates the internal repository files. + fn create_files_at>(&self, root: P, initial_branch: &str) -> EngineResult<()> { + let ref_path = root + .as_ref() + .join(self.heads_refs_dir_rel()) + .join(initial_branch); + let log_path = root + .as_ref() + .join(self.heads_logs_dir_rel()) + .join(initial_branch); + + create_file_with_dirs(ref_path)?; + create_file_with_dirs(log_path)?; + + let head_path = root.as_ref().join(self.head_file_rel()); + let mut head = fs::File::create(&head_path)?; + + write!( + head, + "ref: {}/{}/{}", + Self::REFS_DIR, + Self::HEADS_DIR, + initial_branch + )?; + + let head_log_path = root.as_ref().join(self.head_logs_file_rel()); + fs::File::create(head_log_path)?; + + Ok(()) + } +} + +impl RepositoryLayout for MevaRepository { + const REPOSITORY_DIR: &'static str = ".meva"; + + const OBJECTS_DIR: &'static str = "objects"; + + const REFS_DIR: &'static str = "refs"; + + const LOGS_DIR: &'static str = "logs"; + + const HEADS_DIR: &'static str = "heads"; + + const HEAD_FILE: &'static str = "HEAD"; + + /// Returns the repository’s working directory path. + fn working_dir(&self) -> &std::path::Path { + &self.working_dir + } +} + +#[cfg(test)] +mod tests { + use crate::EngineError; + + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + use std::fs::{self, File}; + use std::io::Read; + use tempfile::TempDir; + + fn read_file(path: &PathBuf) -> String { + let mut content = String::new(); + let mut file = fs::File::open(path).expect("File should exist"); + file.read_to_string(&mut content) + .expect("Failed to read file"); + content + } + + #[rstest] + fn init_creates_expected_structure() { + let tmp = TempDir::new().unwrap(); + let repo = MevaRepository::new(tmp.path()); + + let result = repo.init("main"); + assert!(result.is_ok()); + + let repo_dir = tmp.path().join(".meva"); + assert!(repo_dir.exists()); + + assert!(repo.objects_dir().exists()); + assert!(repo.refs_dir().exists()); + assert!(repo.heads_refs_dir().exists()); + assert!(repo.logs_dir().exists()); + assert!(repo.heads_logs_dir().exists()); + + let head_path = repo.head_file(); + assert!(head_path.exists()); + + let head_contents = read_file(&head_path); + assert_eq!(head_contents, "ref: refs/heads/main"); + + assert!(repo.head_logs_file().exists()); + + let branch_ref = repo.heads_refs_dir().join("main"); + assert!(branch_ref.exists()); + + let branch_log = repo.heads_logs_dir().join("main"); + assert!(branch_log.exists()); + } + + #[rstest] + fn init_fails_if_repo_already_exists() { + let tmp = TempDir::new().expect("failed to create TempDir"); + let repo = MevaRepository::new(tmp.path()); + + // First init succeeds + assert!(repo.init("dev").is_ok()); + + // Second init should fail + let result = repo.init("dev"); + assert!(result.is_err()); + + if let Err(err) = result { + let message = format!("{err}"); + assert!( + message.contains("already initialized"), + "Unexpected error: {message}" + ); + } + } + + #[rstest] + fn init_fails_if_nonexistent_working_dir() { + let tmp = TempDir::new().expect("failed to create TempDir"); + let bad_path = tmp.path().join("does_not_exist"); + + let repo = MevaRepository::new(&bad_path); + + let err = repo.init("main").unwrap_err(); + assert!(matches!( + err, + EngineError::Init(InitError::InvalidWorkingDir { path }) + if path == bad_path.to_string_lossy() + )); + } + + #[rstest] + fn init_fails_if_path_is_a_file() { + let tmp = TempDir::new().expect("failed to create TempDir"); + let file_path = tmp.path().join("not_a_dir.txt"); + + File::create(&file_path).expect("failed to create test file"); + + let repo = MevaRepository::new(&file_path); + let err = repo.init("main").unwrap_err(); + + assert!(matches!( + err, + EngineError::Init(InitError::InvalidWorkingDir { path }) + if path == file_path.to_string_lossy() + )); + } +} diff --git a/engine/src/repositories/repository_layout.rs b/engine/src/repositories/repository_layout.rs new file mode 100644 index 0000000..00a2101 --- /dev/null +++ b/engine/src/repositories/repository_layout.rs @@ -0,0 +1,180 @@ +use std::path::{Path, PathBuf}; + +/// Defines the directory layout of a Meva repository. +/// +/// Provides methods to compute paths to internal repository components +/// i.e. absolute and relative to a working directory. +#[allow(dead_code)] +pub trait RepositoryLayout { + /// Name of the root repository directory. + const REPOSITORY_DIR: &'static str; + + /// Subdirectory for storing objects. + const OBJECTS_DIR: &'static str; + + /// Subdirectory for storing references. + const REFS_DIR: &'static str; + + /// Subdirectory for reference logs. + const LOGS_DIR: &'static str; + + /// Subdirectory for storing heads references and logs. + const HEADS_DIR: &'static str; + + /// Name of the HEAD file. + const HEAD_FILE: &'static str; + + /// Returns the working directory where the repository is located. + fn working_dir(&self) -> &Path; + + // --- relative paths --- + + /// Returns the relative path to the repository directory. + fn repository_dir_rel(&self) -> PathBuf { + PathBuf::from(Self::REPOSITORY_DIR) + } + + /// Path to the objects directory relative to `working_dir`. + fn objects_dir_rel(&self) -> PathBuf { + self.repository_dir_rel().join(Self::OBJECTS_DIR) + } + + /// Path to the refs directory relative to `working_dir`. + fn refs_dir_rel(&self) -> PathBuf { + self.repository_dir_rel().join(Self::REFS_DIR) + } + + /// Path to heads in refs relative to `working_dir`. + fn heads_refs_dir_rel(&self) -> PathBuf { + self.refs_dir_rel().join(Self::HEADS_DIR) + } + + /// Path to the logs directory relative to `working_dir`. + fn logs_dir_rel(&self) -> PathBuf { + self.repository_dir_rel().join(Self::LOGS_DIR) + } + + /// Path to heads in logs relative to `working_dir`. + fn heads_logs_dir_rel(&self) -> PathBuf { + self.logs_dir_rel().join(Self::HEADS_DIR) + } + + /// Path to the HEAD file relative to `working_dir`. + fn head_file_rel(&self) -> PathBuf { + self.repository_dir_rel().join(Self::HEAD_FILE) + } + + /// Path to the HEAD log file relative to `working_dir`. + fn head_logs_file_rel(&self) -> PathBuf { + self.logs_dir_rel().join(Self::HEAD_FILE) + } + + // --- absolute paths --- + + /// Absolute path to the repository directory. + fn repository_dir(&self) -> PathBuf { + self.working_dir().join(self.repository_dir_rel()) + } + + /// Absolute path to the objects directory. + fn objects_dir(&self) -> PathBuf { + self.working_dir().join(self.objects_dir_rel()) + } + + /// Absolute path to the refs directory. + fn refs_dir(&self) -> PathBuf { + self.working_dir().join(self.refs_dir_rel()) + } + + /// Absolute path to heads in refs. + fn heads_refs_dir(&self) -> PathBuf { + self.working_dir().join(self.heads_refs_dir_rel()) + } + + /// Absolute path to the logs directory. + fn logs_dir(&self) -> PathBuf { + self.working_dir().join(self.logs_dir_rel()) + } + + /// Absolute path to heads in logs. + fn heads_logs_dir(&self) -> PathBuf { + self.working_dir().join(self.heads_logs_dir_rel()) + } + + /// Absolute path to the HEAD file. + fn head_file(&self) -> PathBuf { + self.working_dir().join(self.head_file_rel()) + } + + /// Absolute path to the HEAD log file. + fn head_logs_file(&self) -> PathBuf { + self.working_dir().join(self.head_logs_file_rel()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + use std::path::PathBuf; + + struct TestRepoLayout { + dir: PathBuf, + } + + impl TestRepoLayout { + fn new>(path: P) -> Self { + Self { dir: path.into() } + } + } + + impl RepositoryLayout for TestRepoLayout { + const REPOSITORY_DIR: &'static str = ".meva"; + const OBJECTS_DIR: &'static str = "objects"; + const REFS_DIR: &'static str = "refs"; + const LOGS_DIR: &'static str = "logs"; + const HEADS_DIR: &'static str = "heads"; + + const HEAD_FILE: &'static str = "HEAD"; + + fn working_dir(&self) -> &Path { + &self.dir + } + } + + #[rstest] + fn repository_paths_are_correct() { + let base = PathBuf::from("/tmp/project"); + let layout = TestRepoLayout::new(&base); + + // relative paths + assert_eq!(layout.repository_dir_rel(), PathBuf::from(".meva")); + assert_eq!(layout.objects_dir_rel(), PathBuf::from(".meva/objects")); + assert_eq!(layout.refs_dir_rel(), PathBuf::from(".meva/refs")); + assert_eq!( + layout.heads_refs_dir_rel(), + PathBuf::from(".meva/refs/heads") + ); + assert_eq!(layout.logs_dir_rel(), PathBuf::from(".meva/logs")); + assert_eq!( + layout.heads_logs_dir_rel(), + PathBuf::from(".meva/logs/heads") + ); + assert_eq!(layout.head_file_rel(), PathBuf::from(".meva/HEAD")); + assert_eq!( + layout.head_logs_file_rel(), + PathBuf::from(".meva/logs/HEAD") + ); + + // absolute paths + assert_eq!(layout.repository_dir(), base.join(".meva")); + assert_eq!(layout.objects_dir(), base.join(".meva/objects")); + assert_eq!(layout.refs_dir(), base.join(".meva/refs")); + assert_eq!(layout.heads_refs_dir(), base.join(".meva/refs/heads")); + assert_eq!(layout.logs_dir(), base.join(".meva/logs")); + assert_eq!(layout.heads_logs_dir(), base.join(".meva/logs/heads")); + assert_eq!(layout.head_file(), base.join(".meva/HEAD")); + assert_eq!(layout.head_logs_file(), base.join(".meva/logs/HEAD")); + } +} diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 17916db..0239b43 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -4,6 +4,9 @@ version = "0.1.0" edition = "2024" authors.workspace = true +[dependencies] +tempfile.workspace = true + [dev-dependencies] rstest.workspace = true mockall.workspace = true diff --git a/shared/src/extensions.rs b/shared/src/extensions.rs new file mode 100644 index 0000000..8114773 --- /dev/null +++ b/shared/src/extensions.rs @@ -0,0 +1,3 @@ +pub mod fs; + +pub use fs::create_file_with_dirs; diff --git a/shared/src/extensions/fs.rs b/shared/src/extensions/fs.rs new file mode 100644 index 0000000..2bf3638 --- /dev/null +++ b/shared/src/extensions/fs.rs @@ -0,0 +1,38 @@ +use std::fs; +use std::io; +use std::path::Path; + +/// Creates a file at the given path, ensuring all parent directories exist. +/// +/// # Arguments +/// +/// * `path` - Target path for the file (including filename). +/// +/// # Errors +/// +/// Returns an `io::Error` if creating parent directories or the file itself fails. +pub fn create_file_with_dirs>(path: P) -> io::Result<()> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::File::create(path)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use tempfile::TempDir; + + #[rstest] + fn creates_file_and_nested_directories() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("a").join("b").join("c.txt"); + + let result = create_file_with_dirs(&path); + assert!(result.is_ok()); + assert!(path.exists()); + } +} diff --git a/shared/src/lib.rs b/shared/src/lib.rs index ab976c8..44b9248 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -1,15 +1,3 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +pub mod extensions; -#[cfg(test)] -mod tests { - use super::*; - use rstest::*; - - #[rstest] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub use extensions::fs; From ba36609a7b2ec2b4fc84d351defa92b0d3f9b28c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Mon, 21 Jul 2025 20:55:19 +0200 Subject: [PATCH 02/42] Command `config` (#2) --- Cargo.lock | 211 +++++++++++++- Cargo.toml | 4 + cli/Cargo.toml | 1 + cli/src/commands.rs | 2 + cli/src/commands/config.rs | 62 +++++ cli/src/commands/config/subcommands.rs | 11 + cli/src/commands/config/subcommands/edit.rs | 108 ++++++++ cli/src/commands/config/subcommands/get.rs | 91 ++++++ cli/src/commands/config/subcommands/list.rs | 79 ++++++ cli/src/commands/config/subcommands/set.rs | 84 ++++++ cli/src/commands/config/subcommands/unset.rs | 73 +++++ cli/src/commands/init.rs | 18 +- cli/src/commands/meva_command.rs | 28 +- cli/src/extensions.rs | 5 + cli/src/extensions/arg_matches.rs | 3 + .../arg_matches/location_selection.rs | 79 ++++++ cli/src/extensions/command.rs | 5 + cli/src/extensions/command/with_key.rs | 33 +++ cli/src/extensions/command/with_locations.rs | 158 +++++++++++ cli/src/main.rs | 6 +- cli/src/meva_cli.rs | 4 +- engine/Cargo.toml | 4 + engine/src/config.rs | 9 + engine/src/config/config_document.rs | 258 ++++++++++++++++++ engine/src/config/config_loader.rs | 84 ++++++ engine/src/config/config_location.rs | 73 +++++ engine/src/config/config_operations.rs | 46 ++++ engine/src/errors.rs | 5 +- engine/src/errors/config_error.rs | 55 ++++ engine/src/errors/engine_error.rs | 6 +- engine/src/errors/init_error.rs | 10 +- engine/src/lib.rs | 8 +- engine/src/repositories.rs | 4 +- engine/src/repositories/meva_repository.rs | 46 +++- engine/src/repositories/repository_layout.rs | 16 ++ shared/Cargo.toml | 1 + shared/src/extensions.rs | 2 + shared/src/extensions/fs.rs | 11 +- shared/src/extensions/upward_search.rs | 50 ++++ shared/src/lib.rs | 2 +- 40 files changed, 1699 insertions(+), 56 deletions(-) create mode 100644 cli/src/commands/config.rs create mode 100644 cli/src/commands/config/subcommands.rs create mode 100644 cli/src/commands/config/subcommands/edit.rs create mode 100644 cli/src/commands/config/subcommands/get.rs create mode 100644 cli/src/commands/config/subcommands/list.rs create mode 100644 cli/src/commands/config/subcommands/set.rs create mode 100644 cli/src/commands/config/subcommands/unset.rs create mode 100644 cli/src/extensions.rs create mode 100644 cli/src/extensions/arg_matches.rs create mode 100644 cli/src/extensions/arg_matches/location_selection.rs create mode 100644 cli/src/extensions/command.rs create mode 100644 cli/src/extensions/command/with_key.rs create mode 100644 cli/src/extensions/command/with_locations.rs create mode 100644 engine/src/config.rs create mode 100644 engine/src/config/config_document.rs create mode 100644 engine/src/config/config_loader.rs create mode 100644 engine/src/config/config_location.rs create mode 100644 engine/src/config/config_operations.rs create mode 100644 engine/src/errors/config_error.rs create mode 100644 shared/src/extensions/upward_search.rs diff --git a/Cargo.lock b/Cargo.lock index 60eb553..f925f6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,6 +144,7 @@ name = "cli" version = "0.1.0" dependencies = [ "clap", + "editor-command", "engine", "miette", "mockall", @@ -166,22 +167,56 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "downcast" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" +[[package]] +name = "editor-command" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee98e369cc618396b828ba0231d5bc39857ee1d8e9dcb1c1adef49a21bb5c427" +dependencies = [ + "shell-words", +] + [[package]] name = "engine" version = "0.1.0" dependencies = [ + "dirs", "mockall", "pretty_assertions", "rstest", + "serde", "shared", "tempfile", "thiserror", + "toml", + "toml_edit 0.23.2", ] [[package]] @@ -255,6 +290,17 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.3.3" @@ -264,7 +310,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi", + "wasi 0.14.2+wasi-0.2.4", ] [[package]] @@ -325,6 +371,16 @@ version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "libredox" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -423,6 +479,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "owo-colors" version = "4.2.2" @@ -493,7 +555,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" dependencies = [ - "toml_edit", + "toml_edit 0.22.27", ] [[package]] @@ -520,6 +582,17 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.11.1" @@ -591,6 +664,12 @@ version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[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" @@ -619,6 +698,35 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +dependencies = [ + "serde", +] + [[package]] name = "shared" version = "0.1.0" @@ -627,8 +735,15 @@ dependencies = [ "pretty_assertions", "rstest", "tempfile", + "up_finder", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "slab" version = "0.4.10" @@ -680,7 +795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.3", "once_cell", "rustix", "windows-sys", @@ -732,12 +847,36 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.7.0", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -745,10 +884,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 0.6.11", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1dee9dc43ac2aaf7d3b774e2fba5148212bf2bd9374f4e50152ebe9afd03d42" +dependencies = [ + "indexmap", + "toml_datetime 0.7.0", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30" +dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" + +[[package]] +name = "typed-builder" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce63bcaf7e9806c206f7d7b9c1f38e0dce8bb165a80af0898161058b19248534" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d8d828da2a3d759d3519cdf29a5bac49c77d039ad36d0782edadbf9cd5415b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -773,12 +960,28 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +[[package]] +name = "up_finder" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be33efd9986755f20533a6d724f20c4f62f680de544999f47ae04cffc1d996ae" +dependencies = [ + "rustc-hash", + "typed-builder", +] + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasi" version = "0.14.2+wasi-0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 7c3b31b..4368b08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,3 +13,7 @@ rstest = "0.25.0" mockall = "0.13.1" pretty_assertions = "1.4.1" tempfile = "3.20.0" +serde = { version = "1.0", features = ["derive"] } +up_finder = "0.0.4" +dirs = "6.0.0" +editor-command = "1.0.0" \ No newline at end of file diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 9d1c2ed..d38cac6 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -15,6 +15,7 @@ plugins = { path = "../plugins" } clap = "4.5.41" thiserror.workspace = true miette = { version = "7.6.0", features = ["fancy"] } +editor-command.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 65a828b..50fabdb 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1,5 +1,7 @@ +pub mod config; pub mod init; pub mod meva_command; +pub use config::ConfigCommand; pub use init::InitCommand; pub use meva_command::MevaCommand; diff --git a/cli/src/commands/config.rs b/cli/src/commands/config.rs new file mode 100644 index 0000000..f4f9bbc --- /dev/null +++ b/cli/src/commands/config.rs @@ -0,0 +1,62 @@ +pub mod subcommands; + +use crate::commands::MevaCommand; +use subcommands::*; + +/// Implements the `config` command for Meva DVCS. +/// +/// Serves as a namespace for configuration-related subcommands: +/// list, get, set, unset, and edit settings at global/local/file scopes. +pub struct ConfigCommand; + +impl ConfigCommand { + /// Creates a new instance of the `ConfigCommand`. + pub fn new() -> Self { + Self + } +} + +impl MevaCommand for ConfigCommand { + fn name(&self) -> &'static str { + "config" + } + + fn about(&self) -> &'static str { + "Manage Meva configuration (system, global, or local)" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Define the set of subcommands under `config` namespace. + /// + /// Returns boxed instances of each config operation command. + fn subcommands(&self) -> Vec> { + vec![ + Box::new(ConfigListCommand), + Box::new(ConfigGetCommand), + Box::new(ConfigSetCommand), + Box::new(ConfigUnsetCommand), + Box::new(ConfigEditCommand), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = ConfigCommand::new(); + assert_eq!(cmd.name(), "config"); + assert_eq!( + cmd.about(), + "Manage Meva configuration (system, global, or local)" + ); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/config/subcommands.rs b/cli/src/commands/config/subcommands.rs new file mode 100644 index 0000000..998cd35 --- /dev/null +++ b/cli/src/commands/config/subcommands.rs @@ -0,0 +1,11 @@ +pub mod edit; +pub mod get; +pub mod list; +pub mod set; +pub mod unset; + +pub use edit::ConfigEditCommand; +pub use get::ConfigGetCommand; +pub use list::ConfigListCommand; +pub use set::ConfigSetCommand; +pub use unset::ConfigUnsetCommand; diff --git a/cli/src/commands/config/subcommands/edit.rs b/cli/src/commands/config/subcommands/edit.rs new file mode 100644 index 0000000..13a7aa7 --- /dev/null +++ b/cli/src/commands/config/subcommands/edit.rs @@ -0,0 +1,108 @@ +use clap::{ArgMatches, Command}; +use editor_command::EditorBuilder; +use engine::{ConfigDocument, ConfigLoader}; +use miette::{Context, IntoDiagnostic}; + +use crate::commands::MevaCommand; +use crate::extensions::{LocationSelection, WithLocations}; + +/// Implements the `edit` subcommand for Meva configuration management. +/// +/// Opens the chosen configuration file in the user's preferred editor, +/// respecting any `core.editor` override in config or falling back to OS defaults. +pub struct ConfigEditCommand; + +impl ConfigEditCommand { + /// Creates a new instance of the `ConfigGetCommand`. + #[allow(dead_code)] + pub fn new() -> Self { + Self + } +} + +impl MevaCommand for ConfigEditCommand { + fn name(&self) -> &'static str { + "edit" + } + + fn about(&self) -> &'static str { + "Open the config file in your default editor" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command().with_location_args( + "Use the global (user) config file", + "Use the repository-local config file", + "Path to an arbitrary config file", + ) + } + + fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + let loader = ConfigLoader::new_default(); + let override_cmd = loader.get("core.editor", None); + let location = matches + .get_config_location() + .get_default_path() + .into_diagnostic() + .wrap_err("Failed to determine configuration file path")?; + + ConfigDocument::validate_existing_file(&location).into_diagnostic()?; + + let mut builder = EditorBuilder::new().environment(); + + if let Ok(editor) = override_cmd { + if !editor.trim().is_empty() { + builder = builder.source(Some(editor)); + } + } else { + #[cfg(target_os = "windows")] + { + use std::env; + let has_editor = env::var_os("VISUAL").is_some() || env::var_os("EDITOR").is_some(); + if !has_editor { + builder = builder.source(Some("notepad".to_string())); + } + } + } + + let mut cmd = builder + .build() + .into_diagnostic() + .wrap_err("Failed to build editor command")?; + + cmd.arg(location); + + let status = cmd + .status() + .into_diagnostic() + .wrap_err("Failed to launch the editor")?; + + if !status.success() { + return Err(miette::miette!( + "Editor returned error code: {}", + status.code().unwrap_or(-1) + )); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = ConfigEditCommand::new(); + assert_eq!(cmd.name(), "edit"); + assert_eq!(cmd.about(), "Open the config file in your default editor"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/config/subcommands/get.rs b/cli/src/commands/config/subcommands/get.rs new file mode 100644 index 0000000..1e8469f --- /dev/null +++ b/cli/src/commands/config/subcommands/get.rs @@ -0,0 +1,91 @@ +use clap::{Arg, ArgMatches, Command}; +use engine::{ConfigDocument, ConfigOperations}; +use miette::IntoDiagnostic; + +use crate::commands::MevaCommand; +use crate::extensions::{LocationSelection, WithLocations}; + +pub struct ConfigGetCommand; + +/// Implements the `get` subcommand for Meva configuration management. +/// +/// Retrieves the value of a specified TOML key from the chosen +/// configuration scope, with an optional default fallback if the key is missing. +impl ConfigGetCommand { + /// Creates a new instance of the `ConfigGetCommand`. + #[allow(dead_code)] + pub fn new() -> Self { + Self + } + + const ARG_KEY: &'static str = "key"; + + const ARG_DEFAULT: &'static str = "default"; +} + +impl MevaCommand for ConfigGetCommand { + fn name(&self) -> &'static str { + "get" + } + + fn about(&self) -> &'static str { + "Get the value for a given configuration key" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .with_location_args( + "Use the global (user) config file", + "Use the repository-local config file", + "Path to an arbitrary config file", + ) + .arg( + Arg::new(Self::ARG_KEY) + .value_name("KEY") + .help("TOML path to the config entry") + .required(true) + .index(1), + ) + .arg( + Arg::new(Self::ARG_DEFAULT) + .short('d') + .long("default") + .value_name("DEFAULT") + .help("Default value if entry is missing"), + ) + } + + fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + let location = matches + .get_config_location() + .get_default_path() + .into_diagnostic()?; + + let key = matches.get_one::(Self::ARG_KEY).unwrap(); + let default = matches.get_one::(Self::ARG_DEFAULT); + + let doc = ConfigDocument::load(location).into_diagnostic()?; + let config_value = doc.get(key, default).into_diagnostic()?; + println!("{config_value}"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = ConfigGetCommand::new(); + assert_eq!(cmd.name(), "get"); + assert_eq!(cmd.about(), "Get the value for a given configuration key"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/config/subcommands/list.rs b/cli/src/commands/config/subcommands/list.rs new file mode 100644 index 0000000..c100cc5 --- /dev/null +++ b/cli/src/commands/config/subcommands/list.rs @@ -0,0 +1,79 @@ +use clap::{ArgMatches, Command}; +use engine::{ConfigDocument, ConfigOperations}; +use miette::IntoDiagnostic; + +use crate::commands::MevaCommand; +use crate::extensions::{LocationSelection, WithLocations}; + +/// Implements the `list` subcommand for Meva configuration management. +/// +/// Retrieves and displays all key/value pairs from the selected +/// configuration scope. +pub struct ConfigListCommand; + +impl ConfigListCommand { + /// Creates a new instance of the `ConfigListCommand`. + #[allow(dead_code)] + pub fn new() -> Self { + Self + } +} + +impl MevaCommand for ConfigListCommand { + fn name(&self) -> &'static str { + "list" + } + + fn about(&self) -> &'static str { + "Print all available configuration entries" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command().with_location_args( + "Use the global (user) config file", + "Use the repository-local config file", + "Path to an arbitrary config file", + ) + } + + fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + let location = matches + .get_config_location() + .get_default_path() + .into_diagnostic()?; + + let doc = ConfigDocument::load(location).into_diagnostic()?; + let key_values = doc.list(); + + if key_values.is_empty() { + println!("Config file has no key-value pairs to display.") + } + + let max_key_len = key_values.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + + for (key, value) in key_values { + println!("{key:max_key_len$} = {value}"); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = ConfigListCommand::new(); + assert_eq!(cmd.name(), "list"); + assert_eq!(cmd.about(), "Print all available configuration entries"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/config/subcommands/set.rs b/cli/src/commands/config/subcommands/set.rs new file mode 100644 index 0000000..09a2dc1 --- /dev/null +++ b/cli/src/commands/config/subcommands/set.rs @@ -0,0 +1,84 @@ +use clap::{Arg, ArgMatches, Command}; +use engine::{ConfigDocument, ConfigOperations}; +use miette::IntoDiagnostic; + +use crate::commands::MevaCommand; +use crate::extensions::{LocationSelection, WithKey, WithLocations}; + +/// Implements the `set` subcommand for Meva configuration management. +/// +/// Adds or updates a specified TOML key in the chosen configuration scope +/// with a provided value. +pub struct ConfigSetCommand; + +impl ConfigSetCommand { + /// Creates a new instance of the `ConfigSetCommand`. + #[allow(dead_code)] + pub fn new() -> Self { + Self + } + + const ARG_VALUE: &'static str = "value"; +} + +impl MevaCommand for ConfigSetCommand { + fn name(&self) -> &'static str { + "set" + } + + fn about(&self) -> &'static str { + "Set the value for a given configuration key" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .with_location_args( + "Use the global (user) config file", + "Use the repository-local config file", + "Path to an arbitrary config file", + ) + .with_key_arg("TOML path to the config entry") + .arg( + Arg::new(Self::ARG_VALUE) + .value_name("VALUE") + .help("Value to assign") + .required(true) + .index(2), + ) + } + + fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + let key = matches.get_one::(Command::ARG_KEY).unwrap(); + let value = matches.get_one::(Self::ARG_VALUE).unwrap(); + let location = matches + .get_config_location() + .get_default_path() + .into_diagnostic()?; + + let mut doc = ConfigDocument::load(location).into_diagnostic()?; + + doc.set(key, value).into_diagnostic()?; + doc.save().into_diagnostic()?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = ConfigSetCommand::new(); + assert_eq!(cmd.name(), "set"); + assert_eq!(cmd.about(), "Set the value for a given configuration key"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/config/subcommands/unset.rs b/cli/src/commands/config/subcommands/unset.rs new file mode 100644 index 0000000..d20bf80 --- /dev/null +++ b/cli/src/commands/config/subcommands/unset.rs @@ -0,0 +1,73 @@ +use clap::{ArgMatches, Command}; +use engine::{ConfigDocument, ConfigOperations}; +use miette::IntoDiagnostic; + +use crate::commands::MevaCommand; +use crate::extensions::{LocationSelection, WithKey, WithLocations}; + +/// Implements the `unset` subcommand for Meva configuration management. +/// +/// Removes a specified TOML key from the chosen configuration scope. +pub struct ConfigUnsetCommand; + +impl ConfigUnsetCommand { + /// Creates a new instance of the `ConfigUnsetCommand`. + #[allow(dead_code)] + pub fn new() -> Self { + Self + } +} + +impl MevaCommand for ConfigUnsetCommand { + fn name(&self) -> &'static str { + "unset" + } + + fn about(&self) -> &'static str { + "Remove a configuration entry" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .with_location_args( + "Use the global (user) config file", + "Use the repository-local config file", + "Path to an arbitrary config file", + ) + .with_key_arg("TOML path to the config entry") + } + + fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + let key = matches.get_one::(Command::ARG_KEY).unwrap(); + let location = matches + .get_config_location() + .get_default_path() + .into_diagnostic()?; + + let mut doc = ConfigDocument::load(location).into_diagnostic()?; + + doc.unset(key).into_diagnostic()?; + doc.save().into_diagnostic()?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = ConfigUnsetCommand::new(); + assert_eq!(cmd.name(), "unset"); + assert_eq!(cmd.about(), "Remove a configuration entry"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/init.rs b/cli/src/commands/init.rs index e6e1b3d..c67ee44 100644 --- a/cli/src/commands/init.rs +++ b/cli/src/commands/init.rs @@ -1,14 +1,14 @@ +use std::path::PathBuf; + use clap::{Arg, ArgMatches, Command}; use miette::{IntoDiagnostic, Result}; -use std::path::PathBuf; use crate::commands::MevaCommand; - use engine::MevaRepository; -/// Represents the `init` command for Meva DVCS. +/// Implements the `init` command for Meva DVCS. /// -/// This command initializes a new empty repository at the specified path, +/// Initializes a new repository at a specified path, /// optionally setting the initial branch name. pub struct InitCommand; @@ -48,7 +48,7 @@ impl MevaCommand for InitCommand { .arg( Arg::new(Self::ARG_BRANCH) .short('b') - .long("initial-branch") + .long(Self::ARG_BRANCH) .value_name("BRANCH") .help("Name of the initial branch") .default_value("master") @@ -70,13 +70,13 @@ impl MevaCommand for InitCommand { /// initial branch name. If the repository already exists or an error occurs /// during initialization, the error is reported. /// - /// # Parameters - /// - `matches`: Parsed command-line arguments containing: + /// # Arguments + /// * `matches`: Parsed command-line arguments containing: /// - `initial-branch`: The name of the initial branch (defaults to "master"). /// - `path`: The target directory to initialize the repository (defaults to current dir). /// /// # Returns - /// - `Result<()>`: Indicates success or detailed error if initialization fails. + /// * `Result<()>`: Indicates success or detailed error if initialization fails. fn execute(&self, matches: &ArgMatches) -> Result<()> { let branch = matches.get_one::(Self::ARG_BRANCH).unwrap(); let target = matches.get_one::(Self::ARG_PATH).unwrap(); @@ -156,7 +156,7 @@ mod tests { #[case(&["init", "--initial-branch=feature"], "feature", ".")] #[case(&["init", "-b", "dev", "./repo_path"], "dev", "./repo_path")] #[case(&["init", "./some_path"], "master", "./some_path")] - fn test_execute_parses_args_correctly( + fn test_init_parses_args_correctly( #[case] args: &[&str], #[case] expected_branch: &str, #[case] expected_path: &str, diff --git a/cli/src/commands/meva_command.rs b/cli/src/commands/meva_command.rs index ffaa470..6f48c06 100644 --- a/cli/src/commands/meva_command.rs +++ b/cli/src/commands/meva_command.rs @@ -1,5 +1,5 @@ use clap::{ArgMatches, Command}; -use miette::Result; +use miette::{Context, Result}; /// A trait representing a top-level command in the Meva CLI. pub trait MevaCommand { @@ -17,11 +17,21 @@ pub trait MevaCommand { self.build_base_command() } - /// Builds a basic `clap::Command` with name, description, and version. + /// Builds a basic `clap::Command` with name, description and version. fn build_base_command(&self) -> Command { - Command::new(self.name()) + let cmd = Command::new(self.name()) .about(self.about()) - .version(self.version()) + .version(self.version()); + + self.register_subcommands(cmd) + } + + /// Adds subcommands for this command to the given command mutable instance. + fn register_subcommands(&self, mut cmd: Command) -> Command { + for sub in self.subcommands() { + cmd = cmd.subcommand(sub.build_command()); + } + cmd } /// Executes this command or delegates to a matching subcommand if present. @@ -32,16 +42,18 @@ pub trait MevaCommand { /// /// If no subcommand is matched, it returns `Ok(())` by default, meaning no operation was performed. /// - /// # Parameters - /// - `matches`: The parsed CLI arguments for this command, including any subcommand matches. + /// # Arguments + /// * `matches`: The parsed CLI arguments for this command, including any subcommand matches. /// /// # Returns - /// - `Result<()>`: Indicates whether the execution succeeded or an error occurred during dispatch. + /// * `Result<()>`: Indicates whether the execution succeeded or an error occurred during dispatch. fn execute(&self, matches: &ArgMatches) -> Result<()> { if let Some((name, sub_matches)) = matches.subcommand() { for sub_command in self.subcommands() { if sub_command.name() == name { - return sub_command.execute(sub_matches); + return sub_command + .execute(sub_matches) + .wrap_err_with(|| format!("Error running `{name}` subcommand")); } } } diff --git a/cli/src/extensions.rs b/cli/src/extensions.rs new file mode 100644 index 0000000..da95e64 --- /dev/null +++ b/cli/src/extensions.rs @@ -0,0 +1,5 @@ +pub mod arg_matches; +pub mod command; + +pub use arg_matches::*; +pub use command::*; diff --git a/cli/src/extensions/arg_matches.rs b/cli/src/extensions/arg_matches.rs new file mode 100644 index 0000000..37e1a44 --- /dev/null +++ b/cli/src/extensions/arg_matches.rs @@ -0,0 +1,3 @@ +pub mod location_selection; + +pub use location_selection::LocationSelection; diff --git a/cli/src/extensions/arg_matches/location_selection.rs b/cli/src/extensions/arg_matches/location_selection.rs new file mode 100644 index 0000000..6eee4ff --- /dev/null +++ b/cli/src/extensions/arg_matches/location_selection.rs @@ -0,0 +1,79 @@ +use std::path::PathBuf; + +use clap::{ArgMatches, Command}; + +use engine::ConfigLocation; + +use crate::extensions::WithLocations; + +/// Trait to extract the chosen configuration source from CLI matches. +/// +/// Provides a method to map parsed arguments into a `ConfigLocation` enum. +pub trait LocationSelection { + /// Inspect parsed arguments and return the corresponding `ConfigLocation`. + fn get_config_location(&self) -> ConfigLocation; +} + +impl LocationSelection for ArgMatches { + fn get_config_location(&self) -> ConfigLocation { + if self.get_flag(Command::ARG_GLOBAL) { + ConfigLocation::Global + } else if self.get_flag(Command::ARG_LOCAL) { + ConfigLocation::Local + } else if let Some(path) = self.get_one::(Command::ARG_FILE) { + ConfigLocation::File(path.clone()) + } else { + // Default fallback when no location is explicitly specified + ConfigLocation::Local + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::extensions::WithLocations; + use pretty_assertions::assert_eq; + use rstest::rstest; + + fn build_command() -> Command { + Command::new("cmd").with_location_args( + "use global config", + "use local config", + "use custom config file", + ) + } + + fn get_matches(args: &[&str]) -> ArgMatches { + build_command() + .try_get_matches_from(std::iter::once("cmd").chain(args.iter().copied())) + .unwrap() + } + + #[rstest] + fn returns_global_location() { + let matches = get_matches(&["--global"]); + assert_eq!(matches.get_config_location(), ConfigLocation::Global); + } + + #[rstest] + fn returns_local_location() { + let matches = get_matches(&["--local"]); + assert_eq!(matches.get_config_location(), ConfigLocation::Local); + } + + #[rstest] + fn returns_file_location() { + let matches = get_matches(&["--file", "custom.toml"]); + assert_eq!( + matches.get_config_location(), + ConfigLocation::File(PathBuf::from("custom.toml")), + ); + } + + #[rstest] + fn returns_local_as_default_when_nothing_given() { + let matches = get_matches(&[]); + assert_eq!(matches.get_config_location(), ConfigLocation::Local); + } +} diff --git a/cli/src/extensions/command.rs b/cli/src/extensions/command.rs new file mode 100644 index 0000000..ec4ac38 --- /dev/null +++ b/cli/src/extensions/command.rs @@ -0,0 +1,5 @@ +pub mod with_key; +pub mod with_locations; + +pub use with_key::WithKey; +pub use with_locations::WithLocations; diff --git a/cli/src/extensions/command/with_key.rs b/cli/src/extensions/command/with_key.rs new file mode 100644 index 0000000..af8c644 --- /dev/null +++ b/cli/src/extensions/command/with_key.rs @@ -0,0 +1,33 @@ +use clap::{Arg, Command}; + +/// Trait to add a required `key` argument to a Clap command. +pub trait WithKey { + /// Constant name of the CLI argument for the key. + const ARG_KEY: &'static str; + + /// Extends a `Command` by adding a positional `key` argument with the given help text. + /// + /// # Arguments + /// + /// * `self` - The command being extended. + /// * `key_help` - Help message describing the purpose of the `key` argument. + /// + /// # Returns + /// + /// The original `Command` with the `key` argument appended. + fn with_key_arg(self, key_help: &'static str) -> Self; +} + +impl WithKey for Command { + const ARG_KEY: &'static str = "key"; + + fn with_key_arg(self, key_help: &'static str) -> Self { + self.arg( + Arg::new(Self::ARG_KEY) + .value_name("KEY") + .help(key_help) + .required(true) + .index(1), + ) + } +} diff --git a/cli/src/extensions/command/with_locations.rs b/cli/src/extensions/command/with_locations.rs new file mode 100644 index 0000000..63bc796 --- /dev/null +++ b/cli/src/extensions/command/with_locations.rs @@ -0,0 +1,158 @@ +use std::path::PathBuf; + +use clap::{Arg, ArgAction, ArgGroup, Command}; + +/// Trait for adding CLI flags or options to select configuration source locations. +/// +/// Provides argument names and a convenience method to attach mutually-exclusive +/// options for global, local, or explicit file configurations. +pub trait WithLocations { + /// Argument name for the global location flag. + const ARG_GLOBAL: &'static str; + + /// Argument name for the local location flag. + const ARG_LOCAL: &'static str; + + /// Argument name for the custom location flag + const ARG_FILE: &'static str; + + /// Extend a `Command` with location-selection arguments. + /// + /// # Parameters + /// + /// * `self` - The `Command` to augment. + /// * `global_help` - Help text for the global flag. + /// * `local_help` - Help text for the local flag. + /// * `file_help` - Help text for the file option. + /// + /// # Returns + /// + /// The updated `Command` with global, local, and file options, + /// constrained to be mutually exclusive, grouped under "location". + fn with_location_args( + self, + global_help: &'static str, + local_help: &'static str, + file_help: &'static str, + ) -> Self; +} + +impl WithLocations for Command { + const ARG_GLOBAL: &'static str = "global"; + + const ARG_LOCAL: &'static str = "local"; + + const ARG_FILE: &'static str = "file"; + + fn with_location_args( + self, + global_help: &'static str, + local_help: &'static str, + file_help: &'static str, + ) -> Self { + self.arg( + Arg::new(Self::ARG_GLOBAL) + .short('g') + .long(Self::ARG_GLOBAL) + .help(global_help) + .action(ArgAction::SetTrue) + // Cannot be used with local or file + .conflicts_with_all([Self::ARG_LOCAL, Self::ARG_FILE]), + ) + .arg( + Arg::new(Self::ARG_LOCAL) + .short('l') + .long(Self::ARG_LOCAL) + .help(local_help) + .action(ArgAction::SetTrue) + // Cannot be used with global or file + .conflicts_with_all([Self::ARG_GLOBAL, Self::ARG_FILE]), + ) + .arg( + Arg::new(Self::ARG_FILE) + .short('f') + .long(Self::ARG_FILE) + .value_name("FILE") + .help(file_help) + .value_parser(clap::value_parser!(PathBuf)) + // Cannot be used with global or local + .conflicts_with_all([Self::ARG_GLOBAL, Self::ARG_LOCAL]), + ) + .group( + ArgGroup::new("location") + .args([Self::ARG_GLOBAL, Self::ARG_LOCAL, Self::ARG_FILE]) + .required(false), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + use rstest::rstest; + + fn make_app() -> Command { + Command::new("testcmd").with_location_args( + "use global config", + "use local config", + "use file config", + ) + } + + #[rstest] + fn test_global_flag() { + let matches = make_app() + .try_get_matches_from(vec!["testcmd", "--global"]) + .unwrap(); + assert!(matches.get_flag("global")); + assert!(!matches.get_flag("local")); + assert!(matches.get_one::("file").is_none()); + } + + #[rstest] + fn test_local_flag() { + let matches = make_app() + .try_get_matches_from(vec!["testcmd", "-l"]) + .unwrap(); + assert!(matches.get_flag("local")); + assert!(!matches.get_flag("global")); + assert!(matches.get_one::("file").is_none()); + } + + #[rstest] + fn test_file_option() { + let path = "config.toml"; + let matches = make_app() + .try_get_matches_from(vec!["testcmd", "--file", path]) + .unwrap(); + assert_eq!( + matches.get_one::("file"), + Some(&PathBuf::from(path)) + ); + assert!(!matches.get_flag("global")); + assert!(!matches.get_flag("local")); + } + + #[rstest] + fn test_conflicting_flags_global_local() { + let result = make_app().try_get_matches_from(vec!["testcmd", "-g", "-l"]); + assert!(result.is_err_and(|e| e.kind() == ErrorKind::ArgumentConflict)); + } + + #[rstest] + fn test_conflicting_flags_global_file() { + let result = + make_app().try_get_matches_from(vec!["testcmd", "--global", "--file", "conf.toml"]); + assert!(result.is_err_and(|e| e.kind() == ErrorKind::ArgumentConflict)); + } + + #[rstest] + fn test_no_flags() { + let matches = make_app().try_get_matches_from(vec!["testcmd"]).unwrap(); + assert!(!matches.get_flag("global")); + assert!(!matches.get_flag("local")); + assert!(matches.get_one::("file").is_none()); + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index d043f6e..02d1d27 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,14 +1,18 @@ mod commands; +mod extensions; mod meva_cli; use crate::meva_cli::MevaCli; -use commands::InitCommand; +use commands::{ConfigCommand, InitCommand}; use miette::Result; fn main() -> Result<()> { miette::set_panic_hook(); let mut cli = MevaCli::new(); + cli.add_command(Box::new(InitCommand::new())); + cli.add_command(Box::new(ConfigCommand::new())); + cli.run() } diff --git a/cli/src/meva_cli.rs b/cli/src/meva_cli.rs index 54c45be..7fecb08 100644 --- a/cli/src/meva_cli.rs +++ b/cli/src/meva_cli.rs @@ -24,8 +24,8 @@ impl MevaCli { /// /// Commands must implement the `MevaCommand` trait and are stored as trait objects. /// - /// # Parameters - /// - `command`: The boxed command to register. + /// # Arguments + /// * `command`: The boxed command to register. pub fn add_command(&mut self, command: Box) { self.commands.push(command); } diff --git a/engine/Cargo.toml b/engine/Cargo.toml index deda1a6..51ac646 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -8,6 +8,10 @@ authors.workspace = true shared = { path = "../shared" } tempfile.workspace = true thiserror.workspace = true +serde.workspace = true +toml = "0.9.2" +toml_edit = "0.23.2" +dirs.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/engine/src/config.rs b/engine/src/config.rs new file mode 100644 index 0000000..8d796e0 --- /dev/null +++ b/engine/src/config.rs @@ -0,0 +1,9 @@ +pub mod config_document; +pub mod config_loader; +pub mod config_location; +pub mod config_operations; + +pub use config_document::ConfigDocument; +pub use config_loader::ConfigLoader; +pub use config_location::ConfigLocation; +pub use config_operations::ConfigOperations; diff --git a/engine/src/config/config_document.rs b/engine/src/config/config_document.rs new file mode 100644 index 0000000..3e898ad --- /dev/null +++ b/engine/src/config/config_document.rs @@ -0,0 +1,258 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use toml_edit::{Datetime, DocumentMut, Item, Table, Value, value}; + +use crate::{ + ConfigOperations, + errors::{ConfigError, EngineResult}, +}; + +/// Represents an editable TOML configuration file on disk. +pub struct ConfigDocument { + /// Filesystem path to the TOML file. + pub path: PathBuf, + + /// In-memory mutable representation of the TOML document. + pub doc: DocumentMut, +} + +impl ConfigDocument { + /// Load and parse a TOML file from the given path. + /// + /// # Arguments + /// + /// * `path` – Path reference to the TOML configuration file. + /// + /// # Returns + /// + /// An `EngineResult` containing a new `ConfigDocument` on success, + /// or an error if reading or parsing fails. + pub fn load>(path: P) -> EngineResult { + Self::validate_existing_file(path.as_ref())?; + + let path_buf: PathBuf = path.as_ref().into(); + + let content = fs::read_to_string(&path_buf)?; + let doc = content.parse::().map_err(ConfigError::Toml)?; + + Ok(Self { + path: path_buf, + doc, + }) + } + + /// Write the current in-memory TOML document back to disk. + /// + /// # Returns + /// + /// An `EngineResult<()>` indicating success or containing an I/O error. + pub fn save(&self) -> EngineResult<()> { + fs::write(&self.path, self.doc.to_string())?; + Ok(()) + } + + /// Validates that the provided path refers to an existing regular file. + pub fn validate_existing_file(path: &Path) -> EngineResult<()> { + if !path.is_file() { + return Err(ConfigError::InvalidConfigFile { + path: path.display().to_string(), + reason: "path is not a regular file".to_string(), + } + .into()); + } + + if !path.exists() { + return Err(ConfigError::InvalidConfigFile { + path: path.display().to_string(), + reason: "file does not exist".to_string(), + } + .into()); + } + + Ok(()) + } + + /// Split a dotted key path into its segments. + fn split_key_path(path: &str) -> Vec<&str> { + path.split('.').filter(|s| !s.is_empty()).collect() + } + + /// Parse a string into a TOML `Item`, trying multiple types. + /// + /// Supports bool, integer, float, datetime, or falls back to string. + fn parse_item(s: &str) -> EngineResult { + let trimmed = s.trim(); + + if let Ok(it) = trimmed.parse::() { + return Ok(it); + } + if let Ok(b) = trimmed.parse::() { + return Ok(value(b)); + } + if let Ok(i) = trimmed.parse::() { + return Ok(value(i)); + } + if let Ok(f) = trimmed.parse::() { + return Ok(value(f)); + } + if let Ok(dt) = trimmed.parse::() { + return Ok(value(dt)); + } + + // Fallback to raw string value + Ok(value(s)) + } + + /// Convert a TOML `Value` to its string representation. + fn value_to_string(value: &Value) -> Option { + match value { + Value::String(s) => Some(s.value().clone()), + Value::Integer(i) => Some(i.value().to_string()), + Value::Float(f) => Some(f.value().to_string()), + Value::Boolean(b) => Some(b.value().to_string()), + Value::Datetime(dt) => Some(dt.value().to_string()), + Value::Array(arr) => { + // Recursively convert each element in the array + let elems = arr + .iter() + .map(Self::value_to_string) + .collect::>>()?; + Some(format!("[{}]", elems.join(","))) + } + Value::InlineTable(table) => Some(table.to_string()), + } + } + + /// Recursively collect all key/value pairs from a TOML `Item`. + /// + /// # Arguments + /// + /// * `prefix` – Current key path prefix (dot-separated). + /// * `item` – The TOML item to traverse. + /// * `result` – Accumulates found (key, value) tuples. + fn collect_key_values(prefix: &str, item: &Item, result: &mut Vec<(String, String)>) { + match item { + // Recurse into tables, appending each sub-key + Item::Table(table) => { + for (key, value) in table.iter() { + let full_key = if prefix.is_empty() { + key.to_string() + } else { + format!("{prefix}.{key}") + }; + Self::collect_key_values(&full_key, value, result); + } + } + // Convert leaf values to string and push them to the result + Item::Value(value) => { + if let Some(value_str) = Self::value_to_string(value) { + result.push((prefix.to_string(), value_str)); + } + } + _ => {} // Ignore other item types + } + } +} + +impl ConfigOperations for ConfigDocument { + fn get(&self, key_path: &str, default: Option<&String>) -> EngineResult { + let keys = Self::split_key_path(key_path); + let mut current_item = self.doc.as_item(); + + for key in keys { + current_item = match current_item.get(key) { + Some(item) => item, + None => { + return match default { + Some(val) => Ok(val.to_string()), + None => Err(ConfigError::KeyNotFound { + key: key_path.to_string(), + } + .into()), + }; + } + } + } + + match current_item { + Item::Value(value) => Self::value_to_string(value).ok_or_else(|| { + ConfigError::InvalidValueType { + type_name: value.type_name().to_string(), + } + .into() + }), + _ => Err(ConfigError::InvalidValueType { + type_name: current_item.type_name().to_string(), + } + .into()), + } + } + + fn set(&mut self, key_path: &str, val: &str) -> EngineResult<()> { + let keys = Self::split_key_path(key_path); + + if keys.is_empty() { + return Err(ConfigError::InvalidKey { + key: key_path.to_string(), + } + .into()); + } + + let item = Self::parse_item(val)?; + let mut current_table = self.doc.as_table_mut(); + + for &seg in &keys[..keys.len() - 1] { + let entry = current_table.entry(seg); + let next = entry.or_insert(Item::Table(Table::new())); + current_table = next.as_table_mut().unwrap(); + } + + let last = keys[keys.len() - 1]; + current_table[last] = item; + + println!("{key_path} = {val}"); + + Ok(()) + } + + fn unset(&mut self, key_path: &str) -> EngineResult<()> { + let keys = Self::split_key_path(key_path); + + if keys.is_empty() { + return Err(ConfigError::InvalidKey { + key: key_path.to_string(), + } + .into()); + } + + let mut current = self.doc.as_item_mut(); + + for key in &keys[..keys.len() - 1] { + if let Some(item) = current.get_mut(key) { + current = item; + } else { + return Err(ConfigError::KeyNotFound { + key: key_path.to_string(), + } + .into()); + } + } + + let last_key = keys[keys.len() - 1]; + if let Some(table) = current.as_table_like_mut() { + table.remove(last_key); + } + + Ok(()) + } + + fn list(&self) -> Vec<(String, String)> { + let mut result = Vec::new(); + Self::collect_key_values("", self.doc.as_item(), &mut result); + + result + } +} diff --git a/engine/src/config/config_loader.rs b/engine/src/config/config_loader.rs new file mode 100644 index 0000000..5b42217 --- /dev/null +++ b/engine/src/config/config_loader.rs @@ -0,0 +1,84 @@ +use crate::{ + ConfigDocument, ConfigLocation, ConfigOperations, + errors::{ConfigError, EngineError, EngineResult}, +}; + +/// Responsible for loading configuration values from multiple sources +/// in a defined search order (e.g., local, global). +pub struct ConfigLoader { + /// Ordered list of locations to search for configuration files. + locations: Vec, +} + +impl ConfigLoader { + /// Creates a loader with the default search order. + pub fn new_default() -> Self { + Self { + locations: vec![ConfigLocation::Local, ConfigLocation::Global], + } + } + + /// Creates a loader with a custom list of locations. + /// + /// # Arguments + /// + /// * `locations` - A vector of `ConfigLocation` enums defining the search order. + pub fn with_locations(locations: Vec) -> Self { + Self { locations } + } + + /// Retrieve a configuration value by key, searching through each + /// configured location until found or falling back to default. + /// + /// # Arguments + /// + /// * `key_path` - Dot-separated key string (e.g., "section.key"). + /// * `default` - Optional default value if key is missing in all locations. + /// + /// # Returns + /// + /// The found `String` value or an error if not found or on I/O issues. + pub fn get(&self, key_path: &str, default: Option<&String>) -> EngineResult { + for loc in &self.locations { + match self.try_load_from(loc, key_path, default) { + Ok(val) => { + return Ok(val); + } + Err(err) => match err { + // If key not found, continue to next location + EngineError::Config(ConfigError::KeyNotFound { .. }) => {} + other => { + return Err(other); + } + }, + } + } + Err(ConfigError::KeyNotFound { + key: key_path.to_string(), + } + .into()) + } + + /// Attempt to load a configuration value from a single location. + /// + /// # Arguments + /// + /// * `loc` - The `ConfigLocation` to load from (local, global, system). + /// * `key_path` - The dot-separated key string. + /// * `default` - Optional default value if the key is missing. + /// + /// # Returns + /// + /// The found value or an error wrapped in `EngineResult`. + fn try_load_from( + &self, + loc: &ConfigLocation, + key_path: &str, + default: Option<&String>, + ) -> EngineResult { + let path = loc.get_default_path()?; + let doc = ConfigDocument::load(&path)?; + + doc.get(key_path, default) + } +} diff --git a/engine/src/config/config_location.rs b/engine/src/config/config_location.rs new file mode 100644 index 0000000..ea0a747 --- /dev/null +++ b/engine/src/config/config_location.rs @@ -0,0 +1,73 @@ +use std::path::PathBuf; + +use shared::UpwardSearch; + +use crate::{ + MevaRepository, RepositoryLayout, + errors::{ConfigError, EngineResult}, +}; + +/// Defines where to load configuration from: global, local repository, or a specific file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigLocation { + /// User-level config file in the OS config directory + Global, + + /// Repository-specific config file, searched upward from current working directory + Local, + + /// Explicit config file path provided by user + File(PathBuf), +} + +impl ConfigLocation { + /// Resolve the full path to the configuration file for this location variant. + /// + /// # Returns + /// + /// A `PathBuf` pointing to the config file, or an error if resolution fails. + pub fn get_default_path(&self) -> EngineResult { + match self { + ConfigLocation::Global => Self::global_path(MevaRepository::CONFIG_FILE), + ConfigLocation::Local => { + Self::local_path(MevaRepository::REPOSITORY_DIR, MevaRepository::CONFIG_FILE) + } + ConfigLocation::File(path) => Ok(path.to_path_buf()), + } + } + + /// Compute the global configuration path in the OS config directory. + /// + /// # Arguments + /// + /// * `config_file` – The filename for the config. + /// + /// # Errors + /// + /// * `HomeDirNotFound` if the OS config directory is unavailable. + pub fn global_path(config_file: &str) -> EngineResult { + let base = dirs::config_dir().ok_or(ConfigError::HomeDirNotFound)?; + // Prefix filename with a dot for hidden file convention + Ok(base.join(format!(".{config_file}"))) + } + + /// Locate the repository root by searching upward, then append the config file. + /// + /// # Arguments + /// + /// * `repository_dir` – Marker directory name indicating the repo root. + /// * `config_file` – The filename for the config. + /// + /// # Errors + /// + /// * `ConfigNotFound` if no ancestor directory contains `repository_dir`. + fn local_path(repository_dir: &str, config_file: &str) -> EngineResult { + let repo = std::env::current_dir()? + .search_dir_up(repository_dir) + .ok_or(ConfigError::ConfigNotFound { + location: ConfigLocation::Local, + })?; + + Ok(repo.join(config_file)) + } +} diff --git a/engine/src/config/config_operations.rs b/engine/src/config/config_operations.rs new file mode 100644 index 0000000..08e4694 --- /dev/null +++ b/engine/src/config/config_operations.rs @@ -0,0 +1,46 @@ +use crate::errors::EngineResult; + +/// Defines operations for accessing and modifying configuration values +pub trait ConfigOperations { + /// Retrieves the value at the specified key path. + /// + /// # Arguments + /// + /// * `key_path` – A dot‑separated string indicating the nested configuration key. + /// * `default` – An optional fallback value if the key is not present. + /// + /// # Returns + /// + /// An `EngineResult` wrapping the found value as `String`, or an error if retrieval fails. + fn get(&self, key_path: &str, default: Option<&String>) -> EngineResult; + + /// Sets or overrides the value at the specified key path. + /// + /// # Arguments + /// + /// * `key_path` – A dot‑separated string indicating where to store the value. + /// * `val` – The new string value to assign. + /// + /// # Returns + /// + /// An `EngineResult<()>` indicating success or containing an error. + fn set(&mut self, key_path: &str, val: &str) -> EngineResult<()>; + + /// Removes the value at the specified key path, if it exists. + /// + /// # Arguments + /// + /// * `key_path` – A dot‑separated string for the key to remove. + /// + /// # Returns + /// + /// An `EngineResult<()>` indicating success or containing an error. + fn unset(&mut self, key_path: &str) -> EngineResult<()>; + + /// Lists all configuration entries as key/value pairs. + /// + /// # Returns + /// + /// A `Vec` of tuples where each tuple contains the full key path and its corresponding value. + fn list(&self) -> Vec<(String, String)>; +} diff --git a/engine/src/errors.rs b/engine/src/errors.rs index abb79fc..bd559af 100644 --- a/engine/src/errors.rs +++ b/engine/src/errors.rs @@ -1,6 +1,9 @@ +pub mod config_error; pub mod engine_error; pub mod init_error; +pub use config_error::ConfigError; +pub use init_error::InitError; + pub use engine_error::EngineError; pub use engine_error::Result as EngineResult; -pub use init_error::InitError; diff --git a/engine/src/errors/config_error.rs b/engine/src/errors/config_error.rs new file mode 100644 index 0000000..fb7ca3f --- /dev/null +++ b/engine/src/errors/config_error.rs @@ -0,0 +1,55 @@ +use thiserror::Error; +use toml_edit; + +use crate::ConfigLocation; + +/// Errors that can occur when reading or writing configuration files. +/// +/// Encapsulates parsing errors, missing keys, invalid formats, and +/// filesystem or lookup failures related to configuration handling. +#[derive(Error, Debug)] +pub enum ConfigError { + /// Propagates TOML parsing errors from `toml_edit`. + #[error(transparent)] + Toml(#[from] toml_edit::TomlError), + + /// Indicates that a requested configuration key was not found. + #[error("The key `{key}` was not found")] + KeyNotFound { + /// The configuration key path that was missing. + key: String, + }, + + /// Indicates that a configuration value could not be converted to the expected type. + #[error("Invalid value type `{type_name}`")] + InvalidValueType { + /// The name of the type encountered in the config that was not valid. + type_name: String, + }, + + /// Occurs when attempting to use an empty or malformed key path. + #[error("Invalid key `{key}`")] + InvalidKey { + /// The invalid key string provided by the caller. + key: String, + }, + + /// Represents failure to locate a configuration file at a given location. + #[error("Configuration not found at {location:?}")] + ConfigNotFound { + /// The location where the config lookup was attempted. + location: ConfigLocation, + }, + + #[error("Invalid configuration file at `{path}`: {reason}")] + InvalidConfigFile { + /// Path provided by the user or inferred by the system. + path: String, + /// Reason why the file is invalid (e.g. "not a file", "missing"). + reason: String, + }, + + /// Indicates that the user's home directory could not be determined. + #[error("Home directory not found")] + HomeDirNotFound, +} diff --git a/engine/src/errors/engine_error.rs b/engine/src/errors/engine_error.rs index 84a0864..3d2cefc 100644 --- a/engine/src/errors/engine_error.rs +++ b/engine/src/errors/engine_error.rs @@ -1,7 +1,8 @@ use std::io; + use thiserror::Error; -use crate::errors::InitError; +use crate::errors::{ConfigError, InitError}; /// A convenient result type alias for engine-related operations. pub type Result = std::result::Result; @@ -21,6 +22,9 @@ pub enum EngineError { #[error(transparent)] Init(#[from] InitError), + #[error(transparent)] + Config(#[from] ConfigError), + /// A catch-all variant for any unknown or unexpected engine error. /// Accepts a descriptive string message. #[error("Unknown Engine error: {0}")] diff --git a/engine/src/errors/init_error.rs b/engine/src/errors/init_error.rs index 6c65a1c..364d35f 100644 --- a/engine/src/errors/init_error.rs +++ b/engine/src/errors/init_error.rs @@ -5,9 +5,15 @@ use thiserror::Error; pub enum InitError { /// Attempted to initialize a repository at a location that already contains one. #[error("Repository already initialized at `{path}`")] - AlreadyInitialized { path: String }, + AlreadyInitialized { + /// Filesystem path where initialization was retried. + path: String, + }, /// The provided working directory either does not exist or is not a directory. #[error("Invalid working directory: `{path}`")] - InvalidWorkingDir { path: String }, + InvalidWorkingDir { + /// The invalid directory path + path: String, + }, } diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 944d4d5..bd785df 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,8 +1,8 @@ +pub mod config; pub mod errors; pub mod repositories; -pub use errors::EngineError; -pub use errors::EngineResult; -use errors::InitError; +use errors::{EngineError, EngineResult, InitError}; -pub use repositories::MevaRepository; +pub use config::{ConfigDocument, ConfigLoader, ConfigLocation, ConfigOperations}; +pub use repositories::{MevaRepository, RepositoryLayout}; diff --git a/engine/src/repositories.rs b/engine/src/repositories.rs index 906a8da..7cc2b1f 100644 --- a/engine/src/repositories.rs +++ b/engine/src/repositories.rs @@ -1,5 +1,5 @@ pub mod meva_repository; -mod repository_layout; +pub mod repository_layout; pub use meva_repository::MevaRepository; -use repository_layout::RepositoryLayout; +pub use repository_layout::RepositoryLayout; diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index 65e8346..308de4e 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -1,14 +1,14 @@ -use shared::fs::create_file_with_dirs; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; + use tempfile::TempDir; use crate::repositories::RepositoryLayout; -use crate::{EngineError, InitError}; - -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; - -use crate::EngineResult; +use crate::{EngineError, EngineResult, InitError}; +use shared::fs::create_file_with_dirs; /// Represents a Meva repository on disk. /// @@ -87,11 +87,14 @@ impl MevaRepository { create_file_with_dirs(ref_path)?; create_file_with_dirs(log_path)?; - let head_path = root.as_ref().join(self.head_file_rel()); - let mut head = fs::File::create(&head_path)?; + let config_path = root.as_ref().join(self.config_file_rel()); + let mut config_file = fs::File::create(&config_path)?; + config_file.write_all(Self::DEFAULT_CONFIG.as_bytes())?; + let head_path = root.as_ref().join(self.head_file_rel()); + let mut head_file = fs::File::create(&head_path)?; write!( - head, + head_file, "ref: {}/{}/{}", Self::REFS_DIR, Self::HEADS_DIR, @@ -103,6 +106,19 @@ impl MevaRepository { Ok(()) } + + const DEFAULT_CONFIG: &'static str = concat!( + "# Meva Configuration File\n", + "# Edit this file to customize your settings\n", + "\n", + "# [user]\n", + "# name = \"Your Name\"\n", + "# email = \"your.email@example.com\"\n", + "\n", + "# [editor]\n", + "# default = \"vim\"\n", + "\n", + ); } impl RepositoryLayout for MevaRepository { @@ -118,6 +134,8 @@ impl RepositoryLayout for MevaRepository { const HEAD_FILE: &'static str = "HEAD"; + const CONFIG_FILE: &'static str = "mevaconfig"; + /// Returns the repository’s working directory path. fn working_dir(&self) -> &std::path::Path { &self.working_dir @@ -173,6 +191,12 @@ mod tests { let branch_log = repo.heads_logs_dir().join("main"); assert!(branch_log.exists()); + + let config_path = repo.config_file(); + assert!(config_path.exists()); + + let config_content = read_file(&config_path); + assert_eq!(config_content, MevaRepository::DEFAULT_CONFIG); } #[rstest] diff --git a/engine/src/repositories/repository_layout.rs b/engine/src/repositories/repository_layout.rs index 00a2101..2825767 100644 --- a/engine/src/repositories/repository_layout.rs +++ b/engine/src/repositories/repository_layout.rs @@ -24,6 +24,9 @@ pub trait RepositoryLayout { /// Name of the HEAD file. const HEAD_FILE: &'static str; + /// Name of the config file. + const CONFIG_FILE: &'static str; + /// Returns the working directory where the repository is located. fn working_dir(&self) -> &Path; @@ -64,6 +67,11 @@ pub trait RepositoryLayout { self.repository_dir_rel().join(Self::HEAD_FILE) } + /// Path to the config file relative to `working_dir`. + fn config_file_rel(&self) -> PathBuf { + self.repository_dir_rel().join(Self::CONFIG_FILE) + } + /// Path to the HEAD log file relative to `working_dir`. fn head_logs_file_rel(&self) -> PathBuf { self.logs_dir_rel().join(Self::HEAD_FILE) @@ -106,6 +114,11 @@ pub trait RepositoryLayout { self.working_dir().join(self.head_file_rel()) } + /// Absolute path to the config file. + fn config_file(&self) -> PathBuf { + self.working_dir().join(self.config_file_rel()) + } + /// Absolute path to the HEAD log file. fn head_logs_file(&self) -> PathBuf { self.working_dir().join(self.head_logs_file_rel()) @@ -137,6 +150,7 @@ mod tests { const HEADS_DIR: &'static str = "heads"; const HEAD_FILE: &'static str = "HEAD"; + const CONFIG_FILE: &'static str = "mevaconfig"; fn working_dir(&self) -> &Path { &self.dir @@ -162,6 +176,7 @@ mod tests { PathBuf::from(".meva/logs/heads") ); assert_eq!(layout.head_file_rel(), PathBuf::from(".meva/HEAD")); + assert_eq!(layout.config_file_rel(), PathBuf::from(".meva/mevaconfig")); assert_eq!( layout.head_logs_file_rel(), PathBuf::from(".meva/logs/HEAD") @@ -174,6 +189,7 @@ mod tests { assert_eq!(layout.heads_refs_dir(), base.join(".meva/refs/heads")); assert_eq!(layout.logs_dir(), base.join(".meva/logs")); assert_eq!(layout.heads_logs_dir(), base.join(".meva/logs/heads")); + assert_eq!(layout.config_file(), base.join(".meva/mevaconfig")); assert_eq!(layout.head_file(), base.join(".meva/HEAD")); assert_eq!(layout.head_logs_file(), base.join(".meva/logs/HEAD")); } diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 0239b43..4259774 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -6,6 +6,7 @@ authors.workspace = true [dependencies] tempfile.workspace = true +up_finder.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/shared/src/extensions.rs b/shared/src/extensions.rs index 8114773..ecc3e20 100644 --- a/shared/src/extensions.rs +++ b/shared/src/extensions.rs @@ -1,3 +1,5 @@ pub mod fs; +pub mod upward_search; pub use fs::create_file_with_dirs; +pub use upward_search::UpwardSearch; diff --git a/shared/src/extensions/fs.rs b/shared/src/extensions/fs.rs index 2bf3638..e649fac 100644 --- a/shared/src/extensions/fs.rs +++ b/shared/src/extensions/fs.rs @@ -1,15 +1,6 @@ -use std::fs; -use std::io; -use std::path::Path; +use std::{fs, io, path::Path}; /// Creates a file at the given path, ensuring all parent directories exist. -/// -/// # Arguments -/// -/// * `path` - Target path for the file (including filename). -/// -/// # Errors -/// /// Returns an `io::Error` if creating parent directories or the file itself fails. pub fn create_file_with_dirs>(path: P) -> io::Result<()> { let path = path.as_ref(); diff --git a/shared/src/extensions/upward_search.rs b/shared/src/extensions/upward_search.rs new file mode 100644 index 0000000..18fbc5e --- /dev/null +++ b/shared/src/extensions/upward_search.rs @@ -0,0 +1,50 @@ +use std::path::{Path, PathBuf}; + +use up_finder::{FindUpKind, UpFinder}; + +/// Extension trait providing "search upward" functionality on paths. +/// +/// Allows finding files or directories by name, walking up the filesystem +/// hierarchy from a starting path toward the root. +pub trait UpwardSearch { + /// Search for **all** files named `name`, starting from `self` and + /// traversing upward through ancestor directories. + /// + /// Returns a vector of absolute paths to every matching file found, + /// ordered from nearest (lowest) ancestor to farthest (highest). + fn search_files_up(&self, name: &str) -> Vec; + + /// Search for the **first** file named `name` in `self` or any ancestor. + /// + /// Returns the nearest match (lowest in the directory tree), or `None` + /// if no such file is found. + fn search_file_up(&self, name: &str) -> Option; + + /// Search for the **first** directory named `name` in `self` or any ancestor. + /// + /// Returns the nearest matching directory path, or `None` if not found. + fn search_dir_up(&self, name: &str) -> Option; +} + +/// Builds an `UpFinder` configured to start at `start_path` +/// and look for either files or directories, based on `kind`. +fn build_up_finder>(start_path: P, kind: FindUpKind) -> UpFinder

{ + UpFinder::builder().cwd(start_path).kind(kind).build() +} + +impl> UpwardSearch for P { + fn search_files_up(&self, name: &str) -> Vec { + build_up_finder(self.as_ref(), FindUpKind::File).find_up(name) + } + + fn search_file_up(&self, name: &str) -> Option { + self.search_files_up(name).into_iter().next() + } + + fn search_dir_up(&self, name: &str) -> Option { + build_up_finder(self.as_ref(), FindUpKind::Dir) + .find_up(name) + .into_iter() + .next() + } +} diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 44b9248..1e5d68a 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -1,3 +1,3 @@ pub mod extensions; -pub use extensions::fs; +pub use extensions::{UpwardSearch, fs}; From 0c9f0a10f4d65ec5580d1ab4d0f9a9b216928309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Sun, 27 Jul 2025 16:46:37 +0200 Subject: [PATCH 03/42] Command `ignore` (#3) --- Cargo.lock | 31 ++++ Cargo.toml | 3 +- cli/Cargo.toml | 1 + cli/src/commands.rs | 3 + cli/src/commands/config/subcommands/edit.rs | 46 +----- cli/src/commands/ignore.rs | 61 +++++++ cli/src/commands/ignore/subcommands.rs | 9 + cli/src/commands/ignore/subcommands/add.rs | 77 +++++++++ cli/src/commands/ignore/subcommands/check.rs | 121 ++++++++++++++ cli/src/commands/ignore/subcommands/edit.rs | 73 +++++++++ cli/src/commands/ignore/subcommands/remove.rs | 89 ++++++++++ cli/src/extensions.rs | 2 + cli/src/extensions/command.rs | 4 + cli/src/extensions/command/with_file.rs | 88 ++++++++++ cli/src/extensions/command/with_key.rs | 43 +++++ cli/src/extensions/command/with_locations.rs | 42 ++--- cli/src/extensions/command/with_pattern.rs | 87 ++++++++++ cli/src/extensions/path.rs | 3 + cli/src/extensions/path/open_in_editor.rs | 59 +++++++ cli/src/main.rs | 3 +- engine/Cargo.toml | 1 + engine/src/errors.rs | 2 + engine/src/errors/engine_error.rs | 16 +- engine/src/errors/ignore_error.rs | 15 ++ engine/src/ignore.rs | 8 + engine/src/ignore/ignore_operations.rs | 66 ++++++++ engine/src/ignore/ignore_result.rs | 46 ++++++ engine/src/ignore/ignore_service.rs | 154 ++++++++++++++++++ engine/src/lib.rs | 2 + engine/src/repositories/meva_repository.rs | 2 + engine/src/repositories/repository_layout.rs | 4 + shared/src/extensions/upward_search.rs | 90 ++++++++++ 32 files changed, 1179 insertions(+), 72 deletions(-) create mode 100644 cli/src/commands/ignore.rs create mode 100644 cli/src/commands/ignore/subcommands.rs create mode 100644 cli/src/commands/ignore/subcommands/add.rs create mode 100644 cli/src/commands/ignore/subcommands/check.rs create mode 100644 cli/src/commands/ignore/subcommands/edit.rs create mode 100644 cli/src/commands/ignore/subcommands/remove.rs create mode 100644 cli/src/extensions/command/with_file.rs create mode 100644 cli/src/extensions/command/with_pattern.rs create mode 100644 cli/src/extensions/path.rs create mode 100644 cli/src/extensions/path/open_in_editor.rs create mode 100644 engine/src/errors/ignore_error.rs create mode 100644 engine/src/ignore.rs create mode 100644 engine/src/ignore/ignore_operations.rs create mode 100644 engine/src/ignore/ignore_result.rs create mode 100644 engine/src/ignore/ignore_service.rs diff --git a/Cargo.lock b/Cargo.lock index f925f6d..e1973a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,6 +106,16 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "bstr" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "cfg-if" version = "1.0.1" @@ -146,6 +156,7 @@ dependencies = [ "clap", "editor-command", "engine", + "globset", "miette", "mockall", "plugins", @@ -208,6 +219,7 @@ name = "engine" version = "0.1.0" dependencies = [ "dirs", + "globset", "mockall", "pretty_assertions", "rstest", @@ -325,6 +337,19 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +[[package]] +name = "globset" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "gui" version = "0.1.0" @@ -387,6 +412,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + [[package]] name = "memchr" version = "2.7.5" diff --git a/Cargo.toml b/Cargo.toml index 4368b08..44c86ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,4 +16,5 @@ tempfile = "3.20.0" serde = { version = "1.0", features = ["derive"] } up_finder = "0.0.4" dirs = "6.0.0" -editor-command = "1.0.0" \ No newline at end of file +editor-command = "1.0.0" +globset = "0.4.16" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index d38cac6..c30a2ee 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -16,6 +16,7 @@ clap = "4.5.41" thiserror.workspace = true miette = { version = "7.6.0", features = ["fancy"] } editor-command.workspace = true +globset.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 50fabdb..a935c19 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1,7 +1,10 @@ pub mod config; +pub mod ignore; pub mod init; pub mod meva_command; pub use config::ConfigCommand; +pub use ignore::IgnoreCommand; pub use init::InitCommand; + pub use meva_command::MevaCommand; diff --git a/cli/src/commands/config/subcommands/edit.rs b/cli/src/commands/config/subcommands/edit.rs index 13a7aa7..21a4ad4 100644 --- a/cli/src/commands/config/subcommands/edit.rs +++ b/cli/src/commands/config/subcommands/edit.rs @@ -1,10 +1,10 @@ use clap::{ArgMatches, Command}; -use editor_command::EditorBuilder; -use engine::{ConfigDocument, ConfigLoader}; use miette::{Context, IntoDiagnostic}; +use engine::{ConfigDocument, ConfigLoader}; + use crate::commands::MevaCommand; -use crate::extensions::{LocationSelection, WithLocations}; +use crate::extensions::{LocationSelection, OpenInEditor, WithLocations}; /// Implements the `edit` subcommand for Meva configuration management. /// @@ -43,7 +43,7 @@ impl MevaCommand for ConfigEditCommand { fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { let loader = ConfigLoader::new_default(); - let override_cmd = loader.get("core.editor", None); + let override_cmd = loader.get("core.editor", None).ok(); let location = matches .get_config_location() .get_default_path() @@ -52,43 +52,7 @@ impl MevaCommand for ConfigEditCommand { ConfigDocument::validate_existing_file(&location).into_diagnostic()?; - let mut builder = EditorBuilder::new().environment(); - - if let Ok(editor) = override_cmd { - if !editor.trim().is_empty() { - builder = builder.source(Some(editor)); - } - } else { - #[cfg(target_os = "windows")] - { - use std::env; - let has_editor = env::var_os("VISUAL").is_some() || env::var_os("EDITOR").is_some(); - if !has_editor { - builder = builder.source(Some("notepad".to_string())); - } - } - } - - let mut cmd = builder - .build() - .into_diagnostic() - .wrap_err("Failed to build editor command")?; - - cmd.arg(location); - - let status = cmd - .status() - .into_diagnostic() - .wrap_err("Failed to launch the editor")?; - - if !status.success() { - return Err(miette::miette!( - "Editor returned error code: {}", - status.code().unwrap_or(-1) - )); - } - - Ok(()) + location.open_in_editor(override_cmd) } } diff --git a/cli/src/commands/ignore.rs b/cli/src/commands/ignore.rs new file mode 100644 index 0000000..39e48b5 --- /dev/null +++ b/cli/src/commands/ignore.rs @@ -0,0 +1,61 @@ +pub mod subcommands; + +use crate::commands::MevaCommand; + +use subcommands::*; + +/// Implements the `ignore` command for Meva DVCS. +/// +/// Serves as a namespace for subcommands managing ignore patterns: +/// add, remove, check, and edit ignore rules for files and directories. +pub struct IgnoreCommand; + +impl IgnoreCommand { + pub fn new() -> Self { + Self + } +} + +impl MevaCommand for IgnoreCommand { + fn name(&self) -> &'static str { + "ignore" + } + + fn about(&self) -> &'static str { + "Manage ignore patterns for files and directories" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Define the set of subcommands under `ignore` namespace. + /// + /// Returns boxed instances of each ignore operation command. + fn subcommands(&self) -> Vec> { + vec![ + Box::new(IgnoreAddCommand), + Box::new(IgnoreCheckCommand), + Box::new(IgnoreEditCommand), + Box::new(IgnoreRemoveCommand), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = IgnoreCommand::new(); + assert_eq!(cmd.name(), "ignore"); + assert_eq!( + cmd.about(), + "Manage ignore patterns for files and directories" + ); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/ignore/subcommands.rs b/cli/src/commands/ignore/subcommands.rs new file mode 100644 index 0000000..26a7004 --- /dev/null +++ b/cli/src/commands/ignore/subcommands.rs @@ -0,0 +1,9 @@ +pub mod add; +pub mod check; +pub mod edit; +pub mod remove; + +pub use add::IgnoreAddCommand; +pub use check::IgnoreCheckCommand; +pub use edit::IgnoreEditCommand; +pub use remove::IgnoreRemoveCommand; diff --git a/cli/src/commands/ignore/subcommands/add.rs b/cli/src/commands/ignore/subcommands/add.rs new file mode 100644 index 0000000..b545a62 --- /dev/null +++ b/cli/src/commands/ignore/subcommands/add.rs @@ -0,0 +1,77 @@ +use std::path::PathBuf; + +use clap::{ArgMatches, Command}; +use engine::{IgnoreOperations, IgnoreService, MevaRepository, RepositoryLayout}; +use globset::Glob; +use miette::IntoDiagnostic; + +use crate::{ + commands::MevaCommand, + extensions::{WithFile, WithPattern}, +}; + +/// Implements the `add` subcommand for Meva ignored files management. +/// +/// Adds the specified pattern to the chosen ignore file, +/// ensuring the ignore rule is appended without duplicates or formatting errors. +pub struct IgnoreAddCommand; + +impl IgnoreAddCommand { + /// Creates a new instance of the `IgnoreAddCommand`. + #[allow(dead_code)] + pub fn new() -> Self { + Self + } +} + +impl MevaCommand for IgnoreAddCommand { + fn name(&self) -> &'static str { + "add" + } + + fn about(&self) -> &'static str { + "Appends a pattern to the ignore file" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .with_pattern_arg("Pattern to append to the ignore file") + .with_file_arg("Path to a specific ignore file") + } + + fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + let pattern = matches.get_one::(Command::ARG_PATTERN).unwrap(); + let file = matches.get_one::(Command::ARG_FILE); + + let ignore_service = IgnoreService::new(MevaRepository::IGNORE_FILE); + + let result_path = ignore_service.add(pattern, file).into_diagnostic()?; + + println!( + "Pattern '{}' appended to {}", + pattern, + result_path.to_string_lossy() + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = IgnoreAddCommand::new(); + assert_eq!(cmd.name(), "add"); + assert_eq!(cmd.about(), "Appends a pattern to the ignore file"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/ignore/subcommands/check.rs b/cli/src/commands/ignore/subcommands/check.rs new file mode 100644 index 0000000..056d820 --- /dev/null +++ b/cli/src/commands/ignore/subcommands/check.rs @@ -0,0 +1,121 @@ +use std::path::{Path, PathBuf}; + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use engine::{IgnoreOperations, IgnoreResult, IgnoreService, MevaRepository, RepositoryLayout}; +use miette::IntoDiagnostic; + +use crate::{commands::MevaCommand, extensions::WithFile}; + +/// Implements the `check` subcommand for Meva ignored files management. +/// +/// Checks whether the specified path is ignored according to the rules of the chosen ignore file, +/// optionally providing an explanation of which patterns matched. +pub struct IgnoreCheckCommand; + +impl IgnoreCheckCommand { + /// Creates a new instance of the `IgnoreCheckCommand`. + #[allow(dead_code)] + pub fn new() -> Self { + Self + } + + const ARG_PATH: &'static str = "path"; + + const ARG_EXPLAIN: &'static str = "explain"; + + fn print_check_result(result: &IgnoreResult, checked_path: &Path, explain: bool) { + match result { + IgnoreResult::Ignored { patterns, path } => { + if explain { + println!( + "'{}' is ignored by the following pattern{} in {}:", + checked_path.to_string_lossy(), + if patterns.len() == 1 { "" } else { "s" }, + path.to_string_lossy() + ); + for pattern in patterns { + println!("{pattern}"); + } + } else { + println!("ignored"); + } + } + IgnoreResult::NotIgnored { path } => { + if explain { + println!( + "'{}' is not ignored by {}", + checked_path.to_string_lossy(), + path.to_string_lossy() + ); + } else { + println!("not ignored"); + } + } + } + } +} + +impl MevaCommand for IgnoreCheckCommand { + fn name(&self) -> &'static str { + "check" + } + + fn about(&self) -> &'static str { + "Check if a path is ignored by the rules of the ignore file" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .with_file_arg("Path to a specific ignore file") + .arg( + Arg::new(Self::ARG_EXPLAIN) + .short('e') + .long(Self::ARG_EXPLAIN) + .help("Explain why the path is ignored") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_PATH) + .value_name("PATH") + .help("Path to check for ignore rule match") + .value_parser(clap::value_parser!(PathBuf)) + .index(1), + ) + } + + fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + let file = matches.get_one::(Command::ARG_FILE); + let explain = matches.get_flag(Self::ARG_EXPLAIN); + let checked_path = matches.get_one::(Self::ARG_PATH).unwrap(); + + let ignore_service = IgnoreService::new(MevaRepository::IGNORE_FILE); + + let ignore_result = ignore_service.check(checked_path, file).into_diagnostic()?; + + Self::print_check_result(&ignore_result, checked_path, explain); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = IgnoreCheckCommand::new(); + assert_eq!(cmd.name(), "check"); + assert_eq!( + cmd.about(), + "Check if a path is ignored by the rules of the ignore file" + ); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/ignore/subcommands/edit.rs b/cli/src/commands/ignore/subcommands/edit.rs new file mode 100644 index 0000000..295011a --- /dev/null +++ b/cli/src/commands/ignore/subcommands/edit.rs @@ -0,0 +1,73 @@ +use std::path::PathBuf; + +use clap::{ArgMatches, Command}; +use engine::{ConfigLoader, IgnoreOperations, IgnoreService, MevaRepository, RepositoryLayout}; +use miette::IntoDiagnostic; + +use crate::{ + commands::MevaCommand, + extensions::{OpenInEditor, WithFile}, +}; + +/// Implements the `edit` subcommand for Meva ignored files management. +/// +/// Opens the chosen configuration file in the user's preferred editor, +/// respecting any `core.editor` override in config or falling back to OS defaults. +pub struct IgnoreEditCommand; + +impl IgnoreEditCommand { + /// Creates a new instance of the `IgnoreEditCommand`. + #[allow(dead_code)] + pub fn new() -> Self { + Self + } +} + +impl MevaCommand for IgnoreEditCommand { + fn name(&self) -> &'static str { + "edit" + } + + fn about(&self) -> &'static str { + "Open the ignore file in your default editor" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .with_file_arg("Path to a specific ignore file") + } + + fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + let file = matches.get_one::(Command::ARG_FILE); + let ignore_service = IgnoreService::new(MevaRepository::IGNORE_FILE); + + let loader = ConfigLoader::new_default(); + let override_cmd = loader.get("core.editor", None).ok(); + + let ignore_file = match file { + Some(p) => p.to_path_buf(), + None => ignore_service.find_ignore_file(None).into_diagnostic()?, + }; + + ignore_file.open_in_editor(override_cmd) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = IgnoreEditCommand::new(); + assert_eq!(cmd.name(), "edit"); + assert_eq!(cmd.about(), "Open the ignore file in your default editor"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/ignore/subcommands/remove.rs b/cli/src/commands/ignore/subcommands/remove.rs new file mode 100644 index 0000000..9bfc64e --- /dev/null +++ b/cli/src/commands/ignore/subcommands/remove.rs @@ -0,0 +1,89 @@ +use std::path::PathBuf; + +use clap::{ArgMatches, Command}; +use engine::{IgnoreOperations, IgnoreService, MevaRepository, RepositoryLayout}; +use globset::Glob; +use miette::IntoDiagnostic; + +use crate::{ + commands::MevaCommand, + extensions::{WithFile, WithPattern}, +}; + +/// Implements the `remove` subcommand for Meva ignored files management. +/// +/// Removes the specified pattern from the chosen ignore file, +/// reporting how many lines were deleted or if the pattern was not present. +pub struct IgnoreRemoveCommand; + +impl IgnoreRemoveCommand { + /// Creates a new instance of the `IgnoreRemoveCommand`. + #[allow(dead_code)] + pub fn new() -> Self { + Self + } +} + +impl MevaCommand for IgnoreRemoveCommand { + fn name(&self) -> &'static str { + "remove" + } + + fn about(&self) -> &'static str { + "Remove a pattern from the ignore file" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .with_pattern_arg("Pattern to remove from the ignore file") + .with_file_arg("Path to a specific ignore file") + } + + fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + let pattern = matches.get_one::(Command::ARG_PATTERN).unwrap(); + let file = matches.get_one::(Command::ARG_FILE); + + let ignore_service = IgnoreService::new(MevaRepository::IGNORE_FILE); + + let (result_path, removed_patterns) = + ignore_service.remove(pattern, file).into_diagnostic()?; + + if removed_patterns.is_empty() { + println!( + "No lines matching “{}” were found in {}.", + pattern.glob(), + result_path.to_string_lossy() + ); + } else { + println!( + "Removed {} line{} from {}:", + removed_patterns.len(), + if removed_patterns.len() == 1 { "" } else { "s" }, + result_path.to_string_lossy() + ); + for pat in &removed_patterns { + println!("{pat}"); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = IgnoreRemoveCommand::new(); + assert_eq!(cmd.name(), "remove"); + assert_eq!(cmd.about(), "Remove a pattern from the ignore file"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/extensions.rs b/cli/src/extensions.rs index da95e64..1d5427f 100644 --- a/cli/src/extensions.rs +++ b/cli/src/extensions.rs @@ -1,5 +1,7 @@ pub mod arg_matches; pub mod command; +pub mod path; pub use arg_matches::*; pub use command::*; +pub use path::*; diff --git a/cli/src/extensions/command.rs b/cli/src/extensions/command.rs index ec4ac38..0a8fe8d 100644 --- a/cli/src/extensions/command.rs +++ b/cli/src/extensions/command.rs @@ -1,5 +1,9 @@ +pub mod with_file; pub mod with_key; pub mod with_locations; +pub mod with_pattern; +pub use with_file::WithFile; pub use with_key::WithKey; pub use with_locations::WithLocations; +pub use with_pattern::WithPattern; diff --git a/cli/src/extensions/command/with_file.rs b/cli/src/extensions/command/with_file.rs new file mode 100644 index 0000000..9d7bb01 --- /dev/null +++ b/cli/src/extensions/command/with_file.rs @@ -0,0 +1,88 @@ +use std::path::PathBuf; + +use clap::{Arg, Command}; + +/// Trait to add an optional `file` argument to a Clap command. +pub trait WithFile { + /// Constant name of the CLI argument for the key. + const ARG_FILE: &'static str; + + /// Extends a `Command` by adding a `file` argument with the provided help text. + /// + /// # Arguments + /// + /// * `self` - The command being extended. + /// * `file_help` - Help message describing the purpose of the `file` argument. + /// + /// # Returns + /// + /// The original `Command` with the `file` argument appended. + fn with_file_arg(self, key_help: &'static str) -> Self; +} + +impl WithFile for Command { + const ARG_FILE: &'static str = "file"; + + fn with_file_arg(self, file_help: &'static str) -> Self { + self.arg( + Arg::new(Self::ARG_FILE) + .short('f') + .long(Self::ARG_FILE) + .value_name("FILE") + .help(file_help) + .value_parser(clap::value_parser!(PathBuf)), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Command; + use pretty_assertions::assert_eq; + use rstest::{fixture, rstest}; + use std::path::PathBuf; + + #[fixture] + fn cmd() -> Command { + Command::new("cli").with_file_arg("help text") + } + + #[rstest] + fn adds_argument_with_correct_properties(cmd: Command) { + let arg = cmd + .get_arguments() + .find(|a| a.get_id() == "file") + .expect("Argument should exist"); + + assert_eq!(arg.get_short(), Some('f')); + assert_eq!(arg.get_long(), Some("file")); + assert_eq!( + arg.get_value_names().unwrap().iter().next().unwrap(), + "FILE" + ); + } + + #[rstest] + #[case(vec!["cli", "-f", "path/to.x"], Some("path/to.x"))] + #[case(vec!["cli", "--file", "other/file.y"], Some("other/file.y"))] + #[case(vec!["cli"], None)] + fn yields_file_arg_as_pathbuf( + #[case] args: Vec<&str>, + #[case] expected: Option<&str>, + cmd: Command, + ) { + let matches = cmd.clone().try_get_matches_from(args).unwrap(); + let got: Option<&PathBuf> = matches.get_one("file"); + + match expected { + Some(file) => { + let want = PathBuf::from(file); + assert_eq!(got.unwrap(), &want); + } + None => { + assert!(got.is_none()); + } + } + } +} diff --git a/cli/src/extensions/command/with_key.rs b/cli/src/extensions/command/with_key.rs index af8c644..a0a35db 100644 --- a/cli/src/extensions/command/with_key.rs +++ b/cli/src/extensions/command/with_key.rs @@ -31,3 +31,46 @@ impl WithKey for Command { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Command; + use pretty_assertions::assert_eq; + use rstest::{fixture, rstest}; + + #[fixture] + fn cmd() -> Command { + Command::new("cmd").with_key_arg("help text") + } + + #[rstest] + fn adds_argument_with_correct_properties(cmd: Command) { + let arg = cmd + .get_positionals() + .find(|a| a.get_id() == "key") + .expect("Argument should exist"); + + assert!(arg.is_required_set()); + assert_eq!(arg.get_index(), Some(1)); + assert_eq!(arg.get_value_names().unwrap().iter().next().unwrap(), "KEY"); + } + + #[rstest] + #[case(vec!["cmd", "secret-key"], Some("secret-key"))] + #[case(vec!["cmd"], None)] + fn yields_key_arg(#[case] args: Vec<&str>, #[case] expected: Option<&str>, cmd: Command) { + let matches = cmd.clone().try_get_matches_from(args); + + match expected { + Some(key) => { + let m = matches.unwrap(); + let got: &String = m.get_one("key").expect("Should have value"); + assert_eq!(got, key); + } + None => { + assert!(matches.is_err()); + } + } + } +} diff --git a/cli/src/extensions/command/with_locations.rs b/cli/src/extensions/command/with_locations.rs index 63bc796..e58e1e9 100644 --- a/cli/src/extensions/command/with_locations.rs +++ b/cli/src/extensions/command/with_locations.rs @@ -91,41 +91,34 @@ mod tests { use super::*; use clap::error::ErrorKind; use pretty_assertions::assert_eq; - use rstest::rstest; + use rstest::{fixture, rstest}; - fn make_app() -> Command { - Command::new("testcmd").with_location_args( - "use global config", - "use local config", - "use file config", - ) + #[fixture] + fn cmd() -> Command { + Command::new("cmd").with_location_args("global help", "local help", "file help") } #[rstest] - fn test_global_flag() { - let matches = make_app() - .try_get_matches_from(vec!["testcmd", "--global"]) - .unwrap(); + fn test_global_flag(cmd: Command) { + let matches = cmd.try_get_matches_from(vec!["cmd", "--global"]).unwrap(); assert!(matches.get_flag("global")); assert!(!matches.get_flag("local")); assert!(matches.get_one::("file").is_none()); } #[rstest] - fn test_local_flag() { - let matches = make_app() - .try_get_matches_from(vec!["testcmd", "-l"]) - .unwrap(); + fn test_local_flag(cmd: Command) { + let matches = cmd.try_get_matches_from(vec!["cmd", "-l"]).unwrap(); assert!(matches.get_flag("local")); assert!(!matches.get_flag("global")); assert!(matches.get_one::("file").is_none()); } #[rstest] - fn test_file_option() { + fn test_file_option(cmd: Command) { let path = "config.toml"; - let matches = make_app() - .try_get_matches_from(vec!["testcmd", "--file", path]) + let matches = cmd + .try_get_matches_from(vec!["cmd", "--file", path]) .unwrap(); assert_eq!( matches.get_one::("file"), @@ -136,21 +129,20 @@ mod tests { } #[rstest] - fn test_conflicting_flags_global_local() { - let result = make_app().try_get_matches_from(vec!["testcmd", "-g", "-l"]); + fn test_conflicting_flags_global_local(cmd: Command) { + let result = cmd.try_get_matches_from(vec!["cmd", "-g", "-l"]); assert!(result.is_err_and(|e| e.kind() == ErrorKind::ArgumentConflict)); } #[rstest] - fn test_conflicting_flags_global_file() { - let result = - make_app().try_get_matches_from(vec!["testcmd", "--global", "--file", "conf.toml"]); + fn test_conflicting_flags_global_file(cmd: Command) { + let result = cmd.try_get_matches_from(vec!["cmd", "--global", "--file", "conf.toml"]); assert!(result.is_err_and(|e| e.kind() == ErrorKind::ArgumentConflict)); } #[rstest] - fn test_no_flags() { - let matches = make_app().try_get_matches_from(vec!["testcmd"]).unwrap(); + fn test_no_flags(cmd: Command) { + let matches = cmd.try_get_matches_from(vec!["cmd"]).unwrap(); assert!(!matches.get_flag("global")); assert!(!matches.get_flag("local")); assert!(matches.get_one::("file").is_none()); diff --git a/cli/src/extensions/command/with_pattern.rs b/cli/src/extensions/command/with_pattern.rs new file mode 100644 index 0000000..42982b0 --- /dev/null +++ b/cli/src/extensions/command/with_pattern.rs @@ -0,0 +1,87 @@ +use clap::{Arg, Command}; +use globset::Glob; + +/// Trait to add a required `pattern` argument to a Clap command. +pub trait WithPattern { + /// Constant name of the CLI argument for the pattern. + const ARG_PATTERN: &'static str; + + /// Extends a `Command` by adding a positional `pattern` argument with the given help text. + /// Uses `Glob` parser to interpret pattern syntax, typically for matching file paths. + /// + /// # Arguments + /// + /// * `self` - The command being extended. + /// * `pattern_help` - Help message describing the purpose of the `pattern` argument. + /// + /// # Returns + /// + /// The original `Command` with the `pattern` argument appended. + fn with_pattern_arg(self, key_help: &'static str) -> Self; +} + +impl WithPattern for Command { + const ARG_PATTERN: &'static str = "pattern"; + + fn with_pattern_arg(self, pattern_help: &'static str) -> Self { + self.arg( + Arg::new(Self::ARG_PATTERN) + .value_name("PATTERN") + .value_parser(clap::value_parser!(Glob)) + .help(pattern_help) + .required(true) + .index(1), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Command; + use pretty_assertions::assert_eq; + use rstest::{fixture, rstest}; + + #[fixture] + fn cmd() -> Command { + Command::new("cmd").with_pattern_arg("help text") + } + + #[rstest] + fn adds_argument_with_correct_properties(cmd: Command) { + let arg = cmd + .get_positionals() + .find(|a| a.get_id() == "pattern") + .expect("Argument should exist"); + + assert!(arg.is_required_set()); + assert_eq!(arg.get_index(), Some(1)); + assert_eq!( + arg.get_value_names().unwrap().iter().next().unwrap(), + "PATTERN" + ); + } + + #[rstest] + #[case(vec!["cmd", "*.sh"], Some("*.sh"))] + #[case(vec!["cmd"], None)] + fn yields_pattern_arg_as_glob( + #[case] args: Vec<&str>, + #[case] expected: Option<&str>, + cmd: Command, + ) { + let matches = cmd.clone().try_get_matches_from(args); + + match expected { + Some(pattern) => { + let m = matches.unwrap(); + let got: &Glob = m.get_one("pattern").expect("Should have value"); + let want = Glob::new(pattern).unwrap(); + assert_eq!(got, &want); + } + None => { + assert!(matches.is_err()); + } + } + } +} diff --git a/cli/src/extensions/path.rs b/cli/src/extensions/path.rs new file mode 100644 index 0000000..50a14bb --- /dev/null +++ b/cli/src/extensions/path.rs @@ -0,0 +1,3 @@ +pub mod open_in_editor; + +pub use open_in_editor::OpenInEditor; diff --git a/cli/src/extensions/path/open_in_editor.rs b/cli/src/extensions/path/open_in_editor.rs new file mode 100644 index 0000000..f8890ec --- /dev/null +++ b/cli/src/extensions/path/open_in_editor.rs @@ -0,0 +1,59 @@ +use std::path::Path; + +use editor_command::EditorBuilder; +use miette::{IntoDiagnostic, Result, WrapErr}; + +/// Trait to open a file or directory in the user’s preferred text editor. +/// +/// The editor binary can be overridden explicitly or resolved automatically +/// from the environment, falling back to sensible defaults on each platform. +pub trait OpenInEditor { + /// Launches an editor with the given path. + /// + /// # Arguments + /// + /// * `override_cmd` – Takes precedence over `VISUAL`, `EDITOR`, and OS‐specific fallbacks. + fn open_in_editor(&self, override_cmd: Option) -> Result<()>; +} + +impl> OpenInEditor for P { + fn open_in_editor(&self, override_cmd: Option) -> Result<()> { + let mut builder = EditorBuilder::new().environment(); + + if let Some(editor) = override_cmd { + if !editor.trim().is_empty() { + builder = builder.source(Some(editor)); + } + } else { + #[cfg(target_os = "windows")] + { + use std::env; + let has_editor = env::var_os("VISUAL").is_some() || env::var_os("EDITOR").is_some(); + if !has_editor { + builder = builder.source(Some("notepad".to_string())); + } + } + } + + let mut cmd = builder + .build() + .into_diagnostic() + .wrap_err("Failed to build editor command")?; + + cmd.arg(self.as_ref()); + + let status = cmd + .status() + .into_diagnostic() + .wrap_err("Failed to launch the editor")?; + + if !status.success() { + return Err(miette::miette!( + "Editor returned error code: {}", + status.code().unwrap_or(-1) + )); + } + + Ok(()) + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 02d1d27..d869b3b 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -3,7 +3,7 @@ mod extensions; mod meva_cli; use crate::meva_cli::MevaCli; -use commands::{ConfigCommand, InitCommand}; +use commands::{ConfigCommand, IgnoreCommand, InitCommand}; use miette::Result; fn main() -> Result<()> { @@ -13,6 +13,7 @@ fn main() -> Result<()> { cli.add_command(Box::new(InitCommand::new())); cli.add_command(Box::new(ConfigCommand::new())); + cli.add_command(Box::new(IgnoreCommand::new())); cli.run() } diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 51ac646..7ae4517 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -12,6 +12,7 @@ serde.workspace = true toml = "0.9.2" toml_edit = "0.23.2" dirs.workspace = true +globset.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/engine/src/errors.rs b/engine/src/errors.rs index bd559af..9da11ca 100644 --- a/engine/src/errors.rs +++ b/engine/src/errors.rs @@ -1,8 +1,10 @@ pub mod config_error; pub mod engine_error; +pub mod ignore_error; pub mod init_error; pub use config_error::ConfigError; +pub use ignore_error::IgnoreError; pub use init_error::InitError; pub use engine_error::EngineError; diff --git a/engine/src/errors/engine_error.rs b/engine/src/errors/engine_error.rs index 3d2cefc..46d93a0 100644 --- a/engine/src/errors/engine_error.rs +++ b/engine/src/errors/engine_error.rs @@ -2,7 +2,7 @@ use std::io; use thiserror::Error; -use crate::errors::{ConfigError, InitError}; +use crate::errors::{ConfigError, IgnoreError, InitError}; /// A convenient result type alias for engine-related operations. pub type Result = std::result::Result; @@ -13,8 +13,8 @@ pub type Result = std::result::Result; /// across components. #[derive(Error, Debug)] pub enum EngineError { - /// An I/O error occurred, such as failing to read from or write to disk. - /// Automatically converted from `std::io::Error`. + /// An operating-system I/O error (read, write, fs-metadata, etc.). + /// Automatically converted from [`std::io::Error`]. #[error(transparent)] Io(#[from] io::Error), @@ -22,11 +22,19 @@ pub enum EngineError { #[error(transparent)] Init(#[from] InitError), + /// Configuration-layer parsing, lookup, or validation error. #[error(transparent)] Config(#[from] ConfigError), + /// An error originating from ignore-file processing. + #[error(transparent)] + Ignore(#[from] IgnoreError), + /// A catch-all variant for any unknown or unexpected engine error. /// Accepts a descriptive string message. #[error("Unknown Engine error: {0}")] - Unknown(String), + Unknown( + /// Human-readable message describing the unexpected condition. + String, + ), } diff --git a/engine/src/errors/ignore_error.rs b/engine/src/errors/ignore_error.rs new file mode 100644 index 0000000..556a02b --- /dev/null +++ b/engine/src/errors/ignore_error.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +/// Errors that may occur while manipulating ignore files or patterns. +#[derive(Error, Debug)] +pub enum IgnoreError { + /// Propagates [`globset::Error`] when parsing or compiling glob patterns. + /// + /// This usually indicates that the user supplied an invalid pattern. + #[error(transparent)] + Glob(#[from] globset::Error), + + /// Indicates that no ignore file could be located at the expected path. + #[error("Ignore file not found at {path}")] + IgnoreNotFound { path: String }, +} diff --git a/engine/src/ignore.rs b/engine/src/ignore.rs new file mode 100644 index 0000000..50caad3 --- /dev/null +++ b/engine/src/ignore.rs @@ -0,0 +1,8 @@ +mod ignore_operations; + +pub mod ignore_result; +pub mod ignore_service; + +pub use ignore_operations::IgnoreOperations; +pub use ignore_result::IgnoreResult; +pub use ignore_service::IgnoreService; diff --git a/engine/src/ignore/ignore_operations.rs b/engine/src/ignore/ignore_operations.rs new file mode 100644 index 0000000..c279e44 --- /dev/null +++ b/engine/src/ignore/ignore_operations.rs @@ -0,0 +1,66 @@ +use std::path::{Path, PathBuf}; + +use globset::Glob; + +use crate::{IgnoreResult, errors::EngineResult}; + +/// Trait defining core operations on Meva *ignore* files. +/// +/// Provides high-level helpers for adding, removing, checking, and locating +/// ignore rules. Implementations decide how an ignore file is discovered, +/// parsed, and persisted, while callers interact through a uniform API. +/// +/// All methods return an [`EngineResult`] that wraps domain-specific +/// errors in a single engine-level error type. +pub trait IgnoreOperations { + /// Adds a new ignore `pattern` to the target ignore file. + /// + /// # Arguments + /// * `pattern` – Pre-compiled [`Glob`] expression to append. + /// * `path` – Optional path to an explicit ignore file. + /// + /// # Returns + /// Path to the file that was modified. + fn add

(&self, pattern: &Glob, path: Option<&P>) -> EngineResult + where + P: AsRef; + + /// Removes occurrences of `pattern` from the target ignore file. + /// + /// # Arguments + /// * `pattern` – [`Glob`] to search for and delete. + /// * `path` – Optional override for the ignore file to edit. + /// + /// # Returns + /// Tuple containing: + /// 1. Path to the file that was modified. + /// 2. Vector of string patterns that were actually removed (empty if none + /// matched). + fn remove

(&self, pattern: &Glob, path: Option<&P>) -> EngineResult<(PathBuf, Vec)> + where + P: AsRef; + + /// Checks whether `checked_path` is ignored by the rules in the selected + /// ignore file. + /// + /// # Arguments + /// * `checked_path` – File or directory whose ignore status is queried. + /// * `path` – Optional override for the ignore file to consult. + /// + /// # Returns + /// [`IgnoreResult`] indicating *ignored* or *not ignored* and, when + /// requested, the patterns that triggered the match. + fn check

(&self, checked_path: &P, path: Option<&P>) -> EngineResult + where + P: AsRef; + + /// Finds the nearest applicable ignore file, starting at `starting_path` + /// and traversing upward toward the repository root. + /// + /// # Arguments + /// * `starting_path` – Directory to begin the search. + /// + /// # Returns + /// Absolute path of the ignore file discovered. + fn find_ignore_file(&self, starting_path: Option<&Path>) -> EngineResult; +} diff --git a/engine/src/ignore/ignore_result.rs b/engine/src/ignore/ignore_result.rs new file mode 100644 index 0000000..f32e4a5 --- /dev/null +++ b/engine/src/ignore/ignore_result.rs @@ -0,0 +1,46 @@ +use std::path::{Path, PathBuf}; + +/// Result of checking whether a path is ignored. +/// +/// Encapsulates both the *yes* (ignored) and *no* (not ignored) cases, +/// optionally carrying the patterns that triggered a match. +pub enum IgnoreResult { + /// The path **is** ignored. + Ignored { + /// List of ignore patterns that matched the path. + patterns: Vec, + /// Path that was evaluated. + path: PathBuf, + }, + /// The path **is not** ignored. + NotIgnored { + /// Path that was evaluated. + path: PathBuf, + }, +} + +impl IgnoreResult { + /// Constructs an [`IgnoreResult`] from a set of matched patterns. + /// + /// # Arguments + /// + /// * `matched_patterns` – Collection of matched patterns. + /// * `path` – Path that was checked. + /// + /// # Returns + /// + /// * [`IgnoreResult::Ignored`] when the pattern list is non-empty. + /// * [`IgnoreResult::NotIgnored`] when no patterns matched. + pub fn from_matches(matched_patterns: Vec, path: &Path) -> Self { + if !matched_patterns.is_empty() { + IgnoreResult::Ignored { + patterns: matched_patterns, + path: path.to_path_buf(), + } + } else { + IgnoreResult::NotIgnored { + path: path.to_path_buf(), + } + } + } +} diff --git a/engine/src/ignore/ignore_service.rs b/engine/src/ignore/ignore_service.rs new file mode 100644 index 0000000..9fe576f --- /dev/null +++ b/engine/src/ignore/ignore_service.rs @@ -0,0 +1,154 @@ +use std::{ + env, + fs::{self, OpenOptions}, + io::{BufRead, BufReader, BufWriter, Write}, + path::{Path, PathBuf}, +}; + +use globset::{Glob, GlobSetBuilder}; + +use shared::UpwardSearch; + +use crate::{ + errors::{EngineResult, IgnoreError}, + ignore::{IgnoreOperations, IgnoreResult}, +}; + +/// Service implementing ignore file operations. +/// +/// Provides concrete implementations for adding, removing, checking, and +/// locating ignore patterns within ignore files throughout a Meva repository. +/// The service handles file discovery, pattern matching, and persistence +/// operations while maintaining compatibility with glob syntax. +pub struct IgnoreService { + /// Base filename used when searching for ignore files in the repository. + ignore_file: String, +} + +impl IgnoreService { + /// Creates a new instance of the ignore service. + pub fn new(ignore_file: &str) -> Self { + Self { + ignore_file: ignore_file.to_string(), + } + } +} + +impl IgnoreOperations for IgnoreService { + fn add

(&self, pattern: &globset::Glob, path: Option<&P>) -> EngineResult + where + P: AsRef, + { + let ignore_file = match path { + Some(p) => p.as_ref().to_path_buf(), + None => self.find_ignore_file(None)?, + }; + + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&ignore_file)?; + + let mut writer = BufWriter::new(file); + + writeln!(writer, "{}", pattern.glob())?; + + Ok(ignore_file) + } + + fn remove

( + &self, + pattern: &globset::Glob, + path: Option<&P>, + ) -> EngineResult<(PathBuf, Vec)> + where + P: AsRef, + { + let ignore_file = match path { + Some(p) => p.as_ref().to_path_buf(), + None => self.find_ignore_file(None)?, + }; + + let file = fs::File::open(&ignore_file)?; + let reader = BufReader::new(file); + + let raw_pattern = pattern.glob(); + + let mut kept_lines = Vec::new(); + let mut removed_lines = Vec::new(); + + for line_result in reader.lines() { + let line = line_result?; + if line != raw_pattern { + kept_lines.push(line); + } else { + removed_lines.push(line); + } + } + + let file = OpenOptions::new() + .write(true) + .truncate(true) + .open(&ignore_file)?; + + let mut writer = BufWriter::new(file); + + for line in kept_lines { + writeln!(writer, "{line}")?; + } + + Ok((ignore_file, removed_lines)) + } + + fn check

(&self, checked_path: &P, path: Option<&P>) -> EngineResult + where + P: AsRef, + { + let ignore_file = match path { + Some(p) => p.as_ref().to_path_buf(), + None => self.find_ignore_file(None)?, + }; + + let file = fs::File::open(&ignore_file)?; + let reader = BufReader::new(file); + + let mut builder = GlobSetBuilder::new(); + let mut patterns: Vec = Vec::new(); + + for line_result in reader.lines() { + let line = line_result?; + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + patterns.push(trimmed.to_string()); + + let glob = Glob::new(trimmed).map_err(IgnoreError::Glob)?; + builder.add(glob); + } + + let set = builder.build().map_err(IgnoreError::Glob)?; + + let matches = set.matches(checked_path); + let matched_patterns: Vec = + matches.into_iter().map(|m| patterns[m].clone()).collect(); + + Ok(IgnoreResult::from_matches(matched_patterns, &ignore_file)) + } + + fn find_ignore_file(&self, starting_path: Option<&Path>) -> EngineResult { + let search_path = if let Some(path) = starting_path { + path.to_path_buf() + } else { + env::current_dir()? + }; + + search_path.search_file_up(&self.ignore_file).ok_or( + IgnoreError::IgnoreNotFound { + path: self.ignore_file.clone(), + } + .into(), + ) + } +} diff --git a/engine/src/lib.rs b/engine/src/lib.rs index bd785df..fab8f7b 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,8 +1,10 @@ pub mod config; pub mod errors; +pub mod ignore; pub mod repositories; use errors::{EngineError, EngineResult, InitError}; pub use config::{ConfigDocument, ConfigLoader, ConfigLocation, ConfigOperations}; +pub use ignore::{IgnoreOperations, IgnoreResult, IgnoreService}; pub use repositories::{MevaRepository, RepositoryLayout}; diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index 308de4e..574fb21 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -136,6 +136,8 @@ impl RepositoryLayout for MevaRepository { const CONFIG_FILE: &'static str = "mevaconfig"; + const IGNORE_FILE: &'static str = ".mevaignore"; + /// Returns the repository’s working directory path. fn working_dir(&self) -> &std::path::Path { &self.working_dir diff --git a/engine/src/repositories/repository_layout.rs b/engine/src/repositories/repository_layout.rs index 2825767..759f51e 100644 --- a/engine/src/repositories/repository_layout.rs +++ b/engine/src/repositories/repository_layout.rs @@ -27,6 +27,9 @@ pub trait RepositoryLayout { /// Name of the config file. const CONFIG_FILE: &'static str; + /// Name of the ignore file. + const IGNORE_FILE: &'static str; + /// Returns the working directory where the repository is located. fn working_dir(&self) -> &Path; @@ -151,6 +154,7 @@ mod tests { const HEAD_FILE: &'static str = "HEAD"; const CONFIG_FILE: &'static str = "mevaconfig"; + const IGNORE_FILE: &'static str = ".mevaignore"; fn working_dir(&self) -> &Path { &self.dir diff --git a/shared/src/extensions/upward_search.rs b/shared/src/extensions/upward_search.rs index 18fbc5e..da417b5 100644 --- a/shared/src/extensions/upward_search.rs +++ b/shared/src/extensions/upward_search.rs @@ -48,3 +48,93 @@ impl> UpwardSearch for P { .next() } } + +#[cfg(test)] +mod tests { + use std::fs::{self, File}; + + use super::*; + use rstest::rstest; + use tempfile::TempDir; + + fn make_nested_dirs(base: &TempDir, parts: &[&str]) -> PathBuf { + let mut path = base.path().to_path_buf(); + for p in parts { + path.push(p); + fs::create_dir(&path).unwrap(); + } + path + } + + #[rstest] + fn search_files_up_returns_matches_in_order() { + let tmp = TempDir::new().unwrap(); + let start = make_nested_dirs(&tmp, &["a", "b", "c"]); + + let mut f2 = start.parent().unwrap().to_path_buf(); + f2.push("f.txt"); + File::create(&f2).unwrap(); + let f1 = tmp.path().join("a").join("f.txt"); + File::create(&f1).unwrap(); + + let results = start.search_files_up("f.txt"); + let got: Vec = results + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + let want = vec![ + f2.to_string_lossy().into_owned(), + f1.to_string_lossy().into_owned(), + ]; + assert_eq!(got, want); + } + + #[rstest] + fn search_file_up_returns_only_nearest_match() { + let tmp = TempDir::new().unwrap(); + let start = make_nested_dirs(&tmp, &["a", "b", "c"]); + + let bfile = start.parent().unwrap().join("f.log"); + File::create(&bfile).unwrap(); + + let afile = tmp.path().join("a").join("f.log"); + File::create(&afile).unwrap(); + + let found = start.search_file_up("f.log").unwrap(); + assert_eq!(found, bfile); + } + + #[rstest] + fn search_files_up_when_no_matches_returns_empty_vec() { + let tmp = TempDir::new().unwrap(); + let start = make_nested_dirs(&tmp, &["a", "b"]); + let results = start.search_files_up("f.txt"); + assert_eq!(results.len(), 0); + } + + #[rstest] + fn search_file_up_when_no_matches_returns_none() { + let tmp = TempDir::new().unwrap(); + let start = make_nested_dirs(&tmp, &["a", "b"]); + assert!(start.search_file_up("f.txt").is_none()); + } + + #[rstest] + fn search_dir_up_returns_nearest_directory() { + let tmp = TempDir::new().unwrap(); + let start = make_nested_dirs(&tmp, &["a", "b", "c"]); + let p2 = tmp.path().join("a").join("b").join("d"); + fs::create_dir(&p2).unwrap(); + let p1 = tmp.path().join("a").join("d"); + fs::create_dir(&p1).unwrap(); + let found = start.search_dir_up("d").unwrap(); + assert_eq!(found, p2); + } + + #[test] + fn search_dir_up_when_no_matches_returns_none() { + let tmp = TempDir::new().unwrap(); + let start = make_nested_dirs(&tmp, &["a"]); + assert!(start.search_dir_up("b").is_none()); + } +} From 44c9af93f6ee82484ddd5485dae6f828d3cc8326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Mon, 15 Sep 2025 21:00:25 +0200 Subject: [PATCH 04/42] Command `plugins` (#5) --- .gitignore | 3 + Cargo.lock | 313 +++++++++++++++++- Cargo.toml | 5 + cli/Cargo.toml | 5 +- cli/src/commands.rs | 2 + cli/src/commands/config.rs | 6 +- cli/src/commands/config/subcommands/edit.rs | 14 +- cli/src/commands/config/subcommands/get.rs | 32 +- cli/src/commands/config/subcommands/list.rs | 34 +- cli/src/commands/config/subcommands/set.rs | 29 +- cli/src/commands/config/subcommands/unset.rs | 28 +- cli/src/commands/ignore.rs | 5 +- cli/src/commands/ignore/subcommands/add.rs | 12 +- cli/src/commands/ignore/subcommands/check.rs | 13 +- cli/src/commands/ignore/subcommands/edit.rs | 25 +- cli/src/commands/ignore/subcommands/remove.rs | 12 +- cli/src/commands/init.rs | 31 +- cli/src/commands/meva_command.rs | 12 +- cli/src/commands/plugins.rs | 62 ++++ cli/src/commands/plugins/subcommands.rs | 11 + cli/src/commands/plugins/subcommands/edit.rs | 125 +++++++ cli/src/commands/plugins/subcommands/info.rs | 88 +++++ cli/src/commands/plugins/subcommands/list.rs | 123 +++++++ .../commands/plugins/subcommands/register.rs | 187 +++++++++++ .../plugins/subcommands/unregister.rs | 93 ++++++ cli/src/extensions.rs | 2 - cli/src/extensions/command.rs | 4 + .../command/with_command_and_plugin.rs | 61 ++++ cli/src/extensions/command/with_file.rs | 7 +- cli/src/extensions/command/with_locations.rs | 3 +- cli/src/extensions/command/with_scope.rs | 86 +++++ cli/src/extensions/path.rs | 3 - cli/src/main.rs | 7 +- cli/src/meva_cli.rs | 20 +- engine/Cargo.toml | 4 + engine/src/config.rs | 8 +- engine/src/config/config_document.rs | 54 ++- ...tions.rs => config_document_operations.rs} | 6 +- engine/src/config/config_loader.rs | 137 +++++++- engine/src/config/config_location.rs | 49 ++- engine/src/config/handler.rs | 270 +++++++++++++++ engine/src/config/operations.rs | 50 +++ engine/src/engine_container.rs | 75 +++++ engine/src/errors.rs | 2 + engine/src/errors/config_error.rs | 2 +- engine/src/errors/engine_error.rs | 13 +- engine/src/errors/repository_error.rs | 16 + engine/src/init.rs | 5 + engine/src/init/handler.rs | 83 +++++ engine/src/init/operations.rs | 16 + engine/src/lib.rs | 8 +- engine/src/plugins.rs | 5 + engine/src/plugins/handler.rs | 146 ++++++++ engine/src/plugins/operations.rs | 134 ++++++++ engine/src/plugins_interceptor.rs | 240 ++++++++++++++ engine/src/repositories.rs | 1 + engine/src/repositories/meva_repository.rs | 175 ++++------ .../repositories/meva_repository_layout.rs | 75 +++++ engine/src/repositories/repository_layout.rs | 101 ++++-- plugins/Cargo.toml | 7 + plugins/src/enums.rs | 9 + plugins/src/enums/command_type.rs | 16 + plugins/src/enums/event_type.rs | 26 ++ plugins/src/enums/invocation_payload.rs | 58 ++++ plugins/src/enums/scope_type.rs | 16 + plugins/src/errors.rs | 10 + plugins/src/errors/configuration_error.rs | 18 + plugins/src/errors/invocation_error.rs | 12 + plugins/src/errors/plugin_error.rs | 62 ++++ plugins/src/errors/register_error.rs | 17 + plugins/src/layout.rs | 51 +++ plugins/src/lib.rs | 29 +- plugins/src/models.rs | 11 + plugins/src/models/invocation.rs | 9 + .../models/invocation/invocation_context.rs | 49 +++ .../src/models/invocation/invocation_input.rs | 33 ++ .../models/invocation/invocation_logger.rs | 109 ++++++ .../models/invocation/invocation_output.rs | 37 +++ plugins/src/models/payloads.rs | 5 + plugins/src/models/payloads/config_payload.rs | 79 +++++ plugins/src/models/payloads/init_payload.rs | 17 + plugins/src/models/plugin_configuration.rs | 90 +++++ plugins/src/models/plugin_entry.rs | 22 ++ plugins/src/models/plugins_configuration.rs | 123 +++++++ plugins/src/plugins_discovery.rs | 86 +++++ plugins/src/plugins_engine.rs | 252 ++++++++++++++ plugins/src/plugins_repository.rs | 108 ++++++ plugins/src/plugins_runner.rs | 210 ++++++++++++ shared/Cargo.toml | 1 + shared/src/extensions.rs | 2 + shared/src/extensions/fs.rs | 60 +++- .../src/extensions}/open_in_editor.rs | 16 +- shared/src/lib.rs | 3 + shared/src/pretty_field.rs | 12 + 94 files changed, 4476 insertions(+), 327 deletions(-) create mode 100644 cli/src/commands/plugins.rs create mode 100644 cli/src/commands/plugins/subcommands.rs create mode 100644 cli/src/commands/plugins/subcommands/edit.rs create mode 100644 cli/src/commands/plugins/subcommands/info.rs create mode 100644 cli/src/commands/plugins/subcommands/list.rs create mode 100644 cli/src/commands/plugins/subcommands/register.rs create mode 100644 cli/src/commands/plugins/subcommands/unregister.rs create mode 100644 cli/src/extensions/command/with_command_and_plugin.rs create mode 100644 cli/src/extensions/command/with_scope.rs delete mode 100644 cli/src/extensions/path.rs rename engine/src/config/{config_operations.rs => config_document_operations.rs} (89%) create mode 100644 engine/src/config/handler.rs create mode 100644 engine/src/config/operations.rs create mode 100644 engine/src/engine_container.rs create mode 100644 engine/src/errors/repository_error.rs create mode 100644 engine/src/init.rs create mode 100644 engine/src/init/handler.rs create mode 100644 engine/src/init/operations.rs create mode 100644 engine/src/plugins.rs create mode 100644 engine/src/plugins/handler.rs create mode 100644 engine/src/plugins/operations.rs create mode 100644 engine/src/plugins_interceptor.rs create mode 100644 engine/src/repositories/meva_repository_layout.rs create mode 100644 plugins/src/enums.rs create mode 100644 plugins/src/enums/command_type.rs create mode 100644 plugins/src/enums/event_type.rs create mode 100644 plugins/src/enums/invocation_payload.rs create mode 100644 plugins/src/enums/scope_type.rs create mode 100644 plugins/src/errors.rs create mode 100644 plugins/src/errors/configuration_error.rs create mode 100644 plugins/src/errors/invocation_error.rs create mode 100644 plugins/src/errors/plugin_error.rs create mode 100644 plugins/src/errors/register_error.rs create mode 100644 plugins/src/layout.rs create mode 100644 plugins/src/models.rs create mode 100644 plugins/src/models/invocation.rs create mode 100644 plugins/src/models/invocation/invocation_context.rs create mode 100644 plugins/src/models/invocation/invocation_input.rs create mode 100644 plugins/src/models/invocation/invocation_logger.rs create mode 100644 plugins/src/models/invocation/invocation_output.rs create mode 100644 plugins/src/models/payloads.rs create mode 100644 plugins/src/models/payloads/config_payload.rs create mode 100644 plugins/src/models/payloads/init_payload.rs create mode 100644 plugins/src/models/plugin_configuration.rs create mode 100644 plugins/src/models/plugin_entry.rs create mode 100644 plugins/src/models/plugins_configuration.rs create mode 100644 plugins/src/plugins_discovery.rs create mode 100644 plugins/src/plugins_engine.rs create mode 100644 plugins/src/plugins_repository.rs create mode 100644 plugins/src/plugins_runner.rs rename {cli/src/extensions/path => shared/src/extensions}/open_in_editor.rs (83%) create mode 100644 shared/src/pretty_field.rs diff --git a/.gitignore b/.gitignore index ad67955..ff2474a 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Ignore all the files created by running `cargo run --bin meva -- init` at the root of the project +.meva \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index e1973a6..d9f40d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.19" @@ -76,6 +91,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "backtrace" version = "0.3.75" @@ -116,12 +137,42 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "cc" +version = "1.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" +dependencies = [ + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.41" @@ -129,6 +180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -143,6 +195,18 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_derive" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "clap_lex" version = "0.7.5" @@ -154,7 +218,6 @@ name = "cli" version = "0.1.0" dependencies = [ "clap", - "editor-command", "engine", "globset", "miette", @@ -163,6 +226,8 @@ dependencies = [ "pretty_assertions", "rstest", "shared", + "strum", + "strum_macros", "thiserror", ] @@ -172,6 +237,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "diff" version = "0.1.13" @@ -218,12 +289,15 @@ dependencies = [ name = "engine" version = "0.1.0" dependencies = [ + "chrono", "dirs", "globset", "mockall", + "plugins", "pretty_assertions", "rstest", "serde", + "serde_json", "shared", "tempfile", "thiserror", @@ -368,6 +442,36 @@ version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "indexmap" version = "2.10.0" @@ -390,6 +494,22 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.174" @@ -489,6 +609,15 @@ dependencies = [ "syn", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.36.7" @@ -538,10 +667,17 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" name = "plugins" version = "0.1.0" dependencies = [ + "chrono", "mockall", "pretty_assertions", "rstest", + "serde", + "serde_json", "shared", + "strum", + "strum_macros", + "thiserror", + "wait-timeout", ] [[package]] @@ -723,6 +859,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + [[package]] name = "semver" version = "1.0.26" @@ -749,6 +897,18 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "serde_spanned" version = "1.0.0" @@ -762,6 +922,7 @@ dependencies = [ name = "shared" version = "0.1.0" dependencies = [ + "editor-command", "mockall", "pretty_assertions", "rstest", @@ -775,6 +936,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "slab" version = "0.4.10" @@ -787,6 +954,24 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "supports-color" version = "3.0.2" @@ -1007,6 +1192,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wait-timeout" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f3bf741a801531993db6478b95682117471f76916f5e690dd8d45395b09349" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1022,6 +1216,123 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index 44c86ce..e8e3d8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,12 @@ mockall = "0.13.1" pretty_assertions = "1.4.1" tempfile = "3.20.0" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0.143" up_finder = "0.0.4" dirs = "6.0.0" editor-command = "1.0.0" globset = "0.4.16" +chrono = { version = "0.4.41", features = ["serde"] } +clap = { version = "4.5.41", features = ["derive"] } +strum = "0.27" +strum_macros = "0.27" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index c30a2ee..49f8606 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -12,11 +12,12 @@ path = "src/main.rs" shared = { path = "../shared" } engine = { path = "../engine" } plugins = { path = "../plugins" } -clap = "4.5.41" +clap.workspace = true thiserror.workspace = true miette = { version = "7.6.0", features = ["fancy"] } -editor-command.workspace = true globset.workspace = true +strum.workspace = true +strum_macros.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/cli/src/commands.rs b/cli/src/commands.rs index a935c19..30074e0 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -2,9 +2,11 @@ pub mod config; pub mod ignore; pub mod init; pub mod meva_command; +pub mod plugins; pub use config::ConfigCommand; pub use ignore::IgnoreCommand; pub use init::InitCommand; +pub use plugins::PluginsCommand; pub use meva_command::MevaCommand; diff --git a/cli/src/commands/config.rs b/cli/src/commands/config.rs index f4f9bbc..0d0ef1b 100644 --- a/cli/src/commands/config.rs +++ b/cli/src/commands/config.rs @@ -1,6 +1,8 @@ pub mod subcommands; use crate::commands::MevaCommand; + +use engine::engine_container::MevaContainer; use subcommands::*; /// Implements the `config` command for Meva DVCS. @@ -16,7 +18,7 @@ impl ConfigCommand { } } -impl MevaCommand for ConfigCommand { +impl MevaCommand for ConfigCommand { fn name(&self) -> &'static str { "config" } @@ -32,7 +34,7 @@ impl MevaCommand for ConfigCommand { /// Define the set of subcommands under `config` namespace. /// /// Returns boxed instances of each config operation command. - fn subcommands(&self) -> Vec> { + fn subcommands(&self) -> Vec>> { vec![ Box::new(ConfigListCommand), Box::new(ConfigGetCommand), diff --git a/cli/src/commands/config/subcommands/edit.rs b/cli/src/commands/config/subcommands/edit.rs index 21a4ad4..9fffe6c 100644 --- a/cli/src/commands/config/subcommands/edit.rs +++ b/cli/src/commands/config/subcommands/edit.rs @@ -1,10 +1,12 @@ use clap::{ArgMatches, Command}; +use engine::engine_container::MevaContainer; use miette::{Context, IntoDiagnostic}; use engine::{ConfigDocument, ConfigLoader}; +use shared::extensions::OpenInEditor; use crate::commands::MevaCommand; -use crate::extensions::{LocationSelection, OpenInEditor, WithLocations}; +use crate::extensions::{LocationSelection, WithLocations}; /// Implements the `edit` subcommand for Meva configuration management. /// @@ -20,7 +22,7 @@ impl ConfigEditCommand { } } -impl MevaCommand for ConfigEditCommand { +impl MevaCommand for ConfigEditCommand { fn name(&self) -> &'static str { "edit" } @@ -41,8 +43,8 @@ impl MevaCommand for ConfigEditCommand { ) } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { - let loader = ConfigLoader::new_default(); + fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> miette::Result<()> { + let loader = ConfigLoader::default(); let override_cmd = loader.get("core.editor", None).ok(); let location = matches .get_config_location() @@ -52,7 +54,9 @@ impl MevaCommand for ConfigEditCommand { ConfigDocument::validate_existing_file(&location).into_diagnostic()?; - location.open_in_editor(override_cmd) + location.open_in_editor(override_cmd).into_diagnostic()?; + + Ok(()) } } diff --git a/cli/src/commands/config/subcommands/get.rs b/cli/src/commands/config/subcommands/get.rs index 1e8469f..88106a6 100644 --- a/cli/src/commands/config/subcommands/get.rs +++ b/cli/src/commands/config/subcommands/get.rs @@ -1,5 +1,7 @@ use clap::{Arg, ArgMatches, Command}; -use engine::{ConfigDocument, ConfigOperations}; +use engine::EngineContainer; +use engine::config::GetRequest; +use engine::engine_container::MevaContainer; use miette::IntoDiagnostic; use crate::commands::MevaCommand; @@ -23,7 +25,7 @@ impl ConfigGetCommand { const ARG_DEFAULT: &'static str = "default"; } -impl MevaCommand for ConfigGetCommand { +impl MevaCommand for ConfigGetCommand { fn name(&self) -> &'static str { "get" } @@ -59,18 +61,26 @@ impl MevaCommand for ConfigGetCommand { ) } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { - let location = matches - .get_config_location() - .get_default_path() - .into_diagnostic()?; - + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + let location = matches.get_config_location(); let key = matches.get_one::(Self::ARG_KEY).unwrap(); let default = matches.get_one::(Self::ARG_DEFAULT); - let doc = ConfigDocument::load(location).into_diagnostic()?; - let config_value = doc.get(key, default).into_diagnostic()?; - println!("{config_value}"); + let config_handler = container.config_handler().into_diagnostic()?; + let interceptor = container.plugins_interceptor().into_diagnostic()?; + + let request = GetRequest { + location, + key: key.clone(), + default: default.cloned(), + }; + + let response = config_handler + .handle_get(request, &interceptor) + .into_diagnostic()?; + + println!("{}", response.value); + Ok(()) } } diff --git a/cli/src/commands/config/subcommands/list.rs b/cli/src/commands/config/subcommands/list.rs index c100cc5..461aa86 100644 --- a/cli/src/commands/config/subcommands/list.rs +++ b/cli/src/commands/config/subcommands/list.rs @@ -1,5 +1,7 @@ use clap::{ArgMatches, Command}; -use engine::{ConfigDocument, ConfigOperations}; +use engine::EngineContainer; +use engine::config::ListRequest; +use engine::engine_container::MevaContainer; use miette::IntoDiagnostic; use crate::commands::MevaCommand; @@ -19,7 +21,7 @@ impl ConfigListCommand { } } -impl MevaCommand for ConfigListCommand { +impl MevaCommand for ConfigListCommand { fn name(&self) -> &'static str { "list" } @@ -40,22 +42,30 @@ impl MevaCommand for ConfigListCommand { ) } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { - let location = matches - .get_config_location() - .get_default_path() - .into_diagnostic()?; + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + let location = matches.get_config_location(); + + let config_handler = container.config_handler().into_diagnostic()?; + let interceptor = container.plugins_interceptor().into_diagnostic()?; - let doc = ConfigDocument::load(location).into_diagnostic()?; - let key_values = doc.list(); + let request = ListRequest { location }; + + let response = config_handler + .handle_list(request, &interceptor) + .into_diagnostic()?; - if key_values.is_empty() { + if response.key_values.is_empty() { println!("Config file has no key-value pairs to display.") } - let max_key_len = key_values.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + let max_key_len = response + .key_values + .iter() + .map(|(k, _)| k.len()) + .max() + .unwrap_or(0); - for (key, value) in key_values { + for (key, value) in response.key_values { println!("{key:max_key_len$} = {value}"); } diff --git a/cli/src/commands/config/subcommands/set.rs b/cli/src/commands/config/subcommands/set.rs index 09a2dc1..35950b0 100644 --- a/cli/src/commands/config/subcommands/set.rs +++ b/cli/src/commands/config/subcommands/set.rs @@ -1,5 +1,7 @@ use clap::{Arg, ArgMatches, Command}; -use engine::{ConfigDocument, ConfigOperations}; +use engine::EngineContainer; +use engine::config::SetRequest; +use engine::engine_container::MevaContainer; use miette::IntoDiagnostic; use crate::commands::MevaCommand; @@ -21,7 +23,7 @@ impl ConfigSetCommand { const ARG_VALUE: &'static str = "value"; } -impl MevaCommand for ConfigSetCommand { +impl MevaCommand for ConfigSetCommand { fn name(&self) -> &'static str { "set" } @@ -51,18 +53,25 @@ impl MevaCommand for ConfigSetCommand { ) } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { let key = matches.get_one::(Command::ARG_KEY).unwrap(); let value = matches.get_one::(Self::ARG_VALUE).unwrap(); - let location = matches - .get_config_location() - .get_default_path() - .into_diagnostic()?; + let location = matches.get_config_location(); + + let config_handler = container.config_handler().into_diagnostic()?; + let interceptor = container.plugins_interceptor().into_diagnostic()?; - let mut doc = ConfigDocument::load(location).into_diagnostic()?; + let request = SetRequest { + location, + key: key.clone(), + value: value.clone(), + }; + + let response = config_handler + .handle_set(request, &interceptor) + .into_diagnostic()?; - doc.set(key, value).into_diagnostic()?; - doc.save().into_diagnostic()?; + println!("{} = {}", response.key, response.value); Ok(()) } diff --git a/cli/src/commands/config/subcommands/unset.rs b/cli/src/commands/config/subcommands/unset.rs index d20bf80..3b34a6d 100644 --- a/cli/src/commands/config/subcommands/unset.rs +++ b/cli/src/commands/config/subcommands/unset.rs @@ -1,5 +1,7 @@ use clap::{ArgMatches, Command}; -use engine::{ConfigDocument, ConfigOperations}; +use engine::EngineContainer; +use engine::config::UnsetRequest; +use engine::engine_container::MevaContainer; use miette::IntoDiagnostic; use crate::commands::MevaCommand; @@ -18,7 +20,7 @@ impl ConfigUnsetCommand { } } -impl MevaCommand for ConfigUnsetCommand { +impl MevaCommand for ConfigUnsetCommand { fn name(&self) -> &'static str { "unset" } @@ -41,17 +43,23 @@ impl MevaCommand for ConfigUnsetCommand { .with_key_arg("TOML path to the config entry") } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { let key = matches.get_one::(Command::ARG_KEY).unwrap(); - let location = matches - .get_config_location() - .get_default_path() - .into_diagnostic()?; + let location = matches.get_config_location(); + + let config_handler = container.config_handler().into_diagnostic()?; + let interceptor = container.plugins_interceptor().into_diagnostic()?; - let mut doc = ConfigDocument::load(location).into_diagnostic()?; + let request = UnsetRequest { + location, + key: key.clone(), + }; + + let response = config_handler + .handle_unset(request, &interceptor) + .into_diagnostic()?; - doc.unset(key).into_diagnostic()?; - doc.save().into_diagnostic()?; + println!("{}", response.value); Ok(()) } diff --git a/cli/src/commands/ignore.rs b/cli/src/commands/ignore.rs index 39e48b5..3d37505 100644 --- a/cli/src/commands/ignore.rs +++ b/cli/src/commands/ignore.rs @@ -2,6 +2,7 @@ pub mod subcommands; use crate::commands::MevaCommand; +use engine::engine_container::MevaContainer; use subcommands::*; /// Implements the `ignore` command for Meva DVCS. @@ -16,7 +17,7 @@ impl IgnoreCommand { } } -impl MevaCommand for IgnoreCommand { +impl MevaCommand for IgnoreCommand { fn name(&self) -> &'static str { "ignore" } @@ -32,7 +33,7 @@ impl MevaCommand for IgnoreCommand { /// Define the set of subcommands under `ignore` namespace. /// /// Returns boxed instances of each ignore operation command. - fn subcommands(&self) -> Vec> { + fn subcommands(&self) -> Vec>> { vec![ Box::new(IgnoreAddCommand), Box::new(IgnoreCheckCommand), diff --git a/cli/src/commands/ignore/subcommands/add.rs b/cli/src/commands/ignore/subcommands/add.rs index b545a62..b33bf85 100644 --- a/cli/src/commands/ignore/subcommands/add.rs +++ b/cli/src/commands/ignore/subcommands/add.rs @@ -1,7 +1,10 @@ use std::path::PathBuf; use clap::{ArgMatches, Command}; -use engine::{IgnoreOperations, IgnoreService, MevaRepository, RepositoryLayout}; +use engine::{ + IgnoreOperations, IgnoreService, RepositoryLayout, engine_container::MevaContainer, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; use globset::Glob; use miette::IntoDiagnostic; @@ -24,7 +27,7 @@ impl IgnoreAddCommand { } } -impl MevaCommand for IgnoreAddCommand { +impl MevaCommand for IgnoreAddCommand { fn name(&self) -> &'static str { "add" } @@ -43,11 +46,12 @@ impl MevaCommand for IgnoreAddCommand { .with_file_arg("Path to a specific ignore file") } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> miette::Result<()> { let pattern = matches.get_one::(Command::ARG_PATTERN).unwrap(); let file = matches.get_one::(Command::ARG_FILE); - let ignore_service = IgnoreService::new(MevaRepository::IGNORE_FILE); + let layout = MevaRepositoryLayout::from_env().into_diagnostic()?; + let ignore_service = IgnoreService::new(layout.ignore_file_name()); let result_path = ignore_service.add(pattern, file).into_diagnostic()?; diff --git a/cli/src/commands/ignore/subcommands/check.rs b/cli/src/commands/ignore/subcommands/check.rs index 056d820..d4119ea 100644 --- a/cli/src/commands/ignore/subcommands/check.rs +++ b/cli/src/commands/ignore/subcommands/check.rs @@ -1,7 +1,10 @@ use std::path::{Path, PathBuf}; use clap::{Arg, ArgAction, ArgMatches, Command}; -use engine::{IgnoreOperations, IgnoreResult, IgnoreService, MevaRepository, RepositoryLayout}; +use engine::{ + IgnoreOperations, IgnoreResult, IgnoreService, RepositoryLayout, + engine_container::MevaContainer, repositories::meva_repository_layout::MevaRepositoryLayout, +}; use miette::IntoDiagnostic; use crate::{commands::MevaCommand, extensions::WithFile}; @@ -23,6 +26,7 @@ impl IgnoreCheckCommand { const ARG_EXPLAIN: &'static str = "explain"; + #[allow(dead_code)] fn print_check_result(result: &IgnoreResult, checked_path: &Path, explain: bool) { match result { IgnoreResult::Ignored { patterns, path } => { @@ -55,7 +59,7 @@ impl IgnoreCheckCommand { } } -impl MevaCommand for IgnoreCheckCommand { +impl MevaCommand for IgnoreCheckCommand { fn name(&self) -> &'static str { "check" } @@ -87,12 +91,13 @@ impl MevaCommand for IgnoreCheckCommand { ) } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> miette::Result<()> { let file = matches.get_one::(Command::ARG_FILE); let explain = matches.get_flag(Self::ARG_EXPLAIN); let checked_path = matches.get_one::(Self::ARG_PATH).unwrap(); - let ignore_service = IgnoreService::new(MevaRepository::IGNORE_FILE); + let layout = MevaRepositoryLayout::from_env().into_diagnostic()?; + let ignore_service = IgnoreService::new(layout.ignore_file_name()); let ignore_result = ignore_service.check(checked_path, file).into_diagnostic()?; diff --git a/cli/src/commands/ignore/subcommands/edit.rs b/cli/src/commands/ignore/subcommands/edit.rs index 295011a..3ec7de5 100644 --- a/cli/src/commands/ignore/subcommands/edit.rs +++ b/cli/src/commands/ignore/subcommands/edit.rs @@ -1,13 +1,14 @@ use std::path::PathBuf; use clap::{ArgMatches, Command}; -use engine::{ConfigLoader, IgnoreOperations, IgnoreService, MevaRepository, RepositoryLayout}; +use engine::{ + ConfigLoader, IgnoreOperations, IgnoreService, RepositoryLayout, + engine_container::MevaContainer, repositories::meva_repository_layout::MevaRepositoryLayout, +}; use miette::IntoDiagnostic; +use shared::extensions::OpenInEditor; -use crate::{ - commands::MevaCommand, - extensions::{OpenInEditor, WithFile}, -}; +use crate::{commands::MevaCommand, extensions::WithFile}; /// Implements the `edit` subcommand for Meva ignored files management. /// @@ -23,7 +24,7 @@ impl IgnoreEditCommand { } } -impl MevaCommand for IgnoreEditCommand { +impl MevaCommand for IgnoreEditCommand { fn name(&self) -> &'static str { "edit" } @@ -41,11 +42,13 @@ impl MevaCommand for IgnoreEditCommand { .with_file_arg("Path to a specific ignore file") } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> miette::Result<()> { let file = matches.get_one::(Command::ARG_FILE); - let ignore_service = IgnoreService::new(MevaRepository::IGNORE_FILE); - let loader = ConfigLoader::new_default(); + let layout = MevaRepositoryLayout::from_env().into_diagnostic()?; + let ignore_service = IgnoreService::new(layout.ignore_file_name()); + + let loader = ConfigLoader::default(); let override_cmd = loader.get("core.editor", None).ok(); let ignore_file = match file { @@ -53,7 +56,9 @@ impl MevaCommand for IgnoreEditCommand { None => ignore_service.find_ignore_file(None).into_diagnostic()?, }; - ignore_file.open_in_editor(override_cmd) + ignore_file.open_in_editor(override_cmd).into_diagnostic()?; + + Ok(()) } } diff --git a/cli/src/commands/ignore/subcommands/remove.rs b/cli/src/commands/ignore/subcommands/remove.rs index 9bfc64e..b65e160 100644 --- a/cli/src/commands/ignore/subcommands/remove.rs +++ b/cli/src/commands/ignore/subcommands/remove.rs @@ -1,7 +1,10 @@ use std::path::PathBuf; use clap::{ArgMatches, Command}; -use engine::{IgnoreOperations, IgnoreService, MevaRepository, RepositoryLayout}; +use engine::{ + IgnoreOperations, IgnoreService, RepositoryLayout, engine_container::MevaContainer, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; use globset::Glob; use miette::IntoDiagnostic; @@ -24,7 +27,7 @@ impl IgnoreRemoveCommand { } } -impl MevaCommand for IgnoreRemoveCommand { +impl MevaCommand for IgnoreRemoveCommand { fn name(&self) -> &'static str { "remove" } @@ -43,11 +46,12 @@ impl MevaCommand for IgnoreRemoveCommand { .with_file_arg("Path to a specific ignore file") } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> miette::Result<()> { let pattern = matches.get_one::(Command::ARG_PATTERN).unwrap(); let file = matches.get_one::(Command::ARG_FILE); - let ignore_service = IgnoreService::new(MevaRepository::IGNORE_FILE); + let layout = MevaRepositoryLayout::from_env().into_diagnostic()?; + let ignore_service = IgnoreService::new(layout.ignore_file_name()); let (result_path, removed_patterns) = ignore_service.remove(pattern, file).into_diagnostic()?; diff --git a/cli/src/commands/init.rs b/cli/src/commands/init.rs index c67ee44..25b5062 100644 --- a/cli/src/commands/init.rs +++ b/cli/src/commands/init.rs @@ -1,21 +1,21 @@ use std::path::PathBuf; -use clap::{Arg, ArgMatches, Command}; +use clap::{Arg, ArgMatches, Command, ValueHint}; +use engine::{EngineContainer, engine_container::MevaContainer, init::Request}; use miette::{IntoDiagnostic, Result}; use crate::commands::MevaCommand; -use engine::MevaRepository; /// Implements the `init` command for Meva DVCS. /// /// Initializes a new repository at a specified path, /// optionally setting the initial branch name. -pub struct InitCommand; +pub struct InitCommand {} impl InitCommand { /// Creates a new instance of the `InitCommand`. pub fn new() -> Self { - Self + Self {} } /// Argument name for specifying the initial branch. @@ -25,7 +25,7 @@ impl InitCommand { const ARG_PATH: &'static str = "path"; } -impl MevaCommand for InitCommand { +impl MevaCommand for InitCommand { fn name(&self) -> &'static str { "init" } @@ -60,6 +60,7 @@ impl MevaCommand for InitCommand { .help("Path to initialize repository") .default_value(".") .value_parser(clap::value_parser!(PathBuf)) + .value_hint(ValueHint::FilePath) .index(1), ) } @@ -77,14 +78,26 @@ impl MevaCommand for InitCommand { /// /// # Returns /// * `Result<()>`: Indicates success or detailed error if initialization fails. - fn execute(&self, matches: &ArgMatches) -> Result<()> { + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { let branch = matches.get_one::(Self::ARG_BRANCH).unwrap(); let target = matches.get_one::(Self::ARG_PATH).unwrap(); - let repository = MevaRepository::new(target); - repository.init(branch).into_diagnostic()?; + let init_handler = container.init_handler().into_diagnostic()?; + let interceptor = container.plugins_interceptor().into_diagnostic()?; - println!("Repository initialized successfully!"); + let request = Request { + working_dir: target.clone(), + initial_branch: branch.clone(), + }; + + let response = init_handler + .handle_init(request, &interceptor) + .into_diagnostic()?; + + println!( + "Repository initialized successfully at: {}", + response.repository_dir.to_string_lossy() + ); Ok(()) } diff --git a/cli/src/commands/meva_command.rs b/cli/src/commands/meva_command.rs index 6f48c06..50b9da1 100644 --- a/cli/src/commands/meva_command.rs +++ b/cli/src/commands/meva_command.rs @@ -1,8 +1,12 @@ use clap::{ArgMatches, Command}; +use engine::EngineContainer; use miette::{Context, Result}; /// A trait representing a top-level command in the Meva CLI. -pub trait MevaCommand { +pub trait MevaCommand +where + T: EngineContainer, +{ /// Returns the unique name of the command. fn name(&self) -> &'static str; @@ -47,12 +51,12 @@ pub trait MevaCommand { /// /// # Returns /// * `Result<()>`: Indicates whether the execution succeeded or an error occurred during dispatch. - fn execute(&self, matches: &ArgMatches) -> Result<()> { + fn execute(&self, matches: &ArgMatches, container: &T) -> Result<()> { if let Some((name, sub_matches)) = matches.subcommand() { for sub_command in self.subcommands() { if sub_command.name() == name { return sub_command - .execute(sub_matches) + .execute(sub_matches, container) .wrap_err_with(|| format!("Error running `{name}` subcommand")); } } @@ -64,7 +68,7 @@ pub trait MevaCommand { /// Returns a vector of boxed subcommands for this command. /// /// Default implementation returns an empty vector. - fn subcommands(&self) -> Vec> { + fn subcommands(&self) -> Vec>> { Vec::new() } } diff --git a/cli/src/commands/plugins.rs b/cli/src/commands/plugins.rs new file mode 100644 index 0000000..b489ea4 --- /dev/null +++ b/cli/src/commands/plugins.rs @@ -0,0 +1,62 @@ +pub mod subcommands; + +use crate::commands::MevaCommand; + +use engine::engine_container::MevaContainer; +use subcommands::*; + +/// Implements the `plugins` top-level command for Meva. +/// +/// The `plugins` command serves as a namespace for all plugin-related operations +/// such as listing, editing, registering, unregistering, or retrieving information +/// about plugins. +pub struct PluginsCommand; + +impl PluginsCommand { + /// Creates a new instance of the `PluginsCommand`. + pub fn new() -> Self { + Self + } +} + +impl MevaCommand for PluginsCommand { + fn name(&self) -> &'static str { + "plugins" + } + + fn about(&self) -> &'static str { + "Manage plugins" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Define the set of subcommands under `plugins` namespace. + /// + /// Returns boxed instances of each ignore operation command. + fn subcommands(&self) -> Vec>> { + vec![ + Box::new(PluginsEditCommand), + Box::new(PluginsInfoCommand), + Box::new(PluginsListCommand), + Box::new(PluginsRegisterCommand), + Box::new(PluginsUnregisterCommand), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = PluginsCommand::new(); + assert_eq!(cmd.name(), "plugins"); + assert_eq!(cmd.about(), "Manage plugins"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/plugins/subcommands.rs b/cli/src/commands/plugins/subcommands.rs new file mode 100644 index 0000000..4a82e4e --- /dev/null +++ b/cli/src/commands/plugins/subcommands.rs @@ -0,0 +1,11 @@ +mod edit; +mod info; +mod list; +mod register; +mod unregister; + +pub use edit::PluginsEditCommand; +pub use info::PluginsInfoCommand; +pub use list::PluginsListCommand; +pub use register::PluginsRegisterCommand; +pub use unregister::PluginsUnregisterCommand; diff --git a/cli/src/commands/plugins/subcommands/edit.rs b/cli/src/commands/plugins/subcommands/edit.rs new file mode 100644 index 0000000..f1d3d76 --- /dev/null +++ b/cli/src/commands/plugins/subcommands/edit.rs @@ -0,0 +1,125 @@ +use clap::{Arg, ArgAction, ArgMatches, Command}; +use engine::{ + ConfigLoader, EngineContainer, + engine_container::MevaContainer, + plugins::{EditRequest, PluginsOperations}, +}; +use miette::IntoDiagnostic; +use plugins::{CommandType, ScopeType}; +use shared::extensions::OpenInEditor; + +use crate::{ + commands::MevaCommand, + extensions::{WithCommandPlugin, WithScope}, +}; + +pub struct PluginsEditCommand; + +impl PluginsEditCommand { + #[allow(dead_code)] + pub fn new() -> Self { + Self + } + + const ARG_ENABLE: &'static str = "enable"; + + const ARG_DISABLE: &'static str = "disable"; +} + +impl MevaCommand for PluginsEditCommand { + fn name(&self) -> &'static str { + "edit" + } + + fn about(&self) -> &'static str { + "Open the file with plugin's source code in your default editor" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .with_command_and_plugin_arg( + "The command associated with the plugin", + "The name of the plugin to edit", + ) + .with_scope_arg("Scope of the plugin") + .arg( + Arg::new(Self::ARG_ENABLE) + .short('e') + .long(Self::ARG_ENABLE) + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_DISABLE) + .help("Enable plugin"), + ) + .arg( + Arg::new(Self::ARG_DISABLE) + .short('d') + .long(Self::ARG_DISABLE) + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_ENABLE) + .help("Disable plugin"), + ) + } + + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + let command_str = matches.get_one::(Command::ARG_COMMAND).unwrap(); + let name = matches.get_one::(Command::ARG_PLUGIN).unwrap(); + let default_scope = ScopeType::Local.to_string(); + let scope_str = matches + .get_one::(Command::ARG_SCOPE) + .unwrap_or(&default_scope); + let enable = matches.get_flag(Self::ARG_ENABLE); + let disable = matches.get_flag(Self::ARG_DISABLE); + + let enabled = if enable || disable { + Some(!disable) + } else { + None + }; + + let request = EditRequest { + command: command_str.parse::().unwrap(), + name: name.clone(), + scope: scope_str.parse::().unwrap(), + enabled, + }; + + let handler = container.plugins_handler().into_diagnostic()?; + + let response = handler.edit(request).into_diagnostic()?; + + if enabled.is_none() { + let loader = ConfigLoader::default(); + let override_cmd = loader.get("core.editor", None).ok(); + response + .source_file + .open_in_editor(override_cmd) + .into_diagnostic()?; + } else { + println!("{response}"); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = PluginsEditCommand::new(); + assert_eq!(cmd.name(), "edit"); + assert_eq!( + cmd.about(), + "Open the file with plugin's source code in your default editor" + ); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/plugins/subcommands/info.rs b/cli/src/commands/plugins/subcommands/info.rs new file mode 100644 index 0000000..570349d --- /dev/null +++ b/cli/src/commands/plugins/subcommands/info.rs @@ -0,0 +1,88 @@ +use clap::{ArgMatches, Command}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + plugins::{InfoRequest, PluginsOperations}, +}; +use miette::IntoDiagnostic; +use plugins::{CommandType, ScopeType}; + +use crate::{ + commands::MevaCommand, + extensions::{WithCommandPlugin, WithScope}, +}; + +pub struct PluginsInfoCommand; + +impl PluginsInfoCommand { + #[allow(dead_code)] + pub fn new() -> Self { + Self + } +} + +impl MevaCommand for PluginsInfoCommand { + fn name(&self) -> &'static str { + "info" + } + + fn about(&self) -> &'static str { + "Display detailed information about a registered plugin" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .with_command_and_plugin_arg( + "The command associated with the plugin", + "The name of the plugin to show details for", + ) + .with_scope_arg("Scope of the plugin") + } + + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + let command_str = matches.get_one::(Command::ARG_COMMAND).unwrap(); + let default_scope = ScopeType::Local.to_string(); + let scope_str = matches + .get_one::(Command::ARG_SCOPE) + .unwrap_or(&default_scope); + + let request = InfoRequest { + name: matches + .get_one::(Command::ARG_PLUGIN) + .unwrap() + .clone(), + command: command_str.parse::().unwrap(), + scope: scope_str.parse::().unwrap(), + }; + + let handler = container.plugins_handler().into_diagnostic()?; + + let response = handler.info(request).into_diagnostic()?; + + println!("{response}"); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = PluginsInfoCommand::new(); + assert_eq!(cmd.name(), "info"); + assert_eq!( + cmd.about(), + "Display detailed information about a registered plugin" + ); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/plugins/subcommands/list.rs b/cli/src/commands/plugins/subcommands/list.rs new file mode 100644 index 0000000..9dcbdf2 --- /dev/null +++ b/cli/src/commands/plugins/subcommands/list.rs @@ -0,0 +1,123 @@ +use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + plugins::{ListRequest, PluginsOperations}, +}; +use miette::IntoDiagnostic; +use plugins::{CommandType, EventType, ScopeType}; + +use crate::{commands::MevaCommand, extensions::WithScope}; + +pub struct PluginsListCommand; + +impl PluginsListCommand { + #[allow(dead_code)] + pub fn new() -> Self { + Self + } + + const ARG_COMMAND: &'static str = "command"; + + const ARG_EVENT: &'static str = "event"; + + const ARG_DISABLED: &'static str = "disabled"; + + const ARG_ENABLED: &'static str = "enabled"; +} + +impl MevaCommand for PluginsListCommand { + fn name(&self) -> &'static str { + "list" + } + + fn about(&self) -> &'static str { + "List registered plugins with optional filters" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_COMMAND) + .index(1) + .required(true) + .value_name("COMMAND") + .help("Filter plugins by associated command"), + ) + .arg( + Arg::new(Self::ARG_EVENT) + .index(2) + .required(true) + .value_name("EVENT") + .help("Filter plugins by event"), + ) + .with_scope_arg("Scope of the plugin") + .arg( + Arg::new(Self::ARG_ENABLED) + .short('E') + .long(Self::ARG_ENABLED) + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_DISABLED) + .help("Include enabled plugins in the output"), + ) + .arg( + Arg::new(Self::ARG_DISABLED) + .short('D') + .long(Self::ARG_DISABLED) + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_ENABLED) + .help("Include disabled plugins in the output"), + ) + .group( + ArgGroup::new("filter") + .args([Self::ARG_ENABLED, Self::ARG_DISABLED]) + .multiple(true), + ) + } + + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + let command_str = matches.get_one::(Self::ARG_COMMAND).unwrap(); + let event_str = matches.get_one::(Self::ARG_EVENT).unwrap(); + let default_scope = ScopeType::Local.to_string(); + let scope_str = matches + .get_one::(Command::ARG_SCOPE) + .unwrap_or(&default_scope); + + let include_disabled = matches.get_flag(Self::ARG_DISABLED); + let include_enabled = matches.get_flag(Self::ARG_ENABLED) || !include_disabled; + + let request = ListRequest { + command: command_str.parse::().unwrap(), + event: event_str.parse::().unwrap(), + scope: scope_str.parse::().unwrap(), + include_disabled, + include_enabled, + }; + + let handler = container.plugins_handler().into_diagnostic()?; + let response = handler.list(request).into_diagnostic()?; + + println!("{response}"); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = PluginsListCommand::new(); + assert_eq!(cmd.name(), "list"); + assert_eq!(cmd.about(), "List registered plugins with optional filters"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/plugins/subcommands/register.rs b/cli/src/commands/plugins/subcommands/register.rs new file mode 100644 index 0000000..e2cc1f4 --- /dev/null +++ b/cli/src/commands/plugins/subcommands/register.rs @@ -0,0 +1,187 @@ +use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint, builder::PossibleValuesParser}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + plugins::{PluginsOperations, RegisterRequest}, +}; +use miette::IntoDiagnostic; +use plugins::{CommandType, EventType, ScopeType}; +use std::path::PathBuf; +use strum::VariantNames; + +use crate::{commands::MevaCommand, extensions::WithFile}; + +pub struct PluginsRegisterCommand; + +impl PluginsRegisterCommand { + #[allow(dead_code)] + pub fn new() -> Self { + Self + } + + const ARG_PATH: &'static str = "path"; + + const ARG_SCOPE: &'static str = "scope"; + + const ARG_NAME: &'static str = "name"; + + const ARG_DESCRIPTION: &'static str = "description"; + + const ARG_COMMAND: &'static str = "command"; + + const ARG_EVENT: &'static str = "event"; + + const ARG_ORDER: &'static str = "order"; + + const ARG_DISABLED: &'static str = "disabled"; + + const ARG_INTERPRETER: &'static str = "interpreter"; +} + +impl MevaCommand for PluginsRegisterCommand { + fn name(&self) -> &'static str { + "register" + } + + fn about(&self) -> &'static str { + "Register a new plugin" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_PATH) + .value_name("PATH") + .index(1) + .required(true) + .value_parser(clap::value_parser!(PathBuf)) + .value_hint(ValueHint::FilePath) + .help("Path to the script to register"), + ) + .with_file_arg("Relative path to the file") + .arg( + Arg::new(Self::ARG_SCOPE) + .short('s') + .long(Self::ARG_SCOPE) + .value_parser(PossibleValuesParser::new(ScopeType::VARIANTS)) + .help("Scope of the plugin"), + ) + .arg( + Arg::new(Self::ARG_NAME) + .short('n') + .long(Self::ARG_NAME) + .value_name("NAME") + .required(true) + .help("Plugin name"), + ) + .arg( + Arg::new(Self::ARG_DESCRIPTION) + .short('d') + .long(Self::ARG_DESCRIPTION) + .value_name("DESCRIPTION") + .help("Plugin description"), + ) + .arg( + Arg::new(Self::ARG_COMMAND) + .short('c') + .long(Self::ARG_COMMAND) + .value_name("COMMAND") + .required(true) + .value_parser(PossibleValuesParser::new(CommandType::VARIANTS)) + .help("Command type (kebab-case)"), + ) + .arg( + Arg::new(Self::ARG_EVENT) + .short('e') + .long(Self::ARG_EVENT) + .value_name("EVENT") + .required(true) + .value_parser(PossibleValuesParser::new(EventType::VARIANTS)) + .help("Event type (kebab-case)"), + ) + .arg( + Arg::new(Self::ARG_ORDER) + .short('o') + .long(Self::ARG_ORDER) + .value_name("ORDER") + .required(true) + .value_parser(clap::value_parser!(u32)) + .help("Execution order (integer)"), + ) + .arg( + Arg::new(Self::ARG_DISABLED) + .short('D') + .long(Self::ARG_DISABLED) + .action(ArgAction::SetTrue) + .help("Disable plugin"), + ) + .arg( + Arg::new(Self::ARG_INTERPRETER) + .short('i') + .long(Self::ARG_INTERPRETER) + .value_name("INTERPRETER") + .required(false) + .help("Interpreter used use to run the script (e.g. 'python3')"), + ) + } + + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + let command_str = matches.get_one::(Self::ARG_COMMAND).unwrap(); + let event_str = matches.get_one::(Self::ARG_EVENT).unwrap(); + let default_scope = ScopeType::Local.to_string(); + let scope_str = matches + .get_one::(Self::ARG_SCOPE) + .unwrap_or(&default_scope); + + let request = RegisterRequest { + path: matches.get_one::(Self::ARG_PATH).unwrap().clone(), + scope: scope_str.parse::().unwrap(), + name: matches.get_one::(Self::ARG_NAME).unwrap().clone(), + description: matches.get_one::(Self::ARG_DESCRIPTION).cloned(), + file: matches + .get_one::(Command::ARG_FILE) + .unwrap() + .clone(), + command: command_str.parse::().unwrap(), + event: event_str.parse::().unwrap(), + order: *matches.get_one::(Self::ARG_ORDER).unwrap(), + enabled: !matches.get_flag(Self::ARG_DISABLED), + interpreter: matches.get_one::(Self::ARG_INTERPRETER).cloned(), + }; + + let handler = container.plugins_handler().into_diagnostic()?; + + let response = handler.register(request).into_diagnostic()?; + + println!("Plugin registered successfully!"); + println!( + "Source code copied to: {}", + response.plugin_source_file.display() + ); + println!( + "Metadata saved at: {}", + response.plugins_metadata_file.display() + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = PluginsRegisterCommand::new(); + assert_eq!(cmd.name(), "register"); + assert_eq!(cmd.about(), "Register a new plugin"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/plugins/subcommands/unregister.rs b/cli/src/commands/plugins/subcommands/unregister.rs new file mode 100644 index 0000000..236742d --- /dev/null +++ b/cli/src/commands/plugins/subcommands/unregister.rs @@ -0,0 +1,93 @@ +use clap::{ArgMatches, Command}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + plugins::{PluginsOperations, UnregisterRequest}, +}; +use miette::IntoDiagnostic; +use plugins::{CommandType, ScopeType}; + +use crate::{ + commands::MevaCommand, + extensions::{WithCommandPlugin, WithScope}, +}; + +pub struct PluginsUnregisterCommand; + +impl PluginsUnregisterCommand { + #[allow(dead_code)] + pub fn new() -> Self { + Self + } +} + +impl MevaCommand for PluginsUnregisterCommand { + fn name(&self) -> &'static str { + "unregister" + } + + fn about(&self) -> &'static str { + "Unregister a previously registered plugin" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .with_command_and_plugin_arg( + "The command associated with the plugin", + "The plugin name to unregister", + ) + .with_scope_arg("Scope of the plugin") + } + + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + let command_str = matches.get_one::(Command::ARG_COMMAND).unwrap(); + let default_scope = ScopeType::Local.to_string(); + let scope_str = matches + .get_one::(Command::ARG_SCOPE) + .unwrap_or(&default_scope); + + let request = UnregisterRequest { + command: command_str.parse::().unwrap().clone(), + name: matches + .get_one::(Command::ARG_PLUGIN) + .unwrap() + .clone(), + scope: scope_str.parse::().unwrap(), + }; + + let handler = container.plugins_handler().into_diagnostic()?; + + let response = handler.unregister(request).into_diagnostic()?; + + println!("Plugin unregistered successfully!"); + println!( + "Removed entry from configuration file: {}", + response.plugins_metadata_file.display() + ); + println!( + "Deleted source code file: {}", + response.plugin_source_file.display() + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = PluginsUnregisterCommand::new(); + assert_eq!(cmd.name(), "unregister"); + assert_eq!(cmd.about(), "Unregister a previously registered plugin"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/extensions.rs b/cli/src/extensions.rs index 1d5427f..da95e64 100644 --- a/cli/src/extensions.rs +++ b/cli/src/extensions.rs @@ -1,7 +1,5 @@ pub mod arg_matches; pub mod command; -pub mod path; pub use arg_matches::*; pub use command::*; -pub use path::*; diff --git a/cli/src/extensions/command.rs b/cli/src/extensions/command.rs index 0a8fe8d..eaf038e 100644 --- a/cli/src/extensions/command.rs +++ b/cli/src/extensions/command.rs @@ -1,9 +1,13 @@ +pub mod with_command_and_plugin; pub mod with_file; pub mod with_key; pub mod with_locations; pub mod with_pattern; +pub mod with_scope; +pub use with_command_and_plugin::WithCommandPlugin; pub use with_file::WithFile; pub use with_key::WithKey; pub use with_locations::WithLocations; pub use with_pattern::WithPattern; +pub use with_scope::WithScope; diff --git a/cli/src/extensions/command/with_command_and_plugin.rs b/cli/src/extensions/command/with_command_and_plugin.rs new file mode 100644 index 0000000..f4e0327 --- /dev/null +++ b/cli/src/extensions/command/with_command_and_plugin.rs @@ -0,0 +1,61 @@ +use clap::{Arg, Command, builder::PossibleValuesParser}; +use plugins::CommandType; +use strum::VariantNames; + +/// Trait to add required `command` and `plugin` arguments to a Clap command. +/// +/// This allows CLI commands to explicitly specify which command type and plugin +/// they want to operate on. +pub trait WithCommandPlugin { + /// Constant name of the CLI argument for the command. + const ARG_COMMAND: &'static str; + + /// Constant name of the CLI argument for the plugin. + const ARG_PLUGIN: &'static str; + + /// Extends a `Command` by adding the `command` and `plugin` positional arguments. + /// + /// # Arguments + /// + /// * `self` – The command being extended. + /// * `command_help` – Help message describing the purpose of the `command` argument. + /// * `plugin_help` – Help message describing the purpose of the `plugin` argument. + /// + /// # Returns + /// + /// The original [`Command`] with both `command` and `plugin` arguments appended. + fn with_command_and_plugin_arg( + self, + command_help: &'static str, + plugin_help: &'static str, + ) -> Self; +} + +impl WithCommandPlugin for Command { + const ARG_COMMAND: &'static str = "command"; + + const ARG_PLUGIN: &'static str = "plugin"; + + fn with_command_and_plugin_arg( + self, + command_help: &'static str, + plugin_help: &'static str, + ) -> Self { + self.arg( + Arg::new(Self::ARG_COMMAND) + .value_name("COMMAND") + .index(1) + .required(true) + // Restricts values to the variants of `CommandType` + .value_parser(PossibleValuesParser::new(CommandType::VARIANTS)) + .help(command_help), + ) + .arg( + Arg::new(Self::ARG_PLUGIN) + .value_name("PLUGIN") + .index(2) + .required(true) + .help(plugin_help), + ) + } +} diff --git a/cli/src/extensions/command/with_file.rs b/cli/src/extensions/command/with_file.rs index 9d7bb01..72bc96f 100644 --- a/cli/src/extensions/command/with_file.rs +++ b/cli/src/extensions/command/with_file.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use clap::{Arg, Command}; +use clap::{Arg, Command, ValueHint}; /// Trait to add an optional `file` argument to a Clap command. pub trait WithFile { @@ -29,8 +29,9 @@ impl WithFile for Command { .short('f') .long(Self::ARG_FILE) .value_name("FILE") - .help(file_help) - .value_parser(clap::value_parser!(PathBuf)), + .value_parser(clap::value_parser!(PathBuf)) + .value_hint(ValueHint::FilePath) + .help(file_help), ) } } diff --git a/cli/src/extensions/command/with_locations.rs b/cli/src/extensions/command/with_locations.rs index e58e1e9..1b479b6 100644 --- a/cli/src/extensions/command/with_locations.rs +++ b/cli/src/extensions/command/with_locations.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use clap::{Arg, ArgAction, ArgGroup, Command}; +use clap::{Arg, ArgAction, ArgGroup, Command, ValueHint}; /// Trait for adding CLI flags or options to select configuration source locations. /// @@ -74,6 +74,7 @@ impl WithLocations for Command { .long(Self::ARG_FILE) .value_name("FILE") .help(file_help) + .value_hint(ValueHint::FilePath) .value_parser(clap::value_parser!(PathBuf)) // Cannot be used with global or local .conflicts_with_all([Self::ARG_GLOBAL, Self::ARG_LOCAL]), diff --git a/cli/src/extensions/command/with_scope.rs b/cli/src/extensions/command/with_scope.rs new file mode 100644 index 0000000..450fea3 --- /dev/null +++ b/cli/src/extensions/command/with_scope.rs @@ -0,0 +1,86 @@ +use clap::{Arg, Command, builder::PossibleValuesParser}; +use plugins::ScopeType; +use strum::VariantNames; + +/// Trait to add an optional `scope` argument to a Clap command. +/// +/// This argument allows users to specify the scope in which the command should operate +/// (e.g., local, global, etc.), using the variants defined in [`ScopeType`]. +pub trait WithScope { + /// Constant name of the CLI argument for the scope. + const ARG_SCOPE: &'static str; + + /// Extends a `Command` by adding a `scope` argument with the given help text. + /// + /// # Arguments + /// + /// * `self` – The command being extended. + /// * `scope_help` – Help message describing the purpose of the `scope` argument. + /// + /// # Returns + /// + /// The original [`Command`] with the `scope` argument appended. + fn with_scope_arg(self, scope_help: &'static str) -> Self; +} + +impl WithScope for Command { + const ARG_SCOPE: &'static str = "scope"; + + fn with_scope_arg(self, scope_help: &'static str) -> Self { + self.arg( + Arg::new(Self::ARG_SCOPE) + .short('s') + .long(Self::ARG_SCOPE) + // Uses the variants of `ScopeType` as allowed values + .value_parser(PossibleValuesParser::new(ScopeType::VARIANTS)) + .help(scope_help), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + use rstest::{fixture, rstest}; + + #[fixture] + fn cmd() -> Command { + Command::new("cmd").with_scope_arg("scope help") + } + + #[rstest] + fn test_short_scope_local(cmd: Command) { + let matches = cmd + .try_get_matches_from(vec!["cmd", "-s", "local"]) + .unwrap(); + assert_eq!( + matches.get_one::("scope"), + Some(&"local".to_string()) + ); + } + + #[rstest] + fn test_long_scope_global(cmd: Command) { + let matches = cmd + .try_get_matches_from(vec!["cmd", "--scope", "global"]) + .unwrap(); + assert_eq!( + matches.get_one::("scope"), + Some(&"global".to_string()) + ); + } + + #[rstest] + fn test_invalid_scope_value(cmd: Command) { + let result = cmd.try_get_matches_from(vec!["cmd", "--scope", "invalid"]); + assert!(result.is_err_and(|e| e.kind() == ErrorKind::InvalidValue)); + } + + #[rstest] + fn test_no_scope_provided(cmd: Command) { + let matches = cmd.try_get_matches_from(vec!["cmd"]).unwrap(); + assert!(matches.get_one::("scope").is_none()); + } +} diff --git a/cli/src/extensions/path.rs b/cli/src/extensions/path.rs deleted file mode 100644 index 50a14bb..0000000 --- a/cli/src/extensions/path.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod open_in_editor; - -pub use open_in_editor::OpenInEditor; diff --git a/cli/src/main.rs b/cli/src/main.rs index d869b3b..7a71ec5 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -3,17 +3,20 @@ mod extensions; mod meva_cli; use crate::meva_cli::MevaCli; -use commands::{ConfigCommand, IgnoreCommand, InitCommand}; +use commands::{ConfigCommand, IgnoreCommand, InitCommand, PluginsCommand}; +use engine::engine_container::MevaContainer; use miette::Result; fn main() -> Result<()> { miette::set_panic_hook(); - let mut cli = MevaCli::new(); + let container = MevaContainer {}; + let mut cli = MevaCli::new(container); cli.add_command(Box::new(InitCommand::new())); cli.add_command(Box::new(ConfigCommand::new())); cli.add_command(Box::new(IgnoreCommand::new())); + cli.add_command(Box::new(PluginsCommand::new())); cli.run() } diff --git a/cli/src/meva_cli.rs b/cli/src/meva_cli.rs index 7fecb08..595fd5b 100644 --- a/cli/src/meva_cli.rs +++ b/cli/src/meva_cli.rs @@ -1,4 +1,5 @@ use clap::{Command, error::ErrorKind}; +use engine::EngineContainer; use miette::{IntoDiagnostic, Result, WrapErr, miette}; use crate::commands::MevaCommand; @@ -7,16 +8,23 @@ use crate::commands::MevaCommand; /// /// This struct manages registration of commands, building the CLI parser, /// and dispatching command execution based on user input. -pub struct MevaCli { +pub struct MevaCli +where + T: EngineContainer, +{ /// Registered commands available in the CLI. - commands: Vec>, + commands: Vec>>, + + /// Dependency injection container. + container: T, } -impl MevaCli { +impl MevaCli { /// Creates a new instance of the Meva CLI application with no commands registered. - pub fn new() -> Self { + pub fn new(container: T) -> Self { Self { commands: Vec::new(), + container, } } @@ -26,7 +34,7 @@ impl MevaCli { /// /// # Arguments /// * `command`: The boxed command to register. - pub fn add_command(&mut self, command: Box) { + pub fn add_command(&mut self, command: Box>) { self.commands.push(command); } @@ -73,7 +81,7 @@ impl MevaCli { for cmd in &self.commands { if cmd.name() == name { return cmd - .execute(sub_matches) + .execute(sub_matches, &self.container) .wrap_err_with(|| format!("Error running `{name}` command")); } } diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 7ae4517..7729002 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -6,13 +6,17 @@ authors.workspace = true [dependencies] shared = { path = "../shared" } +plugins = { path = "../plugins" } tempfile.workspace = true thiserror.workspace = true serde.workspace = true +serde_json.workspace = true toml = "0.9.2" toml_edit = "0.23.2" dirs.workspace = true globset.workspace = true +chrono.workspace = true + [dev-dependencies] rstest.workspace = true diff --git a/engine/src/config.rs b/engine/src/config.rs index 8d796e0..672316b 100644 --- a/engine/src/config.rs +++ b/engine/src/config.rs @@ -1,9 +1,13 @@ pub mod config_document; +pub mod config_document_operations; pub mod config_loader; pub mod config_location; -pub mod config_operations; +mod handler; +mod operations; pub use config_document::ConfigDocument; +pub use config_document_operations::ConfigDocumentOperations; pub use config_loader::ConfigLoader; pub use config_location::ConfigLocation; -pub use config_operations::ConfigOperations; +pub use handler::ConfigHandler; +pub use operations::*; diff --git a/engine/src/config/config_document.rs b/engine/src/config/config_document.rs index 3e898ad..245ba98 100644 --- a/engine/src/config/config_document.rs +++ b/engine/src/config/config_document.rs @@ -6,7 +6,7 @@ use std::{ use toml_edit::{Datetime, DocumentMut, Item, Table, Value, value}; use crate::{ - ConfigOperations, + config::ConfigDocumentOperations, errors::{ConfigError, EngineResult}, }; @@ -59,15 +59,7 @@ impl ConfigDocument { if !path.is_file() { return Err(ConfigError::InvalidConfigFile { path: path.display().to_string(), - reason: "path is not a regular file".to_string(), - } - .into()); - } - - if !path.exists() { - return Err(ConfigError::InvalidConfigFile { - path: path.display().to_string(), - reason: "file does not exist".to_string(), + reason: "path is not a regular file or it does not exist".to_string(), } .into()); } @@ -157,7 +149,7 @@ impl ConfigDocument { } } -impl ConfigOperations for ConfigDocument { +impl ConfigDocumentOperations for ConfigDocument { fn get(&self, key_path: &str, default: Option<&String>) -> EngineResult { let keys = Self::split_key_path(key_path); let mut current_item = self.doc.as_item(); @@ -213,12 +205,10 @@ impl ConfigOperations for ConfigDocument { let last = keys[keys.len() - 1]; current_table[last] = item; - println!("{key_path} = {val}"); - Ok(()) } - fn unset(&mut self, key_path: &str) -> EngineResult<()> { + fn unset(&mut self, key_path: &str) -> EngineResult { let keys = Self::split_key_path(key_path); if keys.is_empty() { @@ -242,17 +232,47 @@ impl ConfigOperations for ConfigDocument { } let last_key = keys[keys.len() - 1]; + if let Some(table) = current.as_table_like_mut() { - table.remove(last_key); + if let Some(removed_item) = table.remove(last_key) { + let value_str = match removed_item { + Item::Value(v) => { + Self::value_to_string(&v).ok_or_else(|| ConfigError::InvalidValueType { + type_name: v.type_name().to_string(), + })? + } + Item::Table(t) => t.to_string(), + other => { + let s = other.to_string(); + if s.is_empty() { + return Err(ConfigError::InvalidValueType { + type_name: other.type_name().to_string(), + } + .into()); + } else { + s + } + } + }; + + return Ok(value_str); + } else { + return Err(ConfigError::KeyNotFound { + key: key_path.to_string(), + } + .into()); + } } - Ok(()) + Err(ConfigError::KeyNotFound { + key: key_path.to_string(), + } + .into()) } fn list(&self) -> Vec<(String, String)> { let mut result = Vec::new(); Self::collect_key_values("", self.doc.as_item(), &mut result); - result } } diff --git a/engine/src/config/config_operations.rs b/engine/src/config/config_document_operations.rs similarity index 89% rename from engine/src/config/config_operations.rs rename to engine/src/config/config_document_operations.rs index 08e4694..6fe0566 100644 --- a/engine/src/config/config_operations.rs +++ b/engine/src/config/config_document_operations.rs @@ -1,7 +1,7 @@ use crate::errors::EngineResult; /// Defines operations for accessing and modifying configuration values -pub trait ConfigOperations { +pub trait ConfigDocumentOperations { /// Retrieves the value at the specified key path. /// /// # Arguments @@ -34,8 +34,8 @@ pub trait ConfigOperations { /// /// # Returns /// - /// An `EngineResult<()>` indicating success or containing an error. - fn unset(&mut self, key_path: &str) -> EngineResult<()>; + /// An `EngineResult` indicating success or containing an error. + fn unset(&mut self, key_path: &str) -> EngineResult; /// Lists all configuration entries as key/value pairs. /// diff --git a/engine/src/config/config_loader.rs b/engine/src/config/config_loader.rs index 5b42217..6aa14b6 100644 --- a/engine/src/config/config_loader.rs +++ b/engine/src/config/config_loader.rs @@ -1,5 +1,10 @@ +use std::{io::Write, path::Path, str::FromStr}; + +use shared::fs::create_file_with_dirs; + use crate::{ - ConfigDocument, ConfigLocation, ConfigOperations, + ConfigLocation, + config::{ConfigDocument, ConfigDocumentOperations}, errors::{ConfigError, EngineError, EngineResult}, }; @@ -10,14 +15,16 @@ pub struct ConfigLoader { locations: Vec, } -impl ConfigLoader { +impl Default for ConfigLoader { /// Creates a loader with the default search order. - pub fn new_default() -> Self { + fn default() -> Self { Self { locations: vec![ConfigLocation::Local, ConfigLocation::Global], } } +} +impl ConfigLoader { /// Creates a loader with a custom list of locations. /// /// # Arguments @@ -39,24 +46,58 @@ impl ConfigLoader { /// /// The found `String` value or an error if not found or on I/O issues. pub fn get(&self, key_path: &str, default: Option<&String>) -> EngineResult { + let mut last_key_not_found = None; + for loc in &self.locations { match self.try_load_from(loc, key_path, default) { Ok(val) => { return Ok(val); } Err(err) => match err { - // If key not found, continue to next location - EngineError::Config(ConfigError::KeyNotFound { .. }) => {} + EngineError::Config(ConfigError::KeyNotFound { .. }) => { + last_key_not_found = Some(err); + } + EngineError::Config(ConfigError::ConfigNotFound { .. }) => { + continue; + } other => { return Err(other); } }, } } - Err(ConfigError::KeyNotFound { - key: key_path.to_string(), - } - .into()) + + Err(last_key_not_found.unwrap_or_else(|| { + EngineError::Config(ConfigError::KeyNotFound { + key: key_path.to_string(), + }) + })) + } + + /// Retrieve a configuration value and parse it into a specific type. + /// + /// # Arguments + /// + /// * `key` - Dot-separated key string to locate the configuration value. + /// * `default` - Default value returned if key is missing. + /// + /// # Returns + /// + /// Parsed value of type `T`, or an error if parsing fails. + pub fn get_parsed(&self, key: &str, default: T) -> EngineResult + where + T: FromStr + ToString, + ::Err: std::fmt::Display, + { + let default_string = default.to_string(); + + let raw_value = self.get(key, Some(&default_string))?; + + raw_value.parse::().map_err(|e| { + EngineError::Config(ConfigError::InvalidValueType { + type_name: format!("{}: {}", std::any::type_name::(), e), + }) + }) } /// Attempt to load a configuration value from a single location. @@ -72,13 +113,83 @@ impl ConfigLoader { /// The found value or an error wrapped in `EngineResult`. fn try_load_from( &self, - loc: &ConfigLocation, + location: &ConfigLocation, key_path: &str, default: Option<&String>, ) -> EngineResult { - let path = loc.get_default_path()?; - let doc = ConfigDocument::load(&path)?; + match location.get_default_path() { + Ok(path) => { + let doc = ConfigDocument::load(&path)?; + doc.get(key_path, default) + } + Err(err) => Err(err), + } + } + + /// Create a new local configuration file with default settings. + /// + /// # Arguments + /// + /// * `path` - Path where the local config file should be created. + pub fn create_local_config(&self, path: &Path) -> EngineResult<()> { + let (mut config_file, created) = create_file_with_dirs(path)?; + + if created { + config_file.write_all(self.get_default_local_config().as_bytes())?; + } + + Ok(()) + } + + /// Create a new global configuration file with default settings. + /// If the file already exists, it will not overwrite it. + pub fn create_global_config(&self) -> EngineResult<()> { + let path = ConfigLocation::Global.get_default_path()?; + let (mut config_file, created) = create_file_with_dirs(path)?; + + if created { + config_file.write_all(self.get_default_global_config().as_bytes())?; + } + + Ok(()) + } + + /// Returns the default content for a local configuration file. + pub fn get_default_local_config(&self) -> &str { + let default_config = concat!( + "# Meva Configuration File\n", + "# Edit this file to customize your local settings\n", + "\n", + "[plugins]\n", + "enabled = false\n", + "collect_logs = false\n", + "timeout_ms = 500\n", + "\n" + ); + + default_config + } + + /// Returns the default content for a global configuration file. + pub fn get_default_global_config(&self) -> &str { + let default_config = concat!( + "# Meva Configuration File\n", + "# Edit this file to customize your global settings\n", + "\n", + "# [user]\n", + "# name = \"Your Name\"\n", + "# email = \"your.email@example.com\"\n", + "\n", + "# [editor]\n", + "# default = \"vim\"\n", + "\n", + "[plugins]\n", + "enabled = false\n", + "collect_logs = false\n", + "timeout_ms = 500\n", + "\n" + ); - doc.get(key_path, default) + default_config } } diff --git a/engine/src/config/config_location.rs b/engine/src/config/config_location.rs index ea0a747..b2267a7 100644 --- a/engine/src/config/config_location.rs +++ b/engine/src/config/config_location.rs @@ -1,10 +1,11 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use shared::UpwardSearch; use crate::{ - MevaRepository, RepositoryLayout, + RepositoryLayout, errors::{ConfigError, EngineResult}, + repositories::meva_repository_layout::MevaRepositoryLayout, }; /// Defines where to load configuration from: global, local repository, or a specific file. @@ -21,16 +22,51 @@ pub enum ConfigLocation { } impl ConfigLocation { + /// Determines the `ConfigLocation` variant based on a given path. + /// + /// # Arguments + /// + /// * `path` - The path to check against known global and local config locations. + /// + /// # Returns + /// + /// Returns `Global` if the path matches the OS config directory, `Local` if it matches + /// a repository-specific config, or `File` for any other path. + pub fn from_path(path: &Path) -> EngineResult { + // TODO: MevaRepositoryLayout should not be directly used here + let layout = MevaRepositoryLayout::from_env()?; + + let path = path.canonicalize()?; + let expected_global = + Self::global_path(layout.repository_dir_name(), layout.config_file_name())?; + if path == expected_global { + return Ok(ConfigLocation::Global); + } + + if let Ok(local_candidate) = + Self::local_path(layout.repository_dir_name(), layout.config_file_name()) + && path == local_candidate + { + return Ok(ConfigLocation::Local); + } + + Ok(ConfigLocation::File(path.to_path_buf())) + } + /// Resolve the full path to the configuration file for this location variant. /// /// # Returns /// /// A `PathBuf` pointing to the config file, or an error if resolution fails. pub fn get_default_path(&self) -> EngineResult { + // TODO: MevaRepositoryLayout should not be directly used here + let layout = MevaRepositoryLayout::from_env()?; match self { - ConfigLocation::Global => Self::global_path(MevaRepository::CONFIG_FILE), + ConfigLocation::Global => { + Self::global_path(layout.repository_dir_name(), layout.config_file_name()) + } ConfigLocation::Local => { - Self::local_path(MevaRepository::REPOSITORY_DIR, MevaRepository::CONFIG_FILE) + Self::local_path(layout.repository_dir_name(), layout.config_file_name()) } ConfigLocation::File(path) => Ok(path.to_path_buf()), } @@ -40,15 +76,16 @@ impl ConfigLocation { /// /// # Arguments /// + /// * `repository_dir` – Marker directory name indicating the repo root. /// * `config_file` – The filename for the config. /// /// # Errors /// /// * `HomeDirNotFound` if the OS config directory is unavailable. - pub fn global_path(config_file: &str) -> EngineResult { + pub fn global_path(repository_dir: &str, config_file: &str) -> EngineResult { let base = dirs::config_dir().ok_or(ConfigError::HomeDirNotFound)?; // Prefix filename with a dot for hidden file convention - Ok(base.join(format!(".{config_file}"))) + Ok(base.join(repository_dir).join(format!(".{config_file}"))) } /// Locate the repository root by searching upward, then append the config file. diff --git a/engine/src/config/handler.rs b/engine/src/config/handler.rs new file mode 100644 index 0000000..3af5a48 --- /dev/null +++ b/engine/src/config/handler.rs @@ -0,0 +1,270 @@ +use plugins::{ + CommandType, InvocationPostPayload, InvocationPrePayload, MevaPluginsLayout, PluginError, + models::*, +}; + +use crate::{ + ConfigLocation, + config::{ConfigDocument, ConfigDocumentOperations, operations::*}, + errors::{EngineError, EngineResult}, + plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; + +pub struct ConfigHandler; + +impl ConfigHandler { + pub fn handle_get( + &self, + request: GetRequest, + interceptor: &PluginsInterceptor, + ) -> EngineResult { + interceptor.intercept_with_plugins(CommandType::ConfigGet, None, request, self, |req| { + self.get(req) + }) + } + + pub fn handle_set( + &self, + request: SetRequest, + interceptor: &PluginsInterceptor, + ) -> EngineResult { + interceptor.intercept_with_plugins(CommandType::ConfigSet, None, request, self, |req| { + self.set(req) + }) + } + + pub fn handle_unset( + &self, + request: UnsetRequest, + interceptor: &PluginsInterceptor, + ) -> EngineResult { + interceptor.intercept_with_plugins(CommandType::ConfigUnset, None, request, self, |req| { + self.unset(req) + }) + } + + pub fn handle_list( + &self, + request: ListRequest, + interceptor: &PluginsInterceptor, + ) -> EngineResult { + interceptor.intercept_with_plugins(CommandType::ConfigList, None, request, self, |req| { + self.list(req) + }) + } +} + +impl ConfigOperations for ConfigHandler { + fn get(&self, request: GetRequest) -> EngineResult { + let doc = ConfigDocument::load(request.location.get_default_path()?)?; + let value = doc.get(&request.key, request.default.as_ref())?; + + let response = GetResponse { + key: request.key, + value, + }; + + Ok(response) + } + + fn set(&self, request: SetRequest) -> EngineResult { + let mut doc = ConfigDocument::load(request.location.get_default_path()?)?; + + doc.set(&request.key, &request.value)?; + doc.save()?; + + let response = SetResponse { + key: request.key, + value: request.value, + }; + + Ok(response) + } + + fn unset(&self, request: UnsetRequest) -> EngineResult { + let mut doc = ConfigDocument::load(request.location.get_default_path()?)?; + + let value = doc.unset(&request.key)?; + doc.save()?; + + let response = UnsetResponse { value }; + + Ok(response) + } + + fn list(&self, request: ListRequest) -> EngineResult { + let doc = ConfigDocument::load(request.location.get_default_path()?)?; + let key_values = doc.list(); + + let response = ListResponse { key_values }; + + Ok(response) + } +} + +impl PluginsInvocationMapper for ConfigHandler { + fn request_to_payload(&self, req: &GetRequest) -> EngineResult { + Ok(InvocationPrePayload::ConfigGet(ConfigGetPrePayload { + config_file: req.location.get_default_path()?, + key: req.key.clone(), + default: req.default.clone(), + })) + } + + fn response_to_payload(&self, res: &GetResponse) -> EngineResult { + Ok(InvocationPostPayload::ConfigGet(ConfigGetPostPayload { + key: res.key.clone(), + value: res.value.clone(), + })) + } + + fn input_to_request(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPrePayload::ConfigGet(pre)) = &input.pre_payload { + Ok(GetRequest { + location: ConfigLocation::from_path(&pre.config_file)?, + key: pre.key.clone(), + default: pre.default.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PrePayload { + payload: input.pre_payload.clone(), + })) + } + } + + fn input_to_response(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPostPayload::ConfigGet(post)) = &input.post_payload { + Ok(GetResponse { + key: post.key.clone(), + value: post.value.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PostPayload { + payload: input.post_payload.clone(), + })) + } + } +} + +impl PluginsInvocationMapper for ConfigHandler { + fn request_to_payload(&self, req: &SetRequest) -> EngineResult { + Ok(InvocationPrePayload::ConfigSet(ConfigSetPrePayload { + config_file: req.location.get_default_path()?, + key: req.key.clone(), + value: req.value.clone(), + })) + } + + fn response_to_payload(&self, res: &SetResponse) -> EngineResult { + Ok(InvocationPostPayload::ConfigSet(ConfigSetPostPayload { + key: res.key.clone(), + value: res.value.clone(), + })) + } + + fn input_to_request(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPrePayload::ConfigSet(pre)) = &input.pre_payload { + Ok(SetRequest { + location: ConfigLocation::from_path(&pre.config_file)?, + key: pre.key.clone(), + value: pre.value.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PrePayload { + payload: input.pre_payload.clone(), + })) + } + } + + fn input_to_response(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPostPayload::ConfigGet(post)) = &input.post_payload { + Ok(SetResponse { + key: post.key.clone(), + value: post.value.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PrePayload { + payload: input.pre_payload.clone(), + })) + } + } +} + +impl PluginsInvocationMapper for ConfigHandler { + fn request_to_payload(&self, req: &UnsetRequest) -> EngineResult { + Ok(InvocationPrePayload::ConfigUnset(ConfigUnsetPrePayload { + config_file: req.location.get_default_path()?, + key: req.key.clone(), + })) + } + + fn response_to_payload(&self, res: &UnsetResponse) -> EngineResult { + Ok(InvocationPostPayload::ConfigUnset(ConfigUnsetPostPayload { + value: res.value.clone(), + })) + } + + fn input_to_request(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPrePayload::ConfigUnset(pre)) = &input.pre_payload { + Ok(UnsetRequest { + location: ConfigLocation::from_path(&pre.config_file)?, + key: pre.key.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PrePayload { + payload: input.pre_payload.clone(), + })) + } + } + + fn input_to_response(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPostPayload::ConfigUnset(post)) = &input.post_payload { + Ok(UnsetResponse { + value: post.value.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PostPayload { + payload: input.post_payload.clone(), + })) + } + } +} + +impl PluginsInvocationMapper for ConfigHandler { + fn request_to_payload(&self, req: &ListRequest) -> EngineResult { + Ok(InvocationPrePayload::ConfigList(ConfigListPrePayload { + config_file: req.location.get_default_path()?, + })) + } + + fn response_to_payload(&self, res: &ListResponse) -> EngineResult { + Ok(InvocationPostPayload::ConfigList(ConfigListPostPayload { + key_values: res.key_values.clone(), + })) + } + + fn input_to_request(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPrePayload::ConfigList(pre)) = &input.pre_payload { + Ok(ListRequest { + location: ConfigLocation::from_path(&pre.config_file)?, + }) + } else { + Err(EngineError::Plugins(PluginError::PrePayload { + payload: input.pre_payload.clone(), + })) + } + } + + fn input_to_response(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPostPayload::ConfigList(post)) = &input.post_payload { + Ok(ListResponse { + key_values: post.key_values.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PostPayload { + payload: input.post_payload.clone(), + })) + } + } +} diff --git a/engine/src/config/operations.rs b/engine/src/config/operations.rs new file mode 100644 index 0000000..3d97b2a --- /dev/null +++ b/engine/src/config/operations.rs @@ -0,0 +1,50 @@ +use crate::{ConfigLocation, errors::EngineResult}; + +pub struct GetRequest { + pub location: ConfigLocation, + pub key: String, + pub default: Option, +} + +pub struct GetResponse { + pub key: String, + pub value: String, +} + +pub struct SetRequest { + pub location: ConfigLocation, + pub key: String, + pub value: String, +} + +pub struct SetResponse { + pub key: String, + pub value: String, +} + +pub struct UnsetRequest { + pub location: ConfigLocation, + pub key: String, +} + +pub struct UnsetResponse { + pub value: String, +} + +pub struct ListRequest { + pub location: ConfigLocation, +} + +pub struct ListResponse { + pub key_values: Vec<(String, String)>, +} + +pub trait ConfigOperations { + fn get(&self, request: GetRequest) -> EngineResult; + + fn set(&self, request: SetRequest) -> EngineResult; + + fn unset(&self, request: UnsetRequest) -> EngineResult; + + fn list(&self, request: ListRequest) -> EngineResult; +} diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs new file mode 100644 index 0000000..46100c4 --- /dev/null +++ b/engine/src/engine_container.rs @@ -0,0 +1,75 @@ +use plugins::{MevaPluginsLayout, PluginsDiscovery, PluginsEngine}; + +use crate::{ + InitHandler, config::ConfigHandler, errors::EngineResult, plugins::PluginsHandler, + plugins_interceptor::PluginsInterceptor, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; + +/// Dependency injection container for the engine. +pub trait EngineContainer { + /// Creates a plugin interceptor, which executes plugins + /// before and after engine commands. + fn plugins_interceptor( + &self, + ) -> EngineResult>; + + /// Returns the handler responsible for repository initialization. + fn init_handler(&self) -> EngineResult; + + /// Returns the handler responsible for configuration management. + fn config_handler(&self) -> EngineResult; + + /// Returns the handler responsible for plugin operations. + fn plugins_handler(&self) -> EngineResult; +} + +/// Concrete implementation of `EngineContainer` for Meva. +pub struct MevaContainer {} + +impl MevaContainer { + /// Returns the Meva-specific plugin layout. + fn plugins_layout(&self) -> EngineResult { + Ok(MevaPluginsLayout) + } + + /// Returns the Meva-specific repository layout. + fn repository_layout(&self) -> EngineResult { + MevaRepositoryLayout::from_env() + } + + /// Builds a [`PluginsEngine`] instance used by Meva. + fn plugins_engine(&self) -> EngineResult> { + Ok(PluginsEngine { + discovery: PluginsDiscovery { + layout: self.plugins_layout()?, + }, + }) + } +} + +impl EngineContainer for MevaContainer { + fn plugins_interceptor( + &self, + ) -> EngineResult> { + Ok(PluginsInterceptor::new( + self.plugins_engine()?, + self.repository_layout()?, + )) + } + + fn init_handler(&self) -> EngineResult { + Ok(InitHandler) + } + + fn config_handler(&self) -> EngineResult { + Ok(ConfigHandler) + } + + fn plugins_handler(&self) -> EngineResult { + Ok(PluginsHandler { + plugins_repository: Box::new(self.plugins_engine()?), + repository_layout: Box::new(self.repository_layout()?), + }) + } +} diff --git a/engine/src/errors.rs b/engine/src/errors.rs index 9da11ca..a085c79 100644 --- a/engine/src/errors.rs +++ b/engine/src/errors.rs @@ -2,10 +2,12 @@ pub mod config_error; pub mod engine_error; pub mod ignore_error; pub mod init_error; +pub mod repository_error; pub use config_error::ConfigError; pub use ignore_error::IgnoreError; pub use init_error::InitError; +pub use repository_error::RepositoryError; pub use engine_error::EngineError; pub use engine_error::Result as EngineResult; diff --git a/engine/src/errors/config_error.rs b/engine/src/errors/config_error.rs index fb7ca3f..f873fbf 100644 --- a/engine/src/errors/config_error.rs +++ b/engine/src/errors/config_error.rs @@ -45,7 +45,7 @@ pub enum ConfigError { InvalidConfigFile { /// Path provided by the user or inferred by the system. path: String, - /// Reason why the file is invalid (e.g. "not a file", "missing"). + /// Reason why the file is invalid (e.g., "not a file", "missing"). reason: String, }, diff --git a/engine/src/errors/engine_error.rs b/engine/src/errors/engine_error.rs index 46d93a0..6eb97dd 100644 --- a/engine/src/errors/engine_error.rs +++ b/engine/src/errors/engine_error.rs @@ -1,8 +1,9 @@ use std::io; +use plugins::PluginError; use thiserror::Error; -use crate::errors::{ConfigError, IgnoreError, InitError}; +use crate::errors::{ConfigError, IgnoreError, InitError, RepositoryError}; /// A convenient result type alias for engine-related operations. pub type Result = std::result::Result; @@ -30,9 +31,17 @@ pub enum EngineError { #[error(transparent)] Ignore(#[from] IgnoreError), + /// Errors encountered when interacting with the repository layout or structure. + #[error(transparent)] + Repository(#[from] RepositoryError), + + /// Errors originating from plugin execution or management. + #[error(transparent)] + Plugins(#[from] PluginError), + /// A catch-all variant for any unknown or unexpected engine error. /// Accepts a descriptive string message. - #[error("Unknown Engine error: {0}")] + #[error("Unknown engine error: {0}")] Unknown( /// Human-readable message describing the unexpected condition. String, diff --git a/engine/src/errors/repository_error.rs b/engine/src/errors/repository_error.rs new file mode 100644 index 0000000..faee1d0 --- /dev/null +++ b/engine/src/errors/repository_error.rs @@ -0,0 +1,16 @@ +use std::path::PathBuf; + +use thiserror::Error; + +/// Represents common errors that can occur during Meva repository operations. +#[derive(Error, Debug)] +pub enum RepositoryError { + /// Raised when the repository root cannot be found by searching upwards + /// from the given path. + #[error("Repository not found above directory: `{path}`")] + RepositoryNotFound { path: PathBuf }, + + /// Raised when the user's configuration directory cannot be determined. + #[error("User's config directory not found")] + ConfigDirNotFound, +} diff --git a/engine/src/init.rs b/engine/src/init.rs new file mode 100644 index 0000000..15f98ea --- /dev/null +++ b/engine/src/init.rs @@ -0,0 +1,5 @@ +mod handler; +mod operations; + +pub use handler::InitHandler; +pub use operations::*; diff --git a/engine/src/init/handler.rs b/engine/src/init/handler.rs new file mode 100644 index 0000000..2470b2c --- /dev/null +++ b/engine/src/init/handler.rs @@ -0,0 +1,83 @@ +use plugins::{ + CommandType, InitPostPayload, InitPrePayload, InvocationInput, InvocationPostPayload, + InvocationPrePayload, MevaPluginsLayout, PluginError, +}; + +use crate::{ + ConfigLoader, MevaRepository, + errors::{EngineError, EngineResult}, + init::operations::{InitOperations, Request, Response}, + plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; + +pub struct InitHandler; + +impl InitHandler { + pub fn handle_init( + &self, + request: Request, + interceptor: &PluginsInterceptor, + ) -> EngineResult { + interceptor.intercept_with_plugins( + CommandType::Init, + Some(request.working_dir.clone()), + request, + self, + |req| self.init(req), + ) + } +} + +impl InitOperations for InitHandler { + fn init(&self, request: Request) -> EngineResult { + let layout = Box::new(MevaRepositoryLayout::new(request.working_dir)?); + let config_loader = ConfigLoader::default(); + let repository = MevaRepository::new(layout, config_loader); + + let repository_dir = repository.init(&request.initial_branch)?; + + let response = Response { repository_dir }; + + Ok(response) + } +} + +impl PluginsInvocationMapper for InitHandler { + fn request_to_payload(&self, req: &Request) -> EngineResult { + Ok(InvocationPrePayload::Init(InitPrePayload { + initial_branch: req.initial_branch.clone(), + })) + } + + fn response_to_payload(&self, res: &Response) -> EngineResult { + Ok(InvocationPostPayload::Init(InitPostPayload { + repository_dir: res.repository_dir.clone(), + })) + } + + fn input_to_request(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPrePayload::Init(pre)) = &input.pre_payload { + Ok(Request { + working_dir: input.context.working_dir.clone(), + initial_branch: pre.initial_branch.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PrePayload { + payload: input.pre_payload.clone(), + })) + } + } + + fn input_to_response(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPostPayload::Init(post)) = &input.post_payload { + Ok(Response { + repository_dir: post.repository_dir.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PostPayload { + payload: input.post_payload.clone(), + })) + } + } +} diff --git a/engine/src/init/operations.rs b/engine/src/init/operations.rs new file mode 100644 index 0000000..3f8bafe --- /dev/null +++ b/engine/src/init/operations.rs @@ -0,0 +1,16 @@ +use std::path::PathBuf; + +use crate::errors::EngineResult; + +pub struct Request { + pub working_dir: PathBuf, + pub initial_branch: String, +} + +pub struct Response { + pub repository_dir: PathBuf, +} + +pub trait InitOperations { + fn init(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/lib.rs b/engine/src/lib.rs index fab8f7b..000e266 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,10 +1,16 @@ pub mod config; +pub mod engine_container; pub mod errors; pub mod ignore; +pub mod init; +pub mod plugins; +pub mod plugins_interceptor; pub mod repositories; use errors::{EngineError, EngineResult, InitError}; -pub use config::{ConfigDocument, ConfigLoader, ConfigLocation, ConfigOperations}; +pub use config::{ConfigDocument, ConfigHandler, ConfigLoader, ConfigLocation}; +pub use engine_container::EngineContainer; pub use ignore::{IgnoreOperations, IgnoreResult, IgnoreService}; +pub use init::InitHandler; pub use repositories::{MevaRepository, RepositoryLayout}; diff --git a/engine/src/plugins.rs b/engine/src/plugins.rs new file mode 100644 index 0000000..cf62a98 --- /dev/null +++ b/engine/src/plugins.rs @@ -0,0 +1,5 @@ +mod handler; +mod operations; + +pub use handler::PluginsHandler; +pub use operations::*; diff --git a/engine/src/plugins/handler.rs b/engine/src/plugins/handler.rs new file mode 100644 index 0000000..a745be9 --- /dev/null +++ b/engine/src/plugins/handler.rs @@ -0,0 +1,146 @@ +use std::{env, path::PathBuf}; + +use plugins::{PluginConfiguration, PluginsRepository, ScopeType}; +use shared::UpwardSearch; + +use crate::{ + RepositoryLayout, + errors::{EngineResult, RepositoryError}, + plugins::{ + EditRequest, EditResponse, InfoRequest, InfoResponse, ListRequest, ListResponse, + PluginsOperations, RegisterRequest, RegisterResponse, UnregisterRequest, + UnregisterResponse, + }, +}; + +pub struct PluginsHandler { + pub plugins_repository: Box, + pub repository_layout: Box, +} + +impl PluginsHandler { + pub fn new( + plugins_repository: Box, + repository_layout: Box, + ) -> Self { + Self { + plugins_repository, + repository_layout, + } + } + + fn get_plugins_dir(&self, scope: &ScopeType) -> EngineResult { + match scope { + ScopeType::Local => { + let current_dir = env::current_dir()?; + let repository_dir_rel = self.repository_layout.repository_dir_name(); + let repository_dir = current_dir + .search_dir_up(repository_dir_rel) + .ok_or(RepositoryError::RepositoryNotFound { path: current_dir })?; + Ok(repository_dir.join(self.repository_layout.plugins_dir_name())) + } + ScopeType::Global => { + let config_dir = dirs::config_dir().ok_or(RepositoryError::ConfigDirNotFound)?; + Ok(config_dir.join(self.repository_layout.plugins_dir_rel())) + } + } + } +} + +impl PluginsOperations for PluginsHandler { + fn register(&self, request: RegisterRequest) -> EngineResult { + let plugins_dir = self.get_plugins_dir(&request.scope)?; + + let plugin_configuration = PluginConfiguration::new( + request.name, + request.description, + request.file, + request.event, + request.order, + request.enabled, + request.interpreter, + ); + + let (plugins_metadata_file, plugin_source_file) = self.plugins_repository.register( + plugin_configuration, + &request.command, + &plugins_dir, + &request.path, + )?; + + let response = RegisterResponse { + plugin_source_file, + plugins_metadata_file, + }; + + Ok(response) + } + + fn unregister(&self, request: UnregisterRequest) -> EngineResult { + let plugins_dir = self.get_plugins_dir(&request.scope)?; + + let (plugins_metadata_file, plugin_source_file) = + self.plugins_repository + .unregister(&request.command, &request.name, &plugins_dir)?; + + let response = UnregisterResponse { + plugin_source_file, + plugins_metadata_file, + }; + + Ok(response) + } + + fn list(&self, request: ListRequest) -> EngineResult { + let plugins_dir = self.get_plugins_dir(&request.scope)?; + let plugin_entries = self.plugins_repository.list( + &request.command, + &request.event, + &plugins_dir, + request.include_enabled, + request.include_disabled, + )?; + + let response = ListResponse { + plugins: plugin_entries + .into_iter() + .map(|entry| (entry.plugin, entry.source_file)) + .collect(), + }; + + Ok(response) + } + + fn info(&self, request: InfoRequest) -> EngineResult { + let plugins_dir = self.get_plugins_dir(&request.scope)?; + + let plugin_entry = + self.plugins_repository + .info(&request.command, &request.name, &plugins_dir)?; + + let response = InfoResponse { + configuration: plugin_entry.plugin, + source_file: plugin_entry.source_file, + }; + + Ok(response) + } + + fn edit(&self, request: EditRequest) -> EngineResult { + let plugins_dir = self.get_plugins_dir(&request.scope)?; + + let plugin_entry = self.plugins_repository.update_enabled( + &request.command, + &request.name, + &plugins_dir, + request.enabled, + )?; + + let response = EditResponse { + configuration: plugin_entry.plugin, + source_file: plugin_entry.source_file, + }; + + Ok(response) + } +} diff --git a/engine/src/plugins/operations.rs b/engine/src/plugins/operations.rs new file mode 100644 index 0000000..61c99ad --- /dev/null +++ b/engine/src/plugins/operations.rs @@ -0,0 +1,134 @@ +use std::{ + fmt::{Display, Formatter, Result}, + path::PathBuf, +}; + +use plugins::{CommandType, EventType, PluginConfiguration, ScopeType}; + +use crate::errors::EngineResult; + +pub struct RegisterRequest { + pub path: PathBuf, + pub scope: ScopeType, + pub name: String, + pub description: Option, + pub file: PathBuf, + pub command: CommandType, + pub event: EventType, + pub order: u32, + pub enabled: bool, + pub interpreter: Option, +} + +pub struct RegisterResponse { + pub plugins_metadata_file: PathBuf, + pub plugin_source_file: PathBuf, +} + +pub struct UnregisterRequest { + pub name: String, + pub command: CommandType, + pub scope: ScopeType, +} + +pub struct UnregisterResponse { + pub plugins_metadata_file: PathBuf, + pub plugin_source_file: PathBuf, +} + +pub struct ListRequest { + pub command: CommandType, + pub event: EventType, + pub scope: ScopeType, + pub include_enabled: bool, + pub include_disabled: bool, +} + +pub struct ListResponse { + pub plugins: Vec<(PluginConfiguration, PathBuf)>, +} + +impl Display for ListResponse { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + if self.plugins.is_empty() { + writeln!(f, "No plugins found.")?; + return Ok(()); + } + + for (i, (config, path)) in self.plugins.iter().enumerate() { + writeln!(f, "[{}] Source file: {}", i + 1, path.display())?; + writeln!(f, "Configuration:")?; + + let conf_str = format!("{config}"); + for line in conf_str.lines() { + writeln!(f, " {line}")?; + } + + if i + 1 < self.plugins.len() { + writeln!(f)?; + } + } + Ok(()) + } +} + +pub struct InfoRequest { + pub name: String, + pub command: CommandType, + pub scope: ScopeType, +} + +pub struct InfoResponse { + pub configuration: PluginConfiguration, + pub source_file: PathBuf, +} + +impl Display for InfoResponse { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + writeln!(f, "Source file: {}", self.source_file.display())?; + writeln!(f, "Configuration:")?; + + let conf_str = format!("{}", self.configuration); + for line in conf_str.lines() { + writeln!(f, " {line}")?; + } + Ok(()) + } +} + +pub struct EditRequest { + pub name: String, + pub command: CommandType, + pub scope: ScopeType, + pub enabled: Option, +} + +pub struct EditResponse { + pub configuration: PluginConfiguration, + pub source_file: PathBuf, +} + +impl Display for EditResponse { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + writeln!(f, "Source file: {}", self.source_file.display())?; + writeln!(f, "Configuration:")?; + + let conf_str = format!("{}", self.configuration); + for line in conf_str.lines() { + writeln!(f, " {line}")?; + } + Ok(()) + } +} + +pub trait PluginsOperations { + fn register(&self, request: RegisterRequest) -> EngineResult; + + fn unregister(&self, request: UnregisterRequest) -> EngineResult; + + fn list(&self, request: ListRequest) -> EngineResult; + + fn info(&self, request: InfoRequest) -> EngineResult; + + fn edit(&self, request: EditRequest) -> EngineResult; +} diff --git a/engine/src/plugins_interceptor.rs b/engine/src/plugins_interceptor.rs new file mode 100644 index 0000000..123c7c1 --- /dev/null +++ b/engine/src/plugins_interceptor.rs @@ -0,0 +1,240 @@ +use std::{env, fs, path::PathBuf}; + +use chrono::Utc; +use plugins::{ + CommandType, EventType, InvocationContext, InvocationInput, InvocationOutput, + InvocationPostPayload, InvocationPrePayload, PluginError, PluginsEngine, PluginsLayout, + PluginsRunner, ScopeType, +}; +use shared::UpwardSearch; + +use crate::{ + ConfigLoader, RepositoryLayout, + errors::{EngineError, EngineResult, RepositoryError}, +}; + +/// Defines how requests and responses are mapped to plugin payloads and back. +/// +/// Implementations of this trait allow converting between application-specific +/// request/response types and the generic `InvocationInput`/payloads used by plugins. +pub trait PluginsInvocationMapper { + /// Converts an incoming request into a pre-execution payload for plugins. + fn request_to_payload(&self, req: &Req) -> EngineResult; + + /// Converts a response from the core execution into a post-execution payload for plugins. + fn response_to_payload(&self, res: &Res) -> EngineResult; + + /// Converts plugin input back into a request for the main engine. + fn input_to_request(&self, input: &InvocationInput) -> EngineResult; + + /// Converts plugin input back into a response for the main engine. + fn input_to_response(&self, input: &InvocationInput) -> EngineResult; +} + +/// Intercepts engine commands and routes them through plugins before and after execution. +/// +/// The interceptor is responsible for: +/// - Locating the appropriate plugin directories (local/global), +/// - Creating invocation contexts, +/// - Running plugins in order for `PreExecute` and `PostExecute` events, +/// - Handling errors, timeouts, and logging. +pub struct PluginsInterceptor +where + T: PluginsLayout, + E: RepositoryLayout, +{ + /// The engine responsible for managing plugin lifecycle and execution. + pub plugins_engine: PluginsEngine, + + /// Layout abstraction to locate repository-specific paths. + pub repository_layout: E, + + /// Configuration loader used for plugin-related settings. + pub config_loader: ConfigLoader, +} + +impl PluginsInterceptor { + /// Creates a new `PluginsInterceptor` with the provided plugin engine and repository layout. + pub fn new(plugins_engine: PluginsEngine, repository_layout: E) -> Self { + Self { + plugins_engine, + repository_layout, + config_loader: ConfigLoader::default(), + } + } + + // Runs the given request through all configured plugins for the given command. + pub fn intercept_with_plugins( + &self, + command: CommandType, + working_dir: Option, + request: Req, + mapper: &M, + exec: F, + ) -> EngineResult + where + F: FnOnce(Req) -> EngineResult, + M: PluginsInvocationMapper, + { + if !self.get_enabled()? { + return exec(request); + } + + let is_init = command == CommandType::Init; + + let local_plugins_dir = if is_init { + None + } else { + Some(self.get_plugins_dir(&ScopeType::Local)?) + }; + + let global_plugins_dir = self.get_plugins_dir(&ScopeType::Global)?; + + let (local_plugins, global_plugins) = self.plugins_engine.get_plugins_for_command( + &local_plugins_dir, + &global_plugins_dir, + &command, + )?; + + let timestamp = Utc::now(); + + let plugins_dir = if is_init { + global_plugins_dir + } else { + local_plugins_dir.unwrap() + }; + + let invocation_dir = + self.plugins_engine + .create_invocation_dir(×tamp, &plugins_dir, &command)?; + + let logger = self + .plugins_engine + .create_logger(&invocation_dir, self.get_collect_logs()?)?; + + let context = InvocationContext::new( + command.clone(), + EventType::PreExecute, + Some(timestamp), + working_dir, + )?; + + let pre_payload = mapper.request_to_payload(&request)?; + + let invocation_input = InvocationInput { + context, + pre_payload: Some(pre_payload), + post_payload: None, + error: None, + }; + + let invocation_file = self + .plugins_engine + .create_invocation_file(&invocation_input, &invocation_dir)?; + + let timeout = self.get_timeout_ms()?; + + let mut runner = PluginsRunner::new( + invocation_file.clone(), + plugins_dir.join(command.to_string()).clone(), + logger, + timeout, + ); + + let pre_outputs = runner.run_plugins_for_event( + &local_plugins, + &global_plugins, + &EventType::PreExecute, + )?; + + let json = fs::read_to_string(&invocation_file)?; + let mut invocation_input = InvocationInput::from_json(&json)?; + + self.handle_last_plugin_result(pre_outputs.last(), &invocation_input)?; + + let response = exec(mapper.input_to_request(&invocation_input)?)?; + + invocation_input.context.event = EventType::PostExecute; + invocation_input.post_payload = Some(mapper.response_to_payload(&response)?); + + let invocation_file = self + .plugins_engine + .create_invocation_file(&invocation_input, &invocation_dir)?; + + runner.invocation_file = invocation_file.clone(); + + let post_outputs = runner.run_plugins_for_event( + &local_plugins, + &global_plugins, + &EventType::PostExecute, + )?; + + self.handle_last_plugin_result(post_outputs.last(), &invocation_input)?; + + let json = fs::read_to_string(&invocation_file)?; + let invocation_input = InvocationInput::from_json(&json)?; + + mapper.input_to_response(&invocation_input) + } + + /// Resolves the plugin directory path for the given scope. + fn get_plugins_dir(&self, scope: &ScopeType) -> EngineResult { + match scope { + ScopeType::Local => { + let current_dir = env::current_dir()?; + let repository_dir_rel = self.repository_layout.repository_dir_name(); + let repository_dir = current_dir + .search_dir_up(repository_dir_rel) + .ok_or(RepositoryError::RepositoryNotFound { path: current_dir })?; + Ok(repository_dir.join(self.repository_layout.plugins_dir_name())) + } + ScopeType::Global => { + let config_dir = dirs::config_dir().ok_or(RepositoryError::ConfigDirNotFound)?; + Ok(config_dir.join(self.repository_layout.plugins_dir_rel())) + } + } + } + + // Reads from configuration whether plugins are globally enabled. + pub fn get_enabled(&self) -> EngineResult { + self.config_loader.get_parsed("plugins.enabled", false) + } + + /// Reads the maximum allowed execution time for plugins (in milliseconds). + fn get_timeout_ms(&self) -> EngineResult { + self.config_loader.get_parsed("plugins.timeout_ms", 500u64) + } + + /// Reads from configuration whether plugin logs should be collected. + fn get_collect_logs(&self) -> EngineResult { + self.config_loader.get_parsed("plugins.collect_logs", false) + } + + /// Handles the result of the last executed plugin in a chain, + /// returning an appropriate error if it failed or timed out. + fn handle_last_plugin_result( + &self, + last: Option<&InvocationOutput>, + invocation_input: &InvocationInput, + ) -> EngineResult<()> { + if let Some(last) = last + && last.is_error() + { + if last.is_timed_out() { + return Err(EngineError::Plugins(PluginError::Timeout { + plugin: last.plugin.clone(), + duration_ms: last.duration_ms, + })); + } + + let unknown_msg = match &invocation_input.error { + Some(err) => serde_json::to_string(err).unwrap_or_else(|_| format!("{err:?}")), + None => format!("plugin exited with code {}", last.status_code), + }; + + return Err(EngineError::Plugins(PluginError::Unknown(unknown_msg))); + } + + Ok(()) + } +} diff --git a/engine/src/repositories.rs b/engine/src/repositories.rs index 7cc2b1f..153583e 100644 --- a/engine/src/repositories.rs +++ b/engine/src/repositories.rs @@ -1,4 +1,5 @@ pub mod meva_repository; +pub mod meva_repository_layout; pub mod repository_layout; pub use meva_repository::MevaRepository; diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index 574fb21..806f3f9 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -1,13 +1,10 @@ -use std::{ - fs, - io::Write, - path::{Path, PathBuf}, -}; +use std::path::PathBuf; +use std::{fs, io::Write, path::Path}; use tempfile::TempDir; use crate::repositories::RepositoryLayout; -use crate::{EngineError, EngineResult, InitError}; +use crate::{ConfigLoader, EngineError, EngineResult, InitError}; use shared::fs::create_file_with_dirs; /// Represents a Meva repository on disk. @@ -15,14 +12,16 @@ use shared::fs::create_file_with_dirs; /// Provides functionality to initialize repository layout and manage /// directory structure and configuration files. pub struct MevaRepository { - working_dir: PathBuf, + pub layout: Box, + pub config_loader: ConfigLoader, } impl MevaRepository { /// Creates a new `MevaRepository` for the given working directory. - pub fn new>(working_dir: P) -> Self { + pub fn new(layout: Box, config_loader: ConfigLoader) -> Self { Self { - working_dir: working_dir.into(), + layout, + config_loader, } } @@ -31,15 +30,10 @@ impl MevaRepository { /// Creates required directories and files under `.meva/`. /// Returns an error if the repository is already initialized /// or the working directory is invalid. - pub fn init(&self, initial_branch: &str) -> EngineResult<()> { - if !self.working_dir.exists() || !self.working_dir.is_dir() { - return Err(InitError::InvalidWorkingDir { - path: self.working_dir.to_string_lossy().into(), - } - .into()); - } + pub fn init(&self, initial_branch: &str) -> EngineResult { + self.config_loader.create_global_config()?; - let repository_dir = self.repository_dir(); + let repository_dir = self.layout.repository_dir(); if repository_dir.exists() { return Err(InitError::AlreadyInitialized { @@ -48,28 +42,28 @@ impl MevaRepository { .into()); } - let tmp_parent = &self.working_dir; + let tmp_parent = &self.layout.working_dir(); let tmp_dir = TempDir::new_in(tmp_parent).map_err(EngineError::Io)?; - let tmp_repo = tmp_dir.path().join(Self::REPOSITORY_DIR); + let tmp_repo = tmp_dir.path().join(self.layout.repository_dir_name()); self.create_dirs_at(&tmp_dir)?; self.create_files_at(&tmp_dir, initial_branch)?; - let final_repo = self.repository_dir(); + let final_repo = self.layout.repository_dir(); fs::rename(&tmp_repo, &final_repo).map_err(EngineError::Io)?; - Ok(()) + Ok(final_repo) } /// Creates the internal repository directories. fn create_dirs_at>(&self, root: P) -> EngineResult<()> { - fs::create_dir_all(root.as_ref().join(self.objects_dir_rel()))?; - fs::create_dir_all(root.as_ref().join(self.refs_dir_rel()))?; - fs::create_dir_all(root.as_ref().join(self.heads_refs_dir_rel()))?; - fs::create_dir_all(root.as_ref().join(self.logs_dir_rel()))?; - fs::create_dir_all(root.as_ref().join(self.heads_logs_dir_rel()))?; + fs::create_dir_all(root.as_ref().join(self.layout.objects_dir_rel()))?; + fs::create_dir_all(root.as_ref().join(self.layout.refs_dir_rel()))?; + fs::create_dir_all(root.as_ref().join(self.layout.heads_refs_dir_rel()))?; + fs::create_dir_all(root.as_ref().join(self.layout.logs_dir_rel()))?; + fs::create_dir_all(root.as_ref().join(self.layout.heads_logs_dir_rel()))?; Ok(()) } @@ -77,82 +71,46 @@ impl MevaRepository { fn create_files_at>(&self, root: P, initial_branch: &str) -> EngineResult<()> { let ref_path = root .as_ref() - .join(self.heads_refs_dir_rel()) + .join(self.layout.heads_refs_dir_rel()) .join(initial_branch); let log_path = root .as_ref() - .join(self.heads_logs_dir_rel()) + .join(self.layout.heads_logs_dir_rel()) .join(initial_branch); create_file_with_dirs(ref_path)?; create_file_with_dirs(log_path)?; - let config_path = root.as_ref().join(self.config_file_rel()); - let mut config_file = fs::File::create(&config_path)?; - config_file.write_all(Self::DEFAULT_CONFIG.as_bytes())?; + let config_path = root.as_ref().join(self.layout.config_file_rel()); + self.config_loader.create_local_config(&config_path)?; - let head_path = root.as_ref().join(self.head_file_rel()); + let head_path = root.as_ref().join(self.layout.head_file_rel()); let mut head_file = fs::File::create(&head_path)?; write!( head_file, "ref: {}/{}/{}", - Self::REFS_DIR, - Self::HEADS_DIR, + self.layout.refs_dir_name(), + self.layout.heads_dir_name(), initial_branch )?; - let head_log_path = root.as_ref().join(self.head_logs_file_rel()); + let head_log_path = root.as_ref().join(self.layout.head_logs_file_rel()); fs::File::create(head_log_path)?; Ok(()) } - - const DEFAULT_CONFIG: &'static str = concat!( - "# Meva Configuration File\n", - "# Edit this file to customize your settings\n", - "\n", - "# [user]\n", - "# name = \"Your Name\"\n", - "# email = \"your.email@example.com\"\n", - "\n", - "# [editor]\n", - "# default = \"vim\"\n", - "\n", - ); -} - -impl RepositoryLayout for MevaRepository { - const REPOSITORY_DIR: &'static str = ".meva"; - - const OBJECTS_DIR: &'static str = "objects"; - - const REFS_DIR: &'static str = "refs"; - - const LOGS_DIR: &'static str = "logs"; - - const HEADS_DIR: &'static str = "heads"; - - const HEAD_FILE: &'static str = "HEAD"; - - const CONFIG_FILE: &'static str = "mevaconfig"; - - const IGNORE_FILE: &'static str = ".mevaignore"; - - /// Returns the repository’s working directory path. - fn working_dir(&self) -> &std::path::Path { - &self.working_dir - } } #[cfg(test)] mod tests { - use crate::EngineError; + use crate::repositories::meva_repository_layout::MevaRepositoryLayout; use super::*; use pretty_assertions::assert_eq; use rstest::rstest; - use std::fs::{self, File}; + use std::fs; use std::io::Read; + use std::path::PathBuf; use tempfile::TempDir; fn read_file(path: &PathBuf) -> String { @@ -163,10 +121,18 @@ mod tests { content } + fn get_repo(path: &Path) -> MevaRepository { + let layout = Box::new(MevaRepositoryLayout { + working_dir: path.to_path_buf(), + }); + + MevaRepository::new(layout, ConfigLoader::default()) + } + #[rstest] fn init_creates_expected_structure() { - let tmp = TempDir::new().unwrap(); - let repo = MevaRepository::new(tmp.path()); + let tmp = TempDir::new().expect("failed to create TempDir"); + let repo = get_repo(tmp.path()); let result = repo.init("main"); assert!(result.is_ok()); @@ -174,37 +140,40 @@ mod tests { let repo_dir = tmp.path().join(".meva"); assert!(repo_dir.exists()); - assert!(repo.objects_dir().exists()); - assert!(repo.refs_dir().exists()); - assert!(repo.heads_refs_dir().exists()); - assert!(repo.logs_dir().exists()); - assert!(repo.heads_logs_dir().exists()); + assert!(repo.layout.objects_dir().exists()); + assert!(repo.layout.refs_dir().exists()); + assert!(repo.layout.heads_refs_dir().exists()); + assert!(repo.layout.logs_dir().exists()); + assert!(repo.layout.heads_logs_dir().exists()); - let head_path = repo.head_file(); + let head_path = repo.layout.head_file(); assert!(head_path.exists()); let head_contents = read_file(&head_path); assert_eq!(head_contents, "ref: refs/heads/main"); - assert!(repo.head_logs_file().exists()); + assert!(repo.layout.head_logs_file().exists()); - let branch_ref = repo.heads_refs_dir().join("main"); + let branch_ref = repo.layout.heads_refs_dir().join("main"); assert!(branch_ref.exists()); - let branch_log = repo.heads_logs_dir().join("main"); + let branch_log = repo.layout.heads_logs_dir().join("main"); assert!(branch_log.exists()); - let config_path = repo.config_file(); + let config_path = repo.layout.config_file(); assert!(config_path.exists()); let config_content = read_file(&config_path); - assert_eq!(config_content, MevaRepository::DEFAULT_CONFIG); + assert_eq!( + config_content, + repo.config_loader.get_default_local_config() + ); } #[rstest] fn init_fails_if_repo_already_exists() { let tmp = TempDir::new().expect("failed to create TempDir"); - let repo = MevaRepository::new(tmp.path()); + let repo = get_repo(tmp.path()); // First init succeeds assert!(repo.init("dev").is_ok()); @@ -221,36 +190,4 @@ mod tests { ); } } - - #[rstest] - fn init_fails_if_nonexistent_working_dir() { - let tmp = TempDir::new().expect("failed to create TempDir"); - let bad_path = tmp.path().join("does_not_exist"); - - let repo = MevaRepository::new(&bad_path); - - let err = repo.init("main").unwrap_err(); - assert!(matches!( - err, - EngineError::Init(InitError::InvalidWorkingDir { path }) - if path == bad_path.to_string_lossy() - )); - } - - #[rstest] - fn init_fails_if_path_is_a_file() { - let tmp = TempDir::new().expect("failed to create TempDir"); - let file_path = tmp.path().join("not_a_dir.txt"); - - File::create(&file_path).expect("failed to create test file"); - - let repo = MevaRepository::new(&file_path); - let err = repo.init("main").unwrap_err(); - - assert!(matches!( - err, - EngineError::Init(InitError::InvalidWorkingDir { path }) - if path == file_path.to_string_lossy() - )); - } } diff --git a/engine/src/repositories/meva_repository_layout.rs b/engine/src/repositories/meva_repository_layout.rs new file mode 100644 index 0000000..bb6015a --- /dev/null +++ b/engine/src/repositories/meva_repository_layout.rs @@ -0,0 +1,75 @@ +use std::{env, path::PathBuf}; + +use crate::{ + RepositoryLayout, + errors::{EngineResult, InitError}, +}; + +pub struct MevaRepositoryLayout { + pub working_dir: PathBuf, +} + +impl MevaRepositoryLayout { + pub fn new(working_dir: PathBuf) -> EngineResult { + if !working_dir.is_dir() { + return Err(InitError::InvalidWorkingDir { + path: working_dir.to_string_lossy().into(), + } + .into()); + } + Ok(Self { working_dir }) + } + + pub fn from_env() -> EngineResult { + Ok(Self { + working_dir: env::current_dir()?, + }) + } +} + +impl RepositoryLayout for MevaRepositoryLayout { + /// Returns the repository’s working directory path. + fn working_dir(&self) -> &std::path::Path { + &self.working_dir + } + + fn set_working_dir(&mut self, new_working_dir: PathBuf) { + self.working_dir = new_working_dir; + } + + fn repository_dir_name(&self) -> &str { + ".meva" + } + + fn objects_dir_name(&self) -> &str { + "objects" + } + + fn refs_dir_name(&self) -> &str { + "refs" + } + + fn logs_dir_name(&self) -> &str { + "logs" + } + + fn heads_dir_name(&self) -> &str { + "heads" + } + + fn head_file_name(&self) -> &str { + "HEAD" + } + + fn config_file_name(&self) -> &str { + "mevaconfig" + } + + fn ignore_file_name(&self) -> &str { + ".mevaignore" + } + + fn plugins_dir_name(&self) -> &str { + "plugins" + } +} diff --git a/engine/src/repositories/repository_layout.rs b/engine/src/repositories/repository_layout.rs index 759f51e..cbdfe51 100644 --- a/engine/src/repositories/repository_layout.rs +++ b/engine/src/repositories/repository_layout.rs @@ -7,77 +7,87 @@ use std::path::{Path, PathBuf}; #[allow(dead_code)] pub trait RepositoryLayout { /// Name of the root repository directory. - const REPOSITORY_DIR: &'static str; + fn repository_dir_name(&self) -> &str; /// Subdirectory for storing objects. - const OBJECTS_DIR: &'static str; + fn objects_dir_name(&self) -> &str; /// Subdirectory for storing references. - const REFS_DIR: &'static str; + fn refs_dir_name(&self) -> &str; /// Subdirectory for reference logs. - const LOGS_DIR: &'static str; + fn logs_dir_name(&self) -> &str; /// Subdirectory for storing heads references and logs. - const HEADS_DIR: &'static str; + fn heads_dir_name(&self) -> &str; + + /// Subdirectory for storing plugins related metadata. + fn plugins_dir_name(&self) -> &str; /// Name of the HEAD file. - const HEAD_FILE: &'static str; + fn head_file_name(&self) -> &str; /// Name of the config file. - const CONFIG_FILE: &'static str; + fn config_file_name(&self) -> &str; /// Name of the ignore file. - const IGNORE_FILE: &'static str; + fn ignore_file_name(&self) -> &str; /// Returns the working directory where the repository is located. fn working_dir(&self) -> &Path; + fn set_working_dir(&mut self, new_working_dir: PathBuf); + // --- relative paths --- /// Returns the relative path to the repository directory. fn repository_dir_rel(&self) -> PathBuf { - PathBuf::from(Self::REPOSITORY_DIR) + PathBuf::from(self.repository_dir_name()) } /// Path to the objects directory relative to `working_dir`. fn objects_dir_rel(&self) -> PathBuf { - self.repository_dir_rel().join(Self::OBJECTS_DIR) + self.repository_dir_rel().join(self.objects_dir_name()) } /// Path to the refs directory relative to `working_dir`. fn refs_dir_rel(&self) -> PathBuf { - self.repository_dir_rel().join(Self::REFS_DIR) + self.repository_dir_rel().join(self.refs_dir_name()) } /// Path to heads in refs relative to `working_dir`. fn heads_refs_dir_rel(&self) -> PathBuf { - self.refs_dir_rel().join(Self::HEADS_DIR) + self.refs_dir_rel().join(self.heads_dir_name()) } /// Path to the logs directory relative to `working_dir`. fn logs_dir_rel(&self) -> PathBuf { - self.repository_dir_rel().join(Self::LOGS_DIR) + self.repository_dir_rel().join(self.logs_dir_name()) } /// Path to heads in logs relative to `working_dir`. fn heads_logs_dir_rel(&self) -> PathBuf { - self.logs_dir_rel().join(Self::HEADS_DIR) + self.logs_dir_rel().join(self.heads_dir_name()) + } + + /// Path to plugins directory relative to `working_dir`. + fn plugins_dir_rel(&self) -> PathBuf { + self.repository_dir_rel().join(self.plugins_dir_name()) } /// Path to the HEAD file relative to `working_dir`. fn head_file_rel(&self) -> PathBuf { - self.repository_dir_rel().join(Self::HEAD_FILE) + self.repository_dir_rel().join(self.head_file_name()) } /// Path to the config file relative to `working_dir`. fn config_file_rel(&self) -> PathBuf { - self.repository_dir_rel().join(Self::CONFIG_FILE) + self.repository_dir_rel().join(self.config_file_name()) } /// Path to the HEAD log file relative to `working_dir`. fn head_logs_file_rel(&self) -> PathBuf { - self.logs_dir_rel().join(Self::HEAD_FILE) + self.logs_dir_rel().join(self.head_file_name()) } // --- absolute paths --- @@ -112,6 +122,11 @@ pub trait RepositoryLayout { self.working_dir().join(self.heads_logs_dir_rel()) } + /// Absolute path to plugins directory. + fn plugins_dir(&self) -> PathBuf { + self.working_dir().join(self.plugins_dir_rel()) + } + /// Absolute path to the HEAD file. fn head_file(&self) -> PathBuf { self.working_dir().join(self.head_file_rel()) @@ -146,19 +161,49 @@ mod tests { } impl RepositoryLayout for TestRepoLayout { - const REPOSITORY_DIR: &'static str = ".meva"; - const OBJECTS_DIR: &'static str = "objects"; - const REFS_DIR: &'static str = "refs"; - const LOGS_DIR: &'static str = "logs"; - const HEADS_DIR: &'static str = "heads"; - - const HEAD_FILE: &'static str = "HEAD"; - const CONFIG_FILE: &'static str = "mevaconfig"; - const IGNORE_FILE: &'static str = ".mevaignore"; - fn working_dir(&self) -> &Path { &self.dir } + + fn set_working_dir(&mut self, new_working_dir: PathBuf) { + self.dir = new_working_dir; + } + + fn repository_dir_name(&self) -> &str { + ".meva" + } + + fn objects_dir_name(&self) -> &str { + "objects" + } + + fn refs_dir_name(&self) -> &str { + "refs" + } + + fn logs_dir_name(&self) -> &str { + "logs" + } + + fn heads_dir_name(&self) -> &str { + "heads" + } + + fn head_file_name(&self) -> &str { + "HEAD" + } + + fn config_file_name(&self) -> &str { + "mevaconfig" + } + + fn ignore_file_name(&self) -> &str { + ".mevaignore" + } + + fn plugins_dir_name(&self) -> &str { + "plugins" + } } #[rstest] @@ -179,6 +224,7 @@ mod tests { layout.heads_logs_dir_rel(), PathBuf::from(".meva/logs/heads") ); + assert_eq!(layout.plugins_dir_rel(), PathBuf::from(".meva/plugins")); assert_eq!(layout.head_file_rel(), PathBuf::from(".meva/HEAD")); assert_eq!(layout.config_file_rel(), PathBuf::from(".meva/mevaconfig")); assert_eq!( @@ -193,6 +239,7 @@ mod tests { assert_eq!(layout.heads_refs_dir(), base.join(".meva/refs/heads")); assert_eq!(layout.logs_dir(), base.join(".meva/logs")); assert_eq!(layout.heads_logs_dir(), base.join(".meva/logs/heads")); + assert_eq!(layout.plugins_dir(), base.join(".meva/plugins")); assert_eq!(layout.config_file(), base.join(".meva/mevaconfig")); assert_eq!(layout.head_file(), base.join(".meva/HEAD")); assert_eq!(layout.head_logs_file(), base.join(".meva/logs/HEAD")); diff --git a/plugins/Cargo.toml b/plugins/Cargo.toml index 1daa0f5..126b783 100644 --- a/plugins/Cargo.toml +++ b/plugins/Cargo.toml @@ -6,6 +6,13 @@ authors.workspace = true [dependencies] shared = { path = "../shared" } +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +chrono.workspace = true +strum.workspace = true +strum_macros.workspace = true +wait-timeout = "0.1.5" [dev-dependencies] rstest.workspace = true diff --git a/plugins/src/enums.rs b/plugins/src/enums.rs new file mode 100644 index 0000000..1df7c8f --- /dev/null +++ b/plugins/src/enums.rs @@ -0,0 +1,9 @@ +mod command_type; +mod event_type; +mod invocation_payload; +mod scope_type; + +pub use command_type::CommandType; +pub use event_type::EventType; +pub use invocation_payload::{InvocationPostPayload, InvocationPrePayload}; +pub use scope_type::ScopeType; diff --git a/plugins/src/enums/command_type.rs b/plugins/src/enums/command_type.rs new file mode 100644 index 0000000..392acc5 --- /dev/null +++ b/plugins/src/enums/command_type.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; +use strum_macros::{Display, EnumString, VariantNames}; + +/// Defines the command types that plugins can be registered to. +#[derive( + Debug, Deserialize, Serialize, Clone, EnumString, VariantNames, Display, Eq, PartialEq, +)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum CommandType { + Init, + ConfigGet, + ConfigSet, + ConfigUnset, + ConfigList, +} diff --git a/plugins/src/enums/event_type.rs b/plugins/src/enums/event_type.rs new file mode 100644 index 0000000..201b3ae --- /dev/null +++ b/plugins/src/enums/event_type.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; +use strum_macros::{Display, EnumString, VariantNames}; + +/// Represents the types of events that plugins can be registered to. +/// +/// Plugins can hook into specific execution phases: +/// +/// - **PreExecute** +/// - Triggered before a command is executed, +/// - Useful for validation, preprocessing, or setup tasks. +/// +/// - **PostExecute** +/// - Triggered after a command has been executed, +/// - Useful for cleanup, logging, or post-processing. +#[derive( + Debug, Deserialize, Serialize, Clone, PartialEq, Eq, EnumString, VariantNames, Display, +)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum EventType { + /// Event fired before command execution. + PreExecute, + + /// Event fired after successful command execution. + PostExecute, +} diff --git a/plugins/src/enums/invocation_payload.rs b/plugins/src/enums/invocation_payload.rs new file mode 100644 index 0000000..5bc5b14 --- /dev/null +++ b/plugins/src/enums/invocation_payload.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ + InitPostPayload, InitPrePayload, + models::{ + ConfigGetPostPayload, ConfigGetPrePayload, ConfigListPostPayload, ConfigListPrePayload, + ConfigSetPostPayload, ConfigSetPrePayload, ConfigUnsetPostPayload, ConfigUnsetPrePayload, + }, +}; + +/// Represents the data passed to plugins +/// before a command is executed. +/// +/// Each variant contains a payload specific to the command +/// that is about to run. This allows plugins to inspect or +/// modify data prior to execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum InvocationPrePayload { + /// Context for the `init` command. + Init(InitPrePayload), + + /// Context for the `config set` command. + ConfigSet(ConfigSetPrePayload), + + /// Context for the `config unset` command. + ConfigUnset(ConfigUnsetPrePayload), + + /// Context for the `config get` command. + ConfigGet(ConfigGetPrePayload), + + /// Context for the `config list` command. + ConfigList(ConfigListPrePayload), +} + +/// Represents the context data passed to plugins +/// after a command has been executed. +/// +/// Each variant contains a payload specific to the command +/// that has just completed. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum InvocationPostPayload { + /// Context for the `init` command. + Init(InitPostPayload), + + /// Context for the `config get` command. + ConfigGet(ConfigGetPostPayload), + + /// Context for the `config set` command. + ConfigSet(ConfigSetPostPayload), + + /// Context for the `config unset` command. + ConfigUnset(ConfigUnsetPostPayload), + + /// Context for the `config list` command. + ConfigList(ConfigListPostPayload), +} diff --git a/plugins/src/enums/scope_type.rs b/plugins/src/enums/scope_type.rs new file mode 100644 index 0000000..b1a336c --- /dev/null +++ b/plugins/src/enums/scope_type.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; +use strum_macros::{Display, EnumString, VariantNames}; + +/// Defines the scope of plugins. +#[derive( + Debug, Deserialize, Serialize, Clone, PartialEq, Eq, EnumString, VariantNames, Display, +)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum ScopeType { + /// A plugin that is specific to a single repository. + Local, + + /// A plugin that is shared across all repositories. + Global, +} diff --git a/plugins/src/errors.rs b/plugins/src/errors.rs new file mode 100644 index 0000000..c1b86ed --- /dev/null +++ b/plugins/src/errors.rs @@ -0,0 +1,10 @@ +mod configuration_error; +mod invocation_error; +mod plugin_error; +mod register_error; + +pub use configuration_error::ConfigurationError; +pub use invocation_error::InvocationError; +pub use plugin_error::PluginError; +pub use plugin_error::Result as PluginResult; +pub use register_error::RegisterError; diff --git a/plugins/src/errors/configuration_error.rs b/plugins/src/errors/configuration_error.rs new file mode 100644 index 0000000..50a243b --- /dev/null +++ b/plugins/src/errors/configuration_error.rs @@ -0,0 +1,18 @@ +use thiserror::Error; + +/// Represents errors related to plugins metadata stored +/// in the configuration files. +#[derive(Error, Debug)] +pub enum ConfigurationError { + /// Raised when a plugin with the same name already exists. + #[error("Duplicate plugin name found: `{name}`")] + DuplicateName { name: String }, + + /// Raised when a plugin with the same execution order already exists. + #[error("Duplicate plugin order found: {order}")] + DuplicateOrder { order: u32 }, + + /// Raised when a referenced plugin name could not be found. + #[error("Plugin name not found: `{name}`")] + NameNotFound { name: String }, +} diff --git a/plugins/src/errors/invocation_error.rs b/plugins/src/errors/invocation_error.rs new file mode 100644 index 0000000..44cb661 --- /dev/null +++ b/plugins/src/errors/invocation_error.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; + +/// Represents an error produced during plugin invocation. +#[derive(Debug, Serialize, Deserialize)] +pub struct InvocationError { + pub code: String, + /// Human-readable description of the error. + pub message: String, + + /// Optional additional details (e.g., stack traces). + pub details: Option, +} diff --git a/plugins/src/errors/plugin_error.rs b/plugins/src/errors/plugin_error.rs new file mode 100644 index 0000000..828713d --- /dev/null +++ b/plugins/src/errors/plugin_error.rs @@ -0,0 +1,62 @@ +use std::{ + io::{self, Error}, + path::PathBuf, +}; + +use thiserror::Error; + +use crate::{ + InvocationPostPayload, InvocationPrePayload, + errors::{ConfigurationError, RegisterError}, +}; + +/// The common alias for results using `PluginError`. +pub type Result = std::result::Result; + +/// A unified error type for all plugin-related operations. +#[derive(Error, Debug)] +pub enum PluginError { + /// Wrapper around standard I/O errors. + #[error(transparent)] + Io(#[from] io::Error), + + /// Wrapper around JSON serialization or deserialization errors. + #[error(transparent)] + Serde(#[from] serde_json::Error), + + /// Errors related to invalid or conflicting plugin configuration. + #[error(transparent)] + Configuration(#[from] ConfigurationError), + + /// Errors encountered while registering plugins. + #[error(transparent)] + Register(#[from] RegisterError), + + /// Raised when the specified plugin file could not be found. + #[error("Plugin file not found: {path}")] + FileNotFound { path: PathBuf }, + + /// Raised when a plugin process could not be spawned. + #[error("Plugin spawn error: {error}")] + Spawn { error: Error }, + + /// Raised when a pre-execution payload could not be deserialized or was invalid. + #[error("Invalid pre-execute payload: {payload:?}")] + PrePayload { + payload: Option, + }, + + /// Raised when a post-execution payload could not be deserialized or was invalid. + #[error("Invalid post-execute payload: {payload:?}")] + PostPayload { + payload: Option, + }, + + /// Raised when a plugin exceeded the configured timeout limit. + #[error("Plugin '{plugin}' timed out after {duration_ms} ms")] + Timeout { plugin: String, duration_ms: u128 }, + + /// A catch-all for unexpected errors. + #[error("Unknown Plugin error: {0}")] + Unknown(String), +} diff --git a/plugins/src/errors/register_error.rs b/plugins/src/errors/register_error.rs new file mode 100644 index 0000000..967f734 --- /dev/null +++ b/plugins/src/errors/register_error.rs @@ -0,0 +1,17 @@ +use std::path::PathBuf; + +use thiserror::Error; + +/// Represents errors that can occur during plugin registration. +#[derive(Error, Debug)] +pub enum RegisterError { + /// Raised when the given directory path is not valid + /// or cannot be accessed. + #[error("Invalid directory path provided: {path}")] + InvalidDirPath { path: PathBuf }, + + /// Raised when the given file path is not valid + /// or cannot be accessed. + #[error("Invalid file path provided: {path}")] + InvalidFilePath { path: PathBuf }, +} diff --git a/plugins/src/layout.rs b/plugins/src/layout.rs new file mode 100644 index 0000000..7da037b --- /dev/null +++ b/plugins/src/layout.rs @@ -0,0 +1,51 @@ +use crate::EventType; + +/// Defines the file and directory layout for plugin management. +pub trait PluginsLayout { + /// Returns the name of the directory where plugin invocation records are stored. + fn invocations_dir_name(&self) -> &'static str; + + /// Returns the name of the plugins configuration files. + fn plugins_file_name(&self) -> &'static str; + + /// Returns the name of the log files recording plugin invocation metadata. + fn invocation_log_name(&self) -> &'static str; + + /// Returns the name of the log files for capturing plugin stdout. + fn stdout_log_name(&self) -> &'static str; + + /// Returns the name of the log files for capturing plugin stderr. + fn stderr_log_name(&self) -> &'static str; + + /// Returns the filename for a plugin context file, based on the event type. + fn context_file_name(&self, event: &EventType) -> String; +} + +/// Default implementation of `PluginsLayout` following the Meva conventions. +pub struct MevaPluginsLayout; + +impl PluginsLayout for MevaPluginsLayout { + fn invocations_dir_name(&self) -> &'static str { + ".invocations" + } + + fn plugins_file_name(&self) -> &'static str { + "plugins.json" + } + + fn invocation_log_name(&self) -> &'static str { + "invocation.log" + } + + fn stdout_log_name(&self) -> &'static str { + "stdout.log" + } + + fn stderr_log_name(&self) -> &'static str { + "stderr.log" + } + + fn context_file_name(&self, event: &EventType) -> String { + format!("{event}-context.json") + } +} diff --git a/plugins/src/lib.rs b/plugins/src/lib.rs index ab976c8..888d405 100644 --- a/plugins/src/lib.rs +++ b/plugins/src/lib.rs @@ -1,15 +1,18 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +mod enums; +mod errors; +mod layout; +mod plugins_discovery; +mod plugins_engine; +mod plugins_repository; +mod plugins_runner; -#[cfg(test)] -mod tests { - use super::*; - use rstest::*; +pub mod models; - #[rstest] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub use enums::{CommandType, EventType, InvocationPostPayload, InvocationPrePayload, ScopeType}; +pub use errors::PluginError; +pub use layout::{MevaPluginsLayout, PluginsLayout}; +pub use models::*; +pub use plugins_discovery::PluginsDiscovery; +pub use plugins_engine::PluginsEngine; +pub use plugins_repository::PluginsRepository; +pub use plugins_runner::PluginsRunner; diff --git a/plugins/src/models.rs b/plugins/src/models.rs new file mode 100644 index 0000000..de34346 --- /dev/null +++ b/plugins/src/models.rs @@ -0,0 +1,11 @@ +mod invocation; +mod payloads; +mod plugin_configuration; +mod plugin_entry; +mod plugins_configuration; + +pub use invocation::{InvocationContext, InvocationInput, InvocationLogger, InvocationOutput}; +pub use payloads::*; +pub use plugin_configuration::PluginConfiguration; +pub use plugin_entry::PluginEntry; +pub use plugins_configuration::PluginsConfiguration; diff --git a/plugins/src/models/invocation.rs b/plugins/src/models/invocation.rs new file mode 100644 index 0000000..528b50a --- /dev/null +++ b/plugins/src/models/invocation.rs @@ -0,0 +1,9 @@ +mod invocation_context; +mod invocation_input; +mod invocation_logger; +mod invocation_output; + +pub use invocation_context::InvocationContext; +pub use invocation_input::InvocationInput; +pub use invocation_logger::InvocationLogger; +pub use invocation_output::InvocationOutput; diff --git a/plugins/src/models/invocation/invocation_context.rs b/plugins/src/models/invocation/invocation_context.rs new file mode 100644 index 0000000..8baccd4 --- /dev/null +++ b/plugins/src/models/invocation/invocation_context.rs @@ -0,0 +1,49 @@ +use std::{env, path::PathBuf}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{ + enums::{CommandType, EventType}, + errors::PluginResult, +}; + +/// Represents the data passed to a plugin +/// during every invocation, regardless of the specific command. +#[derive(Debug, Serialize, Deserialize)] +pub struct InvocationContext { + /// The command type that triggered the plugin. + pub command: CommandType, + + /// The execution phase. + pub event: EventType, + + /// The time at which the invocation occurred. + pub timestamp: DateTime, + + /// The working directory where the command was executed. + pub working_dir: PathBuf, +} + +impl InvocationContext { + /// Creates a new `InvocationContext`. + pub fn new( + command: CommandType, + event: EventType, + timestamp: Option>, + working_dir: Option, + ) -> PluginResult { + Ok(Self { + command, + event, + timestamp: match timestamp { + Some(ts) => ts, + None => Utc::now(), + }, + working_dir: match working_dir { + Some(wd) => wd, + None => env::current_dir()?, + }, + }) + } +} diff --git a/plugins/src/models/invocation/invocation_input.rs b/plugins/src/models/invocation/invocation_input.rs new file mode 100644 index 0000000..e2ba734 --- /dev/null +++ b/plugins/src/models/invocation/invocation_input.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ + InvocationPostPayload, InvocationPrePayload, + errors::{InvocationError, PluginResult}, + models::invocation::InvocationContext, +}; + +/// Represents the complete set of data exchanged between +/// the runner and a plugin during execution. +#[derive(Debug, Serialize, Deserialize)] +pub struct InvocationInput { + /// Invocation context providing metadata about the execution environment. + pub context: InvocationContext, + + /// Optional data passed **before** the command execution. + #[serde(rename = "pre-payload")] + pub pre_payload: Option, + + /// Optional data passed **after** the command execution. + #[serde(rename = "post-payload")] + pub post_payload: Option, + + /// Optional error information captured during execution. + pub error: Option, +} + +impl InvocationInput { + /// Parses a JSON string into an `InvocationInput` instance. + pub fn from_json(json: &str) -> PluginResult { + Ok(serde_json::from_str::(json)?) + } +} diff --git a/plugins/src/models/invocation/invocation_logger.rs b/plugins/src/models/invocation/invocation_logger.rs new file mode 100644 index 0000000..8643e9b --- /dev/null +++ b/plugins/src/models/invocation/invocation_logger.rs @@ -0,0 +1,109 @@ +use std::{ + fs::{File, OpenOptions}, + io::Write, + path::Path, +}; + +use crate::{ + errors::PluginResult, + models::{InvocationOutput, PluginConfiguration}, +}; + +/// Handles logging of plugin invocations, including their stdout, +/// stderr, and metadata about the execution. +#[derive(Debug)] +pub enum InvocationLogger { + /// Writes stdout, stderr, and invocation metadata to separate files. + ToFiles { + stdout_log: File, + stderr_log: File, + invocation_log: File, + }, + + /// Only console output is printed (no files are written). + ConsoleOnly, +} + +impl InvocationLogger { + /// Creates a new `InvocationLogger` that writes logs to the specified file paths. + /// + /// # Arguments + /// + /// * `stdout_path` - File path for stdout logging. + /// * `stderr_path` - File path for stderr logging. + /// * `invocation_path` - File path for plugin invocation metadata. + /// + /// # Returns + /// + /// A `PluginResult` containing the initialized `InvocationLogger`. + pub fn from_paths( + stdout_path: &Path, + stderr_path: &Path, + invocation_path: &Path, + ) -> PluginResult { + let stdout_log = OpenOptions::new() + .create(true) + .append(true) + .open(stdout_path)?; + + let stderr_log = OpenOptions::new() + .create(true) + .append(true) + .open(stderr_path)?; + + let invocation_log = OpenOptions::new() + .create(true) + .append(true) + .open(invocation_path)?; + + Ok(InvocationLogger::ToFiles { + stdout_log, + stderr_log, + invocation_log, + }) + } + + /// Logs a full stdout content (used when you collect stdout in a thread). + pub fn log_stdout(&mut self, content: &str) -> PluginResult<()> { + print!("{content}"); + if let InvocationLogger::ToFiles { stdout_log, .. } = self { + stdout_log.write_all(content.as_bytes())?; + stdout_log.flush()?; + } + Ok(()) + } + + /// Logs a full stderr content (used when you collect stderr in a thread). + pub fn log_stderr(&mut self, content: &str) -> PluginResult<()> { + eprint!("{content}"); + if let InvocationLogger::ToFiles { stderr_log, .. } = self { + stderr_log.write_all(content.as_bytes())?; + stderr_log.flush()?; + } + Ok(()) + } + + /// Logs metadata about a plugin invocation, such as its exit status + /// and execution duration, to the invocation log file (if enabled). + pub fn log_invocation( + &mut self, + plugin_configuration: &PluginConfiguration, + output: &InvocationOutput, + ) -> PluginResult<()> { + let timeout_suffix = if output.timed_out { " (timed out)" } else { "" }; + + if let InvocationLogger::ToFiles { invocation_log, .. } = self { + let log_line = format!( + "{}) Plugin {} finished with exit status {} in {} ms{}\n", + plugin_configuration.order, + plugin_configuration.name, + output.status_code, + output.duration_ms, + timeout_suffix + ); + invocation_log.write_all(log_line.as_bytes())?; + invocation_log.flush()?; + } + Ok(()) + } +} diff --git a/plugins/src/models/invocation/invocation_output.rs b/plugins/src/models/invocation/invocation_output.rs new file mode 100644 index 0000000..d95141d --- /dev/null +++ b/plugins/src/models/invocation/invocation_output.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +/// Represents the result of a plugin invocation. +#[derive(Debug, Serialize, Deserialize)] +pub struct InvocationOutput { + /// Unique identifier of the plugin (its name). + pub plugin: String, + + /// Exit status code returned by the plugin. + /// Conventionally, `0` indicates success, any non-zero value indicates an error. + pub status_code: i32, + + /// Execution duration in milliseconds. + pub duration_ms: u128, + + // Flag indicating if the process has timed out. + pub timed_out: bool, +} + +impl InvocationOutput { + /// Returns `true` if the plugin exited successfully (status code `0`) + /// and did not time out. + pub fn is_success(&self) -> bool { + !self.timed_out && self.status_code == 0 + } + + /// Returns `true` if the plugin invocation should be considered an error. + /// We treat either a non-zero exit code or a timeout as an error. + pub fn is_error(&self) -> bool { + self.timed_out || self.status_code != 0 + } + + /// Convenience accessor for timeout state. + pub fn is_timed_out(&self) -> bool { + self.timed_out + } +} diff --git a/plugins/src/models/payloads.rs b/plugins/src/models/payloads.rs new file mode 100644 index 0000000..dff6e8a --- /dev/null +++ b/plugins/src/models/payloads.rs @@ -0,0 +1,5 @@ +mod config_payload; +mod init_payload; + +pub use config_payload::*; +pub use init_payload::*; diff --git a/plugins/src/models/payloads/config_payload.rs b/plugins/src/models/payloads/config_payload.rs new file mode 100644 index 0000000..711d237 --- /dev/null +++ b/plugins/src/models/payloads/config_payload.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Payload provided to plugins **before** the `config get` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigGetPrePayload { + /// Path to the configuration file being queried. + pub config_file: PathBuf, + + /// Configuration key to retrieve. + pub key: String, + + /// Default value to return if the key is not found. + pub default: Option, +} + +/// Payload provided to plugins **after** the `config get` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigGetPostPayload { + /// Configuration key that was retrieved. + pub key: String, + + /// Value associated with the key. + pub value: String, +} + +/// Payload provided to plugins **before** the `config set` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigSetPrePayload { + /// Path to the configuration file being modified. + pub config_file: PathBuf, + + /// Configuration key to set. + pub key: String, + + /// Value to assign to the key. + pub value: String, +} + +/// Payload provided to plugins **after** the `config set` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigSetPostPayload { + /// Configuration key that was set. + pub key: String, + + /// Value that was assigned to the key. + pub value: String, +} + +/// Payload provided to plugins **before** the `config unset` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigUnsetPrePayload { + /// Path to the configuration file being modified. + pub config_file: PathBuf, + + /// Configuration key to remove. + pub key: String, +} + +/// Payload provided to plugins **after** the `config unset` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigUnsetPostPayload { + /// Value that was previously associated with the key. + pub value: String, +} + +/// Payload provided to plugins **before** the `config list` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigListPrePayload { + /// Path to the configuration file being listed. + pub config_file: PathBuf, +} + +/// Payload provided to plugins **after** the `config list` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigListPostPayload { + /// List of all key-value pairs in the configuration file. + pub key_values: Vec<(String, String)>, +} diff --git a/plugins/src/models/payloads/init_payload.rs b/plugins/src/models/payloads/init_payload.rs new file mode 100644 index 0000000..b02ca7c --- /dev/null +++ b/plugins/src/models/payloads/init_payload.rs @@ -0,0 +1,17 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Payload provided to plugins **before** the `init` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct InitPrePayload { + /// Name of the initial branch to be created during repository initialization. + pub initial_branch: String, +} + +/// Payload provided to plugins **after** the `init` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct InitPostPayload { + /// Path to the newly initialized repository directory. + pub repository_dir: PathBuf, +} diff --git a/plugins/src/models/plugin_configuration.rs b/plugins/src/models/plugin_configuration.rs new file mode 100644 index 0000000..ab12f16 --- /dev/null +++ b/plugins/src/models/plugin_configuration.rs @@ -0,0 +1,90 @@ +use std::{ + fmt::{Display, Formatter, Result}, + path::PathBuf, +}; + +use serde::{Deserialize, Serialize}; +use shared::PrettyField; + +use crate::enums::EventType; + +/// Represents the configuration of a plugin within the system. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct PluginConfiguration { + /// Unique name of the plugin. + pub name: String, + + /// Optional human-readable description of the plugin. + pub description: Option, + + /// Relative path to the plugin's file on the filesystem. + pub file: PathBuf, + + /// The event type the plugin is registered to. + pub event: EventType, + + /// Execution order of the plugin relative to other plugins for the same event. + pub order: u32, + + /// Whether the plugin is enabled or disabled. + pub enabled: bool, + + /// Optional interpreter to use when executing the plugin (e.g., `python3`). + pub interpreter: Option, +} + +impl PluginConfiguration { + // Creates a new `PluginConfiguration` with the provided values. + pub fn new( + name: String, + description: Option, + file: PathBuf, + event: EventType, + order: u32, + enabled: bool, + interpreter: Option, + ) -> Self { + Self { + name, + description, + file, + event, + order, + enabled, + interpreter, + } + } +} + +impl Display for PluginConfiguration { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let labels = [ + "Description", + "File", + "Event", + "Order", + "Enabled", + "Interpreter", + ]; + let max_len = labels.iter().map(|s| s.len()).max().unwrap_or(0); + let indent = 2; + + writeln!(f, "Plugin: {}", self.name)?; + if let Some(desc) = &self.description { + f.field(indent, labels[0], desc, max_len)?; + } + f.field(indent, labels[1], self.file.display(), max_len)?; + f.field(indent, labels[2], format!("{:?}", self.event), max_len)?; + f.field(indent, labels[3], self.order, max_len)?; + f.field( + indent, + labels[4], + if self.enabled { "yes" } else { "no" }, + max_len, + )?; + if let Some(interp) = &self.interpreter { + f.field(indent, labels[5], interp, max_len)?; + } + Ok(()) + } +} diff --git a/plugins/src/models/plugin_entry.rs b/plugins/src/models/plugin_entry.rs new file mode 100644 index 0000000..8f6cd88 --- /dev/null +++ b/plugins/src/models/plugin_entry.rs @@ -0,0 +1,22 @@ +use std::path::PathBuf; + +use crate::models::PluginConfiguration; + +/// Represents a plugin along with its source file location. +pub struct PluginEntry { + /// The plugin's configuration metadata. + pub plugin: PluginConfiguration, + + /// The path to the plugin's source file on disk. + pub source_file: PathBuf, +} + +impl PluginEntry { + /// Creates a new `PluginEntry` from a plugin configuration and its source file path. + pub fn new(plugin: PluginConfiguration, source_file: PathBuf) -> Self { + Self { + plugin, + source_file, + } + } +} diff --git a/plugins/src/models/plugins_configuration.rs b/plugins/src/models/plugins_configuration.rs new file mode 100644 index 0000000..a000584 --- /dev/null +++ b/plugins/src/models/plugins_configuration.rs @@ -0,0 +1,123 @@ +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +use crate::{ + errors::{ConfigurationError, PluginError, PluginResult}, + models::PluginConfiguration, +}; + +/// Represents a collection of plugins loaded from a configuration file. +/// +/// This structure manages a list of `PluginConfiguration` entries and provides +/// methods for deserialization, validation, retrieval, and merging. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct PluginsConfiguration { + /// List of plugin configurations. + pub plugins: Vec, +} + +impl Default for PluginsConfiguration { + /// Creates an empty `PluginsConfiguration`. + fn default() -> Self { + Self { + plugins: Vec::new(), + } + } +} + +impl PluginsConfiguration { + /// Creates a `PluginsConfiguration` from a JSON string. + /// + /// Validates that plugin names and orders are unique. + pub fn from_json(json: &str) -> PluginResult { + let plugins_config: Self = serde_json::from_str(json)?; + plugins_config.validate_unique_names()?; + plugins_config.validate_unique_orders()?; + Ok(plugins_config) + } + + /// Retrieves a mutable reference to a plugin by name. + pub fn get_by_name(&mut self, name: &str) -> Option<&mut PluginConfiguration> { + self.plugins.iter_mut().find(|p| p.name == name) + } + + /// Retrieves an owned copy of a plugin by name. + pub fn get_by_name_owned(&self, name: &str) -> Option { + self.plugins.iter().find(|p| p.name == name).cloned() + } + + /// Adds a new plugin to the configuration, validating uniqueness. + pub fn push(&mut self, plugin: PluginConfiguration) -> PluginResult<()> { + Self::validate_name_not_present(&self.plugins, &plugin.name)?; + Self::validate_order_not_present(&self.plugins, plugin.order)?; + self.plugins.push(plugin); + Ok(()) + } + + /// Merges another `PluginsConfiguration` into this one, validating uniqueness. + pub fn merge(&self, other: &PluginsConfiguration) -> PluginResult { + let mut combined = Vec::with_capacity(self.plugins.len() + other.plugins.len()); + combined.extend(self.plugins.iter().cloned()); + combined.extend(other.plugins.iter().cloned()); + + let combined_config = PluginsConfiguration { plugins: combined }; + + combined_config.validate_unique_names()?; + combined_config.validate_unique_orders()?; + + Ok(combined_config) + } + + /// Validates that a given plugin name is not already present. + fn validate_name_not_present(plugins: &[PluginConfiguration], name: &str) -> PluginResult<()> { + if plugins.iter().any(|p| p.name == name) { + return Err(PluginError::Configuration( + ConfigurationError::DuplicateName { + name: name.to_string(), + }, + )); + } + Ok(()) + } + + /// Validates that a given plugin order is not already present. + fn validate_order_not_present(plugins: &[PluginConfiguration], order: u32) -> PluginResult<()> { + if plugins.iter().any(|p| p.order == order) { + return Err(PluginError::Configuration( + ConfigurationError::DuplicateOrder { order }, + )); + } + Ok(()) + } + + /// Ensures all plugin names are unique in the configuration. + fn validate_unique_names(&self) -> PluginResult<()> { + let mut seen: HashSet = HashSet::with_capacity(self.plugins.len()); + for plugin in self.plugins.iter() { + if !seen.insert(plugin.name.clone()) { + return Err(PluginError::Configuration( + ConfigurationError::DuplicateName { + name: plugin.name.clone(), + }, + )); + } + } + Ok(()) + } + + /// Ensures all plugin orders are unique in the configuration. + fn validate_unique_orders(&self) -> PluginResult<()> { + let mut seen: HashSet = HashSet::with_capacity(self.plugins.len()); + for plugin in self.plugins.iter() { + if !seen.insert(plugin.order) { + return Err(PluginError::Configuration( + ConfigurationError::DuplicateOrder { + order: plugin.order, + }, + )); + } + } + Ok(()) + } +} diff --git a/plugins/src/plugins_discovery.rs b/plugins/src/plugins_discovery.rs new file mode 100644 index 0000000..433ccb0 --- /dev/null +++ b/plugins/src/plugins_discovery.rs @@ -0,0 +1,86 @@ +use std::{ + fs, + io::ErrorKind, + path::{Path, PathBuf}, +}; + +use crate::{ + CommandType, PluginError, PluginsLayout, errors::PluginResult, models::PluginsConfiguration, +}; + +/// Responsible for discovering and loading plugins from local and global directories. +/// +/// `PluginsDiscovery` uses a layout (`PluginsLayout`) to locate plugin files and directories, +/// read their configuration, and provide structured plugin lists for a given command. +pub struct PluginsDiscovery { + /// Layout defining standard plugin directory and file names. + pub layout: T, +} + +impl PluginsDiscovery { + /// Creates a new `PluginsDiscovery` instance with the given layout. + pub fn new(layout: T) -> Self { + Self { layout } + } + + /// Retrieves plugins for a specific command, returning local and global plugins separately. + pub fn get_plugins_for_command( + &self, + local_plugins_dir: &Option, + global_plugins_dir: &Path, + command: &CommandType, + ) -> PluginResult<(PluginsConfiguration, PluginsConfiguration)> { + let local_plugins = match local_plugins_dir { + Some(plugins_dir) => { + let local_command_dir = self.get_command_dir(command, plugins_dir)?; + let local_plugins_file = local_command_dir.join(self.layout.plugins_file_name()); + let (local_plugins, _) = self.get_plugins(&local_plugins_file)?; + local_plugins + } + None => PluginsConfiguration::default(), + }; + + let global_command_dir = self.get_command_dir(command, global_plugins_dir)?; + let global_plugins_file = global_command_dir.join(self.layout.plugins_file_name()); + let (global_plugins, _) = self.get_plugins(&global_plugins_file)?; + + Ok((local_plugins, global_plugins)) + } + + /// Loads plugins from a given plugins JSON file. + pub fn get_plugins(&self, plugins_file: &Path) -> PluginResult<(PluginsConfiguration, bool)> { + match fs::read_to_string(plugins_file) { + Ok(content) => Ok((PluginsConfiguration::from_json(&content)?, true)), + Err(ref err) if err.kind() == ErrorKind::NotFound => { + Ok((PluginsConfiguration::default(), false)) + } + Err(err) => Err(PluginError::Io(err)), + } + } + + /// Returns the directory path for a specific command, creating it if necessary. + pub fn get_command_dir( + &self, + command: &CommandType, + plugins_dir: &Path, + ) -> PluginResult { + let command_str = command.to_string(); + let command_dir = plugins_dir.join(command_str); + + fs::create_dir_all(&command_dir).map_err(PluginError::Io)?; + + Ok(command_dir) + } + + /// Returns the command directory, plugins file path, and loaded plugins for a command. + pub fn get_command_dir_and_plugins( + &self, + command: &CommandType, + plugins_dir: &Path, + ) -> PluginResult<(PathBuf, PathBuf, PluginsConfiguration)> { + let command_dir = self.get_command_dir(command, plugins_dir)?; + let plugins_file = command_dir.join(self.layout.plugins_file_name()); + let (plugins, _) = self.get_plugins(&plugins_file)?; + Ok((command_dir, plugins_file, plugins)) + } +} diff --git a/plugins/src/plugins_engine.rs b/plugins/src/plugins_engine.rs new file mode 100644 index 0000000..b5fcf75 --- /dev/null +++ b/plugins/src/plugins_engine.rs @@ -0,0 +1,252 @@ +use std::{ + fs::{self}, + io::Write, + path::{Path, PathBuf}, +}; + +use chrono::{DateTime, Utc}; +use shared::fs::create_file_with_dirs; + +use crate::{ + CommandType, EventType, PluginsConfiguration, PluginsLayout, PluginsRepository, + errors::{ConfigurationError, PluginError, PluginResult}, + models::{InvocationInput, InvocationLogger, PluginEntry}, + plugins_discovery::PluginsDiscovery, +}; +use crate::{errors::RegisterError, models::PluginConfiguration}; + +/// Core engine responsible for managing plugins and their invocations +pub struct PluginsEngine { + /// Discovery component used to locate plugins in local and global directories. + pub discovery: PluginsDiscovery, +} + +impl PluginsEngine { + /// Retrieves plugins configured for a specific command, separating local and global plugins. + pub fn get_plugins_for_command( + &self, + local_plugins_dir: &Option, + global_plugins_dir: &Path, + command: &CommandType, + ) -> PluginResult<(PluginsConfiguration, PluginsConfiguration)> { + self.discovery + .get_plugins_for_command(local_plugins_dir, global_plugins_dir, command) + } + + // Creates a directory for a plugin invocation, structured by timestamp and command. + pub fn create_invocation_dir( + &self, + timestamp: &DateTime, + plugins_dir: &Path, + command: &CommandType, + ) -> PluginResult { + let formatted_timestamp = self.formatted_timestamp(timestamp); + let invocation_dir = plugins_dir + .join(self.discovery.layout.invocations_dir_name()) + .join(command.to_string()) + .join(formatted_timestamp); + + fs::create_dir_all(&invocation_dir)?; + + Ok(invocation_dir) + } + + /// Creates the invocation JSON file to pass to a plugin. + pub fn create_invocation_file( + &self, + invocation: &InvocationInput, + invocation_dir: &PathBuf, + ) -> PluginResult { + fs::create_dir_all(invocation_dir)?; + + let context_file = invocation_dir.join( + self.discovery + .layout + .context_file_name(&invocation.context.event), + ); + let mut context = fs::File::create(&context_file)?; + let json = serde_json::to_string_pretty(&invocation)?; + context.write_all(json.as_bytes())?; + + Ok(context_file) + } + + /// Creates a logger for capturing stdout, stderr, and invocation logs. + pub fn create_logger( + &self, + invocation_dir: &Path, + log_to_files: bool, + ) -> PluginResult { + let logger = if log_to_files { + InvocationLogger::from_paths( + &invocation_dir.join(self.discovery.layout.stdout_log_name()), + &invocation_dir.join(self.discovery.layout.stderr_log_name()), + &invocation_dir.join(self.discovery.layout.invocation_log_name()), + )? + } else { + InvocationLogger::ConsoleOnly + }; + + Ok(logger) + } + + /// Formats a timestamp into a string suitable for directory or file names. + fn formatted_timestamp(&self, timestamp: &DateTime) -> String { + timestamp.format("%Y%m%d-%H%M%S").to_string() + } +} + +impl PluginsRepository for PluginsEngine { + fn register( + &self, + plugin: PluginConfiguration, + command: &CommandType, + plugins_dir: &Path, + source_file: &Path, + ) -> PluginResult<(PathBuf, PathBuf)> { + let (command_dir, plugins_file, mut plugins) = self + .discovery + .get_command_dir_and_plugins(command, plugins_dir)?; + + let dest_file = command_dir.join(&plugin.file); + create_file_with_dirs(&dest_file)?; + + plugins.push(plugin)?; + + let json = serde_json::to_string_pretty(&plugins)?; + + fs::write(&plugins_file, json)?; + fs::copy(source_file, &dest_file)?; + + Ok((plugins_file, dest_file)) + } + + fn unregister( + &self, + command: &CommandType, + name: &str, + plugins_dir: &Path, + ) -> PluginResult<(PathBuf, PathBuf)> { + let (command_dir, plugins_file, mut plugins) = self + .discovery + .get_command_dir_and_plugins(command, plugins_dir)?; + + let position = plugins + .plugins + .iter() + .position(|p| p.name == name) + .ok_or_else(|| { + PluginError::Configuration(ConfigurationError::NameNotFound { + name: name.to_string(), + }) + })?; + + let plugin_to_remove = plugins.plugins.remove(position); + + let json = serde_json::to_string_pretty(&plugins)?; + fs::write(&plugins_file, json)?; + + let script_path = command_dir.join(&plugin_to_remove.file); + if script_path.exists() { + fs::remove_file(&script_path)?; + } + + Ok((plugins_file, script_path)) + } + + fn info( + &self, + command: &CommandType, + name: &str, + plugins_dir: &Path, + ) -> PluginResult { + let (command_dir, _, plugins) = self + .discovery + .get_command_dir_and_plugins(command, plugins_dir)?; + + let plugin = plugins.get_by_name_owned(name).ok_or_else(|| { + PluginError::Configuration(ConfigurationError::NameNotFound { + name: name.to_string(), + }) + })?; + + let source_code_file = command_dir.join(&plugin.file); + + if !source_code_file.is_file() { + return Err(PluginError::Register(RegisterError::InvalidFilePath { + path: plugin.file.clone(), + })); + } + + Ok(PluginEntry::new(plugin, source_code_file)) + } + + fn update_enabled( + &self, + command: &CommandType, + name: &str, + plugins_dir: &Path, + value: Option, + ) -> PluginResult { + if value.is_none() { + return self.info(command, name, plugins_dir); + } + + let (command_dir, plugins_file, mut plugins) = self + .discovery + .get_command_dir_and_plugins(command, plugins_dir)?; + + let plugin = { + let plugin = plugins.get_by_name(name).ok_or_else(|| { + PluginError::Configuration(ConfigurationError::NameNotFound { + name: name.to_string(), + }) + })?; + + plugin.enabled = value.unwrap(); + plugin.clone() + }; + + let json = serde_json::to_string_pretty(&plugins)?; + fs::write(&plugins_file, json)?; + + let source_code_file = command_dir.join(&plugin.file); + if !source_code_file.is_file() { + return Err(PluginError::Register(RegisterError::InvalidFilePath { + path: plugin.file.clone(), + })); + } + + Ok(PluginEntry::new(plugin, source_code_file)) + } + + fn list( + &self, + command: &CommandType, + event: &EventType, + plugins_dir: &Path, + include_enabled: bool, + include_disabled: bool, + ) -> PluginResult> { + let (command_dir, _, plugins) = self + .discovery + .get_command_dir_and_plugins(command, plugins_dir)?; + + let mut entries = Vec::new(); + + for plugin in &plugins.plugins { + if plugin.event != *event { + continue; + } + + if (plugin.enabled && include_enabled) || (!plugin.enabled && include_disabled) { + let script_path = &plugin.file; + let source_file = command_dir.join(script_path); + + entries.push(PluginEntry::new(plugin.clone(), source_file)); + } + } + + Ok(entries) + } +} diff --git a/plugins/src/plugins_repository.rs b/plugins/src/plugins_repository.rs new file mode 100644 index 0000000..84a6c74 --- /dev/null +++ b/plugins/src/plugins_repository.rs @@ -0,0 +1,108 @@ +use std::path::{Path, PathBuf}; + +use crate::{ + CommandType, EventType, + errors::PluginResult, + models::{PluginConfiguration, PluginEntry}, +}; + +/// Defines the interface for managing plugins within a repository. +pub trait PluginsRepository { + /// Registers a plugin in the repository. + /// + /// # Arguments + /// + /// * `plugin` - The plugin configuration to register. + /// * `command` - The command this plugin is associated with. + /// * `plugins_dir` - Directory where plugins for this command are stored. + /// * `source_file` - Path to the plugin file to be copied or linked. + /// + /// # Returns + /// + /// A tuple containing the paths to the registered plugin file and configuration file. + fn register( + &self, + plugin: PluginConfiguration, + command: &CommandType, + plugins_dir: &Path, + source_file: &Path, + ) -> PluginResult<(PathBuf, PathBuf)>; + + /// Unregisters a plugin from the repository. + /// + /// # Arguments + /// + /// * `command` - The command this plugin is associated with. + /// * `name` - The name of the plugin to remove. + /// * `plugins_dir` - Directory where plugins for this command are stored. + /// + /// # Returns + /// + /// A tuple containing the paths of the removed plugin file and configuration file. + fn unregister( + &self, + command: &CommandType, + name: &str, + plugins_dir: &Path, + ) -> PluginResult<(PathBuf, PathBuf)>; + + /// Retrieves detailed information about a specific plugin. + /// + /// # Arguments + /// + /// * `command` - The command this plugin is associated with. + /// * `name` - The name of the plugin. + /// * `plugins_dir` - Directory where plugins for this command are stored. + /// + /// # Returns + /// + /// A `PluginEntry` containing the plugin configuration and source file path. + fn info( + &self, + command: &CommandType, + name: &str, + plugins_dir: &Path, + ) -> PluginResult; + + /// Lists plugins for a specific command and event. + /// + /// # Arguments + /// + /// * `command` - The command to list plugins for. + /// * `event` - The event type (`PreExecute` or `PostExecute`) to filter by. + /// * `plugins_dir` - Directory where plugins for this command are stored. + /// * `include_enabled` - Whether to include enabled plugins. + /// * `include_disabled` - Whether to include disabled plugins. + /// + /// # Returns + /// + /// A vector of `PluginEntry` for the matching plugins. + fn list( + &self, + command: &CommandType, + event: &EventType, + plugins_dir: &Path, + include_enabled: bool, + include_disabled: bool, + ) -> PluginResult>; + + /// Updates the enabled state of a specific plugin. + /// + /// # Arguments + /// + /// * `command` - The command this plugin is associated with. + /// * `name` - The name of the plugin. + /// * `plugins_dir` - Directory where plugins for this command are stored. + /// * `value` - New enabled state for the plugin. + /// + /// # Returns + /// + /// A `PluginEntry` reflecting the updated plugin configuration. + fn update_enabled( + &self, + command: &CommandType, + name: &str, + plugins_dir: &Path, + value: Option, + ) -> PluginResult; +} diff --git a/plugins/src/plugins_runner.rs b/plugins/src/plugins_runner.rs new file mode 100644 index 0000000..7bee4e2 --- /dev/null +++ b/plugins/src/plugins_runner.rs @@ -0,0 +1,210 @@ +use std::{ + io::{self, BufRead, BufReader, Read, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +use wait_timeout::ChildExt; + +use crate::{ + EventType, PluginError, + errors::PluginResult, + models::{InvocationLogger, InvocationOutput, PluginConfiguration, PluginsConfiguration}, +}; + +/// Responsible for executing plugins for a given command/event. +/// +/// `PluginsRunner` handles running both local and global plugins, +/// capturing stdout/stderr, logging execution details, and enforcing +/// execution order and timeouts. +pub struct PluginsRunner { + /// Path to the invocation JSON file passed to plugins. + pub invocation_file: PathBuf, + + /// Directory where plugin files are located. + command_dir: PathBuf, + + /// Logger used to capture stdout, stderr, and invocation metadata. + logger: InvocationLogger, + + /// Timeout which prevents plugins from running for too long. + timeout: u64, +} + +impl PluginsRunner { + /// Creates a new `PluginsRunner` instance. + pub fn new( + invocation_file: PathBuf, + command_dir: PathBuf, + logger: InvocationLogger, + timeout: u64, + ) -> Self { + Self { + invocation_file, + command_dir, + logger, + timeout, + } + } + + /// Executes a single plugin and captures its output. + pub fn run_plugin(&mut self, plugin: PluginConfiguration) -> PluginResult { + let source_file = self.command_dir.join(&plugin.file); + + if !source_file.is_file() { + return Err(PluginError::FileNotFound { path: source_file }); + } + + let mut command = self.build_command(&plugin, &source_file)?; + + let start = Instant::now(); + + let mut child = command.spawn()?; + + let stdout_handle = child + .stdout + .take() + .map(|out| self.spawn_stream_reader(out, false)); + + let stderr_handle = child + .stderr + .take() + .map(|err| self.spawn_stream_reader(err, true)); + + let timeout = self.get_timeout_duration(); + let timed_out; + + let status_code = match child.wait_timeout(timeout)? { + Some(status) => { + timed_out = false; + status.code().unwrap_or(-1) + } + None => { + timed_out = true; + let _ = child.kill(); + child.wait()?.code().unwrap_or(-1) + } + }; + + let duration_ms = start.elapsed().as_millis(); + + if let Some(h) = stdout_handle { + let out = h.join().unwrap_or_default(); + self.logger.log_stdout(&out)?; + } + if let Some(h) = stderr_handle { + let err = h.join().unwrap_or_default(); + self.logger.log_stderr(&err)?; + } + + let output = InvocationOutput { + plugin: plugin.name.clone(), + status_code, + duration_ms, + timed_out, + }; + + self.logger.log_invocation(&plugin, &output)?; + + Ok(output) + } + + /// Executes all plugins registered for a specific event, combining + /// local and global plugins, respecting order and enabled state. + pub fn run_plugins_for_event( + &mut self, + local_plugins: &PluginsConfiguration, + global_plugins: &PluginsConfiguration, + event: &EventType, + ) -> PluginResult> { + let mut plugins: Vec = local_plugins + .plugins + .iter() + .chain(global_plugins.plugins.iter()) + .filter(|p| p.enabled && p.event == *event) + .cloned() + .collect(); + + plugins.sort_by_key(|p| p.order); + + let mut results = Vec::new(); + + for plugin in plugins { + let result = self.run_plugin(plugin)?; + if result.is_error() { + results.push(result); + break; + } + results.push(result); + } + + Ok(results) + } + + /// Builds a `Command` to run the plugin, optionally using an interpreter. + fn build_command( + &self, + plugin: &PluginConfiguration, + source_file: &Path, + ) -> PluginResult { + let mut command = if let Some(ref interpreter) = plugin.interpreter { + let mut cmd = Command::new(interpreter); + cmd.arg(source_file); + cmd + } else { + Command::new(source_file) + }; + + command + .arg(&self.invocation_file) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .stdin(Stdio::inherit()); + + Ok(command) + } + + /// Spawns a thread that reads from `stream` line-by-line, prints each line + /// immediately to stdout or stderr (controlled by `to_stderr`) and collects + /// the whole output into a `String` returned by the JoinHandle. + fn spawn_stream_reader(&self, stream: R, to_stderr: bool) -> thread::JoinHandle + where + R: Read + Send + 'static, + { + thread::spawn(move || { + let mut reader = BufReader::new(stream); + let mut collected = String::new(); + let mut line = String::new(); + + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => break, // EOF + Ok(_) => { + if to_stderr { + eprint!("{line}"); + let _ = io::stderr().flush(); + } else { + print!("{line}"); + let _ = io::stdout().flush(); + } + collected.push_str(&line); + } + Err(e) => { + eprintln!("Error reading stream: {e}"); + break; + } + } + } + + collected + }) + } + + /// Returns the timeout duration for plugin execution. + fn get_timeout_duration(&self) -> Duration { + Duration::from_millis(self.timeout) + } +} diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 4259774..754c9e7 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -7,6 +7,7 @@ authors.workspace = true [dependencies] tempfile.workspace = true up_finder.workspace = true +editor-command.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/shared/src/extensions.rs b/shared/src/extensions.rs index ecc3e20..b9de664 100644 --- a/shared/src/extensions.rs +++ b/shared/src/extensions.rs @@ -1,5 +1,7 @@ pub mod fs; +pub mod open_in_editor; pub mod upward_search; pub use fs::create_file_with_dirs; +pub use open_in_editor::OpenInEditor; pub use upward_search::UpwardSearch; diff --git a/shared/src/extensions/fs.rs b/shared/src/extensions/fs.rs index e649fac..f7a9bc5 100644 --- a/shared/src/extensions/fs.rs +++ b/shared/src/extensions/fs.rs @@ -1,20 +1,40 @@ -use std::{fs, io, path::Path}; +use std::{ + fs::{self, File, OpenOptions}, + io, + path::Path, +}; /// Creates a file at the given path, ensuring all parent directories exist. -/// Returns an `io::Error` if creating parent directories or the file itself fails. -pub fn create_file_with_dirs>(path: P) -> io::Result<()> { +/// +/// # Returns +/// +/// * `(File, true)` – if the file was newly created +/// * `(File, false)` – if the file already existed (its contents are preserved) +/// +/// # Errors +/// +/// Returns an [`io::Error`] if creating parent directories or opening the file fails. +pub fn create_file_with_dirs>(path: P) -> io::Result<(File, bool)> { let path = path.as_ref(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - fs::File::create(path)?; - Ok(()) + + match OpenOptions::new().write(true).create_new(true).open(path) { + Ok(file) => Ok((file, true)), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + let file = OpenOptions::new().write(true).open(path)?; + Ok((file, false)) + } + Err(e) => Err(e), + } } #[cfg(test)] mod tests { use super::*; use rstest::rstest; + use std::io::Write; use tempfile::TempDir; #[rstest] @@ -22,8 +42,32 @@ mod tests { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("a").join("b").join("c.txt"); - let result = create_file_with_dirs(&path); - assert!(result.is_ok()); - assert!(path.exists()); + let (file, created) = create_file_with_dirs(&path).unwrap(); + + assert!(path.exists(), "File should exist after creation"); + assert!(created, "File should be marked as newly created"); + drop(file); + } + + #[rstest] + fn returns_false_if_file_already_exists() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("d.txt"); + + { + let (_f, created) = create_file_with_dirs(&path).unwrap(); + assert!(created); + } + + { + let mut f = File::create(&path).unwrap(); + writeln!(f, "hello").unwrap(); + } + + let (_f, created_again) = create_file_with_dirs(&path).unwrap(); + assert!(!created_again, "File already existed, should return false"); + + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(contents.contains("hello")); } } diff --git a/cli/src/extensions/path/open_in_editor.rs b/shared/src/extensions/open_in_editor.rs similarity index 83% rename from cli/src/extensions/path/open_in_editor.rs rename to shared/src/extensions/open_in_editor.rs index f8890ec..4637f32 100644 --- a/cli/src/extensions/path/open_in_editor.rs +++ b/shared/src/extensions/open_in_editor.rs @@ -1,7 +1,9 @@ -use std::path::Path; +use std::{ + io::{Error, Result}, + path::Path, +}; use editor_command::EditorBuilder; -use miette::{IntoDiagnostic, Result, WrapErr}; /// Trait to open a file or directory in the user’s preferred text editor. /// @@ -37,21 +39,19 @@ impl> OpenInEditor for P { let mut cmd = builder .build() - .into_diagnostic() - .wrap_err("Failed to build editor command")?; + .map_err(|e| Error::other(format!("Failed to build editor command: {e}")))?; cmd.arg(self.as_ref()); let status = cmd .status() - .into_diagnostic() - .wrap_err("Failed to launch the editor")?; + .map_err(|e| Error::other(format!("Failed to launch the editor: {e}")))?; if !status.success() { - return Err(miette::miette!( + return Err(Error::other(format!( "Editor returned error code: {}", status.code().unwrap_or(-1) - )); + ))); } Ok(()) diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 1e5d68a..5034db1 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -1,3 +1,6 @@ +mod pretty_field; + pub mod extensions; pub use extensions::{UpwardSearch, fs}; +pub use pretty_field::PrettyField; diff --git a/shared/src/pretty_field.rs b/shared/src/pretty_field.rs new file mode 100644 index 0000000..807b28e --- /dev/null +++ b/shared/src/pretty_field.rs @@ -0,0 +1,12 @@ +use std::fmt::{Display, Formatter, Result}; + +pub trait PrettyField { + fn field(&mut self, indent: usize, label: &str, value: T, width: usize) -> Result; +} + +impl<'a> PrettyField for Formatter<'a> { + fn field(&mut self, indent: usize, label: &str, value: T, width: usize) -> Result { + let padding = " ".repeat(indent); + writeln!(self, "{padding}{label:width$} : {value}") + } +} From 30bc8ff6f65d2068f085aeb7351c63a1c1a5ede3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Sat, 20 Sep 2025 13:14:04 +0200 Subject: [PATCH 05/42] Feature/command add (#4) --- .gitignore | 25 +- Cargo.lock | 588 ++++++++++++------ Cargo.toml | 2 +- cli/src/commands.rs | 2 + cli/src/commands/add.rs | 151 +++++ cli/src/main.rs | 3 +- engine/Cargo.toml | 5 +- engine/src/add.rs | 2 + engine/src/add/handlers.rs | 134 ++++ engine/src/add/operations.rs | 22 + engine/src/engine_container.rs | 8 + engine/src/errors.rs | 3 + engine/src/errors/engine_error.rs | 24 +- engine/src/errors/ignore_error.rs | 4 + engine/src/errors/index_error.rs | 21 + engine/src/errors/path_error.rs | 42 ++ engine/src/ignore/ignore_operations.rs | 27 +- engine/src/ignore/ignore_service.rs | 200 ++++-- engine/src/index.rs | 7 + engine/src/index/file_mode.rs | 80 +++ engine/src/index/index_entry.rs | 73 +++ engine/src/index/meva_hasher.rs | 33 + engine/src/index/meva_index.rs | 298 +++++++++ engine/src/index/serde_utils.rs | 63 ++ engine/src/index/stage.rs | 28 + engine/src/lib.rs | 2 + engine/src/repositories/meva_repository.rs | 7 +- .../repositories/meva_repository_layout.rs | 26 +- engine/src/repositories/repository_layout.rs | 37 +- plugins/src/enums/command_type.rs | 1 + plugins/src/enums/invocation_payload.rs | 8 +- .../models/invocation/invocation_context.rs | 5 +- plugins/src/models/payloads.rs | 2 + plugins/src/models/payloads/add_payload.rs | 21 + shared/src/extensions.rs | 3 + shared/src/extensions/canonicalize_clean.rs | 63 ++ shared/src/extensions/is_within.rs | 78 +++ .../src/extensions/remove_windows_prefix.rs | 60 ++ 38 files changed, 1907 insertions(+), 251 deletions(-) create mode 100644 cli/src/commands/add.rs create mode 100644 engine/src/add.rs create mode 100644 engine/src/add/handlers.rs create mode 100644 engine/src/add/operations.rs create mode 100644 engine/src/errors/index_error.rs create mode 100644 engine/src/errors/path_error.rs create mode 100644 engine/src/index.rs create mode 100644 engine/src/index/file_mode.rs create mode 100644 engine/src/index/index_entry.rs create mode 100644 engine/src/index/meva_hasher.rs create mode 100644 engine/src/index/meva_index.rs create mode 100644 engine/src/index/serde_utils.rs create mode 100644 engine/src/index/stage.rs create mode 100644 plugins/src/models/payloads/add_payload.rs create mode 100644 shared/src/extensions/canonicalize_clean.rs create mode 100644 shared/src/extensions/is_within.rs create mode 100644 shared/src/extensions/remove_windows_prefix.rs diff --git a/.gitignore b/.gitignore index ff2474a..116b1a2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ target # Contains mutation testing data **/mutants.out*/ +.meva + # RustRover # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore @@ -21,4 +23,25 @@ target #.idea/ # Ignore all the files created by running `cargo run --bin meva -- init` at the root of the project -.meva \ No newline at end of file +.idea/workspace.xml +.idea/dictionaries/project.xml +.idea/codeStyles/* +.idea/vcs.xml +.idea/inspectionProfiles/** +.idea/inspectionProfiles/Project_Default.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + diff --git a/Cargo.lock b/Cargo.lock index d9f40d7..a740a83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,12 +26,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -43,9 +37,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -73,22 +67,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.60.2", ] [[package]] @@ -109,7 +103,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -123,9 +117,18 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.9.1" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "block-buffer" +version = "0.11.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "e9ef36a6fcdb072aa548f3da057640ec10859eb4e91ddf526ee648d50c76a949" +dependencies = [ + "hybrid-array", +] [[package]] name = "bstr" @@ -145,39 +148,39 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "cc" -version = "1.2.34" +version = "1.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" +checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" dependencies = [ + "find-msvc-tools", "shlex", ] [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.0", ] [[package]] name = "clap" -version = "4.5.41" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" dependencies = [ "clap_builder", "clap_derive", @@ -185,9 +188,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.41" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" dependencies = [ "anstream", "anstyle", @@ -197,9 +200,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.41" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ "heck", "proc-macro2", @@ -237,18 +240,78 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "const-oid" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dabb6555f92fb9ee4140454eb5dcd14c7960e1225c6d1a6cc361f032947713e" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.2.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8235645834fbc6832939736ce2f2d08192652269e11010a6240f61b908a1c6" +dependencies = [ + "hybrid-array", +] + [[package]] name = "diff" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "digest" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6749b668519cd7149ee3d11286a442a8a8bdc3a9d529605f579777bfccc5a4bc" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -267,7 +330,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.61.0", ] [[package]] @@ -285,6 +348,12 @@ dependencies = [ "shell-words", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "engine" version = "0.1.0" @@ -292,17 +361,21 @@ dependencies = [ "chrono", "dirs", "globset", + "hex", "mockall", "plugins", "pretty_assertions", + "rayon", "rstest", "serde", "serde_json", + "sha1", "shared", "tempfile", "thiserror", "toml", - "toml_edit 0.23.2", + "toml_edit", + "walkdir", ] [[package]] @@ -313,12 +386,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.0", ] [[package]] @@ -327,6 +400,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" + [[package]] name = "fragile" version = "2.0.1" @@ -396,7 +475,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] @@ -407,9 +486,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" @@ -438,9 +517,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" [[package]] name = "heck" @@ -448,11 +527,26 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hybrid-array" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7116c472cf19838450b1d421b4e842569f52b519d640aee9ace1ebcf5b21051" +dependencies = [ + "typenum", +] + [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -474,9 +568,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "92119844f513ffa41556430369ab02c295a3578af21cf945caa3e9e0c2481ac3" dependencies = [ "equivalent", "hashbrown", @@ -502,9 +596,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" dependencies = [ "once_cell", "wasm-bindgen", @@ -512,15 +606,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.174" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libredox" -version = "0.1.4" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ "bitflags", "libc", @@ -528,15 +622,15 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "memchr" @@ -718,18 +812,18 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.22.27", + "toml_edit", ] [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -749,11 +843,31 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", @@ -762,9 +876,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", @@ -774,9 +888,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", @@ -785,9 +899,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "relative-path" @@ -827,9 +941,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -848,15 +962,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.0", ] [[package]] @@ -871,26 +985,45 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.225" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" dependencies = [ "proc-macro2", "quote", @@ -899,23 +1032,35 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] name = "serde_spanned" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +checksum = "2789234a13a53fc4be1b51ea1bab45a3c338bdb884862a257d10e5a74ae009e6" dependencies = [ - "serde", + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e046edf639aa2e7afb285589e5405de2ef7e61d4b0ac1e30256e3eab911af9" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", ] [[package]] @@ -944,9 +1089,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "slab" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "strsim" @@ -995,9 +1140,9 @@ checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" [[package]] name = "syn" -version = "2.0.104" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -1006,25 +1151,25 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.20.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.0", ] [[package]] name = "terminal_size" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ "rustix", - "windows-sys", + "windows-sys 0.60.2", ] [[package]] @@ -1045,18 +1190,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", @@ -1065,14 +1210,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.2" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" +checksum = "ae2a4cf385da23d1d53bc15cdfa5c2109e93d8d362393c801e87da2f72f0e201" dependencies = [ "indexmap", - "serde", + "serde_core", "serde_spanned", - "toml_datetime 0.7.0", + "toml_datetime", "toml_parser", "toml_writer", "winnow", @@ -1080,38 +1225,21 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" - -[[package]] -name = "toml_datetime" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +checksum = "a197c0ec7d131bfc6f7e82c8442ba1595aeab35da7adbf05b6b73cd06a16b6be" dependencies = [ - "serde", + "serde_core", ] [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.23.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "c2ad0b7ae9cfeef5605163839cb9221f453399f15cfb5c10be9885fcf56611f9" dependencies = [ "indexmap", - "toml_datetime 0.6.11", - "winnow", -] - -[[package]] -name = "toml_edit" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1dee9dc43ac2aaf7d3b774e2fba5148212bf2bd9374f4e50152ebe9afd03d42" -dependencies = [ - "indexmap", - "toml_datetime 0.7.0", + "toml_datetime", "toml_parser", "toml_writer", "winnow", @@ -1119,9 +1247,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" dependencies = [ "winnow", ] @@ -1134,29 +1262,35 @@ checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" [[package]] name = "typed-builder" -version = "0.21.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce63bcaf7e9806c206f7d7b9c1f38e0dce8bb165a80af0898161058b19248534" +checksum = "fef81aec2ca29576f9f6ae8755108640d0a86dd3161b2e8bca6cfa554e98f77d" dependencies = [ "typed-builder-macro", ] [[package]] name = "typed-builder-macro" -version = "0.21.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d8d828da2a3d759d3519cdf29a5bac49c77d039ad36d0782edadbf9cd5415b" +checksum = "1ecb9ecf7799210407c14a8cfdfe0173365780968dc57973ed082211958e0b18" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-linebreak" @@ -1201,6 +1335,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1209,30 +1353,40 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" dependencies = [ "bumpalo", "log", @@ -1244,9 +1398,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1254,9 +1408,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" dependencies = [ "proc-macro2", "quote", @@ -1267,22 +1421,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.0", +] + [[package]] name = "windows-core" -version = "0.61.2" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.2.0", "windows-result", "windows-strings", ] @@ -1315,31 +1478,46 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + [[package]] name = "windows-result" -version = "0.3.4" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" dependencies = [ - "windows-link", + "windows-link 0.2.0", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" dependencies = [ - "windows-link", + "windows-link 0.2.0", ] [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link 0.2.0", ] [[package]] @@ -1348,14 +1526,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link 0.1.3", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", ] [[package]] @@ -1364,65 +1559,110 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + [[package]] name = "winnow" -version = "0.7.12" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "yansi" diff --git a/Cargo.toml b/Cargo.toml index e8e3d8f..19f90f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ up_finder = "0.0.4" dirs = "6.0.0" editor-command = "1.0.0" globset = "0.4.16" -chrono = { version = "0.4.41", features = ["serde"] } +chrono = { version = "0.4.42", features = ["serde"] } clap = { version = "4.5.41", features = ["derive"] } strum = "0.27" strum_macros = "0.27" diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 30074e0..2c138b3 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1,9 +1,11 @@ +pub mod add; pub mod config; pub mod ignore; pub mod init; pub mod meva_command; pub mod plugins; +pub use add::AddCommand; pub use config::ConfigCommand; pub use ignore::IgnoreCommand; pub use init::InitCommand; diff --git a/cli/src/commands/add.rs b/cli/src/commands/add.rs new file mode 100644 index 0000000..3937493 --- /dev/null +++ b/cli/src/commands/add.rs @@ -0,0 +1,151 @@ +use std::path::PathBuf; + +use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command, value_parser}; +use miette::{IntoDiagnostic, Result}; + +use crate::commands::MevaCommand; +use engine::EngineContainer; +use engine::add::operations::AddRequest; +use engine::engine_container::MevaContainer; + +/// CLI command for adding files to the Meva staging area. +/// +/// The `AddCommand` allows +/// users to select files or directories to add to the repository's staging area. +/// +/// # Supported arguments +/// - **`path`**: Optional path to file or directory. Use `.` to add the current directory. +/// - **`--all, -a`**: Add all files in the repository. +/// - **`--force, -f`**: Add files even if they are normally ignored. +/// - **`--update, -u`**: Add modified and deleted files only. Conflicts with `--all`. +/// - **`--dry-run, -n`**: Show which files *would* be added, without actually staging them. +/// - **`--verbose, -v`**: Increase verbosity of output. +pub struct AddCommand; + +impl AddCommand { + /// Creates a new instance of the `AddCommand`. + pub fn new() -> Self { + Self + } + + /// Path argument key. + const ARG_PATH: &'static str = "path"; + + /// `--all` flag key. + const ARG_ALL: &'static str = "all"; + + /// `--force` flag key. + const ARG_FORCE: &'static str = "force"; + + /// `--update` flag key. + const ARG_UPDATE: &'static str = "update"; + + /// `--dry-run` flag key. + const ARG_DRY_RUN: &'static str = "dry-run"; + + /// `--verbose` flag key. + const ARG_VERBOSE: &'static str = "verbose"; +} + +impl MevaCommand for AddCommand { + fn name(&self) -> &'static str { + "add" + } + + fn about(&self) -> &'static str { + "Add files to the staging area" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the underlying Clap [`Command`] definition for this command. + /// + /// This defines supported arguments, their flags, and argument groups. + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_PATH) + .value_name("PATH") + .help("Path to file or directory to add, use '.' for current catalog") + .value_parser(value_parser!(PathBuf)) + .index(1), + ) + .arg( + Arg::new(Self::ARG_ALL) + .short('a') + .long(Self::ARG_ALL) + .help("Adds all files to staging area") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_FORCE) + .short('f') + .long(Self::ARG_FORCE) + .help("Add file to staging area despite it's ignored") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_UPDATE) + .short('u') + .help("Add modified and deleted files only") + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_ALL), + ) + .arg( + Arg::new(Self::ARG_DRY_RUN) + .short('n') + .long(Self::ARG_DRY_RUN) + .help("Show files to be added without adding them") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_VERBOSE) + .short('v') + .long(Self::ARG_VERBOSE) + .help("Be verbose") + .action(ArgAction::SetTrue), + ) + .group( + ArgGroup::new("path_group") + .args([Self::ARG_PATH, Self::ARG_ALL]) + .required(true) + .multiple(true), + ) + } + + /// Executes the `add` command. + /// + /// - Reads all flags and arguments from `matches`. + /// - Constructs an [`AddRequest`] with the provided options. + /// - Delegates to the repository's [`add_handler`] to perform the operation. + /// + /// Returns a [`miette::Result`] with diagnostic information if an error occurs. + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + let all_flag = matches.get_flag(Self::ARG_ALL); + let force_flag = matches.get_flag(Self::ARG_FORCE); + let update_flag = matches.get_flag(Self::ARG_UPDATE); + let dry_run_flag = matches.get_flag(Self::ARG_DRY_RUN); + let verbose_flag = matches.get_flag(Self::ARG_VERBOSE); + let path_arg = matches.get_one::(Self::ARG_PATH); + + let add_handler = container.add_handler().into_diagnostic()?; + let interceptor = container.plugins_interceptor().into_diagnostic()?; + + let request = AddRequest { + all_flag, + force_flag, + update_flag, + dry_run_flag, + verbose_flag, + path_arg: path_arg.cloned(), + }; + + add_handler + .handle_add(request, &interceptor) + .into_diagnostic()?; + + Ok(()) + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 7a71ec5..eccdaa5 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -3,7 +3,7 @@ mod extensions; mod meva_cli; use crate::meva_cli::MevaCli; -use commands::{ConfigCommand, IgnoreCommand, InitCommand, PluginsCommand}; +use commands::{AddCommand, ConfigCommand, IgnoreCommand, InitCommand, PluginsCommand}; use engine::engine_container::MevaContainer; use miette::Result; @@ -17,6 +17,7 @@ fn main() -> Result<()> { cli.add_command(Box::new(ConfigCommand::new())); cli.add_command(Box::new(IgnoreCommand::new())); cli.add_command(Box::new(PluginsCommand::new())); + cli.add_command(Box::new(AddCommand::new())); cli.run() } diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 7729002..2612b32 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -15,8 +15,11 @@ toml = "0.9.2" toml_edit = "0.23.2" dirs.workspace = true globset.workspace = true +walkdir = "2.5.0" +sha1 = "0.11.0-rc.0" +hex = "0.4.3" chrono.workspace = true - +rayon = "1.11.0" [dev-dependencies] rstest.workspace = true diff --git a/engine/src/add.rs b/engine/src/add.rs new file mode 100644 index 0000000..4ae1878 --- /dev/null +++ b/engine/src/add.rs @@ -0,0 +1,2 @@ +pub mod handlers; +pub mod operations; diff --git a/engine/src/add/handlers.rs b/engine/src/add/handlers.rs new file mode 100644 index 0000000..997d2a0 --- /dev/null +++ b/engine/src/add/handlers.rs @@ -0,0 +1,134 @@ +use crate::RepositoryLayout; +use crate::add::operations::{AddOperations, AddRequest, AddResponse}; +use crate::errors::path_error::PathError; +use crate::errors::{EngineError, EngineResult, RepositoryError}; +use crate::index::meva_index::MevaIndex; +use crate::plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}; +use crate::repositories::meva_repository_layout::MevaRepositoryLayout; + +use plugins::{ + AddPostPayload, AddPrePayload, CommandType, InvocationInput, InvocationPostPayload, + InvocationPrePayload, MevaPluginsLayout, PluginError, +}; +use shared::UpwardSearch; +use shared::extensions::canonicalize_clean::CanonicalizeClean; +use shared::extensions::is_within::IsWithin; + +pub struct AddHandler; + +impl AddHandler { + pub fn handle_add( + &self, + request: AddRequest, + interceptor: &PluginsInterceptor, + ) -> EngineResult { + interceptor + .intercept_with_plugins(CommandType::Add, None, request, self, |req| self.add(req)) + } +} + +impl AddOperations for AddHandler { + fn add(&self, request: AddRequest) -> EngineResult { + let workdir_abs = std::env::current_dir()?; + let mut repo_layout = MevaRepositoryLayout::new(workdir_abs.clone())?; + let repository_root_abs = match workdir_abs.search_dir_up(repo_layout.repository_dir_name()) + { + Some(val) => val + .parent() + .ok_or_else(|| PathError::NoParent { path: val.clone() })? + .to_owned(), + None => return Err(RepositoryError::RepositoryNotFound { path: workdir_abs }.into()), + }; + + let path_abs = match request.path_arg { + Some(val) if val.is_absolute() => val.clone(), + Some(val) => workdir_abs.join(val), + None => workdir_abs.clone(), + }; + + let path_abs = path_abs.canonicalize_clean()?; + + if !path_abs.is_within(&repository_root_abs)? { + return Err(PathError::NotInBaseDirectory { + path: path_abs, + path_base: repository_root_abs, + } + .into()); + } + + let add_new = request.all_flag || !request.update_flag; + let add_deleted = request.all_flag || request.update_flag; + let add_ignored = request.force_flag; + + let verbose = request.dry_run_flag || request.verbose_flag; + + repo_layout = MevaRepositoryLayout::new(repository_root_abs)?; + let mut index = MevaIndex::new(&repo_layout)?; + + index.load()?; + let (added, modified, removed) = + index.add(&path_abs, add_new, add_deleted, add_ignored, verbose)?; + + if !request.dry_run_flag { + index.save()?; + } + + Ok(AddResponse { + added, + modified, + removed, + }) + } +} + +impl PluginsInvocationMapper for AddHandler { + fn request_to_payload(&self, req: &AddRequest) -> EngineResult { + Ok(InvocationPrePayload::Add(AddPrePayload { + all_flag: req.all_flag, + force_flag: req.force_flag, + update_flag: req.update_flag, + dry_run_flag: req.dry_run_flag, + verbose_flag: req.verbose_flag, + path_arg: req.path_arg.clone(), + })) + } + + fn response_to_payload(&self, res: &AddResponse) -> EngineResult { + Ok(InvocationPostPayload::Add(AddPostPayload { + added: res.added.clone(), + modified: res.modified.clone(), + removed: res.removed.clone(), + })) + } + + fn input_to_request(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPrePayload::Add(pre)) = &input.pre_payload { + Ok(AddRequest { + all_flag: pre.all_flag, + force_flag: pre.force_flag, + update_flag: pre.update_flag, + dry_run_flag: pre.dry_run_flag, + verbose_flag: pre.verbose_flag, + path_arg: pre.path_arg.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PrePayload { + payload: input.pre_payload.clone(), + })) + } + } + + fn input_to_response(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPostPayload::Add(post)) = &input.post_payload { + Ok(AddResponse { + added: post.added.clone(), + modified: post.modified.clone(), + removed: post.removed.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PostPayload { + payload: input.post_payload.clone(), + })) + } + } +} diff --git a/engine/src/add/operations.rs b/engine/src/add/operations.rs new file mode 100644 index 0000000..d491dea --- /dev/null +++ b/engine/src/add/operations.rs @@ -0,0 +1,22 @@ +use std::path::PathBuf; + +use crate::errors::EngineResult; + +pub struct AddRequest { + pub all_flag: bool, + pub force_flag: bool, + pub update_flag: bool, + pub dry_run_flag: bool, + pub verbose_flag: bool, + pub path_arg: Option, +} + +pub struct AddResponse { + pub added: Vec, + pub modified: Vec, + pub removed: Vec, +} + +pub trait AddOperations { + fn add(&self, request: AddRequest) -> EngineResult; +} diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 46100c4..60940fc 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -1,5 +1,6 @@ use plugins::{MevaPluginsLayout, PluginsDiscovery, PluginsEngine}; +use crate::add::handlers::AddHandler; use crate::{ InitHandler, config::ConfigHandler, errors::EngineResult, plugins::PluginsHandler, plugins_interceptor::PluginsInterceptor, @@ -22,6 +23,9 @@ pub trait EngineContainer { /// Returns the handler responsible for plugin operations. fn plugins_handler(&self) -> EngineResult; + + /// Returns the handler responsible for add command + fn add_handler(&self) -> EngineResult; } /// Concrete implementation of `EngineContainer` for Meva. @@ -72,4 +76,8 @@ impl EngineContainer for MevaContainer { repository_layout: Box::new(self.repository_layout()?), }) } + + fn add_handler(&self) -> EngineResult { + Ok(AddHandler) + } } diff --git a/engine/src/errors.rs b/engine/src/errors.rs index a085c79..0d2e7d0 100644 --- a/engine/src/errors.rs +++ b/engine/src/errors.rs @@ -1,11 +1,14 @@ pub mod config_error; pub mod engine_error; pub mod ignore_error; +pub mod index_error; pub mod init_error; +pub mod path_error; pub mod repository_error; pub use config_error::ConfigError; pub use ignore_error::IgnoreError; +pub use index_error::IndexError; pub use init_error::InitError; pub use repository_error::RepositoryError; diff --git a/engine/src/errors/engine_error.rs b/engine/src/errors/engine_error.rs index 6eb97dd..1a7593c 100644 --- a/engine/src/errors/engine_error.rs +++ b/engine/src/errors/engine_error.rs @@ -1,9 +1,11 @@ use std::io; +use std::path::StripPrefixError; use plugins::PluginError; use thiserror::Error; -use crate::errors::{ConfigError, IgnoreError, InitError, RepositoryError}; +use crate::errors::path_error::PathError; +use crate::errors::{ConfigError, IgnoreError, IndexError, InitError, RepositoryError}; /// A convenient result type alias for engine-related operations. pub type Result = std::result::Result; @@ -39,6 +41,26 @@ pub enum EngineError { #[error(transparent)] Plugins(#[from] PluginError), + /// An error originating from index-file access or processing. + #[error(transparent)] + Index(#[from] IndexError), + + /// Errors related to invalid or inconsistent filesystem paths. + /// + /// Typically raised when verifying whether a path belongs inside a base + /// directory, or when a required path cannot be found. + #[error(transparent)] + Path(#[from] PathError), + + /// Error raised when attempting to strip a prefix from a path but the + /// operation is invalid (e.g., the prefix is not actually a parent). + #[error(transparent)] + StripPrefix(#[from] StripPrefixError), + + /// JSON serialization/deserialization error + #[error(transparent)] + Serde(#[from] serde_json::Error), + /// A catch-all variant for any unknown or unexpected engine error. /// Accepts a descriptive string message. #[error("Unknown engine error: {0}")] diff --git a/engine/src/errors/ignore_error.rs b/engine/src/errors/ignore_error.rs index 556a02b..1a55767 100644 --- a/engine/src/errors/ignore_error.rs +++ b/engine/src/errors/ignore_error.rs @@ -12,4 +12,8 @@ pub enum IgnoreError { /// Indicates that no ignore file could be located at the expected path. #[error("Ignore file not found at {path}")] IgnoreNotFound { path: String }, + + /// Indicates that ignore patterns were expected but not loaded (cache missing). + #[error("Ignore patterns not loaded")] + IgnoreCacheEmpty, } diff --git a/engine/src/errors/index_error.rs b/engine/src/errors/index_error.rs new file mode 100644 index 0000000..e0cc2d6 --- /dev/null +++ b/engine/src/errors/index_error.rs @@ -0,0 +1,21 @@ +use std::path::PathBuf; +use thiserror::Error; + +/// Represents errors that can occur when working with the index file. +/// +/// The index file is a core component of the repository that tracks the +/// current state of files (similar to Git’s index). +/// Errors here typically indicate that the index cannot be found, +/// accessed, or is otherwise missing. +#[derive(Error, Debug)] +pub enum IndexError { + /// Indicates that the index file could not be located at the expected path. + /// + /// This usually means the repository has not been initialized properly, + /// or the index file was accidentally deleted. + #[error("Index file not found at `{path}`")] + IndexFileNotFound { + /// The absolute or relative path where the index file was expected. + path: PathBuf, + }, +} diff --git a/engine/src/errors/path_error.rs b/engine/src/errors/path_error.rs new file mode 100644 index 0000000..c430595 --- /dev/null +++ b/engine/src/errors/path_error.rs @@ -0,0 +1,42 @@ +use std::path::PathBuf; +use thiserror::Error; + +/// Represents errors related to filesystem path operations. +/// +/// These errors typically arise when validating repository structure, +/// resolving relative paths, or performing path-based computations. +#[derive(Error, Debug)] +pub enum PathError { + /// The given path is not located inside the specified base directory. + /// + /// This error is returned when a check ensures that a path + /// must be contained within a base directory (for example, + /// to prevent escaping a repository root), but the condition fails. + #[error("Path `{path}` is not located inside the directory `{path_base}`")] + NotInBaseDirectory { + /// The path that was validated. + path: PathBuf, + /// The base directory it was expected to be inside. + path_base: PathBuf, + }, + + /// The given path does not exist in the filesystem. + /// + /// This error is returned when attempting to access or operate on + /// a path that cannot be found. + #[error("Path: `{path}` not found")] + PathNotFound { + /// The path that was expected to exist. + path: PathBuf, + }, + + /// The given path has no parent directory. + /// + /// This can occur if the path is the filesystem root, + /// or if it was otherwise malformed. + #[error("Cannot determine parent of path: `{path}`")] + NoParent { + /// The path for which a parent could not be determined. + path: PathBuf, + }, +} diff --git a/engine/src/ignore/ignore_operations.rs b/engine/src/ignore/ignore_operations.rs index c279e44..c0ffb5a 100644 --- a/engine/src/ignore/ignore_operations.rs +++ b/engine/src/ignore/ignore_operations.rs @@ -1,8 +1,7 @@ use std::path::{Path, PathBuf}; -use globset::Glob; - use crate::{IgnoreResult, errors::EngineResult}; +use globset::Glob; /// Trait defining core operations on Meva *ignore* files. /// @@ -17,7 +16,7 @@ pub trait IgnoreOperations { /// /// # Arguments /// * `pattern` – Pre-compiled [`Glob`] expression to append. - /// * `path` – Optional path to an explicit ignore file. + /// * `path` – Optional path to an explicit ignore file. /// /// # Returns /// Path to the file that was modified. @@ -33,7 +32,7 @@ pub trait IgnoreOperations { /// /// # Returns /// Tuple containing: - /// 1. Path to the file that was modified. + /// 1. Path to the file that was modified. /// 2. Vector of string patterns that were actually removed (empty if none /// matched). fn remove

(&self, pattern: &Glob, path: Option<&P>) -> EngineResult<(PathBuf, Vec)> @@ -54,13 +53,31 @@ pub trait IgnoreOperations { where P: AsRef; + /// Checks whether a given path is ignored using the **cached glob set**. + /// + /// This avoids repeated I/O by reusing the patterns compiled when the + /// service was constructed with [`with_cache`]. Returns an error if + /// no cache is initialized. + fn check_cached

(&self, checked_path: &P) -> EngineResult + where + P: AsRef; + /// Finds the nearest applicable ignore file, starting at `starting_path` /// and traversing upward toward the repository root. /// /// # Arguments - /// * `starting_path` – Directory to begin the search. + /// * `starting_path` – Directory to begin the search. /// /// # Returns /// Absolute path of the ignore file discovered. fn find_ignore_file(&self, starting_path: Option<&Path>) -> EngineResult; + + /// Finds all ignore files under a given path (recursive, downward search). + fn find_ignore_files_down(&self, path: &Path) -> Vec; + + /// Determines whether an ignore file applies to a given file. + /// + /// This checks whether `file` is located within the directory hierarchy + /// of `ignore_file`. + fn ignore_file_applies_to(ignore_file: &Path, file: &Path) -> bool; } diff --git a/engine/src/ignore/ignore_service.rs b/engine/src/ignore/ignore_service.rs index 9fe576f..22866cb 100644 --- a/engine/src/ignore/ignore_service.rs +++ b/engine/src/ignore/ignore_service.rs @@ -6,36 +6,117 @@ use std::{ }; use globset::{Glob, GlobSetBuilder}; - -use shared::UpwardSearch; +use walkdir::WalkDir; use crate::{ errors::{EngineResult, IgnoreError}, ignore::{IgnoreOperations, IgnoreResult}, }; +use shared::UpwardSearch; +use shared::extensions::canonicalize_clean::CanonicalizeClean; +use shared::extensions::is_within::IsWithin; -/// Service implementing ignore file operations. +/// Service implementing ignore file operations for Meva repositories. +/// +/// The `IgnoreService` is responsible for discovering, reading, and applying +/// ignore rules (e.g. `.mevaignore`) across a repository. It provides both +/// *on-demand* and *cached* mechanisms for working with ignore patterns: +/// +/// - **On-demand (`check`)**: Loads the ignore file and compiles patterns +/// every time a path is checked. +/// - **Cached (`check_cached`)**: Loads the ignore file once and keeps a +/// compiled `GlobSet` in memory for repeated checks, avoiding repeated I/O. /// -/// Provides concrete implementations for adding, removing, checking, and -/// locating ignore patterns within ignore files throughout a Meva repository. -/// The service handles file discovery, pattern matching, and persistence -/// operations while maintaining compatibility with glob syntax. +/// Additionally, the service can add, remove, and locate ignore files +/// throughout a repository. It is built on top of the [`globset`] crate +/// for glob pattern matching. pub struct IgnoreService { - /// Base filename used when searching for ignore files in the repository. - ignore_file: String, + /// Base filename used when searching for ignore files in the repository + /// (e.g. `.mevaignore`). + ignore_file_name: String, + + /// Optional cache containing the compiled glob set and patterns + /// loaded from an ignore file. Used by [`check_cached`]. + cached_set: Option, +} + +/// Represents a cached set of ignore patterns tied to a specific ignore file. +struct CachedIgnoreSet { + /// Represents a cached set of ignore patterns tied to a specific ignore file. + path_abs: PathBuf, + /// Compiled glob set used for efficient matching. + set: globset::GlobSet, + /// Raw pattern strings, kept for reporting and reverse lookup. + patterns: Vec, } impl IgnoreService { - /// Creates a new instance of the ignore service. - pub fn new(ignore_file: &str) -> Self { + /// Creates a new instance of the ignore service without a cache. + /// + /// Only the `ignore_file_name` is set. All checks will hit the filesystem + /// unless you explicitly use [`with_cache`]. + pub fn new(ignore_file_name: &str) -> Self { Self { - ignore_file: ignore_file.to_string(), + ignore_file_name: ignore_file_name.to_string(), + cached_set: None, + } + } + + /// Creates a new instance of the ignore service with an initialized cache. + /// + /// Loads patterns from the nearest ignore file (either at the given `path` + /// or discovered upwards from the current working directory). + /// Returns an [`EngineResult`] with the ready-to-use service. + pub fn with_cache(ignore_file_name: &str, path: Option<&Path>) -> EngineResult { + let mut service = Self::new(ignore_file_name); + + let ignore_file_path = match path { + Some(p) => p.to_path_buf(), + None => service.find_ignore_file(None)?, + }; + + let (set, patterns) = Self::load_patterns_from_file(&ignore_file_path)?; + + service.cached_set = Some(CachedIgnoreSet { + path_abs: ignore_file_path, + set, + patterns, + }); + + Ok(service) + } + + /// Loads and compiles ignore patterns from a file. + /// + /// Strips empty lines and comments, then returns a compiled [`GlobSet`] + /// and the original pattern strings. + fn load_patterns_from_file(path: &Path) -> EngineResult<(globset::GlobSet, Vec)> { + let file = fs::File::open(path)?; + let reader = BufReader::new(file); + + let mut builder = GlobSetBuilder::new(); + let mut patterns = Vec::new(); + + for line_result in reader.lines() { + let line = line_result?; + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + patterns.push(trimmed.to_string()); + + let glob = Glob::new(trimmed).map_err(IgnoreError::Glob)?; + builder.add(glob); } + + let set = builder.build().map_err(IgnoreError::Glob)?; + Ok((set, patterns)) } } impl IgnoreOperations for IgnoreService { - fn add

(&self, pattern: &globset::Glob, path: Option<&P>) -> EngineResult + fn add

(&self, pattern: &Glob, path: Option<&P>) -> EngineResult where P: AsRef, { @@ -56,11 +137,7 @@ impl IgnoreOperations for IgnoreService { Ok(ignore_file) } - fn remove

( - &self, - pattern: &globset::Glob, - path: Option<&P>, - ) -> EngineResult<(PathBuf, Vec)> + fn remove

(&self, pattern: &Glob, path: Option<&P>) -> EngineResult<(PathBuf, Vec)> where P: AsRef, { @@ -109,32 +186,52 @@ impl IgnoreOperations for IgnoreService { None => self.find_ignore_file(None)?, }; - let file = fs::File::open(&ignore_file)?; - let reader = BufReader::new(file); - - let mut builder = GlobSetBuilder::new(); - let mut patterns: Vec = Vec::new(); + let (set, patterns) = Self::load_patterns_from_file(&ignore_file)?; - for line_result in reader.lines() { - let line = line_result?; - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } + let matches = set.matches(checked_path); + let matched_patterns: Vec = + matches.into_iter().map(|m| patterns[m].clone()).collect(); - patterns.push(trimmed.to_string()); + Ok(IgnoreResult::from_matches(matched_patterns, &ignore_file)) + } - let glob = Glob::new(trimmed).map_err(IgnoreError::Glob)?; - builder.add(glob); + fn check_cached

(&self, checked_path: &P) -> EngineResult + where + P: AsRef, + { + if self.cached_set.is_none() { + return Err(IgnoreError::IgnoreCacheEmpty.into()); } - let set = builder.build().map_err(IgnoreError::Glob)?; + let cached_set = self.cached_set.as_ref().unwrap(); - let matches = set.matches(checked_path); - let matched_patterns: Vec = - matches.into_iter().map(|m| patterns[m].clone()).collect(); + let mut checked_path = checked_path.as_ref().to_path_buf(); - Ok(IgnoreResult::from_matches(matched_patterns, &ignore_file)) + if checked_path.is_absolute() { + if !Self::ignore_file_applies_to(&cached_set.path_abs, &checked_path) { + return Ok(IgnoreResult::NotIgnored { path: checked_path }); + } + checked_path = checked_path + .canonicalize_clean()? + .strip_prefix( + cached_set + .path_abs + .parent() + .unwrap_or_else(|| Path::new("")), + )? + .to_path_buf(); + } + + let matches = cached_set.set.matches(checked_path); + let matched_patterns: Vec = matches + .into_iter() + .map(|m| cached_set.patterns[m].clone()) + .collect(); + + Ok(IgnoreResult::from_matches( + matched_patterns, + Path::new(&self.ignore_file_name), + )) } fn find_ignore_file(&self, starting_path: Option<&Path>) -> EngineResult { @@ -144,11 +241,36 @@ impl IgnoreOperations for IgnoreService { env::current_dir()? }; - search_path.search_file_up(&self.ignore_file).ok_or( + search_path.search_file_up(&self.ignore_file_name).ok_or( IgnoreError::IgnoreNotFound { - path: self.ignore_file.clone(), + path: self.ignore_file_name.clone(), } .into(), ) } + + fn find_ignore_files_down(&self, path: &Path) -> Vec { + WalkDir::new(path) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_type().is_file() && e.file_name().to_str() == Some(&self.ignore_file_name) + }) + .map(|e| e.path().to_path_buf()) + .collect() + } + + fn ignore_file_applies_to(ignore_file: &Path, file: &Path) -> bool { + let ignore_dir = ignore_file + .parent() + .unwrap_or_else(|| Path::new("")) + .to_path_buf(); + + let file = file.to_path_buf(); + if let Ok(val) = file.is_within(&ignore_dir) { + return val; + } + + false + } } diff --git a/engine/src/index.rs b/engine/src/index.rs new file mode 100644 index 0000000..99fddba --- /dev/null +++ b/engine/src/index.rs @@ -0,0 +1,7 @@ +mod meva_hasher; + +pub mod file_mode; +pub mod index_entry; +pub mod meva_index; +pub mod serde_utils; +pub mod stage; diff --git a/engine/src/index/file_mode.rs b/engine/src/index/file_mode.rs new file mode 100644 index 0000000..c0a89af --- /dev/null +++ b/engine/src/index/file_mode.rs @@ -0,0 +1,80 @@ +use std::fs; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +/// Represents the file mode used to describe how a file +/// should be tracked in the repository. +/// +/// These values are used to +/// differentiate between regular files, executables, symbolic links, +/// and submodules (gitlinks). +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum FileMode { + /// A regular non-executable file (`100644`). + Normal = 0o100644, + + /// A regular executable file (`100755`). + Executable = 0o100755, + + /// A symbolic link (`120000`). + Symlink = 0o120000, + + /// A submodule (gitlink) entry (`160000`). + GitLink = 0o160000, +} + +impl TryFrom<&Path> for FileMode { + type Error = std::io::Error; + + /// Attempts to determine the appropriate [`FileMode`] for the given path, + /// based on its filesystem metadata. + /// + /// # Unix + /// On Unix platforms, the mode bits are inspected directly. + /// + /// # Windows + /// On Windows, executability is inferred from common executable file + /// extensions (`.exe`, `.bat`, `.cmd`, `.ps1`). + fn try_from(path: &Path) -> Result { + let metadata = fs::metadata(path)?; + + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let mode = metadata.mode(); + let file_mode = match mode & 0o170000 { + 0o100000 => { + if mode & 0o111 != 0 { + FileMode::Executable + } else { + FileMode::Normal + } + } + 0o120000 => FileMode::Symlink, + 0o160000 => FileMode::GitLink, + _ => FileMode::Normal, + }; + Ok(file_mode) + } + + #[cfg(windows)] + { + if metadata.is_file() { + let executable = path + .extension() + .and_then(|s| s.to_str()) + .is_some_and(|ext| { + matches!(ext.to_lowercase().as_str(), "exe" | "bat" | "cmd" | "ps1") + }); + Ok(if executable { + FileMode::Executable + } else { + FileMode::Normal + }) + } else { + Ok(FileMode::Normal) + } + } + } +} diff --git a/engine/src/index/index_entry.rs b/engine/src/index/index_entry.rs new file mode 100644 index 0000000..a6fc862 --- /dev/null +++ b/engine/src/index/index_entry.rs @@ -0,0 +1,73 @@ +use std::fs; +use std::path::Path; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::errors::EngineResult; +use crate::index::file_mode::FileMode; +use crate::index::stage::Stage; + +/// Represents a single entry in the repository index. +/// +/// The index entry tracks metadata about a file as it exists in the +/// working directory, including timestamps, file mode, size, hash, and stage. +/// This information is used to detect changes, manage staging, and build commits. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct IndexEntry { + /// The file's creation time (ctime). + pub ctime: DateTime, + + /// The file's last modification time (mtime). + pub mtime: DateTime, + + /// The file mode (e.g. normal, executable, symlink). + pub mode: FileMode, + + /// The file size in bytes. + pub file_size: u64, + + /// The SHA-1 object hash of the file's contents. + /// + /// Defaults to an empty string when the hash is not yet computed. + pub sha1: String, + + /// The stage number associated with the entry (e.g. for merge conflicts). + pub stage: Stage, + + /// The file system path to the entry, stored as a UTF-8 string. + pub path: String, +} + +impl IndexEntry { + /// Creates a new [`IndexEntry`] from the given file path, + /// without computing its SHA-1 hash. + /// + /// This is useful for cases where metadata (ctime, mtime, mode, size) + /// must be captured first, and the hash will be computed later. + /// + /// # Errors + /// Returns an [`EngineError`](crate::errors::EngineError) if: + /// - The file metadata cannot be accessed. + /// - The file mode cannot be determined. + pub fn from_path_without_hash(file_path: &Path) -> EngineResult { + let metadata = fs::metadata(file_path)?; + + let path = file_path.to_string_lossy().into_owned(); + let ctime: DateTime = metadata.created()?.into(); + let mtime: DateTime = metadata.modified()?.into(); + let mode = FileMode::try_from(file_path)?; + let stage = Stage::Normal; + let file_size = metadata.len(); + + Ok(Self { + ctime, + mtime, + mode, + file_size, + stage, + path, + sha1: String::default(), + }) + } +} diff --git a/engine/src/index/meva_hasher.rs b/engine/src/index/meva_hasher.rs new file mode 100644 index 0000000..34f08d3 --- /dev/null +++ b/engine/src/index/meva_hasher.rs @@ -0,0 +1,33 @@ +use std::fs::File; +use std::io::{BufReader, Read, Result}; +use std::path::Path; + +use sha1::{Digest, Sha1}; + +/// Utility struct for computing hashes used in the Meva repository. +/// +/// Currently supports SHA-1, which is used for file content hashing +pub struct MevaHasher; + +impl MevaHasher { + /// Computes the SHA-1 hash of a file at the given path. + /// + /// The file is read in chunks to avoid loading the entire file + /// into memory at once, making this efficient even for large files. + /// + /// # Errors + /// Returns an [`io::Error`] if the file cannot be opened or read. + pub fn sha1_file(path: &Path) -> Result { + let file = File::open(path)?; + let mut reader = BufReader::new(file); + let mut hasher = Sha1::new(); + + let mut buffer = Vec::new(); + reader.read_to_end(&mut buffer)?; + hasher.update(&buffer); + + let result = hasher.finalize(); + let sha1 = hex::encode(result); + Ok(sha1) + } +} diff --git a/engine/src/index/meva_index.rs b/engine/src/index/meva_index.rs new file mode 100644 index 0000000..837bd74 --- /dev/null +++ b/engine/src/index/meva_index.rs @@ -0,0 +1,298 @@ +use std::collections::{HashMap, HashSet}; +use std::fs::OpenOptions; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::{fs, io}; + +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use serde_json::Deserializer; +use walkdir::WalkDir; + +use crate::errors::path_error::PathError; +use crate::errors::{EngineError, EngineResult, IndexError}; +use crate::index::index_entry::IndexEntry; +use crate::index::meva_hasher::MevaHasher; +use crate::index::serde_utils::deserialize_entries_with_absolute_keys; +use crate::{IgnoreOperations, IgnoreResult, IgnoreService, RepositoryLayout}; +use shared::extensions::canonicalize_clean::CanonicalizeClean; + +/// Represents the in-memory index of tracked files in a Meva repository. +/// +/// The index keeps metadata (`IndexEntry`) for each tracked file, and is +/// persisted in a JSON file on disk (the *index file*). It is responsible for: +/// - loading and saving the index, +/// - tracking new/modified/deleted files, +/// - respecting ignore rules, +/// - updating file hashes (SHA1). +pub struct MevaIndex<'a> { + /// Layout of the repository (provides paths to working dir, index file, etc.). + repo_layout: &'a dyn RepositoryLayout, + + /// Map of tracked entries, keyed by their path (absolute or relative). + entries: HashMap, +} + +impl<'a> MevaIndex<'a> { + /// Creates a new, empty `MevaIndex` bound to the given repository layout. + pub fn new(repo_layout: &'a dyn RepositoryLayout) -> EngineResult { + Ok(Self { + repo_layout, + entries: HashMap::new(), + }) + } + + /// Loads the index file from disk into memory. + /// + /// If the file does not exist or is empty, the index will be cleared. + pub fn load(&mut self) -> EngineResult<()> { + let mut file = OpenOptions::new() + .write(true) + .read(true) + .create(true) + .truncate(false) + .open(self.repo_layout.index_file())?; + + let mut data = String::new(); + file.read_to_string(&mut data)?; + + if data.trim().is_empty() { + self.entries.clear(); + return Ok(()); + } + + let deserializer = &mut Deserializer::from_str(&data); + + self.entries = + deserialize_entries_with_absolute_keys(deserializer, self.repo_layout.working_dir()) + .map_err(io::Error::other)?; + + Ok(()) + } + + /// Saves the in-memory index back to disk. + /// + /// - Converts absolute paths in entries into repository-relative paths. + /// - Writes the index as a pretty-printed JSON array. + /// + /// Returns an error if: + /// - the index file cannot be found, + /// - a path in the index lies outside the repository root. + pub fn save(&mut self) -> EngineResult<()> { + if !self.repo_layout.index_file().exists() { + return Err(IndexError::IndexFileNotFound { + path: self.repo_layout.index_file(), + } + .into()); + } + let meva_repository_dir = self.repo_layout.working_dir().canonicalize_clean()?; + + for entry in self.entries.values_mut() { + let entry_absolute_path = PathBuf::from(entry.path.clone()); + let relative_path = match entry_absolute_path.strip_prefix(&meva_repository_dir) { + Ok(relative_path) => relative_path, + Err(_) => { + return Err(EngineError::from(PathError::NotInBaseDirectory { + path: entry_absolute_path, + path_base: meva_repository_dir, + })); + } + }; + + entry.path = relative_path.to_string_lossy().to_string(); + } + + let json = + serde_json::to_string_pretty(&self.entries.values().collect::>())?; + + fs::write(self.repo_layout.index_file(), json)?; + Ok(()) + } + + /// Removes and returns entries from the index corresponding to files that no longer exist + /// in the working directory. + /// + /// - `dir_path`: the directory being checked, + /// - `dir_files`: list of still-existing files, + /// - `verbose`: whether to print removed paths to stdout. + fn remove_deleted_files( + &mut self, + dir_path: &PathBuf, + dir_files: &[PathBuf], + verbose: bool, + ) -> Vec { + let files_in_index: Vec = self + .entries + .keys() + .filter(|k| Path::new(&k).starts_with(dir_path)) + .cloned() + .collect(); + + let workdir_set: HashSet = dir_files + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + + let deleted_files: Vec = files_in_index + .into_iter() + .filter(|k| !workdir_set.contains(k)) + .collect(); + + for file in &deleted_files { + if verbose { + println!("remove: {file}") + } + self.entries.remove(file); + } + + deleted_files + } + + /// Updates or inserts index entries with recalculated SHA1 values. + /// + /// The given list of entries is processed in parallel to speed up hashing. + fn update_index( + &mut self, + index_entries: &mut Vec, + verbose: bool, + ) -> EngineResult<()> { + let result: Vec<_> = index_entries + .par_iter() + .map(|index_entry| { + let mut updated_entry = index_entry.clone(); + updated_entry.sha1 = MevaHasher::sha1_file(index_entry.path.as_ref())?; + if verbose { + println!("add: {}", updated_entry.path) + } + Ok(updated_entry) + }) + .collect::>()?; + + for entry in result { + self.entries.insert(entry.path.clone(), entry); + } + + Ok(()) + } + + /// Groups files into: + /// - new (not yet in the index), + /// - modified (metadata differs from index entry). + /// + /// Returns a tuple `(new_files, modified_files)`. + fn group_files( + &self, + files: &Vec, + ) -> EngineResult<(Vec, Vec)> { + let mut new_files = Vec::::new(); + let mut modified_files = Vec::::new(); + + for file_path in files { + let new_entry = IndexEntry::from_path_without_hash(file_path)?; + + if let Some(old) = self.entries.get(&new_entry.path) { + let modified = old.mtime != new_entry.mtime || old.file_size != new_entry.file_size; + + if modified { + modified_files.push(new_entry); + } + } else { + new_files.push(new_entry); + } + } + + Ok((new_files, modified_files)) + } + + /// Creates all ignore services (`IgnoreService`) for the repository. + /// + /// This scans the working directory downward for ignore files and builds + /// services with cached glob patterns. + pub fn create_ignore_services(&self) -> Vec { + let ignore_service = IgnoreService::new(self.repo_layout.ignore_file_name()); + let ignore_files_abs: Vec = + ignore_service.find_ignore_files_down(self.repo_layout.working_dir()); + + ignore_files_abs + .iter() + .filter_map(|path| { + IgnoreService::with_cache(self.repo_layout.ignore_file_name(), Some(path)).ok() + }) + .collect() + } + + /// Adds files to the index according to the provided flags: + /// + /// - `add_new`: include new files, + /// - `add_deleted`: remove missing files, + /// - `add_ignored`: include ignored files (skip ignore check), + /// - `verbose`: print operations to stdout. + /// + /// The path provided (`path_abs`) is the starting point for traversal. + pub fn add( + &mut self, + path_abs: &PathBuf, + add_new: bool, + add_deleted: bool, + add_ignored: bool, + verbose: bool, + ) -> EngineResult<(Vec, Vec, Vec)> { + let ignore_services = match add_ignored { + true => Vec::new(), + false => self.create_ignore_services(), + }; + + let files_abs = self.collect_files(path_abs, ignore_services); + + let (mut new_entries, mut modified_entries) = self.group_files(&files_abs)?; + + let mut new_files = Vec::::new(); + let mut deleted_files = Vec::::new(); + let modified_files = modified_entries + .iter() + .map(|e| PathBuf::from(e.path.clone())) + .collect(); + + if add_deleted { + deleted_files = self + .remove_deleted_files(path_abs, &files_abs, verbose) + .into_iter() + .map(PathBuf::from) + .collect(); + } + + self.update_index(&mut modified_entries, verbose)?; + + if add_new { + self.update_index(&mut new_entries, verbose)?; + new_files = new_entries + .into_iter() + .map(|e| PathBuf::from(e.path)) + .collect(); + } + + Ok((new_files, modified_files, deleted_files)) + } + + /// Recursively collects files under a given path, applying ignore services + /// if provided. + /// + /// Returns only regular files (`is_file()`). + fn collect_files(&self, path: &Path, ignore_services: Vec) -> Vec { + WalkDir::new(path) + .into_iter() + .filter_entry(|e| { + for ignore_service in &ignore_services { + match ignore_service.check_cached(&e.path()) { + Ok(IgnoreResult::Ignored { .. }) => return false, + Ok(IgnoreResult::NotIgnored { .. }) => {} + Err(_) => return true, + } + } + true + }) + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .map(|e| e.path().to_path_buf()) + .collect() + } +} diff --git a/engine/src/index/serde_utils.rs b/engine/src/index/serde_utils.rs new file mode 100644 index 0000000..67fcb3e --- /dev/null +++ b/engine/src/index/serde_utils.rs @@ -0,0 +1,63 @@ +use std::collections::HashMap; +use std::path::Path; + +use serde::{Deserialize, Deserializer}; + +use crate::index::index_entry::IndexEntry; + +/// Deserializes a list of [`IndexEntry`] objects and rewrites their paths +/// into absolute paths based on a given repository base path. +/// +/// # Arguments +/// +/// * `deserializer` - The Serde deserializer providing a JSON array of index entries. +/// * `base_path` - The working directory of the repository; all relative paths +/// inside the entries will be joined with this path to form absolute paths. +/// +/// # Returns +/// +/// A [`HashMap`] where: +/// - the key is the absolute path of the file as a `String`, +/// - the value is the corresponding [`IndexEntry`] with its `path` field +/// updated to the absolute path. +/// +/// # Errors +/// +/// Returns a Serde [`D::Error`] if deserialization of the underlying +/// `Vec` fails. +/// +/// # Example +/// +/// ```rust,ignore +/// let json = r#"[{ +/// "ctime": "...", +/// "mtime": "...", +/// "mode": "Normal", +/// "file_size": 123, +/// "sha1": "abc123", +/// "stage": "Normal", +/// "path": "src/main.rs" +/// }]"#; +/// +/// let base = Path::new("/home/user/project"); +/// let mut deserializer = serde_json::Deserializer::from_str(json); +/// let entries = deserialize_entries_with_absolute_keys(&mut deserializer, base).unwrap(); +/// +/// assert!(entries.contains_key("/home/user/project/src/main.rs")); +/// ``` +pub fn deserialize_entries_with_absolute_keys<'de, D>( + deserializer: D, + base_path: &Path, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let list: Vec = Vec::deserialize(deserializer)?; + list.into_iter() + .map(|mut ie| { + let absolute_path = base_path.join(&ie.path).to_string_lossy().to_string(); + ie.path = absolute_path.clone(); + Ok((absolute_path, ie)) + }) + .collect::, D::Error>>() +} diff --git a/engine/src/index/stage.rs b/engine/src/index/stage.rs new file mode 100644 index 0000000..d2460b2 --- /dev/null +++ b/engine/src/index/stage.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +/// Represents the stage of a file in the index during merge operations. +/// +/// Stages are used to distinguish different versions of the same file +/// when a merge conflict occurs: +/// +/// - **Normal (0)** — standard entry in the index, no conflict. +/// - **Base (1)** — the common ancestor version in a merge conflict. +/// - **Ours (2)** — the file version from the current branch. +/// - **Theirs (3)** — the file version from the branch being merged. +/// +/// This allows the index to hold multiple versions of a file +/// until the conflict is resolved. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum Stage { + /// Standard entry with no merge conflict. + Normal = 0, + + /// Base (common ancestor) version in a merge conflict. + Base = 1, + + /// "Ours" version — file state from the current branch. + Ours = 2, + + /// "Theirs" version — file state from the branch being merged. + Theirs = 3, +} diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 000e266..4d38533 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,7 +1,9 @@ +pub mod add; pub mod config; pub mod engine_container; pub mod errors; pub mod ignore; +pub mod index; pub mod init; pub mod plugins; pub mod plugins_interceptor; diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index 806f3f9..99639b5 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -1,5 +1,8 @@ -use std::path::PathBuf; -use std::{fs, io::Write, path::Path}; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; use tempfile::TempDir; diff --git a/engine/src/repositories/meva_repository_layout.rs b/engine/src/repositories/meva_repository_layout.rs index bb6015a..19997c5 100644 --- a/engine/src/repositories/meva_repository_layout.rs +++ b/engine/src/repositories/meva_repository_layout.rs @@ -28,15 +28,6 @@ impl MevaRepositoryLayout { } impl RepositoryLayout for MevaRepositoryLayout { - /// Returns the repository’s working directory path. - fn working_dir(&self) -> &std::path::Path { - &self.working_dir - } - - fn set_working_dir(&mut self, new_working_dir: PathBuf) { - self.working_dir = new_working_dir; - } - fn repository_dir_name(&self) -> &str { ".meva" } @@ -57,6 +48,10 @@ impl RepositoryLayout for MevaRepositoryLayout { "heads" } + fn plugins_dir_name(&self) -> &str { + "plugins" + } + fn head_file_name(&self) -> &str { "HEAD" } @@ -69,7 +64,16 @@ impl RepositoryLayout for MevaRepositoryLayout { ".mevaignore" } - fn plugins_dir_name(&self) -> &str { - "plugins" + fn index_file_name(&self) -> &str { + "index" + } + + /// Returns the repository’s working directory path. + fn working_dir(&self) -> &std::path::Path { + &self.working_dir + } + + fn set_working_dir(&mut self, new_working_dir: PathBuf) { + self.working_dir = new_working_dir; } } diff --git a/engine/src/repositories/repository_layout.rs b/engine/src/repositories/repository_layout.rs index cbdfe51..70115df 100644 --- a/engine/src/repositories/repository_layout.rs +++ b/engine/src/repositories/repository_layout.rs @@ -33,6 +33,9 @@ pub trait RepositoryLayout { /// Name of the ignore file. fn ignore_file_name(&self) -> &str; + /// name of the index file + fn index_file_name(&self) -> &str; + /// Returns the working directory where the repository is located. fn working_dir(&self) -> &Path; @@ -90,6 +93,11 @@ pub trait RepositoryLayout { self.logs_dir_rel().join(self.head_file_name()) } + /// Path to index file relative to `workdir` + fn index_file_rel(&self) -> PathBuf { + self.repository_dir_rel().join(self.index_file_name()) + } + // --- absolute paths --- /// Absolute path to the repository directory. @@ -141,6 +149,11 @@ pub trait RepositoryLayout { fn head_logs_file(&self) -> PathBuf { self.working_dir().join(self.head_logs_file_rel()) } + + /// Absolute path to the index file. + fn index_file(&self) -> PathBuf { + self.working_dir().join(self.index_file_rel()) + } } #[cfg(test)] @@ -161,14 +174,6 @@ mod tests { } impl RepositoryLayout for TestRepoLayout { - fn working_dir(&self) -> &Path { - &self.dir - } - - fn set_working_dir(&mut self, new_working_dir: PathBuf) { - self.dir = new_working_dir; - } - fn repository_dir_name(&self) -> &str { ".meva" } @@ -189,6 +194,10 @@ mod tests { "heads" } + fn plugins_dir_name(&self) -> &str { + "plugins" + } + fn head_file_name(&self) -> &str { "HEAD" } @@ -201,8 +210,16 @@ mod tests { ".mevaignore" } - fn plugins_dir_name(&self) -> &str { - "plugins" + fn index_file_name(&self) -> &str { + "index" + } + + fn working_dir(&self) -> &Path { + &self.dir + } + + fn set_working_dir(&mut self, new_working_dir: PathBuf) { + self.dir = new_working_dir; } } diff --git a/plugins/src/enums/command_type.rs b/plugins/src/enums/command_type.rs index 392acc5..a18d5a3 100644 --- a/plugins/src/enums/command_type.rs +++ b/plugins/src/enums/command_type.rs @@ -13,4 +13,5 @@ pub enum CommandType { ConfigSet, ConfigUnset, ConfigList, + Add, } diff --git a/plugins/src/enums/invocation_payload.rs b/plugins/src/enums/invocation_payload.rs index 5bc5b14..a05b1ce 100644 --- a/plugins/src/enums/invocation_payload.rs +++ b/plugins/src/enums/invocation_payload.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use crate::{ - InitPostPayload, InitPrePayload, + AddPostPayload, AddPrePayload, InitPostPayload, InitPrePayload, models::{ ConfigGetPostPayload, ConfigGetPrePayload, ConfigListPostPayload, ConfigListPrePayload, ConfigSetPostPayload, ConfigSetPrePayload, ConfigUnsetPostPayload, ConfigUnsetPrePayload, @@ -31,6 +31,9 @@ pub enum InvocationPrePayload { /// Context for the `config list` command. ConfigList(ConfigListPrePayload), + + /// Context for the `add` command. + Add(AddPrePayload), } /// Represents the context data passed to plugins @@ -55,4 +58,7 @@ pub enum InvocationPostPayload { /// Context for the `config list` command. ConfigList(ConfigListPostPayload), + + /// Context for the `add` command. + Add(AddPostPayload), } diff --git a/plugins/src/models/invocation/invocation_context.rs b/plugins/src/models/invocation/invocation_context.rs index 8baccd4..3ed4d8a 100644 --- a/plugins/src/models/invocation/invocation_context.rs +++ b/plugins/src/models/invocation/invocation_context.rs @@ -36,10 +36,7 @@ impl InvocationContext { Ok(Self { command, event, - timestamp: match timestamp { - Some(ts) => ts, - None => Utc::now(), - }, + timestamp: timestamp.unwrap_or_else(Utc::now), working_dir: match working_dir { Some(wd) => wd, None => env::current_dir()?, diff --git a/plugins/src/models/payloads.rs b/plugins/src/models/payloads.rs index dff6e8a..dee4900 100644 --- a/plugins/src/models/payloads.rs +++ b/plugins/src/models/payloads.rs @@ -1,5 +1,7 @@ +mod add_payload; mod config_payload; mod init_payload; +pub use add_payload::*; pub use config_payload::*; pub use init_payload::*; diff --git a/plugins/src/models/payloads/add_payload.rs b/plugins/src/models/payloads/add_payload.rs new file mode 100644 index 0000000..012da06 --- /dev/null +++ b/plugins/src/models/payloads/add_payload.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Payload provided to plugins **before** the `meva add` command execution +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AddPrePayload { + pub all_flag: bool, + pub force_flag: bool, + pub update_flag: bool, + pub dry_run_flag: bool, + pub verbose_flag: bool, + pub path_arg: Option, +} + +/// Payload provided to plugins **after** the `meva add` command execution +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AddPostPayload { + pub added: Vec, + pub modified: Vec, + pub removed: Vec, +} diff --git a/shared/src/extensions.rs b/shared/src/extensions.rs index b9de664..b14ded6 100644 --- a/shared/src/extensions.rs +++ b/shared/src/extensions.rs @@ -1,5 +1,8 @@ +pub mod canonicalize_clean; pub mod fs; +pub mod is_within; pub mod open_in_editor; +pub mod remove_windows_prefix; pub mod upward_search; pub use fs::create_file_with_dirs; diff --git a/shared/src/extensions/canonicalize_clean.rs b/shared/src/extensions/canonicalize_clean.rs new file mode 100644 index 0000000..7e09ad5 --- /dev/null +++ b/shared/src/extensions/canonicalize_clean.rs @@ -0,0 +1,63 @@ +use crate::extensions::remove_windows_prefix::RemoveWindowsPrefix; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +/// A trait providing a helper method to canonicalize paths +/// and remove the Windows UNC prefix (`\\?\`) if present. +/// +/// This trait allows you to call `canonicalize_clean()` directly on any `Path` +/// without manually handling Windows path normalization. +pub trait CanonicalizeClean { + /// Canonicalizes the path and removes the Windows UNC prefix. + /// + /// # Returns + /// + /// * `Result` - Returns the canonicalized and normalized path, + /// or an `io::Error` if canonicalization fails. + /// + /// # Example + /// + /// ```no_run + /// use std::path::Path; + /// use shared::extensions::canonicalize_clean::CanonicalizeClean; + /// + /// let path = Path::new("./some/path"); + /// let clean_path = path.canonicalize_clean().unwrap(); + /// println!("Canonicalized path: {}", clean_path.display()); + /// ``` + fn canonicalize_clean(&self) -> Result; +} + +impl CanonicalizeClean for Path { + fn canonicalize_clean(&self) -> Result { + let canonical = self.canonicalize()?; + Ok(canonical.remove_windows_prefix()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::fs; + + #[test] + fn canonicalize_existing_relative_path() { + let current_dir = env::current_dir().unwrap(); + let relative = Path::new("."); + let clean = relative.canonicalize_clean().unwrap(); + + assert_eq!(clean, current_dir); + } + + #[test] + fn canonicalize_nested_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + fs::write(&file_path, "hello").unwrap(); + + let clean = file_path.canonicalize_clean().unwrap(); + assert!(clean.ends_with("test.txt")); + } +} diff --git a/shared/src/extensions/is_within.rs b/shared/src/extensions/is_within.rs new file mode 100644 index 0000000..1ded6f4 --- /dev/null +++ b/shared/src/extensions/is_within.rs @@ -0,0 +1,78 @@ +use std::io; +use std::path::{Path, PathBuf}; + +/// Extension trait for checking whether a given path +/// is located within another path. +/// +/// This is useful for verifying repository layout constraints, +/// ensuring that files are inside a specific directory, etc. +pub trait IsWithin { + /// Returns `Ok(true)` if `self` (the candidate path) is located + /// within the given `path` (the base directory). + /// + /// Both paths are canonicalized before comparison, so symbolic + /// links and relative paths are resolved. + /// + /// # Errors + /// + /// Returns an [`io::Error`] if canonicalization of either path fails. + fn is_within(&self, path: &Path) -> Result; +} + +impl IsWithin for PathBuf { + fn is_within(&self, path: &Path) -> Result { + let parent_can = path.canonicalize()?; + let child_can = self.canonicalize()?; + + Ok(child_can.starts_with(&parent_can)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn path_within_base_directory() { + let tmp_dir = tempdir().unwrap(); + let base = tmp_dir.path().to_path_buf(); + + let child = base.join("file.txt"); + fs::write(&child, "hello world").unwrap(); + + assert!(child.is_within(&base).unwrap()); + } + + #[test] + fn path_outside_base_directory() { + let tmp_dir = tempdir().unwrap(); + let base = tmp_dir.path().join("base"); + fs::create_dir(&base).unwrap(); + + let sibling = tmp_dir.path().join("sibling"); + fs::create_dir(&sibling).unwrap(); + + let outside = sibling.join("file.txt"); + fs::write(&outside, "outside file").unwrap(); + + assert!(!outside.is_within(&base).unwrap()); + } + + #[test] + fn relative_path_is_resolved() { + let tmp_dir = tempdir().unwrap(); + let base = tmp_dir.path(); + + let nested = base.join("nested"); + fs::create_dir(&nested).unwrap(); + + let file_path = nested.join("file.txt"); + fs::write(&file_path, "hello world").unwrap(); + + let child_relative = nested.join("..").join("nested/file.txt"); + + assert!(child_relative.is_within(base).unwrap()); + } +} diff --git a/shared/src/extensions/remove_windows_prefix.rs b/shared/src/extensions/remove_windows_prefix.rs new file mode 100644 index 0000000..d30ae48 --- /dev/null +++ b/shared/src/extensions/remove_windows_prefix.rs @@ -0,0 +1,60 @@ +use std::path::PathBuf; + +/// Extension trait for removing the Windows long path prefix (`\\?\`) from a path. +/// +/// On Windows, paths can be prefixed with `\\?\` to allow long paths (> 260 characters) +/// or to use extended path syntax. This trait provides a convenient way to strip +/// that prefix, returning a normalized `PathBuf`. +/// # Example +/// ``` +/// use std::path::PathBuf; +/// use shared::extensions::remove_windows_prefix::RemoveWindowsPrefix; +/// +/// let path = PathBuf::from(r"\\?\C:\Users\example\file.txt"); +/// let stripped = path.remove_windows_prefix(); +/// assert_eq!(stripped, PathBuf::from(r"C:\Users\example\file.txt")); +/// ``` +pub trait RemoveWindowsPrefix { + /// Returns a new `PathBuf` with the `\\?\` prefix removed if present. + /// + /// If the path does not start with the Windows long path prefix, the original + /// path is returned as a clone. + fn remove_windows_prefix(&self) -> PathBuf; +} + +impl RemoveWindowsPrefix for PathBuf { + fn remove_windows_prefix(&self) -> PathBuf { + let s = self.as_os_str().to_string_lossy(); + if let Some(stripped) = s.strip_prefix(r"\\?\") { + PathBuf::from(stripped) + } else { + self.clone() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn removes_windows_prefix_if_present() { + let path = PathBuf::from(r"\\?\C:\Users\example\file.txt"); + let stripped = path.remove_windows_prefix(); + assert_eq!(stripped, PathBuf::from(r"C:\Users\example\file.txt")); + } + + #[test] + fn leaves_path_unchanged_if_no_prefix() { + let path = PathBuf::from(r"C:\Users\example\file.txt"); + let stripped = path.remove_windows_prefix(); + assert_eq!(stripped, path); + } + + #[test] + fn works_with_non_windows_like_path() { + let path = PathBuf::from("/home/user/file.txt"); + let stripped = path.remove_windows_prefix(); + assert_eq!(stripped, path); + } +} From 828f08800fcc32e4fa3c65e4f8ad57ee30cdf0d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Mon, 22 Sep 2025 21:14:44 +0200 Subject: [PATCH 06/42] Plugins Improvements (#6) --- .../commands/plugins/subcommands/register.rs | 11 + docs/plugins/README.md | 393 ++++++++++++++++++ docs/plugins/examples/js/infinite_counter.js | 40 ++ docs/plugins/examples/js/init_files.js | 236 +++++++++++ docs/plugins/examples/js/nonzero_exit.js | 37 ++ docs/plugins/examples/js/print_arg.js | 49 +++ docs/plugins/examples/js/write_error.js | 67 +++ .../examples/powershell/infinite_counter.ps1 | 33 ++ .../examples/powershell/nonzero_exit.ps1 | 33 ++ .../plugins/examples/powershell/print_arg.ps1 | 45 ++ .../examples/python/infinite_counter.py | 38 ++ docs/plugins/examples/python/init_files.py | 184 ++++++++ docs/plugins/examples/python/nonzero_exit.py | 39 ++ docs/plugins/examples/python/print_arg.py | 48 +++ docs/plugins/examples/python/write_error.py | 67 +++ engine/src/config/config_loader.rs | 2 - engine/src/plugins/handler.rs | 19 +- engine/src/plugins/operations.rs | 1 + engine/src/plugins_interceptor.rs | 38 +- plugins/src/errors/invocation_error.rs | 19 + plugins/src/errors/plugin_error.rs | 4 +- .../models/invocation/invocation_logger.rs | 2 - plugins/src/models/plugin_configuration.rs | 37 +- plugins/src/plugins_runner.rs | 56 ++- 24 files changed, 1408 insertions(+), 90 deletions(-) create mode 100644 docs/plugins/README.md create mode 100644 docs/plugins/examples/js/infinite_counter.js create mode 100644 docs/plugins/examples/js/init_files.js create mode 100644 docs/plugins/examples/js/nonzero_exit.js create mode 100644 docs/plugins/examples/js/print_arg.js create mode 100644 docs/plugins/examples/js/write_error.js create mode 100644 docs/plugins/examples/powershell/infinite_counter.ps1 create mode 100644 docs/plugins/examples/powershell/nonzero_exit.ps1 create mode 100644 docs/plugins/examples/powershell/print_arg.ps1 create mode 100644 docs/plugins/examples/python/infinite_counter.py create mode 100644 docs/plugins/examples/python/init_files.py create mode 100644 docs/plugins/examples/python/nonzero_exit.py create mode 100644 docs/plugins/examples/python/print_arg.py create mode 100644 docs/plugins/examples/python/write_error.py diff --git a/cli/src/commands/plugins/subcommands/register.rs b/cli/src/commands/plugins/subcommands/register.rs index e2cc1f4..ccd108c 100644 --- a/cli/src/commands/plugins/subcommands/register.rs +++ b/cli/src/commands/plugins/subcommands/register.rs @@ -33,6 +33,8 @@ impl PluginsRegisterCommand { const ARG_ORDER: &'static str = "order"; + const ARG_TIMEOUT: &'static str = "timeout"; + const ARG_DISABLED: &'static str = "disabled"; const ARG_INTERPRETER: &'static str = "interpreter"; @@ -112,6 +114,14 @@ impl MevaCommand for PluginsRegisterCommand { .value_parser(clap::value_parser!(u32)) .help("Execution order (integer)"), ) + .arg( + Arg::new(Self::ARG_TIMEOUT) + .short('t') + .long(Self::ARG_TIMEOUT) + .value_name("TIMEOUT") + .value_parser(clap::value_parser!(u64)) + .help("Execution timeout in miliseconds (integer)"), + ) .arg( Arg::new(Self::ARG_DISABLED) .short('D') @@ -149,6 +159,7 @@ impl MevaCommand for PluginsRegisterCommand { command: command_str.parse::().unwrap(), event: event_str.parse::().unwrap(), order: *matches.get_one::(Self::ARG_ORDER).unwrap(), + timeout: matches.get_one::(Self::ARG_TIMEOUT).cloned(), enabled: !matches.get_flag(Self::ARG_DISABLED), interpreter: matches.get_one::(Self::ARG_INTERPRETER).cloned(), }; diff --git a/docs/plugins/README.md b/docs/plugins/README.md new file mode 100644 index 0000000..ea03650 --- /dev/null +++ b/docs/plugins/README.md @@ -0,0 +1,393 @@ +# Rozszerzalność projektu MEVA poprzez system pluginów + +System pluginów umożliwia rozszerzanie funkcjonalności systemu kontroli wersji poprzez uruchamianie zewnętrznych skryptów użytkownika w odpowiedzi na określone polecenia wywoływane przez narzędzie `meva`. + +Pluginy pozwalają na walidację, automatyzację i integrację zewnętrznych usług bez modyfikacji kodu źródłowego. + +## Spis treści + +- [Czym jest plugin?](#czym-jest-plugin) + - [Rodzaje pluginów](#rodzaje-pluginów) +- [Zdarzenia systemowe](#zdarzenia-systemowe) + - [pre-execute](#pre-execute) + - [post-execute](#post-execute) +- [Rejestracja pluginu](#rejestracja-pluginu) + - [Polecenie register](#polecenie-register) + - [Argumenty](#argumenty) + - [Opcje](#opcje) + - [Manualna edycja plików konfiguracyjnych](#manualna-edycja-plików-konfiguracyjnych) +- [Kontekst wywołania](#kontekst-wywołania) + - [Struktura pliku](#struktura-pliku) + - [Przykłady](#przykłady) + - [Polecenie config list – zdarzenie post-execute](#polecenie-config-list--zdarzenie-post-execute) + - [Polecenie init – zdarzenie pre-execute](#polecenie-init--zdarzenie-pre-execute) + - [Obiekt error](#obiekt-error) + - [Struktura obiektu](#struktura-obiektu) + - [Przykład obiektu error](#przykład-obiektu-error) +- [Struktura plików w folderze plugins/](#struktura-plików-w-folderze-plugins) + - [Pluginy lokalne](#pluginy-lokalne) + - [Pluginy globalne](#pluginy-globalne) + - [Pliki opisujące informacje o wywołaniu](#pliki-opisujące-informacje-o-wywołaniu) +- [Integracja z plikami konfiguracyjnymi](#integracja-z-plikami-konfiguracyjnymi) + - [Przykładowa konfiguracja](#przykładowa-konfiguracja) + - [Dostępne opcje](#dostępne-opcje) + +## Czym jest plugin? + +Plugin to samodzielny skrypt lub plik wykonywalny, który po zarejestrowaniu w naszym systemie jest wywoływany w określonym momencie. + +Pluginy mogą być napisane w różnych językach programowania, np. Python, JavaScript, PowerShell itp. + +Pluginy pozwalają m.in. na: + +- Walidację danych przed wykonaniem operacji, +- Przetwarzanie wyników po wykonaniu komend, +- Automatyzację zadań związanych z zarządzaniem repozytorium, +- Integrację z zewnętrznymi systemami, +- Inicjalizację i konfigurację projektów. + +Każdy plugin jest wywoływany z pojedynczym argumentem pozycyjnym, będącym ścieżką do pliku zawierającego kontekst wywołania, czyli metadane dotyczące danego polecenia (np. `init`, `commit` czy `push`). + +### Rodzaje pluginów + +Ze względu na lokalizację wyróżniamy dwa rodzaje pluginów: + +- Globalne: + - Zlokalizowane w folderze `~/.meva/plugins/`. + - Dostępne we wszystkich repozytoriach użytkownika. +- Lokalne: + - Zlokalizowane w folderze `/.meva/plugins/`. + - Specyficzne dla konkretnego repozytorium. + +Fizycznie plugin jest przechowywany w systemie w postaci następującej pary: + +- plik wykonywalny/zawierający kod źródłowy pluginu (w wybranym języku programowania), +- wpis w odpowiednim pliku konfiguracyjnym pluginów (położenie pliku odzwierciedla polecenie systemowe, którego dotyczy plugin, czyli np. pluginy uruchamiane dla zdarzeń związanych z poleceniem `commit` znajdują się w pliku `.meva/plugins/commit/plugins.json`). + +## Zdarzenia systemowe + +System pluginów obsługuje dwa zdarzenia `pre-execute` oraz `post-execute`. Dla każdego zdarzenia system obsługuje dowolną liczbę synchronicznie wykonywanych pluginów. + +### `pre-execute` + +- **Kiedy:** Zdarzenie to występuje przed wykonaniem głównej operacji systemu określonej poprzez polecenie, np. `init`, `commit`, `push`. +- **Zastosowanie:** Walidacja, przygotowanie danych, sprawdzenie warunków wstępnych. +- **Zachowanie:** Jeśli plugin zwraca błąd, główna operacja nie zostanie wykonana. + +### `post-execute` + +- **Kiedy:** Zdarzenie to występuje po pomyślnym wykonaniu głównej operacji. +- **Zastosowanie:** Raportowanie, powiadamianie, automatyzacja powtarzalnych czynności, inicjalizacja dodatkowych plików. +- **Zachowanie:** Błąd pluginu nie wpływa na status głównej operacji (przerywa jedynie wykonanie pozostałych zarejestrowanych pluginów). + +## Rejestracja pluginu + +Rejestracja, czyli dodanie nowego pluginu do systemu MEVA, może odbyć się na dwa sposoby: + +1. Poprzez dedykowane polecenie systemu, +2. Poprzez ręczną edycję plików konfiguracyjnych. + +### Polecenie `register` + +Operację rejestracji nowego pluginu umożliwia polecenie `register`: + +```bash + meva plugins register [OPTIONS] --name --command --event --order +``` + +#### Argumenty + +- `` – ścieżka do skryptu/pliku wykonywalnego, który zostanie skopiowany do repozytorium. + +#### Opcje + +- `-f, --file ` – ścieżka względna określająca położenie skryptu wewnątrz repozytorium (przykładowo wewnątrz folderu `.meva/plugins/commit/`). + - Pozwala na tworzenie zagnieżdżonej struktury plików (ułatwia logiczne grupowanie pluginów użytkownikowi systemu). + - Ścieżki względne rozpoczynające się od `./meva/plugins/.invocations/` są niedozwolone. +- `-s, --scope ` – zakres pluginu. + - Dostępne wartości to lokalny (`local`) oraz globalny (`global`). +- `-n, --name ` – nazwa pluginu. + - Może być różna od nazwy pliku określonego przez opcję `file`. + - Musi być unikatowa wśród wszystkich pluginów zarejestrowanych dla danego polecenia i zdarzenia. +- `-d, --description ` – opis pluginu. + - Pełni on jedynie rolę informacyjną dla użytkownika naszego systemu. +- `-c, --command ` – typ komendy (w formacie kebab-case). + - Możliwe wartości to na przykład: `init`, `config-get`, `add`. +- `-e, --event ` – typ zdarzenia, dla którego będzie uruchamiany plugin (w formacie kebab-case). + - Możliwe wartości: `pre-execute` i `post-execute`. +- `-o, --order ` – kolejność uruchomienia pluginu w ramach danego polecenia i zdarzenia systemowego. + - Wszystkie pluginy zarejestrowane na to samo zdarzenie dla danego polecenia uruchamiane są synchronicznie w kolejności określonej przez pole `order`. + - Wartość `order = 1` oznacza, że plugin zostanie uruchomiony jako pierwszy (odpowiednio większy `order` oznacza, że plugin zostanie uruchomiony później). + - Wszystkie pluginy zarejestrowane dla danego polecenia systemowego powinny mieć unikalną wartość `order`. + - W przypadku próby zarejestrowania pluginu dla polecenia, dla którego występuje już plugin o określonej wartości `order`, operacja zakończy się błędem. +- `-t, --timeout ` – limit czasu wykonania w milisekundach (liczba całkowita). + - W przypadku braku wartości czas wykonania nie jest ograniczony z góry (rekomendowane dla pluginów pobierających dane od użytkownika, np. z konsoli). +- `-D, --disabled` – rejestracja pluginu jako wyłączonego. + - Wyłączony plugin nie będzie uruchamiany dla danego zdarzenia systemowego. +- `-i, --interpreter ` – opcjonalny interpreter używany do uruchamiania skryptu (np. `python3`). + - W przypadku braku wartości system będzie bazował na rozszerzeniu pliku z kodem źródłowym. +- `-h, --help` – wyświetla pomoc. +- `-V, --version` – wyświetla wersję. + +### Manualna edycja plików konfiguracyjnych + +Użytkownik może zarejestrować plugin poprzez edycję pliku `plugins.json` dla określonego zdarzenia systemowego. Przykładowo, w celu manualnego dodania nowego pluginu dla polecenia `commit`, należy zmodyfikować plik `.meva/plugins/commit/plugins.json`. + +Przykładowa zawartość pliku `plugins.json`: + +```json +[ + { + "name": "validate-commit-message", + "description": "This python script validates if the commit message contains task number eg. #123 - minor changes", + "file": "validators/validate-commit-message.py", + "event": "pre-execute", + "order": 1, + "timeout": 500, + "enabled": true, + "interpreter": "python" + }, + { + "name": "another-plugin", + "description": "This is another example to show the file structure clearly", + "file": "validators/another-plugin.py", + "event": "post-execute", + "order": 2, + "timeout": null, + "enabled": false, + "interpreter": "python" + } +] +``` + +Dodatkowo, należy przekopiować plik zawierający kod źródłowy do folderu `.meva/plugins/commit/`. Dla pierwszego pluginu z przykładowej zawartości pliku `plugins.json` będzie to: `.meva/plugins/commit/validators/validate-commit-message.py`. + +## Kontekst wywołania + +Każde wywołanie pluginu odbywa się w oparciu o plik w formacie **JSON**, który zawiera informacje o aktualnym stanie, przekazywanych danych oraz błędach. +Plik ten przekazywany jest do skryptu w momencie uruchomienia i pozwala mu zareagować w odpowiedni sposób na zdarzenie. + +### Struktura pliku + +Plik kontekstu zawiera cztery główne pola: + +- **`context`** – informacje stałe, wspólne dla wszystkich wywołań. + Zawiera m.in. typ komendy, typ zdarzenia, znacznik czasu oraz katalog roboczy. + +- **`pre-payload`** – dane wejściowe specyficzne dla danego polecenia. + To pole występuje zawsze, niezależnie od zdarzenia. + +- **`post-payload`** – dane wyjściowe specyficzne dla danego polecenia. + + - Dla zdarzeń `pre-execute` wartość **zawsze** jest `null`. + - Dla zdarzeń `post-execute` zawiera dane zwracane przez wykonane polecenie. + +- **`error`** – obiekt opisujący błąd. + Wypełniany jest przez skrypt pluginu, a następnie sprawdzany przez silnik pluginów. + Umożliwia zgłaszanie zarówno błędów technicznych, jak i biznesowych. + +Dzięki takiemu ujednoliconemu formatowi skrypty pluginów mają zawsze dostęp do niezbędnych danych wejściowych, a silnik pluginów może w spójny sposób obsługiwać zarówno poprawne wyniki, jak i błędy. + +### Przykłady + +#### Polecenie `config list` – zdarzenie `post-execute` + +```json +{ + "context": { + "command": "config-list", + "event": "post-execute", + "timestamp": "2025-09-21T15:09:27.770490400Z", + "working_dir": "C:\\meva\\target\\debug" + }, + "pre-payload": { + "config_file": "C:\\meva\\target\\debug\\.meva\\mevaconfig" + }, + "post-payload": { + "key_values": [ + ["plugins.enabled", "true"], + ["plugins.collect_logs", "true"], + ["plugins.timeout_ms", "500"] + ] + }, + "error": null +} +``` + +#### Polecenie `init` – zdarzenie `pre-execute` + +```json +{ + "context": { + "command": "init", + "event": "pre-execute", + "timestamp": "2025-09-21T20:34:23.091035200Z", + "working_dir": "." + }, + "pre-payload": { + "initial_branch": "master" + }, + "post-payload": null, + "error": { + "code": "BUSINESS_LOGIC_ERROR", + "message": "A business rule was violated during plugin execution", + "details": "Optional additional context or stack trace" + } +} +``` + +### Obiekt `error` + +Pole `error` w pliku kontekstu pozwala skryptowi przekazać szczegółowe informacje o błędach napotkanych podczas wykonania. +Silnik pluginów wykorzystuje je **dopiero wtedy**, gdy proces uruchamiający skrypt zakończy się kodem różnym od `0` (czyli wystąpi błąd). + +W przypadku poprawnego wykonania (`exit status = 0`) zawartość pola `error` nie jest walidowana przez silnik. + +#### Struktura obiektu + +Obiekt `error` jest zdefiniowany w formacie JSON i zawiera następujące pola: + +- **`code`** _(string, wymagane)_ + Kod błędu identyfikujący jego typ. Może przyjmować wartości takie jak np.: + + - `BUSINESS_LOGIC_ERROR` – naruszenie reguły biznesowej, + - `VALIDATION_ERROR` – nieprawidłowe dane wejściowe, + - `RUNTIME_ERROR` – błąd wykonania (np. problem z dostępem do pliku). + +- **`message`** _(string, wymagane)_ + Krótki, czytelny opis błędu, przeznaczony do logów lub komunikatów użytkownika. + +- **`details`** _(string, opcjonalne)_ + Szczegółowe informacje diagnostyczne – np. fragment stosu wywołań, dodatkowy kontekst lub wskazówki do debugowania. + +Dzięki temu mechanizmowi możliwe jest precyzyjne raportowanie zarówno błędów technicznych, jak i naruszeń reguł biznesowych, a logi pluginów pozostają spójne i czytelne. + +#### Przykład obiektu `error` + +```json +"error": { + "code": "VALIDATION_ERROR", + "message": "Missing required configuration parameter", + "details": "Parameter 'plugins.enabled' not found in configuration file" +} +``` + +## Struktura plików w folderze `plugins/` + +Układ katalogów w folderze `plugins/` jest analogiczny w obu lokalizacjach – różni się jedynie miejscem przechowywania: + +- **Pluginy lokalne** – w repozytorium, wersjonowane razem z projektem. +- **Pluginy globalne** – w katalogu użytkownika, dostępne dla wszystkich repozytoriów na danej maszynie. + +### Pluginy lokalne + +Przechowywane w repozytorium – dzięki temu są wersjonowane razem z projektem i dostępne dla wszystkich osób pracujących w tym repozytorium. + +```txt +/.meva/ +└── plugins/ + ├── commit/ + │ ├── plugins.json + │ ├── validate_commit_message.py + │ └── ... + ├── config-set/ + │ ├── plugins.json + │ ├── check_if_secret_key_not_included.py + │ └── ... + ├── config-unset/ + │ ├── plugins.json + │ └── ... + └── ... +``` + +### Pluginy globalne + +Instalowane w katalogu użytkownika – dostępne dla wszystkich repozytoriów na danej maszynie. +Są przydatne np. do wymuszania organizacyjnych reguł (formatowanie kodu, walidacja commitów). + +```txt +~/.meva/ +└── plugins/ + ├── commit/ + │ ├── plugins.json + │ ├── validate_commit_message.py + │ └── ... + ├── config-set/ + │ ├── plugins.json + │ ├── check_if_secret_key_not_included.py + │ └── ... + ├── config-unset/ + │ ├── plugins.json + │ └── ... + └── ... +``` + +### Pliki opisujące informacje o wywołaniu + +Każde uruchomienie pluginu generuje w katalogu `.invocations/` zestaw plików pomocniczych, które umożliwiają analizę działania i debugowanie. +Struktura odzwierciedla rodzaj polecenia i datę/godzinę wywołania. + +```txt +.meva/ +└── plugins/ + └── .invocations/ + ├── commit/ + │ ├── 20250828-202222/ + │ │ ├── invocation.log + │ │ ├── pre-execute-context.json + │ │ ├── post-execute-context.json + │ │ ├── stdout.log + │ │ └── stderr.log + │ └── ... + └── ... +``` + +Zawartość katalogu wywołania: + +- `invocation.log` + Główny log wywołania – zawiera ogólne informacje o uruchomieniu pluginów. + +- `pre-execute-context.json` + Kontekst wywołania dla zdarzenia `pre-execute`. + Zawiera stałe pola (`context`), `pre-payload` oraz ewentualny obiekt `error`. + Pole `post-payload` zawsze ma wartość `null`. + +- `post-execute-context.json` + Kontekst wywołania dla zdarzenia `post-execute`. + Oprócz pól z `pre-execute` zawiera również `post-payload` z danymi wynikowymi. + +- `stdout.log` + Standardowe wyjście (`stdout`) ze skryptów pluginów. + +- `stderr.log` + Standardowe wyjście błędów (`stderr`) ze skryptów pluginów. + +Dzięki takiemu układowi możliwe jest łatwe odtworzenie i przeanalizowanie każdego wywołania – od danych wejściowych (`pre-execute-context.json`), przez dane wynikowe (`post-execute-context.json`), aż po logi i ewentualne błędy. + +## Integracja z plikami konfiguracyjnymi + +Działanie pluginów można kontrolować poprzez wpisy w plikach konfiguracyjnych systemu MEVA. +W sekcji `[plugins]` definiowane są podstawowe parametry, które wpływają na uruchamianie oraz logowanie pluginów. + +### Przykładowa konfiguracja + +```toml +[plugins] +enabled = true +collect_logs = true +``` + +### Dostępne opcje + +- `enabled` (_boolean_): + Określa, czy mechanizm pluginów jest aktywny. + - `true` - pluginy są uruchamiane zgodnie z rejestracją, + - `false` - wszystkie pluginy są ignorowane (lokalne i globalne). +- `collect_logs` (_boolean_): + Kontroluje zapisywanie logów z uruchomień pluginów do katalogu `.invocations/`. + - `true` - pliki `invocation.log`, `stdout.log`, `stderr.log` oraz konteksty (`*-context.json`) są zapisywane, + - `false` - logi nie są przechowywane (działanie pluginów jest wtedy trudniejsze do debugowania). + +Dzięki integracji z plikami konfiguracyjnymi możliwe jest centralne sterowanie zachowaniem pluginów – zarówno lokalnie (w repozytorium), jak i globalnie (w katalogu użytkownika). + +**_Uwaga_**: Jeśli dana opcja występuje zarówno w konfiguracji **globalnej**, jak i **lokalnej**, **pierwszeństwo ma konfiguracja lokalna**. diff --git a/docs/plugins/examples/js/infinite_counter.js b/docs/plugins/examples/js/infinite_counter.js new file mode 100644 index 0000000..234918b --- /dev/null +++ b/docs/plugins/examples/js/infinite_counter.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +/* +Description: +Example of Node.js plugin which demonstrates: +- printing messages to standard output (stdout) every 100 ms, +- timeout behaviour. + +Invocation: +On Linux / macOS: + node infinite_counter.js + ./infinite_counter.js +On Windows: + node infinite_counter.js + +Expected behavior: +- stdout: 0, 1, 2, 3, ... (printed continuously), +- exit status: plugin will be terminated by the plugin runner when timeout is reached. + +Example configuration entry (JSON): +{ + "name": "infinite_counter", + "description": "Demonstrates plugin runner timeout behavior", + "file": "infinite_counter.js", + "event": "pre-execute", + "order": 1, + "timeout": 500, + "enabled": true, + "interpreter": "node" +} +*/ + +let i = 0; + +function loop() { + console.log(i); + i++; + setTimeout(loop, 100); // 100 ms +} + +loop(); diff --git a/docs/plugins/examples/js/init_files.js b/docs/plugins/examples/js/init_files.js new file mode 100644 index 0000000..0ecf22e --- /dev/null +++ b/docs/plugins/examples/js/init_files.js @@ -0,0 +1,236 @@ +#!/usr/bin/env node +/* +Description: +Example of a Node.js plugin script which demonstrates: +- creating README.md, LICENSE (MIT), and .mevaignore files, +- handling missing fields in invocation input by writing an error object, +- prompting the user for author name, +- writing output or error messages in JSON to stdout/stderr. + +Invocation: +On Linux / macOS: + node init_files.js /path/to/invocation_input.json +On Windows: + node init_files.js /path/to/invocation_input.json + +Expected behavior: +- if 'post-payload.repository_dir' exists and directory is valid: + -> README.md, LICENSE, and .mevaignore are created if not already present, + -> JSON with {"created": [...]} or friendly message is printed to stdout. +- if required fields are missing: + -> JSON with {"error": {...}} is printed to stdout/stderr, + -> input JSON file is updated with 'error' field. + +Example configuration entry (JSON): +{ + "name": "init_files", + "description": "Creates README.md, LICENSE and .mevaignore in a newly initialized repository", + "file": "init_files.js", + "event": "post-execute", + "order": 1, + "timeout": null, + "enabled": true, + "interpreter": "node" +} +*/ + +const fs = require("fs"); +const path = require("path"); +const readline = require("readline"); + +function makeError(jsonPath, code, message, details = null) { + let payload = {}; + try { + const fileData = fs.readFileSync(jsonPath, "utf-8"); + payload = JSON.parse(fileData); + } catch { + // ignore JSON errors here + } + payload.error = { code, message, details }; + try { + fs.writeFileSync(jsonPath, JSON.stringify(payload, null, 2), "utf-8"); + } catch { + // ignore write errors here + } + console.error(JSON.stringify(payload.error)); + process.exit(1); +} + +function loadJson(jsonPath) { + try { + const data = fs.readFileSync(jsonPath, "utf-8"); + return JSON.parse(data); + } catch (err) { + makeError( + jsonPath, + "JSON_PARSE_ERROR", + "Failed to parse input JSON", + err.message + ); + } +} + +function safeWrite(filePath, content) { + if (fs.existsSync(filePath)) return false; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, "utf-8"); + return true; +} + +function prompt(question) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }) + ); +} + +async function main() { + const args = process.argv.slice(2); + const jsonPath = path.resolve(args[0]); + const payload = loadJson(jsonPath); + + const postPayload = payload["post-payload"]; + if (!postPayload || !postPayload.repository_dir) { + makeError( + jsonPath, + "MISSING_FIELD", + "post-payload.repository_dir is required" + ); + } + + const repoDir = path.resolve(postPayload.repository_dir); + if (!fs.existsSync(repoDir) || !fs.lstatSync(repoDir).isDirectory()) { + makeError( + jsonPath, + "DIR_NOT_FOUND", + `Repository directory does not exist: ${repoDir}` + ); + } + + const year = new Date().getFullYear(); + + console.log("Enter your full name for license copyright:"); + const author = await prompt("Full name: "); + if (!author) { + makeError( + jsonPath, + "MISSING_AUTHOR", + "Author name for LICENSE is required." + ); + } + + const readmeContent = `# ${path.basename(repoDir)} + +A concise description of the project purpose and features. + +## Table of Contents +- [Installation](#installation) +- [Usage](#usage) +- [Configuration](#configuration) +- [Development](#development) +- [Contributing](#contributing) +- [License](#license) + +## Installation +Steps to install the project locally: +1. Clone the repository. +2. Install dependencies. +3. Run initial setup if needed. + +## Usage +Examples of how to use this project: +\`\`\` +command-to-run --with options +\`\`\` + +## Configuration +List available configuration options and environment variables. + +## Development +Instructions for contributors and developers: +- Run tests: \`command-to-test\` +- Run linting: \`command-to-lint\` +- Build or compile: \`command-to-build\` + +## Contributing +To contribute: +1. Fork the repository. +2. Create a feature branch. +3. Make changes with clear commits. +4. Submit a pull request after testing. + +## License +This project is licensed under the MIT License. See the \`LICENSE\` file for details. +`; + + const mitLicense = `MIT License + +Copyright (c) ${year} ${author} + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +`; + + const mevaignoreContent = `# VS Code / Editor artifacts +.vscode/ +.idea/ +*.code-workspace +.env + +# Python cache and artifacts +__pycache__/ +.python-version + +# Build and distribution +dist/ +build/ + +# Dependency management +.venv/ +venv/ + +# Logs and temporary files +*.log +tmp/ +`; + + try { + const created = []; + if (safeWrite(path.join(repoDir, "README.md"), readmeContent)) + created.push("README.md"); + if (safeWrite(path.join(repoDir, "LICENSE"), mitLicense)) + created.push("LICENSE"); + if (safeWrite(path.join(repoDir, ".mevaignore"), mevaignoreContent)) + created.push(".mevaignore"); + + if (created.length) { + console.log("Files created:"); + created.forEach((f) => console.log(`- ${f}`)); + } else { + console.log("No files were created (all files already exist)."); + } + process.exit(0); + } catch (err) { + makeError(jsonPath, "WRITE_ERROR", "Error writing files", err.message); + } +} + +main(); diff --git a/docs/plugins/examples/js/nonzero_exit.js b/docs/plugins/examples/js/nonzero_exit.js new file mode 100644 index 0000000..a4d3ad1 --- /dev/null +++ b/docs/plugins/examples/js/nonzero_exit.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** +Description: +Example of Node.js plugin which demonstrates: +- printing a message to standard output (stdout), +- printing a message to standard error (stderr), +- exiting with a non-zero status code (3). + +Invocation: +On Linux / macOS: + node nonzero_exit.js + ./nonzero_exit.js +On Windows: + node nonzero_exit.js + +Expected behavior: +- stdout: "This is a message to stdout", +- stderr: "This is a message to stderr", +- exit status: 3. + +Example configuration entry (JSON): +{ + "name": "nonzero_exit", + "description": "Demonstrates exiting with a non-zero status code", + "file": "nonzero_exit.js", + "event": "pre-execute", + "order": 1, + "timeout": null, + "enabled": true, + "interpreter": "node" +} +*/ + +console.log("This is a message to stdout"); +console.error("This is a message to stderr"); + +process.exit(3); diff --git a/docs/plugins/examples/js/print_arg.js b/docs/plugins/examples/js/print_arg.js new file mode 100644 index 0000000..e4f5ca9 --- /dev/null +++ b/docs/plugins/examples/js/print_arg.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +/* +Description: +Example Node.js plugin which demonstrates: +- reading a file specified as the first argument in the console, +- printing its content to standard output (stdout), +- handling file-not-found or read errors by printing to standard error (stderr). + +Invocation: +On Linux / macOS: + node print_arg.js + ./print_arg.js +On Windows: + node print_arg.js + +Expected behavior: +- stdout: content of the specified file, +- stderr: error messages if the file does not exist or cannot be read, +- exit status: 0 on success, 1 on error. + +Example configuration entry (JSON): +{ + "name": "print_arg", + "description": null, + "file": "print_arg.js", + "event": "pre-execute", + "order": 1, + "timeout": null, + "enabled": true, + "interpreter": "node" +} + */ + +const fs = require("fs"); + +const args = process.argv.slice(2); +const filename = args[0]; + +try { + const content = fs.readFileSync(filename, { encoding: "utf8" }); + console.log(content); +} catch (err) { + if (err.code === "ENOENT") { + console.error(`Error: File '${filename}' not found.`); + } else { + console.error(`Error reading file '${filename}': ${err.message}`); + } + process.exit(1); +} diff --git a/docs/plugins/examples/js/write_error.js b/docs/plugins/examples/js/write_error.js new file mode 100644 index 0000000..7fe86f1 --- /dev/null +++ b/docs/plugins/examples/js/write_error.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/* +Description: +Example Node.js plugin that writes a business logic error +to the "error" field of a JSON file representing plugin execution results. + +Invocation: +On Linux / macOS: + node write_error.js + ./write_error.js +On Windows: + node write_error.js + +Expected behavior: +- The "error" field in the JSON file will be updated with a serialized InvocationError: + { + "code": "BUSINESS_LOGIC_ERROR", + "message": "A business rule was violated during plugin execution", + "details": "Optional additional context or stack trace" + }, +- exit status: 1 (indicating the plugin returned an error). + +Example configuration entry (JSON): +{ + "name": "write_error", + "description": null, + "file": "write_error.js", + "event": "pre-execute", + "order": 1, + "timeout": null, + "enabled": true, + "interpreter": "node" +} + */ + +const fs = require("fs"); +const path = require("path"); + +const args = process.argv.slice(2); +const filename = args[0]; + +const invocationError = { + code: "BUSINESS_LOGIC_ERROR", + message: "A business rule was violated during plugin execution", + details: "Optional additional context or stack trace", +}; + +try { + const rawData = fs.readFileSync(filename, "utf8"); + const data = JSON.parse(rawData); + + data.error = invocationError; + + fs.writeFileSync(filename, JSON.stringify(data, null, 2), "utf8"); + + console.log(`Updated error in ${filename}`); + process.exit(1); +} catch (err) { + if (err.code === "ENOENT") { + console.error(`Error: File '${filename}' not found.`); + } else if (err.name === "SyntaxError") { + console.error(`Error parsing JSON: ${err.message}`); + } else { + console.error(`Unexpected error: ${err.message}`); + } + process.exit(1); +} diff --git a/docs/plugins/examples/powershell/infinite_counter.ps1 b/docs/plugins/examples/powershell/infinite_counter.ps1 new file mode 100644 index 0000000..bc4a8db --- /dev/null +++ b/docs/plugins/examples/powershell/infinite_counter.ps1 @@ -0,0 +1,33 @@ +<# +Description: +Example of PowerShell plugin which demonstrates: +- printing messages to standard output (stdout) every 100 ms, +- timeout behaviour. + +Invocation: +On Windows: + powershell infinite_counter.ps1 + +Expected behavior: +- stdout: 0, 1, 2, 3, ... (printed continuously), +- exit status: plugin will be terminated by the plugin runner when timeout is reached. + +Example configuration entry (JSON): +{ + "name": "infinite_counter", + "description": "Demonstrates plugin runner timeout behavior", + "file": "infinite_counter.ps1", + "event": "pre-execute", + "order": 1, + "timeout": 500, + "enabled": true, + "interpreter": "powershell" +} +#> + +$i = 0 +while ($true) { + Write-Output $i + $i++ + Start-Sleep -Milliseconds 100 +} \ No newline at end of file diff --git a/docs/plugins/examples/powershell/nonzero_exit.ps1 b/docs/plugins/examples/powershell/nonzero_exit.ps1 new file mode 100644 index 0000000..ca73a86 --- /dev/null +++ b/docs/plugins/examples/powershell/nonzero_exit.ps1 @@ -0,0 +1,33 @@ +<# +Description: +Example of PowerShell plugin which demonstrates: +- printing a message to standard output (stdout), +- printing a message to standard error (stderr), +- exiting with a non-zero status code (3). + +Invocation: +On Windows: + powershell nonzero_exit.ps1 + +Expected behavior: +- stdout: "This is a message to stdout", +- stderr: "This is a message to stderr", +- exit status: 3. + +Example configuration entry (JSON): +{ + "name": "nonzero_exit", + "description": "Demonstrates exiting with a non-zero status code", + "file": "nonzero_exit.ps1", + "event": "pre-execute", + "order": 1, + "timeout": null, + "enabled": true, + "interpreter": "powershell" +} +#> + +Write-Output "This is a message to stdout" +Write-Error "This is a message to stderr" + +$host.SetShouldExit(3) \ No newline at end of file diff --git a/docs/plugins/examples/powershell/print_arg.ps1 b/docs/plugins/examples/powershell/print_arg.ps1 new file mode 100644 index 0000000..c45befa --- /dev/null +++ b/docs/plugins/examples/powershell/print_arg.ps1 @@ -0,0 +1,45 @@ +<# +Description: +Example Powershell plugin which demonstrates: +- reading a file specified as the first argument in the console, +- printing its content to standard output (stdout), +- handling file-not-found or read errors by printing to standard error (stderr). + +Invocation: +On Windows: + powershell print_arg.ps1 + +Expected behavior: +- stdout: content of the specified file, +- stderr: error messages if the file does not exist or cannot be read, +- exit status: 0 on success, 1 on error. + +Example configuration entry (JSON): +{ + "name": "print_arg", + "description": null, + "file": "print_arg.ps1", + "event": "pre-execute", + "order": 1, + "timeout": null, + "enabled": true, + "interpreter": "powershell" +} +#> + +param ( + [Parameter(Mandatory = $true)] + [string]$filename +) + +try { + Get-Content -Path $filename -ErrorAction Stop | ForEach-Object { + Write-Output $_ + } +} +catch { + Write-Error "Error reading file '$filename': $_" + $host.SetShouldExit(1) +} + +$host.SetShouldExit(0) \ No newline at end of file diff --git a/docs/plugins/examples/python/infinite_counter.py b/docs/plugins/examples/python/infinite_counter.py new file mode 100644 index 0000000..8dcea94 --- /dev/null +++ b/docs/plugins/examples/python/infinite_counter.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +""" +Description: +Example of Python plugin which demonstrates: +- printing messages to standard output (stdout) every 100 ms, +- timeout behaviour. + +Invocation: +On Linux / macOS: + python3 infinite_counter.py + ./infinite_counter.py +On Windows: + python infinite_counter.py + +Expected behavior: +- stdout: 0, 1, 2, 3, ... (printed continuously), +- exit status: plugin will be terminated by the plugin runner when timeout is reached. + +Example configuration entry (JSON): +{ + "name": "infinite_counter", + "description": "Demonstrates plugin runner timeout behavior", + "file": "infinite_counter.py", + "event": "pre-execute", + "order": 1, + "timeout": 500, + "enabled": true, + "interpreter": "python" +} +""" + +import time + +i = 0 +while True: + print(i, flush=True) + i += 1 + time.sleep(0.1) # 100 ms diff --git a/docs/plugins/examples/python/init_files.py b/docs/plugins/examples/python/init_files.py new file mode 100644 index 0000000..6481eb9 --- /dev/null +++ b/docs/plugins/examples/python/init_files.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +Description: +Example of Python plugin which demonstrates: +- creating README.md, LICENSE (MIT) and .mevaignore files, +- handling missing fields in InvocationInput by writing an error object. + +Invocation: +On Linux / macOS: + python3 init_files.py /path/to/invocation_input.json + ./init_files.py /path/to/invocation_input.json +On Windows: + python init_files.py /path/to/invocation_input.json + +Expected behavior: +- if 'post-payload.repository_dir' exists and directory is valid: + -> README.md, LICENSE and .mevaignore are created if not already present, + -> JSON with {"created": [...]} is printed to stdout. +- if required fields are missing: + -> JSON with {"error": {...}} is printed to stdout, + -> input JSON file is updated with 'error' field. + +Example configuration entry (JSON): +{ + "name": "init_files", + "description": "Creates README.md, LICENSE and .mevaignore in a newly initialized repository", + "file": "init_files.py", + "event": "post-execute", + "order": 1, + "timeout": null, + "enabled": true, + "interpreter": "python" +} +""" + +import sys +import json +from pathlib import Path +from datetime import datetime +from typing import Any, Dict, Optional, List + +def make_error(json_path: Path, code: str, message: str, details: Optional[str] = None): + try: + payload = json.loads(json_path.read_text(encoding="utf-8")) + except Exception: + payload = {} + payload["error"] = { + "code": code, + "message": message, + "details": details, + } + json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + sys.exit(1) + +def load_json(p: Path) -> Dict[str, Any]: + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception as e: + make_error(p, "JSON_PARSE_ERROR", "Failed to parse input JSON", str(e)) + +def safe_write(path: Path, content: str) -> bool: + if path.exists(): + return False + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return True + +def main(): + json_path = Path(sys.argv[1]) + payload = load_json(json_path) + + post = payload.get("post-payload") + if not post or "repository_dir" not in post: + make_error(json_path, "MISSING_FIELD", "post-payload.repository_dir is required") + + repo_dir = Path(post["repository_dir"]).resolve() + if not repo_dir.exists(): + make_error(json_path, "DIR_NOT_FOUND", f"Repository directory does not exist: {repo_dir}") + + year = datetime.now().year + + print("Enter your full name for license copyright:", flush=True) + author = input().strip() + if not author: + make_error(json_path, "MISSING_AUTHOR", "Author name for LICENSE is required.") + + readme_content = ( + "# " + repo_dir.name + "\n\n" + "A concise description of the project purpose and features.\n\n" + "## Table of Contents\n" + "- [Installation](#installation)\n" + "- [Usage](#usage)\n" + "- [Configuration](#configuration)\n" + "- [Development](#development)\n" + "- [Contributing](#contributing)\n" + "- [License](#license)\n\n" + "## Installation\n" + "Steps to install the project locally:\n" + "1. Clone the repository.\n" + "2. Install dependencies.\n" + "3. Run initial setup if needed.\n\n" + "## Usage\n" + "Examples of how to use this project:\n" + "```\n" + "command-to-run --with options\n" + "```\n\n" + "## Configuration\n" + "List available configuration options and environment variables.\n\n" + "## Development\n" + "Instructions for contributors and developers:\n" + "- Run tests: `command-to-test`\n" + "- Run linting: `command-to-lint`\n" + "- Build or compile: `command-to-build`\n\n" + "## Contributing\n" + "To contribute:\n" + "1. Fork the repository.\n" + "2. Create a feature branch.\n" + "3. Make changes with clear commits.\n" + "4. Submit a pull request after testing.\n\n" + "## License\n" + "This project is licensed under the MIT License. See the `LICENSE` file for details.\n" + ) + + mit_license = ( + "MIT License\n\n" + f"Copyright (c) {year} {author}\n\n" + "Permission is hereby granted, free of charge, to any person obtaining a copy\n" + "of this software and associated documentation files (the \"Software\"), to deal\n" + "in the Software without restriction, including without limitation the rights\n" + "to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" + "copies of the Software, and to permit persons to whom the Software is\n" + "furnished to do so, subject to the following conditions:\n\n" + "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" + "IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" + "FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" + "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" + "LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" + "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n" + "SOFTWARE.\n" + ) + + mevaignore_content = ( + "# VS Code / Editor artifacts\n" + ".vscode/\n" + ".idea/\n" + "*.code-workspace\n" + ".env\n\n" + "# Python cache and artifacts\n" + "__pycache__/\n" + ".python-version\n\n" + "# Build and distribution\n" + "dist/\n" + "build/\n\n" + "# Dependency management\n" + ".venv/\n" + "venv/\n\n" + "# Logs and temporary files\n" + "*.log\n" + "tmp/\n" + ) + + try: + created: List[str] = [] + if safe_write(repo_dir / "README.md", readme_content): + created.append("README.md") + if safe_write(repo_dir / "LICENSE", mit_license): + created.append("LICENSE") + if safe_write(repo_dir / ".mevaignore", mevaignore_content): + created.append(".mevaignore") + + if created: + print("Files created:", flush=True) + for file in created: + print(f"- {file}", flush=True) + else: + print("No files were created (all files already exist).", flush=True) + sys.exit(0) + + except Exception as e: + make_error(json_path, "WRITE_ERROR", "Error writing files", str(e)) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/docs/plugins/examples/python/nonzero_exit.py b/docs/plugins/examples/python/nonzero_exit.py new file mode 100644 index 0000000..39b5cb2 --- /dev/null +++ b/docs/plugins/examples/python/nonzero_exit.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +""" +Description: +Example of Node.js plugin which demonstrates: +- printing a message to standard output (stdout), +- printing a message to standard error (stderr), +- exiting with a non-zero status code (3). + +Invocation: +On Linux / macOS: + python3 nonzero_exit.py + ./nonzero_exit.py +On Windows: + python nonzero_exit.py + +Expected behavior: +- stdout: "This is a message to stdout", +- stderr: "This is a message to stderr", +- exit status: 3. + +Example configuration entry (JSON): +{ + "name": "nonzero_exit", + "description": "Demonstrates exiting with a non-zero status code", + "file": "nonzero_exit.py", + "event": "pre-execute", + "order": 1, + "timeout": null, + "enabled": true, + "interpreter": "python" +} +""" + +import sys + +print("This is a message to stdout", flush=True) +print("This is a message to stderr", file=sys.stderr, flush=True) + +sys.exit(3) \ No newline at end of file diff --git a/docs/plugins/examples/python/print_arg.py b/docs/plugins/examples/python/print_arg.py new file mode 100644 index 0000000..5f4c4e0 --- /dev/null +++ b/docs/plugins/examples/python/print_arg.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +""" +Description: +Example Python plugin which demonstrates: +- reading a file specified as the first argument in the console, +- printing its content to standard output (stdout), +- handling file-not-found or read errors by printing to standard error (stderr). + +Invocation: +On Linux / macOS: + python3 print_arg.py + ./print_arg.py +On Windows: + python print_arg.py + +Expected behavior: +- stdout: content of the specified file, +- stderr: error messages if the file does not exist or cannot be read, +- exit status: 0 on success, 1 on error. + +Example configuration entry (JSON): +{ + "name": "print_arg", + "description": null, + "file": "print_arg.py", + "event": "pre-execute", + "order": 1, + "timeout": null, + "enabled": true, + "interpreter": "python" +} +""" + +import sys + +filename = sys.argv[1] + +try: + with open(filename, "r", encoding="utf-8") as f: + for line in f: + print(line, end="", flush=True) + print(flush=True) +except FileNotFoundError: + print(f"Error: File '{filename}' not found.", file=sys.stderr, flush=True) + sys.exit(1) +except Exception as e: + print(f"Error reading file '{filename}': {e}", file=sys.stderr, flush=True) + sys.exit(1) \ No newline at end of file diff --git a/docs/plugins/examples/python/write_error.py b/docs/plugins/examples/python/write_error.py new file mode 100644 index 0000000..8a764b7 --- /dev/null +++ b/docs/plugins/examples/python/write_error.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" +Description: +Example Python plugin that writes a business logic error +to the "error" field of a JSON file representing plugin execution results. + +Invocation: +On Linux / macOS: + python3 write_error.py + ./write_error.py +On Windows: + python write_error.py + +Expected behavior: +- The "error" field in the JSON file will be updated with a serialized InvocationError: + { + "code": "BUSINESS_LOGIC_ERROR", + "message": "A business rule was violated during plugin execution", + "details": "Optional additional context or stack trace" + }, +- exit status: 1 (indicating the plugin returned an error). + +Example configuration entry (JSON): +{ + "name": "write_error", + "description": null, + "file": "write_error.py", + "event": "pre-execute", + "order": 1, + "timeout": null, + "enabled": true, + "interpreter": "python" +} +""" + +import sys +import json + +filename = sys.argv[1] + +invocation_error = { + "code": "BUSINESS_LOGIC_ERROR", + "message": "A business rule was violated during plugin execution", + "details": "Optional additional context or stack trace" +} + +try: + with open(filename, "r", encoding="utf-8") as f: + data = json.load(f) + + data["error"] = invocation_error + + with open(filename, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + + print(f"Updated error in {filename}", flush=True) + sys.exit(1) + +except FileNotFoundError: + print(f"Error: File '{filename}' not found.", file=sys.stderr, flush=True) + sys.exit(1) +except json.JSONDecodeError as e: + print(f"Error parsing JSON: {e}", file=sys.stderr, flush=True) + sys.exit(1) +except Exception as e: + print(f"Unexpected error: {e}", file=sys.stderr, flush=True) + sys.exit(1) diff --git a/engine/src/config/config_loader.rs b/engine/src/config/config_loader.rs index 6aa14b6..e645b82 100644 --- a/engine/src/config/config_loader.rs +++ b/engine/src/config/config_loader.rs @@ -163,7 +163,6 @@ impl ConfigLoader { "[plugins]\n", "enabled = false\n", "collect_logs = false\n", - "timeout_ms = 500\n", "\n" ); @@ -186,7 +185,6 @@ impl ConfigLoader { "[plugins]\n", "enabled = false\n", "collect_logs = false\n", - "timeout_ms = 500\n", "\n" ); diff --git a/engine/src/plugins/handler.rs b/engine/src/plugins/handler.rs index a745be9..ec34655 100644 --- a/engine/src/plugins/handler.rs +++ b/engine/src/plugins/handler.rs @@ -51,15 +51,16 @@ impl PluginsOperations for PluginsHandler { fn register(&self, request: RegisterRequest) -> EngineResult { let plugins_dir = self.get_plugins_dir(&request.scope)?; - let plugin_configuration = PluginConfiguration::new( - request.name, - request.description, - request.file, - request.event, - request.order, - request.enabled, - request.interpreter, - ); + let plugin_configuration = PluginConfiguration { + name: request.name, + description: request.description, + file: request.file, + event: request.event, + order: request.order, + timeout: request.timeout, + enabled: request.enabled, + interpreter: request.interpreter, + }; let (plugins_metadata_file, plugin_source_file) = self.plugins_repository.register( plugin_configuration, diff --git a/engine/src/plugins/operations.rs b/engine/src/plugins/operations.rs index 61c99ad..5abfcfd 100644 --- a/engine/src/plugins/operations.rs +++ b/engine/src/plugins/operations.rs @@ -16,6 +16,7 @@ pub struct RegisterRequest { pub command: CommandType, pub event: EventType, pub order: u32, + pub timeout: Option, pub enabled: bool, pub interpreter: Option, } diff --git a/engine/src/plugins_interceptor.rs b/engine/src/plugins_interceptor.rs index 123c7c1..c2a342a 100644 --- a/engine/src/plugins_interceptor.rs +++ b/engine/src/plugins_interceptor.rs @@ -132,13 +132,10 @@ impl PluginsInterceptor { .plugins_engine .create_invocation_file(&invocation_input, &invocation_dir)?; - let timeout = self.get_timeout_ms()?; - let mut runner = PluginsRunner::new( invocation_file.clone(), plugins_dir.join(command.to_string()).clone(), logger, - timeout, ); let pre_outputs = runner.run_plugins_for_event( @@ -147,10 +144,8 @@ impl PluginsInterceptor { &EventType::PreExecute, )?; - let json = fs::read_to_string(&invocation_file)?; - let mut invocation_input = InvocationInput::from_json(&json)?; - - self.handle_last_plugin_result(pre_outputs.last(), &invocation_input)?; + let mut invocation_input = + self.handle_last_plugin_result(pre_outputs.last(), &invocation_file)?; let response = exec(mapper.input_to_request(&invocation_input)?)?; @@ -169,10 +164,8 @@ impl PluginsInterceptor { &EventType::PostExecute, )?; - self.handle_last_plugin_result(post_outputs.last(), &invocation_input)?; - - let json = fs::read_to_string(&invocation_file)?; - let invocation_input = InvocationInput::from_json(&json)?; + let invocation_input = + self.handle_last_plugin_result(post_outputs.last(), &invocation_file)?; mapper.input_to_response(&invocation_input) } @@ -200,11 +193,6 @@ impl PluginsInterceptor { self.config_loader.get_parsed("plugins.enabled", false) } - /// Reads the maximum allowed execution time for plugins (in milliseconds). - fn get_timeout_ms(&self) -> EngineResult { - self.config_loader.get_parsed("plugins.timeout_ms", 500u64) - } - /// Reads from configuration whether plugin logs should be collected. fn get_collect_logs(&self) -> EngineResult { self.config_loader.get_parsed("plugins.collect_logs", false) @@ -215,8 +203,11 @@ impl PluginsInterceptor { fn handle_last_plugin_result( &self, last: Option<&InvocationOutput>, - invocation_input: &InvocationInput, - ) -> EngineResult<()> { + invocation_file: &PathBuf, + ) -> EngineResult { + let json = fs::read_to_string(invocation_file)?; + let invocation_input = InvocationInput::from_json(&json)?; + if let Some(last) = last && last.is_error() { @@ -228,13 +219,16 @@ impl PluginsInterceptor { } let unknown_msg = match &invocation_input.error { - Some(err) => serde_json::to_string(err).unwrap_or_else(|_| format!("{err:?}")), - None => format!("plugin exited with code {}", last.status_code), + Some(err) => format!("{err}"), + None => format!("Exited with code {}", last.status_code), }; - return Err(EngineError::Plugins(PluginError::Unknown(unknown_msg))); + return Err(EngineError::Plugins(PluginError::Unknown { + plugin: last.plugin.clone(), + message: unknown_msg, + })); } - Ok(()) + Ok(invocation_input) } } diff --git a/plugins/src/errors/invocation_error.rs b/plugins/src/errors/invocation_error.rs index 44cb661..6835b04 100644 --- a/plugins/src/errors/invocation_error.rs +++ b/plugins/src/errors/invocation_error.rs @@ -1,4 +1,7 @@ +use std::fmt::{Display, Formatter, Result}; + use serde::{Deserialize, Serialize}; +use shared::PrettyField; /// Represents an error produced during plugin invocation. #[derive(Debug, Serialize, Deserialize)] @@ -10,3 +13,19 @@ pub struct InvocationError { /// Optional additional details (e.g., stack traces). pub details: Option, } + +/// Implement Display to allow printing nicely. +impl Display for InvocationError { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let indent = 2; + let width = 10; + + f.field(indent, "Code", &self.code, width)?; + f.field(indent, "Message", &self.message, width)?; + if let Some(details) = &self.details { + f.field(indent, "Details", details, width)?; + } + + Ok(()) + } +} diff --git a/plugins/src/errors/plugin_error.rs b/plugins/src/errors/plugin_error.rs index 828713d..bdd21c2 100644 --- a/plugins/src/errors/plugin_error.rs +++ b/plugins/src/errors/plugin_error.rs @@ -57,6 +57,6 @@ pub enum PluginError { Timeout { plugin: String, duration_ms: u128 }, /// A catch-all for unexpected errors. - #[error("Unknown Plugin error: {0}")] - Unknown(String), + #[error("Unknown plugin error in '{plugin}':\n{message}")] + Unknown { plugin: String, message: String }, } diff --git a/plugins/src/models/invocation/invocation_logger.rs b/plugins/src/models/invocation/invocation_logger.rs index 8643e9b..fccd216 100644 --- a/plugins/src/models/invocation/invocation_logger.rs +++ b/plugins/src/models/invocation/invocation_logger.rs @@ -65,7 +65,6 @@ impl InvocationLogger { /// Logs a full stdout content (used when you collect stdout in a thread). pub fn log_stdout(&mut self, content: &str) -> PluginResult<()> { - print!("{content}"); if let InvocationLogger::ToFiles { stdout_log, .. } = self { stdout_log.write_all(content.as_bytes())?; stdout_log.flush()?; @@ -75,7 +74,6 @@ impl InvocationLogger { /// Logs a full stderr content (used when you collect stderr in a thread). pub fn log_stderr(&mut self, content: &str) -> PluginResult<()> { - eprint!("{content}"); if let InvocationLogger::ToFiles { stderr_log, .. } = self { stderr_log.write_all(content.as_bytes())?; stderr_log.flush()?; diff --git a/plugins/src/models/plugin_configuration.rs b/plugins/src/models/plugin_configuration.rs index ab12f16..2a63a04 100644 --- a/plugins/src/models/plugin_configuration.rs +++ b/plugins/src/models/plugin_configuration.rs @@ -26,6 +26,9 @@ pub struct PluginConfiguration { /// Execution order of the plugin relative to other plugins for the same event. pub order: u32, + /// Maximum execution time allowed for the plugin in milliseconds before it is terminated. + pub timeout: Option, + /// Whether the plugin is enabled or disabled. pub enabled: bool, @@ -33,29 +36,6 @@ pub struct PluginConfiguration { pub interpreter: Option, } -impl PluginConfiguration { - // Creates a new `PluginConfiguration` with the provided values. - pub fn new( - name: String, - description: Option, - file: PathBuf, - event: EventType, - order: u32, - enabled: bool, - interpreter: Option, - ) -> Self { - Self { - name, - description, - file, - event, - order, - enabled, - interpreter, - } - } -} - impl Display for PluginConfiguration { fn fmt(&self, f: &mut Formatter<'_>) -> Result { let labels = [ @@ -63,6 +43,7 @@ impl Display for PluginConfiguration { "File", "Event", "Order", + "Timeout", "Enabled", "Interpreter", ]; @@ -79,11 +60,19 @@ impl Display for PluginConfiguration { f.field( indent, labels[4], + self.timeout + .map(|t| format!("{t} ms")) + .unwrap_or_else(|| "-".to_string()), + max_len, + )?; + f.field( + indent, + labels[5], if self.enabled { "yes" } else { "no" }, max_len, )?; if let Some(interp) = &self.interpreter { - f.field(indent, labels[5], interp, max_len)?; + f.field(indent, labels[6], interp, max_len)?; } Ok(()) } diff --git a/plugins/src/plugins_runner.rs b/plugins/src/plugins_runner.rs index 7bee4e2..ea89540 100644 --- a/plugins/src/plugins_runner.rs +++ b/plugins/src/plugins_runner.rs @@ -2,7 +2,7 @@ use std::{ io::{self, BufRead, BufReader, Read, Write}, path::{Path, PathBuf}, process::{Command, Stdio}, - thread, + thread::{self, JoinHandle}, time::{Duration, Instant}, }; @@ -28,24 +28,15 @@ pub struct PluginsRunner { /// Logger used to capture stdout, stderr, and invocation metadata. logger: InvocationLogger, - - /// Timeout which prevents plugins from running for too long. - timeout: u64, } impl PluginsRunner { /// Creates a new `PluginsRunner` instance. - pub fn new( - invocation_file: PathBuf, - command_dir: PathBuf, - logger: InvocationLogger, - timeout: u64, - ) -> Self { + pub fn new(invocation_file: PathBuf, command_dir: PathBuf, logger: InvocationLogger) -> Self { Self { invocation_file, command_dir, logger, - timeout, } } @@ -73,20 +64,7 @@ impl PluginsRunner { .take() .map(|err| self.spawn_stream_reader(err, true)); - let timeout = self.get_timeout_duration(); - let timed_out; - - let status_code = match child.wait_timeout(timeout)? { - Some(status) => { - timed_out = false; - status.code().unwrap_or(-1) - } - None => { - timed_out = true; - let _ = child.kill(); - child.wait()?.code().unwrap_or(-1) - } - }; + let (status_code, timed_out) = self.wait_for_status(child, plugin.timeout)?; let duration_ms = start.elapsed().as_millis(); @@ -169,7 +147,7 @@ impl PluginsRunner { /// Spawns a thread that reads from `stream` line-by-line, prints each line /// immediately to stdout or stderr (controlled by `to_stderr`) and collects /// the whole output into a `String` returned by the JoinHandle. - fn spawn_stream_reader(&self, stream: R, to_stderr: bool) -> thread::JoinHandle + fn spawn_stream_reader(&self, stream: R, to_stderr: bool) -> JoinHandle where R: Read + Send + 'static, { @@ -203,8 +181,28 @@ impl PluginsRunner { }) } - /// Returns the timeout duration for plugin execution. - fn get_timeout_duration(&self) -> Duration { - Duration::from_millis(self.timeout) + /// Waits for the plugin process to complete, respecting the optional timeout. + /// Returns (status_code, timed_out). + fn wait_for_status( + &self, + mut child: std::process::Child, + timeout: Option, + ) -> PluginResult<(i32, bool)> { + let failure_status = -1; + let mut timed_out = false; + + let status_code = match timeout { + Some(ms) => match child.wait_timeout(Duration::from_millis(ms))? { + Some(status) => status.code().unwrap_or(failure_status), + None => { + timed_out = true; + let _ = child.kill(); + child.wait()?.code().unwrap_or(failure_status) + } + }, + None => child.wait()?.code().unwrap_or(failure_status), + }; + + Ok((status_code, timed_out)) } } From 7221611bf081318f6c861320c648dce94805b49e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Mon, 29 Sep 2025 20:55:44 +0100 Subject: [PATCH 07/42] Blob Storage (#7) --- Cargo.lock | 53 +++++++++ engine/Cargo.toml | 2 + engine/src/errors/engine_error.rs | 8 +- engine/src/hasher.rs | 2 + engine/src/hasher/hasher_trait.rs | 8 ++ engine/src/hasher/meva_hasher.rs | 20 ++++ engine/src/index.rs | 2 - engine/src/index/meva_hasher.rs | 33 ------ engine/src/index/meva_index.rs | 6 +- engine/src/lib.rs | 3 + engine/src/object_storage.rs | 6 + engine/src/object_storage/meva_object.rs | 73 ++++++++++++ .../src/object_storage/meva_object_storage.rs | 104 ++++++++++++++++++ engine/src/object_storage/meva_object_type.rs | 24 ++++ .../repositories/meva_repository_layout.rs | 3 + engine/src/repositories/repository_layout.rs | 12 +- 16 files changed, 317 insertions(+), 42 deletions(-) create mode 100644 engine/src/hasher.rs create mode 100644 engine/src/hasher/hasher_trait.rs create mode 100644 engine/src/hasher/meva_hasher.rs delete mode 100644 engine/src/index/meva_hasher.rs create mode 100644 engine/src/object_storage.rs create mode 100644 engine/src/object_storage/meva_object.rs create mode 100644 engine/src/object_storage/meva_object_storage.rs create mode 100644 engine/src/object_storage/meva_object_type.rs diff --git a/Cargo.lock b/Cargo.lock index a740a83..6656592 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,6 +115,26 @@ dependencies = [ "backtrace", ] +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bitflags" version = "2.9.4" @@ -261,6 +281,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -358,8 +387,10 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" name = "engine" version = "0.1.0" dependencies = [ + "bincode", "chrono", "dirs", + "flate2", "globset", "hex", "mockall", @@ -406,6 +437,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fragile" version = "2.0.1" @@ -1310,6 +1351,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "up_finder" version = "0.0.4" @@ -1326,6 +1373,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "wait-timeout" version = "0.1.5" diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 2612b32..46d904b 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -20,6 +20,8 @@ sha1 = "0.11.0-rc.0" hex = "0.4.3" chrono.workspace = true rayon = "1.11.0" +flate2 = "1.1.2" +bincode = "2.0.1" [dev-dependencies] rstest.workspace = true diff --git a/engine/src/errors/engine_error.rs b/engine/src/errors/engine_error.rs index 1a7593c..7b0ef57 100644 --- a/engine/src/errors/engine_error.rs +++ b/engine/src/errors/engine_error.rs @@ -1,7 +1,7 @@ +use bincode::error::EncodeError; +use plugins::PluginError; use std::io; use std::path::StripPrefixError; - -use plugins::PluginError; use thiserror::Error; use crate::errors::path_error::PathError; @@ -61,6 +61,10 @@ pub enum EngineError { #[error(transparent)] Serde(#[from] serde_json::Error), + /// Error automatically converted from [`EncodeError`] + #[error(transparent)] + Encode(#[from] EncodeError), + /// A catch-all variant for any unknown or unexpected engine error. /// Accepts a descriptive string message. #[error("Unknown engine error: {0}")] diff --git a/engine/src/hasher.rs b/engine/src/hasher.rs new file mode 100644 index 0000000..20a988b --- /dev/null +++ b/engine/src/hasher.rs @@ -0,0 +1,2 @@ +pub mod hasher_trait; +pub mod meva_hasher; diff --git a/engine/src/hasher/hasher_trait.rs b/engine/src/hasher/hasher_trait.rs new file mode 100644 index 0000000..c4eb6c6 --- /dev/null +++ b/engine/src/hasher/hasher_trait.rs @@ -0,0 +1,8 @@ +/// Collection of utility functions for computing cryptographic hashes +/// used in the Meva repository. +/// +/// Currently, this trait provides a single function for computing **SHA-1** +/// hashes of in-memory data. +pub trait HasherTrait: Send + Sync { + fn sha1(&self, data: &[u8]) -> String; +} diff --git a/engine/src/hasher/meva_hasher.rs b/engine/src/hasher/meva_hasher.rs new file mode 100644 index 0000000..b89f538 --- /dev/null +++ b/engine/src/hasher/meva_hasher.rs @@ -0,0 +1,20 @@ +use crate::hasher::hasher_trait::HasherTrait; +use sha1::{Digest, Sha1}; + +/// Default implementation of [`HasherTrait`] used within the Meva project. +/// +/// `MevaHasher` is stateless and provides only static-style methods. +pub struct MevaHasher; + +impl HasherTrait for MevaHasher { + /// Computes the SHA-1 hash of the given byte slice. + /// + /// The input data is provided as a slice of bytes (`&[u8]`), and the + /// function returns a hexadecimal string representation of the hash. + fn sha1(&self, data: &[u8]) -> String { + let mut hasher = Sha1::new(); + hasher.update(data); + let result = hasher.finalize(); + hex::encode(result) + } +} diff --git a/engine/src/index.rs b/engine/src/index.rs index 99fddba..a3e9903 100644 --- a/engine/src/index.rs +++ b/engine/src/index.rs @@ -1,5 +1,3 @@ -mod meva_hasher; - pub mod file_mode; pub mod index_entry; pub mod meva_index; diff --git a/engine/src/index/meva_hasher.rs b/engine/src/index/meva_hasher.rs deleted file mode 100644 index 34f08d3..0000000 --- a/engine/src/index/meva_hasher.rs +++ /dev/null @@ -1,33 +0,0 @@ -use std::fs::File; -use std::io::{BufReader, Read, Result}; -use std::path::Path; - -use sha1::{Digest, Sha1}; - -/// Utility struct for computing hashes used in the Meva repository. -/// -/// Currently supports SHA-1, which is used for file content hashing -pub struct MevaHasher; - -impl MevaHasher { - /// Computes the SHA-1 hash of a file at the given path. - /// - /// The file is read in chunks to avoid loading the entire file - /// into memory at once, making this efficient even for large files. - /// - /// # Errors - /// Returns an [`io::Error`] if the file cannot be opened or read. - pub fn sha1_file(path: &Path) -> Result { - let file = File::open(path)?; - let mut reader = BufReader::new(file); - let mut hasher = Sha1::new(); - - let mut buffer = Vec::new(); - reader.read_to_end(&mut buffer)?; - hasher.update(&buffer); - - let result = hasher.finalize(); - let sha1 = hex::encode(result); - Ok(sha1) - } -} diff --git a/engine/src/index/meva_index.rs b/engine/src/index/meva_index.rs index 837bd74..78cbe2c 100644 --- a/engine/src/index/meva_index.rs +++ b/engine/src/index/meva_index.rs @@ -11,8 +11,8 @@ use walkdir::WalkDir; use crate::errors::path_error::PathError; use crate::errors::{EngineError, EngineResult, IndexError}; use crate::index::index_entry::IndexEntry; -use crate::index::meva_hasher::MevaHasher; use crate::index::serde_utils::deserialize_entries_with_absolute_keys; +use crate::object_storage::meva_object_storage::ObjectStorage; use crate::{IgnoreOperations, IgnoreResult, IgnoreService, RepositoryLayout}; use shared::extensions::canonicalize_clean::CanonicalizeClean; @@ -155,11 +155,13 @@ impl<'a> MevaIndex<'a> { index_entries: &mut Vec, verbose: bool, ) -> EngineResult<()> { + let object_storage = ObjectStorage::new(self.repo_layout); + let result: Vec<_> = index_entries .par_iter() .map(|index_entry| { let mut updated_entry = index_entry.clone(); - updated_entry.sha1 = MevaHasher::sha1_file(index_entry.path.as_ref())?; + updated_entry.sha1 = object_storage.add_file(index_entry.path.as_ref())?; if verbose { println!("add: {}", updated_entry.path) } diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 4d38533..7ecd1b9 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,7 +1,10 @@ +mod object_storage; + pub mod add; pub mod config; pub mod engine_container; pub mod errors; +pub mod hasher; pub mod ignore; pub mod index; pub mod init; diff --git a/engine/src/object_storage.rs b/engine/src/object_storage.rs new file mode 100644 index 0000000..e35e857 --- /dev/null +++ b/engine/src/object_storage.rs @@ -0,0 +1,6 @@ +mod meva_object; +mod meva_object_type; + +pub mod meva_object_storage; + +pub use crate::hasher::{hasher_trait::HasherTrait, meva_hasher::MevaHasher}; diff --git a/engine/src/object_storage/meva_object.rs b/engine/src/object_storage/meva_object.rs new file mode 100644 index 0000000..af90956 --- /dev/null +++ b/engine/src/object_storage/meva_object.rs @@ -0,0 +1,73 @@ +use crate::errors::EngineResult; +use crate::object_storage::HasherTrait; +use crate::object_storage::meva_object_type::MevaObjectType; +use bincode::Encode; +use flate2::{Compression, write::ZlibEncoder}; +use std::io::Write; + +/// Represents a stored object in the Meva repository. +/// +/// A `MevaObject` bundles an object type together with its raw binary data. +/// It can be serialized into a binary form, hashed, or compressed for +/// efficient storage and transmission. +/// +/// Objects are encoded using [`bincode`] with fixed integer encoding +/// and little-endian byte order. +#[derive(Encode)] +pub struct MevaObject { + /// The logical type of this object (e.g., blob, tree, commit). + pub object_type: MevaObjectType, + + /// Raw binary payload of the object. + pub data: Vec, +} + +impl MevaObject { + /// Serializes the object into a binary representation using [`bincode`]. + /// + /// Uses fixed integer encoding and little-endian byte order to ensure + /// consistent hashing and cross-platform compatibility. + /// + /// # Errors + /// + /// Returns an [`EngineError`] if serialization fails. + fn to_bytes(&self) -> EngineResult> { + let configuration = bincode::config::standard() + .with_fixed_int_encoding() + .with_little_endian(); + let bytes = bincode::encode_to_vec(self, configuration)?; + Ok(bytes) + } + + /// Computes the SHA-1 hash of the serialized object using a provided hasher. + /// + /// The object is first serialized into a binary representation via [`Self::to_bytes`]. + /// The resulting bytes are then passed to the given `hasher` to compute the SHA-1 hash. + /// + /// # Parameters + /// + /// * `hasher` - A reference to an object implementing [`HasherTrait`] used to perform + /// the SHA-1 hashing. This allows for custom or mock hashers in testing. + /// + /// # Errors + /// + /// Returns an [`EngineError`] if serialization of the object fails. + pub fn sha1(&self, hasher: &dyn HasherTrait) -> EngineResult { + let h = self.to_bytes()?; + Ok(hasher.sha1(&h)) + } + + /// Returns a zlib-compressed version of the serialized object. + /// + /// Compression is performed using the best available zlib compression level. + /// + /// # Errors + /// + /// Returns an [`EngineError`] if serialization or compression fails. + pub fn compressed(&self) -> EngineResult> { + let bytes = self.to_bytes()?; + let mut encoder = ZlibEncoder::new(Vec::with_capacity(bytes.len()), Compression::best()); + encoder.write_all(&bytes)?; + Ok(encoder.finish()?) + } +} diff --git a/engine/src/object_storage/meva_object_storage.rs b/engine/src/object_storage/meva_object_storage.rs new file mode 100644 index 0000000..b5d210d --- /dev/null +++ b/engine/src/object_storage/meva_object_storage.rs @@ -0,0 +1,104 @@ +use crate::RepositoryLayout; +use crate::errors::EngineResult; +use crate::errors::path_error::PathError; +use crate::object_storage::MevaHasher; +use crate::object_storage::meva_object::MevaObject; +use crate::object_storage::meva_object_type::MevaObjectType; +use std::fs::OpenOptions; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +/// Provides low-level object storage for the Meva repository. +/// +/// `ObjectStorage` is responsible for writing and retrieving +/// [`MevaObject`]s in the meva `objects` directory managed by a +/// [`RepositoryLayout`]. +/// +/// Each object is stored under a subdirectory named after the first two +/// characters of its SHA-1 hash, following a layout: +/// +/// ```text +/// objects/aa/bb... (where `aa` are the first two hex chars of the hash) +/// ``` +/// +/// Objects are compressed using zlib for efficient storage. +pub struct ObjectStorage<'a> { + /// Provides access to repository paths such as the `objects` directory. + repo_layout: &'a dyn RepositoryLayout, +} +impl<'a> ObjectStorage<'a> { + /// Creates a new [`ObjectStorage`] instance for the given repository layout. + /// + /// # Arguments + /// + /// * `repo_layout` – A [`RepositoryLayout`] that defines where objects + /// should be stored on disk. + pub fn new(repo_layout: &'a dyn RepositoryLayout) -> Self { + Self { repo_layout } + } + + /// Adds a file as a blob object to the repository and returns its SHA-1 hash. + /// + /// The file is read in full, wrapped in a [`MevaObject`] of type + /// [`MevaObjectType::Blob`], serialized, compressed, and stored under + /// the repository’s `objects` directory. + /// + /// If an object with the same hash already exists, it is not overwritten. + /// + /// # Arguments + /// + /// * `path` – Path to the file to be added. + /// + /// # Returns + /// + /// A `String` containing the hexadecimal SHA-1 hash of the stored object. + /// + /// # Errors + /// + /// Returns an [`EngineResult::Err`] if: + /// - the file cannot be opened or read, + /// - serialization or compression fails, + /// - the target directory cannot be created, + /// - or the object file cannot be created. + pub fn add_file(&self, path: &Path) -> EngineResult { + let mut file = OpenOptions::new().read(true).open(path)?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer)?; + + let object = MevaObject { + object_type: MevaObjectType::Blob, + data: buffer, + }; + + let hash = object.sha1(&MevaHasher {})?; + + let object_path = self.object_path(&hash); + + if !object_path.exists() { + let parent = object_path.parent().ok_or(PathError::NoParent { + path: object_path.clone(), + })?; + if !parent.exists() { + std::fs::create_dir_all(parent)?; + } + let compressed_data = object.compressed()?; + + let mut blob_file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&object_path)?; + blob_file.write_all(&compressed_data)?; + } + + Ok(hash) + } + + /// Returns the full on-disk path where an object with the given hash is stored. + /// + /// The path is constructed by splitting the first two characters of + /// the hash into a subdirectory + fn object_path(&self, hash: &str) -> PathBuf { + let (directory, file) = hash.split_at(2); + self.repo_layout.objects_dir().join(directory).join(file) + } +} diff --git a/engine/src/object_storage/meva_object_type.rs b/engine/src/object_storage/meva_object_type.rs new file mode 100644 index 0000000..d0b7f04 --- /dev/null +++ b/engine/src/object_storage/meva_object_type.rs @@ -0,0 +1,24 @@ +use bincode::Encode; + +/// Represents the logical type of object stored in the Meva repository. +/// +/// A `MevaObjectType` identifies what kind of content a [`MevaObject`] +/// contains. This is used during serialization, hashing, and object interpretation. +/// +/// # Encoding +/// +/// The enum is [`bincode::Encode`]-able, ensuring a stable binary format +/// for hashing and storage. +#[allow(dead_code)] +#[derive(Encode)] +pub enum MevaObjectType { + /// A raw data blob (e.g., file contents). + Blob, + + /// A tree object, representing a directory and its children. + Tree, + + /// A commit object, storing commit metadata such as author, message, + /// and references to parent commits. + Commit, +} diff --git a/engine/src/repositories/meva_repository_layout.rs b/engine/src/repositories/meva_repository_layout.rs index 19997c5..3a94c57 100644 --- a/engine/src/repositories/meva_repository_layout.rs +++ b/engine/src/repositories/meva_repository_layout.rs @@ -1,5 +1,6 @@ use std::{env, path::PathBuf}; +use crate::repositories::repository_layout::MutableRepositoryLayout; use crate::{ RepositoryLayout, errors::{EngineResult, InitError}, @@ -72,7 +73,9 @@ impl RepositoryLayout for MevaRepositoryLayout { fn working_dir(&self) -> &std::path::Path { &self.working_dir } +} +impl MutableRepositoryLayout for MevaRepositoryLayout { fn set_working_dir(&mut self, new_working_dir: PathBuf) { self.working_dir = new_working_dir; } diff --git a/engine/src/repositories/repository_layout.rs b/engine/src/repositories/repository_layout.rs index 70115df..114fcfc 100644 --- a/engine/src/repositories/repository_layout.rs +++ b/engine/src/repositories/repository_layout.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; /// Provides methods to compute paths to internal repository components /// i.e. absolute and relative to a working directory. #[allow(dead_code)] -pub trait RepositoryLayout { +pub trait RepositoryLayout: Send + Sync { /// Name of the root repository directory. fn repository_dir_name(&self) -> &str; @@ -39,8 +39,6 @@ pub trait RepositoryLayout { /// Returns the working directory where the repository is located. fn working_dir(&self) -> &Path; - fn set_working_dir(&mut self, new_working_dir: PathBuf); - // --- relative paths --- /// Returns the relative path to the repository directory. @@ -156,6 +154,12 @@ pub trait RepositoryLayout { } } +/// Extension of [`RepositoryLayout`] that allows **mutating** the +/// repository’s working directory. +pub trait MutableRepositoryLayout: RepositoryLayout { + fn set_working_dir(&mut self, new_working_dir: PathBuf); +} + #[cfg(test)] mod tests { use super::*; @@ -217,7 +221,9 @@ mod tests { fn working_dir(&self) -> &Path { &self.dir } + } + impl MutableRepositoryLayout for TestRepoLayout { fn set_working_dir(&mut self, new_working_dir: PathBuf) { self.dir = new_working_dir; } From 48ce85879a05544c199231b85dfa5b6433374a92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Fri, 3 Oct 2025 21:30:43 +0100 Subject: [PATCH 08/42] Fix/ignore meva folder (#8) --- engine/src/index/meva_index.rs | 5 +++++ engine/src/object_storage/meva_object_storage.rs | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/engine/src/index/meva_index.rs b/engine/src/index/meva_index.rs index 78cbe2c..28b344e 100644 --- a/engine/src/index/meva_index.rs +++ b/engine/src/index/meva_index.rs @@ -283,6 +283,10 @@ impl<'a> MevaIndex<'a> { WalkDir::new(path) .into_iter() .filter_entry(|e| { + if e.file_type().is_dir() && e.path() == self.repo_layout.repository_dir() { + return false; + } + for ignore_service in &ignore_services { match ignore_service.check_cached(&e.path()) { Ok(IgnoreResult::Ignored { .. }) => return false, @@ -290,6 +294,7 @@ impl<'a> MevaIndex<'a> { Err(_) => return true, } } + true }) .filter_map(|e| e.ok()) diff --git a/engine/src/object_storage/meva_object_storage.rs b/engine/src/object_storage/meva_object_storage.rs index b5d210d..8a8f538 100644 --- a/engine/src/object_storage/meva_object_storage.rs +++ b/engine/src/object_storage/meva_object_storage.rs @@ -83,11 +83,21 @@ impl<'a> ObjectStorage<'a> { } let compressed_data = object.compressed()?; - let mut blob_file = OpenOptions::new() + match OpenOptions::new() .write(true) .create_new(true) - .open(&object_path)?; - blob_file.write_all(&compressed_data)?; + .open(&object_path) + { + Ok(mut blob_file) => { + blob_file.write_all(&compressed_data)?; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + // another thread or process already wrote this blob + // (or a different file with identical content produced the same hash) + // nothing to do here, since the hash guarantees the data is identical + } + Err(e) => return Err(e.into()), + } } Ok(hash) From abeacbee21813c0650f06719b3791798ab813989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Sun, 12 Oct 2025 19:45:15 +0200 Subject: [PATCH 09/42] Command `commit` (#9) --- Cargo.lock | 73 +++- Cargo.toml | 1 + cli/src/commands.rs | 2 + cli/src/commands/add.rs | 2 +- cli/src/commands/commit.rs | 160 +++++++++ cli/src/commands/config/subcommands/edit.rs | 2 +- cli/src/commands/ignore/subcommands/edit.rs | 2 +- cli/src/commands/plugins/subcommands/edit.rs | 2 +- cli/src/main.rs | 5 +- engine/Cargo.toml | 2 + engine/src/add.rs | 2 + engine/src/add/handlers.rs | 26 +- engine/src/branch_manager.rs | 68 ++++ .../src/branch_manager/meva_branch_manager.rs | 168 ++++++++++ engine/src/commit.rs | 5 + engine/src/commit/handlers.rs | 167 ++++++++++ engine/src/commit/operations.rs | 36 ++ engine/src/commit_builder.rs | 3 + .../src/commit_builder/meva_commit_builder.rs | 216 ++++++++++++ engine/src/engine_container.rs | 14 +- engine/src/errors.rs | 7 + engine/src/errors/commit_error.rs | 12 + engine/src/errors/engine_error.rs | 38 ++- engine/src/errors/path_error.rs | 7 + engine/src/errors/tree_error.rs | 35 ++ engine/src/errors/unpack_error.rs | 20 ++ engine/src/hasher.rs | 12 +- engine/src/hasher/hasher_trait.rs | 8 - engine/src/hasher/meva_hasher.rs | 4 +- engine/src/ignore/ignore_service.rs | 4 +- engine/src/index.rs | 5 + engine/src/index/meva_index.rs | 157 +++++---- engine/src/lib.rs | 13 +- engine/src/object_storage.rs | 44 ++- .../meva_dry_run_object_storage.rs | 53 +++ engine/src/object_storage/meva_object.rs | 73 ---- .../src/object_storage/meva_object_storage.rs | 156 +++++---- engine/src/objects.rs | 19 ++ engine/src/objects/meva_blob.rs | 67 ++++ engine/src/objects/meva_commit.rs | 120 +++++++ engine/src/objects/meva_object.rs | 313 ++++++++++++++++++ .../src/objects/meva_object_decode_context.rs | 19 ++ .../meva_object_type.rs | 16 +- engine/src/objects/meva_tree.rs | 72 ++++ engine/src/objects/person.rs | 94 ++++++ engine/src/objects/tree_entry.rs | 56 ++++ engine/src/objects/tree_entry_type.rs | 18 + engine/src/ref_manager.rs | 35 ++ engine/src/ref_manager/head.rs | 47 +++ engine/src/ref_manager/head_mode.rs | 20 ++ engine/src/ref_manager/meva_ref_manager.rs | 106 ++++++ engine/src/ref_manager/ref_entry.rs | 36 ++ engine/src/repositories.rs | 1 + engine/src/repositories/meva_repository.rs | 28 +- .../repositories/meva_repository_layout.rs | 45 ++- engine/src/serialize_deserialize.rs | 5 + .../serialize_deserialize/binary_compress.rs | 47 +++ .../src/serialize_deserialize/meva_encode.rs | 125 +++++++ engine/src/utils.rs | 3 + engine/src/utils/strip_base.rs | 84 +++++ plugins/src/enums/command_type.rs | 1 + plugins/src/enums/invocation_payload.rs | 9 +- plugins/src/models/payloads.rs | 2 + plugins/src/models/payloads/commit_payload.rs | 33 ++ shared/src/extensions.rs | 7 + shared/src/extensions/cumulative_paths.rs | 118 +++++++ shared/src/extensions/path_to_string.rs | 22 ++ shared/src/lib.rs | 5 +- 68 files changed, 2889 insertions(+), 288 deletions(-) create mode 100644 cli/src/commands/commit.rs create mode 100644 engine/src/branch_manager.rs create mode 100644 engine/src/branch_manager/meva_branch_manager.rs create mode 100644 engine/src/commit.rs create mode 100644 engine/src/commit/handlers.rs create mode 100644 engine/src/commit/operations.rs create mode 100644 engine/src/commit_builder.rs create mode 100644 engine/src/commit_builder/meva_commit_builder.rs create mode 100644 engine/src/errors/commit_error.rs create mode 100644 engine/src/errors/tree_error.rs create mode 100644 engine/src/errors/unpack_error.rs delete mode 100644 engine/src/hasher/hasher_trait.rs create mode 100644 engine/src/object_storage/meva_dry_run_object_storage.rs delete mode 100644 engine/src/object_storage/meva_object.rs create mode 100644 engine/src/objects.rs create mode 100644 engine/src/objects/meva_blob.rs create mode 100644 engine/src/objects/meva_commit.rs create mode 100644 engine/src/objects/meva_object.rs create mode 100644 engine/src/objects/meva_object_decode_context.rs rename engine/src/{object_storage => objects}/meva_object_type.rs (52%) create mode 100644 engine/src/objects/meva_tree.rs create mode 100644 engine/src/objects/person.rs create mode 100644 engine/src/objects/tree_entry.rs create mode 100644 engine/src/objects/tree_entry_type.rs create mode 100644 engine/src/ref_manager.rs create mode 100644 engine/src/ref_manager/head.rs create mode 100644 engine/src/ref_manager/head_mode.rs create mode 100644 engine/src/ref_manager/meva_ref_manager.rs create mode 100644 engine/src/ref_manager/ref_entry.rs create mode 100644 engine/src/serialize_deserialize.rs create mode 100644 engine/src/serialize_deserialize/binary_compress.rs create mode 100644 engine/src/serialize_deserialize/meva_encode.rs create mode 100644 engine/src/utils.rs create mode 100644 engine/src/utils/strip_base.rs create mode 100644 plugins/src/models/payloads/commit_payload.rs create mode 100644 shared/src/extensions/cumulative_paths.rs create mode 100644 shared/src/extensions/path_to_string.rs diff --git a/Cargo.lock b/Cargo.lock index 6656592..50500ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -397,6 +397,7 @@ dependencies = [ "plugins", "pretty_assertions", "rayon", + "regex", "rstest", "serde", "serde_json", @@ -406,6 +407,7 @@ dependencies = [ "thiserror", "toml", "toml_edit", + "tree-ds", "walkdir", ] @@ -645,6 +647,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.175" @@ -667,6 +675,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.28" @@ -917,9 +934,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.2" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" dependencies = [ "aho-corasick", "memchr", @@ -929,9 +946,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" dependencies = [ "aho-corasick", "memchr", @@ -1035,12 +1052,28 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +[[package]] +name = "sequential_gen" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d630b1418311e028ceb45e1e567845501a9d9c770a258b80d2edc025ffad17c" +dependencies = [ + "lazy_static", + "uuid", +] + [[package]] name = "serde" version = "1.0.225" @@ -1134,6 +1167,15 @@ version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "lock_api", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1301,6 +1343,19 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" +[[package]] +name = "tree-ds" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710c388a2bb52d1a7186ae4aa2e92c6fae0606426bf4bdfb4106fa1e8e5c602e" +dependencies = [ + "lazy_static", + "sequential_gen", + "serde", + "spin", + "thiserror", +] + [[package]] name = "typed-builder" version = "0.21.2" @@ -1373,6 +1428,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.3", + "serde", +] + [[package]] name = "virtue" version = "0.0.18" diff --git a/Cargo.toml b/Cargo.toml index 19f90f5..ef0e050 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,4 @@ chrono = { version = "0.4.42", features = ["serde"] } clap = { version = "4.5.41", features = ["derive"] } strum = "0.27" strum_macros = "0.27" +regex = "1.11.3" diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 2c138b3..fb15091 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1,4 +1,5 @@ pub mod add; +pub mod commit; pub mod config; pub mod ignore; pub mod init; @@ -6,6 +7,7 @@ pub mod meva_command; pub mod plugins; pub use add::AddCommand; +pub use commit::CommitCommand; pub use config::ConfigCommand; pub use ignore::IgnoreCommand; pub use init::InitCommand; diff --git a/cli/src/commands/add.rs b/cli/src/commands/add.rs index 3937493..87e024d 100644 --- a/cli/src/commands/add.rs +++ b/cli/src/commands/add.rs @@ -5,7 +5,7 @@ use miette::{IntoDiagnostic, Result}; use crate::commands::MevaCommand; use engine::EngineContainer; -use engine::add::operations::AddRequest; +use engine::add::AddRequest; use engine::engine_container::MevaContainer; /// CLI command for adding files to the Meva staging area. diff --git a/cli/src/commands/commit.rs b/cli/src/commands/commit.rs new file mode 100644 index 0000000..665fc53 --- /dev/null +++ b/cli/src/commands/commit.rs @@ -0,0 +1,160 @@ +use crate::commands::MevaCommand; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use engine::EngineContainer; +use engine::add::AddRequest; +use engine::commit::CommitRequest; +use engine::engine_container::MevaContainer; +use engine::objects::Person; +use miette::IntoDiagnostic; + +/// Implements the `commit` command for Meva DVCS. +/// +/// Creates a new commit object that records the current stage contents, +/// along with a user-supplied message and optional metadata (author, date, etc.). +/// Supports automatically staging changes, amending the last commit, or performing a dry run +/// to preview the commit without persisting it. +pub struct CommitCommand; + +impl CommitCommand { + /// Creates a new instance of the `CommitCommand`. + pub fn new() -> Self { + Self + } + + /// Argument key for specifying the commit message. + /// Corresponds to the `-m, --message ` option. + const ARG_MESSAGE: &'static str = "message"; + + /// Argument key for overriding the commit author. + /// Format: `"Name Surname "` + /// Corresponds to the `--author ` option. + const ARG_AUTHOR: &'static str = "author"; + + /// Flag key for staging all modified and deleted files before commiting. + /// Corresponds to the `-a, --all` flag. + const ARG_ALL: &'static str = "all"; + + /// Flag key for performing a dry run (no commit is actually created). + /// Corresponds to the `-n --dry-run` flag. + const ARG_DRY_RUN: &'static str = "dry-run"; + + /// Flag key for amending the tip of the current branch instead of creating a new commit. + /// Corresponds to the `--amend` flag. + const ARG_AMEND: &'static str = "amend"; +} + +impl MevaCommand for CommitCommand { + fn name(&self) -> &'static str { + "commit" + } + + fn about(&self) -> &'static str { + "Create a new commit object containing the current stage contents and message" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `commit` command using `clap`. + /// + /// Adds the following options and flags: + /// - `-m, --message `: Commit message describing the changes. + /// - `--author `: Override the default author (`"Name "` format). + /// - `-n, --dry-run`: Show what would be committed without actually creating the commit. + /// - `--amend`: Amend the tip of the current branch instead of creating a new commit. + /// - `-a, --all`: Stage all modified and deleted files before committing. + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_MESSAGE) + .value_name("MESSAGE") + .short('m') + .long(Self::ARG_MESSAGE) + .help("Commit message describing the changes") + .required(true), + ) + .arg( + Arg::new(Self::ARG_AUTHOR) + .value_name("AUTHOR") + .long(Self::ARG_AUTHOR) + .help("Override the default author (format: \"Name Surname \")") + .value_parser(clap::value_parser!(Person)), + ) + .arg( + Arg::new(Self::ARG_DRY_RUN) + .value_name("DRY_RUN") + .short('n') + .long(Self::ARG_DRY_RUN) + .help("Show what would be committed without creating a commit") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_AMEND) + .value_name("AMEND") + .long(Self::ARG_AMEND) + .help("Amend the tip of the current branch instead of creating a new commit") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_ALL) + .value_name("ALL") + .short('a') + .long(Self::ARG_ALL) + .help("Stage all modified and deleted files before committing") + .action(ArgAction::SetTrue), + ) + } + + /// Executes the `commit` command. + /// + /// Reads the provided CLI options (message, author, etc.) and performs + /// the necessary repository operations: + /// + /// * If the `--all` flag is set, stages all modified and deleted files + /// before creating the commit. + /// * Builds a [`CommitRequest`] using the collected arguments + /// (message, author, date, dry-run, amend). + /// * Invokes the commit handler to either create the commit + /// or show a preview when `--dry-run` is used. + /// + /// # Returns + /// * [`miette::Result`]: Indicates success or a diagnostic error if the commit operation fails. + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + let message_arg = matches.get_one::(Self::ARG_MESSAGE).unwrap(); + let author_arg = matches.get_one::(Self::ARG_AUTHOR); + let all_arg = matches.get_flag(Self::ARG_ALL); + let dry_run_arg = matches.get_flag(Self::ARG_DRY_RUN); + let amend_arg = matches.get_flag(Self::ARG_AMEND); + + let interceptor = container.plugins_interceptor().into_diagnostic()?; + + if all_arg { + let add_handler = container.add_handler().into_diagnostic()?; + let add_request = AddRequest { + all_flag: true, + force_flag: false, + update_flag: false, + dry_run_flag: false, + verbose_flag: false, + path_arg: None, + }; + add_handler + .handle_add(add_request, &interceptor) + .into_diagnostic()?; + } + + let commit_handler = container.commit_handler().into_diagnostic()?; + let commit_request = CommitRequest { + dry_run_arg, + amend_arg, + message_arg: message_arg.clone(), + author_arg: author_arg.cloned(), + }; + commit_handler + .handle_commit(commit_request, &interceptor) + .into_diagnostic()?; + + Ok(()) + } +} diff --git a/cli/src/commands/config/subcommands/edit.rs b/cli/src/commands/config/subcommands/edit.rs index 9fffe6c..4695696 100644 --- a/cli/src/commands/config/subcommands/edit.rs +++ b/cli/src/commands/config/subcommands/edit.rs @@ -3,7 +3,7 @@ use engine::engine_container::MevaContainer; use miette::{Context, IntoDiagnostic}; use engine::{ConfigDocument, ConfigLoader}; -use shared::extensions::OpenInEditor; +use shared::OpenInEditor; use crate::commands::MevaCommand; use crate::extensions::{LocationSelection, WithLocations}; diff --git a/cli/src/commands/ignore/subcommands/edit.rs b/cli/src/commands/ignore/subcommands/edit.rs index 3ec7de5..ac6e76a 100644 --- a/cli/src/commands/ignore/subcommands/edit.rs +++ b/cli/src/commands/ignore/subcommands/edit.rs @@ -6,7 +6,7 @@ use engine::{ engine_container::MevaContainer, repositories::meva_repository_layout::MevaRepositoryLayout, }; use miette::IntoDiagnostic; -use shared::extensions::OpenInEditor; +use shared::OpenInEditor; use crate::{commands::MevaCommand, extensions::WithFile}; diff --git a/cli/src/commands/plugins/subcommands/edit.rs b/cli/src/commands/plugins/subcommands/edit.rs index f1d3d76..ad3d8c0 100644 --- a/cli/src/commands/plugins/subcommands/edit.rs +++ b/cli/src/commands/plugins/subcommands/edit.rs @@ -6,7 +6,7 @@ use engine::{ }; use miette::IntoDiagnostic; use plugins::{CommandType, ScopeType}; -use shared::extensions::OpenInEditor; +use shared::OpenInEditor; use crate::{ commands::MevaCommand, diff --git a/cli/src/main.rs b/cli/src/main.rs index eccdaa5..dfc94a6 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -3,7 +3,9 @@ mod extensions; mod meva_cli; use crate::meva_cli::MevaCli; -use commands::{AddCommand, ConfigCommand, IgnoreCommand, InitCommand, PluginsCommand}; +use commands::{ + AddCommand, CommitCommand, ConfigCommand, IgnoreCommand, InitCommand, PluginsCommand, +}; use engine::engine_container::MevaContainer; use miette::Result; @@ -18,6 +20,7 @@ fn main() -> Result<()> { cli.add_command(Box::new(IgnoreCommand::new())); cli.add_command(Box::new(PluginsCommand::new())); cli.add_command(Box::new(AddCommand::new())); + cli.add_command(Box::new(CommitCommand::new())); cli.run() } diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 46d904b..2a45e5f 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -11,6 +11,7 @@ tempfile.workspace = true thiserror.workspace = true serde.workspace = true serde_json.workspace = true +regex.workspace = true toml = "0.9.2" toml_edit = "0.23.2" dirs.workspace = true @@ -22,6 +23,7 @@ chrono.workspace = true rayon = "1.11.0" flate2 = "1.1.2" bincode = "2.0.1" +tree-ds = "0.2.0" [dev-dependencies] rstest.workspace = true diff --git a/engine/src/add.rs b/engine/src/add.rs index 4ae1878..cd87a93 100644 --- a/engine/src/add.rs +++ b/engine/src/add.rs @@ -1,2 +1,4 @@ pub mod handlers; pub mod operations; + +pub use operations::*; diff --git a/engine/src/add/handlers.rs b/engine/src/add/handlers.rs index 997d2a0..9a4bc01 100644 --- a/engine/src/add/handlers.rs +++ b/engine/src/add/handlers.rs @@ -1,8 +1,7 @@ use crate::RepositoryLayout; -use crate::add::operations::{AddOperations, AddRequest, AddResponse}; -use crate::errors::path_error::PathError; -use crate::errors::{EngineError, EngineResult, RepositoryError}; -use crate::index::meva_index::MevaIndex; +use crate::add::{AddOperations, AddRequest, AddResponse}; +use crate::errors::{EngineError, EngineResult, PathError}; +use crate::index::MevaIndex; use crate::plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}; use crate::repositories::meva_repository_layout::MevaRepositoryLayout; @@ -10,9 +9,7 @@ use plugins::{ AddPostPayload, AddPrePayload, CommandType, InvocationInput, InvocationPostPayload, InvocationPrePayload, MevaPluginsLayout, PluginError, }; -use shared::UpwardSearch; -use shared::extensions::canonicalize_clean::CanonicalizeClean; -use shared::extensions::is_within::IsWithin; +use shared::{CanonicalizeClean, IsWithin}; pub struct AddHandler; @@ -30,15 +27,7 @@ impl AddHandler { impl AddOperations for AddHandler { fn add(&self, request: AddRequest) -> EngineResult { let workdir_abs = std::env::current_dir()?; - let mut repo_layout = MevaRepositoryLayout::new(workdir_abs.clone())?; - let repository_root_abs = match workdir_abs.search_dir_up(repo_layout.repository_dir_name()) - { - Some(val) => val - .parent() - .ok_or_else(|| PathError::NoParent { path: val.clone() })? - .to_owned(), - None => return Err(RepositoryError::RepositoryNotFound { path: workdir_abs }.into()), - }; + let repo_layout = MevaRepositoryLayout::discover()?; let path_abs = match request.path_arg { Some(val) if val.is_absolute() => val.clone(), @@ -48,10 +37,10 @@ impl AddOperations for AddHandler { let path_abs = path_abs.canonicalize_clean()?; - if !path_abs.is_within(&repository_root_abs)? { + if !path_abs.is_within(repo_layout.working_dir())? { return Err(PathError::NotInBaseDirectory { path: path_abs, - path_base: repository_root_abs, + path_base: repo_layout.working_dir().into(), } .into()); } @@ -62,7 +51,6 @@ impl AddOperations for AddHandler { let verbose = request.dry_run_flag || request.verbose_flag; - repo_layout = MevaRepositoryLayout::new(repository_root_abs)?; let mut index = MevaIndex::new(&repo_layout)?; index.load()?; diff --git a/engine/src/branch_manager.rs b/engine/src/branch_manager.rs new file mode 100644 index 0000000..69c0ae2 --- /dev/null +++ b/engine/src/branch_manager.rs @@ -0,0 +1,68 @@ +pub mod meva_branch_manager; + +use crate::errors::EngineResult; +use crate::objects::MevaCommit; + +pub use meva_branch_manager::MevaBranchManager; +/// Defines a common interface for managing commits within a branch. +/// +/// Currently, this trait focuses on commit-level operations: +/// - adding new commits, +/// - retrieving the latest commit hash or object. +/// +/// In the future, it can be extended to support full branch operations, +/// such as creating new branches, switching (checkout), merging, or +/// enumerating commits. +/// +/// # Methods +/// +/// - `add_commit` – adds a commit to the branch and returns its SHA-1 hash. +/// - `last_commit_hash` – retrieves the SHA-1 hash of the most recent commit, if any. +/// - `last_commit` – retrieves the most recent commit object, if any. +pub trait BranchManger { + /// Adds a new commit to the branch. + /// + /// # Arguments + /// + /// * `commit` – The [`MevaCommit`] object to add. + /// + /// # Returns + /// + /// Returns the SHA-1 hash of the newly added commit. + /// + /// # Errors + /// + /// Returns an [`EngineResult`] containing errors that occur during + /// commit storage or hash computation. + fn add_commit(&self, commit: MevaCommit) -> EngineResult; + + /// Amends the latest commit in the branch with a new [`MevaCommit`]. + /// + /// Returns the SHA-1 hash of the updated commit. + fn amend_last_commit(&self, comit: MevaCommit) -> EngineResult; + + /// Returns the SHA-1 hash of the most recent commit in the branch. + /// + /// # Returns + /// + /// Returns `Ok(Some(hash))` if there is at least one commit, + /// `Ok(None)` if the branch has no commits yet. + /// + /// # Errors + /// + /// Returns an [`EngineResult`] if an error occurs during retrieval. + fn last_commit_hash(&self) -> EngineResult>; + + /// Returns the most recent commit object in the branch. + /// + /// # Returns + /// + /// Returns `Ok(Some(commit))` if there is at least one commit, + /// `Ok(None)` if the branch has no commits yet. + /// + /// # Errors + /// + /// Returns an [`EngineResult`] if an error occurs during retrieval + /// or object deserialization. + fn last_commit(&self) -> EngineResult>; +} diff --git a/engine/src/branch_manager/meva_branch_manager.rs b/engine/src/branch_manager/meva_branch_manager.rs new file mode 100644 index 0000000..7cdb6f8 --- /dev/null +++ b/engine/src/branch_manager/meva_branch_manager.rs @@ -0,0 +1,168 @@ +use crate::BranchManger; +use crate::ObjectStorage; +use crate::RefManager; +use crate::RepositoryLayout; +use crate::errors::{CommitError, EngineResult}; +use crate::object_storage::MevaObjectStorage; +use crate::objects::MevaCommit; +use crate::ref_manager::{Head, HeadMode, MevaRefManager, RefEntry}; + +/// Manages commits within a Meva branch. +/// +/// `MevaBranchManager` is a concrete implementation of [`BranchManger`] +/// that provides commit-level operations for a branch using: +/// - [`MevaObjectStorage`] for persistent object storage, +/// - [`MevaRefManager`] for reading and updating branch references (HEAD). +/// +/// Currently, this manager handles adding commits and querying the latest commit. +/// In the future, it can be extended to support full branch-level operations, +/// such as creating new branches, switching branches (checkout), or merging. +pub struct MevaBranchManager<'a> { + /// Provides storage for commit objects. + object_storage: MevaObjectStorage<'a>, + + /// Provides access to branch references (HEAD, etc.). + ref_manager: MevaRefManager<'a>, +} +impl<'a> MevaBranchManager<'a> { + /// Creates a new `MevaBranchManager` for the given repository layout. + /// + /// # Arguments + /// + /// * `repo_layout` – Provides repository paths and layout information. + pub fn new(repo_layout: &'a dyn RepositoryLayout) -> Self { + Self { + object_storage: MevaObjectStorage::new(repo_layout), + ref_manager: MevaRefManager::new(repo_layout), + } + } + /// Updates the branch head to point to a new commit. + /// + /// Depending on the current [`HeadMode`], this method either: + /// - updates the direct HEAD reference (in detached state), or + /// - updates the symbolic reference (e.g. a branch name) to the given commit hash. + /// + /// # Arguments + /// + /// * `hash` – The SHA-1 hash of the commit to move the HEAD to. + /// + /// # Errors + /// + /// Returns an [`EngineError`] if reading or updating HEAD or the reference fails. + fn move_head(&self, hash: &str) -> EngineResult<()> { + let head = self.ref_manager.read_head()?; + + match head.mode { + HeadMode::Direct => { + let new_head = Head { + mode: head.mode, + target: hash.to_string(), + }; + self.ref_manager.update_head(new_head)?; + } + HeadMode::Symbolic => { + let ref_entry = RefEntry { + name: head.target.clone(), + commit_hash: hash.to_string(), + }; + self.ref_manager.update_ref(ref_entry)?; + } + } + + Ok(()) + } +} + +impl BranchManger for MevaBranchManager<'_> { + /// Adds a commit to the branch. + /// + /// Automatically sets the `parent` field of the commit to the + /// current latest commit, if one exists. + /// + /// # Returns + /// + /// The SHA-1 hash of the newly added commit. + /// + /// # Errors + /// + /// Returns an [`EngineResult`] if storing the commit fails. + fn add_commit(&self, mut commit: MevaCommit) -> EngineResult { + let last_commit_hash = self.last_commit_hash()?; + match last_commit_hash { + Some(last_commit_hash) => { + commit.parents = vec![last_commit_hash]; + } + None => { + commit.parents = vec![]; + } + } + + let hash = self.object_storage.add_commit(commit)?; + + self.move_head(&hash)?; + + Ok(hash) + } + + /// Amends the most recent commit in the branch. + /// + /// Replaces the latest commit with a new one while preserving its parent commits. + /// + /// # Returns + /// + /// The SHA-1 hash of the amended commit. + /// + /// # Errors + /// + /// Returns [`CommitError::NoCommitToAmend`] if the branch has no commits yet, + /// or an [`EngineResult`] if an error occurs during commit storage or reference update. + fn amend_last_commit(&self, mut commit: MevaCommit) -> EngineResult { + let last_commit = self.last_commit()?.ok_or(CommitError::NoCommitToAmend)?; + + commit.parents = last_commit.parents.clone(); + + let hash = self.object_storage.add_commit(commit)?; + self.move_head(&hash)?; + Ok(hash) + } + + /// Returns the SHA-1 hash of the most recent commit in the branch. + /// + /// # Returns + /// + /// `Ok(Some(hash))` if there is at least one commit, `Ok(None)` if the branch is empty. + /// + /// # Errors + /// + /// Returns an [`EngineResult`] if reading branch references fails. + fn last_commit_hash(&self) -> EngineResult> { + let head = self.ref_manager.read_head()?; + Ok(match head.mode { + HeadMode::Direct => Some(head.target), + HeadMode::Symbolic => self + .ref_manager + .read_ref(&head.target)? + .map(|e| e.commit_hash), + }) + } + + /// Returns the most recent commit object in the branch. + /// + /// # Returns + /// + /// `Ok(Some(commit))` if there is at least one commit, `Ok(None)` if the branch is empty. + /// + /// # Errors + /// + /// Returns an [`EngineResult`] if retrieving the commit object fails. + fn last_commit(&self) -> EngineResult> { + if let Some(hash) = self.last_commit_hash()? { + let object = self.object_storage.get_object(&hash)?; + let commit = MevaCommit::try_from(object)?; + + Ok(Some(commit)) + } else { + Ok(None) + } + } +} diff --git a/engine/src/commit.rs b/engine/src/commit.rs new file mode 100644 index 0000000..c784e9b --- /dev/null +++ b/engine/src/commit.rs @@ -0,0 +1,5 @@ +pub mod handlers; +pub mod operations; + +pub use handlers::CommitHandler; +pub use operations::*; diff --git a/engine/src/commit/handlers.rs b/engine/src/commit/handlers.rs new file mode 100644 index 0000000..62f6692 --- /dev/null +++ b/engine/src/commit/handlers.rs @@ -0,0 +1,167 @@ +use crate::ConfigLoader; +use crate::branch_manager::{BranchManger, MevaBranchManager}; +use crate::commit::{CommitOperations, CommitRequest, CommitResponse}; +use crate::commit_builder::MevaCommitBuilder; +use crate::errors::{EngineError, EngineResult}; +use crate::object_storage::{MevaDryRunObjectStorage, MevaObjectStorage, ObjectStorage}; +use crate::objects::{MevaCommit, Person}; +use crate::plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}; +use crate::repositories::meva_repository_layout::MevaRepositoryLayout; +use plugins::{ + CommandType, CommitAuthor, CommitContent, CommitPostPayload, CommitPrePayload, InvocationInput, + InvocationPostPayload, InvocationPrePayload, MevaPluginsLayout, PluginError, +}; + +/// Handles the `meva commit` command execution flow. +/// +/// The `CommitHandler` coordinates the process of creating or amending commits, +/// optionally invoking plugin interceptors before and after the operation. +/// It is responsible for: +/// - Building new commits from the current index, +/// - Amending the last commit if requested, +/// - Retrieving user information (name and email) from the configuration via [`ConfigLoader`]. +/// +/// The `config_loader` field provides access to repository or global configuration, +/// allowing the handler to resolve author information and other commit metadata. +pub struct CommitHandler { + pub config_loader: ConfigLoader, +} + +impl CommitHandler { + /// Executes the commit operation with plugin interception. + /// + /// Invokes registered plugins before and after the commit logic, + /// passing the [`CommitRequest`] and receiving a [`CommitResponse`]. + /// + /// # Errors + /// + /// Returns an [`EngineResult`] if plugin execution or commit processing fails. + pub fn handle_commit( + &self, + request: CommitRequest, + interceptor: &PluginsInterceptor, + ) -> EngineResult { + interceptor.intercept_with_plugins(CommandType::Commit, None, request, self, |req| { + self.commit(req) + }) + } + + /// Retrieves the configured Git-style username from the repository settings. + fn get_username(&self) -> EngineResult { + self.config_loader + .get_parsed("user.name", String::default()) + } + + /// Retrieves the configured Git-style user email from the repository settings. + fn get_user_email(&self) -> EngineResult { + self.config_loader + .get_parsed("user.email", String::default()) + } +} + +impl CommitOperations for CommitHandler { + fn commit(&self, request: CommitRequest) -> EngineResult { + let repo_layout = MevaRepositoryLayout::discover()?; + let object_storage: Box = match request.dry_run_arg { + true => Box::new(MevaDryRunObjectStorage::new()), + false => Box::new(MevaObjectStorage::new(&repo_layout)), + }; + let branch_manager = MevaBranchManager::new(&repo_layout); + let commit_builder = MevaCommitBuilder::new(&repo_layout, object_storage.as_ref()); + + let author = match request.author_arg.clone() { + Some(a) => a, + None => Person { + name: self.get_username()?, + email: self.get_user_email()?, + }, + }; + + let commit = commit_builder.build_commit( + request.message_arg.clone(), + author, + request.dry_run_arg, + )?; + + let hash = match request.amend_arg { + true => branch_manager.amend_last_commit(commit.clone())?, + false => branch_manager.add_commit(commit.clone())?, + }; + + Ok(CommitResponse { + commit_hash: hash.to_string(), + commit, + }) + } +} + +impl PluginsInvocationMapper for CommitHandler { + fn request_to_payload(&self, req: &CommitRequest) -> EngineResult { + Ok(InvocationPrePayload::Commit(CommitPrePayload { + message: req.message_arg.clone(), + author: CommitAuthor { + name: req.author_arg.clone().map(|a| a.name).unwrap_or_default(), + email: req.author_arg.clone().map(|a| a.email).unwrap_or_default(), + }, + dry_run: req.dry_run_arg, + amend: req.amend_arg, + })) + } + + fn response_to_payload(&self, res: &CommitResponse) -> EngineResult { + Ok(InvocationPostPayload::Commit(Box::new(CommitPostPayload { + commit_hash: res.commit_hash.clone(), + + commit_content: CommitContent { + commit_tree_hash: res.commit.tree.clone(), + parents: res.commit.parents.clone(), + message: res.commit.message.clone(), + author: CommitAuthor { + name: res.commit.author.name.clone(), + email: res.commit.author.email.clone(), + }, + timestamp: res.commit.timestamp, + }, + }))) + } + + fn input_to_request(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPrePayload::Commit(pre)) = &input.pre_payload { + Ok(CommitRequest { + message_arg: pre.message.clone(), + author_arg: Some(Person { + name: pre.author.name.clone(), + email: pre.author.name.clone(), + }), + dry_run_arg: pre.dry_run, + amend_arg: pre.amend, + }) + } else { + Err(EngineError::Plugins(PluginError::PrePayload { + payload: input.pre_payload.clone(), + })) + } + } + + fn input_to_response(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPostPayload::Commit(post)) = &input.post_payload { + Ok(CommitResponse { + commit_hash: post.commit_hash.clone(), + commit: MevaCommit { + tree: post.commit_content.commit_tree_hash.clone(), + parents: post.commit_content.parents.clone(), + message: post.commit_content.message.clone(), + author: Person { + name: post.commit_content.author.name.clone(), + email: post.commit_content.author.email.clone(), + }, + timestamp: post.commit_content.timestamp, + }, + }) + } else { + Err(EngineError::Plugins(PluginError::PostPayload { + payload: input.post_payload.clone(), + })) + } + } +} diff --git a/engine/src/commit/operations.rs b/engine/src/commit/operations.rs new file mode 100644 index 0000000..dd4c373 --- /dev/null +++ b/engine/src/commit/operations.rs @@ -0,0 +1,36 @@ +use crate::errors::EngineResult; +use crate::objects::{MevaCommit, Person}; + +/// Represents the user-provided arguments for the `meva commit` command. +/// +/// This struct holds input data passed to the commit process, +/// including the commit message, author information, and execution flags. +pub struct CommitRequest { + /// Commit message provided by the user. + pub message_arg: String, + + /// Optional author information (name and email). + pub author_arg: Option, + + /// If `true`, the commit is simulated without actually modifying the repository. + pub dry_run_arg: bool, + + /// If `true`, the last commit is amended instead of creating a new one. + pub amend_arg: bool, +} + +/// Represents the result of executing the `meva commit` command. +/// +/// Contains details about the created (or amended) commit and +/// echoes back the input arguments used for the operation. +pub struct CommitResponse { + /// SHA-1 hash of the created or amended commit. + pub commit_hash: String, + + /// Commit object + pub commit: MevaCommit, +} + +pub trait CommitOperations { + fn commit(&self, request: CommitRequest) -> EngineResult; +} diff --git a/engine/src/commit_builder.rs b/engine/src/commit_builder.rs new file mode 100644 index 0000000..52c528b --- /dev/null +++ b/engine/src/commit_builder.rs @@ -0,0 +1,3 @@ +pub mod meva_commit_builder; + +pub use meva_commit_builder::MevaCommitBuilder; diff --git a/engine/src/commit_builder/meva_commit_builder.rs b/engine/src/commit_builder/meva_commit_builder.rs new file mode 100644 index 0000000..fe7b5c7 --- /dev/null +++ b/engine/src/commit_builder/meva_commit_builder.rs @@ -0,0 +1,216 @@ +use crate::ObjectStorage; +use crate::RepositoryLayout; +use crate::errors::{EngineError, EngineResult, NodeError, PathError, TreeError}; +use crate::index::MevaIndex; +use crate::objects::{MevaCommit, MevaTree, Person, TreeEntry}; +use crate::utils::StripBase; +use chrono::Utc; +use shared::{CanonicalizeClean, CumulativePaths, PathToString}; +use std::path::Path; +use tree_ds::prelude::{Node, Tree}; + +/// Builder responsible for constructing and storing a commit tree in memory. +/// +/// A commit is represented as a hierarchical [`Tree`] where: +/// - each directory and file path corresponds to a node, +/// - the root node represents the repository root, +/// - leaf nodes store file hashes (SHA-1). +/// +/// The builder uses the in-memory [`MevaIndex`] to discover tracked files and +/// their associated object hashes, then arranges them into a tree structure. +/// Finally, it stores the resulting tree objects in the object storage. +/// +/// This structure is the core mechanism for assembling commits in Meva. +pub struct MevaCommitBuilder<'a> { + /// Repository layout (provides access to working dir, index file, etc.). + repo_layout: &'a dyn RepositoryLayout, + + /// Responsible for persisting objects (trees, blobs, commits). + object_storage: &'a dyn ObjectStorage, +} + +impl<'a> MevaCommitBuilder<'a> { + /// Creates a new [`MevaCommitBuilder`] instance. + /// + /// # Arguments + /// + /// * `repo_layout` – Reference to the repository layout implementation. + /// * `object_storage` – Object storage backend for persisting commit components. + pub fn new( + repo_layout: &'a dyn RepositoryLayout, + object_storage: &'a dyn ObjectStorage, + ) -> Self { + Self { + repo_layout, + object_storage, + } + } + + /// Builds a new [`MevaCommit`] from the current index. + /// + /// Constructs a full tree from the index, serializes and stores all + /// related [`MevaTree`] objects, and returns a new commit referencing + /// the root tree. + /// + /// The resulting commit **does not have its parent set** — this must be + /// handled externally (e.g., by the branch manager). + /// + /// # Arguments + /// + /// * `message` – Commit message describing the change. + /// * `author` – Author information (name and email). + /// * `verbose` – If true, prints information about stored objects. + /// + /// # Errors + /// + /// Returns an [`EngineResult`] if index loading, tree construction, + /// or object storage fails. + pub fn build_commit( + &self, + message: String, + author: Person, + verbose: bool, + ) -> EngineResult { + let tree = self.build_tree_from_index()?; + + let root_tree_hash = self.store_tree_objects(tree, verbose)?; + + Ok(MevaCommit::new(root_tree_hash, message, author, Utc::now())) + } + + /// Stores a complete [`Tree`] of repository contents recursively in object storage. + /// + /// Returns the hash of the root tree object. + fn store_tree_objects( + &self, + tree: Tree, + verbose: bool, + ) -> EngineResult { + let root_node_id = tree + .get_root_node() + .ok_or(TreeError::from(NodeError::RootNodeMissing))? + .get_node_id() + .map_err(|_| TreeError::from(NodeError::NodeMissingId))?; + + self.store_tree_recursively(&tree, root_node_id, verbose) + } + + /// Recursively traverses the tree and stores each subtree as a [`MevaTree`] object. + /// + /// Returns the SHA-1 hash of the stored tree. + fn store_tree_recursively( + &self, + tree: &Tree, + root_id: String, + verbose: bool, + ) -> EngineResult { + let root = tree + .get_node_by_id(&root_id) + .ok_or(TreeError::from(NodeError::NodeNotFound { id: root_id }))?; + + let node_ids = root + .get_children_ids() + .map_err(|e| TreeError::OperationFailed { + message: e.to_string(), + })?; + + let mut tree_entries: Vec = Vec::new(); + for node_id in node_ids { + let node = + tree.get_node_by_id(&node_id) + .ok_or(TreeError::from(NodeError::NodeNotFound { + id: node_id.clone(), + }))?; + let name = Path::new(&node_id) + .file_name() + .ok_or(PathError::EmptyPath)? + .to_string_lossy() + .to_string(); + if let Some(hash) = node.get_value().map_err(|e| TreeError::OperationFailed { + message: e.to_string(), + })? { + tree_entries.push(TreeEntry::blob(name, hash)); + } else { + let hash = self.store_tree_recursively(tree, node_id, verbose)?; + tree_entries.push(TreeEntry::tree(name, hash)); + } + } + + // TODO: Print only objects that changed since last commit + if verbose { + for entry in &tree_entries { + println!("{entry}"); + } + } + + self.object_storage.add_tree(MevaTree::new(tree_entries)) + } + + /// Builds a hierarchical [`Tree`] from the current index state. + /// + /// Converts tracked paths and their associated blob hashes into a + /// nested directory structure. + /// + /// # Errors + /// + /// Returns an [`EngineResult`] if index loading, path resolution, + /// or tree construction fails. + fn build_tree_from_index(&self) -> EngineResult> { + let index = MevaIndex::new_and_load(self.repo_layout)?; + let entries = index.get_entries(); + let meva_repository_dir = self.repo_layout.working_dir().canonicalize_clean()?; + + let mut tree: Tree = Tree::new(None); + let root_key = meva_repository_dir.to_utf8_string(); + tree.add_node(Node::new(root_key.clone(), None), None) + .map_err(|e| TreeError::OperationFailed { + message: e.to_string(), + })?; + + for entry in entries { + let entry_absolute_path = Path::new(&entry.path); + let relative_path = entry_absolute_path.strip_base(&meva_repository_dir)?; + + let components = relative_path.cumulative_paths(); + + let first_key = components + .first() + .ok_or(EngineError::EmptyCollection)? + .to_utf8_string(); + if tree.get_node_by_id(&first_key).is_none() { + let val = match components.len() { + 1 => Some(entry.sha1.clone()), + _ => None, + }; + let node = Node::new(first_key, val); + tree.add_node(node, Some(&root_key)) + .map_err(|e| TreeError::OperationFailed { + message: e.to_string(), + })?; + } + + for window in components.windows(2) { + let parent_key = window[0].to_utf8_string(); + let key = window[1].to_utf8_string(); + + if tree.get_node_by_id(&key).is_some() { + continue; + } + + let hash = if key == components.last().unwrap().to_utf8_string() { + Some(entry.sha1.clone()) + } else { + None + }; + + let node = Node::new(key, hash); + tree.add_node(node, Some(&parent_key)) + .map_err(|e| TreeError::OperationFailed { + message: e.to_string(), + })?; + } + } + + Ok(tree) + } +} diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 60940fc..959d300 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -1,9 +1,10 @@ use plugins::{MevaPluginsLayout, PluginsDiscovery, PluginsEngine}; use crate::add::handlers::AddHandler; +use crate::commit::handlers::CommitHandler; use crate::{ - InitHandler, config::ConfigHandler, errors::EngineResult, plugins::PluginsHandler, - plugins_interceptor::PluginsInterceptor, + ConfigLoader, InitHandler, config::ConfigHandler, errors::EngineResult, + plugins::PluginsHandler, plugins_interceptor::PluginsInterceptor, repositories::meva_repository_layout::MevaRepositoryLayout, }; @@ -26,6 +27,9 @@ pub trait EngineContainer { /// Returns the handler responsible for add command fn add_handler(&self) -> EngineResult; + + /// Return the handler responsible for commit creation + fn commit_handler(&self) -> EngineResult; } /// Concrete implementation of `EngineContainer` for Meva. @@ -80,4 +84,10 @@ impl EngineContainer for MevaContainer { fn add_handler(&self) -> EngineResult { Ok(AddHandler) } + + fn commit_handler(&self) -> EngineResult { + Ok(CommitHandler { + config_loader: ConfigLoader::default(), + }) + } } diff --git a/engine/src/errors.rs b/engine/src/errors.rs index 0d2e7d0..22acae3 100644 --- a/engine/src/errors.rs +++ b/engine/src/errors.rs @@ -1,3 +1,4 @@ +pub mod commit_error; pub mod config_error; pub mod engine_error; pub mod ignore_error; @@ -5,12 +6,18 @@ pub mod index_error; pub mod init_error; pub mod path_error; pub mod repository_error; +pub mod tree_error; +pub mod unpack_error; +pub use commit_error::CommitError; pub use config_error::ConfigError; pub use ignore_error::IgnoreError; pub use index_error::IndexError; pub use init_error::InitError; +pub use path_error::PathError; pub use repository_error::RepositoryError; +pub use tree_error::{NodeError, TreeError}; +pub use unpack_error::UnpackError; pub use engine_error::EngineError; pub use engine_error::Result as EngineResult; diff --git a/engine/src/errors/commit_error.rs b/engine/src/errors/commit_error.rs new file mode 100644 index 0000000..468d524 --- /dev/null +++ b/engine/src/errors/commit_error.rs @@ -0,0 +1,12 @@ +use thiserror::Error; + +/// Errors that can occur when working with commits. +/// +/// Currently, this only includes errors related to commit amendment, +/// but it can be extended in the future to cover other commit-related failures. +#[derive(Error, Debug)] +pub enum CommitError { + /// Raised when attempting to amend a commit but the branch has no commits. + #[error("No commit to amend")] + NoCommitToAmend, +} diff --git a/engine/src/errors/engine_error.rs b/engine/src/errors/engine_error.rs index 7b0ef57..21c6ca8 100644 --- a/engine/src/errors/engine_error.rs +++ b/engine/src/errors/engine_error.rs @@ -1,11 +1,13 @@ -use bincode::error::EncodeError; +use bincode::error::{DecodeError, EncodeError}; use plugins::PluginError; use std::io; use std::path::StripPrefixError; use thiserror::Error; -use crate::errors::path_error::PathError; -use crate::errors::{ConfigError, IgnoreError, IndexError, InitError, RepositoryError}; +use crate::errors::{ + CommitError, ConfigError, IgnoreError, IndexError, InitError, PathError, RepositoryError, + TreeError, UnpackError, +}; /// A convenient result type alias for engine-related operations. pub type Result = std::result::Result; @@ -65,6 +67,36 @@ pub enum EngineError { #[error(transparent)] Encode(#[from] EncodeError), + /// Error automatically converted from [`DecodeError`] + #[error(transparent)] + Decode(#[from] DecodeError), + + /// Errors originating from tree-based data structure operations. + /// + /// Typically raised when inserting, looking up, or modifying nodes + /// in a [`TreeDs`](crate::TreeDs)-backed structure. + #[error(transparent)] + Tree(#[from] TreeError), + + /// Errors related to commit operations, such as attempting to amend a commit + /// when no commits exist. + #[error(transparent)] + Commit(#[from] CommitError), + + /// Raised when a collection is unexpectedly empty. + /// + /// This is used in situations where the engine requires at least one + /// element (e.g., a non-empty set of candidates), but none were provided. + #[error("Expected collection to contain at least one element, but it was empty")] + EmptyCollection, + + /// Raised when unpacking a [`MevaObject`] fails. + /// + /// Typically occurs when the stored object type does not match + /// the expected variant or when deserialization of its content fails. + #[error(transparent)] + Unpack(#[from] UnpackError), + /// A catch-all variant for any unknown or unexpected engine error. /// Accepts a descriptive string message. #[error("Unknown engine error: {0}")] diff --git a/engine/src/errors/path_error.rs b/engine/src/errors/path_error.rs index c430595..1d54edb 100644 --- a/engine/src/errors/path_error.rs +++ b/engine/src/errors/path_error.rs @@ -39,4 +39,11 @@ pub enum PathError { /// The path for which a parent could not be determined. path: PathBuf, }, + + /// The given path was empty (i.e., no components). + /// + /// This can occur if a `PathBuf` or `&Path` was constructed + /// from an empty string, which is considered invalid in this context. + #[error("Path is empty and cannot be processed")] + EmptyPath, } diff --git a/engine/src/errors/tree_error.rs b/engine/src/errors/tree_error.rs new file mode 100644 index 0000000..e1a8082 --- /dev/null +++ b/engine/src/errors/tree_error.rs @@ -0,0 +1,35 @@ +use thiserror::Error; + +/// Errors that can occur when performing operations on a tree data structure. +/// +/// This enum covers all failure cases for common tree operations, +/// including traversals, lookups, insertions, and modifications. +#[derive(Error, Debug)] +pub enum TreeError { + /// A generic failure during a tree operation. + /// + /// This is used when an operation cannot complete successfully + /// due to an unexpected condition. The `message` provides details. + #[error("Tree operation failed: {message}")] + OperationFailed { message: String }, + + /// Errors originating from individual tree nodes. + #[error(transparent)] + Node(#[from] NodeError), +} + +/// Errors related to individual nodes within a tree. +#[derive(Error, Debug)] +pub enum NodeError { + /// Raised when a node with the specified id cannot be found in the tree. + #[error("Node with id '{id}' not found")] + NodeNotFound { id: String }, + + /// Raised when the tree does not have a root node defined. + #[error("Root node not found")] + RootNodeMissing, + + /// Raised when a node exists but lacks an identifier. + #[error("Node has no ID")] + NodeMissingId, +} diff --git a/engine/src/errors/unpack_error.rs b/engine/src/errors/unpack_error.rs new file mode 100644 index 0000000..af690ea --- /dev/null +++ b/engine/src/errors/unpack_error.rs @@ -0,0 +1,20 @@ +use crate::objects::meva_object_type::MevaObjectType; + +/// Errors that can occur while unpacking a [`MevaObject`] into its +/// high-level representation. +/// +/// These errors indicate mismatches or inconsistencies between the +/// declared object type and its actual serialized data. +#[derive(thiserror::Error, Debug)] +pub enum UnpackError { + /// Raised when the object's declared [`MevaObjectType`] does not + /// match the expected variant during unpacking. + /// + /// For example, attempting to unpack a `Tree` object as a `Blob` + /// will trigger this error. + #[error("Object type mismatch: expected {expected:?}, actual {actual:?}.")] + ObjectTypeMismatch { + expected: MevaObjectType, + actual: MevaObjectType, + }, +} diff --git a/engine/src/hasher.rs b/engine/src/hasher.rs index 20a988b..47c87f4 100644 --- a/engine/src/hasher.rs +++ b/engine/src/hasher.rs @@ -1,2 +1,12 @@ -pub mod hasher_trait; pub mod meva_hasher; + +pub use meva_hasher::MevaHasher; + +/// Collection of utility functions for computing cryptographic hashes +/// used in the Meva repository. +/// +/// Currently, this trait provides a single function for computing **SHA-1** +/// hashes of in-memory data. +pub trait Hasher: Send + Sync { + fn sha1(&self, data: &[u8]) -> String; +} diff --git a/engine/src/hasher/hasher_trait.rs b/engine/src/hasher/hasher_trait.rs deleted file mode 100644 index c4eb6c6..0000000 --- a/engine/src/hasher/hasher_trait.rs +++ /dev/null @@ -1,8 +0,0 @@ -/// Collection of utility functions for computing cryptographic hashes -/// used in the Meva repository. -/// -/// Currently, this trait provides a single function for computing **SHA-1** -/// hashes of in-memory data. -pub trait HasherTrait: Send + Sync { - fn sha1(&self, data: &[u8]) -> String; -} diff --git a/engine/src/hasher/meva_hasher.rs b/engine/src/hasher/meva_hasher.rs index b89f538..8048ce9 100644 --- a/engine/src/hasher/meva_hasher.rs +++ b/engine/src/hasher/meva_hasher.rs @@ -1,4 +1,4 @@ -use crate::hasher::hasher_trait::HasherTrait; +use crate::hasher::Hasher; use sha1::{Digest, Sha1}; /// Default implementation of [`HasherTrait`] used within the Meva project. @@ -6,7 +6,7 @@ use sha1::{Digest, Sha1}; /// `MevaHasher` is stateless and provides only static-style methods. pub struct MevaHasher; -impl HasherTrait for MevaHasher { +impl Hasher for MevaHasher { /// Computes the SHA-1 hash of the given byte slice. /// /// The input data is provided as a slice of bytes (`&[u8]`), and the diff --git a/engine/src/ignore/ignore_service.rs b/engine/src/ignore/ignore_service.rs index 22866cb..40d752a 100644 --- a/engine/src/ignore/ignore_service.rs +++ b/engine/src/ignore/ignore_service.rs @@ -12,9 +12,7 @@ use crate::{ errors::{EngineResult, IgnoreError}, ignore::{IgnoreOperations, IgnoreResult}, }; -use shared::UpwardSearch; -use shared::extensions::canonicalize_clean::CanonicalizeClean; -use shared::extensions::is_within::IsWithin; +use shared::{CanonicalizeClean, IsWithin, UpwardSearch}; /// Service implementing ignore file operations for Meva repositories. /// diff --git a/engine/src/index.rs b/engine/src/index.rs index a3e9903..493f05f 100644 --- a/engine/src/index.rs +++ b/engine/src/index.rs @@ -3,3 +3,8 @@ pub mod index_entry; pub mod meva_index; pub mod serde_utils; pub mod stage; + +use index_entry::IndexEntry; +use serde_utils::deserialize_entries_with_absolute_keys; + +pub use meva_index::MevaIndex; diff --git a/engine/src/index/meva_index.rs b/engine/src/index/meva_index.rs index 28b344e..50ab9b6 100644 --- a/engine/src/index/meva_index.rs +++ b/engine/src/index/meva_index.rs @@ -8,13 +8,13 @@ use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use serde_json::Deserializer; use walkdir::WalkDir; -use crate::errors::path_error::PathError; -use crate::errors::{EngineError, EngineResult, IndexError}; -use crate::index::index_entry::IndexEntry; -use crate::index::serde_utils::deserialize_entries_with_absolute_keys; -use crate::object_storage::meva_object_storage::ObjectStorage; +use crate::errors::{EngineResult, IndexError}; +use crate::index::{IndexEntry, deserialize_entries_with_absolute_keys}; +use crate::object_storage::{MevaObjectStorage, ObjectStorage}; +use crate::objects::MevaBlob; +use crate::utils::StripBase; use crate::{IgnoreOperations, IgnoreResult, IgnoreService, RepositoryLayout}; -use shared::extensions::canonicalize_clean::CanonicalizeClean; +use shared::CanonicalizeClean; /// Represents the in-memory index of tracked files in a Meva repository. /// @@ -41,6 +41,26 @@ impl<'a> MevaIndex<'a> { }) } + /// Creates a new `MevaIndex` and immediately loads its state from disk. + /// + /// This is equivalent to calling [`MevaIndex::new`] followed by [`MevaIndex::load`]. + /// + /// Returns an error if the index file cannot be read or parsed. + pub fn new_and_load(repo_layout: &'a dyn RepositoryLayout) -> EngineResult { + let mut meva_index = MevaIndex::new(repo_layout)?; + meva_index.load()?; + + Ok(meva_index) + } + + /// Returns all entries currently tracked in the index. + /// + /// The returned vector contains borrowed references to the underlying + /// [`IndexEntry`] values, in unspecified order. + pub fn get_entries(&self) -> Vec<&IndexEntry> { + self.entries.values().collect() + } + /// Loads the index file from disk into memory. /// /// If the file does not exist or is empty, the index will be cleared. @@ -69,6 +89,59 @@ impl<'a> MevaIndex<'a> { Ok(()) } + /// Adds files to the index according to the provided flags: + /// + /// - `add_new`: include new files, + /// - `add_deleted`: remove missing files, + /// - `add_ignored`: include ignored files (skip ignore check), + /// - `verbose`: print operations to stdout. + /// + /// The path provided (`path_abs`) is the starting point for traversal. + pub fn add( + &mut self, + path_abs: &PathBuf, + add_new: bool, + add_deleted: bool, + add_ignored: bool, + verbose: bool, + ) -> EngineResult<(Vec, Vec, Vec)> { + let ignore_services = match add_ignored { + true => Vec::new(), + false => self.create_ignore_services(), + }; + + let files_abs = self.collect_files(path_abs, ignore_services); + + let (mut new_entries, mut modified_entries) = self.group_files(&files_abs)?; + + let mut new_files = Vec::::new(); + let mut deleted_files = Vec::::new(); + let modified_files = modified_entries + .iter() + .map(|e| PathBuf::from(e.path.clone())) + .collect(); + + if add_deleted { + deleted_files = self + .remove_deleted_files(path_abs, &files_abs, verbose) + .into_iter() + .map(PathBuf::from) + .collect(); + } + + self.update_index(&mut modified_entries, verbose)?; + + if add_new { + self.update_index(&mut new_entries, verbose)?; + new_files = new_entries + .into_iter() + .map(|e| PathBuf::from(e.path)) + .collect(); + } + + Ok((new_files, modified_files, deleted_files)) + } + /// Saves the in-memory index back to disk. /// /// - Converts absolute paths in entries into repository-relative paths. @@ -87,16 +160,8 @@ impl<'a> MevaIndex<'a> { let meva_repository_dir = self.repo_layout.working_dir().canonicalize_clean()?; for entry in self.entries.values_mut() { - let entry_absolute_path = PathBuf::from(entry.path.clone()); - let relative_path = match entry_absolute_path.strip_prefix(&meva_repository_dir) { - Ok(relative_path) => relative_path, - Err(_) => { - return Err(EngineError::from(PathError::NotInBaseDirectory { - path: entry_absolute_path, - path_base: meva_repository_dir, - })); - } - }; + let entry_absolute_path = Path::new(&entry.path); + let relative_path = entry_absolute_path.strip_base(&meva_repository_dir)?; entry.path = relative_path.to_string_lossy().to_string(); } @@ -155,13 +220,14 @@ impl<'a> MevaIndex<'a> { index_entries: &mut Vec, verbose: bool, ) -> EngineResult<()> { - let object_storage = ObjectStorage::new(self.repo_layout); + let object_storage = MevaObjectStorage::new(self.repo_layout); let result: Vec<_> = index_entries .par_iter() .map(|index_entry| { let mut updated_entry = index_entry.clone(); - updated_entry.sha1 = object_storage.add_file(index_entry.path.as_ref())?; + let blob = MevaBlob::from_file(index_entry.path.as_ref())?; + updated_entry.sha1 = object_storage.add_blob(blob)?; if verbose { println!("add: {}", updated_entry.path) } @@ -209,7 +275,7 @@ impl<'a> MevaIndex<'a> { /// /// This scans the working directory downward for ignore files and builds /// services with cached glob patterns. - pub fn create_ignore_services(&self) -> Vec { + fn create_ignore_services(&self) -> Vec { let ignore_service = IgnoreService::new(self.repo_layout.ignore_file_name()); let ignore_files_abs: Vec = ignore_service.find_ignore_files_down(self.repo_layout.working_dir()); @@ -222,59 +288,6 @@ impl<'a> MevaIndex<'a> { .collect() } - /// Adds files to the index according to the provided flags: - /// - /// - `add_new`: include new files, - /// - `add_deleted`: remove missing files, - /// - `add_ignored`: include ignored files (skip ignore check), - /// - `verbose`: print operations to stdout. - /// - /// The path provided (`path_abs`) is the starting point for traversal. - pub fn add( - &mut self, - path_abs: &PathBuf, - add_new: bool, - add_deleted: bool, - add_ignored: bool, - verbose: bool, - ) -> EngineResult<(Vec, Vec, Vec)> { - let ignore_services = match add_ignored { - true => Vec::new(), - false => self.create_ignore_services(), - }; - - let files_abs = self.collect_files(path_abs, ignore_services); - - let (mut new_entries, mut modified_entries) = self.group_files(&files_abs)?; - - let mut new_files = Vec::::new(); - let mut deleted_files = Vec::::new(); - let modified_files = modified_entries - .iter() - .map(|e| PathBuf::from(e.path.clone())) - .collect(); - - if add_deleted { - deleted_files = self - .remove_deleted_files(path_abs, &files_abs, verbose) - .into_iter() - .map(PathBuf::from) - .collect(); - } - - self.update_index(&mut modified_entries, verbose)?; - - if add_new { - self.update_index(&mut new_entries, verbose)?; - new_files = new_entries - .into_iter() - .map(|e| PathBuf::from(e.path)) - .collect(); - } - - Ok((new_files, modified_files, deleted_files)) - } - /// Recursively collects files under a given path, applying ignore services /// if provided. /// diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 7ecd1b9..7b143c2 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,6 +1,11 @@ +mod commit_builder; mod object_storage; +mod serialize_deserialize; +mod utils; pub mod add; +pub mod branch_manager; +pub mod commit; pub mod config; pub mod engine_container; pub mod errors; @@ -8,14 +13,20 @@ pub mod hasher; pub mod ignore; pub mod index; pub mod init; +pub mod objects; pub mod plugins; pub mod plugins_interceptor; +pub mod ref_manager; pub mod repositories; use errors::{EngineError, EngineResult, InitError}; +use object_storage::ObjectStorage; +use ref_manager::RefManager; +pub use branch_manager::BranchManger; pub use config::{ConfigDocument, ConfigHandler, ConfigLoader, ConfigLocation}; pub use engine_container::EngineContainer; +pub use hasher::Hasher; pub use ignore::{IgnoreOperations, IgnoreResult, IgnoreService}; pub use init::InitHandler; -pub use repositories::{MevaRepository, RepositoryLayout}; +pub use repositories::{MevaRepository, MutableRepositoryLayout, RepositoryLayout}; diff --git a/engine/src/object_storage.rs b/engine/src/object_storage.rs index e35e857..505f86b 100644 --- a/engine/src/object_storage.rs +++ b/engine/src/object_storage.rs @@ -1,6 +1,42 @@ -mod meva_object; -mod meva_object_type; - +pub mod meva_dry_run_object_storage; pub mod meva_object_storage; -pub use crate::hasher::{hasher_trait::HasherTrait, meva_hasher::MevaHasher}; +use crate::errors::EngineResult; +use crate::objects::meva_blob::MevaBlob; +use crate::objects::meva_commit::MevaCommit; +use crate::objects::meva_object::MevaObject; +use crate::objects::meva_tree::MevaTree; + +pub use meva_dry_run_object_storage::MevaDryRunObjectStorage; +pub use meva_object_storage::MevaObjectStorage; + +/// Defines a common interface for handling repository objects. +/// +/// Implementations of this trait are responsible for processing +/// objects of different types — such as blobs, trees, and commits — +/// and returning their unique content hash. +/// +/// The specific behavior depends on the implementation: +/// - a storage backend may **persist** objects on disk, +/// - a mock or dry-run backend may **simulate** the operation +/// without actually writing any data. +/// +/// Each method returns the computed SHA-1 hash of the processed object. +/// +/// # Errors +/// +/// All methods return an [`EngineResult`] that may contain serialization +/// or processing errors. +pub trait ObjectStorage { + /// Processes a [`MevaBlob`] (raw file data) and returns its hash. + fn add_blob(&self, blob: MevaBlob) -> EngineResult; + + /// Processes a [`MevaTree`] (directory structure) and returns its hash. + fn add_tree(&self, tree: MevaTree) -> EngineResult; + + /// Processes a [`MevaCommit`] (repository commit metadata) and returns its hash. + fn add_commit(&self, commit: MevaCommit) -> EngineResult; + + /// Retrieves and deserializes a [`MevaObject`] from storage by its hash. + fn get_object(&self, hash: &str) -> EngineResult; +} diff --git a/engine/src/object_storage/meva_dry_run_object_storage.rs b/engine/src/object_storage/meva_dry_run_object_storage.rs new file mode 100644 index 0000000..832f159 --- /dev/null +++ b/engine/src/object_storage/meva_dry_run_object_storage.rs @@ -0,0 +1,53 @@ +use crate::ObjectStorage; +use crate::errors::EngineResult; +use crate::objects::{MevaBlob, MevaCommit, MevaObject, MevaTree}; + +/// A no-op implementation of [`ObjectStorage`] that performs +/// all transformations in memory without writing any data to disk. +/// +/// `MevaDryRunObjectStorage` can be used for testing, validation, +/// or previewing operations that would normally store objects. +/// It serializes objects into their binary form, computes their SHA-1 hash, +/// and returns it — but skips compression and file creation. +/// +/// # Typical Use Cases +/// +/// - **Dry runs**: preview commit or object creation without modifying repository state. +/// - **Testing**: verify logic producing [`MevaBlob`], [`MevaTree`], or [`MevaCommit`] +/// without needing a physical repository. +pub struct MevaDryRunObjectStorage; + +impl MevaDryRunObjectStorage { + /// Creates a new dry-run object storage instance. + pub fn new() -> Self { + Self {} + } +} + +impl ObjectStorage for MevaDryRunObjectStorage { + /// Processes a blob and returns its computed hash without writing it. + fn add_blob(&self, blob: MevaBlob) -> EngineResult { + let object = MevaObject::try_from(blob)?; + Ok(object.sha1().clone()) + } + + /// Processes a tree and returns its computed hash without writing it. + fn add_tree(&self, tree: MevaTree) -> EngineResult { + let object = MevaObject::try_from(tree)?; + Ok(object.sha1().clone()) + } + + /// Processes a commit and returns its computed hash without writing it. + fn add_commit(&self, commit: MevaCommit) -> EngineResult { + let object = MevaObject::try_from(commit)?; + Ok(object.sha1().clone()) + } + + /// Returns a placeholder [`MevaObject`] instead of reading from disk. + /// + /// This method allows components that depend on object retrieval to + /// operate in dry-run mode without accessing the filesystem. + fn get_object(&self, _hash: &str) -> EngineResult { + Ok(MevaObject::default()) + } +} diff --git a/engine/src/object_storage/meva_object.rs b/engine/src/object_storage/meva_object.rs deleted file mode 100644 index af90956..0000000 --- a/engine/src/object_storage/meva_object.rs +++ /dev/null @@ -1,73 +0,0 @@ -use crate::errors::EngineResult; -use crate::object_storage::HasherTrait; -use crate::object_storage::meva_object_type::MevaObjectType; -use bincode::Encode; -use flate2::{Compression, write::ZlibEncoder}; -use std::io::Write; - -/// Represents a stored object in the Meva repository. -/// -/// A `MevaObject` bundles an object type together with its raw binary data. -/// It can be serialized into a binary form, hashed, or compressed for -/// efficient storage and transmission. -/// -/// Objects are encoded using [`bincode`] with fixed integer encoding -/// and little-endian byte order. -#[derive(Encode)] -pub struct MevaObject { - /// The logical type of this object (e.g., blob, tree, commit). - pub object_type: MevaObjectType, - - /// Raw binary payload of the object. - pub data: Vec, -} - -impl MevaObject { - /// Serializes the object into a binary representation using [`bincode`]. - /// - /// Uses fixed integer encoding and little-endian byte order to ensure - /// consistent hashing and cross-platform compatibility. - /// - /// # Errors - /// - /// Returns an [`EngineError`] if serialization fails. - fn to_bytes(&self) -> EngineResult> { - let configuration = bincode::config::standard() - .with_fixed_int_encoding() - .with_little_endian(); - let bytes = bincode::encode_to_vec(self, configuration)?; - Ok(bytes) - } - - /// Computes the SHA-1 hash of the serialized object using a provided hasher. - /// - /// The object is first serialized into a binary representation via [`Self::to_bytes`]. - /// The resulting bytes are then passed to the given `hasher` to compute the SHA-1 hash. - /// - /// # Parameters - /// - /// * `hasher` - A reference to an object implementing [`HasherTrait`] used to perform - /// the SHA-1 hashing. This allows for custom or mock hashers in testing. - /// - /// # Errors - /// - /// Returns an [`EngineError`] if serialization of the object fails. - pub fn sha1(&self, hasher: &dyn HasherTrait) -> EngineResult { - let h = self.to_bytes()?; - Ok(hasher.sha1(&h)) - } - - /// Returns a zlib-compressed version of the serialized object. - /// - /// Compression is performed using the best available zlib compression level. - /// - /// # Errors - /// - /// Returns an [`EngineError`] if serialization or compression fails. - pub fn compressed(&self) -> EngineResult> { - let bytes = self.to_bytes()?; - let mut encoder = ZlibEncoder::new(Vec::with_capacity(bytes.len()), Compression::best()); - encoder.write_all(&bytes)?; - Ok(encoder.finish()?) - } -} diff --git a/engine/src/object_storage/meva_object_storage.rs b/engine/src/object_storage/meva_object_storage.rs index 8a8f538..3f59b46 100644 --- a/engine/src/object_storage/meva_object_storage.rs +++ b/engine/src/object_storage/meva_object_storage.rs @@ -1,12 +1,11 @@ +use crate::ObjectStorage; use crate::RepositoryLayout; -use crate::errors::EngineResult; -use crate::errors::path_error::PathError; -use crate::object_storage::MevaHasher; -use crate::object_storage::meva_object::MevaObject; -use crate::object_storage::meva_object_type::MevaObjectType; -use std::fs::OpenOptions; +use crate::errors::{EngineResult, PathError}; +use crate::objects::{MevaBlob, MevaCommit, MevaObject, MevaObjectDecodeContext, MevaTree}; +use crate::serialize_deserialize::BinaryCompress; +use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; /// Provides low-level object storage for the Meva repository. /// @@ -21,13 +20,15 @@ use std::path::{Path, PathBuf}; /// objects/aa/bb... (where `aa` are the first two hex chars of the hash) /// ``` /// -/// Objects are compressed using zlib for efficient storage. -pub struct ObjectStorage<'a> { +/// Objects are compressed before being written to disk for efficient storage. +/// If an object with the same hash already exists, it is not overwritten, +/// because identical hashes guarantee identical content. +pub struct MevaObjectStorage<'a> { /// Provides access to repository paths such as the `objects` directory. repo_layout: &'a dyn RepositoryLayout, } -impl<'a> ObjectStorage<'a> { - /// Creates a new [`ObjectStorage`] instance for the given repository layout. +impl<'a> MevaObjectStorage<'a> { + /// Creates a new [`MevaObjectStorage`] instance for the given repository layout. /// /// # Arguments /// @@ -37,78 +38,103 @@ impl<'a> ObjectStorage<'a> { Self { repo_layout } } - /// Adds a file as a blob object to the repository and returns its SHA-1 hash. + /// Stores a [`MevaObject`] on disk under its SHA-1-derived path. /// - /// The file is read in full, wrapped in a [`MevaObject`] of type - /// [`MevaObjectType::Blob`], serialized, compressed, and stored under - /// the repository’s `objects` directory. + /// If the object already exists (another process wrote the same content), + /// the write is skipped without error, since the data is guaranteed to be identical. /// - /// If an object with the same hash already exists, it is not overwritten. - /// - /// # Arguments - /// - /// * `path` – Path to the file to be added. - /// - /// # Returns - /// - /// A `String` containing the hexadecimal SHA-1 hash of the stored object. + /// Returns the SHA-1 hash of the object. /// /// # Errors /// - /// Returns an [`EngineResult::Err`] if: - /// - the file cannot be opened or read, - /// - serialization or compression fails, - /// - the target directory cannot be created, - /// - or the object file cannot be created. - pub fn add_file(&self, path: &Path) -> EngineResult { - let mut file = OpenOptions::new().read(true).open(path)?; - let mut buffer = Vec::new(); - file.read_to_end(&mut buffer)?; - - let object = MevaObject { - object_type: MevaObjectType::Blob, - data: buffer, - }; + /// Returns an error if the parent directory cannot be created or if + /// writing the object to disk fails for reasons other than + /// `AlreadyExists`. + fn add_object(&self, object: MevaObject) -> EngineResult { + let hash = object.sha1(); - let hash = object.sha1(&MevaHasher {})?; + let object_path = self.object_path(hash); - let object_path = self.object_path(&hash); + let parent = object_path.parent().ok_or(PathError::NoParent { + path: object_path.clone(), + })?; + if !parent.exists() { + std::fs::create_dir_all(parent)?; + } - if !object_path.exists() { - let parent = object_path.parent().ok_or(PathError::NoParent { - path: object_path.clone(), - })?; - if !parent.exists() { - std::fs::create_dir_all(parent)?; + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&object_path) + { + Ok(mut blob_file) => { + let compressed_blob = object.to_compressed_bytes()?; + blob_file.write_all(&compressed_blob)?; } - let compressed_data = object.compressed()?; - - match OpenOptions::new() - .write(true) - .create_new(true) - .open(&object_path) - { - Ok(mut blob_file) => { - blob_file.write_all(&compressed_data)?; - } - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - // another thread or process already wrote this blob - // (or a different file with identical content produced the same hash) - // nothing to do here, since the hash guarantees the data is identical - } - Err(e) => return Err(e.into()), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + // another thread or process already wrote this blob + // (or a different file with identical content produced the same hash) + // nothing to do here, since the hash guarantees the data is identical } + Err(e) => return Err(e.into()), } - Ok(hash) + Ok(hash.clone()) } - /// Returns the full on-disk path where an object with the given hash is stored. + /// Returns the full on-disk path for the given hash. /// /// The path is constructed by splitting the first two characters of - /// the hash into a subdirectory + /// the hash into a subdirectory, e.g.: + /// + /// ```text + /// objects/aa/bb... + /// ``` fn object_path(&self, hash: &str) -> PathBuf { let (directory, file) = hash.split_at(2); self.repo_layout.objects_dir().join(directory).join(file) } } + +impl<'a> ObjectStorage for MevaObjectStorage<'a> { + /// Converts a [`MevaBlob`] into a [`MevaObject`] and stores it on disk. + /// + /// Returns the SHA-1 hash of the stored object. + fn add_blob(&self, blob: MevaBlob) -> EngineResult { + let object = MevaObject::try_from(blob)?; + self.add_object(object) + } + + /// Converts a [`MevaTree`] into a [`MevaObject`] and stores it on disk. + /// + /// Returns the SHA-1 hash of the stored object. + fn add_tree(&self, tree: MevaTree) -> EngineResult { + let object = MevaObject::try_from(tree)?; + self.add_object(object) + } + + /// Converts a [`MevaCommit`] into a [`MevaObject`] and stores it on disk. + /// + /// Returns the SHA-1 hash of the stored object. + fn add_commit(&self, commit: MevaCommit) -> EngineResult { + let object = MevaObject::try_from(commit)?; + self.add_object(object) + } + + /// Loads a [`MevaObject`] from disk using its SHA-1 hash. + /// + /// Reads, decompresses, and deserializes the object into memory. + fn get_object(&self, hash: &str) -> EngineResult { + let path = self.object_path(hash); + let mut file = File::open(path)?; + let mut compressed: Vec = Vec::new(); + file.read_to_end(&mut compressed)?; + + let context = MevaObjectDecodeContext { + hasher: None, + known_sha1: Some(hash.to_string()), + }; + + MevaObject::from_compressed_bytes_with_context(&compressed, &context) + } +} diff --git a/engine/src/objects.rs b/engine/src/objects.rs new file mode 100644 index 0000000..8846d7d --- /dev/null +++ b/engine/src/objects.rs @@ -0,0 +1,19 @@ +mod tree_entry_type; + +pub mod meva_blob; +pub mod meva_commit; +pub mod meva_object; +pub mod meva_object_decode_context; +pub mod meva_object_type; +pub mod meva_tree; +pub mod person; +pub mod tree_entry; + +pub use meva_blob::MevaBlob; +pub use meva_commit::MevaCommit; +pub use meva_object::MevaObject; +pub use meva_object_decode_context::MevaObjectDecodeContext; +pub use meva_object_type::MevaObjectType; +pub use meva_tree::MevaTree; +pub use person::Person; +pub use tree_entry::TreeEntry; diff --git a/engine/src/objects/meva_blob.rs b/engine/src/objects/meva_blob.rs new file mode 100644 index 0000000..2ce31fc --- /dev/null +++ b/engine/src/objects/meva_blob.rs @@ -0,0 +1,67 @@ +use crate::errors::{EngineError, EngineResult, UnpackError}; +use crate::objects::{MevaObject, MevaObjectType}; +use crate::serialize_deserialize::meva_encode::MevaEncode; +use bincode::{Decode, Encode}; +use std::fs::OpenOptions; +use std::io::Read; +use std::path::Path; + +/// Represents a binary object containing raw file data. +/// +/// A [`MevaBlob`] stores the unstructured contents of a file +/// (as a sequence of bytes). It can be created directly from a +/// byte vector or by reading a file from disk. +/// +/// Blobs are serialized into bytes for storage and can be wrapped +/// into a [`MevaObject`] of type [`MevaObjectType::Blob`]. +#[derive(Encode, Decode)] +pub struct MevaBlob { + /// The raw file contents stored as bytes. + pub data: Vec, +} +impl MevaBlob { + /// Creates a new [`MevaBlob`] from the given raw byte data. + pub fn new(data: Vec) -> Self { + Self { data } + } + + /// Reads the contents of the file at the given path into a new [`MevaBlob`]. + /// + /// # Errors + /// + /// Returns an [`EngineError`](crate::errors::EngineError) if the file + /// cannot be opened or read. + pub fn from_file(path: &Path) -> EngineResult { + let mut file = OpenOptions::new().read(true).open(path)?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer)?; + + Ok(Self::new(buffer)) + } +} + +/// Converts a `MevaObject` into a `MevaBlob`. +/// +/// # Behavior +/// +/// - If the object's type is `Blob`, it deserializes the raw data into a `MevaBlob`. +/// - If the object's type is not `Blob`, returns an `UnpackError::ObjectTypeMismatch`. +/// +/// # Errors +/// +/// Returns an [`EngineError`] if deserialization fails or the type does not match. +impl TryFrom for MevaBlob { + type Error = EngineError; + fn try_from(value: MevaObject) -> Result { + match value.object_type() { + MevaObjectType::Blob => Ok(MevaBlob::from_bytes(value.data().as_slice())?), + object_type => Err(UnpackError::ObjectTypeMismatch { + expected: MevaObjectType::Blob, + actual: object_type.clone(), + } + .into()), + } + } +} + +impl MevaEncode for MevaBlob {} diff --git a/engine/src/objects/meva_commit.rs b/engine/src/objects/meva_commit.rs new file mode 100644 index 0000000..ed0a848 --- /dev/null +++ b/engine/src/objects/meva_commit.rs @@ -0,0 +1,120 @@ +use crate::errors::{EngineError, UnpackError}; +use crate::objects::{MevaObject, MevaObjectType, Person}; +use crate::serialize_deserialize::MevaEncode; +use bincode::{de::Decoder, enc::Encoder, error::DecodeError}; +use chrono::{DateTime, Utc}; + +/// Represents a commit object in the Meva repository. +/// +/// A commit captures a snapshot of the repository state by referencing +/// a tree (directory structure) along with its parent commits, author, +/// message, and timestamp. Commits form the history of changes and +/// establish the relationships between successive repository states. +#[derive(Debug, Clone)] +pub struct MevaCommit { + /// The hash of the root tree object describing the directory layout + /// and file entries for this commit. + pub tree: String, + + /// A list of hashes referencing parent commits. + /// + /// - For the initial commit this will be empty. + /// - For a standard commit it contains exactly one parent. + /// - For merges it can contain multiple parents. + pub parents: Vec, + + /// The descriptive message associated with this commit. + pub message: String, + + /// Information about the person who authored the commit. + pub author: Person, + + /// The UTC timestamp when this commit was created + pub timestamp: DateTime, +} + +impl MevaCommit { + /// Creates a new [`MevaCommit`] instance with the provided data. + /// + /// Typically, commits are constructed by higher-level components + /// such as the commit builder rather than directly. + pub fn new(tree: String, message: String, author: Person, timestamp: DateTime) -> Self { + Self { + tree, + parents: Vec::new(), + message, + author, + timestamp, + } + } +} + +/// Converts a `MevaObject` into a `MevaCommit`. +/// +/// # Behavior +/// +/// - If the object's type is `Commit`, it deserializes the raw data into a `MevaCommit`. +/// - If the object's type is not `Commit`, returns an `UnpackError::ObjectTypeMismatch`. +/// +/// # Errors +/// +/// Returns an [`EngineError`] if deserialization fails or the type does not match. +impl TryFrom for MevaCommit { + type Error = EngineError; + fn try_from(value: MevaObject) -> Result { + match value.object_type() { + MevaObjectType::Commit => Ok(MevaCommit::from_bytes(value.data().as_slice())?), + object_type => Err(UnpackError::ObjectTypeMismatch { + expected: MevaObjectType::Commit, + actual: object_type.clone(), + } + .into()), + } + } +} + +impl MevaEncode for MevaCommit {} + +impl bincode::Encode for MevaCommit { + /// Custom encoding logic for bincode serialization. + /// + /// Fields are serialized in the following order: + /// 1. `tree` + /// 2. `parent` + /// 3. `message` + /// 4. `author` + /// 5. `timestamp` (as a Unix timestamp, i64) + fn encode(&self, encoder: &mut E) -> Result<(), bincode::error::EncodeError> { + self.tree.encode(encoder)?; + self.parents.encode(encoder)?; + self.message.encode(encoder)?; + self.author.encode(encoder)?; + + let ts = self.timestamp.timestamp(); + ts.encode(encoder)?; + + Ok(()) + } +} + +impl bincode::Decode<()> for MevaCommit { + fn decode>(decoder: &mut D) -> Result { + let tree: String = bincode::Decode::decode(decoder)?; + let parents: Vec = bincode::Decode::decode(decoder)?; + let message: String = bincode::Decode::decode(decoder)?; + let author: Person = bincode::Decode::decode(decoder)?; + let timestamp_i64 = bincode::Decode::decode(decoder)?; + + let timestamp = DateTime::from_timestamp(timestamp_i64, 0).ok_or( + DecodeError::OtherString("Failed to decode timestamp".to_string()), + )?; + + Ok(MevaCommit { + tree, + parents, + message, + author, + timestamp, + }) + } +} diff --git a/engine/src/objects/meva_object.rs b/engine/src/objects/meva_object.rs new file mode 100644 index 0000000..2778ff7 --- /dev/null +++ b/engine/src/objects/meva_object.rs @@ -0,0 +1,313 @@ +use crate::errors::{EngineError, EngineResult}; +use crate::hasher::{Hasher, MevaHasher}; +use crate::objects::{MevaBlob, MevaCommit, MevaObjectDecodeContext, MevaObjectType, MevaTree}; +use crate::serialize_deserialize::{BinaryCompress, MevaEncode, MevaEncodeMut}; +use bincode::{ + Decode, Encode, + de::Decoder, + enc::Encoder, + error::{DecodeError, EncodeError}, +}; +use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder}; +use std::{ + io::{Read, Write}, + rc::Rc, +}; + +/// Represents a stored object in the Meva repository. +/// +/// A `MevaObject` bundles a logical object type (`MevaObjectType`) together +/// with its raw binary data. It can be serialized, deserialized, hashed, +/// or compressed for efficient storage and transmission. +/// +/// This object is specifically designed for use within the Meva repository +/// and is not intended as a general-purpose data container. +/// +/// # Encoding and Decoding +/// +/// `MevaObject` implements [`bincode::Encode`] and [`bincode::Decode`] using +/// fixed integer encoding and little-endian byte order to ensure stable +/// serialization, consistent hashing, and cross-platform compatibility. +/// +/// # SHA-1 Hashing +/// +/// Each `MevaObject` computes a SHA-1 hash of its serialized content using +/// a provided hasher implementing [`Hasher`]. The hash is automatically +/// updated upon object creation or deserialization. +/// +/// # Default +/// +/// `MevaObject::default()` creates an object with: +/// - `object_type` set to [`MevaObjectType::Blob`] +/// - empty `data` +/// - default SHA-1 hash (calculated from empty data) +/// - a standard `MevaHasher` wrapped in `Rc` +pub struct MevaObject { + /// The logical type of this object (e.g., blob, tree, commit). + object_type: MevaObjectType, + + /// Raw binary payload of the object. + data: Vec, + + sha1: String, + + hasher: Rc, +} + +impl MevaObject { + /// Creates a new [`MevaObject`] from the given type and raw data. + /// + /// This method directly constructs a `MevaObject`, calculates its SHA-1 hash, + /// and assigns a default hasher. + /// + /// **Note:** Direct use of this constructor is **not recommended**. + /// Prefer using [`IntoMevaObject::into_meva_object`] for safer and + /// type-consistent object creation. + /// + /// # Arguments + /// + /// * `object_type` – The logical type of the object (blob, tree, or commit). + /// * `data` – Raw binary content of the object. + /// + /// # Errors + /// + /// Returns an [`EngineResult`] if SHA-1 computation fails. + pub fn new(object_type: MevaObjectType, data: Vec) -> EngineResult { + let mut self_temp = Self::default(); + self_temp.object_type = object_type; + self_temp.data = data; + self_temp.sha1 = self_temp.calculate_sha1()?; + + Ok(self_temp) + } + + /// Returns the SHA-1 hash of this `MevaObject`. + /// + /// This is a simple getter for the internally stored `sha1` field. + /// The hash is automatically calculated when the object is created + /// or deserialized. + pub fn sha1(&self) -> &String { + &self.sha1 + } + + /// Returns the logical type of this `MevaObject`. + /// + /// Indicates whether the object is a blob, tree, or commit. + pub fn object_type(&self) -> &MevaObjectType { + &self.object_type + } + + /// Returns the raw binary data of this `MevaObject`. + /// + /// This is the serialized payload corresponding to the object's logical type. + pub fn data(&self) -> &Vec { + &self.data + } + + /// Computes the SHA-1 hash of the serialized object using a provided hasher. + /// + /// The object is first serialized into a binary representation via [`Self::to_bytes`]. + /// The resulting bytes are then passed to the given `hasher` to compute the SHA-1 hash. + /// + /// # Parameters + /// + /// * `hasher` - A reference to an object implementing [`Hasher`] used to perform + /// the SHA-1 hashing. This allows for custom or mock hashers in testing. + /// + /// # Errors + /// + /// Returns an [`EngineError`] if serialization of the object fails. + fn calculate_sha1(&self) -> EngineResult { + let h = self.to_bytes()?; + Ok(self.hasher.sha1(&h)) + } +} + +impl Default for MevaObject { + /// Creates a default `MevaObject` with: + /// - `object_type` set to [`MevaObjectType::Blob`] + /// - empty `data` + /// - SHA-1 hash calculated from the empty data + /// - `hasher` set to a standard `MevaHasher` wrapped in `Rc` + fn default() -> Self { + Self { + object_type: MevaObjectType::default(), + data: Vec::default(), + sha1: String::default(), + hasher: Rc::new(MevaHasher), + } + } +} + +impl MevaEncode for MevaObject {} + +impl BinaryCompress for MevaObject { + /// Serializes and compresses the [`MevaObject`] into a zlib-compressed binary buffer. + /// + /// Compression is performed using the best available zlib level. + /// This method is primarily used when preparing an object for storage on disk. + /// + /// # Errors + /// + /// Returns an error if serialization or compression fails. + fn to_compressed_bytes(&self) -> EngineResult> + where + Self: Encode, + { + let bytes = self.to_bytes()?; + let mut encoder = ZlibEncoder::new(Vec::with_capacity(bytes.len()), Compression::best()); + encoder.write_all(&bytes)?; + Ok(encoder.finish()?) + } + + /// Decompresses and deserializes a zlib-compressed byte buffer into a [`MevaObject`]. + /// + /// This method reverses [`Self::to_compressed_bytes`], restoring the original + /// object from its compressed form. + /// + /// # Errors + /// + /// Returns an error if decompression or deserialization fails. + fn from_compressed_bytes(bytes: &[u8]) -> EngineResult + where + Self: Sized, + { + let mut decoder = ZlibDecoder::new(bytes); + let mut decompressed = Vec::new(); + decoder.read_to_end(&mut decompressed)?; + + Self::from_bytes(&decompressed) + } + + /// Decompresses and deserializes a zlib-compressed byte buffer using a decoding context. + /// + /// Used for deserializing objects that require additional decoding context + /// (e.g., for types with references or contextual dependencies). + /// + /// # Errors + /// + /// Returns an error if decompression or deserialization fails. + fn from_compressed_bytes_with_context<'a, C>(bytes: &[u8], context: &'a C) -> EngineResult + where + Self: Decode<&'a C>, + { + let mut decoder = ZlibDecoder::new(bytes); + let mut decompressed = Vec::new(); + decoder.read_to_end(&mut decompressed)?; + + Self::from_bytes_with_context(&decompressed, context) + } +} + +impl TryFrom for MevaObject { + /// Converts a [`MevaBlob`] into a [`MevaObject`] of type [`MevaObjectType::Blob`]. + /// + /// Used internally to wrap raw blob data into a serializable repository object. + /// + /// # Errors + /// + /// Returns an error if serialization or object creation fails. + type Error = EngineError; + fn try_from(value: MevaBlob) -> Result { + let meva_object = MevaObject::new(MevaObjectType::Blob, value.to_bytes()?)?; + + Ok(meva_object) + } +} + +impl TryFrom for MevaObject { + /// Converts a [`MevaTree`] into a [`MevaObject`] of type [`MevaObjectType::Tree`]. + /// + /// Used internally when persisting directory structures to the object store. + /// + /// # Errors + /// + /// Returns an error if serialization or object creation fails. + type Error = EngineError; + fn try_from(mut value: MevaTree) -> Result { + let meva_object = MevaObject::new(MevaObjectType::Tree, value.to_bytes_mut()?)?; + + Ok(meva_object) + } +} + +impl TryFrom for MevaObject { + /// Converts a [`MevaCommit`] into a [`MevaObject`] of type [`MevaObjectType::Commit`]. + /// + /// Used internally to serialize and store commit metadata as a versioned object. + /// + /// # Errors + /// + /// Returns an error if serialization or object creation fails. + type Error = EngineError; + fn try_from(value: MevaCommit) -> Result { + let meva_object = MevaObject::new(MevaObjectType::Commit, value.to_bytes()?)?; + Ok(meva_object) + } +} + +impl bincode::Encode for MevaObject { + /// Encodes the `MevaObject` into a binary format using `bincode`. + /// + /// Only the `object_type` and `data` fields are serialized. The SHA-1 hash + /// is not included, as it is computed automatically from the serialized content. + /// + /// # Errors + /// + /// Returns an [`EncodeError`] if encoding of either field fails. + fn encode(&self, encoder: &mut E) -> Result<(), EncodeError> { + bincode::Encode::encode(&self.object_type, encoder)?; + bincode::Encode::encode(&self.data, encoder)?; + Ok(()) + } +} + +/// Implements context-aware binary decoding for [`MevaObject`]. +/// +/// This implementation supports decoding with an external +/// [`MevaObjectDecodeContext`], which may provide a precomputed SHA-1 hash +/// and a shared hasher instance. +/// +/// If the context does not include a known SHA-1 hash, it is recalculated +/// automatically after decoding. +impl<'a> bincode::Decode<&'a MevaObjectDecodeContext> for MevaObject { + fn decode>( + decoder: &mut D, + ) -> Result { + let mut meva_object = Self { + object_type: bincode::Decode::decode(decoder)?, + data: bincode::Decode::decode(decoder)?, + ..Default::default() + }; + + let ctx = decoder.context(); + meva_object.sha1 = match &ctx.known_sha1 { + Some(val) => val.clone(), + None => meva_object + .calculate_sha1() + .map_err(|e| DecodeError::OtherString(e.to_string()))?, + }; + + if let Some(hasher) = &ctx.hasher { + meva_object.hasher = hasher.clone(); + } + + Ok(meva_object) + } +} + +/// Provides default binary decoding for [`MevaObject`] without external context. +/// +/// Internally, this creates a temporary [`MevaObjectDecodeContext`] with +/// no known SHA-1 or hasher and delegates decoding to the context-aware +/// implementation above. +impl bincode::Decode<()> for MevaObject { + fn decode>(decoder: &mut D) -> Result { + let context: MevaObjectDecodeContext = MevaObjectDecodeContext { + hasher: None, + known_sha1: None, + }; + let mut sub_decoder = decoder.with_context(&context); + >::decode(&mut sub_decoder) + } +} diff --git a/engine/src/objects/meva_object_decode_context.rs b/engine/src/objects/meva_object_decode_context.rs new file mode 100644 index 0000000..f2b7028 --- /dev/null +++ b/engine/src/objects/meva_object_decode_context.rs @@ -0,0 +1,19 @@ +use crate::Hasher; +use std::rc::Rc; + +/// Provides decoding context for reconstructing [`MevaObject`] instances. +/// +/// This structure allows passing optional metadata and shared resources +/// during deserialization — in particular: +/// - a shared [`Hasher`] instance used to compute or verify SHA-1 hashes, +/// - an already known SHA-1 value to skip recomputation. +/// +/// Used primarily in [`bincode`] decoding with context-aware implementations +/// of [`bincode::Decode`]. +pub struct MevaObjectDecodeContext { + /// Optional shared hasher instance used for computing object hashes. + pub hasher: Option>, + + /// Optional precomputed SHA-1 hash of the object. + pub known_sha1: Option, +} diff --git a/engine/src/object_storage/meva_object_type.rs b/engine/src/objects/meva_object_type.rs similarity index 52% rename from engine/src/object_storage/meva_object_type.rs rename to engine/src/objects/meva_object_type.rs index d0b7f04..5e5159d 100644 --- a/engine/src/object_storage/meva_object_type.rs +++ b/engine/src/objects/meva_object_type.rs @@ -1,18 +1,22 @@ -use bincode::Encode; +use bincode::{Decode, Encode}; /// Represents the logical type of object stored in the Meva repository. /// /// A `MevaObjectType` identifies what kind of content a [`MevaObject`] /// contains. This is used during serialization, hashing, and object interpretation. /// -/// # Encoding +/// This enum is specifically designed for use within the Meva repository, +/// and is not intended as a general-purpose type. /// -/// The enum is [`bincode::Encode`]-able, ensuring a stable binary format -/// for hashing and storage. -#[allow(dead_code)] -#[derive(Encode)] +/// # Encoding and Decoding +/// +/// The enum implements [`bincode::Encode`] and [`bincode::Decode`], ensuring +/// a stable binary format for hashing and storage. This stability is important +/// for consistent object hashes and reliable storage on disk. +#[derive(Encode, Decode, Default, Debug, Clone)] pub enum MevaObjectType { /// A raw data blob (e.g., file contents). + #[default] Blob, /// A tree object, representing a directory and its children. diff --git a/engine/src/objects/meva_tree.rs b/engine/src/objects/meva_tree.rs new file mode 100644 index 0000000..0ce162e --- /dev/null +++ b/engine/src/objects/meva_tree.rs @@ -0,0 +1,72 @@ +use crate::errors::{EngineError, EngineResult, UnpackError}; +use crate::objects::{MevaObject, MevaObjectType, TreeEntry}; +use crate::serialize_deserialize::meva_encode::{BincodeConfig, MevaEncodeMut}; +use bincode::{Decode, Encode}; + +/// Represents a tree structure stored in the Meva object storage. +/// +/// A [`MevaTree`] is a collection of [`TreeEntry`] values that describe the +/// contents of a directory: files and subdirectories. Each entry contains +/// the information necessary to identify and reconstruct a repository state. +/// +/// This type is serialized into a binary format and wrapped into [`MevaObject`] +/// before being persisted in the object storage. Entries are sorted during serialization to +/// guarantee that identical input always produces the same binary representation. +#[derive(Encode, Decode, Debug)] +pub struct MevaTree { + /// Collection of tree entries describing files and subdirectories. + pub tree_entries: Vec, +} + +impl MevaTree { + /// Creates a new [`MevaTree`] from the given entries. + pub fn new(entries: Vec) -> Self { + Self { + tree_entries: entries, + } + } +} + +/// Converts a `MevaObject` into a `MevaTree`. +/// +/// # Behavior +/// +/// - If the object's type is `Tree`, it deserializes the raw data into a `MevaTree`. +/// - If the object's type is not `Tree`, returns an `UnpackError::ObjectTypeMismatch`. +/// +/// # Errors +/// +/// Returns an [`EngineError`] if deserialization fails or the type does not match. +impl TryFrom for MevaTree { + type Error = EngineError; + fn try_from(value: MevaObject) -> Result { + match value.object_type() { + MevaObjectType::Tree => Ok(MevaTree::from_bytes(value.data().as_slice())?), + object_type => Err(UnpackError::ObjectTypeMismatch { + expected: MevaObjectType::Tree, + actual: object_type.clone(), + } + .into()), + } + } +} + +impl MevaEncodeMut for MevaTree { + /// Serializes this tree into bytes, ensuring deterministic ordering. + /// + /// Before encoding, all [`TreeEntry`] values are sorted. This guarantees + /// that two trees with the same contents always result in the same + /// binary output. + /// + /// # Errors + /// + /// Returns an error if serialization fails during the encoding process. + fn to_bytes_mut(&mut self) -> EngineResult> + where + Self: Encode, + { + self.tree_entries.sort(); + let bytes = bincode::encode_to_vec(&*self, BincodeConfig::get())?; + Ok(bytes) + } +} diff --git a/engine/src/objects/person.rs b/engine/src/objects/person.rs new file mode 100644 index 0000000..25b1d8e --- /dev/null +++ b/engine/src/objects/person.rs @@ -0,0 +1,94 @@ +use bincode::{Decode, Encode}; +use regex::Regex; +use std::str::FromStr; + +/// Represents an author or committer identity in the Meva system. +/// +/// A `Person` combines a display name and an email address. +/// It is typically used to record authorship or ownership information +/// in commits and other repository metadata. +#[derive(Encode, Decode, Clone, Debug)] +pub struct Person { + /// The display name of the person, e.g. `"Alice Example"`. + pub name: String, + + /// The email address of the person, e.g. `"alice@example.com"`. + pub email: String, +} + +/// Parses a `Person` from a string in the format `"Name "`. +/// +/// The name part may contain spaces, and the email must be a valid address +/// according to a basic pattern (`name@domain.tld`). +/// +/// # Errors +/// +/// Returns an error string if the input does not match the expected format. +impl FromStr for Person { + type Err = String; + + fn from_str(s: &str) -> Result { + let re = Regex::new( + r#"^\s*(?P[^<>]*\S[^<>]*)\s*<(?P[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>\s*$"#, + ) + .map_err(|e| e.to_string())?; + let caps = re + .captures(s) + .ok_or("Invalid author format, expected 'Name '")?; + Ok(Self { + name: caps["name"].trim().to_string(), + email: caps["email"].trim().to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn parses_valid_person() { + let input = "Alice Example "; + let person = Person::from_str(input).unwrap(); + assert_eq!(person.name, "Alice Example"); + assert_eq!(person.email, "alice@example.com"); + } + + #[test] + fn parses_person_with_extra_spaces() { + let input = " Bob "; + let person = Person::from_str(input).unwrap(); + assert_eq!(person.name, "Bob"); + assert_eq!(person.email, "bob@domain.org"); + } + + #[test] + fn fails_on_missing_email() { + let input = "Charlie"; + let err = Person::from_str(input).unwrap_err(); + assert!(err.contains("Invalid author format")); + } + + #[test] + fn fails_on_invalid_email_format() { + let input = "Dana "; + let err = Person::from_str(input).unwrap_err(); + assert!(err.contains("Invalid author format")); + } + + #[test] + fn fails_on_missing_name() { + let input = ""; + let err = Person::from_str(input).unwrap_err(); + assert!(err.contains("Invalid author format")); + } + + #[test] + fn parses_name_with_spaces() { + let input = "Eve van der Meer "; + let person = Person::from_str(input).unwrap(); + assert_eq!(person.name, "Eve van der Meer"); + assert_eq!(person.email, "eve@domain.com"); + } +} diff --git a/engine/src/objects/tree_entry.rs b/engine/src/objects/tree_entry.rs new file mode 100644 index 0000000..1c02c59 --- /dev/null +++ b/engine/src/objects/tree_entry.rs @@ -0,0 +1,56 @@ +use crate::objects::tree_entry_type::TreeEntryType; +use bincode::{Decode, Encode}; +use std::fmt::Display; + +/// Represents a single entry within a [`MevaTree`](crate::objects::meva_tree::MevaTree). +/// +/// A [`TreeEntry`] describes either: +/// - a **file** (`Blob`), or +/// - a **subdirectory** (`Tree`). +/// +/// Each entry contains: +/// - its **name** (file or directory name), +/// - its **entry type** ([`TreeEntryType`](crate::objects::tree_entry_type), +/// - the **hash** of the referenced object. +#[derive(Encode, Decode, Debug, Ord, PartialOrd, Eq, PartialEq)] +pub struct TreeEntry { + /// The name of the file or directory in this tree + pub name: String, + + /// The kind of object: a file (`Blob`) or a subdirectory (`Tree`). + pub entry_type: TreeEntryType, + + /// The SHA-1 hash of the referenced [`MevaObject`](object_storage::meva_object) + pub object_hash: String, +} + +impl TreeEntry { + /// Creates a new [`TreeEntry`] with the given name, type, and object hash. + pub fn new(name: String, entry_type: TreeEntryType, object_hash: String) -> Self { + Self { + name, + entry_type, + object_hash, + } + } + + /// Creates a [`TreeEntry`] representing a subdirectory (`Tree`). + pub fn tree(name: String, object_hash: String) -> Self { + Self::new(name, TreeEntryType::Tree, object_hash) + } + + /// Creates a [`TreeEntry`] representing a file (`Blob`). + pub fn blob(name: String, object_hash: String) -> Self { + Self::new(name, TreeEntryType::Blob, object_hash) + } +} + +impl Display for TreeEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{:?} {}\t{}", + self.entry_type, self.object_hash, self.name + ) + } +} diff --git a/engine/src/objects/tree_entry_type.rs b/engine/src/objects/tree_entry_type.rs new file mode 100644 index 0000000..b5d54f8 --- /dev/null +++ b/engine/src/objects/tree_entry_type.rs @@ -0,0 +1,18 @@ +use bincode::{Decode, Encode}; + +/// Defines the type of entry inside a [`TreeEntry`](crate::objects::tree_entry::TreeEntry). +/// +/// A tree entry can represent either: +/// - a **Tree**: a subdirectory containing other entries, +/// - a **Blob**: a file with associated content. +/// +/// This enum is used to distinguish the nature of objects stored in a +/// [`MevaTree`](crate::objects::meva_tree::MevaTree). +#[derive(Encode, Decode, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum TreeEntryType { + /// A subdirectory, which may contain nested entries. + Tree, + + /// A file containing data. + Blob, +} diff --git a/engine/src/ref_manager.rs b/engine/src/ref_manager.rs new file mode 100644 index 0000000..71739f1 --- /dev/null +++ b/engine/src/ref_manager.rs @@ -0,0 +1,35 @@ +pub mod head; +pub mod head_mode; +pub mod meva_ref_manager; +pub mod ref_entry; + +use crate::errors::EngineResult; + +pub use head::Head; +pub use head_mode::HeadMode; +pub use meva_ref_manager::MevaRefManager; +pub use ref_entry::RefEntry; + +/// Defines operations for managing references within the Meva repository. +/// +/// A reference (or *ref*) typically points to a specific commit hash or another ref. +/// The [`RefManager`] provides methods for reading and updating both +/// the `HEAD` reference and named references. +/// +/// All methods return an [`EngineResult`] to handle potential I/O or parsing errors. +pub trait RefManager { + /// Reads the current [`Head`] reference, which indicates the active branch + /// or commit the repository is currently pointing to. + fn read_head(&self) -> EngineResult; + + /// Updates the [`Head`] reference with a new value, changing the active branch + /// or commit pointer. + fn update_head(&self, head: Head) -> EngineResult<()>; + + /// Reads a named reference (e.g. `"refs/heads/master"`) and returns the associated + /// [`RefEntry`], if it exists. + fn read_ref(&self, name: &str) -> EngineResult>; + + /// Updates or creates a named reference with the provided [`RefEntry`] value. + fn update_ref(&self, entry: RefEntry) -> EngineResult<()>; +} diff --git a/engine/src/ref_manager/head.rs b/engine/src/ref_manager/head.rs new file mode 100644 index 0000000..5b81255 --- /dev/null +++ b/engine/src/ref_manager/head.rs @@ -0,0 +1,47 @@ +use crate::ref_manager::head_mode::HeadMode; +use crate::serialize_deserialize::MevaEncode; +use serde::{Deserialize, Serialize}; + +/// Represents the current branch reference (HEAD) in the repository. +/// +/// `Head` indicates whether the repository is in a **symbolic** mode +/// (pointing to a branch reference) or a **direct** mode (pointing to +/// a specific commit hash). +/// +/// By default, it is symbolic and points to `refs/heads/master`. +#[derive(Serialize, Deserialize)] +pub struct Head { + pub mode: HeadMode, + pub target: String, +} +impl Head { + pub fn new(mode: HeadMode, target: &str) -> Self { + Self { + mode, + target: target.to_string(), + } + } +} + +impl Default for Head { + fn default() -> Self { + Self::new(HeadMode::Symbolic, "refs/heads/master") + } +} + +impl MevaEncode for Head {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_head_serialize_deserialize() { + let head = Head::default(); + let json = head.to_json().unwrap(); + let parsed = Head::from_json(&json).unwrap(); + + assert_eq!(parsed.mode, HeadMode::Symbolic); + assert_eq!(parsed.target, "refs/heads/master"); + } +} diff --git a/engine/src/ref_manager/head_mode.rs b/engine/src/ref_manager/head_mode.rs new file mode 100644 index 0000000..ecb1e7b --- /dev/null +++ b/engine/src/ref_manager/head_mode.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; + +/// Defines how the repository `HEAD` reference is interpreted. +/// +/// `HeadMode` determines whether `HEAD` points directly to a specific +/// commit hash (`Direct`) or symbolically to a branch reference (`Symbolic`), +/// such as `refs/heads/master`. +/// +/// - [`Symbolic`] — `HEAD` points to another reference (e.g., a branch). +/// This is the normal state when working on a branch. +/// - [`Direct`] — `HEAD` points directly to a commit hash, +/// which typically occurs in a detached HEAD state. +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub enum HeadMode { + /// Symbolic reference (e.g., `refs/heads/master`) + Symbolic, + + /// Direct commit reference (detached HEAD) + Direct, +} diff --git a/engine/src/ref_manager/meva_ref_manager.rs b/engine/src/ref_manager/meva_ref_manager.rs new file mode 100644 index 0000000..f936c0c --- /dev/null +++ b/engine/src/ref_manager/meva_ref_manager.rs @@ -0,0 +1,106 @@ +use crate::RepositoryLayout; +use crate::errors::{EngineResult, PathError}; +use crate::ref_manager::{Head, HeadMode, RefEntry, RefManager}; +use crate::serialize_deserialize::MevaEncode; +use std::fs; +use std::fs::File; +use std::io::{Read, Write}; +use std::path::Path; + +/// Manages repository references (`HEAD`, branches, and tags) for the Meva repository. +/// +/// `MevaRefManager` provides a file-based implementation of [`RefManager`], +/// handling read and write operations for symbolic and direct references. +/// +/// References and the `HEAD` file are stored as JSON using [`JsonSerialize`], +/// ensuring human readability and compatibility. +/// +/// Directory structure and file paths are derived from the provided +/// [`RepositoryLayout`]. +pub struct MevaRefManager<'a> { + /// Provides access to repository paths such as `.meva/HEAD` and `refs/`. + repo_layout: &'a dyn RepositoryLayout, +} + +impl<'a> MevaRefManager<'a> { + /// Creates a new reference manager instance for a given repository layout. + pub fn new(repo_layout: &'a dyn RepositoryLayout) -> Self { + Self { repo_layout } + } + + /// Reads the full contents of a file and returns it as a string. + fn read_file(path: &Path) -> EngineResult { + let mut file = File::open(path)?; + let mut content = String::new(); + file.read_to_string(&mut content)?; + Ok(content) + } + + /// Writes string content to a file, replacing its previous contents. + fn write_file(&self, path: &Path, content: &str) -> EngineResult<()> { + let mut file = File::options().write(true).truncate(true).open(path)?; + file.write_all(content.as_bytes())?; + Ok(()) + } +} + +impl RefManager for MevaRefManager<'_> { + /// Reads the repository’s `HEAD` file and deserializes it into a [`Head`] structure. + fn read_head(&self) -> EngineResult { + let content = Self::read_file(&self.repo_layout.head_file())?; + Head::from_json(&content) + } + + /// Updates the `HEAD` file on disk and ensures the target reference file exists. + /// + /// If the `HEAD` is symbolic, a new reference file is created if missing. + fn update_head(&self, head: Head) -> EngineResult<()> { + self.write_file(&self.repo_layout.head_file(), &head.to_json()?)?; + + if head.mode == HeadMode::Symbolic { + let path = self.repo_layout.repository_dir().join(&head.target); + let parent = path + .parent() + .ok_or(PathError::NoParent { path: path.clone() })?; + + fs::create_dir_all(parent)?; + + if !path.exists() { + File::create(path)?; + } + } + + Ok(()) + } + + /// Reads a reference by name (e.g., `"refs/heads/master"`) and returns it as a [`RefEntry`]. + /// + /// Returns `Ok(None)` if the reference file is empty. + fn read_ref(&self, name: &str) -> EngineResult> { + let path = self.repo_layout.repository_dir().join(name); + let content = Self::read_file(&path)?; + + if content.is_empty() { + return Ok(None); + } + + let entry = RefEntry::from_json(&content)?; + Ok(Some(entry)) + } + + /// Updates or creates a named reference file with the provided [`RefEntry`]. + /// + /// Parent directories are created automatically if they do not exist. + fn update_ref(&self, entry: RefEntry) -> EngineResult<()> { + let path = self.repo_layout.repository_dir().join(&entry.name); + let parent = path + .parent() + .ok_or(PathError::NoParent { path: path.clone() })?; + + fs::create_dir_all(parent)?; + + self.write_file(&path, &entry.to_json()?)?; + + Ok(()) + } +} diff --git a/engine/src/ref_manager/ref_entry.rs b/engine/src/ref_manager/ref_entry.rs new file mode 100644 index 0000000..35a04bf --- /dev/null +++ b/engine/src/ref_manager/ref_entry.rs @@ -0,0 +1,36 @@ +use crate::serialize_deserialize::MevaEncode; +use serde::{Deserialize, Serialize}; + +/// Represents a branch reference entry within the Meva repository. +/// +/// A `RefEntry` maps a branch name (e.g., `refs/heads/master`) to a specific +/// commit hash, allowing the system to track which commit a branch currently +/// points to. +/// +/// These entries are typically stored and managed by the [`MevaRefManager`], +/// which handles reading and updating branch references in the repository. +#[derive(Serialize, Deserialize)] +pub struct RefEntry { + /// The full name of the reference (e.g., `refs/heads/master`). + pub name: String, + + /// The SHA-1 hash of the commit that the reference points to. + pub commit_hash: String, +} + +impl RefEntry { + /// Creates a new [`RefEntry`] with the provided reference name and commit hash. + /// + /// # Arguments + /// + /// * `name` – The name of the reference (e.g., `refs/heads/master`). + /// * `contents` – The commit hash associated with the reference. + pub fn new(name: &str, contents: &str) -> Self { + RefEntry { + name: name.to_string(), + commit_hash: contents.trim().to_string(), + } + } +} + +impl MevaEncode for RefEntry {} diff --git a/engine/src/repositories.rs b/engine/src/repositories.rs index 153583e..5a6b5c3 100644 --- a/engine/src/repositories.rs +++ b/engine/src/repositories.rs @@ -3,4 +3,5 @@ pub mod meva_repository_layout; pub mod repository_layout; pub use meva_repository::MevaRepository; +pub use repository_layout::MutableRepositoryLayout; pub use repository_layout::RepositoryLayout; diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index 99639b5..58c2dc8 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -6,7 +6,9 @@ use std::{ use tempfile::TempDir; +use crate::ref_manager::{Head, HeadMode}; use crate::repositories::RepositoryLayout; +use crate::serialize_deserialize::MevaEncode; use crate::{ConfigLoader, EngineError, EngineResult, InitError}; use shared::fs::create_file_with_dirs; @@ -89,13 +91,17 @@ impl MevaRepository { let head_path = root.as_ref().join(self.layout.head_file_rel()); let mut head_file = fs::File::create(&head_path)?; - write!( - head_file, - "ref: {}/{}/{}", - self.layout.refs_dir_name(), - self.layout.heads_dir_name(), - initial_branch - )?; + let head = Head { + mode: HeadMode::Symbolic, + target: format!( + "{}/{}/{}", + self.layout.refs_dir_name(), + self.layout.heads_dir_name(), + initial_branch + ), + }; + + write!(head_file, "{}", head.to_json()?)?; let head_log_path = root.as_ref().join(self.layout.head_logs_file_rel()); fs::File::create(head_log_path)?; @@ -137,7 +143,7 @@ mod tests { let tmp = TempDir::new().expect("failed to create TempDir"); let repo = get_repo(tmp.path()); - let result = repo.init("main"); + let result = repo.init("master"); assert!(result.is_ok()); let repo_dir = tmp.path().join(".meva"); @@ -153,14 +159,14 @@ mod tests { assert!(head_path.exists()); let head_contents = read_file(&head_path); - assert_eq!(head_contents, "ref: refs/heads/main"); + assert_eq!(head_contents, Head::default().to_json().unwrap()); assert!(repo.layout.head_logs_file().exists()); - let branch_ref = repo.layout.heads_refs_dir().join("main"); + let branch_ref = repo.layout.heads_refs_dir().join("master"); assert!(branch_ref.exists()); - let branch_log = repo.layout.heads_logs_dir().join("main"); + let branch_log = repo.layout.heads_logs_dir().join("master"); assert!(branch_log.exists()); let config_path = repo.layout.config_file(); diff --git a/engine/src/repositories/meva_repository_layout.rs b/engine/src/repositories/meva_repository_layout.rs index 3a94c57..b1e768a 100644 --- a/engine/src/repositories/meva_repository_layout.rs +++ b/engine/src/repositories/meva_repository_layout.rs @@ -1,31 +1,62 @@ +use crate::errors::{EngineResult, InitError, PathError, RepositoryError}; +use crate::{MutableRepositoryLayout, RepositoryLayout}; +use shared::{PathToString, UpwardSearch}; use std::{env, path::PathBuf}; -use crate::repositories::repository_layout::MutableRepositoryLayout; -use crate::{ - RepositoryLayout, - errors::{EngineResult, InitError}, -}; - pub struct MevaRepositoryLayout { pub working_dir: PathBuf, } impl MevaRepositoryLayout { + /// Creates a new `MevaRepositoryLayout` for the given working directory. + /// + /// # Errors + /// + /// Returns [`InitError::InvalidWorkingDir`] if the provided path + /// is not a directory. pub fn new(working_dir: PathBuf) -> EngineResult { if !working_dir.is_dir() { return Err(InitError::InvalidWorkingDir { - path: working_dir.to_string_lossy().into(), + path: working_dir.to_utf8_string(), } .into()); } Ok(Self { working_dir }) } + /// Creates a `MevaRepositoryLayout` from the current working directory + /// of the running process. pub fn from_env() -> EngineResult { Ok(Self { working_dir: env::current_dir()?, }) } + + /// Discovers the root of the Meva repository starting from the current directory. + /// + /// This walks up the directory tree from the current working directory, + /// searching for the repository's metadata directory (e.g. `.meva`). + /// If found, returns a [`MevaRepositoryLayout`] rooted at the repository root. + /// + /// # Errors + /// + /// - Returns [`RepositoryError::RepositoryNotFound`] if no repository + /// can be discovered in the current directory or its parents. + /// - Returns [`PathError::NoParent`] if a candidate repository path + /// has no parent directory (unexpected). + pub fn discover() -> EngineResult { + let current_dir = std::env::current_dir()?; + let meva_repo = MevaRepositoryLayout::new(current_dir.clone())?; + let repo_root = match current_dir.search_dir_up(meva_repo.repository_dir_name()) { + Some(val) => val + .parent() + .ok_or_else(|| PathError::NoParent { path: val.clone() })? + .to_owned(), + None => return Err(RepositoryError::RepositoryNotFound { path: current_dir }.into()), + }; + + Self::new(repo_root) + } } impl RepositoryLayout for MevaRepositoryLayout { diff --git a/engine/src/serialize_deserialize.rs b/engine/src/serialize_deserialize.rs new file mode 100644 index 0000000..bcfdc0e --- /dev/null +++ b/engine/src/serialize_deserialize.rs @@ -0,0 +1,5 @@ +pub mod binary_compress; +pub mod meva_encode; + +pub use binary_compress::BinaryCompress; +pub use meva_encode::{MevaEncode, MevaEncodeMut}; diff --git a/engine/src/serialize_deserialize/binary_compress.rs b/engine/src/serialize_deserialize/binary_compress.rs new file mode 100644 index 0000000..7b17ab8 --- /dev/null +++ b/engine/src/serialize_deserialize/binary_compress.rs @@ -0,0 +1,47 @@ +use crate::errors::EngineResult; +use crate::serialize_deserialize::MevaEncode; +use bincode::{Decode, Encode}; + +/// Defines compression and decompression behavior for binary-serializable types. +/// +/// The `Compressible` trait extends [`MevaEncode`] by adding support +/// for compressing and decompressing objects represented in binary form. +/// This is typically used for efficient storage and transmission of serialized data. +/// +/// Implementations must define how to: +/// - Convert the object into a compressed binary form (`to_compressed_bytes`) +/// - Reconstruct the object from its compressed binary representation (`from_compressed_bytes`) +/// +/// # Errors +/// +/// All methods return an [`EngineResult`] that may contain serialization +/// or compression-related errors. +#[allow(dead_code)] +pub trait BinaryCompress: MevaEncode { + /// Serializes and compresses the object into a binary buffer. + /// + /// Returns a compressed representation of the object suitable + /// for disk storage or network transfer. + fn to_compressed_bytes(&self) -> EngineResult> + where + Self: Encode; + + /// Decompresses and deserializes a binary buffer back into the original object. + /// + /// Used for reconstructing values previously written via + /// [`to_compressed_bytes`](Self::to_compressed_bytes). + fn from_compressed_bytes(bytes: &[u8]) -> EngineResult + where + Self: Sized; + + /// Decompresses and deserializes a binary buffer using a decoding context. + /// + /// Useful for types that depend on external state or lifetimes during + /// deserialization (e.g., references or contextual decoding). + fn from_compressed_bytes_with_context<'a, C>( + bytes: &[u8], + context: &'a C, + ) -> EngineResult + where + Self: Decode<&'a C>; +} diff --git a/engine/src/serialize_deserialize/meva_encode.rs b/engine/src/serialize_deserialize/meva_encode.rs new file mode 100644 index 0000000..eb48da0 --- /dev/null +++ b/engine/src/serialize_deserialize/meva_encode.rs @@ -0,0 +1,125 @@ +use crate::errors::EngineResult; +use bincode::config::{Configuration, Fixint, LittleEndian}; +use bincode::{Decode, Encode}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +/// Provides a standard binary serialization configuration for Meva. +/// +/// Ensures: +/// - integers are encoded with fixed width, +/// - byte order is little-endian, +/// - the format is consistent across platforms. +pub struct BincodeConfig; + +impl BincodeConfig { + /// Returns the global [bincode] configuration used for serialization. + pub fn get() -> Configuration { + bincode::config::standard() + .with_fixed_int_encoding() + .with_little_endian() + } +} + +/// Trait for types that can be serialized to and deserialized from binary form +/// using [bincode] with the standard [BincodeConfig]. +/// +/// **Important:** In the Meva version control system, all objects should +/// ultimately be serialized in binary form. The JSON methods (`to_json` / +/// `from_json`) are provided for convenience (e.g., debugging or tests) and +/// **should not be used in production storage**. +pub trait MevaEncode { + /// Serializes `self` into a vector of bytes using the standard [BincodeConfig]. + /// + /// # Errors + /// + /// Returns an [EngineError] if serialization fails. + fn to_bytes(&self) -> EngineResult> + where + Self: Encode, + { + let bytes = bincode::encode_to_vec(self, BincodeConfig::get())?; + Ok(bytes) + } + + /// Deserializes an instance of `Self` from a byte slice using the standard [BincodeConfig]. + /// + /// # Errors + /// + /// Returns an [EngineError] if deserialization fails. + fn from_bytes(bytes: &[u8]) -> EngineResult + where + Self: Decode<()>, + { + let (value, _len): (Self, usize) = bincode::decode_from_slice(bytes, BincodeConfig::get())?; + Ok(value) + } + + /// Deserializes an instance of `Self` from bytes using the provided decoding context. + /// + /// Useful for types that require external data or lifetimes during decoding. + /// + /// # Errors + /// + /// Returns an [EngineError] if deserialization fails. + fn from_bytes_with_context<'a, C>(bytes: &[u8], context: &'a C) -> EngineResult + where + Self: Decode<&'a C>, + { + let (value, _len): (Self, usize) = + bincode::decode_from_slice_with_context(bytes, BincodeConfig::get(), context)?; + Ok(value) + } + + /// Serializes the current object into a pretty-printed JSON string. + /// + /// **Note:** This method is intended for debugging or testing only. + /// Production storage should always use binary serialization. + fn to_json(&self) -> EngineResult + where + Self: Serialize, + { + let json = serde_json::to_string_pretty(self)?; + Ok(json) + } + + /// Deserializes an object from a JSON string. + /// + /// **Note:** This method is intended for debugging or testing only. + /// Production storage should always use binary deserialization. + fn from_json(json: &str) -> EngineResult + where + Self: DeserializeOwned, + { + let object: Self = serde_json::from_str(json)?; + Ok(object) + } +} + +/// Trait for types that require mutable access before binary serialization. +/// +/// Unlike [MevaEncode], this trait is meant for cases where the object must +/// be modified prior to encoding (e.g., sorting an internal collection to +/// ensure deterministic output). +pub trait MevaEncodeMut { + /// Serializes `self` into a vector of bytes, allowing mutation before encoding. + /// + /// Useful for producing a stable binary representation even if the object + /// contains unsorted or non-deterministic internal state. + fn to_bytes_mut(&mut self) -> EngineResult> + where + Self: Encode; + + /// Deserializes an instance of `Self` from a byte slice using the standard [BincodeConfig]. + /// + /// # Errors + /// + /// Returns an [EngineError] if deserialization fails. + fn from_bytes(bytes: &[u8]) -> EngineResult + where + Self: Decode<()>, + { + let (value, _len): (Self, usize) = bincode::decode_from_slice(bytes, BincodeConfig::get())?; + Ok(value) + } +} diff --git a/engine/src/utils.rs b/engine/src/utils.rs new file mode 100644 index 0000000..755f90e --- /dev/null +++ b/engine/src/utils.rs @@ -0,0 +1,3 @@ +pub mod strip_base; + +pub use strip_base::StripBase; diff --git a/engine/src/utils/strip_base.rs b/engine/src/utils/strip_base.rs new file mode 100644 index 0000000..34a5824 --- /dev/null +++ b/engine/src/utils/strip_base.rs @@ -0,0 +1,84 @@ +use crate::errors::{EngineError, EngineResult, PathError}; +use std::path::Path; + +/// A helper trait for stripping a base directory prefix from paths. +/// +/// This is similar to [`Path::strip_prefix`], but returns a custom +/// [`EngineError`] (`PathError::NotInBaseDirectory`) if the given path +/// does not start with the provided base. +/// +/// Useful for ensuring that paths used inside the repository remain +/// relative to its root. +pub trait StripBase { + /// Attempts to strip the given `base` prefix from this path. + /// + /// # Errors + /// + /// Returns [`PathError::NotInBaseDirectory`] if `self` is not located + /// under the provided `base` directory. + fn strip_base<'a>(&'a self, base: &'a Path) -> EngineResult<&'a Path>; +} + +impl StripBase for Path { + fn strip_base<'a>(&'a self, base: &'a Path) -> EngineResult<&'a Path> { + self.strip_prefix(base).map_err(|_| { + EngineError::from(PathError::NotInBaseDirectory { + path: self.to_path_buf(), + path_base: base.to_path_buf(), + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn test_strip_base_success() { + let base = Path::new("/home/meva/repo"); + let abs = Path::new("/home/meva/repo/src/lib.rs"); + + let relative = abs.strip_base(base).unwrap(); + assert_eq!(relative, Path::new("src/lib.rs")); + } + + #[test] + fn test_strip_base_failure() { + let base = Path::new("/home/meva/repo"); + let abs = Path::new("/home/other/project"); + + let err = abs.strip_base(base); + assert!(err.is_err()); + } + + #[test] + fn test_strip_base_relative_path() { + let base = Path::new("repo"); + let abs = Path::new("repo/src/main.rs"); + + let relative = abs.strip_base(base).unwrap(); + assert_eq!(relative, Path::new("src/main.rs")); + } + + #[cfg(windows)] + #[test] + fn test_strip_base_windows_absolute() { + let base = Path::new(r"C:\repo"); + let abs = Path::new(r"C:\repo\src\main.rs"); + + let relative = abs.strip_base(base).unwrap(); + assert_eq!(relative, Path::new(r"src\main.rs")); + } + + #[cfg(windows)] + #[test] + fn test_strip_base_windows_unc() { + let base = Path::new(r"\\server\share\repo"); + let abs = Path::new(r"\\server\share\repo\src\main.rs"); + + let relative = abs.strip_base(base).unwrap(); + assert_eq!(relative, Path::new(r"src\main.rs")); + } +} diff --git a/plugins/src/enums/command_type.rs b/plugins/src/enums/command_type.rs index a18d5a3..4fd7a2d 100644 --- a/plugins/src/enums/command_type.rs +++ b/plugins/src/enums/command_type.rs @@ -14,4 +14,5 @@ pub enum CommandType { ConfigUnset, ConfigList, Add, + Commit, } diff --git a/plugins/src/enums/invocation_payload.rs b/plugins/src/enums/invocation_payload.rs index a05b1ce..e2df3d3 100644 --- a/plugins/src/enums/invocation_payload.rs +++ b/plugins/src/enums/invocation_payload.rs @@ -1,7 +1,8 @@ use serde::{Deserialize, Serialize}; use crate::{ - AddPostPayload, AddPrePayload, InitPostPayload, InitPrePayload, + AddPostPayload, AddPrePayload, CommitPostPayload, CommitPrePayload, InitPostPayload, + InitPrePayload, models::{ ConfigGetPostPayload, ConfigGetPrePayload, ConfigListPostPayload, ConfigListPrePayload, ConfigSetPostPayload, ConfigSetPrePayload, ConfigUnsetPostPayload, ConfigUnsetPrePayload, @@ -34,6 +35,9 @@ pub enum InvocationPrePayload { /// Context for the `add` command. Add(AddPrePayload), + + /// Context for the `commit` command + Commit(CommitPrePayload), } /// Represents the context data passed to plugins @@ -61,4 +65,7 @@ pub enum InvocationPostPayload { /// Context for the `add` command. Add(AddPostPayload), + + /// Context for the `commit` command. + Commit(Box), } diff --git a/plugins/src/models/payloads.rs b/plugins/src/models/payloads.rs index dee4900..fcf3ecb 100644 --- a/plugins/src/models/payloads.rs +++ b/plugins/src/models/payloads.rs @@ -1,7 +1,9 @@ mod add_payload; +mod commit_payload; mod config_payload; mod init_payload; pub use add_payload::*; +pub use commit_payload::*; pub use config_payload::*; pub use init_payload::*; diff --git a/plugins/src/models/payloads/commit_payload.rs b/plugins/src/models/payloads/commit_payload.rs new file mode 100644 index 0000000..eb0742b --- /dev/null +++ b/plugins/src/models/payloads/commit_payload.rs @@ -0,0 +1,33 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Payload provided to plugins **before** the `meva commit` command execution +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CommitPrePayload { + pub message: String, + pub author: CommitAuthor, + pub dry_run: bool, + pub amend: bool, +} + +/// Payload provided to plugins **after** the `meva commit` command execution +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CommitPostPayload { + pub commit_hash: String, + pub commit_content: CommitContent, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CommitAuthor { + pub name: String, + pub email: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CommitContent { + pub commit_tree_hash: String, + pub parents: Vec, + pub message: String, + pub author: CommitAuthor, + pub timestamp: DateTime, +} diff --git a/shared/src/extensions.rs b/shared/src/extensions.rs index b14ded6..46ddc9c 100644 --- a/shared/src/extensions.rs +++ b/shared/src/extensions.rs @@ -1,10 +1,17 @@ pub mod canonicalize_clean; +pub mod cumulative_paths; pub mod fs; pub mod is_within; pub mod open_in_editor; +pub mod path_to_string; pub mod remove_windows_prefix; pub mod upward_search; +pub use canonicalize_clean::CanonicalizeClean; +pub use cumulative_paths::CumulativePaths; pub use fs::create_file_with_dirs; +pub use is_within::IsWithin; pub use open_in_editor::OpenInEditor; +pub use path_to_string::PathToString; +pub use remove_windows_prefix::RemoveWindowsPrefix; pub use upward_search::UpwardSearch; diff --git a/shared/src/extensions/cumulative_paths.rs b/shared/src/extensions/cumulative_paths.rs new file mode 100644 index 0000000..9de8b88 --- /dev/null +++ b/shared/src/extensions/cumulative_paths.rs @@ -0,0 +1,118 @@ +use std::path::{Path, PathBuf}; + +/// Provides a utility for generating cumulative path segments. +/// +/// The [`CumulativePaths`] trait defines a helper method that expands +/// a given path into all of its cumulative parent paths. +/// This is particularly useful when reconstructing directory trees +/// or building hierarchical structures (e.g., commit trees) from file paths. +/// +/// # Example +/// +/// ``` +/// use std::path::Path; +/// use shared::extensions::cumulative_paths::CumulativePaths; +/// +/// let path = Path::new("src/utils/mod.rs"); +/// let parts = path.cumulative_paths(); +/// +/// assert_eq!(parts, vec![ +/// Path::new("src"), +/// Path::new("src/utils"), +/// Path::new("src/utils/mod.rs"), +/// ]); +/// ``` +pub trait CumulativePaths { + /// Returns a vector of cumulative path components. + fn cumulative_paths(&self) -> Vec; +} + +impl CumulativePaths for Path { + fn cumulative_paths(&self) -> Vec { + let mut cum = Vec::new(); + let mut current = PathBuf::new(); + + for comp in self.components() { + current.push(comp); + cum.push(current.clone()); + } + + cum + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_empty_path() { + let path = Path::new(""); + let cum = path.cumulative_paths(); + assert!(cum.is_empty()); + } + + #[test] + fn test_single_component() { + let path = Path::new("home"); + let cum = path.cumulative_paths(); + assert_eq!(cum, vec![PathBuf::from("home")]); + } + + #[test] + fn test_unix_absolute_path() { + let path = Path::new("/etc/config"); + let cum = path.cumulative_paths(); + let expected: Vec = vec![ + PathBuf::from("/"), + PathBuf::from("/etc"), + PathBuf::from("/etc/config"), + ]; + assert_eq!(cum, expected); + } + #[test] + fn test_unix_relative_path() { + let path = Path::new("home/user/projects/meva"); + let cum = path.cumulative_paths(); + let expected: Vec = vec![ + PathBuf::from("home"), + PathBuf::from("home/user"), + PathBuf::from("home/user/projects"), + PathBuf::from("home/user/projects/meva"), + ]; + assert_eq!(cum, expected); + } + #[cfg(windows)] + #[test] + fn test_windows_absolute_path() { + { + let path = Path::new(r"C:\Users\meva\project"); + let cum = path.cumulative_paths(); + let expected: Vec = vec![ + PathBuf::from("C:"), + PathBuf::from(r"C:\"), + PathBuf::from(r"C:\Users"), + PathBuf::from(r"C:\Users\meva"), + PathBuf::from(r"C:\Users\meva\project"), + ]; + assert_eq!(cum, expected); + } + } + + #[cfg(windows)] + #[test] + fn test_windows_relative_path() { + use std::path::Path; + use std::path::PathBuf; + + let path = Path::new(r"folder\subfolder\file.txt"); + let cum = path.cumulative_paths(); + + let expected: Vec = vec![ + PathBuf::from("folder"), + PathBuf::from(r"folder\subfolder"), + PathBuf::from(r"folder\subfolder\file.txt"), + ]; + + assert_eq!(cum, expected); + } +} diff --git a/shared/src/extensions/path_to_string.rs b/shared/src/extensions/path_to_string.rs new file mode 100644 index 0000000..e1523f9 --- /dev/null +++ b/shared/src/extensions/path_to_string.rs @@ -0,0 +1,22 @@ +use std::path::PathBuf; + +/// A helper trait for converting paths into owned UTF-8 strings. +/// +/// This provides a convenient way to obtain a `String` representation +/// of a path without dealing with `OsStr` or platform-specific encodings. +/// +/// Internally, this uses [`PathBuf::to_string_lossy`], which replaces +/// any invalid UTF-8 sequences with the Unicode replacement character (`�`). +pub trait PathToString { + /// Converts this path into an owned UTF-8 string. + /// + /// Any non-UTF-8 sequences will be replaced with the Unicode + /// replacement character (`�`). + fn to_utf8_string(&self) -> String; +} + +impl PathToString for PathBuf { + fn to_utf8_string(&self) -> String { + self.to_string_lossy().to_string() + } +} diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 5034db1..44bed87 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -2,5 +2,8 @@ mod pretty_field; pub mod extensions; -pub use extensions::{UpwardSearch, fs}; +pub use extensions::{ + CanonicalizeClean, CumulativePaths, IsWithin, OpenInEditor, PathToString, RemoveWindowsPrefix, + UpwardSearch, fs, +}; pub use pretty_field::PrettyField; From 08434808050196216e9c8af9ca92ef4d15ac4369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Wed, 15 Oct 2025 11:54:43 +0200 Subject: [PATCH 10/42] Command `ls-files` --- Cargo.lock | 12 +- Cargo.toml | 1 + cli/Cargo.toml | 1 + cli/src/commands.rs | 8 + cli/src/commands/add.rs | 6 +- cli/src/commands/commit.rs | 3 +- cli/src/commands/config/subcommands/get.rs | 2 +- cli/src/commands/config/subcommands/list.rs | 2 +- cli/src/commands/config/subcommands/set.rs | 2 +- cli/src/commands/config/subcommands/unset.rs | 2 +- cli/src/commands/diff.rs | 147 +++++++++ cli/src/commands/ignore.rs | 1 + cli/src/commands/init.rs | 22 +- cli/src/commands/ls_files.rs | 164 ++++++++++ cli/src/commands/plugins/subcommands/edit.rs | 2 +- cli/src/commands/plugins/subcommands/info.rs | 2 +- cli/src/commands/plugins/subcommands/list.rs | 2 +- .../commands/plugins/subcommands/register.rs | 2 +- .../plugins/subcommands/unregister.rs | 2 +- cli/src/commands/show.rs | 238 +++++++++++++++ cli/src/commands/status.rs | 178 +++++++++++ cli/src/main.rs | 27 +- cli/src/meva_cli.rs | 35 +-- engine/Cargo.toml | 1 + .../src/branch_manager/meva_branch_manager.rs | 2 +- .../src/commit_builder/meva_commit_builder.rs | 6 +- engine/src/config.rs | 4 - engine/src/diff.rs | 11 + engine/src/diff/change_kind.rs | 70 +++++ engine/src/diff/diff_stat.rs | 53 ++++ engine/src/diff/file_change.rs | 57 ++++ engine/src/diff/file_diff_stat.rs | 87 ++++++ engine/src/diff/hunk.rs | 88 ++++++ engine/src/engine_container.rs | 33 +- engine/src/handlers.rs | 8 + engine/src/{ => handlers}/add.rs | 1 + engine/src/{ => handlers}/add/handlers.rs | 5 +- engine/src/{ => handlers}/add/operations.rs | 0 engine/src/{ => handlers}/commit.rs | 0 engine/src/{ => handlers}/commit/handlers.rs | 4 +- .../src/{ => handlers}/commit/operations.rs | 0 engine/src/handlers/config.rs | 5 + .../config/handlers.rs} | 7 +- .../src/{ => handlers}/config/operations.rs | 0 engine/src/handlers/init.rs | 5 + .../handler.rs => handlers/init/handlers.rs} | 2 +- engine/src/{ => handlers}/init/operations.rs | 0 engine/src/handlers/ls_files.rs | 8 + engine/src/handlers/ls_files/handlers.rs | 117 ++++++++ engine/src/handlers/ls_files/models.rs | 5 + .../ls_files/models/ls_files_entry.rs | 35 +++ .../ls_files/models/ls_files_filter.rs | 10 + engine/src/handlers/ls_files/operations.rs | 18 ++ engine/src/handlers/plugins.rs | 5 + .../plugins/handlers.rs} | 9 +- .../src/{ => handlers}/plugins/operations.rs | 0 engine/src/handlers/show.rs | 8 + engine/src/handlers/show/handlers.rs | 17 ++ engine/src/handlers/show/models.rs | 5 + engine/src/handlers/show/models/patch_mode.rs | 18 ++ engine/src/handlers/show/models/snapshot.rs | 48 +++ engine/src/handlers/show/operations.rs | 23 ++ engine/src/handlers/status.rs | 8 + engine/src/handlers/status/handlers.rs | 17 ++ engine/src/handlers/status/models.rs | 8 + .../src/handlers/status/models/branch_info.rs | 47 +++ .../handlers/status/models/grouped_status.rs | 81 +++++ .../handlers/status/models/status_entry.rs | 121 ++++++++ .../src/handlers/status/models/status_kind.rs | 19 ++ engine/src/handlers/status/operations.rs | 43 +++ engine/src/index/file_mode.rs | 10 +- engine/src/index/meva_index.rs | 138 +++++++-- engine/src/index/stage.rs | 11 +- engine/src/init.rs | 5 - engine/src/lib.rs | 12 +- engine/src/objects/person.rs | 12 +- engine/src/plugins.rs | 5 - engine/src/revision_parsing.rs | 6 + engine/src/revision_parsing/base_reference.rs | 21 ++ engine/src/revision_parsing/revision.rs | 284 ++++++++++++++++++ .../src/revision_parsing/revision_modifier.rs | 16 + .../revision_parsing/revision_parse_error.rs | 30 ++ engine/src/utils.rs | 3 - engine/src/utils/strip_base.rs | 84 ------ shared/src/extensions.rs | 2 + shared/src/extensions/path_to_string.rs | 9 +- shared/src/extensions/strip_base.rs | 61 ++++ shared/src/lib.rs | 2 +- 88 files changed, 2474 insertions(+), 217 deletions(-) create mode 100644 cli/src/commands/diff.rs create mode 100644 cli/src/commands/ls_files.rs create mode 100644 cli/src/commands/show.rs create mode 100644 cli/src/commands/status.rs create mode 100644 engine/src/diff.rs create mode 100644 engine/src/diff/change_kind.rs create mode 100644 engine/src/diff/diff_stat.rs create mode 100644 engine/src/diff/file_change.rs create mode 100644 engine/src/diff/file_diff_stat.rs create mode 100644 engine/src/diff/hunk.rs create mode 100644 engine/src/handlers.rs rename engine/src/{ => handlers}/add.rs (67%) rename engine/src/{ => handlers}/add/handlers.rs (97%) rename engine/src/{ => handlers}/add/operations.rs (100%) rename engine/src/{ => handlers}/commit.rs (100%) rename engine/src/{ => handlers}/commit/handlers.rs (98%) rename engine/src/{ => handlers}/commit/operations.rs (100%) create mode 100644 engine/src/handlers/config.rs rename engine/src/{config/handler.rs => handlers/config/handlers.rs} (97%) rename engine/src/{ => handlers}/config/operations.rs (100%) create mode 100644 engine/src/handlers/init.rs rename engine/src/{init/handler.rs => handlers/init/handlers.rs} (97%) rename engine/src/{ => handlers}/init/operations.rs (100%) create mode 100644 engine/src/handlers/ls_files.rs create mode 100644 engine/src/handlers/ls_files/handlers.rs create mode 100644 engine/src/handlers/ls_files/models.rs create mode 100644 engine/src/handlers/ls_files/models/ls_files_entry.rs create mode 100644 engine/src/handlers/ls_files/models/ls_files_filter.rs create mode 100644 engine/src/handlers/ls_files/operations.rs create mode 100644 engine/src/handlers/plugins.rs rename engine/src/{plugins/handler.rs => handlers/plugins/handlers.rs} (95%) rename engine/src/{ => handlers}/plugins/operations.rs (100%) create mode 100644 engine/src/handlers/show.rs create mode 100644 engine/src/handlers/show/handlers.rs create mode 100644 engine/src/handlers/show/models.rs create mode 100644 engine/src/handlers/show/models/patch_mode.rs create mode 100644 engine/src/handlers/show/models/snapshot.rs create mode 100644 engine/src/handlers/show/operations.rs create mode 100644 engine/src/handlers/status.rs create mode 100644 engine/src/handlers/status/handlers.rs create mode 100644 engine/src/handlers/status/models.rs create mode 100644 engine/src/handlers/status/models/branch_info.rs create mode 100644 engine/src/handlers/status/models/grouped_status.rs create mode 100644 engine/src/handlers/status/models/status_entry.rs create mode 100644 engine/src/handlers/status/models/status_kind.rs create mode 100644 engine/src/handlers/status/operations.rs delete mode 100644 engine/src/init.rs delete mode 100644 engine/src/plugins.rs create mode 100644 engine/src/revision_parsing.rs create mode 100644 engine/src/revision_parsing/base_reference.rs create mode 100644 engine/src/revision_parsing/revision.rs create mode 100644 engine/src/revision_parsing/revision_modifier.rs create mode 100644 engine/src/revision_parsing/revision_parse_error.rs delete mode 100644 engine/src/utils.rs delete mode 100644 engine/src/utils/strip_base.rs create mode 100644 shared/src/extensions/strip_base.rs diff --git a/Cargo.lock b/Cargo.lock index 50500ec..ff2690c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -245,6 +245,7 @@ dependencies = [ "globset", "miette", "mockall", + "owo-colors", "plugins", "pretty_assertions", "rstest", @@ -403,6 +404,7 @@ dependencies = [ "serde_json", "sha1", "shared", + "similar", "tempfile", "thiserror", "toml", @@ -799,9 +801,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "owo-colors" -version = "4.2.2" +version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" [[package]] name = "pin-project-lite" @@ -1161,6 +1163,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "slab" version = "0.4.11" diff --git a/Cargo.toml b/Cargo.toml index ef0e050..a17275c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,4 @@ clap = { version = "4.5.41", features = ["derive"] } strum = "0.27" strum_macros = "0.27" regex = "1.11.3" +similar = "2.7.0" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 49f8606..a7eb585 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -18,6 +18,7 @@ miette = { version = "7.6.0", features = ["fancy"] } globset.workspace = true strum.workspace = true strum_macros.workspace = true +owo-colors = "4.2.3" [dev-dependencies] rstest.workspace = true diff --git a/cli/src/commands.rs b/cli/src/commands.rs index fb15091..e8d8cb9 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1,16 +1,24 @@ pub mod add; pub mod commit; pub mod config; +pub mod diff; pub mod ignore; pub mod init; +pub mod ls_files; pub mod meva_command; pub mod plugins; +pub mod show; +pub mod status; pub use add::AddCommand; pub use commit::CommitCommand; pub use config::ConfigCommand; +pub use diff::DiffCommand; pub use ignore::IgnoreCommand; pub use init::InitCommand; +pub use ls_files::LsFilesCommand; pub use plugins::PluginsCommand; +pub use show::ShowCommand; +pub use status::StatusCommand; pub use meva_command::MevaCommand; diff --git a/cli/src/commands/add.rs b/cli/src/commands/add.rs index 87e024d..f01ebfc 100644 --- a/cli/src/commands/add.rs +++ b/cli/src/commands/add.rs @@ -5,8 +5,8 @@ use miette::{IntoDiagnostic, Result}; use crate::commands::MevaCommand; use engine::EngineContainer; -use engine::add::AddRequest; use engine::engine_container::MevaContainer; +use engine::handlers::add::AddRequest; /// CLI command for adding files to the Meva staging area. /// @@ -130,7 +130,7 @@ impl MevaCommand for AddCommand { let verbose_flag = matches.get_flag(Self::ARG_VERBOSE); let path_arg = matches.get_one::(Self::ARG_PATH); - let add_handler = container.add_handler().into_diagnostic()?; + let handler = container.add_handler().into_diagnostic()?; let interceptor = container.plugins_interceptor().into_diagnostic()?; let request = AddRequest { @@ -142,7 +142,7 @@ impl MevaCommand for AddCommand { path_arg: path_arg.cloned(), }; - add_handler + handler .handle_add(request, &interceptor) .into_diagnostic()?; diff --git a/cli/src/commands/commit.rs b/cli/src/commands/commit.rs index 665fc53..ddb4442 100644 --- a/cli/src/commands/commit.rs +++ b/cli/src/commands/commit.rs @@ -1,9 +1,8 @@ use crate::commands::MevaCommand; use clap::{Arg, ArgAction, ArgMatches, Command}; use engine::EngineContainer; -use engine::add::AddRequest; -use engine::commit::CommitRequest; use engine::engine_container::MevaContainer; +use engine::handlers::{add::AddRequest, commit::CommitRequest}; use engine::objects::Person; use miette::IntoDiagnostic; diff --git a/cli/src/commands/config/subcommands/get.rs b/cli/src/commands/config/subcommands/get.rs index 88106a6..1b38d86 100644 --- a/cli/src/commands/config/subcommands/get.rs +++ b/cli/src/commands/config/subcommands/get.rs @@ -1,7 +1,7 @@ use clap::{Arg, ArgMatches, Command}; use engine::EngineContainer; -use engine::config::GetRequest; use engine::engine_container::MevaContainer; +use engine::handlers::config::GetRequest; use miette::IntoDiagnostic; use crate::commands::MevaCommand; diff --git a/cli/src/commands/config/subcommands/list.rs b/cli/src/commands/config/subcommands/list.rs index 461aa86..6376f11 100644 --- a/cli/src/commands/config/subcommands/list.rs +++ b/cli/src/commands/config/subcommands/list.rs @@ -1,7 +1,7 @@ use clap::{ArgMatches, Command}; use engine::EngineContainer; -use engine::config::ListRequest; use engine::engine_container::MevaContainer; +use engine::handlers::config::ListRequest; use miette::IntoDiagnostic; use crate::commands::MevaCommand; diff --git a/cli/src/commands/config/subcommands/set.rs b/cli/src/commands/config/subcommands/set.rs index 35950b0..bd0235f 100644 --- a/cli/src/commands/config/subcommands/set.rs +++ b/cli/src/commands/config/subcommands/set.rs @@ -1,7 +1,7 @@ use clap::{Arg, ArgMatches, Command}; use engine::EngineContainer; -use engine::config::SetRequest; use engine::engine_container::MevaContainer; +use engine::handlers::config::SetRequest; use miette::IntoDiagnostic; use crate::commands::MevaCommand; diff --git a/cli/src/commands/config/subcommands/unset.rs b/cli/src/commands/config/subcommands/unset.rs index 3b34a6d..529a3b1 100644 --- a/cli/src/commands/config/subcommands/unset.rs +++ b/cli/src/commands/config/subcommands/unset.rs @@ -1,7 +1,7 @@ use clap::{ArgMatches, Command}; use engine::EngineContainer; -use engine::config::UnsetRequest; use engine::engine_container::MevaContainer; +use engine::handlers::config::UnsetRequest; use miette::IntoDiagnostic; use crate::commands::MevaCommand; diff --git a/cli/src/commands/diff.rs b/cli/src/commands/diff.rs new file mode 100644 index 0000000..760c352 --- /dev/null +++ b/cli/src/commands/diff.rs @@ -0,0 +1,147 @@ +use std::path::PathBuf; + +use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; +use engine::{engine_container::MevaContainer, revision_parsing::Revision}; + +use miette::Result; + +use crate::commands::MevaCommand; + +/// Implements the `diff` command for Meva DVCS. +/// +/// Shows changes between commits, the index, and the working tree. +pub struct DiffCommand; + +impl DiffCommand { + /// Creates a new instance of the `DiffCommand`. + pub fn new() -> Self { + Self {} + } + + /// Argument name for the `--cached` flag. + /// When set, the command compares the index to the specified commit (default: HEAD). + const ARG_CACHED: &'static str = "cached"; + + /// Argument name for the `--name-only` flag. + /// When set, only the names of changed files are printed, not their contents. + const ARG_NAME_ONLY: &'static str = "name-only"; + + /// Argument name for the `--stat` flag. + /// When set, print a summary diffstat instead of the full diff output. + const ARG_STAT: &'static str = "stat"; + + /// Positional argument name for the first revision/range. + /// Examples: `HEAD`, `HEAD~1`, a commit id, or other revision expressions. + const ARG_RANGE1: &'static str = "range1"; + + /// Positional argument name for the second revision/range. + /// When present, the diff is shown between RANGE1 and RANGE2. + const ARG_RANGE2: &'static str = "range2"; + + /// Positional argument name for paths limiting the diff. + /// Any number of paths can be provided; use `--` to separate ranges from paths. + const ARG_PATHS: &'static str = "paths"; +} + +impl MevaCommand for DiffCommand { + fn name(&self) -> &'static str { + "diff" + } + + fn about(&self) -> &'static str { + "Show changes between commits, the index, and the working tree" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `diff` command using `clap`. + /// + /// Adds flags and positional arguments with helpful descriptions and value + /// parsers. The positional revision arguments use the `Revision` parser + /// defined in the `engine::revision_parsing` module. + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_CACHED) + .short('c') + .long(Self::ARG_CACHED) + .action(ArgAction::SetTrue) + .help("Compare the index to the specified commit (default: HEAD). If HEAD doesn't exist, show all changes") + ) + .arg( + Arg::new(Self::ARG_NAME_ONLY) + .short('n') + .long(Self::ARG_NAME_ONLY) + .action(ArgAction::SetTrue) + .help("Show only names of changed files") + ) + .arg( + Arg::new(Self::ARG_STAT) + .short('s') + .long(Self::ARG_STAT) + .action(ArgAction::SetTrue) + .help("Show summary diffstat instead of the full diff") + ) + .arg( + Arg::new(Self::ARG_RANGE1) + .value_name("RANGE1") + .index(1) + .value_parser(value_parser!(Revision)) + .required(false) + .help("First range (e.g. commit, HEAD, HEAD~1)") + ) + .arg( + Arg::new(Self::ARG_RANGE2) + .value_name("RANGE2") + .index(2) + .value_parser(value_parser!(Revision)) + .required(false) + .help("Second range (e.g. commit)") + ) + .arg( + Arg::new(Self::ARG_PATHS) + .value_name("PATHS") + .index(3) + .num_args(0..) + .last(true) + .help("Limit the diff to the given paths (use -- to separate ranges from paths)") + ) + } + + /// Executes the `diff` command. + fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> Result<()> { + let _cached = matches.get_flag(Self::ARG_CACHED); + let _name_only = matches.get_flag(Self::ARG_NAME_ONLY); + let _stat = matches.get_flag(Self::ARG_STAT); + + let _range1 = matches.get_one::(Self::ARG_RANGE1); + let _range2 = matches.get_one::(Self::ARG_RANGE2); + + let _paths = match matches.get_many::(Self::ARG_PATHS) { + Some(vals) => vals.map(PathBuf::from).collect(), + None => Vec::new(), + }; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = DiffCommand::new(); + assert_eq!(cmd.name(), "diff"); + assert_eq!( + cmd.about(), + "Show changes between commits, the index, and the working tree" + ); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/ignore.rs b/cli/src/commands/ignore.rs index 3d37505..c7f7aff 100644 --- a/cli/src/commands/ignore.rs +++ b/cli/src/commands/ignore.rs @@ -12,6 +12,7 @@ use subcommands::*; pub struct IgnoreCommand; impl IgnoreCommand { + /// Creates a new instance of the `IgnoreCommand`. pub fn new() -> Self { Self } diff --git a/cli/src/commands/init.rs b/cli/src/commands/init.rs index 25b5062..dd2bcb2 100644 --- a/cli/src/commands/init.rs +++ b/cli/src/commands/init.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use clap::{Arg, ArgMatches, Command, ValueHint}; -use engine::{EngineContainer, engine_container::MevaContainer, init::Request}; +use engine::{EngineContainer, engine_container::MevaContainer, handlers::init::Request}; use miette::{IntoDiagnostic, Result}; use crate::commands::MevaCommand; @@ -19,7 +19,7 @@ impl InitCommand { } /// Argument name for specifying the initial branch. - const ARG_BRANCH: &'static str = "initial-branch"; + const ARG_INITIAL_BRANCH: &'static str = "initial-branch"; /// Argument name for specifying the repository path. const ARG_PATH: &'static str = "path"; @@ -46,9 +46,9 @@ impl MevaCommand for InitCommand { fn build_command(&self) -> Command { self.build_base_command() .arg( - Arg::new(Self::ARG_BRANCH) + Arg::new(Self::ARG_INITIAL_BRANCH) .short('b') - .long(Self::ARG_BRANCH) + .long(Self::ARG_INITIAL_BRANCH) .value_name("BRANCH") .help("Name of the initial branch") .default_value("master") @@ -79,10 +79,10 @@ impl MevaCommand for InitCommand { /// # Returns /// * `Result<()>`: Indicates success or detailed error if initialization fails. fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { - let branch = matches.get_one::(Self::ARG_BRANCH).unwrap(); + let branch = matches.get_one::(Self::ARG_INITIAL_BRANCH).unwrap(); let target = matches.get_one::(Self::ARG_PATH).unwrap(); - let init_handler = container.init_handler().into_diagnostic()?; + let handler = container.init_handler().into_diagnostic()?; let interceptor = container.plugins_interceptor().into_diagnostic()?; let request = Request { @@ -90,7 +90,7 @@ impl MevaCommand for InitCommand { initial_branch: branch.clone(), }; - let response = init_handler + let response = handler .handle_init(request, &interceptor) .into_diagnostic()?; @@ -135,7 +135,7 @@ mod tests { assert!( clap_cmd .get_arguments() - .any(|a| a.get_id() == InitCommand::ARG_BRANCH) + .any(|a| a.get_id() == InitCommand::ARG_INITIAL_BRANCH) ); assert!( clap_cmd @@ -146,7 +146,7 @@ mod tests { // Check default values for args let branch_arg = clap_cmd .get_arguments() - .find(|a| a.get_id() == InitCommand::ARG_BRANCH) + .find(|a| a.get_id() == InitCommand::ARG_INITIAL_BRANCH) .unwrap(); assert_eq!( branch_arg.get_default_values().first().map(|v| v.to_str()), @@ -176,7 +176,9 @@ mod tests { ) { let matches = get_matches_from(args); - let branch = matches.get_one::(InitCommand::ARG_BRANCH).unwrap(); + let branch = matches + .get_one::(InitCommand::ARG_INITIAL_BRANCH) + .unwrap(); let path = matches.get_one::(InitCommand::ARG_PATH).unwrap(); assert_eq!(branch, expected_branch); diff --git a/cli/src/commands/ls_files.rs b/cli/src/commands/ls_files.rs new file mode 100644 index 0000000..807eac8 --- /dev/null +++ b/cli/src/commands/ls_files.rs @@ -0,0 +1,164 @@ +use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command, value_parser}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::ls_files::{LsFilesFilter, Request}, +}; +use miette::{IntoDiagnostic, Result}; + +use crate::commands::MevaCommand; + +/// Implements the `ls-files` command for Meva DVCS. +/// +/// Displays information about files in the index and working directory. +pub struct LsFilesCommand {} + +impl LsFilesCommand { + /// Creates a new instance of the `LsFilesCommand`. + pub fn new() -> Self { + Self {} + } + + /// Argument name for `--cached` flag. + /// When provided, shows only files that are tracked (present in the index). + const ARG_CACHED: &'static str = "cached"; + + /// Argument name for `--deleted` flag. + /// When set, lists files removed from the working tree but still in the index. + const ARG_DELETED: &'static str = "deleted"; + + /// Argument name for `--others` flag. + /// When set, lists files that are not tracked (untracked files including ignored ones). + const ARG_OTHERS: &'static str = "others"; + + /// Argument name for `--stage` flag. + /// Displays extended information about file statuses i.e., mode bits, object names, and stage numbers + const ARG_STAGE: &'static str = "stage"; + + /// Argument name for `--abbrev` option. + /// With `--stage`, this limits the printed object identifier to the + /// first N characters (range: 1–40). + const ARG_ABBREV: &'static str = "abbrev"; + + /// Argument name for `--full-name` flag. + /// Always prints full file paths, regardless of the current working directory. + const ARG_FULL_NAME: &'static str = "full-name"; +} + +impl MevaCommand for LsFilesCommand { + fn name(&self) -> &'static str { + "ls-files" + } + + fn about(&self) -> &'static str { + "Show information about files in the index and the working tree" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI definition for the `ls-files` command using `clap`. + /// + /// Adds several mutually exclusive filter options (`--cached`, `--deleted`, `--others`) + /// as well as options for controlling output format (`--stage`, `--abbrev`, `--full-name`). + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_CACHED) + .short('c') + .long(Self::ARG_CACHED) + .help("Show only files in the index (tracked)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_DELETED) + .short('d') + .long(Self::ARG_DELETED) + .help("Show files that have been deleted from the working tree but are still in the index") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_OTHERS) + .short('o') + .long(Self::ARG_OTHERS) + .help("Show untracked files (not in the index)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_STAGE) + .short('s') + .long(Self::ARG_STAGE) + .help("Display extended information about file status") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_ABBREV) + .long(Self::ARG_ABBREV) + .value_name("N") + .value_parser(value_parser!(u8).range(1..=40)) + .requires(Self::ARG_STAGE) + .help("With --stage, prints only the first N characters of object identifiers"), + ) + .arg( + Arg::new(Self::ARG_FULL_NAME) + .long(Self::ARG_FULL_NAME) + .help("Always show full paths (regardless of the calling directory)") + .action(ArgAction::SetTrue), + ).group( + ArgGroup::new("filters") + .args([ + Self::ARG_CACHED, + Self::ARG_DELETED, + Self::ARG_OTHERS, + ]) + .multiple(false) + ) + } + + /// Executes the `ls-files` command. + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + let filter = if matches.get_flag(Self::ARG_DELETED) { + LsFilesFilter::Deleted + } else if matches.get_flag(Self::ARG_OTHERS) { + LsFilesFilter::Others + } else { + LsFilesFilter::Cached + }; + + let request = Request { + stage: matches.get_flag(Self::ARG_STAGE), + abbrev: matches.get_one::(Self::ARG_ABBREV).cloned(), + full_name: matches.get_flag(Self::ARG_FULL_NAME), + filter, + }; + + let handler = container.ls_files_handler().into_diagnostic()?; + + let response = handler.handle_ls_files(request).into_diagnostic()?; + + for file in response.files { + println!("{file}"); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = LsFilesCommand::new(); + assert_eq!(cmd.name(), "ls-files"); + assert_eq!( + cmd.about(), + "Show information about files in the index and the working tree" + ); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/plugins/subcommands/edit.rs b/cli/src/commands/plugins/subcommands/edit.rs index ad3d8c0..3eba283 100644 --- a/cli/src/commands/plugins/subcommands/edit.rs +++ b/cli/src/commands/plugins/subcommands/edit.rs @@ -2,7 +2,7 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; use engine::{ ConfigLoader, EngineContainer, engine_container::MevaContainer, - plugins::{EditRequest, PluginsOperations}, + handlers::plugins::{EditRequest, PluginsOperations}, }; use miette::IntoDiagnostic; use plugins::{CommandType, ScopeType}; diff --git a/cli/src/commands/plugins/subcommands/info.rs b/cli/src/commands/plugins/subcommands/info.rs index 570349d..2dd7793 100644 --- a/cli/src/commands/plugins/subcommands/info.rs +++ b/cli/src/commands/plugins/subcommands/info.rs @@ -2,7 +2,7 @@ use clap::{ArgMatches, Command}; use engine::{ EngineContainer, engine_container::MevaContainer, - plugins::{InfoRequest, PluginsOperations}, + handlers::plugins::{InfoRequest, PluginsOperations}, }; use miette::IntoDiagnostic; use plugins::{CommandType, ScopeType}; diff --git a/cli/src/commands/plugins/subcommands/list.rs b/cli/src/commands/plugins/subcommands/list.rs index 9dcbdf2..476a641 100644 --- a/cli/src/commands/plugins/subcommands/list.rs +++ b/cli/src/commands/plugins/subcommands/list.rs @@ -2,7 +2,7 @@ use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; use engine::{ EngineContainer, engine_container::MevaContainer, - plugins::{ListRequest, PluginsOperations}, + handlers::plugins::{ListRequest, PluginsOperations}, }; use miette::IntoDiagnostic; use plugins::{CommandType, EventType, ScopeType}; diff --git a/cli/src/commands/plugins/subcommands/register.rs b/cli/src/commands/plugins/subcommands/register.rs index ccd108c..f3a8e4c 100644 --- a/cli/src/commands/plugins/subcommands/register.rs +++ b/cli/src/commands/plugins/subcommands/register.rs @@ -2,7 +2,7 @@ use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint, builder::PossibleValu use engine::{ EngineContainer, engine_container::MevaContainer, - plugins::{PluginsOperations, RegisterRequest}, + handlers::plugins::{PluginsOperations, RegisterRequest}, }; use miette::IntoDiagnostic; use plugins::{CommandType, EventType, ScopeType}; diff --git a/cli/src/commands/plugins/subcommands/unregister.rs b/cli/src/commands/plugins/subcommands/unregister.rs index 236742d..49700de 100644 --- a/cli/src/commands/plugins/subcommands/unregister.rs +++ b/cli/src/commands/plugins/subcommands/unregister.rs @@ -2,7 +2,7 @@ use clap::{ArgMatches, Command}; use engine::{ EngineContainer, engine_container::MevaContainer, - plugins::{PluginsOperations, UnregisterRequest}, + handlers::plugins::{PluginsOperations, UnregisterRequest}, }; use miette::IntoDiagnostic; use plugins::{CommandType, ScopeType}; diff --git a/cli/src/commands/show.rs b/cli/src/commands/show.rs new file mode 100644 index 0000000..9e4eacb --- /dev/null +++ b/cli/src/commands/show.rs @@ -0,0 +1,238 @@ +use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command, value_parser}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::show::{PatchMode, Request, Response}, + revision_parsing::Revision, +}; +use miette::{IntoDiagnostic, Result}; + +use crate::commands::MevaCommand; + +/// Implements the `show` command for Meva DVCS. +/// +/// Displays information about a specific snapshot (commit) in the repository. +pub struct ShowCommand {} + +impl ShowCommand { + /// Creates a new instance of the `ShowCommand`. + pub fn new() -> Self { + Self {} + } + + /// Argument name for specifying the snapshot identifier to display. + const ARG_SNAPSHOT_ID: &'static str = "snapshot-id"; + + /// Argument name for the `--patch` flag. + /// Displays the patch (diff) between the selected snapshot and its parent. + const ARG_PATCH: &'static str = "patch"; + + /// Argument name for the `--no-patch` flag. + /// Shows only the snapshot header (author, date, message) without the diff. + const ARG_NO_PATCH: &'static str = "no-patch"; + + /// Argument name for the `--name-only` flag. + /// Displays only the names of modified files in the snapshot. + const ARG_NAME_ONLY: &'static str = "name-only"; + + /// Argument name for the `--name-status` flag. + /// Displays modified file names along with their change types (A, M, D). + const ARG_NAME_STATUS: &'static str = "name-status"; + + /// Argument name for the `--stat` flag. + /// Displays a summary with the number of changed lines per file. + const ARG_STAT: &'static str = "stat"; + + /// Helper method for rendering the `Response` object depending on the selected `PatchMode`. + /// + /// This function handles multiple display modes: + /// - [PatchMode::Patch]: shows unified diffs or per-file diffs. + /// - [PatchMode::NameOnly]: prints only filenames. + /// - [PatchMode::NameStatus]: prints filenames with change kinds. + /// - [PatchMode::Stat]: prints summary statistics. + fn display_response(&self, mode: &PatchMode, response: &Response) { + println!("{}", response.snapshot); + + match mode { + PatchMode::Patch => self.display_patch(response), + PatchMode::NameOnly => self.display_name_only(response), + PatchMode::NameStatus => self.display_name_status(response), + PatchMode::Stat => self.display_stat(response), + _ => (), + }; + } + + /// Shows unified diffs or per-file diffs. + fn display_patch(&self, response: &Response) { + if let Some(diff) = &response.unified_diff { + println!("{diff}"); + } else if let Some(files) = &response.files { + for file in files { + println!("{file}"); + if let Some(hunks) = &file.hunks { + for h in hunks { + print!("{h}"); + } + } else if let Some(diff) = &file.unified_diff { + println!("{diff}"); + } + } + } + } + + /// Prints only filenames. + fn display_name_only(&self, response: &Response) { + if let Some(files) = &response.files { + println!(); + for file in files { + println!("{}", file.new_path.display()); + } + } + } + + /// Prints filenames with change kinds. + fn display_name_status(&self, response: &Response) { + if let Some(files) = &response.files { + println!(); + for file in files { + println!("{}\t{}", file.kind, file.new_path.display()); + } + } + } + + /// Prints summary statistics. + fn display_stat(&self, response: &Response) { + if let Some(stat) = &response.stat { + println!("{stat}"); + } + } +} + +impl MevaCommand for ShowCommand { + fn name(&self) -> &'static str { + "show" + } + + fn about(&self) -> &'static str { + "Show information about a snapshot" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI command structure for the `show` command. + /// + /// Defines options controlling the output format and filtering behavior: + /// - `--patch` (default) displays the diff. + /// - `--no-patch` hides diff output. + /// - `--name-only` and `--name-status` show affected files in different formats. + /// - `--stat` displays change statistics. + /// + /// Uses `ArgGroup` to make these format options mutually exclusive. + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_SNAPSHOT_ID) + .value_name("SNAPSHOT_ID") + .help("The snapshot identifier") + .default_value("HEAD") + .value_parser(value_parser!(Revision)) + .index(1), + ) + .arg( + Arg::new(Self::ARG_PATCH) + .short('p') + .long(Self::ARG_PATCH) + .help("Show the patch (diff) between the snapshot and its parent") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_NO_PATCH) + .long(Self::ARG_NO_PATCH) + .help("Show only the snapshot header (author, date, message), without the diff") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_NAME_ONLY) + .long(Self::ARG_NAME_ONLY) + .help("List only the files modified in the snapshot") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_NAME_STATUS) + .long(Self::ARG_NAME_STATUS) + .help("Show the list of files with the type of change (A, M, D)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_STAT) + .long(Self::ARG_STAT) + .help("Add statistics about the number of changed lines for each file") + .action(ArgAction::SetTrue), + ) + .group( + ArgGroup::new("format") + .args([ + Self::ARG_PATCH, + Self::ARG_NO_PATCH, + Self::ARG_NAME_ONLY, + Self::ARG_NAME_STATUS, + Self::ARG_STAT, + ]) + .multiple(false), + ) + } + + /// Executes the `show` command. + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + let snapshot_id = matches.get_one::(Self::ARG_SNAPSHOT_ID).unwrap(); + let patch = matches.get_flag(Self::ARG_PATCH); + let no_patch = matches.get_flag(Self::ARG_NO_PATCH); + let name_only = matches.get_flag(Self::ARG_NAME_ONLY); + let name_status = matches.get_flag(Self::ARG_NAME_STATUS); + let stat = matches.get_flag(Self::ARG_STAT); + + let handler = container.show_handler().into_diagnostic()?; + + let mode = if patch { + PatchMode::Patch + } else if no_patch { + PatchMode::NoPatch + } else if name_only { + PatchMode::NameOnly + } else if name_status { + PatchMode::NameStatus + } else if stat { + PatchMode::Stat + } else { + PatchMode::Patch + }; + + let request = Request { + snapshot_id: snapshot_id.clone(), + mode, + }; + + let response = handler.handle_show(request).into_diagnostic()?; + + self.display_response(&mode, &response); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = ShowCommand::new(); + assert_eq!(cmd.name(), "show"); + assert_eq!(cmd.about(), "Show information about a snapshot"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/status.rs b/cli/src/commands/status.rs new file mode 100644 index 0000000..272725a --- /dev/null +++ b/cli/src/commands/status.rs @@ -0,0 +1,178 @@ +use clap::{Arg, ArgAction, ArgMatches, Command}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::status::{Request, Response, StatusGrouping}, +}; + +use crate::commands::MevaCommand; + +use miette::{IntoDiagnostic, Result}; + +/// Implements the `status` command for Meva DVCS. +/// +/// Displays the current working tree status, including tracked, untracked, +/// and ignored files. Supports short output format and branch information. +pub struct StatusCommand {} + +impl StatusCommand { + /// Creates a new instance of the `StatusCommand`. + pub fn new() -> Self { + Self {} + } + + /// Argument name for enabling short format (`-s` / `--short`). + const ARG_SHORT: &'static str = "short"; + + /// Argument name for showing branch information (`-b` / `--branch`). + const ARG_BRANCH: &'static str = "branch"; + + /// Argument name for hiding branch information (`--no-branch`). + const ARG_NO_BRANCH: &'static str = "no-branch"; + + /// Argument name for including untracked files in the output (`-u` / `--untracked`). + const ARG_UNTRACKED: &'static str = "untracked"; + + /// Argument name for including ignored files in the output (`-i` / `--ignored`). + const ARG_IGNORED: &'static str = "ignored"; + + /// Helper method for rendering the `Response` object. + fn display_response(&self, request: &Request, response: &Response) { + let groups = response.entries.grouped(); + + if request.show_branch + && let Some(branch) = &response.branch + { + println!("{branch}"); + } + + if request.short_format { + for entry in groups.all() { + println!("{entry}"); + } + } else { + println!("Staged changes:"); + for e in groups.ordinary.iter().filter(|e| e.is_staged()) { + println!(" {e}"); + } + + println!("Unstaged changes:"); + for e in groups.ordinary.iter().filter(|e| e.is_worktree_changed()) { + println!(" {e}"); + } + + if request.show_untracked { + println!("Untracked files:"); + for e in groups.untracked { + println!(" {e}"); + } + } + + if request.show_ignored { + println!("Ignored files:"); + for e in groups.ignored { + println!(" {e}"); + } + } + } + } +} + +impl MevaCommand for StatusCommand { + fn name(&self) -> &'static str { + "status" + } + + fn about(&self) -> &'static str { + "Show the working tree status" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `status` command using `clap`. + /// + /// This command supports several flags: + /// - `-s, --short` – Display output in short format. + /// - `-b, --branch` – Include branch and tracking information. + /// - `--no-branch` – Suppress branch and tracking info. + /// - `-u, --untracked` – Include untracked files in output. + /// - `-i, --ignored` – Include ignored files in output. + /// + /// Conflicts: + /// - `--branch` and `--no-branch` cannot be used together. + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_SHORT) + .short('s') + .long(Self::ARG_SHORT) + .action(ArgAction::SetTrue) + .help("Give the output in the short-format"), + ) + .arg( + Arg::new(Self::ARG_BRANCH) + .short('b') + .long(Self::ARG_BRANCH) + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_NO_BRANCH) + .help("Show the branch and tracking info"), + ) + .arg( + Arg::new(Self::ARG_NO_BRANCH) + .long(Self::ARG_NO_BRANCH) + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_BRANCH) + .help("Do not show the branch and tracking info"), + ) + .arg( + Arg::new(Self::ARG_UNTRACKED) + .short('u') + .long(Self::ARG_UNTRACKED) + .action(ArgAction::SetTrue) + .help("Show untracked files"), + ) + .arg( + Arg::new(Self::ARG_IGNORED) + .short('i') + .long(Self::ARG_IGNORED) + .action(ArgAction::SetTrue) + .help("Show ignored files"), + ) + } + + /// Executes the `status` command. + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + let short = matches.get_flag(Self::ARG_SHORT); + let branch = matches.get_flag(Self::ARG_BRANCH); + let no_branch = matches.get_flag(Self::ARG_NO_BRANCH); + let untracked = matches.get_flag(Self::ARG_UNTRACKED); + let ignored = matches.get_flag(Self::ARG_IGNORED); + + let request = Request::from_flags(short, Some(branch), no_branch, untracked, ignored); + + let handler = container.status_handler().into_diagnostic()?; + + let response = handler.handle_status(request.clone()).into_diagnostic()?; + + self.display_response(&request, &response); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = StatusCommand::new(); + assert_eq!(cmd.name(), "status"); + assert_eq!(cmd.about(), "Show the working tree status"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index dfc94a6..0fcd89f 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -2,9 +2,10 @@ mod commands; mod extensions; mod meva_cli; -use crate::meva_cli::MevaCli; +use crate::{commands::MevaCommand, meva_cli::MevaCli}; use commands::{ - AddCommand, CommitCommand, ConfigCommand, IgnoreCommand, InitCommand, PluginsCommand, + AddCommand, CommitCommand, ConfigCommand, DiffCommand, IgnoreCommand, InitCommand, + LsFilesCommand, PluginsCommand, ShowCommand, StatusCommand, }; use engine::engine_container::MevaContainer; use miette::Result; @@ -12,15 +13,25 @@ use miette::Result; fn main() -> Result<()> { miette::set_panic_hook(); + let commands: Vec>> = vec![ + Box::new(InitCommand::new()), + Box::new(ConfigCommand::new()), + Box::new(IgnoreCommand::new()), + Box::new(PluginsCommand::new()), + Box::new(AddCommand::new()), + Box::new(LsFilesCommand::new()), + Box::new(ShowCommand::new()), + Box::new(StatusCommand::new()), + Box::new(CommitCommand::new()), + Box::new(DiffCommand::new()), + ]; + let container = MevaContainer {}; let mut cli = MevaCli::new(container); - cli.add_command(Box::new(InitCommand::new())); - cli.add_command(Box::new(ConfigCommand::new())); - cli.add_command(Box::new(IgnoreCommand::new())); - cli.add_command(Box::new(PluginsCommand::new())); - cli.add_command(Box::new(AddCommand::new())); - cli.add_command(Box::new(CommitCommand::new())); + for command in commands { + cli.add_command(command); + } cli.run() } diff --git a/cli/src/meva_cli.rs b/cli/src/meva_cli.rs index 595fd5b..f275631 100644 --- a/cli/src/meva_cli.rs +++ b/cli/src/meva_cli.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use clap::{Command, error::ErrorKind}; use engine::EngineContainer; use miette::{IntoDiagnostic, Result, WrapErr, miette}; @@ -6,14 +8,14 @@ use crate::commands::MevaCommand; /// Represents the top-level CLI application for Meva DVCS. /// -/// This struct manages registration of commands, building the CLI parser, +/// This struct manages command registration, building the CLI parser, /// and dispatching command execution based on user input. pub struct MevaCli where T: EngineContainer, { - /// Registered commands available in the CLI. - commands: Vec>>, + /// Registered commands available in the CLI, stored as a map from command name to command object. + commands: HashMap<&'static str, Box>>, /// Dependency injection container. container: T, @@ -23,25 +25,26 @@ impl MevaCli { /// Creates a new instance of the Meva CLI application with no commands registered. pub fn new(container: T) -> Self { Self { - commands: Vec::new(), + commands: HashMap::new(), container, } } /// Adds a new command to the CLI application. /// - /// Commands must implement the `MevaCommand` trait and are stored as trait objects. + /// Commands must implement the `MevaCommand` trait. + /// The command is stored in a map, with the command's name as the key. /// /// # Arguments /// * `command`: The boxed command to register. pub fn add_command(&mut self, command: Box>) { - self.commands.push(command); + self.commands.insert(command.name(), command); } /// Builds the complete CLI parser using `clap::Command`. /// /// Registers all added commands as subcommands, - /// configures top-level metadata like name, version, and help behavior. + /// and configures top-level metadata like name, version, and help behavior. /// /// # Returns /// - A fully configured `clap::Command` ready to parse CLI arguments. @@ -52,7 +55,7 @@ impl MevaCli { .subcommand_required(true) .arg_required_else_help(true); - for command in &self.commands { + for command in self.commands.values() { cli = cli.subcommand(command.build_command()); } @@ -62,7 +65,7 @@ impl MevaCli { /// Runs the CLI application. /// /// This method builds the CLI parser, parses the command-line arguments, - /// dispatches execution to the matched command, and returns the result. + /// and dispatches execution to the matched command. pub fn run(&self) -> Result<()> { let matches = match self.build_cli().try_get_matches() { Ok(m) => m, @@ -77,14 +80,12 @@ impl MevaCli { } }; - if let Some((name, sub_matches)) = matches.subcommand() { - for cmd in &self.commands { - if cmd.name() == name { - return cmd - .execute(sub_matches, &self.container) - .wrap_err_with(|| format!("Error running `{name}` command")); - } - } + if let Some((name, sub_matches)) = matches.subcommand() + && let Some(cmd) = self.commands.get(name) + { + return cmd + .execute(sub_matches, &self.container) + .wrap_err_with(|| format!("Error running `{name}` command")); } Ok(()) diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 2a45e5f..ff3bcc8 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -24,6 +24,7 @@ rayon = "1.11.0" flate2 = "1.1.2" bincode = "2.0.1" tree-ds = "0.2.0" +similar.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/engine/src/branch_manager/meva_branch_manager.rs b/engine/src/branch_manager/meva_branch_manager.rs index 7cdb6f8..b01f830 100644 --- a/engine/src/branch_manager/meva_branch_manager.rs +++ b/engine/src/branch_manager/meva_branch_manager.rs @@ -1,7 +1,7 @@ -use crate::BranchManger; use crate::ObjectStorage; use crate::RefManager; use crate::RepositoryLayout; +use crate::branch_manager::BranchManger; use crate::errors::{CommitError, EngineResult}; use crate::object_storage::MevaObjectStorage; use crate::objects::MevaCommit; diff --git a/engine/src/commit_builder/meva_commit_builder.rs b/engine/src/commit_builder/meva_commit_builder.rs index fe7b5c7..5c032f9 100644 --- a/engine/src/commit_builder/meva_commit_builder.rs +++ b/engine/src/commit_builder/meva_commit_builder.rs @@ -3,8 +3,8 @@ use crate::RepositoryLayout; use crate::errors::{EngineError, EngineResult, NodeError, PathError, TreeError}; use crate::index::MevaIndex; use crate::objects::{MevaCommit, MevaTree, Person, TreeEntry}; -use crate::utils::StripBase; use chrono::Utc; +use shared::StripBase; use shared::{CanonicalizeClean, CumulativePaths, PathToString}; use std::path::Path; use tree_ds::prelude::{Node, Tree}; @@ -156,7 +156,7 @@ impl<'a> MevaCommitBuilder<'a> { /// Returns an [`EngineResult`] if index loading, path resolution, /// or tree construction fails. fn build_tree_from_index(&self) -> EngineResult> { - let index = MevaIndex::new_and_load(self.repo_layout)?; + let index = MevaIndex::from_disk(self.repo_layout)?; let entries = index.get_entries(); let meva_repository_dir = self.repo_layout.working_dir().canonicalize_clean()?; @@ -169,7 +169,7 @@ impl<'a> MevaCommitBuilder<'a> { for entry in entries { let entry_absolute_path = Path::new(&entry.path); - let relative_path = entry_absolute_path.strip_base(&meva_repository_dir)?; + let relative_path = entry_absolute_path.strip_base(&meva_repository_dir); let components = relative_path.cumulative_paths(); diff --git a/engine/src/config.rs b/engine/src/config.rs index 672316b..bd9d9b4 100644 --- a/engine/src/config.rs +++ b/engine/src/config.rs @@ -2,12 +2,8 @@ pub mod config_document; pub mod config_document_operations; pub mod config_loader; pub mod config_location; -mod handler; -mod operations; pub use config_document::ConfigDocument; pub use config_document_operations::ConfigDocumentOperations; pub use config_loader::ConfigLoader; pub use config_location::ConfigLocation; -pub use handler::ConfigHandler; -pub use operations::*; diff --git a/engine/src/diff.rs b/engine/src/diff.rs new file mode 100644 index 0000000..1dec7b1 --- /dev/null +++ b/engine/src/diff.rs @@ -0,0 +1,11 @@ +mod change_kind; +mod diff_stat; +mod file_change; +mod file_diff_stat; +mod hunk; + +pub use change_kind::ChangeKind; +pub use diff_stat::DiffStat; +pub use file_change::FileChange; +pub use file_diff_stat::FileDiffStat; +pub use hunk::Hunk; diff --git a/engine/src/diff/change_kind.rs b/engine/src/diff/change_kind.rs new file mode 100644 index 0000000..980f7c2 --- /dev/null +++ b/engine/src/diff/change_kind.rs @@ -0,0 +1,70 @@ +use std::fmt::Display; + +use serde::{Deserialize, Serialize}; + +/// Represents the kind of change a file has undergone. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ChangeKind { + /// The file is newly added. + /// + /// Displayed as `'A'`. + Added, + /// The file has been modified since the last revision or index state. + /// + /// Displayed as `'M'`. + Modified, + /// The file was deleted. + /// + /// Displayed as `'D'`. + Deleted, + /// The file was renamed. + /// + /// Displayed as `'R'`. + Renamed, + /// The file was copied from another location. + /// + /// Displayed as `'C'`. + Copied, + /// The file’s type has changed (e.g., from regular file to symlink). + /// + /// Displayed as `'T'`. + TypeChanged, + /// The file is in a merge conflict (unmerged state). + /// + /// Displayed as `'U'`. + Unmerged, + /// The file has not been modified. + /// + /// Displayed as a space `' '`. + Unmodified, + /// The change type could not be determined. + /// + /// Displayed as `'?'`. + Unknown, +} + +impl ChangeKind { + /// Returns `true` if the file represents a meaningful change + /// (i.e., it is not `Unmodified` or `Unknown`). + pub fn is_changed(&self) -> bool { + *self != ChangeKind::Unmodified && *self != ChangeKind::Unknown + } +} + +impl Display for ChangeKind { + /// Formats the change kind as a single-character code (e.g. `A`, `M`, `D`). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let c = match self { + ChangeKind::Added => 'A', + ChangeKind::Modified => 'M', + ChangeKind::Deleted => 'D', + ChangeKind::Renamed => 'R', + ChangeKind::Copied => 'C', + ChangeKind::TypeChanged => 'T', + ChangeKind::Unmerged => 'U', + ChangeKind::Unmodified => ' ', + ChangeKind::Unknown => '?', + }; + write!(f, "{c}") + } +} diff --git a/engine/src/diff/diff_stat.rs b/engine/src/diff/diff_stat.rs new file mode 100644 index 0000000..3c6acd8 --- /dev/null +++ b/engine/src/diff/diff_stat.rs @@ -0,0 +1,53 @@ +use std::fmt::Display; + +use serde::{Deserialize, Serialize}; + +use super::file_diff_stat::FileDiffStat; + +/// Represents an aggregate summary of changes across one or more files. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct DiffStat { + /// The total number of inserted lines across all changed files. + pub insertions: usize, + + /// The total number of deleted lines across all changed files. + pub deletions: usize, + + /// The total number of files that were changed. + pub files_changed: usize, + + /// Detailed statistics for each changed file. + pub file_stats: Vec, +} + +impl Display for DiffStat { + /// Formats the diff statistics in a human-readable summary form. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for file_stat in &self.file_stats { + writeln!(f, "{file_stat}")?; + } + let files_str = if self.files_changed == 1 { + "file" + } else { + "files" + }; + write!(f, "{} {files_str} changed", self.files_changed)?; + if self.insertions > 0 { + let insertions_str = if self.insertions == 1 { + "insertion" + } else { + "insertions" + }; + write!(f, ", {} {insertions_str}(+)", self.insertions)?; + } + if self.deletions > 0 { + let deletions_str = if self.deletions == 1 { + "deletion" + } else { + "deletions" + }; + write!(f, ", {} {deletions_str}(-)", self.deletions)?; + } + writeln!(f) + } +} diff --git a/engine/src/diff/file_change.rs b/engine/src/diff/file_change.rs new file mode 100644 index 0000000..24f3c5d --- /dev/null +++ b/engine/src/diff/file_change.rs @@ -0,0 +1,57 @@ +use std::{fmt::Display, path::PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::index::file_mode::FileMode; + +use super::{change_kind::ChangeKind, hunk::Hunk}; + +/// Represents a single file's change information within a diff or commit. +/// +/// This structure aggregates all relevant metadata about a file's modification, +/// including its change type, diff hunks, and the total number of inserted and deleted lines. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileChange { + /// The previous file path, if applicable (e.g. for renames or copies). + pub old_path: Option, + + /// The current file path after the change. + pub new_path: PathBuf, + + /// The type of change that occurred (added, modified, deleted, etc.). + pub kind: ChangeKind, + + /// Total number of inserted lines in this file. + pub insertions: usize, + + /// Total number of deleted lines in this file. + pub deletions: usize, + + /// Optional structured diff representation divided into hunks. + pub hunks: Option>, + + /// Optional preformatted unified diff string representation. + pub unified_diff: Option, + + /// Optional file mode. + pub mode: Option, +} + +impl Display for FileChange { + /// Formats the change summary in a concise format. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let path_display = match &self.old_path { + Some(old) if old != &self.new_path => { + format!("{} -> {}", old.display(), self.new_path.display()) + } + _ => self.new_path.display().to_string(), + }; + + let changes = self.insertions + self.deletions; + write!( + f, + "{}\t{} | {} ({:+} / {:-})", + self.kind, path_display, changes, self.insertions, self.deletions + ) + } +} diff --git a/engine/src/diff/file_diff_stat.rs b/engine/src/diff/file_diff_stat.rs new file mode 100644 index 0000000..9d0b358 --- /dev/null +++ b/engine/src/diff/file_diff_stat.rs @@ -0,0 +1,87 @@ +use std::{fmt::Display, path::PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// Represents the line change statistics for a single file in a diff. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileDiffStat { + /// Number of lines added in the file. + pub insertions: usize, + + /// Number of lines removed from the file. + pub deletions: usize, + + /// Path of the file to which this stat corresponds. + pub path: PathBuf, +} + +/// Maximum length of the textual bar used in display. +const MAX_BAR: usize = 50; + +impl FileDiffStat { + const INSERTION_SYMBOL: &'static str = "+"; + const DELETION_SYMBOL: &'static str = "-"; + + /// Returns the total number of line changes (insertions + deletions). + pub fn total_changes(&self) -> usize { + self.insertions + self.deletions + } + + /// Generates a textual "bar" representing insertions and deletions. + /// + /// # Example + /// ```txt + /// +++++---- // + for insertions, - for deletions + /// ``` + pub fn bar_string(&self, max_bar: usize) -> String { + let (ins_len, del_len) = self.bar_components(max_bar); + format!( + "{}{}", + Self::INSERTION_SYMBOL.repeat(ins_len), + Self::DELETION_SYMBOL.repeat(del_len) + ) + } + + /// Computes the number of symbols for insertions and deletions for the bar visualization. + pub fn bar_components(&self, max_bar: usize) -> (usize, usize) { + let changes = self.total_changes(); + + if changes == 0 { + return (0, 0); + } + + // Determine bar length using square root scaling for better visual proportion. + let bar_len = ((changes as f64).sqrt().ceil() as usize).clamp(1, max_bar); + + let (ins_len, del_len) = if self.insertions == 0 { + (0, bar_len) + } else if self.deletions == 0 { + (bar_len, 0) + } else { + let ins = ((self.insertions as f64 / changes as f64) * bar_len as f64) + .round() + .clamp(0.0, bar_len.saturating_sub(1) as f64) as usize; + let del = bar_len - ins; + (ins, del) + }; + + (ins_len, del_len) + } +} + +impl Display for FileDiffStat { + /// Formats the file diff stat in a human-readable form. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let changes = self.total_changes(); + let bar = self.bar_string(MAX_BAR); + + write!( + f, + " {} | {:>3} {:, +} + +impl Display for Hunk { + /// Formats the hunk in the unified diff style + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "@@ -{},{} +{},{} @@", + self.old_start, self.old_lines, self.new_start, self.new_lines + )?; + for line in &self.lines { + writeln!(f, "{line}")?; + } + Ok(()) + } +} + +/// Represents a single line within a diff hunk. +/// +/// Each line has a type (`HunkLineType`) and the actual content of the line. +/// The first character of each line determines whether it was added (`+`), +/// removed (`-`), or serves as an unchanged context (` `). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HunkLine { + /// The classification of this line (addition, deletion, or context). + pub line_type: HunkLineType, + + /// The text content of the line. + pub content: String, +} + +impl Display for HunkLine { + /// Formats the line for unified diff output, + /// prefixing it with `+`, `-`, or a space depending on its type. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}{}", self.line_type, self.content) + } +} + +/// Enumerates the possible types of lines within a diff hunk. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum HunkLineType { + /// A context line that remains unchanged between versions. + Context, + /// A newly added line in the new version (`+`). + Addition, + /// A line removed from the old version (`-`). + Deletion, +} + +impl Display for HunkLineType { + /// Formats the line type as its diff marker. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let c = match self { + HunkLineType::Context => ' ', + HunkLineType::Addition => '+', + HunkLineType::Deletion => '-', + }; + write!(f, "{c}") + } +} diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 959d300..a62cab2 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -1,10 +1,14 @@ use plugins::{MevaPluginsLayout, PluginsDiscovery, PluginsEngine}; -use crate::add::handlers::AddHandler; -use crate::commit::handlers::CommitHandler; +use crate::ConfigLoader; + +use crate::handlers::{ + add::AddHandler, commit::CommitHandler, config::ConfigHandler, init::InitHandler, + ls_files::LsFilesHandler, plugins::PluginsHandler, show::ShowHandler, status::StatusHandler, +}; + use crate::{ - ConfigLoader, InitHandler, config::ConfigHandler, errors::EngineResult, - plugins::PluginsHandler, plugins_interceptor::PluginsInterceptor, + errors::EngineResult, plugins_interceptor::PluginsInterceptor, repositories::meva_repository_layout::MevaRepositoryLayout, }; @@ -28,6 +32,15 @@ pub trait EngineContainer { /// Returns the handler responsible for add command fn add_handler(&self) -> EngineResult; + /// Returns the handler responsible for ls-files command + fn ls_files_handler(&self) -> EngineResult; + + /// Returns the handler responsible for show command + fn show_handler(&self) -> EngineResult; + + /// Returns the handler responsible for status command + fn status_handler(&self) -> EngineResult; + /// Return the handler responsible for commit creation fn commit_handler(&self) -> EngineResult; } @@ -85,6 +98,18 @@ impl EngineContainer for MevaContainer { Ok(AddHandler) } + fn ls_files_handler(&self) -> EngineResult { + Ok(LsFilesHandler) + } + + fn show_handler(&self) -> EngineResult { + Ok(ShowHandler) + } + + fn status_handler(&self) -> EngineResult { + Ok(StatusHandler) + } + fn commit_handler(&self) -> EngineResult { Ok(CommitHandler { config_loader: ConfigLoader::default(), diff --git a/engine/src/handlers.rs b/engine/src/handlers.rs new file mode 100644 index 0000000..05e6e9e --- /dev/null +++ b/engine/src/handlers.rs @@ -0,0 +1,8 @@ +pub mod add; +pub mod commit; +pub mod config; +pub mod init; +pub mod ls_files; +pub mod plugins; +pub mod show; +pub mod status; diff --git a/engine/src/add.rs b/engine/src/handlers/add.rs similarity index 67% rename from engine/src/add.rs rename to engine/src/handlers/add.rs index cd87a93..995fa58 100644 --- a/engine/src/add.rs +++ b/engine/src/handlers/add.rs @@ -1,4 +1,5 @@ pub mod handlers; pub mod operations; +pub use handlers::AddHandler; pub use operations::*; diff --git a/engine/src/add/handlers.rs b/engine/src/handlers/add/handlers.rs similarity index 97% rename from engine/src/add/handlers.rs rename to engine/src/handlers/add/handlers.rs index 9a4bc01..9ed1080 100644 --- a/engine/src/add/handlers.rs +++ b/engine/src/handlers/add/handlers.rs @@ -1,10 +1,11 @@ use crate::RepositoryLayout; -use crate::add::{AddOperations, AddRequest, AddResponse}; use crate::errors::{EngineError, EngineResult, PathError}; use crate::index::MevaIndex; use crate::plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}; use crate::repositories::meva_repository_layout::MevaRepositoryLayout; +use super::{AddOperations, AddRequest, AddResponse}; + use plugins::{ AddPostPayload, AddPrePayload, CommandType, InvocationInput, InvocationPostPayload, InvocationPrePayload, MevaPluginsLayout, PluginError, @@ -53,7 +54,7 @@ impl AddOperations for AddHandler { let mut index = MevaIndex::new(&repo_layout)?; - index.load()?; + index.load_from_disk()?; let (added, modified, removed) = index.add(&path_abs, add_new, add_deleted, add_ignored, verbose)?; diff --git a/engine/src/add/operations.rs b/engine/src/handlers/add/operations.rs similarity index 100% rename from engine/src/add/operations.rs rename to engine/src/handlers/add/operations.rs diff --git a/engine/src/commit.rs b/engine/src/handlers/commit.rs similarity index 100% rename from engine/src/commit.rs rename to engine/src/handlers/commit.rs diff --git a/engine/src/commit/handlers.rs b/engine/src/handlers/commit/handlers.rs similarity index 98% rename from engine/src/commit/handlers.rs rename to engine/src/handlers/commit/handlers.rs index 62f6692..0f6999d 100644 --- a/engine/src/commit/handlers.rs +++ b/engine/src/handlers/commit/handlers.rs @@ -1,12 +1,14 @@ +use super::{CommitOperations, CommitRequest, CommitResponse}; + use crate::ConfigLoader; use crate::branch_manager::{BranchManger, MevaBranchManager}; -use crate::commit::{CommitOperations, CommitRequest, CommitResponse}; use crate::commit_builder::MevaCommitBuilder; use crate::errors::{EngineError, EngineResult}; use crate::object_storage::{MevaDryRunObjectStorage, MevaObjectStorage, ObjectStorage}; use crate::objects::{MevaCommit, Person}; use crate::plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}; use crate::repositories::meva_repository_layout::MevaRepositoryLayout; + use plugins::{ CommandType, CommitAuthor, CommitContent, CommitPostPayload, CommitPrePayload, InvocationInput, InvocationPostPayload, InvocationPrePayload, MevaPluginsLayout, PluginError, diff --git a/engine/src/commit/operations.rs b/engine/src/handlers/commit/operations.rs similarity index 100% rename from engine/src/commit/operations.rs rename to engine/src/handlers/commit/operations.rs diff --git a/engine/src/handlers/config.rs b/engine/src/handlers/config.rs new file mode 100644 index 0000000..06b41d1 --- /dev/null +++ b/engine/src/handlers/config.rs @@ -0,0 +1,5 @@ +mod handlers; +mod operations; + +pub use handlers::ConfigHandler; +pub use operations::*; diff --git a/engine/src/config/handler.rs b/engine/src/handlers/config/handlers.rs similarity index 97% rename from engine/src/config/handler.rs rename to engine/src/handlers/config/handlers.rs index 3af5a48..08e80dc 100644 --- a/engine/src/config/handler.rs +++ b/engine/src/handlers/config/handlers.rs @@ -3,9 +3,14 @@ use plugins::{ models::*, }; +use super::{ + ConfigOperations, GetRequest, GetResponse, ListRequest, ListResponse, SetRequest, SetResponse, + UnsetRequest, UnsetResponse, +}; + use crate::{ ConfigLocation, - config::{ConfigDocument, ConfigDocumentOperations, operations::*}, + config::{ConfigDocument, ConfigDocumentOperations}, errors::{EngineError, EngineResult}, plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}, repositories::meva_repository_layout::MevaRepositoryLayout, diff --git a/engine/src/config/operations.rs b/engine/src/handlers/config/operations.rs similarity index 100% rename from engine/src/config/operations.rs rename to engine/src/handlers/config/operations.rs diff --git a/engine/src/handlers/init.rs b/engine/src/handlers/init.rs new file mode 100644 index 0000000..01d4772 --- /dev/null +++ b/engine/src/handlers/init.rs @@ -0,0 +1,5 @@ +mod handlers; +mod operations; + +pub use handlers::InitHandler; +pub use operations::*; diff --git a/engine/src/init/handler.rs b/engine/src/handlers/init/handlers.rs similarity index 97% rename from engine/src/init/handler.rs rename to engine/src/handlers/init/handlers.rs index 2470b2c..efbb700 100644 --- a/engine/src/init/handler.rs +++ b/engine/src/handlers/init/handlers.rs @@ -3,10 +3,10 @@ use plugins::{ InvocationPrePayload, MevaPluginsLayout, PluginError, }; +use super::{InitOperations, Request, Response}; use crate::{ ConfigLoader, MevaRepository, errors::{EngineError, EngineResult}, - init::operations::{InitOperations, Request, Response}, plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}, repositories::meva_repository_layout::MevaRepositoryLayout, }; diff --git a/engine/src/init/operations.rs b/engine/src/handlers/init/operations.rs similarity index 100% rename from engine/src/init/operations.rs rename to engine/src/handlers/init/operations.rs diff --git a/engine/src/handlers/ls_files.rs b/engine/src/handlers/ls_files.rs new file mode 100644 index 0000000..c97b5e4 --- /dev/null +++ b/engine/src/handlers/ls_files.rs @@ -0,0 +1,8 @@ +mod handlers; +mod models; +mod operations; + +pub use handlers::LsFilesHandler; +pub use operations::*; + +pub use models::LsFilesFilter; diff --git a/engine/src/handlers/ls_files/handlers.rs b/engine/src/handlers/ls_files/handlers.rs new file mode 100644 index 0000000..73a06e8 --- /dev/null +++ b/engine/src/handlers/ls_files/handlers.rs @@ -0,0 +1,117 @@ +use super::{ + LsFilesOperations, Request, Response, + models::{LsFilesEntry, LsFilesFilter}, +}; + +use crate::{ + RepositoryLayout, + errors::EngineResult, + index::{MevaIndex, index_entry::IndexEntry}, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; + +use shared::{PathToString, StripBase}; +use std::path::Path; + +/// Handles `ls-files` operations. +pub struct LsFilesHandler; + +impl LsFilesHandler { + /// Entry point for executing the `ls-files` operation. + pub fn handle_ls_files(&self, request: Request) -> EngineResult { + self.ls_files(request) + } + + /// Builds a [`Response`] from a list of [`IndexEntry`] references. + fn build_response_from_index_entries( + &self, + entries: &[&IndexEntry], + abbrev: usize, + include_stage: bool, + working_dir: &Path, + full_name: bool, + ) -> Response { + let files = entries + .iter() + .map(|entry| { + let path = match full_name { + true => entry.path.to_utf8_string(), + false => entry.path.strip_base(working_dir).to_utf8_string(), + }; + match include_stage { + true => LsFilesEntry::Entry { + mode: entry.mode, + object: entry.sha1[..abbrev].to_string(), + stage: entry.stage, + path, + }, + false => LsFilesEntry::Path { path }, + } + }) + .collect(); + + Response { files } + } + + /// Builds a [`Response`] from a list of filesystem paths (not index entries). + /// + /// This is used for untracked files or deleted files that no longer exist + /// in the index. + fn build_response_from_paths( + &self, + paths: &[impl AsRef], + working_dir: &Path, + full_name: bool, + ) -> Response { + Response { + files: paths + .iter() + .map(|p| LsFilesEntry::Path { + path: match full_name { + true => p.as_ref().to_utf8_string(), + false => p.as_ref().strip_base(working_dir).to_utf8_string(), + }, + }) + .collect(), + } + } +} + +impl LsFilesOperations for LsFilesHandler { + fn ls_files(&self, request: Request) -> EngineResult { + let repo_layout = MevaRepositoryLayout::discover()?; + let working_dir = repo_layout.working_dir(); + let index = MevaIndex::from_disk(&repo_layout)?; + + let abbrev = request.abbrev.unwrap_or(40) as usize; + + let response = match request.filter { + LsFilesFilter::Deleted => { + let entries = index.get_deleted_files(); + self.build_response_from_index_entries( + &entries, + abbrev, + request.stage, + working_dir, + request.full_name, + ) + } + LsFilesFilter::Cached => { + let entries = index.get_entries(); + self.build_response_from_index_entries( + &entries, + abbrev, + request.stage, + working_dir, + request.full_name, + ) + } + LsFilesFilter::Others => { + let untracked = index.get_untracked_files(); + self.build_response_from_paths(&untracked, working_dir, request.full_name) + } + }; + + Ok(response) + } +} diff --git a/engine/src/handlers/ls_files/models.rs b/engine/src/handlers/ls_files/models.rs new file mode 100644 index 0000000..2f385ee --- /dev/null +++ b/engine/src/handlers/ls_files/models.rs @@ -0,0 +1,5 @@ +mod ls_files_entry; +mod ls_files_filter; + +pub use ls_files_entry::LsFilesEntry; +pub use ls_files_filter::LsFilesFilter; diff --git a/engine/src/handlers/ls_files/models/ls_files_entry.rs b/engine/src/handlers/ls_files/models/ls_files_entry.rs new file mode 100644 index 0000000..9a9b306 --- /dev/null +++ b/engine/src/handlers/ls_files/models/ls_files_entry.rs @@ -0,0 +1,35 @@ +use std::fmt::Display; + +use crate::index::{file_mode::FileMode, stage::Stage}; + +/// Represents a single entry in an `ls-files` response. +/// +/// Depending on the flags, the output can be either a simple file path or +/// a full index entry with metadata (mode, hash, stage). +#[derive(Debug)] +pub enum LsFilesEntry { + /// Simplified variant containing only the file path. + Path { path: String }, + /// Full index entry variant with file metadata. + Entry { + mode: FileMode, + object: String, + stage: Stage, + path: String, + }, +} + +impl Display for LsFilesEntry { + /// Formats an [`LsFilesEntry`] for human-readable output. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LsFilesEntry::Path { path } => write!(f, "{path}"), + LsFilesEntry::Entry { + mode, + object, + stage, + path, + } => write!(f, "{mode:o} {object} {stage} {path}"), + } + } +} diff --git a/engine/src/handlers/ls_files/models/ls_files_filter.rs b/engine/src/handlers/ls_files/models/ls_files_filter.rs new file mode 100644 index 0000000..4385725 --- /dev/null +++ b/engine/src/handlers/ls_files/models/ls_files_filter.rs @@ -0,0 +1,10 @@ +/// Defines the type of file listing to perform. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LsFilesFilter { + /// Lists all files currently **tracked** (i.e., present in the index). + Cached, + /// Lists files that **were in the index** but **no longer exist** in the working directory. + Deleted, + /// Lists files that **exist in the working directory** but **are not tracked** in the index. + Others, +} diff --git a/engine/src/handlers/ls_files/operations.rs b/engine/src/handlers/ls_files/operations.rs new file mode 100644 index 0000000..35002ee --- /dev/null +++ b/engine/src/handlers/ls_files/operations.rs @@ -0,0 +1,18 @@ +use crate::errors::EngineResult; + +use super::models::{LsFilesEntry, LsFilesFilter}; + +pub struct Request { + pub stage: bool, + pub abbrev: Option, + pub full_name: bool, + pub filter: LsFilesFilter, +} + +pub struct Response { + pub files: Vec, +} + +pub trait LsFilesOperations { + fn ls_files(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/handlers/plugins.rs b/engine/src/handlers/plugins.rs new file mode 100644 index 0000000..d815b64 --- /dev/null +++ b/engine/src/handlers/plugins.rs @@ -0,0 +1,5 @@ +mod handlers; +mod operations; + +pub use handlers::PluginsHandler; +pub use operations::*; diff --git a/engine/src/plugins/handler.rs b/engine/src/handlers/plugins/handlers.rs similarity index 95% rename from engine/src/plugins/handler.rs rename to engine/src/handlers/plugins/handlers.rs index ec34655..878c8cb 100644 --- a/engine/src/plugins/handler.rs +++ b/engine/src/handlers/plugins/handlers.rs @@ -3,14 +3,13 @@ use std::{env, path::PathBuf}; use plugins::{PluginConfiguration, PluginsRepository, ScopeType}; use shared::UpwardSearch; +use super::{ + EditRequest, EditResponse, InfoRequest, InfoResponse, ListRequest, ListResponse, + PluginsOperations, RegisterRequest, RegisterResponse, UnregisterRequest, UnregisterResponse, +}; use crate::{ RepositoryLayout, errors::{EngineResult, RepositoryError}, - plugins::{ - EditRequest, EditResponse, InfoRequest, InfoResponse, ListRequest, ListResponse, - PluginsOperations, RegisterRequest, RegisterResponse, UnregisterRequest, - UnregisterResponse, - }, }; pub struct PluginsHandler { diff --git a/engine/src/plugins/operations.rs b/engine/src/handlers/plugins/operations.rs similarity index 100% rename from engine/src/plugins/operations.rs rename to engine/src/handlers/plugins/operations.rs diff --git a/engine/src/handlers/show.rs b/engine/src/handlers/show.rs new file mode 100644 index 0000000..4245e0d --- /dev/null +++ b/engine/src/handlers/show.rs @@ -0,0 +1,8 @@ +mod handlers; +mod models; +mod operations; + +pub use handlers::ShowHandler; +pub use operations::*; + +pub use models::PatchMode; diff --git a/engine/src/handlers/show/handlers.rs b/engine/src/handlers/show/handlers.rs new file mode 100644 index 0000000..2bfd593 --- /dev/null +++ b/engine/src/handlers/show/handlers.rs @@ -0,0 +1,17 @@ +use crate::errors::EngineResult; + +use super::{Request, Response, ShowOperations}; + +pub struct ShowHandler; + +impl ShowHandler { + pub fn handle_show(&self, request: Request) -> EngineResult { + self.show(request) + } +} + +impl ShowOperations for ShowHandler { + fn show(&self, _request: Request) -> EngineResult { + todo!() + } +} diff --git a/engine/src/handlers/show/models.rs b/engine/src/handlers/show/models.rs new file mode 100644 index 0000000..511de35 --- /dev/null +++ b/engine/src/handlers/show/models.rs @@ -0,0 +1,5 @@ +mod patch_mode; +mod snapshot; + +pub use patch_mode::PatchMode; +pub use snapshot::Snapshot; diff --git a/engine/src/handlers/show/models/patch_mode.rs b/engine/src/handlers/show/models/patch_mode.rs new file mode 100644 index 0000000..83c29b6 --- /dev/null +++ b/engine/src/handlers/show/models/patch_mode.rs @@ -0,0 +1,18 @@ +/// Represents the display mode for the `show` command output. +/// +/// This enum determines how the differences between revisions or commits +/// are presented to the user. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PatchMode { + /// Display the full diff patch, including added and removed lines. + Patch, + /// Show only the commit message and metadata, without any diff output. + NoPatch, + /// Show only the file names that were changed, one per line. + NameOnly, + /// Show both file names and their change status (e.g. `A`, `M`, `D`). + NameStatus, + /// Show a summary of changes per file (insertions/deletions), + /// rather than full patch details. + Stat, +} diff --git a/engine/src/handlers/show/models/snapshot.rs b/engine/src/handlers/show/models/snapshot.rs new file mode 100644 index 0000000..d26c767 --- /dev/null +++ b/engine/src/handlers/show/models/snapshot.rs @@ -0,0 +1,48 @@ +use std::fmt::Display; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::objects::Person; + +/// Represents a commit-like snapshot in the repository +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Snapshot { + /// Unique hash or identifier of the snapshot. + pub id: String, + + /// The person who created this snapshot. + pub author: Person, + + /// Timestamp when the snapshot was created. + pub timestamp: DateTime, + + /// Commit message describing the changes in this snapshot. + pub message: String, + + /// List of parent snapshot IDs. + pub parent_ids: Vec, +} + +impl Display for Snapshot { + /// Formats the snapshot for human-readable display. + /// + /// Example: + /// ```txt + /// Commit: abc1234 + /// Author: John Doe + /// Date: Wed, 01 Jan 2025 12:34:56 +0000 + /// + /// Initial commit + /// ``` + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Commit: {}", self.id)?; + writeln!(f, "Author: {}", self.author)?; + writeln!(f, "Date: {}", self.timestamp.to_rfc2822())?; + writeln!(f)?; + for line in self.message.lines() { + writeln!(f, "\t{line}")?; + } + Ok(()) + } +} diff --git a/engine/src/handlers/show/operations.rs b/engine/src/handlers/show/operations.rs new file mode 100644 index 0000000..381f71c --- /dev/null +++ b/engine/src/handlers/show/operations.rs @@ -0,0 +1,23 @@ +use crate::{ + diff::{DiffStat, FileChange}, + errors::EngineResult, + revision_parsing::Revision, +}; + +use super::models::{PatchMode, Snapshot}; + +pub struct Request { + pub snapshot_id: Revision, + pub mode: PatchMode, +} + +pub struct Response { + pub snapshot: Snapshot, + pub stat: Option, + pub files: Option>, + pub unified_diff: Option, +} + +pub trait ShowOperations { + fn show(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/handlers/status.rs b/engine/src/handlers/status.rs new file mode 100644 index 0000000..7843843 --- /dev/null +++ b/engine/src/handlers/status.rs @@ -0,0 +1,8 @@ +mod handlers; +mod models; +mod operations; + +pub use handlers::StatusHandler; +pub use operations::*; + +pub use models::StatusGrouping; diff --git a/engine/src/handlers/status/handlers.rs b/engine/src/handlers/status/handlers.rs new file mode 100644 index 0000000..1731057 --- /dev/null +++ b/engine/src/handlers/status/handlers.rs @@ -0,0 +1,17 @@ +use crate::errors::EngineResult; + +use super::{Request, Response, StatusOperations}; + +pub struct StatusHandler; + +impl StatusHandler { + pub fn handle_status(&self, request: Request) -> EngineResult { + self.status(request) + } +} + +impl StatusOperations for StatusHandler { + fn status(&self, _request: Request) -> EngineResult { + todo!() + } +} diff --git a/engine/src/handlers/status/models.rs b/engine/src/handlers/status/models.rs new file mode 100644 index 0000000..3962e0c --- /dev/null +++ b/engine/src/handlers/status/models.rs @@ -0,0 +1,8 @@ +mod branch_info; +mod grouped_status; +mod status_entry; +mod status_kind; + +pub use branch_info::BranchInfo; +pub use grouped_status::StatusGrouping; +pub use status_entry::StatusEntry; diff --git a/engine/src/handlers/status/models/branch_info.rs b/engine/src/handlers/status/models/branch_info.rs new file mode 100644 index 0000000..28576aa --- /dev/null +++ b/engine/src/handlers/status/models/branch_info.rs @@ -0,0 +1,47 @@ +use std::fmt::Display; + +/// Represents information about a branch state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BranchInfo { + /// The name of the current branch, if not in a detached HEAD state. + pub head: Option, + + /// The name of the upstream branch being tracked, if any. + pub upstream: Option, + + /// Number of commits the local branch is ahead of the upstream. + pub ahead: usize, + + /// Number of commits the local branch is behind the upstream. + pub behind: usize, + + /// Indicates whether HEAD is detached (not pointing to any branch). + pub is_detached: bool, +} + +impl Display for BranchInfo { + /// Formats branch information in a human-readable form. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.is_detached { + return match &self.head { + Some(oid) => write!(f, "HEAD (detached at {oid})"), + None => write!(f, "HEAD (detached)"), + }; + } + if let Some(head) = &self.head { + match &self.upstream { + Some(up) if self.ahead == 0 && self.behind == 0 => { + write!(f, "On branch {head} (up to date with {up})") + } + Some(up) => write!( + f, + "On branch {head} ({} ahead, {} behind of {})", + self.ahead, self.behind, up + ), + None => write!(f, "On branch {head}"), + } + } else { + write!(f, "On unknown branch") + } + } +} diff --git a/engine/src/handlers/status/models/grouped_status.rs b/engine/src/handlers/status/models/grouped_status.rs new file mode 100644 index 0000000..45008a1 --- /dev/null +++ b/engine/src/handlers/status/models/grouped_status.rs @@ -0,0 +1,81 @@ +use super::{status_entry::StatusEntry, status_kind::StatusKind}; + +/// Represents a collection of `StatusEntry` items grouped by their kind: +/// - ordinary (tracked) files, +/// - untracked files, +/// - ignored files. +#[allow(dead_code)] +pub struct GroupedStatus<'a> { + /// Tracked files with or without staged/unstaged changes. + pub ordinary: Vec<&'a StatusEntry>, + + /// Files not yet tracked by the repository. + pub untracked: Vec<&'a StatusEntry>, + + /// Files explicitly ignored by ignore rules. + pub ignored: Vec<&'a StatusEntry>, +} + +#[allow(dead_code)] +impl<'a> GroupedStatus<'a> { + /// Returns the total number of entries in all groups. + pub fn total(&self) -> usize { + self.ordinary_count() + self.untracked_count() + self.ignored_count() + } + + /// Returns the number of ordinary (tracked) entries. + pub fn ordinary_count(&self) -> usize { + self.ordinary.len() + } + + /// Returns the number of untracked entries. + pub fn untracked_count(&self) -> usize { + self.untracked.len() + } + + /// Returns the number of ignored entries. + pub fn ignored_count(&self) -> usize { + self.ignored.len() + } + + /// Returns all entries from all groups in a single vector. + pub fn all(&self) -> Vec<&'a StatusEntry> { + let mut v = Vec::with_capacity(self.total()); + v.extend(self.ordinary.iter().cloned()); + v.extend(self.untracked.iter().cloned()); + v.extend(self.ignored.iter().cloned()); + v + } +} + +#[allow(dead_code)] +/// Trait for collections of status entries that can be grouped by kind. +pub trait StatusGrouping { + /// Groups status entries into `GroupedStatus` by their kind. + fn grouped(&self) -> GroupedStatus<'_>; +} + +impl StatusGrouping for T +where + T: AsRef<[StatusEntry]>, +{ + fn grouped(&self) -> GroupedStatus<'_> { + let mut ordinary = Vec::new(); + let mut untracked = Vec::new(); + let mut ignored = Vec::new(); + + for e in self.as_ref() { + match e.kind { + StatusKind::Ordinary { .. } => ordinary.push(e), + StatusKind::Untracked => untracked.push(e), + StatusKind::Ignored => ignored.push(e), + } + } + + GroupedStatus { + ordinary, + untracked, + ignored, + } + } +} diff --git a/engine/src/handlers/status/models/status_entry.rs b/engine/src/handlers/status/models/status_entry.rs new file mode 100644 index 0000000..9b125c9 --- /dev/null +++ b/engine/src/handlers/status/models/status_entry.rs @@ -0,0 +1,121 @@ +use std::{fmt::Display, path::PathBuf}; + +use crate::diff::ChangeKind; + +use super::status_kind::StatusKind; + +/// Represents a single file entry in the repository status. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusEntry { + /// The kind of status: ordinary (tracked), untracked, or ignored. + pub kind: StatusKind, + + /// The current path of the file in the working directory. + pub path: PathBuf, + + /// The original path of the file (for renames), if any. + pub orig_path: Option, +} + +impl StatusEntry { + /// Constructs a tracked (ordinary) file entry with given staged and + /// worktree statuses, optionally specifying the original path. + pub fn ordinary( + path: impl Into, + index: ChangeKind, + worktree: ChangeKind, + orig_path: Option, + ) -> Self { + Self { + kind: StatusKind::Ordinary { + index_status: index, + worktree_status: worktree, + }, + path: path.into(), + orig_path, + } + } + + /// Constructs an untracked file entry. + pub fn untracked(path: impl Into) -> Self { + Self { + kind: StatusKind::Untracked, + path: path.into(), + orig_path: None, + } + } + + /// Constructs an ignored file entry. + pub fn ignored(path: impl Into) -> Self { + Self { + kind: StatusKind::Ignored, + path: path.into(), + orig_path: None, + } + } + + /// Returns `true` if the file is untracked. + pub fn is_untracked(&self) -> bool { + matches!(self.kind, StatusKind::Untracked) + } + + /// Returns `true` if the file is ignored. + pub fn is_ignored(&self) -> bool { + matches!(self.kind, StatusKind::Ignored) + } + + /// Returns `true` if the file is tracked (ordinary). + pub fn is_ordinary(&self) -> bool { + matches!(self.kind, StatusKind::Ordinary { .. }) + } + + /// Returns `true` if the file has staged changes in the index. + pub fn is_staged(&self) -> bool { + matches!( + self.kind, + StatusKind::Ordinary { + index_status, + .. + } if index_status.is_changed() + ) + } + + /// Returns `true` if the file has changes in the working tree (unstaged). + pub fn is_worktree_changed(&self) -> bool { + matches!( + self.kind, + StatusKind::Ordinary { + worktree_status, + .. + } if worktree_status.is_changed() + ) + } + + /// Renders a short-format string of the file status. + pub fn render_short(&self) -> String { + match &self.kind { + StatusKind::Untracked => format!("??\t{}", self.path.display()), + StatusKind::Ignored => format!("!!\t{}", self.path.display()), + StatusKind::Ordinary { + index_status, + worktree_status, + } => { + let x = index_status.to_string(); + let y = worktree_status.to_string(); + match &self.orig_path { + Some(orig) => { + format!("{}{}\t{} -> {}", x, y, orig.display(), self.path.display()) + } + None => format!("{}{}\t{}", x, y, self.path.display()), + } + } + } + } +} + +impl Display for StatusEntry { + /// Formats the `StatusEntry` using the short-format representation. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.render_short()) + } +} diff --git a/engine/src/handlers/status/models/status_kind.rs b/engine/src/handlers/status/models/status_kind.rs new file mode 100644 index 0000000..b68c2be --- /dev/null +++ b/engine/src/handlers/status/models/status_kind.rs @@ -0,0 +1,19 @@ +use crate::diff::ChangeKind; + +/// Represents the type of a file's status in the working tree and index. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusKind { + /// A file that is tracked in the index and may have changes in the index + /// or working tree. + Ordinary { + /// The state of the file in the index (staged changes). + index_status: ChangeKind, + + /// The state of the file in the working tree (unstaged changes). + worktree_status: ChangeKind, + }, + /// A file that exists in the working directory but is not tracked in the index. + Untracked, + /// A file that matches ignore rules and is intentionally ignored. + Ignored, +} diff --git a/engine/src/handlers/status/operations.rs b/engine/src/handlers/status/operations.rs new file mode 100644 index 0000000..46954f1 --- /dev/null +++ b/engine/src/handlers/status/operations.rs @@ -0,0 +1,43 @@ +use super::models::{BranchInfo, StatusEntry}; + +use crate::errors::EngineResult; + +#[derive(Clone)] +pub struct Request { + pub show_branch: bool, + pub show_untracked: bool, + pub show_ignored: bool, + pub short_format: bool, +} + +impl Request { + pub fn from_flags( + short: bool, + branch: Option, + no_branch: bool, + untracked: bool, + ignored: bool, + ) -> Self { + let show_branch = match (no_branch, branch) { + (true, _) => false, + (false, Some(b)) => b, + (false, None) => !short, + }; + + Self { + show_branch, + show_untracked: untracked, + show_ignored: ignored, + short_format: short, + } + } +} + +pub struct Response { + pub branch: Option, + pub entries: Vec, +} + +pub trait StatusOperations { + fn status(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/index/file_mode.rs b/engine/src/index/file_mode.rs index c0a89af..4edd77a 100644 --- a/engine/src/index/file_mode.rs +++ b/engine/src/index/file_mode.rs @@ -1,3 +1,4 @@ +use std::fmt; use std::fs; use std::path::Path; @@ -9,7 +10,7 @@ use serde::{Deserialize, Serialize}; /// These values are used to /// differentiate between regular files, executables, symbolic links, /// and submodules (gitlinks). -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, PartialEq)] pub enum FileMode { /// A regular non-executable file (`100644`). Normal = 0o100644, @@ -24,6 +25,13 @@ pub enum FileMode { GitLink = 0o160000, } +impl fmt::Octal for FileMode { + /// Formats the [`FileMode`] value as an octal number. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Octal::fmt(&(*self as u32), f) + } +} + impl TryFrom<&Path> for FileMode { type Error = std::io::Error; diff --git a/engine/src/index/meva_index.rs b/engine/src/index/meva_index.rs index 50ab9b6..5dd554f 100644 --- a/engine/src/index/meva_index.rs +++ b/engine/src/index/meva_index.rs @@ -1,8 +1,9 @@ -use std::collections::{HashMap, HashSet}; -use std::fs::OpenOptions; -use std::io::Read; -use std::path::{Path, PathBuf}; -use std::{fs, io}; +use std::{ + collections::{HashMap, HashSet}, + fs::{self, OpenOptions}, + io::{self, Read}, + path::{Path, PathBuf}, +}; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use serde_json::Deserializer; @@ -12,13 +13,13 @@ use crate::errors::{EngineResult, IndexError}; use crate::index::{IndexEntry, deserialize_entries_with_absolute_keys}; use crate::object_storage::{MevaObjectStorage, ObjectStorage}; use crate::objects::MevaBlob; -use crate::utils::StripBase; use crate::{IgnoreOperations, IgnoreResult, IgnoreService, RepositoryLayout}; -use shared::CanonicalizeClean; + +use shared::{CanonicalizeClean, PathToString, StripBase}; /// Represents the in-memory index of tracked files in a Meva repository. /// -/// The index keeps metadata (`IndexEntry`) for each tracked file, and is +/// The index keeps metadata ([IndexEntry]) for each tracked file, and is /// persisted in a JSON file on disk (the *index file*). It is responsible for: /// - loading and saving the index, /// - tracking new/modified/deleted files, @@ -43,12 +44,12 @@ impl<'a> MevaIndex<'a> { /// Creates a new `MevaIndex` and immediately loads its state from disk. /// - /// This is equivalent to calling [`MevaIndex::new`] followed by [`MevaIndex::load`]. + /// This is equivalent to calling [`MevaIndex::new`] followed by [`MevaIndex::load_from_disk`]. /// /// Returns an error if the index file cannot be read or parsed. - pub fn new_and_load(repo_layout: &'a dyn RepositoryLayout) -> EngineResult { + pub fn from_disk(repo_layout: &'a dyn RepositoryLayout) -> EngineResult { let mut meva_index = MevaIndex::new(repo_layout)?; - meva_index.load()?; + meva_index.load_from_disk()?; Ok(meva_index) } @@ -64,7 +65,7 @@ impl<'a> MevaIndex<'a> { /// Loads the index file from disk into memory. /// /// If the file does not exist or is empty, the index will be cleared. - pub fn load(&mut self) -> EngineResult<()> { + pub fn load_from_disk(&mut self) -> EngineResult<()> { let mut file = OpenOptions::new() .write(true) .read(true) @@ -89,6 +90,53 @@ impl<'a> MevaIndex<'a> { Ok(()) } + /// Identifies tracked files that no longer exist in the working directory. + /// + /// Returns a list of [`IndexEntry`] references representing deleted files. + pub fn get_deleted_files(&self) -> Vec<&IndexEntry> { + let ignore_services = self.create_ignore_services(); + let dir_path = self.repo_layout.working_dir(); + let dir_files = self.collect_files(dir_path, &ignore_services, false); + + let files_in_index = self + .entries + .iter() + .filter(|(key, _)| Path::new(&key).starts_with(dir_path)) + .collect::>(); + + let workdir_set = dir_files + .iter() + .map(|p| p.to_utf8_string()) + .collect::>(); + + files_in_index + .into_iter() + .filter(|(key, _)| !workdir_set.contains(*key)) + .map(|(_, value)| value) + .collect::>() + } + + /// Scans the working directory for files that are **not** currently tracked in the index. + /// + /// Ignores paths that are filtered out by `.mevaignore` files. + pub fn get_untracked_files(&self) -> Vec { + let ignore_services = self.create_ignore_services(); + let dir_path = self.repo_layout.working_dir(); + let dir_files = self.collect_all_files_including_ignored(dir_path, &ignore_services); + + let files_in_index = self + .entries + .keys() + .filter(|k| Path::new(&k).starts_with(dir_path)) + .cloned() + .collect::>(); + + dir_files + .into_iter() + .filter(|p| !files_in_index.contains(&p.to_utf8_string())) + .collect::>() + } + /// Adds files to the index according to the provided flags: /// /// - `add_new`: include new files, @@ -106,16 +154,16 @@ impl<'a> MevaIndex<'a> { verbose: bool, ) -> EngineResult<(Vec, Vec, Vec)> { let ignore_services = match add_ignored { - true => Vec::new(), + true => vec![], false => self.create_ignore_services(), }; - let files_abs = self.collect_files(path_abs, ignore_services); + let files_abs = self.collect_files(path_abs, &ignore_services, false); let (mut new_entries, mut modified_entries) = self.group_files(&files_abs)?; - let mut new_files = Vec::::new(); - let mut deleted_files = Vec::::new(); + let mut new_files = vec![]; + let mut deleted_files = vec![]; let modified_files = modified_entries .iter() .map(|e| PathBuf::from(e.path.clone())) @@ -161,9 +209,9 @@ impl<'a> MevaIndex<'a> { for entry in self.entries.values_mut() { let entry_absolute_path = Path::new(&entry.path); - let relative_path = entry_absolute_path.strip_base(&meva_repository_dir)?; + let relative_path = entry_absolute_path.strip_base(&meva_repository_dir); - entry.path = relative_path.to_string_lossy().to_string(); + entry.path = relative_path.to_utf8_string(); } let json = @@ -185,22 +233,22 @@ impl<'a> MevaIndex<'a> { dir_files: &[PathBuf], verbose: bool, ) -> Vec { - let files_in_index: Vec = self + let files_in_index = self .entries .keys() .filter(|k| Path::new(&k).starts_with(dir_path)) .cloned() - .collect(); + .collect::>(); - let workdir_set: HashSet = dir_files + let workdir_set = dir_files .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); + .map(|p| p.to_utf8_string()) + .collect::>(); - let deleted_files: Vec = files_in_index + let deleted_files = files_in_index .into_iter() .filter(|k| !workdir_set.contains(k)) - .collect(); + .collect::>(); for file in &deleted_files { if verbose { @@ -288,21 +336,49 @@ impl<'a> MevaIndex<'a> { .collect() } - /// Recursively collects files under a given path, applying ignore services - /// if provided. + #[allow(dead_code)] + /// Collects both tracked and untracked files under the given path. /// - /// Returns only regular files (`is_file()`). - fn collect_files(&self, path: &Path, ignore_services: Vec) -> Vec { + /// Applies ignore rules via `ignore_services`. + fn collect_tracked_or_untracked_files( + &self, + path: &Path, + ignore_services: &[IgnoreService], + ) -> Vec { + self.collect_files(path, ignore_services, false) + } + + /// Collects **all files**, including those explicitly ignored. + fn collect_all_files_including_ignored( + &self, + path: &Path, + ignore_services: &[IgnoreService], + ) -> Vec { + self.collect_files(path, ignore_services, true) + } + + /// Recursively traverses a directory, collecting file paths. + /// + /// - Applies ignore rules from `ignore_services` (unless `include_ignored` is `true`). + /// - Excludes the internal `.meva/` repository directory. + /// - Returns only regular files. + fn collect_files( + &self, + path: &Path, + ignore_services: &[IgnoreService], + include_ignored: bool, + ) -> Vec { WalkDir::new(path) .into_iter() .filter_entry(|e| { + // Skip the repository's internal directory if e.file_type().is_dir() && e.path() == self.repo_layout.repository_dir() { return false; } - for ignore_service in &ignore_services { + for ignore_service in ignore_services { match ignore_service.check_cached(&e.path()) { - Ok(IgnoreResult::Ignored { .. }) => return false, + Ok(IgnoreResult::Ignored { .. }) => return include_ignored, Ok(IgnoreResult::NotIgnored { .. }) => {} Err(_) => return true, } diff --git a/engine/src/index/stage.rs b/engine/src/index/stage.rs index d2460b2..b6c43f3 100644 --- a/engine/src/index/stage.rs +++ b/engine/src/index/stage.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use serde::{Deserialize, Serialize}; /// Represents the stage of a file in the index during merge operations. @@ -12,7 +14,7 @@ use serde::{Deserialize, Serialize}; /// /// This allows the index to hold multiple versions of a file /// until the conflict is resolved. -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, PartialEq)] pub enum Stage { /// Standard entry with no merge conflict. Normal = 0, @@ -26,3 +28,10 @@ pub enum Stage { /// "Theirs" version — file state from the branch being merged. Theirs = 3, } + +impl Display for Stage { + /// Formats the [`Stage`] as its numeric representation. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", *self as u8) + } +} diff --git a/engine/src/init.rs b/engine/src/init.rs deleted file mode 100644 index 15f98ea..0000000 --- a/engine/src/init.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod handler; -mod operations; - -pub use handler::InitHandler; -pub use operations::*; diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 7b143c2..87b58ff 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,32 +1,28 @@ mod commit_builder; mod object_storage; mod serialize_deserialize; -mod utils; -pub mod add; pub mod branch_manager; -pub mod commit; pub mod config; +pub mod diff; pub mod engine_container; pub mod errors; +pub mod handlers; pub mod hasher; pub mod ignore; pub mod index; -pub mod init; pub mod objects; -pub mod plugins; pub mod plugins_interceptor; pub mod ref_manager; pub mod repositories; +pub mod revision_parsing; use errors::{EngineError, EngineResult, InitError}; use object_storage::ObjectStorage; use ref_manager::RefManager; -pub use branch_manager::BranchManger; -pub use config::{ConfigDocument, ConfigHandler, ConfigLoader, ConfigLocation}; +pub use config::{ConfigDocument, ConfigLoader, ConfigLocation}; pub use engine_container::EngineContainer; pub use hasher::Hasher; pub use ignore::{IgnoreOperations, IgnoreResult, IgnoreService}; -pub use init::InitHandler; pub use repositories::{MevaRepository, MutableRepositoryLayout, RepositoryLayout}; diff --git a/engine/src/objects/person.rs b/engine/src/objects/person.rs index 25b1d8e..a376f5a 100644 --- a/engine/src/objects/person.rs +++ b/engine/src/objects/person.rs @@ -1,13 +1,14 @@ use bincode::{Decode, Encode}; use regex::Regex; -use std::str::FromStr; +use serde::{Deserialize, Serialize}; +use std::{fmt::Display, str::FromStr}; /// Represents an author or committer identity in the Meva system. /// /// A `Person` combines a display name and an email address. /// It is typically used to record authorship or ownership information /// in commits and other repository metadata. -#[derive(Encode, Decode, Clone, Debug)] +#[derive(Encode, Decode, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct Person { /// The display name of the person, e.g. `"Alice Example"`. pub name: String, @@ -16,6 +17,13 @@ pub struct Person { pub email: String, } +impl Display for Person { + /// Formats the `Person` as `"Name "`. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} <{}>", self.name, self.email) + } +} + /// Parses a `Person` from a string in the format `"Name "`. /// /// The name part may contain spaces, and the email must be a valid address diff --git a/engine/src/plugins.rs b/engine/src/plugins.rs deleted file mode 100644 index cf62a98..0000000 --- a/engine/src/plugins.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod handler; -mod operations; - -pub use handler::PluginsHandler; -pub use operations::*; diff --git a/engine/src/revision_parsing.rs b/engine/src/revision_parsing.rs new file mode 100644 index 0000000..63d131b --- /dev/null +++ b/engine/src/revision_parsing.rs @@ -0,0 +1,6 @@ +mod base_reference; +mod revision; +mod revision_modifier; +mod revision_parse_error; + +pub use revision::Revision; diff --git a/engine/src/revision_parsing/base_reference.rs b/engine/src/revision_parsing/base_reference.rs new file mode 100644 index 0000000..854c930 --- /dev/null +++ b/engine/src/revision_parsing/base_reference.rs @@ -0,0 +1,21 @@ +/// Represents a base reference in revision syntax. +/// +/// A [`BaseReference`] identifies a specific starting point in the repository’s +/// commit graph — such as `HEAD`, a raw commit hash, or a named reference. +/// +/// It is typically used as part of parsing expressions like: +/// - `HEAD~1` +/// - `main^` +/// - `abc1234` +/// +/// These values serve as the *root element* for more complex revision expressions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BaseReference { + /// The symbolic reference `HEAD`, representing the current checked-out commit. + Head, + /// A raw commit hash (e.g. `a1b2c3d4...`). + Hash(String), + /// A named reference, such as a branch, tag, or remote ref + /// (e.g. `refs/heads/main` or `origin/develop`). + Ref(String), +} diff --git a/engine/src/revision_parsing/revision.rs b/engine/src/revision_parsing/revision.rs new file mode 100644 index 0000000..0a834ae --- /dev/null +++ b/engine/src/revision_parsing/revision.rs @@ -0,0 +1,284 @@ +use std::str::FromStr; + +use super::{ + base_reference::BaseReference, revision_modifier::RevisionModifier, + revision_parse_error::RevisionParseError, +}; + +/// Represents a parsed revision expression (e.g. `HEAD^2~3` or `feature/x~5`). +/// +/// A [`Revision`] consists of a **base reference** (`HEAD`, branch name, or hash) +/// and an optional sequence of **modifiers** (such as `^` for parent or `~` for ancestor). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Revision { + /// The base reference for the revision (e.g. `HEAD`, a branch name, or a commit hash). + pub base: BaseReference, + + /// A list of modifiers applied sequentially to the base. + /// + /// For example, in `HEAD^2~3`, the modifiers would be: + /// `[Parent(2), Ancestor(3)]`. + pub modifiers: Vec, +} + +impl Revision { + /// Symbol used to indicate a parent commit (`^`). + const PARENT_SYMBOL: char = '^'; + + /// Symbol used to indicate an ancestor commit (`~`). + const ANCESTOR_SYMBOL: char = '~'; + + /// Literal representing the current commit (`HEAD`). + const HEAD: &'static str = "HEAD"; + + /// Minimum length for a valid commit hash (e.g. `abcd`). + const MIN_HASH_LENGTH: usize = 4; + + /// Maximum length for a valid commit hash (full SHA1 hash). + const MAX_HASH_LENGTH: usize = 40; + + /// Creates a [`Revision`] from `HEAD` with optional modifiers. + pub fn head(modifiers: Vec) -> Self { + Self { + base: BaseReference::Head, + modifiers, + } + } + + /// Creates a [`Revision`] using a raw commit hash as the base. + pub fn hash(hash: &str, modifiers: Vec) -> Self { + Self { + base: BaseReference::Hash(hash.to_string()), + modifiers, + } + } + + /// Creates a [`Revision`] using a named reference (branch, remote, etc.) as the base. + pub fn reference(reference: &str, modifiers: Vec) -> Self { + Self { + base: BaseReference::Ref(reference.to_string()), + modifiers, + } + } + + /// Returns `true` if the given string is a valid hexadecimal hash. + /// + /// Hashes must be between 4 and 40 characters long and consist only of + /// ASCII hexadecimal digits (`0-9a-fA-F`). + fn is_valid_hash(s: &str) -> bool { + let len = s.len(); + (Self::MIN_HASH_LENGTH..=Self::MAX_HASH_LENGTH).contains(&len) + && s.chars().all(|c| c.is_ascii_hexdigit()) + } + + /// Returns `true` if a character is valid in a reference name. + /// + /// Allowed characters: + /// - alphanumeric (`A-Z`, `a-z`, `0-9`) + /// - `/`, `-`, `_` + fn is_valid_ref_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '/' || c == '-' || c == '_' + } +} + +impl FromStr for Revision { + type Err = RevisionParseError; + + /// Parses a revision string (like `HEAD~2^3`) into a [`Revision`] struct. + /// + /// # Errors + /// Returns [`RevisionParseError`] when: + /// - the input is empty or only whitespace, + /// - it starts with `^` or `~` (missing base), + /// - contains invalid characters, + /// - or has invalid numeric modifiers (`^0`, `~0`, or non-numeric sequences). + fn from_str(s: &str) -> Result { + if s.trim().is_empty() { + return Err(RevisionParseError::EmptyInput); + } + if s == Self::HEAD { + return Ok(Self::head(vec![])); + } + + let mut chars = s.chars().peekable(); + let base = if s.starts_with(Self::PARENT_SYMBOL) || s.starts_with(Self::ANCESTOR_SYMBOL) { + return Err(RevisionParseError::InvalidStart); + } else { + let mut base_str = String::new(); + while let Some(&c) = chars.peek() { + if c == Self::PARENT_SYMBOL || c == Self::ANCESTOR_SYMBOL { + break; + } else if !Self::is_valid_ref_char(c) { + return Err(RevisionParseError::UnexpectedChar(c)); + } + + base_str.push(c); + chars.next(); + } + + if base_str.is_empty() { + return Err(RevisionParseError::MissingBaseReference); + } + + if base_str == Self::HEAD { + BaseReference::Head + } else if Self::is_valid_hash(&base_str) { + BaseReference::Hash(base_str) + } else { + BaseReference::Ref(base_str) + } + }; + + let mut modifiers = Vec::new(); + while let Some(&c) = chars.peek() { + match c { + Self::PARENT_SYMBOL | Self::ANCESTOR_SYMBOL => { + chars.next(); + let mut num = String::new(); + while let Some(&nc) = chars.peek() { + if nc.is_ascii_digit() { + num.push(nc); + chars.next(); + } else { + break; + } + } + let count = if num.is_empty() { + 1 + } else { + let num = num + .parse::() + .map_err(|_| RevisionParseError::InvalidNumber)?; + if num == 0 { + return Err(RevisionParseError::InvalidNumber); + } + num + }; + let modifier = if c == Self::PARENT_SYMBOL { + RevisionModifier::Parent(count) + } else { + RevisionModifier::Ancestor(count) + }; + modifiers.push(modifier); + } + other => return Err(RevisionParseError::UnexpectedChar(other)), + } + } + + Ok(Revision { base, modifiers }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + #[case("HEAD", Revision::head(vec![]))] + #[case("HEAD^", Revision::head(vec![RevisionModifier::Parent(1)]))] + #[case("HEAD~", Revision::head(vec![RevisionModifier::Ancestor(1)]))] + #[case("HEAD^2", Revision::head(vec![RevisionModifier::Parent(2)]))] + #[case("HEAD~10", Revision::head(vec![RevisionModifier::Ancestor(10)]))] + #[case("HEAD^2~3", Revision::head(vec![RevisionModifier::Parent(2), RevisionModifier::Ancestor(3)]))] + #[case("HEAD~3^2", Revision::head(vec![RevisionModifier::Ancestor(3), RevisionModifier::Parent(2)]))] + fn parses_head_variants(#[case] input: &str, #[case] expected: Revision) { + let parsed = Revision::from_str(input).expect("should parse"); + assert_eq!(parsed, expected); + } + + #[rstest] + #[case("abcd", Revision::hash("abcd", vec![]))] + #[case("deadBEEF", Revision::hash("deadBEEF", vec![]))] + #[case("a1b2c3d4^", Revision::hash("a1b2c3d4", vec![RevisionModifier::Parent(1)]))] + #[case("0f1e2d3c~5", Revision::hash("0f1e2d3c", vec![RevisionModifier::Ancestor(5)]))] + #[case("abcdef12^2~3", Revision::hash("abcdef12", vec![RevisionModifier::Parent(2), RevisionModifier::Ancestor(3)]))] + fn parses_hash_variants(#[case] input: &str, #[case] expected: Revision) { + let parsed = Revision::from_str(input).expect("should parse"); + assert_eq!(parsed, expected); + } + + #[rstest] + #[case("main", Revision::reference("main", vec![]))] + #[case("feature/x", Revision::reference("feature/x", vec![]))] + #[case("release-1_2", Revision::reference("release-1_2", vec![]))] + #[case("bugfix_123^", Revision::reference("bugfix_123", vec![RevisionModifier::Parent(1)]))] + #[case("hotfix-urgent~7", Revision::reference("hotfix-urgent", vec![RevisionModifier::Ancestor(7)]))] + #[case("topic/alpha^3~2", Revision::reference("topic/alpha", vec![RevisionModifier::Parent(3), RevisionModifier::Ancestor(2)]))] + fn parses_ref_variants(#[case] input: &str, #[case] expected: Revision) { + let parsed = Revision::from_str(input).expect("should parse"); + assert_eq!(parsed, expected); + } + + #[rstest] + #[case("", RevisionParseError::EmptyInput)] + #[case(" ", RevisionParseError::EmptyInput)] + #[case("^", RevisionParseError::InvalidStart)] + #[case("~", RevisionParseError::InvalidStart)] + #[case("^2", RevisionParseError::InvalidStart)] + #[case("~5", RevisionParseError::InvalidStart)] + fn errors_on_empty_input(#[case] input: &str, #[case] expected_err: RevisionParseError) { + let err = Revision::from_str(input).unwrap_err(); + assert_eq!(err, expected_err); + } + + #[rstest] + #[case("ma in", ' ')] + #[case("ref!", '!')] + #[case("weird@", '@')] + #[case("abc^1-2", '-')] + #[case("main^2x", 'x')] + fn errors_on_unexpected_characters(#[case] input: &str, #[case] bad: char) { + let err = Revision::from_str(input).unwrap_err(); + assert_eq!(err, RevisionParseError::UnexpectedChar(bad)); + } + + #[rstest] + #[case("main^0")] + #[case("HEAD~0")] + #[case("abcd^00")] + fn errors_on_zero_modifier_count(#[case] input: &str) { + let result = Revision::from_str(input); + match result { + Err(RevisionParseError::InvalidNumber) => {} + Ok(ok) => panic!("expected InvalidNumber, but parsed: {ok:?}"), + Err(other) => panic!("expected InvalidNumber, got {other:?}"), + } + } + + #[rstest] + #[case("main^x")] + #[case("abcd^1a")] + fn errors_on_invalid_number_after_modifier(#[case] input: &str) { + let err = Revision::from_str(input).unwrap_err(); + assert_eq!( + err, + RevisionParseError::UnexpectedChar(match input.chars().last().unwrap() { + c if c.is_ascii_alphanumeric() => c, + c => c, + }) + ); + } + + #[test] + fn parses_min_hash_length() { + let input = "abcd"; // 4 + let parsed = Revision::from_str(input).expect("should parse"); + assert_eq!(parsed, Revision::hash("abcd", vec![])); + } + + #[test] + fn parses_max_hash_length() { + let input = "a".repeat(40); + let parsed = Revision::from_str(&input).expect("should parse"); + assert_eq!(parsed, Revision::hash(&"a".repeat(40), vec![])); + } + + #[test] + fn accepts_mixed_case_hex_in_hash() { + let input = "aBcDeF12"; + let parsed = Revision::from_str(input).expect("should parse"); + assert_eq!(parsed, Revision::hash("aBcDeF12", vec![])); + } +} diff --git a/engine/src/revision_parsing/revision_modifier.rs b/engine/src/revision_parsing/revision_modifier.rs new file mode 100644 index 0000000..3cf3905 --- /dev/null +++ b/engine/src/revision_parsing/revision_modifier.rs @@ -0,0 +1,16 @@ +/// Represents a modifier applied to a base revision. +/// +/// Modifiers are appended to a base reference (e.g., `HEAD`, a branch name, +/// or a commit hash) to navigate the commit graph. +/// +/// There are two supported modifier types: +/// +/// - **Parent (`^`)** — selects a specific parent commit. +/// - **Ancestor (`~`)** — moves back a number of generations along the first-parent chain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RevisionModifier { + /// Moves to the Nth parent of a commit. + Parent(usize), + /// Moves to the Nth ancestor (first-parent lineage). + Ancestor(usize), +} diff --git a/engine/src/revision_parsing/revision_parse_error.rs b/engine/src/revision_parsing/revision_parse_error.rs new file mode 100644 index 0000000..a51df30 --- /dev/null +++ b/engine/src/revision_parsing/revision_parse_error.rs @@ -0,0 +1,30 @@ +use thiserror::Error; + +/// Represents possible parsing errors that can occur when interpreting +/// a revision expression. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum RevisionParseError { + /// The input string was empty or contained only whitespace. + #[error("Empty revision expression")] + EmptyInput, + + /// The expression began with an invalid modifier symbol (`^` or `~`) + /// without a preceding base reference. + #[error("Invalid starting symbol — cannot begin with '^' or '~'")] + InvalidStart, + + /// The base reference (e.g. `HEAD`, `main`, or a hash) was missing + /// before the modifiers were applied. + #[error("Missing base reference before modifiers")] + MissingBaseReference, + + /// The numeric argument following a modifier was invalid — + /// either not a valid number, or equal to zero. + #[error("Invalid numeric value after modifier")] + InvalidNumber, + + /// The parser encountered an unexpected or unsupported character + /// within the revision string. + #[error("Unexpected character in revision: '{0}'")] + UnexpectedChar(char), +} diff --git a/engine/src/utils.rs b/engine/src/utils.rs deleted file mode 100644 index 755f90e..0000000 --- a/engine/src/utils.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod strip_base; - -pub use strip_base::StripBase; diff --git a/engine/src/utils/strip_base.rs b/engine/src/utils/strip_base.rs deleted file mode 100644 index 34a5824..0000000 --- a/engine/src/utils/strip_base.rs +++ /dev/null @@ -1,84 +0,0 @@ -use crate::errors::{EngineError, EngineResult, PathError}; -use std::path::Path; - -/// A helper trait for stripping a base directory prefix from paths. -/// -/// This is similar to [`Path::strip_prefix`], but returns a custom -/// [`EngineError`] (`PathError::NotInBaseDirectory`) if the given path -/// does not start with the provided base. -/// -/// Useful for ensuring that paths used inside the repository remain -/// relative to its root. -pub trait StripBase { - /// Attempts to strip the given `base` prefix from this path. - /// - /// # Errors - /// - /// Returns [`PathError::NotInBaseDirectory`] if `self` is not located - /// under the provided `base` directory. - fn strip_base<'a>(&'a self, base: &'a Path) -> EngineResult<&'a Path>; -} - -impl StripBase for Path { - fn strip_base<'a>(&'a self, base: &'a Path) -> EngineResult<&'a Path> { - self.strip_prefix(base).map_err(|_| { - EngineError::from(PathError::NotInBaseDirectory { - path: self.to_path_buf(), - path_base: base.to_path_buf(), - }) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::Path; - - #[test] - fn test_strip_base_success() { - let base = Path::new("/home/meva/repo"); - let abs = Path::new("/home/meva/repo/src/lib.rs"); - - let relative = abs.strip_base(base).unwrap(); - assert_eq!(relative, Path::new("src/lib.rs")); - } - - #[test] - fn test_strip_base_failure() { - let base = Path::new("/home/meva/repo"); - let abs = Path::new("/home/other/project"); - - let err = abs.strip_base(base); - assert!(err.is_err()); - } - - #[test] - fn test_strip_base_relative_path() { - let base = Path::new("repo"); - let abs = Path::new("repo/src/main.rs"); - - let relative = abs.strip_base(base).unwrap(); - assert_eq!(relative, Path::new("src/main.rs")); - } - - #[cfg(windows)] - #[test] - fn test_strip_base_windows_absolute() { - let base = Path::new(r"C:\repo"); - let abs = Path::new(r"C:\repo\src\main.rs"); - - let relative = abs.strip_base(base).unwrap(); - assert_eq!(relative, Path::new(r"src\main.rs")); - } - - #[cfg(windows)] - #[test] - fn test_strip_base_windows_unc() { - let base = Path::new(r"\\server\share\repo"); - let abs = Path::new(r"\\server\share\repo\src\main.rs"); - - let relative = abs.strip_base(base).unwrap(); - assert_eq!(relative, Path::new(r"src\main.rs")); - } -} diff --git a/shared/src/extensions.rs b/shared/src/extensions.rs index 46ddc9c..9a126f1 100644 --- a/shared/src/extensions.rs +++ b/shared/src/extensions.rs @@ -5,6 +5,7 @@ pub mod is_within; pub mod open_in_editor; pub mod path_to_string; pub mod remove_windows_prefix; +pub mod strip_base; pub mod upward_search; pub use canonicalize_clean::CanonicalizeClean; @@ -14,4 +15,5 @@ pub use is_within::IsWithin; pub use open_in_editor::OpenInEditor; pub use path_to_string::PathToString; pub use remove_windows_prefix::RemoveWindowsPrefix; +pub use strip_base::StripBase; pub use upward_search::UpwardSearch; diff --git a/shared/src/extensions/path_to_string.rs b/shared/src/extensions/path_to_string.rs index e1523f9..3d31e5f 100644 --- a/shared/src/extensions/path_to_string.rs +++ b/shared/src/extensions/path_to_string.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::Path; /// A helper trait for converting paths into owned UTF-8 strings. /// @@ -15,8 +15,11 @@ pub trait PathToString { fn to_utf8_string(&self) -> String; } -impl PathToString for PathBuf { +impl PathToString for T +where + T: AsRef + ?Sized, +{ fn to_utf8_string(&self) -> String { - self.to_string_lossy().to_string() + self.as_ref().to_string_lossy().into_owned() } } diff --git a/shared/src/extensions/strip_base.rs b/shared/src/extensions/strip_base.rs new file mode 100644 index 0000000..ab2a079 --- /dev/null +++ b/shared/src/extensions/strip_base.rs @@ -0,0 +1,61 @@ +use std::path::Path; + +/// Useful for ensuring that paths used inside the repository remain +/// relative to its root. +pub trait StripBase { + fn strip_base<'a>(&'a self, base: &'a Path) -> &'a Path; +} + +impl StripBase for T +where + T: AsRef + ?Sized, +{ + fn strip_base<'a>(&'a self, base: &'a Path) -> &'a Path { + let path = self.as_ref(); + path.strip_prefix(base).unwrap_or(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn test_strip_base_success() { + let base = Path::new("/home/meva/repo"); + let abs = Path::new("/home/meva/repo/src/lib.rs"); + + let relative = abs.strip_base(base); + assert_eq!(relative, Path::new("src/lib.rs")); + } + + #[test] + fn test_strip_base_relative_path() { + let base = Path::new("repo"); + let abs = Path::new("repo/src/main.rs"); + + let relative = abs.strip_base(base); + assert_eq!(relative, Path::new("src/main.rs")); + } + + #[cfg(windows)] + #[test] + fn test_strip_base_windows_absolute() { + let base = Path::new(r"C:\repo"); + let abs = Path::new(r"C:\repo\src\main.rs"); + + let relative = abs.strip_base(base); + assert_eq!(relative, Path::new(r"src\main.rs")); + } + + #[cfg(windows)] + #[test] + fn test_strip_base_windows_unc() { + let base = Path::new(r"\\server\share\repo"); + let abs = Path::new(r"\\server\share\repo\src\main.rs"); + + let relative = abs.strip_base(base); + assert_eq!(relative, Path::new(r"src\main.rs")); + } +} diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 44bed87..95f0f29 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -4,6 +4,6 @@ pub mod extensions; pub use extensions::{ CanonicalizeClean, CumulativePaths, IsWithin, OpenInEditor, PathToString, RemoveWindowsPrefix, - UpwardSearch, fs, + StripBase, UpwardSearch, fs, }; pub use pretty_field::PrettyField; From 7bc4532bec4b7c0920c05dbe890864a4c8f786cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Thu, 16 Oct 2025 01:25:16 +0200 Subject: [PATCH 11/42] Revision Resolver (#11) --- engine/src/errors.rs | 2 + engine/src/errors/engine_error.rs | 9 +- engine/src/errors/revision_resolver_error.rs | 32 +++++ engine/src/ref_manager.rs | 8 ++ engine/src/ref_manager/meva_ref_manager.rs | 14 ++ .../repositories/meva_repository_layout.rs | 4 + engine/src/repositories/repository_layout.rs | 7 + engine/src/revision_parsing.rs | 2 + .../src/revision_parsing/revision_resolver.rs | 135 ++++++++++++++++++ 9 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 engine/src/errors/revision_resolver_error.rs create mode 100644 engine/src/revision_parsing/revision_resolver.rs diff --git a/engine/src/errors.rs b/engine/src/errors.rs index 22acae3..c6a62cd 100644 --- a/engine/src/errors.rs +++ b/engine/src/errors.rs @@ -6,6 +6,7 @@ pub mod index_error; pub mod init_error; pub mod path_error; pub mod repository_error; +pub mod revision_resolver_error; pub mod tree_error; pub mod unpack_error; @@ -16,6 +17,7 @@ pub use index_error::IndexError; pub use init_error::InitError; pub use path_error::PathError; pub use repository_error::RepositoryError; +pub use revision_resolver_error::RevisionResolverError; pub use tree_error::{NodeError, TreeError}; pub use unpack_error::UnpackError; diff --git a/engine/src/errors/engine_error.rs b/engine/src/errors/engine_error.rs index 21c6ca8..3c1b903 100644 --- a/engine/src/errors/engine_error.rs +++ b/engine/src/errors/engine_error.rs @@ -6,7 +6,7 @@ use thiserror::Error; use crate::errors::{ CommitError, ConfigError, IgnoreError, IndexError, InitError, PathError, RepositoryError, - TreeError, UnpackError, + RevisionResolverError, TreeError, UnpackError, }; /// A convenient result type alias for engine-related operations. @@ -97,6 +97,13 @@ pub enum EngineError { #[error(transparent)] Unpack(#[from] UnpackError), + /// Wraps errors from the [`RevisionResolver`]. + /// + /// This allows propagating revision resolution errors through the engine + /// while keeping the original context and error message. + #[error(transparent)] + RevisionResolver(#[from] RevisionResolverError), + /// A catch-all variant for any unknown or unexpected engine error. /// Accepts a descriptive string message. #[error("Unknown engine error: {0}")] diff --git a/engine/src/errors/revision_resolver_error.rs b/engine/src/errors/revision_resolver_error.rs new file mode 100644 index 0000000..9104b9d --- /dev/null +++ b/engine/src/errors/revision_resolver_error.rs @@ -0,0 +1,32 @@ +use thiserror::Error; + +/// Errors that can occur when resolving a revision in the repository. +/// +/// This enum represents all possible failures that can happen while +/// interpreting a revision string (like `HEAD`, commit hashes, or `HEAD^2`) +/// and resolving it to a concrete commit hash. +#[derive(Error, Debug)] +pub enum RevisionResolverError { + /// The given revision string could not be found in the repository. + /// + /// For example, if the user specifies a branch, tag, or commit hash + /// that does not exist. + #[error("Revision '{0}' not found")] + RevisionNotFound(String), + + /// The repository does not have a HEAD reference. + /// + /// This occurs if the repository is empty or HEAD is not initialized. + #[error("HEAD not found")] + HeadNotFound, + + /// The specified parent index is out of range for the given commit. + /// + /// For example, if a user specifies `HEAD^2` but the commit only has one parent. + /// + /// Fields: + /// - `commit`: the commit hash being inspected + /// - `index`: the parent index that was requested (0-based) + #[error("Parent index {index} is out of range for commit {commit}")] + ParentOutOfRange { commit: String, index: usize }, +} diff --git a/engine/src/ref_manager.rs b/engine/src/ref_manager.rs index 71739f1..f4f23df 100644 --- a/engine/src/ref_manager.rs +++ b/engine/src/ref_manager.rs @@ -22,6 +22,14 @@ pub trait RefManager { /// or commit the repository is currently pointing to. fn read_head(&self) -> EngineResult; + /// Resolves the current head to the commit hash it points to. + /// + /// # Returns + /// - `Ok(Some(hash))` if the head points to a commit. + /// - `Ok(None)` if there is no head (e.g., empty repository). + /// - `Err` if there is an I/O or storage error. + fn resolve_head(&self) -> EngineResult>; + /// Updates the [`Head`] reference with a new value, changing the active branch /// or commit pointer. fn update_head(&self, head: Head) -> EngineResult<()>; diff --git a/engine/src/ref_manager/meva_ref_manager.rs b/engine/src/ref_manager/meva_ref_manager.rs index f936c0c..e082850 100644 --- a/engine/src/ref_manager/meva_ref_manager.rs +++ b/engine/src/ref_manager/meva_ref_manager.rs @@ -51,6 +51,20 @@ impl RefManager for MevaRefManager<'_> { Head::from_json(&content) } + /// Resolves the current `HEAD` reference to the commit hash it points to. + /// + /// - Returns `Ok(Some(hash))` if `HEAD` points to a commit. + /// - Returns `Ok(None)` if `HEAD` is missing or empty. + /// - Returns `Err` if reading `HEAD` or the target reference fails. + fn resolve_head(&self) -> EngineResult> { + let head = self.read_head()?; + let hash = match head.mode { + HeadMode::Direct => Some(head.target), + HeadMode::Symbolic => self.read_ref(&head.target)?.map(|e| e.commit_hash), + }; + Ok(hash) + } + /// Updates the `HEAD` file on disk and ensures the target reference file exists. /// /// If the `HEAD` is symbolic, a new reference file is created if missing. diff --git a/engine/src/repositories/meva_repository_layout.rs b/engine/src/repositories/meva_repository_layout.rs index b1e768a..9cbb6d8 100644 --- a/engine/src/repositories/meva_repository_layout.rs +++ b/engine/src/repositories/meva_repository_layout.rs @@ -80,6 +80,10 @@ impl RepositoryLayout for MevaRepositoryLayout { "heads" } + fn remotes_dir_name(&self) -> &str { + "remotes" + } + fn plugins_dir_name(&self) -> &str { "plugins" } diff --git a/engine/src/repositories/repository_layout.rs b/engine/src/repositories/repository_layout.rs index 114fcfc..9dbda92 100644 --- a/engine/src/repositories/repository_layout.rs +++ b/engine/src/repositories/repository_layout.rs @@ -21,6 +21,9 @@ pub trait RepositoryLayout: Send + Sync { /// Subdirectory for storing heads references and logs. fn heads_dir_name(&self) -> &str; + /// Subdirectory for storing remote-tracking branch references. + fn remotes_dir_name(&self) -> &str; + /// Subdirectory for storing plugins related metadata. fn plugins_dir_name(&self) -> &str; @@ -198,6 +201,10 @@ mod tests { "heads" } + fn remotes_dir_name(&self) -> &str { + "remotes" + } + fn plugins_dir_name(&self) -> &str { "plugins" } diff --git a/engine/src/revision_parsing.rs b/engine/src/revision_parsing.rs index 63d131b..f4cff94 100644 --- a/engine/src/revision_parsing.rs +++ b/engine/src/revision_parsing.rs @@ -2,5 +2,7 @@ mod base_reference; mod revision; mod revision_modifier; mod revision_parse_error; +mod revision_resolver; pub use revision::Revision; +pub use revision_resolver::RevisionResolver; diff --git a/engine/src/revision_parsing/revision_resolver.rs b/engine/src/revision_parsing/revision_resolver.rs new file mode 100644 index 0000000..9d80b53 --- /dev/null +++ b/engine/src/revision_parsing/revision_resolver.rs @@ -0,0 +1,135 @@ +use super::{Revision, base_reference::BaseReference, revision_modifier::RevisionModifier}; +use crate::RepositoryLayout; +use crate::errors::{EngineResult, RevisionResolverError}; +use crate::object_storage::{MevaObjectStorage, ObjectStorage}; +use crate::objects::{MevaCommit, MevaObject}; +use crate::ref_manager::{MevaRefManager, RefManager}; + +/// Resolves revisions (like `HEAD`, commit hashes, branches, or modifiers) into commit hashes. +/// +/// This struct provides methods to interpret a `Revision` and apply +/// modifiers such as `^N` (nth parent) or `~N` (nth ancestor) to compute +/// the final commit hash. +pub struct RevisionResolver<'a> { + repo_layout: &'a dyn RepositoryLayout, + object_storage: MevaObjectStorage<'a>, + ref_manager: MevaRefManager<'a>, +} + +impl<'a> RevisionResolver<'a> { + /// Creates a new `RevisionResolver` for the given repository layout. + /// + /// # Parameters + /// - `repo_layout`: a reference to an object implementing [`RepositoryLayout`]. + pub fn new(repo_layout: &'a dyn RepositoryLayout) -> Self { + Self { + repo_layout, + object_storage: MevaObjectStorage::new(repo_layout), + ref_manager: MevaRefManager::new(repo_layout), + } + } + + /// Resolves a revision to its commit hash. + /// + /// # Parameters + /// - `revision`: The revision to resolve. + /// + /// # Returns + /// - `Ok(String)` containing the resolved commit hash. + /// - `Err(RevisionResolverError)` if the revision cannot be resolved, + /// for example if a ref or `HEAD` does not exist, or a parent index is out of range. + pub fn resolve_hash(&self, revision: &Revision) -> EngineResult { + let base_hash = self.resolve_base(&revision.base)?; + self.resolve_modifiers(&base_hash, &revision.modifiers) + } + + /// Resolves a revision to the corresponding repository object (`MevaObject`). + /// + /// # Parameters + /// - `revision`: The revision to resolve. + /// + /// # Returns + /// - `Ok(MevaObject)` containing the object the revision points to. + /// - `Err(RevisionResolverError)` if the revision cannot be resolved + /// or if retrieving the object fails. + pub fn resolve_object(&self, revision: &Revision) -> EngineResult { + let hash = self.resolve_hash(revision)?; + self.object_storage.get_object(&hash) + } + + /// Applies modifiers (`Parent`, `Ancestor`) to a base commit hash. + /// + /// This is used internally by `resolve_hash` to traverse the commit graph. + fn resolve_modifiers( + &self, + hash: &str, + modifiers: &[RevisionModifier], + ) -> EngineResult { + let mut hash = hash.to_owned(); + + for modifier in modifiers { + match modifier { + RevisionModifier::Parent(val) => { + let object = self.object_storage.get_object(&hash)?; + let commit = MevaCommit::try_from(object)?; + hash = commit.parents.get(val - 1).cloned().ok_or( + RevisionResolverError::ParentOutOfRange { + commit: hash, + index: val - 1, + }, + )?; + } + RevisionModifier::Ancestor(val) => { + for _ in 0..*val as u32 { + let object = self.object_storage.get_object(&hash)?; + let commit = MevaCommit::try_from(object)?; + hash = commit.parents.first().cloned().ok_or( + RevisionResolverError::ParentOutOfRange { + commit: hash, + index: 0, + }, + )?; + } + } + } + } + + Ok(hash) + } + + /// Resolves the base reference of a revision to a commit hash. + /// + /// Handles `BaseReference::Hash`, `BaseReference::Head`, and `BaseReference::Ref`. + fn resolve_base(&self, base: &BaseReference) -> EngineResult { + let res: String = match base { + BaseReference::Hash(val) => val.clone(), + BaseReference::Head => { + let hash = self.ref_manager.resolve_head()?; + hash.ok_or(RevisionResolverError::HeadNotFound)? + } + BaseReference::Ref(ref_val) => { + let refs = vec![ + self.ref_manager.read_ref(ref_val), + self.ref_manager.read_ref(&format!( + "{}/{}/{ref_val}", + self.repo_layout.refs_dir_name(), + self.repo_layout.heads_dir_name(), + )), + self.ref_manager.read_ref(&format!( + "{}/{}/{ref_val}", + self.repo_layout.refs_dir_name(), + self.repo_layout.remotes_dir_name() + )), + ]; + + let entry = refs + .into_iter() + .find_map(|r| r.ok().flatten()) + .ok_or_else(|| RevisionResolverError::RevisionNotFound(ref_val.to_string()))?; + + entry.commit_hash + } + }; + Ok(res) + } +} From 185905202c11d59a13981d8994e4134f2068f29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Fri, 17 Oct 2025 23:40:11 +0200 Subject: [PATCH 12/42] Feature/command ls tree (#12) --- cli/src/commands.rs | 2 + cli/src/commands/ls_tree.rs | 168 ++++++++++++++++++ cli/src/main.rs | 3 +- .../src/commit_builder/meva_commit_builder.rs | 44 +++-- engine/src/engine_container.rs | 10 ++ engine/src/errors/index_error.rs | 2 +- engine/src/handlers.rs | 1 + engine/src/handlers/commit/handlers.rs | 2 +- engine/src/handlers/ls_tree.rs | 6 + engine/src/handlers/ls_tree/handlers.rs | 160 +++++++++++++++++ engine/src/handlers/ls_tree/ls_tree_entry.rs | 86 +++++++++ engine/src/handlers/ls_tree/operations.rs | 42 +++++ engine/src/index.rs | 2 + engine/src/index/index_entry.rs | 9 +- engine/src/objects.rs | 3 +- engine/src/objects/meva_blob.rs | 5 + engine/src/objects/tree_entry.rs | 11 +- engine/src/objects/tree_entry_type.rs | 64 +++++-- 18 files changed, 579 insertions(+), 41 deletions(-) create mode 100644 cli/src/commands/ls_tree.rs create mode 100644 engine/src/handlers/ls_tree.rs create mode 100644 engine/src/handlers/ls_tree/handlers.rs create mode 100644 engine/src/handlers/ls_tree/ls_tree_entry.rs create mode 100644 engine/src/handlers/ls_tree/operations.rs diff --git a/cli/src/commands.rs b/cli/src/commands.rs index e8d8cb9..828e853 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -5,6 +5,7 @@ pub mod diff; pub mod ignore; pub mod init; pub mod ls_files; +pub mod ls_tree; pub mod meva_command; pub mod plugins; pub mod show; @@ -17,6 +18,7 @@ pub use diff::DiffCommand; pub use ignore::IgnoreCommand; pub use init::InitCommand; pub use ls_files::LsFilesCommand; +pub use ls_tree::LsTreeCommand; pub use plugins::PluginsCommand; pub use show::ShowCommand; pub use status::StatusCommand; diff --git a/cli/src/commands/ls_tree.rs b/cli/src/commands/ls_tree.rs new file mode 100644 index 0000000..9d1a112 --- /dev/null +++ b/cli/src/commands/ls_tree.rs @@ -0,0 +1,168 @@ +use crate::commands::MevaCommand; +use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; +use engine::EngineContainer; +use engine::engine_container::MevaContainer; +use engine::handlers::ls_tree::Request; +use engine::revision_parsing::Revision; +use miette::{IntoDiagnostic, Result}; +use std::path::PathBuf; + +/// Implements the `ls-tree` command for Meva DVCS. +/// +/// Lists the contents of a tree object or snapshot (commit) in the repository. +/// This command lists the contents of a given tree object. +/// +/// Supports recursive listing, filtering by path, and multiple display modes: +/// - **default view** – shows object type, mode, and SHA-1 hash, +/// - **long view (`-l`)** – includes file size for blob entries, +/// - **name-only view (`--name-only`)** – prints only filenames and directories. +/// +/// Can be used to inspect historical snapshots, branches, or arbitrary commit hashes. +pub struct LsTreeCommand; + +impl LsTreeCommand { + /// Creates a new instance of the `LsTreeCommand`. + pub fn new() -> Self { + Self {} + } + + /// Argument key for specifying the commit, tag, or branch to inspect. + /// Corresponds to the `` argument (e.g., `HEAD`, branch name, or hash). + const ARG_SNAPSHOT_ID: &'static str = "snapshot_id"; + + /// Argument key for specifying optional path filters. + /// Allows restricting the listing to specific files or directories. + const ARG_PATHS: &'static str = "paths"; + + /// Flag key for listing entries recursively. + /// Corresponds to the `-r, --recursive` flag. + const ARG_RECURSIVE: &'static str = "recursive"; + + /// Flag key for including tree (directory) entries in the output. + /// Corresponds to the `-t` flag. + const ARG_TREE: &'static str = "tree"; + + /// Flag key for displaying additional information such as file size. + /// Corresponds to the `-l` flag. + const ARG_LONG: &'static str = "long"; + + /// Flag key for listing only names (without metadata). + /// Corresponds to the `--name-only` flag. + const ARG_NAME_ONLY: &'static str = "name-only"; +} + +impl MevaCommand for LsTreeCommand { + fn name(&self) -> &'static str { + "ls-tree" + } + + fn about(&self) -> &'static str { + "List the contents of a tree object or snapshot (commit) in the repository" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `ls-tree` command using `clap`. + /// + /// Adds the following options and flags: + /// - `-r, --recursive`: Recursively list all tree entries. + /// - `-t`: Include tree (directory) entries in the output. + /// - `-l`: Show detailed output including file sizes (for blobs). + /// - `--name-only`: Display only names of files and directories (no metadata). + /// - ``: Required identifier of the commit, branch, or tag. + /// - `[PATHS...]`: Optional file or directory paths to narrow the scope. + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_RECURSIVE) + .short('r') + .long(Self::ARG_RECURSIVE) + .action(ArgAction::SetTrue) + .help("List tree entries recursively, including subdirectories"), + ) + .arg( + Arg::new(Self::ARG_TREE) + .short('t') + .action(ArgAction::SetTrue) + .help("Show tree (directory) entries as well. Without this flag, only file (blob) entries are shown"), + ) + .arg( + Arg::new(Self::ARG_LONG) + .short('l') + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_NAME_ONLY) + .help("Show file sizes (in bytes) for blob entries. Cannot be used together with --name-only"), + ) + .arg( + Arg::new(Self::ARG_NAME_ONLY) + .long(Self::ARG_NAME_ONLY) + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_LONG) + .help("List only filenames (and directory names), without type, mode, or object ID"), + ) + .arg( + Arg::new(Self::ARG_SNAPSHOT_ID) + .value_name("SNAPSHOT-ID") + .value_parser(value_parser!(Revision)) + .required(true) + .help("Snapshot identifier (e.g. HEAD, commit hash, tag, or branch)"), + ) + .arg( + Arg::new(Self::ARG_PATHS) + .value_name("PATHS") + .num_args(0..) + .last(true) + .help("Optional path to a directory or file within the tree"), + ) + } + + /// Executes the `ls-tree` command. + /// + /// Reads the provided CLI arguments (revision, paths, flags) and performs + /// a repository tree listing. Depending on the options, it may: + /// + /// * List the entire tree (`--recursive`), + /// * Include directories (`-t`), + /// * Show file sizes (`-l`), + /// * Or display only file and directory names (`--name-only`). + /// + /// # Returns + /// + /// * [`Result`]: Indicates success or a diagnostic error + /// if the tree resolution or listing fails. + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + let recursive = matches.get_flag(Self::ARG_RECURSIVE); + let tree = matches.get_flag(Self::ARG_TREE); + let long = matches.get_flag(Self::ARG_LONG); + let name_only = matches.get_flag(Self::ARG_NAME_ONLY); + let revision = matches + .get_one::(Self::ARG_SNAPSHOT_ID) + .cloned() + .unwrap(); + let paths = match matches.get_many::(Self::ARG_PATHS) { + Some(vals) => vals.map(PathBuf::from).collect(), + None => Vec::new(), + }; + + let request = Request { + recursive, + tree, + long, + name_only, + revision, + paths, + }; + + let handler = container.ls_tree_handler().into_diagnostic()?; + + let response = handler.handle_ls_tree(request).into_diagnostic()?; + + for entry in response.display_entries { + println!("{entry}"); + } + + Ok(()) + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 0fcd89f..c46cff6 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -5,7 +5,7 @@ mod meva_cli; use crate::{commands::MevaCommand, meva_cli::MevaCli}; use commands::{ AddCommand, CommitCommand, ConfigCommand, DiffCommand, IgnoreCommand, InitCommand, - LsFilesCommand, PluginsCommand, ShowCommand, StatusCommand, + LsFilesCommand, LsTreeCommand, PluginsCommand, ShowCommand, StatusCommand, }; use engine::engine_container::MevaContainer; use miette::Result; @@ -24,6 +24,7 @@ fn main() -> Result<()> { Box::new(StatusCommand::new()), Box::new(CommitCommand::new()), Box::new(DiffCommand::new()), + Box::new(LsTreeCommand::new()), ]; let container = MevaContainer {}; diff --git a/engine/src/commit_builder/meva_commit_builder.rs b/engine/src/commit_builder/meva_commit_builder.rs index 5c032f9..e1c29c8 100644 --- a/engine/src/commit_builder/meva_commit_builder.rs +++ b/engine/src/commit_builder/meva_commit_builder.rs @@ -2,6 +2,7 @@ use crate::ObjectStorage; use crate::RepositoryLayout; use crate::errors::{EngineError, EngineResult, NodeError, PathError, TreeError}; use crate::index::MevaIndex; +use crate::index::file_mode::FileMode; use crate::objects::{MevaCommit, MevaTree, Person, TreeEntry}; use chrono::Utc; use shared::StripBase; @@ -29,6 +30,16 @@ pub struct MevaCommitBuilder<'a> { object_storage: &'a dyn ObjectStorage, } +/// Helper structure representing a single file entry in a commit tree. +/// +/// Stores the blob’s SHA-1 hash and its file mode. +/// Used internally by [`MevaCommitBuilder`] when assembling tree objects. +#[derive(Debug, Clone, Eq, PartialEq)] +struct TreeValue { + pub hash: String, + pub mode: FileMode, +} + impl<'a> MevaCommitBuilder<'a> { /// Creates a new [`MevaCommitBuilder`] instance. /// @@ -83,7 +94,7 @@ impl<'a> MevaCommitBuilder<'a> { /// Returns the hash of the root tree object. fn store_tree_objects( &self, - tree: Tree, + tree: Tree, verbose: bool, ) -> EngineResult { let root_node_id = tree @@ -100,7 +111,7 @@ impl<'a> MevaCommitBuilder<'a> { /// Returns the SHA-1 hash of the stored tree. fn store_tree_recursively( &self, - tree: &Tree, + tree: &Tree, root_id: String, verbose: bool, ) -> EngineResult { @@ -124,12 +135,11 @@ impl<'a> MevaCommitBuilder<'a> { let name = Path::new(&node_id) .file_name() .ok_or(PathError::EmptyPath)? - .to_string_lossy() - .to_string(); - if let Some(hash) = node.get_value().map_err(|e| TreeError::OperationFailed { + .to_utf8_string(); + if let Some(tree_value) = node.get_value().map_err(|e| TreeError::OperationFailed { message: e.to_string(), })? { - tree_entries.push(TreeEntry::blob(name, hash)); + tree_entries.push(TreeEntry::blob(name, tree_value.mode, tree_value.hash)); } else { let hash = self.store_tree_recursively(tree, node_id, verbose)?; tree_entries.push(TreeEntry::tree(name, hash)); @@ -155,12 +165,12 @@ impl<'a> MevaCommitBuilder<'a> { /// /// Returns an [`EngineResult`] if index loading, path resolution, /// or tree construction fails. - fn build_tree_from_index(&self) -> EngineResult> { + fn build_tree_from_index(&self) -> EngineResult> { let index = MevaIndex::from_disk(self.repo_layout)?; let entries = index.get_entries(); let meva_repository_dir = self.repo_layout.working_dir().canonicalize_clean()?; - let mut tree: Tree = Tree::new(None); + let mut tree: Tree = Tree::new(None); let root_key = meva_repository_dir.to_utf8_string(); tree.add_node(Node::new(root_key.clone(), None), None) .map_err(|e| TreeError::OperationFailed { @@ -178,11 +188,14 @@ impl<'a> MevaCommitBuilder<'a> { .ok_or(EngineError::EmptyCollection)? .to_utf8_string(); if tree.get_node_by_id(&first_key).is_none() { - let val = match components.len() { - 1 => Some(entry.sha1.clone()), + let node_val = match components.len() { + 1 => Some(TreeValue { + hash: entry.sha1.clone(), + mode: entry.mode, + }), _ => None, }; - let node = Node::new(first_key, val); + let node = Node::new(first_key, node_val); tree.add_node(node, Some(&root_key)) .map_err(|e| TreeError::OperationFailed { message: e.to_string(), @@ -197,13 +210,16 @@ impl<'a> MevaCommitBuilder<'a> { continue; } - let hash = if key == components.last().unwrap().to_utf8_string() { - Some(entry.sha1.clone()) + let node_val = if key == components.last().unwrap().to_utf8_string() { + Some(TreeValue { + hash: entry.sha1.clone(), + mode: entry.mode, + }) } else { None }; - let node = Node::new(key, hash); + let node = Node::new(key, node_val); tree.add_node(node, Some(&parent_key)) .map_err(|e| TreeError::OperationFailed { message: e.to_string(), diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index a62cab2..5a5f5d3 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -7,6 +7,7 @@ use crate::handlers::{ ls_files::LsFilesHandler, plugins::PluginsHandler, show::ShowHandler, status::StatusHandler, }; +use crate::handlers::ls_tree::LsTreeHandler; use crate::{ errors::EngineResult, plugins_interceptor::PluginsInterceptor, repositories::meva_repository_layout::MevaRepositoryLayout, @@ -35,6 +36,9 @@ pub trait EngineContainer { /// Returns the handler responsible for ls-files command fn ls_files_handler(&self) -> EngineResult; + /// Returns the handler responsible for ls-tree command + fn ls_tree_handler(&self) -> EngineResult; + /// Returns the handler responsible for show command fn show_handler(&self) -> EngineResult; @@ -102,6 +106,12 @@ impl EngineContainer for MevaContainer { Ok(LsFilesHandler) } + fn ls_tree_handler(&self) -> EngineResult { + Ok(LsTreeHandler::new(Box::new( + MevaRepositoryLayout::discover()?, + ))) + } + fn show_handler(&self) -> EngineResult { Ok(ShowHandler) } diff --git a/engine/src/errors/index_error.rs b/engine/src/errors/index_error.rs index e0cc2d6..4ea0142 100644 --- a/engine/src/errors/index_error.rs +++ b/engine/src/errors/index_error.rs @@ -4,7 +4,7 @@ use thiserror::Error; /// Represents errors that can occur when working with the index file. /// /// The index file is a core component of the repository that tracks the -/// current state of files (similar to Git’s index). +/// current state of files. /// Errors here typically indicate that the index cannot be found, /// accessed, or is otherwise missing. #[derive(Error, Debug)] diff --git a/engine/src/handlers.rs b/engine/src/handlers.rs index 05e6e9e..14d25a9 100644 --- a/engine/src/handlers.rs +++ b/engine/src/handlers.rs @@ -3,6 +3,7 @@ pub mod commit; pub mod config; pub mod init; pub mod ls_files; +pub mod ls_tree; pub mod plugins; pub mod show; pub mod status; diff --git a/engine/src/handlers/commit/handlers.rs b/engine/src/handlers/commit/handlers.rs index 0f6999d..0350da3 100644 --- a/engine/src/handlers/commit/handlers.rs +++ b/engine/src/handlers/commit/handlers.rs @@ -48,7 +48,7 @@ impl CommitHandler { }) } - /// Retrieves the configured Git-style username from the repository settings. + /// Retrieves the configured username from the repository settings. fn get_username(&self) -> EngineResult { self.config_loader .get_parsed("user.name", String::default()) diff --git a/engine/src/handlers/ls_tree.rs b/engine/src/handlers/ls_tree.rs new file mode 100644 index 0000000..e55b3a5 --- /dev/null +++ b/engine/src/handlers/ls_tree.rs @@ -0,0 +1,6 @@ +mod handlers; +mod ls_tree_entry; +mod operations; + +pub use handlers::LsTreeHandler; +pub use operations::*; diff --git a/engine/src/handlers/ls_tree/handlers.rs b/engine/src/handlers/ls_tree/handlers.rs new file mode 100644 index 0000000..6b9c3df --- /dev/null +++ b/engine/src/handlers/ls_tree/handlers.rs @@ -0,0 +1,160 @@ +use super::{LsTreeOperations, Request, Response}; +use crate::RepositoryLayout; +use crate::errors::EngineResult; +use crate::handlers::ls_tree::ls_tree_entry::{DisplayMode, LsTreeDisplayEntry, LsTreeEntry}; +use crate::object_storage::{MevaObjectStorage, ObjectStorage}; +use crate::objects::tree_entry_type::TreeEntryType; +use crate::objects::{MevaBlob, MevaCommit, MevaTree, TreeEntry}; +use crate::revision_parsing::RevisionResolver; +use std::path::PathBuf; + +/// Handles the `ls-tree` command logic. +/// +/// The [`LsTreeHandler`] performs resolution of revisions (e.g. `HEAD` → commit hash), +/// fetches commit and tree objects from storage, and formats them for output. +/// It supports recursive listing, filtering by path, and different display modes. +pub struct LsTreeHandler { + /// Repository layout providing access to `.meva` directory and object storage. + repo_layout: Box, +} + +impl LsTreeHandler { + /// Creates a new [`LsTreeHandler`] with the given repository layout. + pub fn new(repo_layout: Box) -> LsTreeHandler { + LsTreeHandler { repo_layout } + } + + /// Dispatches the `ls-tree` operation based on the provided [`Request`]. + /// + /// Delegates the logic to [`LsTreeOperations::ls_tree`]. + pub fn handle_ls_tree(&self, request: Request) -> EngineResult { + self.ls_tree(request) + } + + /// Lists all entries (files and optionally directories) from a specific commit. + /// + /// Resolves the commit tree from the commit hash, then lists its contents. + fn list_commit( + &self, + commit_hash: String, + include_trees: bool, + recursive: bool, + ) -> EngineResult> { + let object_storage = MevaObjectStorage::new(self.repo_layout.as_ref()); + + let commit_object = object_storage.get_object(&commit_hash)?; + let commit = MevaCommit::try_from(commit_object)?; + + Self::list_tree_recursive( + PathBuf::new(), + commit.tree, + &object_storage, + include_trees, + recursive, + ) + } + + /// Recursively traverses and lists the contents of a tree object. + /// + /// Returns all matching [`LsTreeEntry`] objects, optionally including nested + /// directories and tree entries. + /// + /// # Errors + /// Returns an [`EngineError`] if tree or blob deserialization fails, + /// or if an object referenced by the tree cannot be found. + fn list_tree_recursive( + path: PathBuf, + tree_hash: String, + object_storage: &MevaObjectStorage, + include_trees: bool, + recursive: bool, + ) -> EngineResult> { + let mut listed_entries: Vec = Vec::new(); + let mut dirs_to_list: Vec = Vec::new(); + let tree_object = object_storage.get_object(&tree_hash)?; + let tree = MevaTree::try_from(tree_object)?; + for entry in tree.tree_entries { + match entry.entry_type { + TreeEntryType::Tree => { + if include_trees { + let ls_tree_entry = LsTreeEntry { + entry_type: entry.entry_type.clone(), + hash: entry.object_hash.clone(), + size: None, + path: path.join(entry.name.clone()), + }; + listed_entries.push(ls_tree_entry); + } + dirs_to_list.push(entry); + } + _ => { + let blob_object = object_storage.get_object(&entry.object_hash)?; + let blob = MevaBlob::try_from(blob_object)?; + let ls_tree_entry = LsTreeEntry { + entry_type: entry.entry_type, + hash: entry.object_hash, + size: Some(blob.size()), + path: path.join(entry.name), + }; + listed_entries.push(ls_tree_entry); + } + } + } + if recursive { + for entry in dirs_to_list { + let listed = Self::list_tree_recursive( + path.join(entry.name), + entry.object_hash, + object_storage, + include_trees, + recursive, + )?; + listed_entries.extend(listed); + } + } + + Ok(listed_entries) + } +} + +impl LsTreeOperations for LsTreeHandler { + fn ls_tree(&self, request: Request) -> EngineResult { + let revision_resolver = RevisionResolver::new(self.repo_layout.as_ref()); + + let commit_hash = revision_resolver.resolve_hash(&request.revision)?; + let include_trees = request.tree || !request.recursive; + let recursive = request.recursive; + + let mut listed_entries = self.list_commit(commit_hash, include_trees, recursive)?; + + if !request.paths.is_empty() { + listed_entries.retain(|e| { + for path in &request.paths { + if e.path.starts_with(path) { + return true; + } + } + false + }); + } + + let display_mode = if request.name_only { + DisplayMode::NameOnly + } else if request.long { + DisplayMode::Long + } else { + DisplayMode::Normal + }; + + let display_entries = listed_entries + .into_iter() + .map(|ls_entry| LsTreeDisplayEntry { + display_mode: display_mode.clone(), + ls_tree_entry: ls_entry, + }); + + Ok(Response { + display_entries: display_entries.collect(), + }) + } +} diff --git a/engine/src/handlers/ls_tree/ls_tree_entry.rs b/engine/src/handlers/ls_tree/ls_tree_entry.rs new file mode 100644 index 0000000..90e800a --- /dev/null +++ b/engine/src/handlers/ls_tree/ls_tree_entry.rs @@ -0,0 +1,86 @@ +use crate::objects::tree_entry_type::TreeEntryType; +use std::fmt::Display; +use std::path::PathBuf; + +/// Represents a single entry displayed by the `ls-tree` command. +/// +/// Each entry corresponds to a file or directory stored in a specific commit tree. +/// It contains metadata necessary for display and inspection of repository contents. +/// +/// This structure is produced by [`LsTreeHandler`](crate::commands::ls_tree::LsTreeHandler) +/// when listing the contents of a tree object. +#[derive(Clone)] +pub struct LsTreeEntry { + /// The type of the entry (e.g., blob, tree). + pub entry_type: TreeEntryType, + + /// The object hash (SHA-1) of the underlying meva object. + pub hash: String, + + /// The size of the object in bytes (only for blobs). + pub size: Option, + + /// The path of the entry relative to the repository root. + pub path: PathBuf, +} + +/// Defines how entries should be displayed in the `ls-tree` command output. +/// +/// This determines the level of detail presented to the user. +#[derive(Clone)] +pub enum DisplayMode { + /// Shows only the entry paths. + NameOnly, + + /// Shows entry type, hash, and path. + Normal, + + /// Shows entry type, hash, size, and path (extended details). + Long, +} + +/// A wrapper for rendering [`LsTreeEntry`] values according to the selected [`DisplayMode`]. +/// +/// This type implements [`Display`] to produce human-readable output +pub struct LsTreeDisplayEntry { + /// The display mode controlling how the entry should be rendered. + pub display_mode: DisplayMode, + + /// The actual entry being displayed. + pub ls_tree_entry: LsTreeEntry, +} + +impl Display for LsTreeDisplayEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let entry = &self.ls_tree_entry; + + match self.display_mode { + DisplayMode::NameOnly => { + write!(f, "{}", entry.path.display()) + } + DisplayMode::Normal => { + write!( + f, + "{} {} {}", + entry.entry_type, + entry.hash, + entry.path.display() + ) + } + DisplayMode::Long => { + let size_str = match entry.size { + Some(s) => s.to_string(), + None => "-".to_string(), + }; + write!( + f, + "{} {} {:>8} {}", + entry.entry_type, + entry.hash, + size_str, + entry.path.display(), + ) + } + } + } +} diff --git a/engine/src/handlers/ls_tree/operations.rs b/engine/src/handlers/ls_tree/operations.rs new file mode 100644 index 0000000..a7f3e7b --- /dev/null +++ b/engine/src/handlers/ls_tree/operations.rs @@ -0,0 +1,42 @@ +use crate::errors::EngineResult; +use crate::handlers::ls_tree::ls_tree_entry::LsTreeDisplayEntry; +use crate::revision_parsing::Revision; +use std::path::PathBuf; + +/// Request object for the `ls-tree` operation. +/// +/// Defines how tree contents should be displayed, including recursion depth, +/// filtering options, and formatting style. +pub struct Request { + /// Whether to list entries recursively (include subtrees). + pub recursive: bool, + + /// Whether to include directory (tree) entries in the output. + pub tree: bool, + + /// Whether to display entries in long format (includes file size). + pub long: bool, + + /// Whether to show only filenames, without metadata. + pub name_only: bool, + + /// The revision to inspect (e.g., `HEAD`, branch name, or commit hash). + pub revision: Revision, + + /// Optional path filters within the tree. + pub paths: Vec, +} + +/// Response returned by the `ls-tree` operation. +/// +/// Contains a list of formatted display entries ready for terminal output. +pub struct Response { + /// Entries formatted for display based on the chosen output mode. + pub display_entries: Vec, +} + +/// Defines operations for listing tree or commit contents. +pub trait LsTreeOperations { + /// Lists the contents of a given tree or commit according to the provided [`Request`]. + fn ls_tree(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/index.rs b/engine/src/index.rs index 493f05f..ca70885 100644 --- a/engine/src/index.rs +++ b/engine/src/index.rs @@ -6,5 +6,7 @@ pub mod stage; use index_entry::IndexEntry; use serde_utils::deserialize_entries_with_absolute_keys; +use stage::Stage; +pub use file_mode::FileMode; pub use meva_index::MevaIndex; diff --git a/engine/src/index/index_entry.rs b/engine/src/index/index_entry.rs index a6fc862..f4a90d0 100644 --- a/engine/src/index/index_entry.rs +++ b/engine/src/index/index_entry.rs @@ -1,12 +1,11 @@ use std::fs; use std::path::Path; +use super::{FileMode, Stage}; +use crate::errors::EngineResult; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; - -use crate::errors::EngineResult; -use crate::index::file_mode::FileMode; -use crate::index::stage::Stage; +use shared::PathToString; /// Represents a single entry in the repository index. /// @@ -53,7 +52,7 @@ impl IndexEntry { pub fn from_path_without_hash(file_path: &Path) -> EngineResult { let metadata = fs::metadata(file_path)?; - let path = file_path.to_string_lossy().into_owned(); + let path = file_path.to_utf8_string(); let ctime: DateTime = metadata.created()?.into(); let mtime: DateTime = metadata.modified()?.into(); let mode = FileMode::try_from(file_path)?; diff --git a/engine/src/objects.rs b/engine/src/objects.rs index 8846d7d..b011d05 100644 --- a/engine/src/objects.rs +++ b/engine/src/objects.rs @@ -1,5 +1,3 @@ -mod tree_entry_type; - pub mod meva_blob; pub mod meva_commit; pub mod meva_object; @@ -8,6 +6,7 @@ pub mod meva_object_type; pub mod meva_tree; pub mod person; pub mod tree_entry; +pub mod tree_entry_type; pub use meva_blob::MevaBlob; pub use meva_commit::MevaCommit; diff --git a/engine/src/objects/meva_blob.rs b/engine/src/objects/meva_blob.rs index 2ce31fc..ac3f3ab 100644 --- a/engine/src/objects/meva_blob.rs +++ b/engine/src/objects/meva_blob.rs @@ -38,6 +38,11 @@ impl MevaBlob { Ok(Self::new(buffer)) } + + /// Returns the size of the blob’s data in bytes. + pub fn size(&self) -> usize { + self.data.len() + } } /// Converts a `MevaObject` into a `MevaBlob`. diff --git a/engine/src/objects/tree_entry.rs b/engine/src/objects/tree_entry.rs index 1c02c59..9767090 100644 --- a/engine/src/objects/tree_entry.rs +++ b/engine/src/objects/tree_entry.rs @@ -1,3 +1,4 @@ +use crate::index::file_mode::FileMode; use crate::objects::tree_entry_type::TreeEntryType; use bincode::{Decode, Encode}; use std::fmt::Display; @@ -40,17 +41,13 @@ impl TreeEntry { } /// Creates a [`TreeEntry`] representing a file (`Blob`). - pub fn blob(name: String, object_hash: String) -> Self { - Self::new(name, TreeEntryType::Blob, object_hash) + pub fn blob(name: String, mode: FileMode, object_hash: String) -> Self { + Self::new(name, TreeEntryType::from(mode), object_hash) } } impl Display for TreeEntry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{:?} {}\t{}", - self.entry_type, self.object_hash, self.name - ) + write!(f, "{} {}\t{}", self.entry_type, self.object_hash, self.name) } } diff --git a/engine/src/objects/tree_entry_type.rs b/engine/src/objects/tree_entry_type.rs index b5d54f8..085d357 100644 --- a/engine/src/objects/tree_entry_type.rs +++ b/engine/src/objects/tree_entry_type.rs @@ -1,18 +1,62 @@ +use crate::index::file_mode::FileMode; use bincode::{Decode, Encode}; +use std::fmt::Display; -/// Defines the type of entry inside a [`TreeEntry`](crate::objects::tree_entry::TreeEntry). +/// Represents the type and mode of an entry stored in a [`TreeEntry`](crate::objects::tree_entry::TreeEntry). /// -/// A tree entry can represent either: -/// - a **Tree**: a subdirectory containing other entries, -/// - a **Blob**: a file with associated content. +/// Each entry in a [`MevaTree`](crate::objects::meva_tree::MevaTree) corresponds to either: +/// - a **directory** (`Tree`), which may contain other trees and blobs, or +/// - a **file-like object** (`Blob*`), which stores actual content or metadata. /// -/// This enum is used to distinguish the nature of objects stored in a -/// [`MevaTree`](crate::objects::meva_tree::MevaTree). -#[derive(Encode, Decode, Debug, Eq, PartialEq, Ord, PartialOrd)] +/// This enum mirrors the standard Unix-like file mode representation, +/// making it compatible with serialized repository structures. +/// +/// # Usage +/// The `TreeEntryType` determines how a node is interpreted when reconstructing +/// the working directory or displaying tree contents via commands like `ls-tree`. +#[derive(Encode, Decode, Debug, Eq, PartialEq, Ord, PartialOrd, Clone)] pub enum TreeEntryType { /// A subdirectory, which may contain nested entries. - Tree, + Tree = 0o040000, + + /// A regular file with standard permissions. + BlobNormal = 0o100644, + + /// An executable file. + BlobExecutable = 0o100755, + + /// A symbolic link. + BlobSymlink = 0o120000, + + /// A meva submodule (commit reference to another repository). + CommitGitLink = 0o160000, +} + +impl From for TreeEntryType { + /// Converts a [`FileMode`](crate::objects::file_mode::FileMode) + /// into the corresponding [`TreeEntryType`]. + fn from(mode: FileMode) -> Self { + match mode { + FileMode::Normal => Self::BlobNormal, + FileMode::Executable => Self::BlobExecutable, + FileMode::Symlink => Self::BlobSymlink, + FileMode::GitLink => Self::CommitGitLink, + } + } +} + +impl Display for TreeEntryType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mode = self.clone() as u32; + + let type_name = match self { + TreeEntryType::Tree => "tree", + TreeEntryType::CommitGitLink => "commit", + TreeEntryType::BlobNormal + | TreeEntryType::BlobExecutable + | TreeEntryType::BlobSymlink => "blob", + }; - /// A file containing data. - Blob, + write!(f, "{mode:06o} {type_name:<6}") + } } From 4e0288a5e41651ded13d88ea7ee215fdf9d7169f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:25:47 +0200 Subject: [PATCH 13/42] Command `diff` (#13) --- Cargo.lock | 22 + cli/src/commands/diff.rs | 68 +- cli/src/commands/show.rs | 20 +- cli/src/main.rs | 2 +- engine/Cargo.toml | 2 + .../src/commit_builder/meva_commit_builder.rs | 2 +- engine/src/diff.rs | 17 +- engine/src/diff/diff_builder.rs | 824 ++++++++++++++++++ engine/src/diff/diff_stat.rs | 53 -- engine/src/diff/file_change.rs | 57 -- engine/src/diff/models.rs | 18 + engine/src/diff/{ => models}/change_kind.rs | 0 engine/src/diff/models/diff_mode.rs | 11 + engine/src/diff/models/diff_stat.rs | 103 +++ engine/src/diff/models/file_change.rs | 179 ++++ engine/src/diff/models/file_change_kind.rs | 105 +++ .../src/diff/{ => models}/file_diff_stat.rs | 0 engine/src/diff/{ => models}/hunk.rs | 2 +- engine/src/diff/models/revision_range.rs | 63 ++ engine/src/diff/models/revision_side.rs | 8 + engine/src/engine_container.rs | 5 +- engine/src/errors.rs | 4 +- engine/src/errors/engine_error.rs | 32 +- ...on_resolver_error.rs => revision_error.rs} | 2 +- engine/src/handlers.rs | 1 + engine/src/handlers/add/handlers.rs | 2 +- engine/src/handlers/diff.rs | 5 + engine/src/handlers/diff/handlers.rs | 97 +++ engine/src/handlers/diff/operations.rs | 52 ++ engine/src/handlers/ls_files/handlers.rs | 2 +- engine/src/handlers/ls_tree.rs | 3 +- engine/src/handlers/ls_tree/handlers.rs | 36 +- engine/src/handlers/ls_tree/ls_tree_entry.rs | 86 -- engine/src/handlers/ls_tree/models.rs | 5 + .../handlers/ls_tree/models/display_mode.rs | 14 + .../handlers/ls_tree/models/ls_tree_entry.rs | 51 ++ engine/src/handlers/ls_tree/operations.rs | 6 +- engine/src/index.rs | 4 +- engine/src/index/file_mode.rs | 39 +- engine/src/index/index_entry.rs | 1 + engine/src/index/meva_index.rs | 289 +++--- engine/src/index/serde_utils.rs | 28 + engine/src/index/working_dir.rs | 231 +++++ engine/src/objects.rs | 2 + engine/src/objects/meva_blob.rs | 48 +- engine/src/objects/object_entry.rs | 21 + .../src/revision_parsing/revision_resolver.rs | 14 +- 47 files changed, 2213 insertions(+), 423 deletions(-) create mode 100644 engine/src/diff/diff_builder.rs delete mode 100644 engine/src/diff/diff_stat.rs delete mode 100644 engine/src/diff/file_change.rs create mode 100644 engine/src/diff/models.rs rename engine/src/diff/{ => models}/change_kind.rs (100%) create mode 100644 engine/src/diff/models/diff_mode.rs create mode 100644 engine/src/diff/models/diff_stat.rs create mode 100644 engine/src/diff/models/file_change.rs create mode 100644 engine/src/diff/models/file_change_kind.rs rename engine/src/diff/{ => models}/file_diff_stat.rs (100%) rename engine/src/diff/{ => models}/hunk.rs (98%) create mode 100644 engine/src/diff/models/revision_range.rs create mode 100644 engine/src/diff/models/revision_side.rs rename engine/src/errors/{revision_resolver_error.rs => revision_error.rs} (97%) create mode 100644 engine/src/handlers/diff.rs create mode 100644 engine/src/handlers/diff/handlers.rs create mode 100644 engine/src/handlers/diff/operations.rs delete mode 100644 engine/src/handlers/ls_tree/ls_tree_entry.rs create mode 100644 engine/src/handlers/ls_tree/models.rs create mode 100644 engine/src/handlers/ls_tree/models/display_mode.rs create mode 100644 engine/src/handlers/ls_tree/models/ls_tree_entry.rs create mode 100644 engine/src/index/working_dir.rs create mode 100644 engine/src/objects/object_entry.rs diff --git a/Cargo.lock b/Cargo.lock index ff2690c..75d6f9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "chardetng" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" +dependencies = [ + "cfg-if", + "encoding_rs", + "memchr", +] + [[package]] name = "chrono" version = "0.4.42" @@ -384,13 +395,24 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "engine" version = "0.1.0" dependencies = [ "bincode", + "chardetng", "chrono", "dirs", + "encoding_rs", "flate2", "globset", "hex", diff --git a/cli/src/commands/diff.rs b/cli/src/commands/diff.rs index 760c352..3b89134 100644 --- a/cli/src/commands/diff.rs +++ b/cli/src/commands/diff.rs @@ -1,9 +1,16 @@ use std::path::PathBuf; use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; -use engine::{engine_container::MevaContainer, revision_parsing::Revision}; +use engine::{ + diff::DiffMode, + engine_container::MevaContainer, + handlers::diff::{DiffHandler, Request, Response}, + repositories::meva_repository_layout::MevaRepositoryLayout, + revision_parsing::Revision, +}; -use miette::Result; +use miette::{IntoDiagnostic, Result}; +use shared::PathToString; use crate::commands::MevaCommand; @@ -41,6 +48,33 @@ impl DiffCommand { /// Positional argument name for paths limiting the diff. /// Any number of paths can be provided; use `--` to separate ranges from paths. const ARG_PATHS: &'static str = "paths"; + + /// Displays the diff response based on the selected DiffMode. + /// + /// Provides different output formats depending on the requested comparison type. + fn display_response(&self, mode: &DiffMode, response: &Response) { + match mode { + DiffMode::NameOnly => { + if let Some(files) = &response.files { + for file in files { + println!("{}", file.path().to_utf8_string()); + } + } + } + DiffMode::Stat => { + if let Some(stat) = &response.stat { + println!("{stat}") + } + } + DiffMode::Full => { + if let Some(files) = &response.files { + for file in files { + file.display_full(); + } + } + } + } + } } impl MevaCommand for DiffCommand { @@ -75,6 +109,7 @@ impl MevaCommand for DiffCommand { .short('n') .long(Self::ARG_NAME_ONLY) .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_STAT) .help("Show only names of changed files") ) .arg( @@ -105,6 +140,7 @@ impl MevaCommand for DiffCommand { .value_name("PATHS") .index(3) .num_args(0..) + .value_parser(value_parser!(PathBuf)) .last(true) .help("Limit the diff to the given paths (use -- to separate ranges from paths)") ) @@ -112,17 +148,27 @@ impl MevaCommand for DiffCommand { /// Executes the `diff` command. fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> Result<()> { - let _cached = matches.get_flag(Self::ARG_CACHED); - let _name_only = matches.get_flag(Self::ARG_NAME_ONLY); - let _stat = matches.get_flag(Self::ARG_STAT); + let cached = matches.get_flag(Self::ARG_CACHED); + let from = matches.get_one::(Self::ARG_RANGE1); + let to = matches.get_one::(Self::ARG_RANGE2); + let paths = matches + .get_many::(Self::ARG_PATHS) + .map(|vals| vals.cloned().collect::>()); + + let mode = if matches.get_flag(Self::ARG_NAME_ONLY) { + DiffMode::NameOnly + } else if matches.get_flag(Self::ARG_STAT) { + DiffMode::Stat + } else { + DiffMode::Full + }; - let _range1 = matches.get_one::(Self::ARG_RANGE1); - let _range2 = matches.get_one::(Self::ARG_RANGE2); + let request = Request::new(from.cloned(), to.cloned(), cached, mode, paths); - let _paths = match matches.get_many::(Self::ARG_PATHS) { - Some(vals) => vals.map(PathBuf::from).collect(), - None => Vec::new(), - }; + let layout = MevaRepositoryLayout::discover().into_diagnostic()?; + let handler = DiffHandler::new(&layout).into_diagnostic()?; + let response = handler.handle_diff(request).into_diagnostic()?; + self.display_response(&mode, &response); Ok(()) } diff --git a/cli/src/commands/show.rs b/cli/src/commands/show.rs index 9e4eacb..e00ab72 100644 --- a/cli/src/commands/show.rs +++ b/cli/src/commands/show.rs @@ -68,14 +68,7 @@ impl ShowCommand { println!("{diff}"); } else if let Some(files) = &response.files { for file in files { - println!("{file}"); - if let Some(hunks) = &file.hunks { - for h in hunks { - print!("{h}"); - } - } else if let Some(diff) = &file.unified_diff { - println!("{diff}"); - } + file.display_full(); } } } @@ -85,18 +78,19 @@ impl ShowCommand { if let Some(files) = &response.files { println!(); for file in files { - println!("{}", file.new_path.display()); + println!("{}", file.path().display()); } } } /// Prints filenames with change kinds. fn display_name_status(&self, response: &Response) { - if let Some(files) = &response.files { + if let Some(_files) = &response.files { println!(); - for file in files { - println!("{}\t{}", file.kind, file.new_path.display()); - } + // TODO: fixes in next PR + // for file in files { + // println!("{}\t{}", file.kind, file.new_path.display()); + // } } } diff --git a/cli/src/main.rs b/cli/src/main.rs index c46cff6..42134aa 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -27,7 +27,7 @@ fn main() -> Result<()> { Box::new(LsTreeCommand::new()), ]; - let container = MevaContainer {}; + let container = MevaContainer; let mut cli = MevaCli::new(container); for command in commands { diff --git a/engine/Cargo.toml b/engine/Cargo.toml index ff3bcc8..0f3b924 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -23,6 +23,8 @@ chrono.workspace = true rayon = "1.11.0" flate2 = "1.1.2" bincode = "2.0.1" +encoding_rs = "0.8.35" +chardetng = "0.1.17" tree-ds = "0.2.0" similar.workspace = true diff --git a/engine/src/commit_builder/meva_commit_builder.rs b/engine/src/commit_builder/meva_commit_builder.rs index e1c29c8..2e7a1d0 100644 --- a/engine/src/commit_builder/meva_commit_builder.rs +++ b/engine/src/commit_builder/meva_commit_builder.rs @@ -166,7 +166,7 @@ impl<'a> MevaCommitBuilder<'a> { /// Returns an [`EngineResult`] if index loading, path resolution, /// or tree construction fails. fn build_tree_from_index(&self) -> EngineResult> { - let index = MevaIndex::from_disk(self.repo_layout)?; + let index = MevaIndex::from_disk(self.repo_layout, None)?; let entries = index.get_entries(); let meva_repository_dir = self.repo_layout.working_dir().canonicalize_clean()?; diff --git a/engine/src/diff.rs b/engine/src/diff.rs index 1dec7b1..e865e8e 100644 --- a/engine/src/diff.rs +++ b/engine/src/diff.rs @@ -1,11 +1,8 @@ -mod change_kind; -mod diff_stat; -mod file_change; -mod file_diff_stat; -mod hunk; +mod diff_builder; +mod models; -pub use change_kind::ChangeKind; -pub use diff_stat::DiffStat; -pub use file_change::FileChange; -pub use file_diff_stat::FileDiffStat; -pub use hunk::Hunk; +pub use diff_builder::DiffBuilder; +pub use models::{ + ChangeKind, DiffMode, DiffStat, FileChange, FileChangeKind, Hunk, HunkLine, HunkLineType, + RevisionRange, RevisionSide, +}; diff --git a/engine/src/diff/diff_builder.rs b/engine/src/diff/diff_builder.rs new file mode 100644 index 0000000..eb7a522 --- /dev/null +++ b/engine/src/diff/diff_builder.rs @@ -0,0 +1,824 @@ +use std::{collections::HashMap, path::PathBuf}; + +use rayon::{ + iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator}, + join, +}; +use similar::{ChangeTag, TextDiff}; + +use crate::{ + RepositoryLayout, + errors::EngineResult, + index::{FileMode, MevaIndex, WorkingDir}, + object_storage::{MevaObjectStorage, ObjectStorage}, + objects::{ + MevaBlob, MevaCommit, MevaObject, MevaTree, ObjectEntry, tree_entry_type::TreeEntryType, + }, + revision_parsing::{Revision, RevisionResolver}, +}; + +use super::models::{DiffMode, FileChange, FileChangeKind, Hunk, HunkLine, HunkLineType}; + +/// A helper struct to group arguments required for building a [FileChange] for a modified file. +struct ModifiedFileArgs<'a> { + /// The string content of the file before the change. + old_content: &'a str, + /// The string content of the file after the change. + new_content: &'a str, + /// The original path of the file. + old_path: PathBuf, + /// The new path of the file (may be the same as `old_path` if not renamed). + new_path: PathBuf, + /// The SHA-1 hash of the old file content. + old_hash: Option, + /// The SHA-1 hash of the new file content. + new_hash: Option, + /// The file's new mode (e.g., executable, normal). + file_mode: &'a FileMode, + /// A flag indicating whether to generate detailed diff information (hunks). + collect_details: bool, +} + +/// Builds diffs between different states of the repository (commits, index, working directory). +pub struct DiffBuilder<'a> { + /// The object storage service for accessing repository objects. + object_storage: MevaObjectStorage<'a>, + /// Resolver for parsing revision strings (e.g., "HEAD", "main", SHA hashes) into commit hashes. + revision_resolver: RevisionResolver<'a>, + /// The staging area (index) of the repository. + index: MevaIndex<'a>, + /// Represents the working directory, providing access to its files. + pub working_dir: WorkingDir<'a>, +} + +impl<'a> DiffBuilder<'a> { + /// Creates a new `DiffBuilder` instance for a given repository layout. + /// + /// # Arguments + /// + /// * `repo_layout` - A reference to the repository's layout, providing paths to its components. + pub fn new(repo_layout: &'a dyn RepositoryLayout) -> EngineResult { + Ok(Self { + object_storage: MevaObjectStorage::new(repo_layout), + revision_resolver: RevisionResolver::new(repo_layout), + index: MevaIndex::from_disk(repo_layout, Some(false))?, + working_dir: WorkingDir::with_ignore_services(repo_layout), + }) + } + + /// Computes the difference between two repository snapshots (commits). + /// + /// # Arguments + /// + /// * `from` - The starting revision for the diff. + /// * `to` - The ending revision for the diff. + /// * `mode` - The level of detail for the diff ([`DiffMode`]). + /// * `paths` - An optional slice of paths to limit the diff to. If `None`, all paths are considered. + /// + /// # Returns + /// + /// A [`EngineResult`] containing a vector of [`FileChange`] objects representing the differences. + pub fn diff_snapshots( + &self, + from: &Revision, + to: &Revision, + mode: &DiffMode, + paths: Option<&[PathBuf]>, + ) -> EngineResult> { + let from_commit = self.get_commit(from)?; + let from_objects = self.collect_object_entries(PathBuf::new(), from_commit.tree, paths)?; + + let to_commit = self.get_commit(to)?; + let to_objects = self.collect_object_entries(PathBuf::new(), to_commit.tree, paths)?; + + self.diff_tree_vs_tree(&from_objects, &to_objects, mode) + } + + /// Computes the difference between a repository snapshot and the working directory. + /// + /// # Arguments + /// + /// * `from` - The revision to compare against the working directory. + /// * `mode` - The level of detail for the diff ([`DiffMode`]). + /// * `paths` - An optional slice of paths to limit the diff to. + /// + /// # Returns + /// + /// A [`EngineResult`] containing a vector of [`FileChange`] objects. + pub fn diff_snapshot_working_dir( + &self, + from: &Revision, + mode: &DiffMode, + paths: Option<&[PathBuf]>, + ) -> EngineResult> { + let mut added = Vec::new(); + let mut deleted = Vec::new(); + let mut modified = Vec::new(); + + let from_commit = self.get_commit(from)?; + let from_objects = self.collect_object_entries(PathBuf::new(), from_commit.tree, paths)?; + + let base_path = self.working_dir.get_absolute_path(); + let working = self.working_dir.collect_files_with_metadata(false, paths)?; + + for (path, entry) in &working { + let absolute_path = base_path.join(path); + match from_objects.get(path) { + None => match mode { + DiffMode::NameOnly => added.push(FileChange::default_added(path.to_path_buf())), + _ => { + let blob = MevaBlob::from_file(&absolute_path)?; + match blob.text_content() { + Ok(content) => { + let object = MevaObject::try_from(blob)?; + + let hash = object.sha1(); + + added.push(self.build_file_change_added( + &content, + path.to_path_buf(), + hash.clone(), + &entry.mode, + *mode == DiffMode::Full, + )) + } + Err(_) => added.push(FileChange::default_added(path.to_path_buf())), + } + } + }, + Some(object_entry) => match mode { + DiffMode::NameOnly => modified.push(FileChange::default_modified( + object_entry.path.clone(), + path.to_path_buf(), + )), + _ => { + let blob = MevaBlob::from_file(&absolute_path)?; + match blob.text_content() { + Ok(new_content) => { + let object = MevaObject::try_from(blob)?; + + let hash = object.sha1(); + + if *hash == object_entry.hash { + continue; + } + + match self.get_text_content(&object_entry.hash) { + Ok(old_content) => { + let args = ModifiedFileArgs { + old_content: &old_content, + new_content: &new_content, + old_path: object_entry.path.clone(), + new_path: path.to_path_buf(), + old_hash: Some(object_entry.hash.clone()), + new_hash: Some(hash.clone()), + file_mode: &entry.mode, + collect_details: *mode == DiffMode::Full, + }; + modified.push(self.build_file_change_modified(args)); + } + Err(_) => modified.push(FileChange::default_modified( + object_entry.path.clone(), + path.to_path_buf(), + )), + } + } + Err(_) => modified.push(FileChange::default_modified( + object_entry.path.clone(), + path.to_path_buf(), + )), + }; + } + }, + } + } + + for (path, entry) in &from_objects { + if !working.contains_key(path) { + match mode { + DiffMode::NameOnly => deleted.push(FileChange { + kind: FileChangeKind::default_deleted(path.into()), + hunks: None, + unified_diff: None, + }), + _ => match self.get_text_content(&entry.hash) { + Ok(content) => deleted.push(self.build_file_change_deleted( + &content, + path.into(), + entry.hash.clone(), + &FileMode::try_from(&entry.entry_type)?, + *mode == DiffMode::Full, + )), + Err(_) => deleted.push(FileChange::default_deleted(path.into())), + }, + } + } + } + + Ok(self.merge_and_sort(added, deleted, modified)) + } + + /// Computes the difference between a repository snapshot and the index (staging area). + /// + /// # Arguments + /// + /// * `from` - The revision to compare against the index. + /// * `mode` - The level of detail for the diff ([`DiffMode`]). + /// * `paths` - An optional slice of paths to limit the diff to. + /// + /// # Returns + /// + /// A [`EngineResult`] containing a vector of [`FileChange`] objects. + pub fn diff_snapshot_index( + &self, + from: &Revision, + mode: &DiffMode, + paths: Option<&[PathBuf]>, + ) -> EngineResult> { + let from_commit = self.get_commit(from)?; + let from_objects = self.collect_object_entries(PathBuf::new(), from_commit.tree, paths)?; + + let to_objects = self.collect_object_entries_from_index(paths); + + self.diff_tree_vs_tree(&from_objects, &to_objects, mode) + } + + /// Computes the difference between the index (staging area) and the working directory. + /// + /// # Arguments + /// + /// * `mode` - The level of detail for the diff ([`DiffMode`]). + /// * `paths` - An optional slice of paths to limit the diff to. + /// + /// # Returns + /// + /// A [`EngineResult`] containing a vector of [`FileChange`] objects. + pub fn diff_index_working_dir( + &self, + mode: &DiffMode, + paths: Option<&[PathBuf]>, + ) -> EngineResult> { + let mut added = Vec::new(); + let mut deleted = Vec::new(); + let mut modified = Vec::new(); + + let base_path = self.working_dir.get_absolute_path(); + let index_map = self.index.get_entries_map(paths); + + let working = self.working_dir.collect_files_with_metadata(false, paths)?; + + for (path, entry) in &working { + let absolute_path = base_path.join(path); + match index_map.get(path) { + None => match mode { + DiffMode::NameOnly => { + added.push(FileChange::default_added(path.to_path_buf())); + } + _ => { + let blob = MevaBlob::from_file(&absolute_path)?; + match blob.text_content() { + Ok(new_content) => { + let object = MevaObject::try_from(blob)?; + + let hash = object.sha1(); + + added.push(self.build_file_change_added( + &new_content, + path.to_path_buf(), + hash.clone(), + &entry.mode, + matches!(mode, DiffMode::Full), + )) + } + Err(_) => added.push(FileChange::default_added(path.to_path_buf())), + } + } + }, + Some(index_entry) => match mode { + DiffMode::NameOnly => { + modified.push(FileChange::default_modified( + index_entry.path.clone().into(), + path.to_path_buf(), + )); + } + _ => { + if FileChange::is_modified_heuristic( + entry.file_size, + &entry.mtime, + index_entry.file_size, + &index_entry.mtime, + ) { + let blob = MevaBlob::from_file(&absolute_path)?; + match blob.text_content() { + Ok(new_content) => { + let object = MevaObject::try_from(blob)?; + + let hash = object.sha1(); + + if *hash == index_entry.sha1 { + continue; + } + + match self.get_text_content(&index_entry.sha1) { + Ok(old_content) => { + let args = ModifiedFileArgs { + old_content: &old_content, + new_content: &new_content, + old_path: index_entry.path.clone().into(), + new_path: path.to_path_buf(), + old_hash: Some(index_entry.sha1.clone()), + new_hash: Some(hash.clone()), + file_mode: &entry.mode, + collect_details: matches!(mode, DiffMode::Full), + }; + modified.push(self.build_file_change_modified(args)); + } + Err(_) => modified.push(FileChange::default_modified( + index_entry.path.clone().into(), + path.to_path_buf(), + )), + } + } + Err(_) => modified.push(FileChange::default_modified( + index_entry.path.clone().into(), + path.to_path_buf(), + )), + } + } + } + }, + } + } + + for (path, entry) in &index_map { + if !working.contains_key(path) { + match mode { + DiffMode::NameOnly => deleted.push(FileChange::default_deleted(path.into())), + _ => match self.get_text_content(&entry.sha1) { + Ok(content) => deleted.push(self.build_file_change_deleted( + &content, + path.into(), + entry.sha1.clone(), + &entry.mode, + matches!(mode, DiffMode::Full), + )), + Err(_) => deleted.push(FileChange::default_deleted(path.into())), + }, + } + } + } + + let all = self.merge_and_sort(added, deleted, modified); + + Ok(all) + } + + fn diff_tree_vs_tree( + &self, + from_objects: &HashMap, + to_objects: &HashMap, + mode: &DiffMode, + ) -> EngineResult> { + let (added, deleted, modified) = self.diff_object_maps(from_objects, to_objects); + + let added_changes = self.added_to_file_changes(added, mode)?; + let deleted_changes = self.deleted_to_file_changes(deleted, mode)?; + let modified_changes = self.modified_to_file_changes(modified, mode)?; + + Ok(self.merge_and_sort(added_changes, deleted_changes, modified_changes)) + } + + /// A common utility to generate diff details (hunks, stats) from old and new text content. + /// + /// # Arguments + /// + /// * `old_content` - The original string content. + /// * `new_content` - The modified string content. + /// * `collect_details` - If `true`, generates detailed hunks and a unified diff string. + /// If `false`, only calculates total insertions and deletions. + /// + /// # Returns + /// + /// A tuple containing: + /// - An `Option` with a vector of [`Hunk`]s. + /// - An `Option` with the unified diff string. + /// - The total number of insertions. + /// - The total number of deletions. + fn build_file_change_common( + &self, + old_content: &str, + new_content: &str, + collect_details: bool, + ) -> (Option>, Option, usize, usize) { + let diff = TextDiff::from_lines(old_content, new_content); + let (total_insertions, total_deletions) = + diff.ops().iter().fold((0, 0), |(ins, del), op| { + (ins + op.new_range().len(), del + op.old_range().len()) + }); + + if collect_details { + let (hunks, unified_diff) = join( + || { + diff.grouped_ops(3) + .into_iter() + .map(|group| { + let old_start_idx = + group.first().map(|op| op.old_range().start).unwrap_or(0); + let new_start_idx = + group.first().map(|op| op.new_range().start).unwrap_or(0); + let mut lines = Vec::new(); + let mut old_count = 0; + let mut new_count = 0; + + for op in group { + for change in diff.iter_changes(&op) { + let line_type = match change.tag() { + ChangeTag::Delete => { + old_count += 1; + HunkLineType::Deletion + } + ChangeTag::Insert => { + new_count += 1; + HunkLineType::Addition + } + ChangeTag::Equal => { + old_count += 1; + new_count += 1; + HunkLineType::Context + } + }; + lines.push(HunkLine { + line_type, + content: change.to_string(), + }); + } + } + + let old_start = if old_count == 0 { 0 } else { old_start_idx + 1 }; + let new_start = if new_count == 0 { 0 } else { new_start_idx + 1 }; + + Hunk { + old_start, + new_start, + old_lines: old_count, + new_lines: new_count, + lines, + } + }) + .collect::>() + }, + || diff.unified_diff().to_string(), + ); + + ( + Some(hunks), + Some(unified_diff), + total_insertions, + total_deletions, + ) + } else { + (None, None, total_insertions, total_deletions) + } + } + + /// Constructs a [`FileChange`] object for a modified file. + fn build_file_change_modified(&self, args: ModifiedFileArgs) -> FileChange { + let (hunks, unified_diff, insertions, deletions) = + self.build_file_change_common(args.old_content, args.new_content, args.collect_details); + + FileChange { + kind: FileChangeKind::Modified { + old_path: args.old_path, + new_path: args.new_path, + old_hash: args.old_hash, + new_hash: args.new_hash, + mode: Some(*args.file_mode), + insertions, + deletions, + }, + hunks, + unified_diff, + } + } + + /// Constructs a [`FileChange`] object for an added file. + fn build_file_change_added( + &self, + new_content: &str, + new_path: PathBuf, + new_hash: String, + file_mode: &FileMode, + collect_details: bool, + ) -> FileChange { + let (hunks, unified_diff, insertions, _) = + self.build_file_change_common("", new_content, collect_details); + + FileChange { + kind: FileChangeKind::Added { + new_path, + mode: Some(*file_mode), + hash: Some(new_hash), + insertions, + }, + hunks, + unified_diff, + } + } + + /// Constructs a [`FileChange`] object for a deleted file. + fn build_file_change_deleted( + &self, + old_content: &str, + old_path: PathBuf, + old_hash: String, + file_mode: &FileMode, + collect_details: bool, + ) -> FileChange { + let (hunks, unified_diff, _, deletions) = + self.build_file_change_common(old_content, "", collect_details); + + FileChange { + kind: FileChangeKind::Deleted { + old_path, + mode: Some(*file_mode), + hash: Some(old_hash), + deletions, + }, + hunks, + unified_diff, + } + } + + /// Merges vectors of added, deleted, and modified changes into a single vector, + /// then sorts it by file path. + fn merge_and_sort( + &self, + added_changes: Vec, + deleted_changes: Vec, + modified_changes: Vec, + ) -> Vec { + let mut all = Vec::with_capacity( + added_changes.len() + deleted_changes.len() + modified_changes.len(), + ); + all.extend(added_changes); + all.extend(deleted_changes); + all.extend(modified_changes); + all.sort_by(|first, second| first.path().cmp(second.path())); + all + } + + /// Recursively traverses a tree object and collects all file entries into a map. + /// + /// # Arguments + /// + /// * `path` - The current directory path being traversed. + /// * `tree_hash` - The hash of the tree object to process. + /// * `paths` - An optional filter to only include specified paths. + /// + /// # Returns + /// + /// A [`EngineResult`] with a [`HashMap`] mapping file paths to their [`ObjectEntry`] data. + pub fn collect_object_entries( + &self, + path: PathBuf, + tree_hash: String, + paths: Option<&[PathBuf]>, + ) -> EngineResult> { + let mut collected: HashMap = HashMap::new(); + let tree_object = self.object_storage.get_object(&tree_hash)?; + let tree = MevaTree::try_from(tree_object)?; + + for entry in tree.tree_entries { + match entry.entry_type { + TreeEntryType::Tree => { + let inner = self.collect_object_entries( + path.join(&entry.name), + entry.object_hash, + paths, + )?; + collected.extend(inner); + } + _ => { + let key = path.join(&entry.name); + if let Some(paths) = paths + && !paths.iter().any(|p| key.starts_with(p)) + { + continue; + } + + let blob_object = self.object_storage.get_object(&entry.object_hash)?; + let blob = MevaBlob::try_from(blob_object)?; + let value = ObjectEntry { + entry_type: entry.entry_type, + hash: entry.object_hash, + size: Some(blob.size()), + path: key.clone(), + }; + collected.insert(key, value); + } + } + } + + Ok(collected) + } + + /// Resolves a revision to a commit hash and retrieves the corresponding [`MevaCommit`] object. + fn get_commit(&self, revision: &Revision) -> EngineResult { + let hash = self.revision_resolver.resolve_hash(revision)?; + let commit_object = self.object_storage.get_object(&hash)?; + MevaCommit::try_from(commit_object) + } + + /// Retrieves the string content of a blob object given its hash. + fn get_text_content(&self, hash: &str) -> EngineResult { + let object = self.object_storage.get_object(hash)?; + let blob = MevaBlob::try_from(object)?; + blob.text_content() + } + + /// Collects all file entries from the index into a map. + /// + /// # Arguments + /// + /// * `paths` - An optional filter to only include specified paths. + /// + /// # Returns + /// + /// A [`HashMap`] mapping file paths to their [`ObjectEntry`] data from the index. + pub fn collect_object_entries_from_index( + &self, + paths: Option<&[PathBuf]>, + ) -> HashMap { + self.index + .get_entries() + .par_iter() + .filter_map(|entry| { + let path = PathBuf::from(&entry.path); + if let Some(allowed_paths) = paths + && !allowed_paths.iter().any(|p| path.starts_with(p)) + { + return None; + } + + Some(( + path.clone(), + ObjectEntry { + entry_type: entry.mode.into(), + hash: entry.sha1.clone(), + // TODO: `file_size` is u64 but `size` is usize (can be u32 on some machines) + size: Some(entry.file_size.try_into().unwrap_or(0)), + path, + }, + )) + }) + .collect() + } + + /// Compares two maps of object entries to determine added, deleted, and modified files. + /// + /// # Arguments + /// + /// * `from_objects` - The map representing the "old" state. + /// * `to_objects` - The map representing the "new" state. + /// + /// # Returns + /// + /// A tuple containing three vectors: + /// - `added`: [`ObjectEntry`]s present in `to_objects` but not `from_objects`. + /// - `deleted`: [`ObjectEntry`]s present in `from_objects` but not `to_objects`. + /// - `modified`: Tuples of (`from_entry`, `to_entry`) for entries present in both but with different hashes. + pub fn diff_object_maps<'b>( + &self, + from_objects: &'b HashMap, + to_objects: &'b HashMap, + ) -> ( + Vec<&'b ObjectEntry>, + Vec<&'b ObjectEntry>, + Vec<(&'b ObjectEntry, &'b ObjectEntry)>, + ) { + let mut added = Vec::new(); + let mut deleted = Vec::new(); + let mut modified = Vec::new(); + + for (path, to_entry) in to_objects { + match from_objects.get(path) { + None => { + added.push(to_entry); + } + Some(from_entry) => { + if from_entry.hash != to_entry.hash { + modified.push((from_entry, to_entry)); + } + } + } + } + + for (path, from_entry) in from_objects { + if !to_objects.contains_key(path) { + deleted.push(from_entry); + } + } + + (added, deleted, modified) + } + + /// Converts a list of added [`ObjectEntry`]s into a vector of [`FileChange`]s. + pub fn added_to_file_changes( + &self, + added: Vec<&ObjectEntry>, + mode: &DiffMode, + ) -> EngineResult> { + match mode { + DiffMode::NameOnly => Ok(added + .into_par_iter() + .map(|object_entry| FileChange::default_added(object_entry.path.clone())) + .collect()), + _ => added + .into_par_iter() + .map(|object_entry| -> EngineResult<_> { + match self.get_text_content(&object_entry.hash) { + Ok(content) => Ok(self.build_file_change_added( + &content, + object_entry.path.clone(), + object_entry.hash.clone(), + &FileMode::try_from(&object_entry.entry_type)?, + matches!(mode, DiffMode::Full), + )), + Err(_) => Ok(FileChange::default_added(object_entry.path.clone())), + } + }) + .collect(), + } + } + + /// Converts a list of deleted [`ObjectEntry`]s into a vector of [`FileChange`]s. + pub fn deleted_to_file_changes( + &self, + deleted: Vec<&ObjectEntry>, + mode: &DiffMode, + ) -> EngineResult> { + match mode { + DiffMode::NameOnly => Ok(deleted + .into_par_iter() + .map(|object_entry| FileChange::default_deleted(object_entry.path.clone())) + .collect()), + _ => deleted + .into_par_iter() + .map(|object_entry| -> EngineResult<_> { + match self.get_text_content(&object_entry.hash) { + Ok(content) => Ok(self.build_file_change_deleted( + &content, + object_entry.path.clone(), + object_entry.hash.clone(), + &FileMode::try_from(&object_entry.entry_type)?, + matches!(mode, DiffMode::Full), + )), + Err(_) => Ok(FileChange::default_deleted(object_entry.path.clone())), + } + }) + .collect(), + } + } + + /// Converts a list of modified [`ObjectEntry`] pairs into a vector of [`FileChange`]s. + pub fn modified_to_file_changes( + &self, + modified: Vec<(&ObjectEntry, &ObjectEntry)>, + mode: &DiffMode, + ) -> EngineResult> { + match mode { + DiffMode::NameOnly => Ok(modified + .into_par_iter() + .map(|(from, to)| FileChange::default_modified(from.path.clone(), to.path.clone())) + .collect()), + _ => modified + .into_par_iter() + .map(|(from, to)| -> EngineResult<_> { + match ( + self.get_text_content(&from.hash), + self.get_text_content(&to.hash), + ) { + (Ok(old_content), Ok(new_content)) => { + let file_mode = FileMode::try_from(&to.entry_type)?; + let args = ModifiedFileArgs { + old_content: &old_content, + new_content: &new_content, + old_path: from.path.clone(), + new_path: to.path.clone(), + old_hash: Some(from.hash.clone()), + new_hash: Some(to.hash.clone()), + file_mode: &file_mode, + collect_details: matches!(mode, DiffMode::Full), + }; + Ok(self.build_file_change_modified(args)) + } + (_, _) => Ok(FileChange::default_modified( + from.path.clone(), + to.path.clone(), + )), + } + }) + .collect(), + } + } +} diff --git a/engine/src/diff/diff_stat.rs b/engine/src/diff/diff_stat.rs deleted file mode 100644 index 3c6acd8..0000000 --- a/engine/src/diff/diff_stat.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::fmt::Display; - -use serde::{Deserialize, Serialize}; - -use super::file_diff_stat::FileDiffStat; - -/// Represents an aggregate summary of changes across one or more files. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct DiffStat { - /// The total number of inserted lines across all changed files. - pub insertions: usize, - - /// The total number of deleted lines across all changed files. - pub deletions: usize, - - /// The total number of files that were changed. - pub files_changed: usize, - - /// Detailed statistics for each changed file. - pub file_stats: Vec, -} - -impl Display for DiffStat { - /// Formats the diff statistics in a human-readable summary form. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for file_stat in &self.file_stats { - writeln!(f, "{file_stat}")?; - } - let files_str = if self.files_changed == 1 { - "file" - } else { - "files" - }; - write!(f, "{} {files_str} changed", self.files_changed)?; - if self.insertions > 0 { - let insertions_str = if self.insertions == 1 { - "insertion" - } else { - "insertions" - }; - write!(f, ", {} {insertions_str}(+)", self.insertions)?; - } - if self.deletions > 0 { - let deletions_str = if self.deletions == 1 { - "deletion" - } else { - "deletions" - }; - write!(f, ", {} {deletions_str}(-)", self.deletions)?; - } - writeln!(f) - } -} diff --git a/engine/src/diff/file_change.rs b/engine/src/diff/file_change.rs deleted file mode 100644 index 24f3c5d..0000000 --- a/engine/src/diff/file_change.rs +++ /dev/null @@ -1,57 +0,0 @@ -use std::{fmt::Display, path::PathBuf}; - -use serde::{Deserialize, Serialize}; - -use crate::index::file_mode::FileMode; - -use super::{change_kind::ChangeKind, hunk::Hunk}; - -/// Represents a single file's change information within a diff or commit. -/// -/// This structure aggregates all relevant metadata about a file's modification, -/// including its change type, diff hunks, and the total number of inserted and deleted lines. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct FileChange { - /// The previous file path, if applicable (e.g. for renames or copies). - pub old_path: Option, - - /// The current file path after the change. - pub new_path: PathBuf, - - /// The type of change that occurred (added, modified, deleted, etc.). - pub kind: ChangeKind, - - /// Total number of inserted lines in this file. - pub insertions: usize, - - /// Total number of deleted lines in this file. - pub deletions: usize, - - /// Optional structured diff representation divided into hunks. - pub hunks: Option>, - - /// Optional preformatted unified diff string representation. - pub unified_diff: Option, - - /// Optional file mode. - pub mode: Option, -} - -impl Display for FileChange { - /// Formats the change summary in a concise format. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let path_display = match &self.old_path { - Some(old) if old != &self.new_path => { - format!("{} -> {}", old.display(), self.new_path.display()) - } - _ => self.new_path.display().to_string(), - }; - - let changes = self.insertions + self.deletions; - write!( - f, - "{}\t{} | {} ({:+} / {:-})", - self.kind, path_display, changes, self.insertions, self.deletions - ) - } -} diff --git a/engine/src/diff/models.rs b/engine/src/diff/models.rs new file mode 100644 index 0000000..bbe2040 --- /dev/null +++ b/engine/src/diff/models.rs @@ -0,0 +1,18 @@ +mod change_kind; +mod diff_mode; +mod diff_stat; +mod file_change; +mod file_change_kind; +mod file_diff_stat; +mod hunk; +mod revision_range; +mod revision_side; + +pub use change_kind::ChangeKind; +pub use diff_mode::DiffMode; +pub use diff_stat::DiffStat; +pub use file_change::FileChange; +pub use file_change_kind::FileChangeKind; +pub use hunk::{Hunk, HunkLine, HunkLineType}; +pub use revision_range::RevisionRange; +pub use revision_side::RevisionSide; diff --git a/engine/src/diff/change_kind.rs b/engine/src/diff/models/change_kind.rs similarity index 100% rename from engine/src/diff/change_kind.rs rename to engine/src/diff/models/change_kind.rs diff --git a/engine/src/diff/models/diff_mode.rs b/engine/src/diff/models/diff_mode.rs new file mode 100644 index 0000000..960d9f5 --- /dev/null +++ b/engine/src/diff/models/diff_mode.rs @@ -0,0 +1,11 @@ +/// Specifies the level of detail for a diff operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffMode { + /// Generate a full diff, including file content changes (hunks). + Full, + /// Only list the names of the files that have changed. + NameOnly, + /// Generate a statistical summary of changes (e.g., insertions/deletions) + /// without the full content diff. + Stat, +} diff --git a/engine/src/diff/models/diff_stat.rs b/engine/src/diff/models/diff_stat.rs new file mode 100644 index 0000000..71b37c8 --- /dev/null +++ b/engine/src/diff/models/diff_stat.rs @@ -0,0 +1,103 @@ +use std::fmt::Display; + +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use serde::{Deserialize, Serialize}; + +use crate::diff::FileChange; + +use super::{file_change_kind::FileChangeKind, file_diff_stat::FileDiffStat}; + +/// Represents an aggregate summary of changes across one or more files. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct DiffStat { + /// The total number of inserted lines across all changed files. + pub insertions: usize, + + /// The total number of deleted lines across all changed files. + pub deletions: usize, + + /// The total number of files that were changed. + pub files_changed: usize, + + /// Detailed statistics for each changed file. + pub file_stats: Vec, +} + +impl Display for DiffStat { + /// Formats the diff statistics in a human-readable summary form. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for file_stat in &self.file_stats { + writeln!(f, "{file_stat}")?; + } + let files_str = if self.files_changed == 1 { + "file" + } else { + "files" + }; + write!(f, "{} {files_str} changed", self.files_changed)?; + if self.insertions > 0 { + let insertions_str = if self.insertions == 1 { + "insertion" + } else { + "insertions" + }; + write!(f, ", {} {insertions_str}(+)", self.insertions)?; + } + if self.deletions > 0 { + let deletions_str = if self.deletions == 1 { + "deletion" + } else { + "deletions" + }; + write!(f, ", {} {deletions_str}(-)", self.deletions)?; + } + writeln!(f) + } +} + +impl From<&[FileChange]> for DiffStat { + /// Converts a collection of [FileChange] objects into a [DiffStat] summary. + /// + /// Uses parallel processing with [rayon] for better performance. + fn from(value: &[FileChange]) -> Self { + let file_stats = value + .par_iter() + .map(|file_change| { + let (insertions, deletions, path) = match &file_change.kind { + FileChangeKind::Added { + new_path, + insertions, + .. + } => (*insertions, 0, new_path.clone()), + FileChangeKind::Deleted { + old_path, + deletions, + .. + } => (0, *deletions, old_path.clone()), + FileChangeKind::Modified { + new_path, + insertions, + deletions, + .. + } => (*insertions, *deletions, new_path.clone()), + }; + + FileDiffStat { + insertions, + deletions, + path, + } + }) + .collect::>(); + + let total_insertions = file_stats.par_iter().map(|s| s.insertions).sum::(); + let total_deletions = file_stats.par_iter().map(|s| s.deletions).sum::(); + + Self { + insertions: total_insertions, + deletions: total_deletions, + files_changed: file_stats.len(), + file_stats, + } + } +} diff --git a/engine/src/diff/models/file_change.rs b/engine/src/diff/models/file_change.rs new file mode 100644 index 0000000..a796687 --- /dev/null +++ b/engine/src/diff/models/file_change.rs @@ -0,0 +1,179 @@ +use std::{fmt::Display, path::PathBuf}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use shared::PathToString; + +use super::{FileChangeKind, Hunk}; + +/// Represents a single file's change information within a diff or commit. +/// +/// This structure aggregates all relevant metadata about a file's modification, +/// including its change type, diff hunks, and the total number of inserted and deleted lines. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileChange { + /// The specific kind of change, represented by the [`FileChangeKind`] enum. + pub kind: FileChangeKind, + + /// Optional structured diff representation divided into [`Hunk`]s. + /// This is typically populated for `Full` or `Stat` diff modes. + pub hunks: Option>, + + /// Optional preformatted unified diff string representation. + /// This provides the raw text output for the diff. + pub unified_diff: Option, +} + +impl FileChange { + /// Creates a default `FileChange` representing an added file, with no diff details. + /// + /// # Arguments + /// + /// * `new_path` - The path of the newly added file. + pub fn default_added(new_path: PathBuf) -> Self { + Self { + kind: FileChangeKind::default_added(new_path), + hunks: None, + unified_diff: None, + } + } + + /// Creates a default `FileChange` representing a deleted file, with no diff details. + /// + /// # Arguments + /// + /// * `old_path` - The path of the deleted file. + pub fn default_deleted(old_path: PathBuf) -> Self { + Self { + kind: FileChangeKind::default_deleted(old_path), + hunks: None, + unified_diff: None, + } + } + + /// Creates a default `FileChange` representing a modified file, with no diff details. + /// + /// # Arguments + /// + /// * `old_path` - The original path of the file. + /// * `new_path` - The new path of the file (can be the same as `old_path` if not renamed). + pub fn default_modified(old_path: PathBuf, new_path: PathBuf) -> Self { + Self { + kind: FileChangeKind::default_modified(old_path, new_path), + hunks: None, + unified_diff: None, + } + } + + /// Returns the relevant path for the file change. + /// + /// - For `Added` changes, it returns the `new_path`. + /// - For `Deleted` changes, it returns the `old_path`. + /// - For `Modified` changes, it returns the `new_path`. + pub fn path(&self) -> &PathBuf { + match &self.kind { + FileChangeKind::Added { new_path, .. } => new_path, + FileChangeKind::Deleted { old_path, .. } => old_path, + FileChangeKind::Modified { new_path, .. } => new_path, + } + } + + pub fn is_modified_heuristic( + file_size: u64, + mtime: &DateTime, + prev_size: u64, + prev_mtime: &DateTime, + ) -> bool { + file_size != prev_size || mtime != prev_mtime + } + + pub fn display_full(&self) { + print!("{self}"); + if let Some(hunks) = &self.hunks { + for h in hunks { + print!("{h}"); + } + } else if let Some(diff) = &self.unified_diff { + println!("{diff}"); + } + } +} + +/// Formats the `FileChange` into a git-compatible diff header format. +/// This does not include the content of the diff (the hunks), only the header lines. +/// +/// # Example for a modified file: +/// ```text +/// index .. +/// --- a/path/to/file.txt +/// +++ b/path/to/file.txt +/// ``` +impl Display for FileChange { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let file_mode = "file mode"; + let empty_hash = "0000000"; + let dev_null = PathBuf::from("dev").join("null"); + let index = "index"; + let pluses = "+++"; + let minuses = "---"; + let a_dir = PathBuf::from("a"); + let b_dir = PathBuf::from("b"); + + match &self.kind { + FileChangeKind::Added { + new_path, + mode, + hash, + .. + } => { + if let Some(mode) = mode { + writeln!(f, "new {file_mode} {mode:o}")?; + } + if let Some(hash) = hash { + writeln!(f, "{index} {empty_hash}..{}", &hash[..7])?; + } + writeln!(f, "{minuses} {}", dev_null.to_utf8_string())?; + writeln!(f, "{pluses} {}", b_dir.join(new_path).to_utf8_string())?; + } + + FileChangeKind::Deleted { + old_path, + mode, + hash, + .. + } => { + if let Some(mode) = mode { + writeln!(f, "deleted {file_mode} {mode:o}")?; + } + if let Some(hash) = hash { + writeln!(f, "{index} {}..{empty_hash}", &hash[..7])?; + } + writeln!(f, "{minuses} {}", a_dir.join(old_path).to_utf8_string())?; + writeln!(f, "{pluses} {}", dev_null.to_utf8_string())?; + } + + FileChangeKind::Modified { + old_path, + new_path, + old_hash, + new_hash, + mode, + .. + } => { + if let (Some(o), Some(n)) = (old_hash.as_ref(), new_hash.as_ref()) { + if let Some(mode) = mode { + writeln!(f, "{index} {}..{} {mode:o}", &o[..7], &n[..7])?; + } else { + writeln!(f, "{index} {}..{}", &o[..7], &n[..7])?; + } + } + + writeln!(f, "{} {}", minuses, a_dir.join(old_path).to_utf8_string())?; + writeln!(f, "{} {}", pluses, b_dir.join(new_path).to_utf8_string())?; + } + } + + Ok(()) + } +} diff --git a/engine/src/diff/models/file_change_kind.rs b/engine/src/diff/models/file_change_kind.rs new file mode 100644 index 0000000..36f8d0c --- /dev/null +++ b/engine/src/diff/models/file_change_kind.rs @@ -0,0 +1,105 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::index::FileMode; + +/// Represents the specific type of change a file has undergone. +/// +/// This enum captures the essential metadata for added, deleted, or modified files, +/// including paths, object hashes, file modes, and change statistics. +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub enum FileChangeKind { + /// The file was added. + Added { + /// The path of the new file. + new_path: PathBuf, + /// The file mode (e.g., regular file, executable). + mode: Option, + /// The SHA-1 hash of the new file's content. + hash: Option, + /// The total number of lines inserted. + insertions: usize, + }, + + /// The file was deleted. + Deleted { + /// The path of the deleted file. + old_path: PathBuf, + /// The file mode of the deleted file. + mode: Option, + /// The SHA-1 hash of the deleted file's content. + hash: Option, + /// The total number of lines deleted. + deletions: usize, + }, + + /// The file was modified. + Modified { + /// The original path of the file before modification (in case of a rename). + old_path: PathBuf, + /// The new path of the file after modification. + new_path: PathBuf, + /// The SHA-1 hash of the file's content before modification. + old_hash: Option, + /// The SHA-1 hash of the file's content after modification. + new_hash: Option, + /// The new file mode. + mode: Option, + /// The total number of lines inserted in this modification. + insertions: usize, + /// The total number of lines deleted in this modification. + deletions: usize, + }, +} + +impl FileChangeKind { + /// Creates a minimal `Added` variant with default values. + /// Useful for name-only diffs. + /// + /// # Arguments + /// + /// * `new_path` - The path of the added file. + pub fn default_added(new_path: PathBuf) -> Self { + Self::Added { + new_path, + mode: None, + hash: None, + insertions: 0, + } + } + + /// Creates a minimal `Deleted` variant with default values. + /// Useful for name-only diffs. + /// + /// # Arguments + /// + /// * `old_path` - The path of the deleted file. + pub fn default_deleted(old_path: PathBuf) -> Self { + Self::Deleted { + old_path, + mode: None, + hash: None, + deletions: 0, + } + } + + /// Creates a minimal `Modified` variant with default values. + /// Useful for name-only diffs. + /// + /// # Arguments + /// + /// * `old_path` - The original path of the file. + /// * `new_path` - The new path of the file. + pub fn default_modified(old_path: PathBuf, new_path: PathBuf) -> Self { + Self::Modified { + old_path, + new_path, + old_hash: None, + new_hash: None, + mode: None, + insertions: 0, + deletions: 0, + } + } +} diff --git a/engine/src/diff/file_diff_stat.rs b/engine/src/diff/models/file_diff_stat.rs similarity index 100% rename from engine/src/diff/file_diff_stat.rs rename to engine/src/diff/models/file_diff_stat.rs diff --git a/engine/src/diff/hunk.rs b/engine/src/diff/models/hunk.rs similarity index 98% rename from engine/src/diff/hunk.rs rename to engine/src/diff/models/hunk.rs index 8a059dc..92c671b 100644 --- a/engine/src/diff/hunk.rs +++ b/engine/src/diff/models/hunk.rs @@ -36,7 +36,7 @@ impl Display for Hunk { self.old_start, self.old_lines, self.new_start, self.new_lines )?; for line in &self.lines { - writeln!(f, "{line}")?; + write!(f, "{line}")?; } Ok(()) } diff --git a/engine/src/diff/models/revision_range.rs b/engine/src/diff/models/revision_range.rs new file mode 100644 index 0000000..53ee01a --- /dev/null +++ b/engine/src/diff/models/revision_range.rs @@ -0,0 +1,63 @@ +use crate::revision_parsing::Revision; + +use super::RevisionSide; + +/// Represents a range between two points in a repository's history or state, +/// used for generating diffs. +/// +/// This can define a comparison between two commits, a commit and the index, +/// the index and the working directory, etc. +#[derive(Debug)] +pub struct RevisionRange { + /// The starting point of the diff range. + pub from: RevisionSide, + /// The ending point of the diff range. + pub to: RevisionSide, +} + +impl Default for RevisionRange { + /// Creates a default `RevisionRange` that represents the changes + /// between the index (staging area) and the working directory. + /// This is equivalent to `git diff`. + fn default() -> Self { + Self { + from: RevisionSide::Index, + to: RevisionSide::WorkingDir, + } + } +} + +impl RevisionRange { + /// Creates a `RevisionRange` between two specific revisions (snapshots). + /// This is equivalent to `git diff `. + /// + /// # Arguments + /// + /// * `from` - The starting revision for the comparison. + /// * `to` - The ending revision for the comparison. + pub fn range(from: Revision, to: Revision) -> Self { + Self { + from: RevisionSide::Snapshot { revision: from }, + to: RevisionSide::Snapshot { revision: to }, + } + } + + /// Creates a `RevisionRange` comparing a single revision to either the index + /// or the working directory. + /// + /// # Arguments + /// + /// * `from` - The revision to compare against. + /// * `cached` - If `true`, compares the revision against the index (`git diff --cached `). + /// If `false`, compares it against the working directory (`git diff `). + pub fn single(from: Revision, cached: bool) -> Self { + Self { + from: RevisionSide::Snapshot { revision: from }, + to: if cached { + RevisionSide::Index + } else { + RevisionSide::WorkingDir + }, + } + } +} diff --git a/engine/src/diff/models/revision_side.rs b/engine/src/diff/models/revision_side.rs new file mode 100644 index 0000000..75ba4c2 --- /dev/null +++ b/engine/src/diff/models/revision_side.rs @@ -0,0 +1,8 @@ +use crate::revision_parsing::Revision; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RevisionSide { + WorkingDir, + Index, + Snapshot { revision: Revision }, +} diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 5a5f5d3..601cd3f 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -47,10 +47,13 @@ pub trait EngineContainer { /// Return the handler responsible for commit creation fn commit_handler(&self) -> EngineResult; + + // /// Return the handler responsible for diff operations + // fn diff_handler(&self) -> EngineResult; } /// Concrete implementation of `EngineContainer` for Meva. -pub struct MevaContainer {} +pub struct MevaContainer; impl MevaContainer { /// Returns the Meva-specific plugin layout. diff --git a/engine/src/errors.rs b/engine/src/errors.rs index c6a62cd..e1f90d7 100644 --- a/engine/src/errors.rs +++ b/engine/src/errors.rs @@ -6,7 +6,7 @@ pub mod index_error; pub mod init_error; pub mod path_error; pub mod repository_error; -pub mod revision_resolver_error; +pub mod revision_error; pub mod tree_error; pub mod unpack_error; @@ -17,7 +17,7 @@ pub use index_error::IndexError; pub use init_error::InitError; pub use path_error::PathError; pub use repository_error::RepositoryError; -pub use revision_resolver_error::RevisionResolverError; +pub use revision_error::RevisionError; pub use tree_error::{NodeError, TreeError}; pub use unpack_error::UnpackError; diff --git a/engine/src/errors/engine_error.rs b/engine/src/errors/engine_error.rs index 3c1b903..7566adb 100644 --- a/engine/src/errors/engine_error.rs +++ b/engine/src/errors/engine_error.rs @@ -1,12 +1,12 @@ use bincode::error::{DecodeError, EncodeError}; use plugins::PluginError; -use std::io; use std::path::StripPrefixError; +use std::{io, string::FromUtf8Error}; use thiserror::Error; use crate::errors::{ CommitError, ConfigError, IgnoreError, IndexError, InitError, PathError, RepositoryError, - RevisionResolverError, TreeError, UnpackError, + RevisionError, TreeError, UnpackError, }; /// A convenient result type alias for engine-related operations. @@ -56,25 +56,39 @@ pub enum EngineError { /// Error raised when attempting to strip a prefix from a path but the /// operation is invalid (e.g., the prefix is not actually a parent). + /// Automatically converted from [`std::path::StripPrefixError`]. #[error(transparent)] StripPrefix(#[from] StripPrefixError), - /// JSON serialization/deserialization error + /// JSON serialization or deserialization error. + /// Automatically converted from [`serde_json::Error`]. #[error(transparent)] Serde(#[from] serde_json::Error), - /// Error automatically converted from [`EncodeError`] + /// An error occurred while traversing a directory tree. + /// Automatically converted from [`walkdir::Error`]. + #[error(transparent)] + WalkDir(#[from] walkdir::Error), + + /// A byte sequence could not be interpreted as valid UTF-8. + /// Automatically converted from [`std::string::FromUtf8Error`]. + #[error(transparent)] + Utf8(#[from] FromUtf8Error), + + /// An error occurred during binary serialization using bincode. + /// Automatically converted from [`bincode::error::EncodeError`]. #[error(transparent)] Encode(#[from] EncodeError), - /// Error automatically converted from [`DecodeError`] + /// An error occurred during binary deserialization using bincode. + /// Automatically converted from [`bincode::error::DecodeError`]. #[error(transparent)] Decode(#[from] DecodeError), /// Errors originating from tree-based data structure operations. /// /// Typically raised when inserting, looking up, or modifying nodes - /// in a [`TreeDs`](crate::TreeDs)-backed structure. + /// in a `TreeDs`-backed structure. #[error(transparent)] Tree(#[from] TreeError), @@ -90,19 +104,19 @@ pub enum EngineError { #[error("Expected collection to contain at least one element, but it was empty")] EmptyCollection, - /// Raised when unpacking a [`MevaObject`] fails. + /// Raised when unpacking a Meva object fails. /// /// Typically occurs when the stored object type does not match /// the expected variant or when deserialization of its content fails. #[error(transparent)] Unpack(#[from] UnpackError), - /// Wraps errors from the [`RevisionResolver`]. + /// Wraps errors from the revision resolver. /// /// This allows propagating revision resolution errors through the engine /// while keeping the original context and error message. #[error(transparent)] - RevisionResolver(#[from] RevisionResolverError), + RevisionResolver(#[from] RevisionError), /// A catch-all variant for any unknown or unexpected engine error. /// Accepts a descriptive string message. diff --git a/engine/src/errors/revision_resolver_error.rs b/engine/src/errors/revision_error.rs similarity index 97% rename from engine/src/errors/revision_resolver_error.rs rename to engine/src/errors/revision_error.rs index 9104b9d..aee8436 100644 --- a/engine/src/errors/revision_resolver_error.rs +++ b/engine/src/errors/revision_error.rs @@ -6,7 +6,7 @@ use thiserror::Error; /// interpreting a revision string (like `HEAD`, commit hashes, or `HEAD^2`) /// and resolving it to a concrete commit hash. #[derive(Error, Debug)] -pub enum RevisionResolverError { +pub enum RevisionError { /// The given revision string could not be found in the repository. /// /// For example, if the user specifies a branch, tag, or commit hash diff --git a/engine/src/handlers.rs b/engine/src/handlers.rs index 14d25a9..e98f9d1 100644 --- a/engine/src/handlers.rs +++ b/engine/src/handlers.rs @@ -1,6 +1,7 @@ pub mod add; pub mod commit; pub mod config; +pub mod diff; pub mod init; pub mod ls_files; pub mod ls_tree; diff --git a/engine/src/handlers/add/handlers.rs b/engine/src/handlers/add/handlers.rs index 9ed1080..7745eb0 100644 --- a/engine/src/handlers/add/handlers.rs +++ b/engine/src/handlers/add/handlers.rs @@ -54,7 +54,7 @@ impl AddOperations for AddHandler { let mut index = MevaIndex::new(&repo_layout)?; - index.load_from_disk()?; + index.load_from_disk(None)?; let (added, modified, removed) = index.add(&path_abs, add_new, add_deleted, add_ignored, verbose)?; diff --git a/engine/src/handlers/diff.rs b/engine/src/handlers/diff.rs new file mode 100644 index 0000000..0004597 --- /dev/null +++ b/engine/src/handlers/diff.rs @@ -0,0 +1,5 @@ +mod handlers; +mod operations; + +pub use handlers::DiffHandler; +pub use operations::*; diff --git a/engine/src/handlers/diff/handlers.rs b/engine/src/handlers/diff/handlers.rs new file mode 100644 index 0000000..da7cfdc --- /dev/null +++ b/engine/src/handlers/diff/handlers.rs @@ -0,0 +1,97 @@ +use crate::{ + RepositoryLayout, + diff::{DiffBuilder, DiffStat, RevisionRange, RevisionSide}, + errors::EngineResult, +}; + +use super::{DiffOperations, Request, Response}; + +/// Handles diffing operations by orchestrating the `DiffBuilder`. +/// +/// This struct acts as a high-level interface for processing diff requests, +/// determining the correct diffing strategy based on the specified revision range, +/// and returning the appropriate response. +pub struct DiffHandler<'a> { + /// The underlying builder responsible for the actual diff computation. + diff_builder: DiffBuilder<'a>, +} + +impl<'a> DiffHandler<'a> { + /// Creates a new `DiffHandler` instance. + /// + /// # Arguments + /// + /// * `repo_layout` - A reference to the repository's layout, used to initialize the `DiffBuilder`. + pub fn new(repo_layout: &'a dyn RepositoryLayout) -> EngineResult { + Ok(Self { + diff_builder: DiffBuilder::new(repo_layout)?, + }) + } + + /// Public entry point for handling a diff request. + /// This is a convenience method that delegates directly to the `diff` method. + /// + /// # Arguments + /// + /// * `request` - The diff request containing the range, mode, and paths. + pub fn handle_diff(&self, request: Request) -> EngineResult { + self.diff(request) + } +} + +impl<'a> DiffOperations for DiffHandler<'a> { + /// Computes the difference between two repository states as specified in the request. + /// + /// It determines the type of diff to perform (e.g., snapshot-to-snapshot, index-to-workdir) + /// based on the `RevisionRange` and calls the appropriate method on the `DiffBuilder`. + /// + /// If the request mode is `Stat`, it returns a statistical summary; otherwise, it returns + /// a list of `FileChange` objects. + /// + /// # Arguments + /// + /// * `request` - The request detailing the desired diff operation. + fn diff(&self, request: Request) -> EngineResult { + let RevisionRange { from, to } = request.range; + let is_stat_mode = matches!(request.mode, crate::diff::DiffMode::Stat); + + let file_changes = + match (from, to) { + // Diff between two commits. + ( + RevisionSide::Snapshot { revision: from }, + RevisionSide::Snapshot { revision: to }, + ) => self.diff_builder.diff_snapshots( + &from, + &to, + &request.mode, + request.paths.as_deref(), + )?, + // Diff between a commit and the index (staged changes). + (RevisionSide::Snapshot { revision: from }, RevisionSide::Index) => self + .diff_builder + .diff_snapshot_index(&from, &request.mode, request.paths.as_deref())?, + // Diff between a commit and the working directory (unstaged changes). + (RevisionSide::Snapshot { revision: from }, RevisionSide::WorkingDir) => self + .diff_builder + .diff_snapshot_working_dir(&from, &request.mode, request.paths.as_deref())?, + // Diff between the index and the working directory. + (RevisionSide::Index, RevisionSide::WorkingDir) => self + .diff_builder + .diff_index_working_dir(&request.mode, request.paths.as_deref())?, + _ => unreachable!("Other combinations are not supported"), + }; + + if is_stat_mode { + Ok(Response { + stat: Some(DiffStat::from(file_changes.as_slice())), + files: None, + }) + } else { + Ok(Response { + stat: None, + files: Some(file_changes), + }) + } + } +} diff --git a/engine/src/handlers/diff/operations.rs b/engine/src/handlers/diff/operations.rs new file mode 100644 index 0000000..873893c --- /dev/null +++ b/engine/src/handlers/diff/operations.rs @@ -0,0 +1,52 @@ +use std::path::PathBuf; + +use crate::{ + diff::{DiffMode, DiffStat, FileChange, RevisionRange}, + errors::EngineResult, + revision_parsing::Revision, +}; + +#[derive(Debug)] +pub struct Request { + pub mode: DiffMode, + pub range: RevisionRange, + pub paths: Option>, +} + +impl Request { + pub fn new( + from: Option, + to: Option, + cached: bool, + mode: DiffMode, + paths: Option>, + ) -> Self { + match (from, to) { + (Some(from), Some(to)) => Self { + mode, + paths, + range: RevisionRange::range(from, to), + }, + (Some(from), None) => Self { + mode, + paths, + range: RevisionRange::single(from, cached), + }, + (None, None) => Self { + mode, + paths, + range: RevisionRange::default(), + }, + (None, Some(_)) => unreachable!("`to` specified without `from` is not supported"), + } + } +} + +pub struct Response { + pub stat: Option, + pub files: Option>, +} + +pub trait DiffOperations { + fn diff(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/handlers/ls_files/handlers.rs b/engine/src/handlers/ls_files/handlers.rs index 73a06e8..f002933 100644 --- a/engine/src/handlers/ls_files/handlers.rs +++ b/engine/src/handlers/ls_files/handlers.rs @@ -81,7 +81,7 @@ impl LsFilesOperations for LsFilesHandler { fn ls_files(&self, request: Request) -> EngineResult { let repo_layout = MevaRepositoryLayout::discover()?; let working_dir = repo_layout.working_dir(); - let index = MevaIndex::from_disk(&repo_layout)?; + let index = MevaIndex::from_disk(&repo_layout, None)?; let abbrev = request.abbrev.unwrap_or(40) as usize; diff --git a/engine/src/handlers/ls_tree.rs b/engine/src/handlers/ls_tree.rs index e55b3a5..de2ca73 100644 --- a/engine/src/handlers/ls_tree.rs +++ b/engine/src/handlers/ls_tree.rs @@ -1,6 +1,7 @@ mod handlers; -mod ls_tree_entry; +mod models; mod operations; pub use handlers::LsTreeHandler; +pub use models::{DisplayMode, LsTreeEntry}; pub use operations::*; diff --git a/engine/src/handlers/ls_tree/handlers.rs b/engine/src/handlers/ls_tree/handlers.rs index 6b9c3df..7b6a087 100644 --- a/engine/src/handlers/ls_tree/handlers.rs +++ b/engine/src/handlers/ls_tree/handlers.rs @@ -1,11 +1,13 @@ use super::{LsTreeOperations, Request, Response}; use crate::RepositoryLayout; use crate::errors::EngineResult; -use crate::handlers::ls_tree::ls_tree_entry::{DisplayMode, LsTreeDisplayEntry, LsTreeEntry}; use crate::object_storage::{MevaObjectStorage, ObjectStorage}; use crate::objects::tree_entry_type::TreeEntryType; -use crate::objects::{MevaBlob, MevaCommit, MevaTree, TreeEntry}; +use crate::objects::{MevaBlob, MevaCommit, MevaTree, ObjectEntry, TreeEntry}; use crate::revision_parsing::RevisionResolver; + +use super::{DisplayMode, LsTreeEntry}; + use std::path::PathBuf; /// Handles the `ls-tree` command logic. @@ -20,8 +22,8 @@ pub struct LsTreeHandler { impl LsTreeHandler { /// Creates a new [`LsTreeHandler`] with the given repository layout. - pub fn new(repo_layout: Box) -> LsTreeHandler { - LsTreeHandler { repo_layout } + pub fn new(repo_layout: Box) -> Self { + Self { repo_layout } } /// Dispatches the `ls-tree` operation based on the provided [`Request`]. @@ -39,7 +41,7 @@ impl LsTreeHandler { commit_hash: String, include_trees: bool, recursive: bool, - ) -> EngineResult> { + ) -> EngineResult> { let object_storage = MevaObjectStorage::new(self.repo_layout.as_ref()); let commit_object = object_storage.get_object(&commit_hash)?; @@ -56,7 +58,7 @@ impl LsTreeHandler { /// Recursively traverses and lists the contents of a tree object. /// - /// Returns all matching [`LsTreeEntry`] objects, optionally including nested + /// Returns all matching [`ObjectEntry`] objects, optionally including nested /// directories and tree entries. /// /// # Errors @@ -68,8 +70,8 @@ impl LsTreeHandler { object_storage: &MevaObjectStorage, include_trees: bool, recursive: bool, - ) -> EngineResult> { - let mut listed_entries: Vec = Vec::new(); + ) -> EngineResult> { + let mut listed_entries: Vec = Vec::new(); let mut dirs_to_list: Vec = Vec::new(); let tree_object = object_storage.get_object(&tree_hash)?; let tree = MevaTree::try_from(tree_object)?; @@ -77,26 +79,26 @@ impl LsTreeHandler { match entry.entry_type { TreeEntryType::Tree => { if include_trees { - let ls_tree_entry = LsTreeEntry { + let object_entry = ObjectEntry { entry_type: entry.entry_type.clone(), hash: entry.object_hash.clone(), size: None, path: path.join(entry.name.clone()), }; - listed_entries.push(ls_tree_entry); + listed_entries.push(object_entry); } dirs_to_list.push(entry); } _ => { let blob_object = object_storage.get_object(&entry.object_hash)?; let blob = MevaBlob::try_from(blob_object)?; - let ls_tree_entry = LsTreeEntry { + let object_entry = ObjectEntry { entry_type: entry.entry_type, hash: entry.object_hash, size: Some(blob.size()), path: path.join(entry.name), }; - listed_entries.push(ls_tree_entry); + listed_entries.push(object_entry); } } } @@ -146,12 +148,10 @@ impl LsTreeOperations for LsTreeHandler { DisplayMode::Normal }; - let display_entries = listed_entries - .into_iter() - .map(|ls_entry| LsTreeDisplayEntry { - display_mode: display_mode.clone(), - ls_tree_entry: ls_entry, - }); + let display_entries = listed_entries.into_iter().map(|ls_entry| LsTreeEntry { + display_mode: display_mode.clone(), + object_entry: ls_entry, + }); Ok(Response { display_entries: display_entries.collect(), diff --git a/engine/src/handlers/ls_tree/ls_tree_entry.rs b/engine/src/handlers/ls_tree/ls_tree_entry.rs deleted file mode 100644 index 90e800a..0000000 --- a/engine/src/handlers/ls_tree/ls_tree_entry.rs +++ /dev/null @@ -1,86 +0,0 @@ -use crate::objects::tree_entry_type::TreeEntryType; -use std::fmt::Display; -use std::path::PathBuf; - -/// Represents a single entry displayed by the `ls-tree` command. -/// -/// Each entry corresponds to a file or directory stored in a specific commit tree. -/// It contains metadata necessary for display and inspection of repository contents. -/// -/// This structure is produced by [`LsTreeHandler`](crate::commands::ls_tree::LsTreeHandler) -/// when listing the contents of a tree object. -#[derive(Clone)] -pub struct LsTreeEntry { - /// The type of the entry (e.g., blob, tree). - pub entry_type: TreeEntryType, - - /// The object hash (SHA-1) of the underlying meva object. - pub hash: String, - - /// The size of the object in bytes (only for blobs). - pub size: Option, - - /// The path of the entry relative to the repository root. - pub path: PathBuf, -} - -/// Defines how entries should be displayed in the `ls-tree` command output. -/// -/// This determines the level of detail presented to the user. -#[derive(Clone)] -pub enum DisplayMode { - /// Shows only the entry paths. - NameOnly, - - /// Shows entry type, hash, and path. - Normal, - - /// Shows entry type, hash, size, and path (extended details). - Long, -} - -/// A wrapper for rendering [`LsTreeEntry`] values according to the selected [`DisplayMode`]. -/// -/// This type implements [`Display`] to produce human-readable output -pub struct LsTreeDisplayEntry { - /// The display mode controlling how the entry should be rendered. - pub display_mode: DisplayMode, - - /// The actual entry being displayed. - pub ls_tree_entry: LsTreeEntry, -} - -impl Display for LsTreeDisplayEntry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let entry = &self.ls_tree_entry; - - match self.display_mode { - DisplayMode::NameOnly => { - write!(f, "{}", entry.path.display()) - } - DisplayMode::Normal => { - write!( - f, - "{} {} {}", - entry.entry_type, - entry.hash, - entry.path.display() - ) - } - DisplayMode::Long => { - let size_str = match entry.size { - Some(s) => s.to_string(), - None => "-".to_string(), - }; - write!( - f, - "{} {} {:>8} {}", - entry.entry_type, - entry.hash, - size_str, - entry.path.display(), - ) - } - } - } -} diff --git a/engine/src/handlers/ls_tree/models.rs b/engine/src/handlers/ls_tree/models.rs new file mode 100644 index 0000000..98d9511 --- /dev/null +++ b/engine/src/handlers/ls_tree/models.rs @@ -0,0 +1,5 @@ +mod display_mode; +mod ls_tree_entry; + +pub use display_mode::DisplayMode; +pub use ls_tree_entry::LsTreeEntry; diff --git a/engine/src/handlers/ls_tree/models/display_mode.rs b/engine/src/handlers/ls_tree/models/display_mode.rs new file mode 100644 index 0000000..133adae --- /dev/null +++ b/engine/src/handlers/ls_tree/models/display_mode.rs @@ -0,0 +1,14 @@ +/// Defines how entries should be displayed in the `ls-tree` command output. +/// +/// This determines the level of detail presented to the user. +#[derive(Clone)] +pub enum DisplayMode { + /// Shows only the entry paths. + NameOnly, + + /// Shows entry type, hash, and path. + Normal, + + /// Shows entry type, hash, size, and path (extended details). + Long, +} diff --git a/engine/src/handlers/ls_tree/models/ls_tree_entry.rs b/engine/src/handlers/ls_tree/models/ls_tree_entry.rs new file mode 100644 index 0000000..a70e29b --- /dev/null +++ b/engine/src/handlers/ls_tree/models/ls_tree_entry.rs @@ -0,0 +1,51 @@ +use std::fmt::Display; + +use super::display_mode::DisplayMode; + +use crate::objects::ObjectEntry; + +/// A wrapper for rendering [`ObjectEntry`] values according to the selected [`DisplayMode`]. +/// +/// This type implements [`Display`] to produce human-readable output +pub struct LsTreeEntry { + /// The display mode controlling how the entry should be rendered. + pub display_mode: DisplayMode, + + /// The actual entry being displayed. + pub object_entry: ObjectEntry, +} + +impl Display for LsTreeEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let entry = &self.object_entry; + + match self.display_mode { + DisplayMode::NameOnly => { + write!(f, "{}", entry.path.display()) + } + DisplayMode::Normal => { + write!( + f, + "{} {} {}", + entry.entry_type, + entry.hash, + entry.path.display() + ) + } + DisplayMode::Long => { + let size_str = match entry.size { + Some(s) => s.to_string(), + None => "-".to_string(), + }; + write!( + f, + "{} {} {:>8} {}", + entry.entry_type, + entry.hash, + size_str, + entry.path.display(), + ) + } + } + } +} diff --git a/engine/src/handlers/ls_tree/operations.rs b/engine/src/handlers/ls_tree/operations.rs index a7f3e7b..a54c554 100644 --- a/engine/src/handlers/ls_tree/operations.rs +++ b/engine/src/handlers/ls_tree/operations.rs @@ -1,6 +1,8 @@ use crate::errors::EngineResult; -use crate::handlers::ls_tree::ls_tree_entry::LsTreeDisplayEntry; use crate::revision_parsing::Revision; + +use super::LsTreeEntry; + use std::path::PathBuf; /// Request object for the `ls-tree` operation. @@ -32,7 +34,7 @@ pub struct Request { /// Contains a list of formatted display entries ready for terminal output. pub struct Response { /// Entries formatted for display based on the chosen output mode. - pub display_entries: Vec, + pub display_entries: Vec, } /// Defines operations for listing tree or commit contents. diff --git a/engine/src/index.rs b/engine/src/index.rs index ca70885..9a78697 100644 --- a/engine/src/index.rs +++ b/engine/src/index.rs @@ -3,10 +3,12 @@ pub mod index_entry; pub mod meva_index; pub mod serde_utils; pub mod stage; +pub mod working_dir; use index_entry::IndexEntry; -use serde_utils::deserialize_entries_with_absolute_keys; +use serde_utils::{deserialize_entries, deserialize_entries_with_absolute_keys}; use stage::Stage; pub use file_mode::FileMode; pub use meva_index::MevaIndex; +pub use working_dir::WorkingDir; diff --git a/engine/src/index/file_mode.rs b/engine/src/index/file_mode.rs index 4edd77a..9691bba 100644 --- a/engine/src/index/file_mode.rs +++ b/engine/src/index/file_mode.rs @@ -1,14 +1,18 @@ use std::fmt; use std::fs; +use std::io::Error; +use std::io::ErrorKind; use std::path::Path; use serde::{Deserialize, Serialize}; +use crate::objects::tree_entry_type::TreeEntryType; + /// Represents the file mode used to describe how a file /// should be tracked in the repository. /// -/// These values are used to -/// differentiate between regular files, executables, symbolic links, +/// These values are used to differentiate between regular +/// files, executables, symbolic links, /// and submodules (gitlinks). #[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, PartialEq)] pub enum FileMode { @@ -32,6 +36,28 @@ impl fmt::Octal for FileMode { } } +impl TryFrom<&TreeEntryType> for FileMode { + type Error = Error; + + /// Converts a [`TreeEntryType`] into a corresponding [`FileMode`]. + /// + /// This conversion is only valid for blob and gitlink entry types. It will + /// return an error if the input is `TreeEntryType::Tree`, as directories + /// do not have a direct file mode representation in this context. + fn try_from(value: &TreeEntryType) -> Result { + match value { + TreeEntryType::BlobExecutable => Ok(FileMode::Executable), + TreeEntryType::BlobNormal => Ok(FileMode::Normal), + TreeEntryType::BlobSymlink => Ok(FileMode::Symlink), + TreeEntryType::CommitGitLink => Ok(FileMode::GitLink), + TreeEntryType::Tree => Err(Error::new( + ErrorKind::InvalidData, + "Cannot convert Tree entry type into a file mode", + )), + } + } +} + impl TryFrom<&Path> for FileMode { type Error = std::io::Error; @@ -39,11 +65,13 @@ impl TryFrom<&Path> for FileMode { /// based on its filesystem metadata. /// /// # Unix - /// On Unix platforms, the mode bits are inspected directly. + /// On Unix platforms, the mode bits are inspected directly to determine if the + /// file is a regular file, executable, symlink, or gitlink. /// /// # Windows - /// On Windows, executability is inferred from common executable file - /// extensions (`.exe`, `.bat`, `.cmd`, `.ps1`). + /// On Windows, where POSIX-style permissions are not native, executability + /// is inferred from common executable file extensions + /// (`.exe`, `.bat`, `.cmd`, `.ps1`). Other file types are treated as `Normal`. fn try_from(path: &Path) -> Result { let metadata = fs::metadata(path)?; @@ -81,6 +109,7 @@ impl TryFrom<&Path> for FileMode { FileMode::Normal }) } else { + // Directories and other types are not given a specific mode here. Ok(FileMode::Normal) } } diff --git a/engine/src/index/index_entry.rs b/engine/src/index/index_entry.rs index f4a90d0..4f3b180 100644 --- a/engine/src/index/index_entry.rs +++ b/engine/src/index/index_entry.rs @@ -26,6 +26,7 @@ pub struct IndexEntry { /// The file size in bytes. pub file_size: u64, + // TODO: poprawić ten komentarz, bo nieaktualny /// The SHA-1 object hash of the file's contents. /// /// Defaults to an empty string when the hash is not yet computed. diff --git a/engine/src/index/meva_index.rs b/engine/src/index/meva_index.rs index 5dd554f..c7bfca6 100644 --- a/engine/src/index/meva_index.rs +++ b/engine/src/index/meva_index.rs @@ -7,49 +7,69 @@ use std::{ use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use serde_json::Deserializer; -use walkdir::WalkDir; + +use super::{IndexEntry, WorkingDir, deserialize_entries, deserialize_entries_with_absolute_keys}; use crate::errors::{EngineResult, IndexError}; -use crate::index::{IndexEntry, deserialize_entries_with_absolute_keys}; use crate::object_storage::{MevaObjectStorage, ObjectStorage}; use crate::objects::MevaBlob; -use crate::{IgnoreOperations, IgnoreResult, IgnoreService, RepositoryLayout}; +use crate::{RepositoryLayout, diff::FileChange}; use shared::{CanonicalizeClean, PathToString, StripBase}; -/// Represents the in-memory index of tracked files in a Meva repository. +/// Represents the in-memory index (staging area) of tracked files in a Meva repository. /// -/// The index keeps metadata ([IndexEntry]) for each tracked file, and is +/// The index keeps metadata ([`IndexEntry`]) for each tracked file and is /// persisted in a JSON file on disk (the *index file*). It is responsible for: -/// - loading and saving the index, -/// - tracking new/modified/deleted files, -/// - respecting ignore rules, -/// - updating file hashes (SHA1). +/// - Loading from and saving to the index file. +/// - Tracking new, modified, and deleted files. +/// - Respecting ignore rules defined in `.mevaignore` files. +/// - Updating file hashes (SHA-1) and storing corresponding blob objects. pub struct MevaIndex<'a> { - /// Layout of the repository (provides paths to working dir, index file, etc.). + /// Layout of the repository, providing paths to the working directory, index file, etc. repo_layout: &'a dyn RepositoryLayout, - /// Map of tracked entries, keyed by their path (absolute or relative). + /// A helper for interacting with the working directory, including file collection and ignore rule processing. + working_dir: WorkingDir<'a>, + + /// Map of tracked entries, keyed by their path. + /// The path can be absolute when in memory and is converted to relative on save. entries: HashMap, } impl<'a> MevaIndex<'a> { /// Creates a new, empty `MevaIndex` bound to the given repository layout. + /// + /// # Arguments + /// + /// * `repo_layout` - A reference to the repository's layout configuration. pub fn new(repo_layout: &'a dyn RepositoryLayout) -> EngineResult { Ok(Self { repo_layout, + working_dir: WorkingDir::with_ignore_services(repo_layout), entries: HashMap::new(), }) } - /// Creates a new `MevaIndex` and immediately loads its state from disk. + /// Creates a new `MevaIndex` and immediately loads its state from the on-disk index file. /// - /// This is equivalent to calling [`MevaIndex::new`] followed by [`MevaIndex::load_from_disk`]. + /// This is a convenience method equivalent to calling [`MevaIndex::new`] followed by [`MevaIndex::load_from_disk`]. + /// + /// # Arguments + /// + /// * `repo_layout` - A reference to the repository's layout configuration. + /// * `with_absolute_keys` - If `true`, assumes paths in the index file are relative and converts them to absolute. + /// If `false`, loads paths as they are. Defaults to `true`. + /// + /// # Errors /// /// Returns an error if the index file cannot be read or parsed. - pub fn from_disk(repo_layout: &'a dyn RepositoryLayout) -> EngineResult { + pub fn from_disk( + repo_layout: &'a dyn RepositoryLayout, + with_absolute_keys: Option, + ) -> EngineResult { let mut meva_index = MevaIndex::new(repo_layout)?; - meva_index.load_from_disk()?; + meva_index.load_from_disk(with_absolute_keys)?; Ok(meva_index) } @@ -57,15 +77,45 @@ impl<'a> MevaIndex<'a> { /// Returns all entries currently tracked in the index. /// /// The returned vector contains borrowed references to the underlying - /// [`IndexEntry`] values, in unspecified order. + /// [`IndexEntry`] values in an unspecified order. pub fn get_entries(&self) -> Vec<&IndexEntry> { self.entries.values().collect() } - /// Loads the index file from disk into memory. + /// Returns a map of index entries, optionally filtered by a list of paths. + /// + /// # Arguments + /// + /// * `paths` - An optional slice of [`PathBuf`]s. If provided, only entries whose paths match + /// one of the allowed paths will be included. + /// + /// # Returns + /// + /// A [`HashMap`] where keys are the [`PathBuf`] of the entries and values are references to the [`IndexEntry`]. + pub fn get_entries_map(&self, paths: Option<&[PathBuf]>) -> HashMap { + self.entries + .iter() + .filter_map(|(key, entry)| { + let path = PathBuf::from(&key); + if let Some(allowed_paths) = paths + && !allowed_paths.iter().any(|p| path.starts_with(p)) + { + return None; + } + Some((path, entry)) + }) + .collect::>() + } + /// Loads the index file from disk into memory, populating the `entries` map. + /// + /// If the file does not exist, it will be created. If it is empty, the in-memory index will be cleared. /// - /// If the file does not exist or is empty, the index will be cleared. - pub fn load_from_disk(&mut self) -> EngineResult<()> { + /// # Arguments + /// + /// * `with_absolute_keys` - If `true`, paths are converted from relative (on disk) to absolute (in memory). + /// Defaults to `true`. + pub fn load_from_disk(&mut self, with_absolute_keys: Option) -> EngineResult<()> { + let with_absolute_keys = with_absolute_keys.unwrap_or(true); let mut file = OpenOptions::new() .write(true) .read(true) @@ -83,21 +133,33 @@ impl<'a> MevaIndex<'a> { let deserializer = &mut Deserializer::from_str(&data); - self.entries = - deserialize_entries_with_absolute_keys(deserializer, self.repo_layout.working_dir()) + match with_absolute_keys { + true => { + self.entries = deserialize_entries_with_absolute_keys( + deserializer, + self.repo_layout.working_dir(), + ) .map_err(io::Error::other)?; + } + false => { + self.entries = deserialize_entries(deserializer).map_err(io::Error::other)?; + } + } Ok(()) } /// Identifies tracked files that no longer exist in the working directory. /// - /// Returns a list of [`IndexEntry`] references representing deleted files. + /// It compares the list of files in the index with the actual files on the filesystem. + /// + /// # Returns + /// + /// A vector of [`IndexEntry`] references representing the deleted files. pub fn get_deleted_files(&self) -> Vec<&IndexEntry> { - let ignore_services = self.create_ignore_services(); let dir_path = self.repo_layout.working_dir(); - let dir_files = self.collect_files(dir_path, &ignore_services, false); + let dir_files = self.working_dir.collect_files(dir_path, false); let files_in_index = self .entries .iter() @@ -118,11 +180,17 @@ impl<'a> MevaIndex<'a> { /// Scans the working directory for files that are **not** currently tracked in the index. /// - /// Ignores paths that are filtered out by `.mevaignore` files. + /// This method respects the ignore rules defined in `.mevaignore` files, so ignored files + /// will not be reported as untracked unless they were explicitly tracked before. + /// + /// # Returns + /// + /// A vector of [`PathBuf`]s for each untracked file. pub fn get_untracked_files(&self) -> Vec { - let ignore_services = self.create_ignore_services(); let dir_path = self.repo_layout.working_dir(); - let dir_files = self.collect_all_files_including_ignored(dir_path, &ignore_services); + let dir_files = self + .working_dir + .collect_all_files_including_ignored(dir_path); let files_in_index = self .entries @@ -137,14 +205,22 @@ impl<'a> MevaIndex<'a> { .collect::>() } - /// Adds files to the index according to the provided flags: + /// Adds files to the index under a given path, respecting various flags. /// - /// - `add_new`: include new files, - /// - `add_deleted`: remove missing files, - /// - `add_ignored`: include ignored files (skip ignore check), - /// - `verbose`: print operations to stdout. + /// This is the core staging operation. It scans for files, categorizes them as new or modified, + /// handles deletions, updates hashes, and modifies the in-memory index. /// - /// The path provided (`path_abs`) is the starting point for traversal. + /// # Arguments + /// + /// * `path_abs` - The absolute path to a file or directory to add. + /// * `add_new` - If `true`, new (untracked) files will be added to the index. + /// * `add_deleted` - If `true`, files that are tracked but missing from the working directory will be removed. + /// * `add_ignored` - If `true`, ignore rules will be bypassed. + /// * `verbose` - If `true`, prints the name of each file being added or removed. + /// + /// # Returns + /// + /// A tuple containing vectors of paths for `(new_files, modified_files, deleted_files)`. pub fn add( &mut self, path_abs: &PathBuf, @@ -153,12 +229,7 @@ impl<'a> MevaIndex<'a> { add_ignored: bool, verbose: bool, ) -> EngineResult<(Vec, Vec, Vec)> { - let ignore_services = match add_ignored { - true => vec![], - false => self.create_ignore_services(), - }; - - let files_abs = self.collect_files(path_abs, &ignore_services, false); + let files_abs = self.working_dir.collect_files(path_abs, add_ignored); let (mut new_entries, mut modified_entries) = self.group_files(&files_abs)?; @@ -166,7 +237,7 @@ impl<'a> MevaIndex<'a> { let mut deleted_files = vec![]; let modified_files = modified_entries .iter() - .map(|e| PathBuf::from(e.path.clone())) + .map(|e| PathBuf::from(&e.path)) .collect(); if add_deleted { @@ -190,14 +261,17 @@ impl<'a> MevaIndex<'a> { Ok((new_files, modified_files, deleted_files)) } - /// Saves the in-memory index back to disk. + /// Saves the in-memory index back to the index file on disk. /// - /// - Converts absolute paths in entries into repository-relative paths. - /// - Writes the index as a pretty-printed JSON array. + /// Before writing, it converts all absolute paths in the entries into paths + /// relative to the repository's working directory. The data is then serialized + /// as a pretty-printed JSON array. /// - /// Returns an error if: - /// - the index file cannot be found, - /// - a path in the index lies outside the repository root. + /// # Errors + /// + /// - [`IndexError::IndexFileNotFound`]: if the index file does not exist. + /// - Fails if a path in the index lies outside the repository root. + /// - Fails if serialization or file writing fails. pub fn save(&mut self) -> EngineResult<()> { if !self.repo_layout.index_file().exists() { return Err(IndexError::IndexFileNotFound { @@ -221,12 +295,17 @@ impl<'a> MevaIndex<'a> { Ok(()) } - /// Removes and returns entries from the index corresponding to files that no longer exist - /// in the working directory. + /// Removes entries from the index that correspond to files no longer present on the filesystem. + /// + /// # Arguments + /// + /// * `dir_path` - The directory path to check for deletions. + /// * `dir_files` - A slice of paths to files that are known to still exist. + /// * `verbose` - If `true`, prints the path of each removed file. /// - /// - `dir_path`: the directory being checked, - /// - `dir_files`: list of still-existing files, - /// - `verbose`: whether to print removed paths to stdout. + /// # Returns + /// + /// A vector of strings containing the paths of the removed files. fn remove_deleted_files( &mut self, dir_path: &PathBuf, @@ -260,9 +339,16 @@ impl<'a> MevaIndex<'a> { deleted_files } - /// Updates or inserts index entries with recalculated SHA1 values. + /// Updates or inserts a list of index entries, calculating their SHA-1 hashes and storing blobs. + /// + /// This function processes the provided entries in parallel to speed up file reading and hashing. + /// For each entry, it creates a [`MevaBlob`], adds it to the object store, gets the resulting hash, + /// and updates the entry. Finally, it inserts the updated entry into the in-memory index. /// - /// The given list of entries is processed in parallel to speed up hashing. + /// # Arguments + /// + /// * `index_entries` - A mutable vector of [`IndexEntry`]s to process. + /// * `verbose` - If `true`, prints the path of each added file. fn update_index( &mut self, index_entries: &mut Vec, @@ -290,11 +376,19 @@ impl<'a> MevaIndex<'a> { Ok(()) } - /// Groups files into: - /// - new (not yet in the index), - /// - modified (metadata differs from index entry). + /// Groups a list of files into two categories: new or modified. + /// + /// A file is considered **modified** if it's already in the index but its + /// modification time (`mtime`) or file size differs from the stored entry. + /// A file is considered **new** if it is not present in the index. /// - /// Returns a tuple `(new_files, modified_files)`. + /// # Arguments + /// + /// * `files` - A vector of [`PathBuf`]s to analyze. + /// + /// # Returns + /// + /// A tuple containing two vectors of [`IndexEntry`]s: `(new_files, modified_files)`. fn group_files( &self, files: &Vec, @@ -306,7 +400,13 @@ impl<'a> MevaIndex<'a> { let new_entry = IndexEntry::from_path_without_hash(file_path)?; if let Some(old) = self.entries.get(&new_entry.path) { - let modified = old.mtime != new_entry.mtime || old.file_size != new_entry.file_size; + // Heuristic check for modification to avoid re-hashing unchanged files. + let modified = FileChange::is_modified_heuristic( + new_entry.file_size, + &new_entry.mtime, + old.file_size, + &old.mtime, + ); if modified { modified_files.push(new_entry); @@ -318,77 +418,4 @@ impl<'a> MevaIndex<'a> { Ok((new_files, modified_files)) } - - /// Creates all ignore services (`IgnoreService`) for the repository. - /// - /// This scans the working directory downward for ignore files and builds - /// services with cached glob patterns. - fn create_ignore_services(&self) -> Vec { - let ignore_service = IgnoreService::new(self.repo_layout.ignore_file_name()); - let ignore_files_abs: Vec = - ignore_service.find_ignore_files_down(self.repo_layout.working_dir()); - - ignore_files_abs - .iter() - .filter_map(|path| { - IgnoreService::with_cache(self.repo_layout.ignore_file_name(), Some(path)).ok() - }) - .collect() - } - - #[allow(dead_code)] - /// Collects both tracked and untracked files under the given path. - /// - /// Applies ignore rules via `ignore_services`. - fn collect_tracked_or_untracked_files( - &self, - path: &Path, - ignore_services: &[IgnoreService], - ) -> Vec { - self.collect_files(path, ignore_services, false) - } - - /// Collects **all files**, including those explicitly ignored. - fn collect_all_files_including_ignored( - &self, - path: &Path, - ignore_services: &[IgnoreService], - ) -> Vec { - self.collect_files(path, ignore_services, true) - } - - /// Recursively traverses a directory, collecting file paths. - /// - /// - Applies ignore rules from `ignore_services` (unless `include_ignored` is `true`). - /// - Excludes the internal `.meva/` repository directory. - /// - Returns only regular files. - fn collect_files( - &self, - path: &Path, - ignore_services: &[IgnoreService], - include_ignored: bool, - ) -> Vec { - WalkDir::new(path) - .into_iter() - .filter_entry(|e| { - // Skip the repository's internal directory - if e.file_type().is_dir() && e.path() == self.repo_layout.repository_dir() { - return false; - } - - for ignore_service in ignore_services { - match ignore_service.check_cached(&e.path()) { - Ok(IgnoreResult::Ignored { .. }) => return include_ignored, - Ok(IgnoreResult::NotIgnored { .. }) => {} - Err(_) => return true, - } - } - - true - }) - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file()) - .map(|e| e.path().to_path_buf()) - .collect() - } } diff --git a/engine/src/index/serde_utils.rs b/engine/src/index/serde_utils.rs index 67fcb3e..c7bfbb9 100644 --- a/engine/src/index/serde_utils.rs +++ b/engine/src/index/serde_utils.rs @@ -61,3 +61,31 @@ where }) .collect::, D::Error>>() } + +/// Deserializes a list of [`IndexEntry`] objects into a map keyed by their paths. +/// +/// This function assumes the paths within the entries are already in the desired +/// format (e.g., absolute or relative) and does not modify them. +/// +/// # Arguments +/// +/// * `deserializer` - The Serde deserializer providing a JSON array of index entries. +/// +/// # Returns +/// +/// A [`HashMap`] where: +/// - the key is the file path as a `String` (taken directly from the entry), +/// - the value is the corresponding [`IndexEntry`]. +/// +/// # Errors +/// +/// Returns a Serde [`D::Error`] if deserialization or collection fails. +pub fn deserialize_entries<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let list: Vec = Vec::deserialize(deserializer)?; + list.into_iter() + .map(|ie| Ok((ie.path.clone(), ie))) + .collect::, D::Error>>() +} diff --git a/engine/src/index/working_dir.rs b/engine/src/index/working_dir.rs new file mode 100644 index 0000000..56bcff0 --- /dev/null +++ b/engine/src/index/working_dir.rs @@ -0,0 +1,231 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use chrono::{DateTime, Utc}; +use shared::StripBase; +use walkdir::WalkDir; + +use crate::{ + IgnoreOperations, IgnoreResult, IgnoreService, RepositoryLayout, errors::EngineResult, + index::FileMode, +}; + +/// Represents metadata for a single file entry in the working directory. +#[derive(Debug, Clone)] +pub struct WorkingDirEntry { + /// The relative path of the file within the working directory. + pub path: PathBuf, + /// The last modification timestamp of the file. + pub mtime: DateTime, + /// The size of the file in bytes. + pub file_size: u64, + /// The file's mode, indicating if it's a regular file, executable, etc. + pub mode: FileMode, +} + +/// Provides an interface to the repository's working directory. +/// +/// This struct is responsible for collecting files, applying ignore rules, +/// and gathering file metadata from the filesystem. +pub struct WorkingDir<'a> { + /// Layout of the repository, providing paths to the working directory, index file, etc. + repo_layout: &'a dyn RepositoryLayout, + + /// A collection of `IgnoreService` instances, each corresponding to a found `.mevaignore` file. + ignore_services: Vec, +} + +impl<'a> WorkingDir<'a> { + /// Creates a new `WorkingDir` instance without initializing ignore services. + /// + /// # Arguments + /// + /// * `repo_layout` - A reference to the repository's layout. + pub fn new(repo_layout: &'a dyn RepositoryLayout) -> Self { + Self { + repo_layout, + ignore_services: Vec::new(), + } + } + + /// Creates a new `WorkingDir` and initializes its ignore services by scanning + /// the repository for `.mevaignore` files. + /// + /// # Arguments + /// + /// * `repo_layout` - A reference to the repository's layout. + pub fn with_ignore_services(repo_layout: &'a dyn RepositoryLayout) -> Self { + Self { + repo_layout, + ignore_services: Self::create_ignore_services(repo_layout), + } + } + + /// Returns a reference to the vector of initialized `IgnoreService` instances. + pub fn get_ignore_services(&self) -> &Vec { + &self.ignore_services + } + + /// Returns the absolute path to the root of the working directory. + pub fn get_absolute_path(&self) -> &Path { + self.repo_layout.working_dir() + } + + /// Collects all files under the given path, respecting ignore rules. + /// This is a convenience method that delegates to `collect_files`. + /// + /// # Arguments + /// + /// * `path` - The starting path for file collection. + #[allow(dead_code)] + pub fn collect_tracked_or_untracked_files(&self, path: &Path) -> Vec { + self.collect_files(path, false) + } + + /// Collects **all files** under the given path, including those that would + /// normally be ignored by `.mevaignore` files. + /// + /// # Arguments + /// + /// * `path` - The starting path for file collection. + pub fn collect_all_files_including_ignored(&self, path: &Path) -> Vec { + self.collect_files(path, true) + } + + /// Recursively traverses a directory and collects the paths of all files it contains. + /// + /// This method performs two main filtering operations: + /// 1. It always excludes the internal `.meva/` repository directory. + /// 2. It applies ignore rules from the initialized `ignore_services` unless `include_ignored` is `true`. + /// + /// # Arguments + /// + /// * `path` - The root path from which to start collecting files. + /// * `include_ignored` - If `true`, files matching patterns in `.mevaignore` will be included. + /// + /// # Returns + /// + /// A vector of [`PathBuf`]s, each representing an absolute path to a file. + pub fn collect_files(&self, path: &Path, include_ignored: bool) -> Vec { + self.get_filtered_entries(path, include_ignored) + .map(|e| e.path().to_path_buf()) + .collect() + } + + /// Collects files from the working directory along with their metadata. + /// + /// This method traverses the working directory, filters files based on ignore rules and an + /// optional path list, and gathers metadata for each valid file. + /// + /// # Arguments + /// + /// * `include_ignored` - If `true`, ignored files will be included in the result. + /// * `paths` - An optional slice of relative paths to specifically include. If `Some`, only + /// files matching one of these paths will be returned. + /// + /// # Returns + /// + /// An [`EngineResult`] containing a [`HashMap`] that maps a file's relative path to its + /// [`WorkingDirEntry`] metadata. + pub fn collect_files_with_metadata( + &self, + include_ignored: bool, + paths: Option<&[PathBuf]>, + ) -> EngineResult> { + let base_path = self.repo_layout.working_dir(); + let mut result = HashMap::new(); + + let entries = self.get_filtered_entries(base_path, include_ignored); + + for entry in entries { + let meta = entry.metadata()?; + let abs_path = entry.path(); + let rel_path = abs_path.strip_base(base_path).to_path_buf(); + + // If a path filter is provided, skip files not in the list. + if let Some(allowed_paths) = paths + && !allowed_paths.iter().any(|p| rel_path.starts_with(p)) + { + continue; + } + + result.insert( + rel_path.clone(), + WorkingDirEntry { + path: rel_path, + mtime: meta.modified()?.into(), + file_size: meta.len(), + mode: FileMode::try_from(abs_path)?, + }, + ); + } + + Ok(result) + } + + /// Creates and returns a configured iterator over directory entries. + /// + /// This iterator is pre-filtered to: + /// 1. Always skip the internal repository directory (`.meva/`). + /// 2. Optionally skip files ignored by `.mevaignore` rules. + /// 3. Yield only entries that are files (not directories). + fn get_filtered_entries( + &self, + path: &Path, + include_ignored: bool, + ) -> impl Iterator { + WalkDir::new(path) + .into_iter() + .filter_entry(move |e| { + // Always skip the repository's internal directory. + let is_repo_dir = + e.file_type().is_dir() && e.path() == self.repo_layout.repository_dir(); + let should_include = include_ignored || !self.is_path_ignored(e.path()); + + !is_repo_dir && should_include + }) + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + } + + /// Checks if a given path is ignored by any of the active ignore services. + /// + /// # Returns + /// + /// `true` if the path matches an ignore pattern, `false` otherwise. + fn is_path_ignored(&self, path: &Path) -> bool { + self.ignore_services.iter().any(|service| { + matches!( + service.check_cached(&path), + Ok(IgnoreResult::Ignored { .. }) + ) + }) + } + + /// Discovers all `.mevaignore` files within the repository and creates an `IgnoreService` for each. + /// + /// This function scans the entire working directory for files matching the ignore file name + /// and initializes a cached service for each one found. + /// + /// # Arguments + /// + /// * `repo_layout` - The repository layout, used to get the working directory path and ignore file name. + /// + /// # Returns + /// + /// A vector of initialized [`IgnoreService`] instances. + fn create_ignore_services(repo_layout: &'a dyn RepositoryLayout) -> Vec { + let ignore_service = IgnoreService::new(repo_layout.ignore_file_name()); + let ignore_files_abs: Vec = + ignore_service.find_ignore_files_down(repo_layout.working_dir()); + + ignore_files_abs + .iter() + .filter_map(|path| { + IgnoreService::with_cache(repo_layout.ignore_file_name(), Some(path)).ok() + }) + .collect() + } +} diff --git a/engine/src/objects.rs b/engine/src/objects.rs index b011d05..ef36b3b 100644 --- a/engine/src/objects.rs +++ b/engine/src/objects.rs @@ -4,6 +4,7 @@ pub mod meva_object; pub mod meva_object_decode_context; pub mod meva_object_type; pub mod meva_tree; +pub mod object_entry; pub mod person; pub mod tree_entry; pub mod tree_entry_type; @@ -14,5 +15,6 @@ pub use meva_object::MevaObject; pub use meva_object_decode_context::MevaObjectDecodeContext; pub use meva_object_type::MevaObjectType; pub use meva_tree::MevaTree; +pub use object_entry::ObjectEntry; pub use person::Person; pub use tree_entry::TreeEntry; diff --git a/engine/src/objects/meva_blob.rs b/engine/src/objects/meva_blob.rs index ac3f3ab..aadef97 100644 --- a/engine/src/objects/meva_blob.rs +++ b/engine/src/objects/meva_blob.rs @@ -2,6 +2,7 @@ use crate::errors::{EngineError, EngineResult, UnpackError}; use crate::objects::{MevaObject, MevaObjectType}; use crate::serialize_deserialize::meva_encode::MevaEncode; use bincode::{Decode, Encode}; +use chardetng::EncodingDetector; use std::fs::OpenOptions; use std::io::Read; use std::path::Path; @@ -9,18 +10,22 @@ use std::path::Path; /// Represents a binary object containing raw file data. /// /// A [`MevaBlob`] stores the unstructured contents of a file -/// (as a sequence of bytes). It can be created directly from a +/// as a sequence of bytes. It can be created directly from a /// byte vector or by reading a file from disk. /// /// Blobs are serialized into bytes for storage and can be wrapped /// into a [`MevaObject`] of type [`MevaObjectType::Blob`]. -#[derive(Encode, Decode)] +#[derive(Debug, Encode, Decode)] pub struct MevaBlob { /// The raw file contents stored as bytes. pub data: Vec, } impl MevaBlob { /// Creates a new [`MevaBlob`] from the given raw byte data. + /// + /// # Arguments + /// + /// * `data` - A vector of bytes representing the file's content. pub fn new(data: Vec) -> Self { Self { data } } @@ -29,8 +34,11 @@ impl MevaBlob { /// /// # Errors /// - /// Returns an [`EngineError`](crate::errors::EngineError) if the file - /// cannot be opened or read. + /// Returns an [`EngineError`] if the file cannot be opened or read. + /// + /// # Arguments + /// + /// * `path` - The path to the file to be read. pub fn from_file(path: &Path) -> EngineResult { let mut file = OpenOptions::new().read(true).open(path)?; let mut buffer = Vec::new(); @@ -43,18 +51,37 @@ impl MevaBlob { pub fn size(&self) -> usize { self.data.len() } + + /// Attempts to decode the blob's raw byte data into a UTF-8 string. + /// + /// It uses character encoding detection to guess the most likely encoding + /// of the byte data and then decodes it. + /// + /// # Errors + /// + /// Returns an [`EngineError`] if the decoding process fails, which can happen + /// if the detected encoding is incorrect or the byte sequence is invalid for that encoding. + pub fn text_content(&self) -> EngineResult { + let mut detector = EncodingDetector::new(); + detector.feed(&self.data, true); + let encoding = detector.guess(None, true); + let (decoded, _, had_errors) = encoding.decode(&self.data); + if had_errors { + return Err(EngineError::Unknown("Failed to decode data".to_string())); + } + + Ok(decoded.into_owned()) + } } -/// Converts a `MevaObject` into a `MevaBlob`. -/// -/// # Behavior +/// Implements the conversion from a generic [`MevaObject`] to a specific [`MevaBlob`]. /// -/// - If the object's type is `Blob`, it deserializes the raw data into a `MevaBlob`. -/// - If the object's type is not `Blob`, returns an `UnpackError::ObjectTypeMismatch`. +/// This is a fallible conversion that succeeds only if the object's type is `Blob`. /// /// # Errors /// -/// Returns an [`EngineError`] if deserialization fails or the type does not match. +/// - Returns an [`UnpackError::ObjectTypeMismatch`] if the object's type is not [`MevaObjectType::Blob`]. +/// - Returns a deserialization error if the object's raw data cannot be decoded into a `MevaBlob`. impl TryFrom for MevaBlob { type Error = EngineError; fn try_from(value: MevaObject) -> Result { @@ -69,4 +96,5 @@ impl TryFrom for MevaBlob { } } +/// Provides binary serialization and deserialization capabilities for `MevaBlob`. impl MevaEncode for MevaBlob {} diff --git a/engine/src/objects/object_entry.rs b/engine/src/objects/object_entry.rs new file mode 100644 index 0000000..67f1ab8 --- /dev/null +++ b/engine/src/objects/object_entry.rs @@ -0,0 +1,21 @@ +use std::path::PathBuf; + +use crate::objects::tree_entry_type::TreeEntryType; + +/// Each entry corresponds to a file or directory stored in a specific commit tree. +/// +/// It contains metadata necessary for display and inspection of repository contents. +#[derive(Debug, Clone)] +pub struct ObjectEntry { + /// The type of the entry (e.g., blob, tree). + pub entry_type: TreeEntryType, + + /// The object hash (SHA-1) of the underlying meva object. + pub hash: String, + + /// The size of the object in bytes (only for blobs). + pub size: Option, + + /// The path of the entry relative to the repository root. + pub path: PathBuf, +} diff --git a/engine/src/revision_parsing/revision_resolver.rs b/engine/src/revision_parsing/revision_resolver.rs index 9d80b53..916052a 100644 --- a/engine/src/revision_parsing/revision_resolver.rs +++ b/engine/src/revision_parsing/revision_resolver.rs @@ -1,6 +1,6 @@ use super::{Revision, base_reference::BaseReference, revision_modifier::RevisionModifier}; use crate::RepositoryLayout; -use crate::errors::{EngineResult, RevisionResolverError}; +use crate::errors::{EngineResult, RevisionError}; use crate::object_storage::{MevaObjectStorage, ObjectStorage}; use crate::objects::{MevaCommit, MevaObject}; use crate::ref_manager::{MevaRefManager, RefManager}; @@ -36,7 +36,7 @@ impl<'a> RevisionResolver<'a> { /// /// # Returns /// - `Ok(String)` containing the resolved commit hash. - /// - `Err(RevisionResolverError)` if the revision cannot be resolved, + /// - `Err(RevisionError)` if the revision cannot be resolved, /// for example if a ref or `HEAD` does not exist, or a parent index is out of range. pub fn resolve_hash(&self, revision: &Revision) -> EngineResult { let base_hash = self.resolve_base(&revision.base)?; @@ -50,7 +50,7 @@ impl<'a> RevisionResolver<'a> { /// /// # Returns /// - `Ok(MevaObject)` containing the object the revision points to. - /// - `Err(RevisionResolverError)` if the revision cannot be resolved + /// - `Err(RevisionError)` if the revision cannot be resolved /// or if retrieving the object fails. pub fn resolve_object(&self, revision: &Revision) -> EngineResult { let hash = self.resolve_hash(revision)?; @@ -73,7 +73,7 @@ impl<'a> RevisionResolver<'a> { let object = self.object_storage.get_object(&hash)?; let commit = MevaCommit::try_from(object)?; hash = commit.parents.get(val - 1).cloned().ok_or( - RevisionResolverError::ParentOutOfRange { + RevisionError::ParentOutOfRange { commit: hash, index: val - 1, }, @@ -84,7 +84,7 @@ impl<'a> RevisionResolver<'a> { let object = self.object_storage.get_object(&hash)?; let commit = MevaCommit::try_from(object)?; hash = commit.parents.first().cloned().ok_or( - RevisionResolverError::ParentOutOfRange { + RevisionError::ParentOutOfRange { commit: hash, index: 0, }, @@ -105,7 +105,7 @@ impl<'a> RevisionResolver<'a> { BaseReference::Hash(val) => val.clone(), BaseReference::Head => { let hash = self.ref_manager.resolve_head()?; - hash.ok_or(RevisionResolverError::HeadNotFound)? + hash.ok_or(RevisionError::HeadNotFound)? } BaseReference::Ref(ref_val) => { let refs = vec![ @@ -125,7 +125,7 @@ impl<'a> RevisionResolver<'a> { let entry = refs .into_iter() .find_map(|r| r.ok().flatten()) - .ok_or_else(|| RevisionResolverError::RevisionNotFound(ref_val.to_string()))?; + .ok_or_else(|| RevisionError::RevisionNotFound(ref_val.to_string()))?; entry.commit_hash } From f55b0b2952741d71933eee70c51964389ead71eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Tue, 21 Oct 2025 07:18:36 +0200 Subject: [PATCH 14/42] Command `show` (#14) --- Cargo.lock | 1 + Cargo.toml | 1 + cli/Cargo.toml | 2 +- cli/src/commands/show.rs | 45 +++++----- engine/Cargo.toml | 1 + engine/src/config/config_loader.rs | 15 ++-- engine/src/diff/diff_builder.rs | 87 ++++++++++++++++--- engine/src/diff/models/change_kind.rs | 60 +++++-------- engine/src/diff/models/diff_mode.rs | 14 +++ engine/src/diff/models/diff_stat.rs | 22 ++++- engine/src/diff/models/file_change.rs | 64 ++++++++++---- engine/src/diff/models/file_diff_stat.rs | 15 ++-- engine/src/diff/models/hunk.rs | 29 ++++--- engine/src/engine_container.rs | 10 +-- engine/src/handlers/commit/handlers.rs | 4 +- engine/src/handlers/commit/operations.rs | 1 + engine/src/handlers/show/handlers.rs | 61 +++++++++++-- engine/src/handlers/show/models/snapshot.rs | 29 +++++-- engine/src/handlers/show/operations.rs | 2 +- .../handlers/status/models/status_entry.rs | 16 +--- 20 files changed, 330 insertions(+), 149 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 75d6f9f..47df172 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -417,6 +417,7 @@ dependencies = [ "globset", "hex", "mockall", + "owo-colors", "plugins", "pretty_assertions", "rayon", diff --git a/Cargo.toml b/Cargo.toml index a17275c..1dd8d77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,3 +25,4 @@ strum = "0.27" strum_macros = "0.27" regex = "1.11.3" similar = "2.7.0" +owo-colors = "4.2.3" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index a7eb585..d5c366d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -18,7 +18,7 @@ miette = { version = "7.6.0", features = ["fancy"] } globset.workspace = true strum.workspace = true strum_macros.workspace = true -owo-colors = "4.2.3" +owo-colors.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/cli/src/commands/show.rs b/cli/src/commands/show.rs index e00ab72..30a53f6 100644 --- a/cli/src/commands/show.rs +++ b/cli/src/commands/show.rs @@ -1,8 +1,9 @@ use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command, value_parser}; use engine::{ - EngineContainer, + diff::ChangeKind, engine_container::MevaContainer, - handlers::show::{PatchMode, Request, Response}, + handlers::show::{PatchMode, Request, Response, ShowHandler}, + repositories::meva_repository_layout::MevaRepositoryLayout, revision_parsing::Revision, }; use miette::{IntoDiagnostic, Result}; @@ -64,9 +65,7 @@ impl ShowCommand { /// Shows unified diffs or per-file diffs. fn display_patch(&self, response: &Response) { - if let Some(diff) = &response.unified_diff { - println!("{diff}"); - } else if let Some(files) = &response.files { + if let Some(files) = &response.files { for file in files { file.display_full(); } @@ -76,7 +75,6 @@ impl ShowCommand { /// Prints only filenames. fn display_name_only(&self, response: &Response) { if let Some(files) = &response.files { - println!(); for file in files { println!("{}", file.path().display()); } @@ -85,12 +83,14 @@ impl ShowCommand { /// Prints filenames with change kinds. fn display_name_status(&self, response: &Response) { - if let Some(_files) = &response.files { - println!(); - // TODO: fixes in next PR - // for file in files { - // println!("{}\t{}", file.kind, file.new_path.display()); - // } + if let Some(files) = &response.files { + for file in files { + println!( + "{}\t{}", + ChangeKind::from(&file.kind), + file.path().display() + ); + } } } @@ -179,25 +179,22 @@ impl MevaCommand for ShowCommand { } /// Executes the `show` command. - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> Result<()> { let snapshot_id = matches.get_one::(Self::ARG_SNAPSHOT_ID).unwrap(); - let patch = matches.get_flag(Self::ARG_PATCH); - let no_patch = matches.get_flag(Self::ARG_NO_PATCH); - let name_only = matches.get_flag(Self::ARG_NAME_ONLY); - let name_status = matches.get_flag(Self::ARG_NAME_STATUS); - let stat = matches.get_flag(Self::ARG_STAT); - let handler = container.show_handler().into_diagnostic()?; + // let handler = container.show_handler().into_diagnostic()?; + let layout = MevaRepositoryLayout::discover().into_diagnostic()?; + let handler = ShowHandler::new(&layout).into_diagnostic()?; - let mode = if patch { + let mode = if matches.get_flag(Self::ARG_PATCH) { PatchMode::Patch - } else if no_patch { + } else if matches.get_flag(Self::ARG_NO_PATCH) { PatchMode::NoPatch - } else if name_only { + } else if matches.get_flag(Self::ARG_NAME_ONLY) { PatchMode::NameOnly - } else if name_status { + } else if matches.get_flag(Self::ARG_NAME_STATUS) { PatchMode::NameStatus - } else if stat { + } else if matches.get_flag(Self::ARG_STAT) { PatchMode::Stat } else { PatchMode::Patch diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 0f3b924..b30cd57 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -27,6 +27,7 @@ encoding_rs = "0.8.35" chardetng = "0.1.17" tree-ds = "0.2.0" similar.workspace = true +owo-colors.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/engine/src/config/config_loader.rs b/engine/src/config/config_loader.rs index e645b82..c99e097 100644 --- a/engine/src/config/config_loader.rs +++ b/engine/src/config/config_loader.rs @@ -49,7 +49,7 @@ impl ConfigLoader { let mut last_key_not_found = None; for loc in &self.locations { - match self.try_load_from(loc, key_path, default) { + match self.try_load_from(loc, key_path, None) { Ok(val) => { return Ok(val); } @@ -67,11 +67,14 @@ impl ConfigLoader { } } - Err(last_key_not_found.unwrap_or_else(|| { - EngineError::Config(ConfigError::KeyNotFound { - key: key_path.to_string(), - }) - })) + match default { + Some(default) => Ok(default.clone()), + None => Err(last_key_not_found.unwrap_or_else(|| { + EngineError::Config(ConfigError::KeyNotFound { + key: key_path.to_string(), + }) + })), + } } /// Retrieve a configuration value and parse it into a specific type. diff --git a/engine/src/diff/diff_builder.rs b/engine/src/diff/diff_builder.rs index eb7a522..a248c3b 100644 --- a/engine/src/diff/diff_builder.rs +++ b/engine/src/diff/diff_builder.rs @@ -86,14 +86,56 @@ impl<'a> DiffBuilder<'a> { paths: Option<&[PathBuf]>, ) -> EngineResult> { let from_commit = self.get_commit(from)?; - let from_objects = self.collect_object_entries(PathBuf::new(), from_commit.tree, paths)?; + let from_objects = self.collect_object_entries(PathBuf::new(), &from_commit.tree, paths)?; let to_commit = self.get_commit(to)?; - let to_objects = self.collect_object_entries(PathBuf::new(), to_commit.tree, paths)?; + let to_objects = self.collect_object_entries(PathBuf::new(), &to_commit.tree, paths)?; self.diff_tree_vs_tree(&from_objects, &to_objects, mode) } + /// Computes the difference between a commit and its first parent. + /// If the commit has no parents (an initial commit), it shows all files as added. + /// + /// # Arguments + /// + /// * `child` - The revision of the commit to analyze. + /// * `mode` - The level of detail for the diff. + /// * `include_changes` - If `false`, skips the diff calculation and returns an empty vector of changes. + /// + /// # Returns + /// + /// A tuple containing the resolved child commit, its hash, and a vector of file changes. + pub fn diff_snapshot_with_parent( + &self, + child: &Revision, + mode: &DiffMode, + include_changes: bool, + ) -> EngineResult<(MevaCommit, String, Vec)> { + let (child_commit, child_hash) = self.get_commit_with_hash(child)?; + if !include_changes { + return Ok((child_commit, child_hash, Vec::new())); + } + + let child_objects = + self.collect_object_entries(PathBuf::new(), &child_commit.tree, None)?; + + let files = match &child_commit.parents.as_slice() { + [parent, ..] => { + let parent_commit = self.get_commit_by_hash(parent)?; + let parent_objects = + self.collect_object_entries(PathBuf::new(), &parent_commit.tree, None)?; + self.diff_tree_vs_tree(&parent_objects, &child_objects, mode) + } + _ => { + let added = child_objects.values().collect::>(); + self.added_to_file_changes(added, mode) + } + }?; + + Ok((child_commit, child_hash, files)) + } + /// Computes the difference between a repository snapshot and the working directory. /// /// # Arguments @@ -116,7 +158,7 @@ impl<'a> DiffBuilder<'a> { let mut modified = Vec::new(); let from_commit = self.get_commit(from)?; - let from_objects = self.collect_object_entries(PathBuf::new(), from_commit.tree, paths)?; + let from_objects = self.collect_object_entries(PathBuf::new(), &from_commit.tree, paths)?; let base_path = self.working_dir.get_absolute_path(); let working = self.working_dir.collect_files_with_metadata(false, paths)?; @@ -236,7 +278,7 @@ impl<'a> DiffBuilder<'a> { paths: Option<&[PathBuf]>, ) -> EngineResult> { let from_commit = self.get_commit(from)?; - let from_objects = self.collect_object_entries(PathBuf::new(), from_commit.tree, paths)?; + let from_objects = self.collect_object_entries(PathBuf::new(), &from_commit.tree, paths)?; let to_objects = self.collect_object_entries_from_index(paths); @@ -373,7 +415,21 @@ impl<'a> DiffBuilder<'a> { Ok(all) } - fn diff_tree_vs_tree( + /// Computes the difference between two tree-like structures represented by object entry maps. + /// + /// This is a core diffing utility that takes two flattened representations of repository trees + /// and produces a vector of file changes. + /// + /// # Arguments + /// + /// * `from_objects` - A map of file paths to object entries representing the "old" state. + /// * `to_objects` - A map of file paths to object entries representing the "new" state. + /// * `mode` - The level of detail for the diff. + /// + /// # Returns + /// + /// A [`EngineResult`] containing a vector of [`FileChange`] objects. + pub fn diff_tree_vs_tree( &self, from_objects: &HashMap, to_objects: &HashMap, @@ -578,14 +634,14 @@ impl<'a> DiffBuilder<'a> { /// # Returns /// /// A [`EngineResult`] with a [`HashMap`] mapping file paths to their [`ObjectEntry`] data. - pub fn collect_object_entries( + fn collect_object_entries( &self, path: PathBuf, - tree_hash: String, + tree_hash: &str, paths: Option<&[PathBuf]>, ) -> EngineResult> { let mut collected: HashMap = HashMap::new(); - let tree_object = self.object_storage.get_object(&tree_hash)?; + let tree_object = self.object_storage.get_object(tree_hash)?; let tree = MevaTree::try_from(tree_object)?; for entry in tree.tree_entries { @@ -593,7 +649,7 @@ impl<'a> DiffBuilder<'a> { TreeEntryType::Tree => { let inner = self.collect_object_entries( path.join(&entry.name), - entry.object_hash, + &entry.object_hash, paths, )?; collected.extend(inner); @@ -625,7 +681,18 @@ impl<'a> DiffBuilder<'a> { /// Resolves a revision to a commit hash and retrieves the corresponding [`MevaCommit`] object. fn get_commit(&self, revision: &Revision) -> EngineResult { let hash = self.revision_resolver.resolve_hash(revision)?; - let commit_object = self.object_storage.get_object(&hash)?; + self.get_commit_by_hash(&hash) + } + + /// Resolves a revision to a commit object and its corresponding hash. + fn get_commit_with_hash(&self, revision: &Revision) -> EngineResult<(MevaCommit, String)> { + let hash = self.revision_resolver.resolve_hash(revision)?; + Ok((self.get_commit_by_hash(&hash)?, hash)) + } + + /// Retrieves a commit object directly by its SHA-1 hash. + fn get_commit_by_hash(&self, hash: &str) -> EngineResult { + let commit_object = self.object_storage.get_object(hash)?; MevaCommit::try_from(commit_object) } diff --git a/engine/src/diff/models/change_kind.rs b/engine/src/diff/models/change_kind.rs index 980f7c2..f283927 100644 --- a/engine/src/diff/models/change_kind.rs +++ b/engine/src/diff/models/change_kind.rs @@ -1,7 +1,10 @@ use std::fmt::Display; +use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; +use crate::diff::FileChangeKind; + /// Represents the kind of change a file has undergone. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ChangeKind { @@ -17,54 +20,31 @@ pub enum ChangeKind { /// /// Displayed as `'D'`. Deleted, - /// The file was renamed. - /// - /// Displayed as `'R'`. - Renamed, - /// The file was copied from another location. - /// - /// Displayed as `'C'`. - Copied, - /// The file’s type has changed (e.g., from regular file to symlink). - /// - /// Displayed as `'T'`. - TypeChanged, /// The file is in a merge conflict (unmerged state). /// /// Displayed as `'U'`. Unmerged, - /// The file has not been modified. - /// - /// Displayed as a space `' '`. - Unmodified, - /// The change type could not be determined. - /// - /// Displayed as `'?'`. - Unknown, -} - -impl ChangeKind { - /// Returns `true` if the file represents a meaningful change - /// (i.e., it is not `Unmodified` or `Unknown`). - pub fn is_changed(&self) -> bool { - *self != ChangeKind::Unmodified && *self != ChangeKind::Unknown - } } impl Display for ChangeKind { - /// Formats the change kind as a single-character code (e.g. `A`, `M`, `D`). + /// Formats the change kind as a single-character code (e.g. `A`, `M`, `D`, `U`). fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let c = match self { - ChangeKind::Added => 'A', - ChangeKind::Modified => 'M', - ChangeKind::Deleted => 'D', - ChangeKind::Renamed => 'R', - ChangeKind::Copied => 'C', - ChangeKind::TypeChanged => 'T', - ChangeKind::Unmerged => 'U', - ChangeKind::Unmodified => ' ', - ChangeKind::Unknown => '?', + let symbol = match self { + ChangeKind::Added => "A".green().bold().to_string(), + ChangeKind::Modified => "M".yellow().bold().to_string(), + ChangeKind::Deleted => "D".red().bold().to_string(), + ChangeKind::Unmerged => "U".bright_yellow().bold().to_string(), }; - write!(f, "{c}") + write!(f, "{symbol}") + } +} + +impl From<&FileChangeKind> for ChangeKind { + fn from(value: &FileChangeKind) -> Self { + match value { + FileChangeKind::Added { .. } => ChangeKind::Added, + FileChangeKind::Deleted { .. } => ChangeKind::Deleted, + FileChangeKind::Modified { .. } => ChangeKind::Modified, + } } } diff --git a/engine/src/diff/models/diff_mode.rs b/engine/src/diff/models/diff_mode.rs index 960d9f5..2ddb3b4 100644 --- a/engine/src/diff/models/diff_mode.rs +++ b/engine/src/diff/models/diff_mode.rs @@ -1,3 +1,5 @@ +use crate::handlers::show::PatchMode; + /// Specifies the level of detail for a diff operation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DiffMode { @@ -9,3 +11,15 @@ pub enum DiffMode { /// without the full content diff. Stat, } + +impl From for DiffMode { + fn from(value: PatchMode) -> Self { + match value { + PatchMode::Patch => DiffMode::Full, + PatchMode::NoPatch => DiffMode::NameOnly, + PatchMode::NameStatus => DiffMode::Full, + PatchMode::NameOnly => DiffMode::NameOnly, + PatchMode::Stat => DiffMode::Stat, + } + } +} diff --git a/engine/src/diff/models/diff_stat.rs b/engine/src/diff/models/diff_stat.rs index 71b37c8..2a5f88c 100644 --- a/engine/src/diff/models/diff_stat.rs +++ b/engine/src/diff/models/diff_stat.rs @@ -1,5 +1,6 @@ use std::fmt::Display; +use owo_colors::OwoColorize; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use serde::{Deserialize, Serialize}; @@ -29,28 +30,43 @@ impl Display for DiffStat { for file_stat in &self.file_stats { writeln!(f, "{file_stat}")?; } + let files_str = if self.files_changed == 1 { "file" } else { "files" }; - write!(f, "{} {files_str} changed", self.files_changed)?; + + write!( + f, + "{} {} changed", + self.files_changed.to_string().yellow().bold(), + files_str + )?; + if self.insertions > 0 { let insertions_str = if self.insertions == 1 { "insertion" } else { "insertions" }; - write!(f, ", {} {insertions_str}(+)", self.insertions)?; + write!( + f, + ", {} {}(+)", + self.insertions.green().bold(), + insertions_str + )?; } + if self.deletions > 0 { let deletions_str = if self.deletions == 1 { "deletion" } else { "deletions" }; - write!(f, ", {} {deletions_str}(-)", self.deletions)?; + write!(f, ", {} {}(-)", self.deletions.red().bold(), deletions_str)?; } + writeln!(f) } } diff --git a/engine/src/diff/models/file_change.rs b/engine/src/diff/models/file_change.rs index a796687..720a272 100644 --- a/engine/src/diff/models/file_change.rs +++ b/engine/src/diff/models/file_change.rs @@ -1,6 +1,7 @@ use std::{fmt::Display, path::PathBuf}; use chrono::{DateTime, Utc}; +use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; use shared::PathToString; @@ -112,11 +113,11 @@ impl FileChange { impl Display for FileChange { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let file_mode = "file mode"; - let empty_hash = "0000000"; + let empty_hash = "0000000".bright_black(); let dev_null = PathBuf::from("dev").join("null"); let index = "index"; - let pluses = "+++"; - let minuses = "---"; + let pluses = "+++".green(); + let minuses = "---".red(); let a_dir = PathBuf::from("a"); let b_dir = PathBuf::from("b"); @@ -128,13 +129,21 @@ impl Display for FileChange { .. } => { if let Some(mode) = mode { - writeln!(f, "new {file_mode} {mode:o}")?; + writeln!(f, "new {file_mode} {:o}", mode.bright_black())?; } if let Some(hash) = hash { - writeln!(f, "{index} {empty_hash}..{}", &hash[..7])?; + writeln!( + f, + "{index} {empty_hash}..{}", + &hash[..7].to_string().bright_black() + )?; } - writeln!(f, "{minuses} {}", dev_null.to_utf8_string())?; - writeln!(f, "{pluses} {}", b_dir.join(new_path).to_utf8_string())?; + writeln!(f, "{minuses} {}", dev_null.to_utf8_string().red())?; + writeln!( + f, + "{pluses} {}", + b_dir.join(new_path).to_utf8_string().green() + )?; } FileChangeKind::Deleted { @@ -147,10 +156,18 @@ impl Display for FileChange { writeln!(f, "deleted {file_mode} {mode:o}")?; } if let Some(hash) = hash { - writeln!(f, "{index} {}..{empty_hash}", &hash[..7])?; + writeln!( + f, + "{index} {}..{empty_hash}", + &hash[..7].to_string().bright_black() + )?; } - writeln!(f, "{minuses} {}", a_dir.join(old_path).to_utf8_string())?; - writeln!(f, "{pluses} {}", dev_null.to_utf8_string())?; + writeln!( + f, + "{minuses} {}", + a_dir.join(old_path).to_utf8_string().red() + )?; + writeln!(f, "{pluses} {}", dev_null.to_utf8_string().green())?; } FileChangeKind::Modified { @@ -163,14 +180,31 @@ impl Display for FileChange { } => { if let (Some(o), Some(n)) = (old_hash.as_ref(), new_hash.as_ref()) { if let Some(mode) = mode { - writeln!(f, "{index} {}..{} {mode:o}", &o[..7], &n[..7])?; + writeln!( + f, + "{index} {}..{} {mode:o}", + &o[..7].to_string().bright_black(), + &n[..7].to_string().bright_black(), + )?; } else { - writeln!(f, "{index} {}..{}", &o[..7], &n[..7])?; + writeln!( + f, + "{index} {}..{}", + &o[..7].to_string().bright_black(), + &n[..7].to_string().bright_black() + )?; } } - - writeln!(f, "{} {}", minuses, a_dir.join(old_path).to_utf8_string())?; - writeln!(f, "{} {}", pluses, b_dir.join(new_path).to_utf8_string())?; + writeln!( + f, + "{minuses} {}", + a_dir.join(old_path).to_utf8_string().red() + )?; + writeln!( + f, + "{pluses} {}", + b_dir.join(new_path).to_utf8_string().green() + )?; } } diff --git a/engine/src/diff/models/file_diff_stat.rs b/engine/src/diff/models/file_diff_stat.rs index 9d0b358..198c400 100644 --- a/engine/src/diff/models/file_diff_stat.rs +++ b/engine/src/diff/models/file_diff_stat.rs @@ -1,5 +1,6 @@ use std::{fmt::Display, path::PathBuf}; +use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; /// Represents the line change statistics for a single file in a diff. @@ -70,17 +71,21 @@ impl FileDiffStat { } impl Display for FileDiffStat { - /// Formats the file diff stat in a human-readable form. + /// Formats the file diff stat with colored insertions/deletions bar. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let changes = self.total_changes(); - let bar = self.bar_string(MAX_BAR); + let (ins_len, del_len) = self.bar_components(MAX_BAR); + + let ins = Self::INSERTION_SYMBOL.repeat(ins_len); + let ins_bar = ins.green(); + let del = Self::DELETION_SYMBOL.repeat(del_len); + let del_bar = del.red(); + let bar = format!("{ins_bar}{del_bar}"); write!( f, - " {} | {:>3} {:3} {bar:) -> std::fmt::Result { - writeln!( - f, + let header_text = format!( "@@ -{},{} +{},{} @@", self.old_start, self.old_lines, self.new_start, self.new_lines - )?; + ); + + writeln!(f, "{}", header_text.blue())?; + for line in &self.lines { write!(f, "{line}")?; } + Ok(()) } } @@ -60,7 +64,12 @@ impl Display for HunkLine { /// Formats the line for unified diff output, /// prefixing it with `+`, `-`, or a space depending on its type. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}{}", self.line_type, self.content) + let line_content = match self.line_type { + HunkLineType::Addition => self.content.green().to_string(), + HunkLineType::Deletion => self.content.red().to_string(), + HunkLineType::Context => self.content.clone(), + }; + write!(f, "{}{line_content}", self.line_type) } } @@ -76,13 +85,13 @@ pub enum HunkLineType { } impl Display for HunkLineType { - /// Formats the line type as its diff marker. + /// Formats the line type as its diff marker with color. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let c = match self { - HunkLineType::Context => ' ', - HunkLineType::Addition => '+', - HunkLineType::Deletion => '-', + let colored = match self { + HunkLineType::Context => ' '.to_string(), + HunkLineType::Addition => '+'.green().to_string(), + HunkLineType::Deletion => '-'.red().to_string(), }; - write!(f, "{c}") + write!(f, "{colored}") } } diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 601cd3f..419e262 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -4,7 +4,7 @@ use crate::ConfigLoader; use crate::handlers::{ add::AddHandler, commit::CommitHandler, config::ConfigHandler, init::InitHandler, - ls_files::LsFilesHandler, plugins::PluginsHandler, show::ShowHandler, status::StatusHandler, + ls_files::LsFilesHandler, plugins::PluginsHandler, status::StatusHandler, }; use crate::handlers::ls_tree::LsTreeHandler; @@ -39,8 +39,8 @@ pub trait EngineContainer { /// Returns the handler responsible for ls-tree command fn ls_tree_handler(&self) -> EngineResult; - /// Returns the handler responsible for show command - fn show_handler(&self) -> EngineResult; + // /// Returns the handler responsible for show command + // fn show_handler(&self) -> EngineResult; /// Returns the handler responsible for status command fn status_handler(&self) -> EngineResult; @@ -115,10 +115,6 @@ impl EngineContainer for MevaContainer { ))) } - fn show_handler(&self) -> EngineResult { - Ok(ShowHandler) - } - fn status_handler(&self) -> EngineResult { Ok(StatusHandler) } diff --git a/engine/src/handlers/commit/handlers.rs b/engine/src/handlers/commit/handlers.rs index 0350da3..606be43 100644 --- a/engine/src/handlers/commit/handlers.rs +++ b/engine/src/handlers/commit/handlers.rs @@ -51,13 +51,13 @@ impl CommitHandler { /// Retrieves the configured username from the repository settings. fn get_username(&self) -> EngineResult { self.config_loader - .get_parsed("user.name", String::default()) + .get("user.name", Some(&String::default())) } /// Retrieves the configured Git-style user email from the repository settings. fn get_user_email(&self) -> EngineResult { self.config_loader - .get_parsed("user.email", String::default()) + .get("user.email", Some(&String::default())) } } diff --git a/engine/src/handlers/commit/operations.rs b/engine/src/handlers/commit/operations.rs index dd4c373..ff3cdc8 100644 --- a/engine/src/handlers/commit/operations.rs +++ b/engine/src/handlers/commit/operations.rs @@ -5,6 +5,7 @@ use crate::objects::{MevaCommit, Person}; /// /// This struct holds input data passed to the commit process, /// including the commit message, author information, and execution flags. +#[derive(Debug)] pub struct CommitRequest { /// Commit message provided by the user. pub message_arg: String, diff --git a/engine/src/handlers/show/handlers.rs b/engine/src/handlers/show/handlers.rs index 2bfd593..1e924e1 100644 --- a/engine/src/handlers/show/handlers.rs +++ b/engine/src/handlers/show/handlers.rs @@ -1,17 +1,66 @@ -use crate::errors::EngineResult; +use crate::{ + RepositoryLayout, + diff::{DiffBuilder, DiffMode, DiffStat}, + errors::EngineResult, + handlers::show::{PatchMode, models::Snapshot}, +}; use super::{Request, Response, ShowOperations}; -pub struct ShowHandler; +pub struct ShowHandler<'a> { + /// The underlying builder responsible for the diff computation. + diff_builder: DiffBuilder<'a>, +} + +impl<'a> ShowHandler<'a> { + /// Creates a new `ShowHandler` instance. + /// + /// # Arguments + /// + /// * `repo_layout` - A reference to the repository's layout, used to initialize the `DiffBuilder`. + pub fn new(repo_layout: &'a dyn RepositoryLayout) -> EngineResult { + Ok(Self { + diff_builder: DiffBuilder::new(repo_layout)?, + }) + } -impl ShowHandler { pub fn handle_show(&self, request: Request) -> EngineResult { self.show(request) } } -impl ShowOperations for ShowHandler { - fn show(&self, _request: Request) -> EngineResult { - todo!() +impl<'a> ShowOperations for ShowHandler<'a> { + fn show(&self, request: Request) -> EngineResult { + let include_changes = !matches!(&request.mode, PatchMode::NoPatch); + let mode = DiffMode::from(request.mode); + + let child = request.snapshot_id; + + let (child_commit, child_hash, files) = + self.diff_builder + .diff_snapshot_with_parent(&child, &mode, include_changes)?; + + if !include_changes { + return Ok(Response { + snapshot: Snapshot::from_commit(child_commit, &child_hash), + stat: None, + files: None, + }); + } + + let response = match &mode { + DiffMode::Stat => Response { + snapshot: Snapshot::from_commit(child_commit, &child_hash), + stat: Some(DiffStat::from(files.as_slice())), + files: None, + }, + _ => Response { + snapshot: Snapshot::from_commit(child_commit, &child_hash), + stat: None, + files: Some(files), + }, + }; + + Ok(response) } } diff --git a/engine/src/handlers/show/models/snapshot.rs b/engine/src/handlers/show/models/snapshot.rs index d26c767..0c6b80b 100644 --- a/engine/src/handlers/show/models/snapshot.rs +++ b/engine/src/handlers/show/models/snapshot.rs @@ -1,15 +1,16 @@ use std::fmt::Display; use chrono::{DateTime, Utc}; +use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; -use crate::objects::Person; +use crate::objects::{MevaCommit, Person}; /// Represents a commit-like snapshot in the repository #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Snapshot { /// Unique hash or identifier of the snapshot. - pub id: String, + pub hash: String, /// The person who created this snapshot. pub author: Person, @@ -20,8 +21,26 @@ pub struct Snapshot { /// Commit message describing the changes in this snapshot. pub message: String, - /// List of parent snapshot IDs. - pub parent_ids: Vec, + /// List of parent snapshot hashes. + pub parents: Vec, +} + +impl Snapshot { + /// Creates a new `Snapshot` from a `MevaCommit` object and its corresponding hash. + /// + /// # Arguments + /// + /// * `commit` - The `MevaCommit` object containing the commit data. + /// * `hash` - The SHA-1 hash of the commit. + pub fn from_commit(commit: MevaCommit, hash: &str) -> Self { + Self { + hash: hash.to_string(), + author: commit.author, + timestamp: commit.timestamp, + message: commit.message, + parents: commit.parents, + } + } } impl Display for Snapshot { @@ -36,7 +55,7 @@ impl Display for Snapshot { /// Initial commit /// ``` fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - writeln!(f, "Commit: {}", self.id)?; + writeln!(f, "Commit: {}", self.hash.yellow())?; writeln!(f, "Author: {}", self.author)?; writeln!(f, "Date: {}", self.timestamp.to_rfc2822())?; writeln!(f)?; diff --git a/engine/src/handlers/show/operations.rs b/engine/src/handlers/show/operations.rs index 381f71c..f2533a4 100644 --- a/engine/src/handlers/show/operations.rs +++ b/engine/src/handlers/show/operations.rs @@ -11,11 +11,11 @@ pub struct Request { pub mode: PatchMode, } +#[derive(Debug)] pub struct Response { pub snapshot: Snapshot, pub stat: Option, pub files: Option>, - pub unified_diff: Option, } pub trait ShowOperations { diff --git a/engine/src/handlers/status/models/status_entry.rs b/engine/src/handlers/status/models/status_entry.rs index 9b125c9..517250a 100644 --- a/engine/src/handlers/status/models/status_entry.rs +++ b/engine/src/handlers/status/models/status_entry.rs @@ -71,24 +71,12 @@ impl StatusEntry { /// Returns `true` if the file has staged changes in the index. pub fn is_staged(&self) -> bool { - matches!( - self.kind, - StatusKind::Ordinary { - index_status, - .. - } if index_status.is_changed() - ) + todo!() } /// Returns `true` if the file has changes in the working tree (unstaged). pub fn is_worktree_changed(&self) -> bool { - matches!( - self.kind, - StatusKind::Ordinary { - worktree_status, - .. - } if worktree_status.is_changed() - ) + todo!() } /// Renders a short-format string of the file status. From 77a2877bc9fda9f37554ef11a307fcca0aa14d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Tue, 21 Oct 2025 23:02:28 +0200 Subject: [PATCH 15/42] Improvement/prevent empty commit (#21) --- cli/src/commands/commit.rs | 20 ++-- .../src/commit_builder/meva_commit_builder.rs | 27 +---- engine/src/engine_container.rs | 7 +- engine/src/errors/commit_error.rs | 9 +- engine/src/handlers/commit/handlers.rs | 109 +++++++++++++----- engine/src/handlers/commit/operations.rs | 6 +- 6 files changed, 109 insertions(+), 69 deletions(-) diff --git a/cli/src/commands/commit.rs b/cli/src/commands/commit.rs index ddb4442..8dda9e5 100644 --- a/cli/src/commands/commit.rs +++ b/cli/src/commands/commit.rs @@ -2,9 +2,11 @@ use crate::commands::MevaCommand; use clap::{Arg, ArgAction, ArgMatches, Command}; use engine::EngineContainer; use engine::engine_container::MevaContainer; -use engine::handlers::{add::AddRequest, commit::CommitRequest}; +use engine::errors::{CommitError, EngineError}; +use engine::handlers::{add::AddRequest, commit::Request}; use engine::objects::Person; use miette::IntoDiagnostic; +use owo_colors::OwoColorize; /// Implements the `commit` command for Meva DVCS. /// @@ -112,7 +114,7 @@ impl MevaCommand for CommitCommand { /// /// * If the `--all` flag is set, stages all modified and deleted files /// before creating the commit. - /// * Builds a [`CommitRequest`] using the collected arguments + /// * Builds a [`Request`] using the collected arguments /// (message, author, date, dry-run, amend). /// * Invokes the commit handler to either create the commit /// or show a preview when `--dry-run` is used. @@ -144,16 +146,20 @@ impl MevaCommand for CommitCommand { } let commit_handler = container.commit_handler().into_diagnostic()?; - let commit_request = CommitRequest { + let commit_request = Request { dry_run_arg, amend_arg, message_arg: message_arg.clone(), author_arg: author_arg.cloned(), }; - commit_handler - .handle_commit(commit_request, &interceptor) - .into_diagnostic()?; - Ok(()) + match commit_handler.handle_commit(commit_request, &interceptor) { + Ok(_) => Ok(()), + Err(err @ EngineError::Commit(CommitError::NothingToCommit)) => { + println!("{}", err.yellow()); + Ok(()) + } + Err(e) => Err(miette::miette!(e)), + } } } diff --git a/engine/src/commit_builder/meva_commit_builder.rs b/engine/src/commit_builder/meva_commit_builder.rs index 2e7a1d0..16ce23a 100644 --- a/engine/src/commit_builder/meva_commit_builder.rs +++ b/engine/src/commit_builder/meva_commit_builder.rs @@ -76,15 +76,10 @@ impl<'a> MevaCommitBuilder<'a> { /// /// Returns an [`EngineResult`] if index loading, tree construction, /// or object storage fails. - pub fn build_commit( - &self, - message: String, - author: Person, - verbose: bool, - ) -> EngineResult { + pub fn build_commit(&self, message: String, author: Person) -> EngineResult { let tree = self.build_tree_from_index()?; - let root_tree_hash = self.store_tree_objects(tree, verbose)?; + let root_tree_hash = self.store_tree_objects(tree)?; Ok(MevaCommit::new(root_tree_hash, message, author, Utc::now())) } @@ -92,18 +87,14 @@ impl<'a> MevaCommitBuilder<'a> { /// Stores a complete [`Tree`] of repository contents recursively in object storage. /// /// Returns the hash of the root tree object. - fn store_tree_objects( - &self, - tree: Tree, - verbose: bool, - ) -> EngineResult { + fn store_tree_objects(&self, tree: Tree) -> EngineResult { let root_node_id = tree .get_root_node() .ok_or(TreeError::from(NodeError::RootNodeMissing))? .get_node_id() .map_err(|_| TreeError::from(NodeError::NodeMissingId))?; - self.store_tree_recursively(&tree, root_node_id, verbose) + self.store_tree_recursively(&tree, root_node_id) } /// Recursively traverses the tree and stores each subtree as a [`MevaTree`] object. @@ -113,7 +104,6 @@ impl<'a> MevaCommitBuilder<'a> { &self, tree: &Tree, root_id: String, - verbose: bool, ) -> EngineResult { let root = tree .get_node_by_id(&root_id) @@ -141,18 +131,11 @@ impl<'a> MevaCommitBuilder<'a> { })? { tree_entries.push(TreeEntry::blob(name, tree_value.mode, tree_value.hash)); } else { - let hash = self.store_tree_recursively(tree, node_id, verbose)?; + let hash = self.store_tree_recursively(tree, node_id)?; tree_entries.push(TreeEntry::tree(name, hash)); } } - // TODO: Print only objects that changed since last commit - if verbose { - for entry in &tree_entries { - println!("{entry}"); - } - } - self.object_storage.add_tree(MevaTree::new(tree_entries)) } diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 419e262..2c92f7e 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -120,8 +120,9 @@ impl EngineContainer for MevaContainer { } fn commit_handler(&self) -> EngineResult { - Ok(CommitHandler { - config_loader: ConfigLoader::default(), - }) + Ok(CommitHandler::new( + Box::new(MevaRepositoryLayout::discover()?), + ConfigLoader::default(), + )) } } diff --git a/engine/src/errors/commit_error.rs b/engine/src/errors/commit_error.rs index 468d524..d5656f6 100644 --- a/engine/src/errors/commit_error.rs +++ b/engine/src/errors/commit_error.rs @@ -2,11 +2,16 @@ use thiserror::Error; /// Errors that can occur when working with commits. /// -/// Currently, this only includes errors related to commit amendment, -/// but it can be extended in the future to cover other commit-related failures. +/// This enum encapsulates all commit-related failures, such as attempting +/// to amend a non-existent commit or trying to create a commit when there +/// are no staged changes. #[derive(Error, Debug)] pub enum CommitError { /// Raised when attempting to amend a commit but the branch has no commits. #[error("No commit to amend")] NoCommitToAmend, + + /// Occurs when there are no staged changes to include in a new commit. + #[error("Nothing to commit, index clean")] + NothingToCommit, } diff --git a/engine/src/handlers/commit/handlers.rs b/engine/src/handlers/commit/handlers.rs index 606be43..f4a6ebe 100644 --- a/engine/src/handlers/commit/handlers.rs +++ b/engine/src/handlers/commit/handlers.rs @@ -1,14 +1,17 @@ -use super::{CommitOperations, CommitRequest, CommitResponse}; +use super::{CommitOperations, Request, Response}; -use crate::ConfigLoader; use crate::branch_manager::{BranchManger, MevaBranchManager}; use crate::commit_builder::MevaCommitBuilder; -use crate::errors::{EngineError, EngineResult}; +use crate::diff::{DiffBuilder, DiffMode}; +use crate::errors::{CommitError, EngineError, EngineResult}; +use crate::index::MevaIndex; use crate::object_storage::{MevaDryRunObjectStorage, MevaObjectStorage, ObjectStorage}; use crate::objects::{MevaCommit, Person}; use crate::plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}; +use crate::ref_manager::{MevaRefManager, RefManager}; use crate::repositories::meva_repository_layout::MevaRepositoryLayout; - +use crate::revision_parsing::Revision; +use crate::{ConfigLoader, RepositoryLayout}; use plugins::{ CommandType, CommitAuthor, CommitContent, CommitPostPayload, CommitPrePayload, InvocationInput, InvocationPostPayload, InvocationPrePayload, MevaPluginsLayout, PluginError, @@ -19,6 +22,7 @@ use plugins::{ /// The `CommitHandler` coordinates the process of creating or amending commits, /// optionally invoking plugin interceptors before and after the operation. /// It is responsible for: +/// - Checking whether there are staged changes before execution. /// - Building new commits from the current index, /// - Amending the last commit if requested, /// - Retrieving user information (name and email) from the configuration via [`ConfigLoader`]. @@ -26,26 +30,71 @@ use plugins::{ /// The `config_loader` field provides access to repository or global configuration, /// allowing the handler to resolve author information and other commit metadata. pub struct CommitHandler { - pub config_loader: ConfigLoader, + repo_layout: Box, + config_loader: ConfigLoader, } impl CommitHandler { - /// Executes the commit operation with plugin interception. + /// Creates a new [`CommitHandler`] instance with the given repository layout + /// and configuration loader. + pub fn new(repo_layout: Box, config_loader: ConfigLoader) -> Self { + Self { + repo_layout, + config_loader, + } + } + + /// Execute the commit flow with plugin interception. /// - /// Invokes registered plugins before and after the commit logic, - /// passing the [`CommitRequest`] and receiving a [`CommitResponse`]. + /// Performs a pre-check (`should_execute`) to decide whether a commit is needed. + /// If there are changes to commit, invokes plugin hooks and delegates the actual + /// commit work to `self.commit` via the interceptor. /// /// # Errors /// - /// Returns an [`EngineResult`] if plugin execution or commit processing fails. + /// Returns `CommitError::NothingToCommit` if there are no changes to commit, + /// or an [`EngineError`] if plugin execution or commit processing fails. pub fn handle_commit( &self, - request: CommitRequest, + request: Request, interceptor: &PluginsInterceptor, - ) -> EngineResult { - interceptor.intercept_with_plugins(CommandType::Commit, None, request, self, |req| { - self.commit(req) - }) + ) -> EngineResult { + match self.should_execute()? { + false => Err(CommitError::NothingToCommit.into()), + true => interceptor.intercept_with_plugins( + CommandType::Commit, + None, + request, + self, + |req| self.commit(req), + ), + } + } + + /// Determines whether a commit operation should proceed. + /// + /// Returns `true` if there are changes between the index and the latest commit, + /// or if no commits exist yet (initial commit). + /// + /// # Errors + /// + /// Returns an [`EngineResult`] if diff computation or index reading fails. + pub fn should_execute(&self) -> EngineResult { + let diff_builder = DiffBuilder::new(self.repo_layout.as_ref())?; + let refs_manager = MevaRefManager::new(self.repo_layout.as_ref()); + let head_hash = refs_manager.resolve_head()?; + match head_hash { + None => { + let index = MevaIndex::from_disk(self.repo_layout.as_ref(), Some(false))?; + Ok(!index.get_entries().is_empty()) + } + Some(hash) => { + let revision = Revision::hash(&hash, Vec::new()); + let diff_mode = DiffMode::NameOnly; + let diff = diff_builder.diff_snapshot_index(&revision, &diff_mode, None)?; + Ok(!diff.is_empty()) + } + } } /// Retrieves the configured username from the repository settings. @@ -62,14 +111,14 @@ impl CommitHandler { } impl CommitOperations for CommitHandler { - fn commit(&self, request: CommitRequest) -> EngineResult { - let repo_layout = MevaRepositoryLayout::discover()?; + fn commit(&self, request: Request) -> EngineResult { let object_storage: Box = match request.dry_run_arg { true => Box::new(MevaDryRunObjectStorage::new()), - false => Box::new(MevaObjectStorage::new(&repo_layout)), + false => Box::new(MevaObjectStorage::new(self.repo_layout.as_ref())), }; - let branch_manager = MevaBranchManager::new(&repo_layout); - let commit_builder = MevaCommitBuilder::new(&repo_layout, object_storage.as_ref()); + let branch_manager = MevaBranchManager::new(self.repo_layout.as_ref()); + let commit_builder = + MevaCommitBuilder::new(self.repo_layout.as_ref(), object_storage.as_ref()); let author = match request.author_arg.clone() { Some(a) => a, @@ -79,26 +128,22 @@ impl CommitOperations for CommitHandler { }, }; - let commit = commit_builder.build_commit( - request.message_arg.clone(), - author, - request.dry_run_arg, - )?; + let commit = commit_builder.build_commit(request.message_arg.clone(), author)?; let hash = match request.amend_arg { true => branch_manager.amend_last_commit(commit.clone())?, false => branch_manager.add_commit(commit.clone())?, }; - Ok(CommitResponse { + Ok(Response { commit_hash: hash.to_string(), commit, }) } } -impl PluginsInvocationMapper for CommitHandler { - fn request_to_payload(&self, req: &CommitRequest) -> EngineResult { +impl PluginsInvocationMapper for CommitHandler { + fn request_to_payload(&self, req: &Request) -> EngineResult { Ok(InvocationPrePayload::Commit(CommitPrePayload { message: req.message_arg.clone(), author: CommitAuthor { @@ -110,7 +155,7 @@ impl PluginsInvocationMapper for CommitHandler { })) } - fn response_to_payload(&self, res: &CommitResponse) -> EngineResult { + fn response_to_payload(&self, res: &Response) -> EngineResult { Ok(InvocationPostPayload::Commit(Box::new(CommitPostPayload { commit_hash: res.commit_hash.clone(), @@ -127,9 +172,9 @@ impl PluginsInvocationMapper for CommitHandler { }))) } - fn input_to_request(&self, input: &InvocationInput) -> EngineResult { + fn input_to_request(&self, input: &InvocationInput) -> EngineResult { if let Some(InvocationPrePayload::Commit(pre)) = &input.pre_payload { - Ok(CommitRequest { + Ok(Request { message_arg: pre.message.clone(), author_arg: Some(Person { name: pre.author.name.clone(), @@ -145,9 +190,9 @@ impl PluginsInvocationMapper for CommitHandler { } } - fn input_to_response(&self, input: &InvocationInput) -> EngineResult { + fn input_to_response(&self, input: &InvocationInput) -> EngineResult { if let Some(InvocationPostPayload::Commit(post)) = &input.post_payload { - Ok(CommitResponse { + Ok(Response { commit_hash: post.commit_hash.clone(), commit: MevaCommit { tree: post.commit_content.commit_tree_hash.clone(), diff --git a/engine/src/handlers/commit/operations.rs b/engine/src/handlers/commit/operations.rs index ff3cdc8..50d6fbc 100644 --- a/engine/src/handlers/commit/operations.rs +++ b/engine/src/handlers/commit/operations.rs @@ -6,7 +6,7 @@ use crate::objects::{MevaCommit, Person}; /// This struct holds input data passed to the commit process, /// including the commit message, author information, and execution flags. #[derive(Debug)] -pub struct CommitRequest { +pub struct Request { /// Commit message provided by the user. pub message_arg: String, @@ -24,7 +24,7 @@ pub struct CommitRequest { /// /// Contains details about the created (or amended) commit and /// echoes back the input arguments used for the operation. -pub struct CommitResponse { +pub struct Response { /// SHA-1 hash of the created or amended commit. pub commit_hash: String, @@ -33,5 +33,5 @@ pub struct CommitResponse { } pub trait CommitOperations { - fn commit(&self, request: CommitRequest) -> EngineResult; + fn commit(&self, request: Request) -> EngineResult; } From 940561068c9a8f0fe9a43319aa2fdcc358ca2f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Sun, 26 Oct 2025 19:27:56 +0100 Subject: [PATCH 16/42] Feature/command log (#22) --- Cargo.lock | 20 ++ Cargo.toml | 1 + cli/Cargo.toml | 2 + cli/src/commands.rs | 2 + cli/src/commands/commit.rs | 88 +++++++- cli/src/commands/log.rs | 196 ++++++++++++++++++ cli/src/main.rs | 3 +- engine/src/diff/diff_builder.rs | 6 +- engine/src/diff/models/diff_stat.rs | 61 +++--- engine/src/engine_container.rs | 12 +- engine/src/handlers.rs | 1 + engine/src/handlers/commit/handlers.rs | 190 +++++++++++++++-- engine/src/handlers/commit/operations.rs | 6 +- engine/src/handlers/log.rs | 5 + engine/src/handlers/log/handlers.rs | 161 ++++++++++++++ engine/src/handlers/log/operations.rs | 63 ++++++ engine/src/handlers/show.rs | 2 +- engine/src/index/file_mode.rs | 56 +++-- engine/src/index/index_entry.rs | 6 +- engine/src/objects/meva_blob.rs | 4 +- engine/src/objects/object_entry.rs | 2 +- plugins/src/models/payloads/commit_payload.rs | 104 +++++++++- 22 files changed, 910 insertions(+), 81 deletions(-) create mode 100644 cli/src/commands/log.rs create mode 100644 engine/src/handlers/log.rs create mode 100644 engine/src/handlers/log/handlers.rs create mode 100644 engine/src/handlers/log/operations.rs diff --git a/Cargo.lock b/Cargo.lock index 47df172..6540050 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "autocfg" version = "1.5.0" @@ -252,6 +258,7 @@ name = "cli" version = "0.1.0" dependencies = [ "clap", + "dateparser", "engine", "globset", "miette", @@ -259,6 +266,7 @@ dependencies = [ "owo-colors", "plugins", "pretty_assertions", + "regex", "rstest", "shared", "strum", @@ -336,6 +344,18 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "dateparser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ef451feee09ae5ecd8a02e738bd9adee9266b8fa9b44e22d3ce968d8694238" +dependencies = [ + "anyhow", + "chrono", + "lazy_static", + "regex", +] + [[package]] name = "diff" version = "0.1.13" diff --git a/Cargo.toml b/Cargo.toml index 1dd8d77..97dd6d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ dirs = "6.0.0" editor-command = "1.0.0" globset = "0.4.16" chrono = { version = "0.4.42", features = ["serde"] } +dateparser = "0.2.1" clap = { version = "4.5.41", features = ["derive"] } strum = "0.27" strum_macros = "0.27" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index d5c366d..efb6276 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -13,12 +13,14 @@ shared = { path = "../shared" } engine = { path = "../engine" } plugins = { path = "../plugins" } clap.workspace = true +dateparser.workspace = true thiserror.workspace = true miette = { version = "7.6.0", features = ["fancy"] } globset.workspace = true strum.workspace = true strum_macros.workspace = true owo-colors.workspace = true +regex.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 828e853..27311fe 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -4,6 +4,7 @@ pub mod config; pub mod diff; pub mod ignore; pub mod init; +pub mod log; pub mod ls_files; pub mod ls_tree; pub mod meva_command; @@ -17,6 +18,7 @@ pub use config::ConfigCommand; pub use diff::DiffCommand; pub use ignore::IgnoreCommand; pub use init::InitCommand; +pub use log::LogCommand; pub use ls_files::LsFilesCommand; pub use ls_tree::LsTreeCommand; pub use plugins::PluginsCommand; diff --git a/cli/src/commands/commit.rs b/cli/src/commands/commit.rs index 8dda9e5..7435dc7 100644 --- a/cli/src/commands/commit.rs +++ b/cli/src/commands/commit.rs @@ -1,10 +1,10 @@ use crate::commands::MevaCommand; use clap::{Arg, ArgAction, ArgMatches, Command}; -use engine::EngineContainer; -use engine::engine_container::MevaContainer; +use engine::diff::{DiffStat, FileChangeKind}; use engine::errors::{CommitError, EngineError}; -use engine::handlers::{add::AddRequest, commit::Request}; +use engine::handlers::{add::AddRequest, commit::Request, commit::Response}; use engine::objects::Person; +use engine::{EngineContainer, engine_container::MevaContainer}; use miette::IntoDiagnostic; use owo_colors::OwoColorize; @@ -42,6 +42,73 @@ impl CommitCommand { /// Flag key for amending the tip of the current branch instead of creating a new commit. /// Corresponds to the `--amend` flag. const ARG_AMEND: &'static str = "amend"; + + /// Displays a formatted summary of a successfully created commit. + /// + /// Includes: + /// - Shortened commit hash and message, + /// - Diff statistics summary (added/deleted lines), + /// - List of file changes (created, deleted, modified). + /// + /// # Arguments + /// * `response` - The [`Response`] returned by the commit handler. + fn display_real_commit_response(&self, response: Response) { + let stat = DiffStat::from(response.changes.as_ref()); + println!( + "[{}] {}", + response + .commit_hash + .unwrap_or_default() + .get(..7) + .unwrap_or("-"), + response.commit.message.cyan() + ); + println!("{}", stat.summary_string()); + for change in response.changes { + match change.kind { + FileChangeKind::Added { new_path, mode, .. } => match mode { + Some(mode) => println!( + "{} mode {:o} {}", + "create".green(), + mode, + new_path.display() + ), + None => println!("create {}", new_path.display()), + }, + FileChangeKind::Deleted { old_path, mode, .. } => match mode { + Some(mode) => { + println!("{} mode {:o} {}", "delete".red(), mode, old_path.display()) + } + None => println!("delete {}", old_path.display()), + }, + FileChangeKind::Modified { .. } => {} + } + } + } + + /// Displays the results of a dry-run commit. + /// + /// Shows which files *would* be committed, but does not perform + /// any actual repository changes. + /// + /// # Arguments + /// * `response` - The [`Response`] produced by the commit handler during a dry run. + fn display_dry_run_response(&self, response: Response) { + println!("Changes to be committed:"); + for change in response.changes { + match change.kind { + FileChangeKind::Added { new_path, .. } => { + println!("\t{} {}", "new file".green(), new_path.display().green()); + } + FileChangeKind::Deleted { old_path, .. } => { + println!("\t{} {}", "deleted".red(), old_path.display().red()); + } + FileChangeKind::Modified { new_path, .. } => { + println!("\t{} {}", "modified".yellow(), new_path.display().yellow()); + } + }; + } + } } impl MevaCommand for CommitCommand { @@ -153,13 +220,20 @@ impl MevaCommand for CommitCommand { author_arg: author_arg.cloned(), }; - match commit_handler.handle_commit(commit_request, &interceptor) { - Ok(_) => Ok(()), + let response = match commit_handler.handle_commit(commit_request, &interceptor) { + Ok(val) => val, Err(err @ EngineError::Commit(CommitError::NothingToCommit)) => { println!("{}", err.yellow()); - Ok(()) + return Ok(()); } - Err(e) => Err(miette::miette!(e)), + Err(e) => return Err(miette::miette!(e)), + }; + + match response.commit_hash { + None => self.display_dry_run_response(response), + Some(_) => self.display_real_commit_response(response), } + + Ok(()) } } diff --git a/cli/src/commands/log.rs b/cli/src/commands/log.rs new file mode 100644 index 0000000..1bfec37 --- /dev/null +++ b/cli/src/commands/log.rs @@ -0,0 +1,196 @@ +use crate::commands::MevaCommand; +use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; +use dateparser::DateTimeUtc; +use engine::EngineContainer; +use engine::engine_container::MevaContainer; +use engine::handlers::log::{LogOperations, Request, Response}; +use engine::revision_parsing::Revision; +use miette::{IntoDiagnostic, Result}; +use owo_colors::OwoColorize; +use regex::Regex; + +/// Implements the `log` command for Meva DVCS. +/// +/// Displays the repository's branch history, allowing inspection of commit metadata, +/// messages, and optional change statistics. +/// Supports flexible output formatting and filtering options, such as compact one-line view, +/// commit range limits, time-based filters, and pattern matching on commit messages. +pub struct LogCommand; + +impl LogCommand { + /// Creates a new instance of the `LogCommand`. + pub fn new() -> Self { + Self {} + } + + /// Flag for displaying commits in a condensed, one-line format. + const ARG_ONELINE: &'static str = "oneline"; + + /// Flag for including file-level statistics (added/deleted lines per file). + const ARG_STAT: &'static str = "stat"; + + /// Argument key for limiting the number of displayed commits. + const ARG_MAX_COUNT: &'static str = "max-count"; + + /// Skips the first N commits in the history. + const ARG_SKIP: &'static str = "skip"; + + /// Filters commits created after (and including) the specified date. + const ARG_AFTER: &'static str = "after"; + + /// Filters commits created before (and including) the specified date. + const ARG_BEFORE: &'static str = "before"; + + /// Shows only commits with messages matching the provided regular expression. + const ARG_GREP: &'static str = "grep"; + + /// Restricts output to commits with multiple parents (merge commits). + const ARG_MERGES: &'static str = "merges"; + + /// Excludes merge commits from the output. + const ARG_NO_MERGES: &'static str = "no-merges"; + + /// Optional starting revision (e.g., branch, tag, or commit hash). + const ARG_REVISION: &'static str = "revision"; + + /// Renders the command output in the appropriate format. + /// + /// Depending on the `oneline` flag, this method prints either + /// a condensed or detailed view of each commit. + fn display_response(&self, response: Response, oneline: bool) { + for entry in response.entries { + match oneline { + false => println!("{}", entry.snapshot), + true => { + let hash = entry.snapshot.hash.get(0..7).unwrap_or("-"); + let message = entry.snapshot.message; + println!("{} {message}", hash.yellow()); + } + } + if entry.stats.is_some() { + println!("{}", entry.stats.unwrap()); + } + } + } +} + +impl MevaCommand for LogCommand { + fn name(&self) -> &'static str { + "log" + } + + fn about(&self) -> &'static str { + "Display commit history of the repository" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `log` command. + /// + /// Adds all filtering, formatting and range options to control commit history display. + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_ONELINE) + .short('o') + .long(Self::ARG_ONELINE) + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_STAT) + .help("Condensed view: show each commit on one line (` `)"), + ) + .arg( + Arg::new(Self::ARG_STAT) + .long(Self::ARG_STAT) + .action(ArgAction::SetTrue) + .help("Show diffstat (number of added and deleted lines per file)"), + ) + .arg( + Arg::new(Self::ARG_MAX_COUNT) + .short('n') + .long(Self::ARG_MAX_COUNT) + .value_name("NUMBER") + .value_parser(value_parser!(u32)) + .help("Limit the number of commits to show"), + ) + .arg( + Arg::new(Self::ARG_SKIP) + .long(Self::ARG_SKIP) + .value_name("N") + .value_parser(value_parser!(u32)) + .help("Skip the first N commits"), + ) + .arg( + Arg::new(Self::ARG_AFTER) + .long(Self::ARG_AFTER) + .value_name("DATE") + .value_parser(value_parser!(DateTimeUtc)) + .help("Show commits made after (and including) the given date"), + ) + .arg( + Arg::new(Self::ARG_BEFORE) + .long(Self::ARG_BEFORE) + .value_name("DATE") + .value_parser(value_parser!(DateTimeUtc)) + .help("Show commits made before (and including) the given date"), + ) + .arg( + Arg::new(Self::ARG_GREP) + .long(Self::ARG_GREP) + .value_name("PATTERN") + .value_parser(value_parser!(Regex)) + .help("Show only commits with a message matching the given regular expression"), + ) + .arg( + Arg::new(Self::ARG_MERGES) + .long(Self::ARG_MERGES) + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_NO_MERGES) + .help("Show only merge commits (commits with two or more parents)"), + ) + .arg( + Arg::new(Self::ARG_NO_MERGES) + .long(Self::ARG_NO_MERGES) + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_MERGES) + .help("Exclude merge commits (show only commits with one or zero parents)"), + ) + .arg( + Arg::new(Self::ARG_REVISION) + .value_name("REVISION") + .value_parser(value_parser!(Revision)) + .required(false) + .help("Optional revision to start the log from (e.g. HEAD, branch, tag, or hash)"), + ) + } + + /// Executes the `log` command. + /// + /// Collects CLI options and builds a [`Request`] to pass to the log handler. + /// The handler is responsible for querying commits and formatting output + /// according to the specified options. + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + let oneline = matches.get_flag(Self::ARG_ONELINE); + let handler = container.log_handler().into_diagnostic()?; + + let request = Request { + oneline, + stat: matches.get_flag(Self::ARG_STAT), + merges: matches.get_flag(Self::ARG_MERGES), + no_merges: matches.get_flag(Self::ARG_NO_MERGES), + max_count: matches.get_one::(Self::ARG_MAX_COUNT).cloned(), + skip: matches.get_one::(Self::ARG_SKIP).copied(), + after: matches.get_one::(Self::ARG_AFTER).map(|a| a.0), + before: matches + .get_one::(Self::ARG_BEFORE) + .map(|b| b.0), + grep: matches.get_one::(Self::ARG_GREP).cloned(), + revision: matches.get_one::(Self::ARG_REVISION).cloned(), + }; + + let response = handler.log(request).into_diagnostic()?; + self.display_response(response, oneline); + Ok(()) + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 42134aa..893d0e7 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -4,7 +4,7 @@ mod meva_cli; use crate::{commands::MevaCommand, meva_cli::MevaCli}; use commands::{ - AddCommand, CommitCommand, ConfigCommand, DiffCommand, IgnoreCommand, InitCommand, + AddCommand, CommitCommand, ConfigCommand, DiffCommand, IgnoreCommand, InitCommand, LogCommand, LsFilesCommand, LsTreeCommand, PluginsCommand, ShowCommand, StatusCommand, }; use engine::engine_container::MevaContainer; @@ -25,6 +25,7 @@ fn main() -> Result<()> { Box::new(CommitCommand::new()), Box::new(DiffCommand::new()), Box::new(LsTreeCommand::new()), + Box::new(LogCommand::new()), ]; let container = MevaContainer; diff --git a/engine/src/diff/diff_builder.rs b/engine/src/diff/diff_builder.rs index a248c3b..a95e510 100644 --- a/engine/src/diff/diff_builder.rs +++ b/engine/src/diff/diff_builder.rs @@ -6,6 +6,7 @@ use rayon::{ }; use similar::{ChangeTag, TextDiff}; +use super::models::{DiffMode, FileChange, FileChangeKind, Hunk, HunkLine, HunkLineType}; use crate::{ RepositoryLayout, errors::EngineResult, @@ -17,8 +18,6 @@ use crate::{ revision_parsing::{Revision, RevisionResolver}, }; -use super::models::{DiffMode, FileChange, FileChangeKind, Hunk, HunkLine, HunkLineType}; - /// A helper struct to group arguments required for building a [FileChange] for a modified file. struct ModifiedFileArgs<'a> { /// The string content of the file before the change. @@ -732,8 +731,7 @@ impl<'a> DiffBuilder<'a> { ObjectEntry { entry_type: entry.mode.into(), hash: entry.sha1.clone(), - // TODO: `file_size` is u64 but `size` is usize (can be u32 on some machines) - size: Some(entry.file_size.try_into().unwrap_or(0)), + size: Some(entry.file_size), path, }, )) diff --git a/engine/src/diff/models/diff_stat.rs b/engine/src/diff/models/diff_stat.rs index 2a5f88c..8a534e3 100644 --- a/engine/src/diff/models/diff_stat.rs +++ b/engine/src/diff/models/diff_stat.rs @@ -24,50 +24,57 @@ pub struct DiffStat { pub file_stats: Vec, } -impl Display for DiffStat { - /// Formats the diff statistics in a human-readable summary form. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for file_stat in &self.file_stats { - writeln!(f, "{file_stat}")?; - } - - let files_str = if self.files_changed == 1 { - "file" - } else { - "files" +impl DiffStat { + /// Returns a human-readable summary of file changes in this diff. + /// Counts are colorized and formatted for terminal display. + pub fn summary_string(&self) -> String { + let files_str = match self.files_changed { + 1 => "file", + _ => "files", }; - write!( - f, + let mut result = format!( "{} {} changed", self.files_changed.to_string().yellow().bold(), files_str - )?; + ); if self.insertions > 0 { - let insertions_str = if self.insertions == 1 { - "insertion" - } else { - "insertions" + let insertions_str = match self.insertions { + 1 => "insertion", + _ => "insertions", }; - write!( - f, + result.push_str(&format!( ", {} {}(+)", self.insertions.green().bold(), insertions_str - )?; + )); } if self.deletions > 0 { - let deletions_str = if self.deletions == 1 { - "deletion" - } else { - "deletions" + let deletions_str = match self.deletions { + 1 => "deletion", + _ => "deletions", }; - write!(f, ", {} {}(-)", self.deletions.red().bold(), deletions_str)?; + result.push_str(&format!( + ", {} {}(-)", + self.deletions.red().bold(), + deletions_str + )); + } + + result + } +} + +impl Display for DiffStat { + /// Formats the diff statistics in a human-readable summary form. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for file_stat in &self.file_stats { + writeln!(f, "{file_stat}")?; } - writeln!(f) + writeln!(f, "{}", self.summary_string()) } } diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 2c92f7e..d9f8e0f 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -4,10 +4,9 @@ use crate::ConfigLoader; use crate::handlers::{ add::AddHandler, commit::CommitHandler, config::ConfigHandler, init::InitHandler, - ls_files::LsFilesHandler, plugins::PluginsHandler, status::StatusHandler, + log::LogHandler, ls_files::LsFilesHandler, ls_tree::LsTreeHandler, plugins::PluginsHandler, + status::StatusHandler, }; - -use crate::handlers::ls_tree::LsTreeHandler; use crate::{ errors::EngineResult, plugins_interceptor::PluginsInterceptor, repositories::meva_repository_layout::MevaRepositoryLayout, @@ -50,6 +49,9 @@ pub trait EngineContainer { // /// Return the handler responsible for diff operations // fn diff_handler(&self) -> EngineResult; + + /// Return the handler responsible for log command + fn log_handler(&self) -> EngineResult; } /// Concrete implementation of `EngineContainer` for Meva. @@ -125,4 +127,8 @@ impl EngineContainer for MevaContainer { ConfigLoader::default(), )) } + + fn log_handler(&self) -> EngineResult { + Ok(LogHandler::new(Box::new(MevaRepositoryLayout::discover()?))) + } } diff --git a/engine/src/handlers.rs b/engine/src/handlers.rs index e98f9d1..918cbda 100644 --- a/engine/src/handlers.rs +++ b/engine/src/handlers.rs @@ -3,6 +3,7 @@ pub mod commit; pub mod config; pub mod diff; pub mod init; +pub mod log; pub mod ls_files; pub mod ls_tree; pub mod plugins; diff --git a/engine/src/handlers/commit/handlers.rs b/engine/src/handlers/commit/handlers.rs index f4a6ebe..5dcd03a 100644 --- a/engine/src/handlers/commit/handlers.rs +++ b/engine/src/handlers/commit/handlers.rs @@ -1,20 +1,22 @@ use super::{CommitOperations, Request, Response}; +use std::path::PathBuf; use crate::branch_manager::{BranchManger, MevaBranchManager}; use crate::commit_builder::MevaCommitBuilder; -use crate::diff::{DiffBuilder, DiffMode}; +use crate::diff::{DiffBuilder, DiffMode, FileChange, FileChangeKind}; use crate::errors::{CommitError, EngineError, EngineResult}; -use crate::index::MevaIndex; +use crate::index::{FileMode, MevaIndex}; use crate::object_storage::{MevaDryRunObjectStorage, MevaObjectStorage, ObjectStorage}; -use crate::objects::{MevaCommit, Person}; +use crate::objects::{MevaCommit, ObjectEntry, Person}; use crate::plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}; use crate::ref_manager::{MevaRefManager, RefManager}; use crate::repositories::meva_repository_layout::MevaRepositoryLayout; use crate::revision_parsing::Revision; use crate::{ConfigLoader, RepositoryLayout}; use plugins::{ - CommandType, CommitAuthor, CommitContent, CommitPostPayload, CommitPrePayload, InvocationInput, - InvocationPostPayload, InvocationPrePayload, MevaPluginsLayout, PluginError, + CommandType, CommitAuthor, CommitContent, CommitFileChange, CommitFileMode, CommitPostPayload, + CommitPrePayload, InvocationInput, InvocationPostPayload, InvocationPrePayload, + MevaPluginsLayout, PluginError, }; /// Handles the `meva commit` command execution flow. @@ -59,7 +61,7 @@ impl CommitHandler { request: Request, interceptor: &PluginsInterceptor, ) -> EngineResult { - match self.should_execute()? { + match self.should_execute(&request)? { false => Err(CommitError::NothingToCommit.into()), true => interceptor.intercept_with_plugins( CommandType::Commit, @@ -71,15 +73,20 @@ impl CommitHandler { } } - /// Determines whether a commit operation should proceed. + /// Determines whether a commit operation should be executed. /// - /// Returns `true` if there are changes between the index and the latest commit, - /// or if no commits exist yet (initial commit). + /// The operation proceeds in the following cases: + /// - The `--amend` flag is set (commit should be amended regardless of changes), + /// - There are staged changes compared to the latest commit, + /// - There are entries in the index but no commits exist yet (initial commit). + /// + /// Returns `false` if the index is clean and no amendments are requested. /// /// # Errors /// - /// Returns an [`EngineResult`] if diff computation or index reading fails. - pub fn should_execute(&self) -> EngineResult { + /// Returns an [`EngineError`](EngineError) if reading the index + /// or computing the diff fails. + fn should_execute(&self, request: &Request) -> EngineResult { let diff_builder = DiffBuilder::new(self.repo_layout.as_ref())?; let refs_manager = MevaRefManager::new(self.repo_layout.as_ref()); let head_hash = refs_manager.resolve_head()?; @@ -89,6 +96,9 @@ impl CommitHandler { Ok(!index.get_entries().is_empty()) } Some(hash) => { + if request.amend_arg { + return Ok(true); + } let revision = Revision::hash(&hash, Vec::new()); let diff_mode = DiffMode::NameOnly; let diff = diff_builder.diff_snapshot_index(&revision, &diff_mode, None)?; @@ -108,6 +118,57 @@ impl CommitHandler { self.config_loader .get("user.email", Some(&String::default())) } + + /// Collects file changes related to the specified commit. + /// + /// - If `commit_hash` is `None` and no commits exist, all indexed files are treated as added. + /// - If `commit_hash` is `None` but a commit exists, computes the diff between the index and `HEAD`. + /// - If `commit_hash` is `Some`, returns changes introduced by that commit. + /// + /// # Errors + /// + /// Returns an [`EngineError`] if reading the index or computing diffs fails. + fn collect_commit_changes(&self, commit_hash: Option) -> EngineResult> { + let diff_builder = DiffBuilder::new(self.repo_layout.as_ref())?; + match commit_hash { + None => { + let branch_manager = MevaBranchManager::new(self.repo_layout.as_ref()); + let head = branch_manager.last_commit_hash()?; + match head { + None => { + let index = MevaIndex::from_disk(self.repo_layout.as_ref(), Some(false))?; + let entries = index.get_entries(); + let object_entries = entries + .into_iter() + .map(|entry| ObjectEntry { + entry_type: entry.mode.into(), + hash: entry.sha1.clone(), + size: Some(entry.file_size), + path: PathBuf::from(&entry.path), + }) + .collect::>(); + + diff_builder.added_to_file_changes( + object_entries.iter().collect(), + &DiffMode::NameOnly, + ) + } + Some(_) => { + let revision = Revision::head(Vec::new()); + diff_builder.diff_snapshot_index(&revision, &DiffMode::NameOnly, None) + } + } + } + Some(hash) => { + let (_, _, changes) = diff_builder.diff_snapshot_with_parent( + &Revision::hash(&hash, Vec::new()), + &DiffMode::Stat, + true, + )?; + Ok(changes) + } + } + } } impl CommitOperations for CommitHandler { @@ -130,14 +191,19 @@ impl CommitOperations for CommitHandler { let commit = commit_builder.build_commit(request.message_arg.clone(), author)?; - let hash = match request.amend_arg { - true => branch_manager.amend_last_commit(commit.clone())?, - false => branch_manager.add_commit(commit.clone())?, + let commit_hash = if request.dry_run_arg { + None + } else if request.amend_arg { + Some(branch_manager.amend_last_commit(commit.clone())?) + } else { + Some(branch_manager.add_commit(commit.clone())?) }; + let changes = self.collect_commit_changes(commit_hash.clone())?; Ok(Response { - commit_hash: hash.to_string(), + commit_hash, commit, + changes, }) } } @@ -169,6 +235,51 @@ impl PluginsInvocationMapper for CommitHandler { }, timestamp: res.commit.timestamp, }, + changes: res + .changes + .iter() + .map(|f_change| match f_change.kind.clone() { + FileChangeKind::Added { + new_path, + mode, + hash, + insertions, + } => CommitFileChange::Added { + new_path, + mode: mode.map(CommitFileMode::from), + hash, + insertions, + }, + FileChangeKind::Deleted { + old_path, + mode, + hash, + deletions, + } => CommitFileChange::Deleted { + old_path, + mode: mode.map(CommitFileMode::from), + hash, + deletions, + }, + FileChangeKind::Modified { + old_path, + new_path, + old_hash, + new_hash, + mode, + insertions, + deletions, + } => CommitFileChange::Modified { + old_path, + new_path, + old_hash, + new_hash, + mode: mode.map(CommitFileMode::from), + insertions, + deletions, + }, + }) + .collect(), }))) } @@ -204,6 +315,55 @@ impl PluginsInvocationMapper for CommitHandler { }, timestamp: post.commit_content.timestamp, }, + changes: post + .changes + .iter() + .map(|p_change| FileChange { + kind: match p_change.clone() { + CommitFileChange::Added { + new_path, + mode, + hash, + insertions, + } => FileChangeKind::Added { + new_path, + mode: mode.map(FileMode::from), + hash, + insertions, + }, + CommitFileChange::Deleted { + old_path, + mode, + hash, + deletions, + } => FileChangeKind::Deleted { + old_path, + mode: mode.map(FileMode::from), + hash, + deletions, + }, + CommitFileChange::Modified { + old_path, + new_path, + old_hash, + new_hash, + mode, + insertions, + deletions, + } => FileChangeKind::Modified { + old_path, + new_path, + old_hash, + new_hash, + mode: mode.map(FileMode::from), + insertions, + deletions, + }, + }, + hunks: None, + unified_diff: None, + }) + .collect(), }) } else { Err(EngineError::Plugins(PluginError::PostPayload { diff --git a/engine/src/handlers/commit/operations.rs b/engine/src/handlers/commit/operations.rs index 50d6fbc..c7ba5df 100644 --- a/engine/src/handlers/commit/operations.rs +++ b/engine/src/handlers/commit/operations.rs @@ -1,3 +1,4 @@ +use crate::diff::FileChange; use crate::errors::EngineResult; use crate::objects::{MevaCommit, Person}; @@ -26,10 +27,13 @@ pub struct Request { /// echoes back the input arguments used for the operation. pub struct Response { /// SHA-1 hash of the created or amended commit. - pub commit_hash: String, + pub commit_hash: Option, /// Commit object pub commit: MevaCommit, + + /// List of file changes included in the commit. + pub changes: Vec, } pub trait CommitOperations { diff --git a/engine/src/handlers/log.rs b/engine/src/handlers/log.rs new file mode 100644 index 0000000..18265f4 --- /dev/null +++ b/engine/src/handlers/log.rs @@ -0,0 +1,5 @@ +pub mod handlers; +pub mod operations; + +pub use handlers::LogHandler; +pub use operations::*; diff --git a/engine/src/handlers/log/handlers.rs b/engine/src/handlers/log/handlers.rs new file mode 100644 index 0000000..2e7e8fa --- /dev/null +++ b/engine/src/handlers/log/handlers.rs @@ -0,0 +1,161 @@ +use crate::RepositoryLayout; +use crate::diff::{DiffBuilder, DiffMode, DiffStat}; +use crate::errors::EngineResult; +use crate::handlers::{ + log::{LogEntry, LogOperations, Request, Response}, + show::Snapshot, +}; +use crate::object_storage::{MevaObjectStorage, ObjectStorage}; +use crate::objects::MevaCommit; +use crate::revision_parsing::{Revision, RevisionResolver}; +use chrono::Utc; + +/// Handles the `meva log` command execution flow. +/// +/// The [`LogHandler`] retrieves and formats commit history based on user-specified +/// options such as revision, range, time filters, or pattern matching. +/// It is responsible for: +/// - Resolving the starting revision and traversing commit ancestry, +/// - Applying filters like `--max-count`, `--skip`, `--before`, `--grep`, and merge options, +/// - Optionally computing statistics of changed files for each commit. +/// +/// This handler forms the core of commit history inspection and supports both +/// detailed (`--stat`) and condensed (`--oneline`) display modes. +pub struct LogHandler { + /// Provides access to repository layout and object storage. + repo_layout: Box, +} + +impl LogHandler { + /// Creates a new [`LogHandler`] using the provided repository layout. + pub fn new(repo_layout: Box) -> Self { + Self { repo_layout } + } + + /// Executes the `log` command logic. + /// + /// Delegates directly to [`Self::log`], which performs the actual + /// commit traversal and filtering. + pub fn handle_log(&self, request: Request) -> EngineResult { + self.log(request) + } + + /// Helper function for loading the next commit in history. + fn load_next_commit( + object_storage: &MevaObjectStorage, + next_commit_hash: &Option, + ) -> EngineResult<(Option, Option)> { + match next_commit_hash { + Some(hash) => { + let commit_object = object_storage.get_object(hash)?; + let commit = MevaCommit::try_from(commit_object)?; + let next_hash = commit.parents.first().cloned(); + Ok((Some(commit), next_hash)) + } + None => Ok((None, None)), + } + } +} + +impl LogOperations for LogHandler { + /// Performs commit log traversal and builds the result. + /// + /// The method walks through the commit history starting from the provided revision + /// (or `HEAD` if none is specified). It applies filters and formatting options + /// from [`Request`], such as: + /// - `max_count` — limits the number of commits, + /// - `skip` — skips the given number of commits before listing, + /// - `before` — restricts commits to those before a given timestamp, + /// - `grep` — filters commits whose message matches a pattern, + /// - `no_merges` or `merges` — controls inclusion of merge commits, + /// - `stat` — includes file change statistics. + /// + /// # Behavior + /// + /// - The traversal proceeds linearly through parent links. + /// - For each matching commit, a [`LogEntry`] is created. + /// - If `--stat` is active, file statistics are computed using [`DiffBuilder`]. + /// + /// # Errors + /// + /// Returns an [`EngineError`] if resolving revisions, reading objects, + /// or computing diffs fails. + fn log(&self, request: Request) -> EngineResult { + let mut result = Response { + entries: Vec::new(), + }; + + let object_storage = MevaObjectStorage::new(self.repo_layout.as_ref()); + let revision_resolver = RevisionResolver::new(self.repo_layout.as_ref()); + let diff_builder = DiffBuilder::new(self.repo_layout.as_ref())?; + + let revision = request + .revision + .unwrap_or_else(|| Revision::head(Vec::new())); + + let commit_object = revision_resolver.resolve_object(&revision)?; + let mut current_commit_hash = Some(commit_object.sha1().clone()); + let mut current_commit = Some(MevaCommit::try_from(commit_object)?); + let mut next_commit_hash = match current_commit.as_ref() { + Some(commit_object) => commit_object.parents.first().cloned(), + None => None, + }; + + let mut skip = request.skip.unwrap_or(0) as i32; + let before = request.before.unwrap_or(Utc::now()); + while let Some(commit) = current_commit.take() { + if commit.timestamp < before && skip <= 0 { + current_commit = Some(commit); + break; + } + current_commit_hash = next_commit_hash; + (current_commit, next_commit_hash) = + Self::load_next_commit(&object_storage, ¤t_commit_hash)?; + skip -= 1; + } + + let max_count = request.max_count.unwrap_or(u32::MAX); + let mut cnt: u32 = 0; + while let Some(commit) = current_commit.take() { + if cnt >= max_count { + break; + } + let matches_grep = request + .grep + .as_ref() + .is_none_or(|regex| regex.is_match(&commit.message)); + let is_merge_skip = (request.no_merges && commit.parents.len() > 1) + || (request.merges && commit.parents.len() < 2); + if matches_grep && !is_merge_skip { + let mut new_log_entry = LogEntry { + snapshot: Snapshot::from_commit( + commit.clone(), + ¤t_commit_hash.clone().unwrap(), + ), + stats: None, + }; + + if !request.oneline && request.stat { + let revision = + Revision::hash(¤t_commit_hash.clone().unwrap(), Vec::new()); + let (_, _, changes) = + diff_builder.diff_snapshot_with_parent(&revision, &DiffMode::Stat, true)?; + new_log_entry.stats = match request.stat { + true => Some(DiffStat::from(changes.as_slice())), + false => None, + }; + } + + result.entries.push(new_log_entry); + } + + current_commit_hash = next_commit_hash; + (current_commit, next_commit_hash) = + Self::load_next_commit(&object_storage, ¤t_commit_hash)?; + + cnt += 1; + } + + Ok(result) + } +} diff --git a/engine/src/handlers/log/operations.rs b/engine/src/handlers/log/operations.rs new file mode 100644 index 0000000..27e1aad --- /dev/null +++ b/engine/src/handlers/log/operations.rs @@ -0,0 +1,63 @@ +use crate::diff::DiffStat; +use crate::errors::EngineResult; +use crate::handlers::show::Snapshot; +use crate::revision_parsing::Revision; +use chrono::{DateTime, Utc}; +use regex::Regex; + +/// Represents a single commit entry in the log output. +pub struct LogEntry { + /// Snapshot of the commit, including hash, message, author, and timestamp. + pub snapshot: Snapshot, + + /// Optional summary of file changes for the commit (insertions/deletions). + pub stats: Option, +} + +/// Represents a request to the `log` command handler. +/// +/// Contains all filtering, formatting, and traversal options +/// used to control how commit history is retrieved and displayed. +pub struct Request { + /// Show each commit in a condensed single-line format. + pub oneline: bool, + + /// Include a summary of file changes (lines added and deleted) for each commit. + pub stat: bool, + + /// Show only merge commits (commits with multiple parents). + pub merges: bool, + + /// Exclude merge commits from the output. + pub no_merges: bool, + + /// Limit the number of commits returned. + pub max_count: Option, + + /// Skip the first N commits in the history. + pub skip: Option, + + /// Include only commits created after this date. + pub after: Option>, + + /// Include only commits created before this date. + pub before: Option>, + + /// Filter commits by message text matching the given regular expression. + pub grep: Option, + + /// Starting point for history traversal (e.g., branch, tag, or commit hash). + pub revision: Option, +} + +/// Represents the response from the `log` command handler. +/// +/// Contains the list of commit entries retrieved based on the request. +pub struct Response { + /// The retrieved commit entries. + pub entries: Vec, +} + +pub trait LogOperations { + fn log(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/handlers/show.rs b/engine/src/handlers/show.rs index 4245e0d..fce5eb6 100644 --- a/engine/src/handlers/show.rs +++ b/engine/src/handlers/show.rs @@ -5,4 +5,4 @@ mod operations; pub use handlers::ShowHandler; pub use operations::*; -pub use models::PatchMode; +pub use models::{PatchMode, Snapshot}; diff --git a/engine/src/index/file_mode.rs b/engine/src/index/file_mode.rs index 9691bba..306ad97 100644 --- a/engine/src/index/file_mode.rs +++ b/engine/src/index/file_mode.rs @@ -4,9 +4,9 @@ use std::io::Error; use std::io::ErrorKind; use std::path::Path; -use serde::{Deserialize, Serialize}; - use crate::objects::tree_entry_type::TreeEntryType; +use plugins::CommitFileMode; +use serde::{Deserialize, Serialize}; /// Represents the file mode used to describe how a file /// should be tracked in the repository. @@ -46,10 +46,10 @@ impl TryFrom<&TreeEntryType> for FileMode { /// do not have a direct file mode representation in this context. fn try_from(value: &TreeEntryType) -> Result { match value { - TreeEntryType::BlobExecutable => Ok(FileMode::Executable), - TreeEntryType::BlobNormal => Ok(FileMode::Normal), - TreeEntryType::BlobSymlink => Ok(FileMode::Symlink), - TreeEntryType::CommitGitLink => Ok(FileMode::GitLink), + TreeEntryType::BlobExecutable => Ok(Self::Executable), + TreeEntryType::BlobNormal => Ok(Self::Normal), + TreeEntryType::BlobSymlink => Ok(Self::Symlink), + TreeEntryType::CommitGitLink => Ok(Self::GitLink), TreeEntryType::Tree => Err(Error::new( ErrorKind::InvalidData, "Cannot convert Tree entry type into a file mode", @@ -59,7 +59,7 @@ impl TryFrom<&TreeEntryType> for FileMode { } impl TryFrom<&Path> for FileMode { - type Error = std::io::Error; + type Error = Error; /// Attempts to determine the appropriate [`FileMode`] for the given path, /// based on its filesystem metadata. @@ -82,14 +82,14 @@ impl TryFrom<&Path> for FileMode { let file_mode = match mode & 0o170000 { 0o100000 => { if mode & 0o111 != 0 { - FileMode::Executable + Self::Executable } else { - FileMode::Normal + Self::Normal } } - 0o120000 => FileMode::Symlink, - 0o160000 => FileMode::GitLink, - _ => FileMode::Normal, + 0o120000 => Self::Symlink, + 0o160000 => Self::GitLink, + _ => Self::Normal, }; Ok(file_mode) } @@ -104,14 +104,40 @@ impl TryFrom<&Path> for FileMode { matches!(ext.to_lowercase().as_str(), "exe" | "bat" | "cmd" | "ps1") }); Ok(if executable { - FileMode::Executable + Self::Executable } else { - FileMode::Normal + Self::Normal }) } else { // Directories and other types are not given a specific mode here. - Ok(FileMode::Normal) + Ok(Self::Normal) } } } } + +/// Converts between `CommitFileMode` and `FileMode`. +/// Each variant maps directly to its counterpart. +impl From for FileMode { + fn from(mode: CommitFileMode) -> Self { + match mode { + CommitFileMode::Normal => Self::Normal, + CommitFileMode::Executable => Self::Executable, + CommitFileMode::Symlink => Self::Symlink, + CommitFileMode::GitLink => Self::GitLink, + } + } +} + +/// Converts a `FileMode` into the corresponding `CommitFileMode`. +/// Each variant maps directly to its counterpart. +impl From for CommitFileMode { + fn from(mode: FileMode) -> Self { + match mode { + FileMode::Normal => Self::Normal, + FileMode::Executable => Self::Executable, + FileMode::Symlink => Self::Symlink, + FileMode::GitLink => Self::GitLink, + } + } +} diff --git a/engine/src/index/index_entry.rs b/engine/src/index/index_entry.rs index 4f3b180..ab49474 100644 --- a/engine/src/index/index_entry.rs +++ b/engine/src/index/index_entry.rs @@ -26,10 +26,10 @@ pub struct IndexEntry { /// The file size in bytes. pub file_size: u64, - // TODO: poprawić ten komentarz, bo nieaktualny - /// The SHA-1 object hash of the file's contents. + /// The SHA-1 hash of the serialized [`MevaBlob`] object. /// - /// Defaults to an empty string when the hash is not yet computed. + /// This value uniquely identifies the blob’s binary content as stored + /// in the object database. It is empty until the blob is serialized and hashed. pub sha1: String, /// The stage number associated with the entry (e.g. for merge conflicts). diff --git a/engine/src/objects/meva_blob.rs b/engine/src/objects/meva_blob.rs index aadef97..6ce6035 100644 --- a/engine/src/objects/meva_blob.rs +++ b/engine/src/objects/meva_blob.rs @@ -48,8 +48,8 @@ impl MevaBlob { } /// Returns the size of the blob’s data in bytes. - pub fn size(&self) -> usize { - self.data.len() + pub fn size(&self) -> u64 { + self.data.len() as u64 } /// Attempts to decode the blob's raw byte data into a UTF-8 string. diff --git a/engine/src/objects/object_entry.rs b/engine/src/objects/object_entry.rs index 67f1ab8..e31c692 100644 --- a/engine/src/objects/object_entry.rs +++ b/engine/src/objects/object_entry.rs @@ -14,7 +14,7 @@ pub struct ObjectEntry { pub hash: String, /// The size of the object in bytes (only for blobs). - pub size: Option, + pub size: Option, /// The path of the entry relative to the repository root. pub path: PathBuf, diff --git a/plugins/src/models/payloads/commit_payload.rs b/plugins/src/models/payloads/commit_payload.rs index eb0742b..1147733 100644 --- a/plugins/src/models/payloads/commit_payload.rs +++ b/plugins/src/models/payloads/commit_payload.rs @@ -1,33 +1,135 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; /// Payload provided to plugins **before** the `meva commit` command execution #[derive(Debug, Serialize, Deserialize, Clone)] pub struct CommitPrePayload { + /// The commit message provided by the user. pub message: String, + + /// Information about the commit author (name and email). pub author: CommitAuthor, + + /// Indicates whether the commit should be a dry run (no changes actually written). pub dry_run: bool, + + /// Indicates whether the commit should amend the previous commit. pub amend: bool, } /// Payload provided to plugins **after** the `meva commit` command execution #[derive(Debug, Serialize, Deserialize, Clone)] pub struct CommitPostPayload { - pub commit_hash: String, + /// The SHA-1 hash of the created or amended commit. + /// `None` if the commit was executed in dry-run mode + pub commit_hash: Option, + + /// The commit content, including tree hash, parent commits, message, author, and timestamp. pub commit_content: CommitContent, + + /// A list of file changes introduced by the commit. + pub changes: Vec, } +/// Represents the author of a commit. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct CommitAuthor { + /// The full name of the author. pub name: String, + + /// The email address of the author. pub email: String, } +/// Contains the full content of a commit. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct CommitContent { + /// The SHA-1 hash of the root tree object associated with this commit. pub commit_tree_hash: String, + + /// List of parent commit hashes. + /// Empty for an initial commit, one entry for a normal commit, multiple for merge commits. pub parents: Vec, + + /// The commit message describing the changes. pub message: String, + + /// The author of the commit. pub author: CommitAuthor, + + /// The timestamp when the commit was created. pub timestamp: DateTime, } + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum CommitFileChange { + /// The file was added. + Added { + /// The path of the new file. + new_path: PathBuf, + + /// The file mode (e.g., regular file, executable). + mode: Option, + + /// The SHA-1 hash of the new file's content. + hash: Option, + + /// The total number of lines inserted. + insertions: usize, + }, + + /// The file was deleted. + Deleted { + /// The path of the deleted file. + old_path: PathBuf, + + /// The file mode of the deleted file. + mode: Option, + + /// The SHA-1 hash of the deleted file's content. + hash: Option, + + /// The total number of lines deleted. + deletions: usize, + }, + + /// The file was modified. + Modified { + /// The original path of the file before modification (in case of a rename). + old_path: PathBuf, + + /// The new path of the file after modification. + new_path: PathBuf, + + /// The SHA-1 hash of the file's content before modification. + old_hash: Option, + + /// The SHA-1 hash of the file's content after modification. + new_hash: Option, + + /// The new file mode. + mode: Option, + + /// The total number of lines inserted in this modification. + insertions: usize, + + /// The total number of lines deleted in this modification. + deletions: usize, + }, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum CommitFileMode { + /// A regular non-executable file. + Normal = 0o100644, + + /// A regular executable file. + Executable = 0o100755, + + /// A symbolic link. + Symlink = 0o120000, + + /// A submodule (gitlink) entry. + GitLink = 0o160000, +} From a4365a1befb1d144dc1b1dcad0273368e1843f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:36:46 +0100 Subject: [PATCH 17/42] Command `status` (#24) --- cli/src/commands/status.rs | 84 +---- engine/src/diff/diff_builder.rs | 81 +++-- engine/src/diff/models/change_kind.rs | 66 +++- engine/src/handlers/diff/handlers.rs | 2 +- engine/src/handlers/status.rs | 2 - engine/src/handlers/status/handlers.rs | 342 +++++++++++++++++- engine/src/handlers/status/models.rs | 4 +- .../src/handlers/status/models/branch_info.rs | 39 +- .../handlers/status/models/grouped_status.rs | 81 ----- .../src/handlers/status/models/merge_entry.rs | 19 + .../handlers/status/models/status_entry.rs | 162 ++++++--- .../src/handlers/status/models/status_kind.rs | 30 +- engine/src/handlers/status/operations.rs | 129 ++++++- engine/src/index.rs | 2 +- engine/src/index/file_mode.rs | 2 +- engine/src/index/meva_index.rs | 25 +- engine/src/index/working_dir.rs | 95 +++-- engine/src/ref_manager.rs | 18 + engine/src/ref_manager/head.rs | 15 + engine/src/ref_manager/meva_ref_manager.rs | 15 +- 20 files changed, 895 insertions(+), 318 deletions(-) delete mode 100644 engine/src/handlers/status/models/grouped_status.rs create mode 100644 engine/src/handlers/status/models/merge_entry.rs diff --git a/cli/src/commands/status.rs b/cli/src/commands/status.rs index 272725a..d6edc83 100644 --- a/cli/src/commands/status.rs +++ b/cli/src/commands/status.rs @@ -1,9 +1,5 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; -use engine::{ - EngineContainer, - engine_container::MevaContainer, - handlers::status::{Request, Response, StatusGrouping}, -}; +use engine::{EngineContainer, engine_container::MevaContainer, handlers::status::Request}; use crate::commands::MevaCommand; @@ -30,52 +26,8 @@ impl StatusCommand { /// Argument name for hiding branch information (`--no-branch`). const ARG_NO_BRANCH: &'static str = "no-branch"; - /// Argument name for including untracked files in the output (`-u` / `--untracked`). - const ARG_UNTRACKED: &'static str = "untracked"; - /// Argument name for including ignored files in the output (`-i` / `--ignored`). const ARG_IGNORED: &'static str = "ignored"; - - /// Helper method for rendering the `Response` object. - fn display_response(&self, request: &Request, response: &Response) { - let groups = response.entries.grouped(); - - if request.show_branch - && let Some(branch) = &response.branch - { - println!("{branch}"); - } - - if request.short_format { - for entry in groups.all() { - println!("{entry}"); - } - } else { - println!("Staged changes:"); - for e in groups.ordinary.iter().filter(|e| e.is_staged()) { - println!(" {e}"); - } - - println!("Unstaged changes:"); - for e in groups.ordinary.iter().filter(|e| e.is_worktree_changed()) { - println!(" {e}"); - } - - if request.show_untracked { - println!("Untracked files:"); - for e in groups.untracked { - println!(" {e}"); - } - } - - if request.show_ignored { - println!("Ignored files:"); - for e in groups.ignored { - println!(" {e}"); - } - } - } - } } impl MevaCommand for StatusCommand { @@ -84,7 +36,7 @@ impl MevaCommand for StatusCommand { } fn about(&self) -> &'static str { - "Show the working tree status" + "Display the current state of the working directory" } fn version(&self) -> &'static str { @@ -116,7 +68,6 @@ impl MevaCommand for StatusCommand { .short('b') .long(Self::ARG_BRANCH) .action(ArgAction::SetTrue) - .conflicts_with(Self::ARG_NO_BRANCH) .help("Show the branch and tracking info"), ) .arg( @@ -126,13 +77,6 @@ impl MevaCommand for StatusCommand { .conflicts_with(Self::ARG_BRANCH) .help("Do not show the branch and tracking info"), ) - .arg( - Arg::new(Self::ARG_UNTRACKED) - .short('u') - .long(Self::ARG_UNTRACKED) - .action(ArgAction::SetTrue) - .help("Show untracked files"), - ) .arg( Arg::new(Self::ARG_IGNORED) .short('i') @@ -144,19 +88,24 @@ impl MevaCommand for StatusCommand { /// Executes the `status` command. fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { - let short = matches.get_flag(Self::ARG_SHORT); - let branch = matches.get_flag(Self::ARG_BRANCH); - let no_branch = matches.get_flag(Self::ARG_NO_BRANCH); - let untracked = matches.get_flag(Self::ARG_UNTRACKED); - let ignored = matches.get_flag(Self::ARG_IGNORED); + let request = Request::from_flags( + matches.get_flag(Self::ARG_SHORT), + matches.get_flag(Self::ARG_BRANCH), + matches.get_flag(Self::ARG_NO_BRANCH), + matches.get_flag(Self::ARG_IGNORED), + ); - let request = Request::from_flags(short, Some(branch), no_branch, untracked, ignored); + let short_format = request.short_format; + let show_branch = request.show_branch; let handler = container.status_handler().into_diagnostic()?; let response = handler.handle_status(request.clone()).into_diagnostic()?; - self.display_response(&request, &response); + println!( + "{}", + response.render_status(Some(short_format), Some(show_branch)) + ); Ok(()) } @@ -172,7 +121,10 @@ mod tests { fn test_command_name_about_version() { let cmd = StatusCommand::new(); assert_eq!(cmd.name(), "status"); - assert_eq!(cmd.about(), "Show the working tree status"); + assert_eq!( + cmd.about(), + "Display the current state of the working directory" + ); assert_eq!(cmd.version(), "1.0.0"); } } diff --git a/engine/src/diff/diff_builder.rs b/engine/src/diff/diff_builder.rs index a95e510..fefed6a 100644 --- a/engine/src/diff/diff_builder.rs +++ b/engine/src/diff/diff_builder.rs @@ -10,7 +10,9 @@ use super::models::{DiffMode, FileChange, FileChangeKind, Hunk, HunkLine, HunkLi use crate::{ RepositoryLayout, errors::EngineResult, - index::{FileMode, MevaIndex, WorkingDir}, + index::{ + FileMode, MevaIndex, WorkingDir, index_entry::IndexEntry, working_dir::WorkingDirEntry, + }, object_storage::{MevaObjectStorage, ObjectStorage}, objects::{ MevaBlob, MevaCommit, MevaObject, MevaTree, ObjectEntry, tree_entry_type::TreeEntryType, @@ -65,6 +67,24 @@ impl<'a> DiffBuilder<'a> { }) } + /// Creates a new `DiffBuilder` instance using an existing `MevaIndex`. + /// + /// This constructor is useful when the index is already loaded in memory, + /// avoiding the cost of reading it from disk again. + /// + /// # Arguments + /// + /// * `repo_layout` - A reference to the repository's layout. + /// * `index` - The pre-loaded `MevaIndex` instance to use for diffing. + pub fn with_index(repo_layout: &'a dyn RepositoryLayout, index: MevaIndex<'a>) -> Self { + Self { + object_storage: MevaObjectStorage::new(repo_layout), + revision_resolver: RevisionResolver::new(repo_layout), + index, + working_dir: WorkingDir::with_ignore_services(repo_layout), + } + } + /// Computes the difference between two repository snapshots (commits). /// /// # Arguments @@ -286,10 +306,18 @@ impl<'a> DiffBuilder<'a> { /// Computes the difference between the index (staging area) and the working directory. /// + /// This method detects changes that have not been staged yet (e.g., modifications, + /// deletions, and untracked files which are considered 'added' relative to the index). + /// /// # Arguments /// /// * `mode` - The level of detail for the diff ([`DiffMode`]). - /// * `paths` - An optional slice of paths to limit the diff to. + /// * `paths` - An optional slice of paths to limit the diff to. If `None`, the entire + /// working directory is compared. + /// * `index_map` - An optional, pre-computed map of index entries. If `None`, this + /// method will generate its own map from the loaded index. + /// * `working_map` - An optional, pre-computed map of working directory entries. + /// If `None`, this method will scan the working directory to create one. /// /// # Returns /// @@ -298,17 +326,25 @@ impl<'a> DiffBuilder<'a> { &self, mode: &DiffMode, paths: Option<&[PathBuf]>, + index_map: Option>, + working_map: Option>, ) -> EngineResult> { let mut added = Vec::new(); let mut deleted = Vec::new(); let mut modified = Vec::new(); let base_path = self.working_dir.get_absolute_path(); - let index_map = self.index.get_entries_map(paths); + let index_map = match index_map { + Some(map) => map, + None => self.index.get_entries_map(paths), + }; - let working = self.working_dir.collect_files_with_metadata(false, paths)?; + let working_map = match working_map { + Some(map) => map, + None => self.working_dir.collect_files_with_metadata(false, paths)?, + }; - for (path, entry) in &working { + for (path, entry) in &working_map { let absolute_path = base_path.join(path); match index_map.get(path) { None => match mode { @@ -335,20 +371,23 @@ impl<'a> DiffBuilder<'a> { } } }, - Some(index_entry) => match mode { - DiffMode::NameOnly => { - modified.push(FileChange::default_modified( - index_entry.path.clone().into(), - path.to_path_buf(), - )); + Some(index_entry) => { + if !FileChange::is_modified_heuristic( + entry.file_size, + &entry.mtime, + index_entry.file_size, + &index_entry.mtime, + ) { + continue; } - _ => { - if FileChange::is_modified_heuristic( - entry.file_size, - &entry.mtime, - index_entry.file_size, - &index_entry.mtime, - ) { + match mode { + DiffMode::NameOnly => { + modified.push(FileChange::default_modified( + index_entry.path.clone().into(), + path.to_path_buf(), + )); + } + _ => { let blob = MevaBlob::from_file(&absolute_path)?; match blob.text_content() { Ok(new_content) => { @@ -387,12 +426,12 @@ impl<'a> DiffBuilder<'a> { } } } - }, + } } } for (path, entry) in &index_map { - if !working.contains_key(path) { + if !working_map.contains_key(path) { match mode { DiffMode::NameOnly => deleted.push(FileChange::default_deleted(path.into())), _ => match self.get_text_content(&entry.sha1) { @@ -633,7 +672,7 @@ impl<'a> DiffBuilder<'a> { /// # Returns /// /// A [`EngineResult`] with a [`HashMap`] mapping file paths to their [`ObjectEntry`] data. - fn collect_object_entries( + pub fn collect_object_entries( &self, path: PathBuf, tree_hash: &str, diff --git a/engine/src/diff/models/change_kind.rs b/engine/src/diff/models/change_kind.rs index f283927..cec8208 100644 --- a/engine/src/diff/models/change_kind.rs +++ b/engine/src/diff/models/change_kind.rs @@ -26,25 +26,77 @@ pub enum ChangeKind { Unmerged, } +impl ChangeKind { + /// Renders a full, human-readable, and colored string representation of the change kind. + /// + /// # Examples + /// + /// * `Added` -> `"Added"` (in green) + /// * `Modified` -> `"Modified"` (in yellow) + pub fn render_full(&self) -> String { + match self { + Self::Added => "Added".green().bold().to_string(), + Self::Modified => "Modified".yellow().bold().to_string(), + Self::Deleted => "Deleted".red().bold().to_string(), + Self::Unmerged => "Unmerged".bright_yellow().bold().to_string(), + } + } + + /// Generates a human-readable description for an unmerged (conflict) state. + /// + /// This function describes the nature of a merge conflict by comparing the + /// change from the "ours" side (e.g., the current branch) and the "theirs" + /// side (e.g., the branch being merged). + /// + /// # Arguments + /// + /// * `ours` - The `ChangeKind` representing the change on the "ours" side. + /// * `theirs` - The `ChangeKind` representing the change on the "theirs" side. + /// + /// # Returns + /// + /// A static string slice describing the conflict, such as "added by both" + /// or "modified by us, deleted by them". + pub fn render_unmerged_state(ours: Self, theirs: Self) -> &'static str { + match (ours, theirs) { + (Self::Added, Self::Added) => "Added by both", + (Self::Deleted, Self::Deleted) => "Deleted by both", + (Self::Modified, Self::Modified) => "Modified by both", + (Self::Added, Self::Deleted) => "Added by us, deleted by them", + (Self::Deleted, Self::Added) => "Deleted by us, added by them", + (Self::Added, Self::Modified) => "Added by us, modified by them", + (Self::Modified, Self::Added) => "Modified by us, added by them", + (Self::Deleted, Self::Modified) => "Deleted by us, modified by them", + (Self::Modified, Self::Deleted) => "Modified by us, deleted by them", + _ => "Unmerged", + } + } +} + impl Display for ChangeKind { /// Formats the change kind as a single-character code (e.g. `A`, `M`, `D`, `U`). fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let symbol = match self { - ChangeKind::Added => "A".green().bold().to_string(), - ChangeKind::Modified => "M".yellow().bold().to_string(), - ChangeKind::Deleted => "D".red().bold().to_string(), - ChangeKind::Unmerged => "U".bright_yellow().bold().to_string(), + Self::Added => "A".green().bold().to_string(), + Self::Modified => "M".yellow().bold().to_string(), + Self::Deleted => "D".red().bold().to_string(), + Self::Unmerged => "U".bright_yellow().bold().to_string(), }; write!(f, "{symbol}") } } +/// Converts a reference to `FileChangeKind` into a `ChangeKind`. +/// +/// This provides a convenient way to map a detailed diff change (which might +/// include more data) into this simpler status-oriented `ChangeKind`. impl From<&FileChangeKind> for ChangeKind { + /// Performs the conversion from `&FileChangeKind` to `ChangeKind`. fn from(value: &FileChangeKind) -> Self { match value { - FileChangeKind::Added { .. } => ChangeKind::Added, - FileChangeKind::Deleted { .. } => ChangeKind::Deleted, - FileChangeKind::Modified { .. } => ChangeKind::Modified, + FileChangeKind::Added { .. } => Self::Added, + FileChangeKind::Deleted { .. } => Self::Deleted, + FileChangeKind::Modified { .. } => Self::Modified, } } } diff --git a/engine/src/handlers/diff/handlers.rs b/engine/src/handlers/diff/handlers.rs index da7cfdc..19540cc 100644 --- a/engine/src/handlers/diff/handlers.rs +++ b/engine/src/handlers/diff/handlers.rs @@ -78,7 +78,7 @@ impl<'a> DiffOperations for DiffHandler<'a> { // Diff between the index and the working directory. (RevisionSide::Index, RevisionSide::WorkingDir) => self .diff_builder - .diff_index_working_dir(&request.mode, request.paths.as_deref())?, + .diff_index_working_dir(&request.mode, request.paths.as_deref(), None, None)?, _ => unreachable!("Other combinations are not supported"), }; diff --git a/engine/src/handlers/status.rs b/engine/src/handlers/status.rs index 7843843..9f86a02 100644 --- a/engine/src/handlers/status.rs +++ b/engine/src/handlers/status.rs @@ -4,5 +4,3 @@ mod operations; pub use handlers::StatusHandler; pub use operations::*; - -pub use models::StatusGrouping; diff --git a/engine/src/handlers/status/handlers.rs b/engine/src/handlers/status/handlers.rs index 1731057..1a37b3e 100644 --- a/engine/src/handlers/status/handlers.rs +++ b/engine/src/handlers/status/handlers.rs @@ -1,17 +1,351 @@ -use crate::errors::EngineResult; +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, +}; -use super::{Request, Response, StatusOperations}; +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use crate::{ + diff::{ChangeKind, DiffBuilder, DiffMode}, + errors::EngineResult, + index::{ + MevaIndex, WorkingDir, index_entry::IndexEntry, stage::Stage, working_dir::WorkingDirEntry, + }, + object_storage::{MevaObjectStorage, ObjectStorage}, + objects::{MevaCommit, ObjectEntry}, + ref_manager::{Head, HeadMode, MevaRefManager, RefManager}, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; + +use super::{ + Request, Response, StatusOperations, + models::{BranchInfo, MergeEntry, StatusEntry}, +}; + +/// Handles the logic for calculating and reporting the repository status. +/// +/// This struct is stateless and provides the main entry point (`status`) +/// for orchestrating the complex task of comparing HEAD, the index, +/// and the working directory. pub struct StatusHandler; impl StatusHandler { + /// Public entry point to handle a status request. + /// + /// This is a simple wrapper that delegates to the [StatusOperations] + /// trait implementation. pub fn handle_status(&self, request: Request) -> EngineResult { self.status(request) } + + /// Splits index entries into "normal" (stage 0) and "unmerged" (non-zero stage). + /// + /// "Normal" entries represent files that are tracked and not in a conflict state. + /// "Others" represent files that are currently in a merge conflict (stages 1, 2, or 3). + fn split_index_entries(&self, entries: Vec) -> (Vec, Vec) { + let mut normal = Vec::new(); + let mut others = Vec::new(); + + for entry in entries { + match entry.stage { + Stage::Normal => normal.push(entry), + _ => others.push(entry), + } + } + + (normal, others) + } + + /// Groups unmerged index entries (stages 1, 2, 3) by their file path. + /// + /// This organizes the different conflict versions (base, ours, theirs) + /// into a single `MergeEntry` struct for each conflicting path. + pub fn group_unmerged_by_path(&self, entries: Vec) -> HashMap { + let mut map: HashMap = HashMap::new(); + + for entry in entries { + let key = entry.path.clone(); + let slot = map.entry(key).or_insert(MergeEntry { + base: None, + ours: None, + theirs: None, + }); + + match entry.stage { + Stage::Base => slot.base = Some(entry), + Stage::Ours => slot.ours = Some(entry), + Stage::Theirs => slot.theirs = Some(entry), + Stage::Normal => {} // Should not happen if `split_index_entries` is used correctly + } + } + + map + } + + /// Converts the grouped [MergeEntry] map into a list of [StatusEntry] items. + /// + /// It analyzes the `base`, `ours`, and `theirs` versions for each path + /// to determine the specific type of conflict (e.g., "both added", "deleted by them"). + pub fn collect_unmerged_status_entries( + &self, + others: HashMap, + ) -> Vec { + let mut status_entries = Vec::new(); + + for (path, unmerged_entry) in others { + let (ours, theirs) = match ( + unmerged_entry.base, + unmerged_entry.ours, + unmerged_entry.theirs, + ) { + // (Base, Ours, Theirs) + (Some(_), Some(_), Some(_)) => (ChangeKind::Unmerged, ChangeKind::Unmerged), // both modified + (Some(_), Some(_), None) => (ChangeKind::Modified, ChangeKind::Deleted), // deleted by them + (Some(_), None, Some(_)) => (ChangeKind::Deleted, ChangeKind::Modified), // deleted by us + (None, Some(_), Some(_)) => (ChangeKind::Added, ChangeKind::Added), // both added + (None, Some(_), None) => (ChangeKind::Added, ChangeKind::Unmerged), // added by us (unusual case) + (None, None, Some(_)) => (ChangeKind::Unmerged, ChangeKind::Added), // added by them (unusual case) + _ => (ChangeKind::Unmerged, ChangeKind::Unmerged), // fallback + }; + + status_entries.push(StatusEntry::unmerged(path, ours, theirs)); + } + + status_entries + } + + /// Collects information about the current branch or HEAD state. + /// + /// Populates a [BranchInfo] struct based on the [Head] mode (symbolic or direct/detached) + /// and whether any commits exist. + fn get_branch_info( + &self, + head: &Head, + head_result: &EngineResult>, + ) -> BranchInfo { + let has_commits = matches!(head_result, Ok(Some(_))); + match &head.mode { + HeadMode::Direct => BranchInfo { + head: Some(head.target.clone()), + is_detached: true, + has_commits, + upstream: None, + ahead: 0, + behind: 0, + }, + HeadMode::Symbolic => BranchInfo { + head: head.extract_branch_name(), + is_detached: false, + has_commits, + upstream: None, + ahead: 0, + behind: 0, + }, + } + } + + /// Collects files from the working directory, handling the `show_ignored` flag. + /// + /// If `show_ignored` is true, it partitions files into ignored and non-ignored, + /// populating `response.ignored` and returning the non-ignored files. + /// Otherwise, it returns only the non-ignored files. + fn collect_working_dir( + &self, + working_dir: &WorkingDir, + show_ignored: bool, + response: &mut Response, + ) -> EngineResult> { + match show_ignored { + true => { + let files = working_dir.collect_files_with_metadata(true, None)?; + let (ignored_files, files): (Vec<_>, Vec<_>) = + files.into_iter().partition(|(_, e)| e.is_ignored); + + let ignored_files = ignored_files + .iter() + .map(|(p, _)| StatusEntry::ignored(p)) + .collect::>(); + + response.ignored = Some(ignored_files); + Ok(files.into_iter().collect()) + } + false => working_dir.collect_files_with_metadata(false, None), + } + } + + /// Identifies untracked files and adds them to the response. + /// + /// It filters the working directory map, keeping only files that do *not* exist + /// in the set of "normal" (tracked) index paths. + fn collect_untracked_files( + &self, + working_map: &HashMap, + normal_paths: &HashSet, + response: &mut Response, + ) { + let untracked = working_map + .iter() + .filter_map(|(_, e)| match !normal_paths.contains(&e.path) { + true => Some(e), + false => None, + }) + .collect::>(); + + if !untracked.is_empty() { + let untracked_status_entries = untracked + .iter() + .map(|p| StatusEntry::untracked(&p.path)) + .collect(); + response.untracked = Some(untracked_status_entries); + } + } + + /// Processes and collects all unmerged (conflicting) file entries. + /// + /// This orchestrates the grouping and conversion of unmerged index entries + /// and adds them to the response. + fn collect_unmerged_files(&self, unmerged_entries: Vec, response: &mut Response) { + if !unmerged_entries.is_empty() { + let grouped_unmerged = self.group_unmerged_by_path(unmerged_entries); + let unmerged_status_entries = self.collect_unmerged_status_entries(grouped_unmerged); + response.unmerged = Some(unmerged_status_entries); + } + } + + /// Calculates **unstaged** changes (diff between Index and Working Directory). + /// + /// It compares the "normal" index entries against the files in the working directory. + /// It filters out `Added` changes, as those are handled as `Untracked` files. + fn collect_unstaged_changes( + &self, + diff_builder: &DiffBuilder, + normal_entries: &[IndexEntry], + working_map: HashMap, + response: &mut Response, + ) -> EngineResult<()> { + let index_map = normal_entries + .iter() + .map(|entry| (PathBuf::from(&entry.path), entry)) + .collect::>(); + + let changes = diff_builder.diff_index_working_dir( + &DiffMode::NameOnly, + None, + Some(index_map), + Some(working_map), + )?; + + let unstaged = changes + .iter() + .filter_map(|c| match ChangeKind::from(&c.kind) { + // We call these "Untracked" and handle them separately + ChangeKind::Added => None, + // Modified or Deleted files are "unstaged" changes + kind => Some(StatusEntry::unstaged(c.path(), kind)), + }) + .collect::>(); + + response.unstaged = Some(unstaged); + Ok(()) + } + + /// Calculates **staged** changes (diff between HEAD and Index). + /// + /// If no HEAD commit exists (e.g., empty repo), this does nothing. + /// It compares the tree objects from the HEAD commit against the "normal" entries + /// currently in the index. + fn collect_staged_changes( + &self, + diff_builder: &DiffBuilder, + object_storage: &MevaObjectStorage, + normal_entries: &[IndexEntry], + head_hash: &Option, + response: &mut Response, + ) -> EngineResult<()> { + let normal_objects = normal_entries + .par_iter() + .map(|entry| { + let path = PathBuf::from(&entry.path); + ( + path.clone(), + ObjectEntry { + entry_type: entry.mode.into(), + hash: entry.sha1.clone(), + size: Some(entry.file_size), + path, + }, + ) + }) + .collect(); + + let head_objects = match head_hash { + Some(head_hash) => { + let head_commit = MevaCommit::try_from(object_storage.get_object(head_hash)?)?; + diff_builder.collect_object_entries(PathBuf::new(), &head_commit.tree, None)? + } + None => HashMap::::new(), + }; + + let changes = + diff_builder.diff_tree_vs_tree(&head_objects, &normal_objects, &DiffMode::NameOnly)?; + + let staged = changes + .iter() + .map(|c| StatusEntry::staged(c.path(), ChangeKind::from(&c.kind))) + .collect::>(); + + response.staged = Some(staged); + Ok(()) + } } impl StatusOperations for StatusHandler { - fn status(&self, _request: Request) -> EngineResult { - todo!() + /// Orchestrates the entire status operation. + fn status(&self, request: Request) -> EngineResult { + let mut response = Response::default(); + + let repo_layout = MevaRepositoryLayout::discover()?; + let working_dir = WorkingDir::with_ignore_services(&repo_layout); + let index = MevaIndex::from_disk(&repo_layout, Some(false))?; + let ref_manager = MevaRefManager::new(&repo_layout); + let object_storage = MevaObjectStorage::new(&repo_layout); + + let head = ref_manager.read_head()?; + let head_result = ref_manager.resolve_commit_hash(&head); + + if request.show_branch { + response.branch = Some(self.get_branch_info(&head, &head_result)); + } + + let head_hash = head_result.unwrap_or(None); + + let working_map = + self.collect_working_dir(&working_dir, request.show_ignored, &mut response)?; + + let entries = index.get_entries_owned(); + let (normal, unmerged) = self.split_index_entries(entries); + + let normal_paths = normal + .iter() + .map(|e| PathBuf::from(&e.path)) + .collect::>(); + + self.collect_untracked_files(&working_map, &normal_paths, &mut response); + self.collect_unmerged_files(unmerged, &mut response); + + if !normal.is_empty() { + let diff_builder = DiffBuilder::with_index(&repo_layout, index); + + self.collect_unstaged_changes(&diff_builder, &normal, working_map, &mut response)?; + self.collect_staged_changes( + &diff_builder, + &object_storage, + &normal, + &head_hash, + &mut response, + )?; + } + + Ok(response) } } diff --git a/engine/src/handlers/status/models.rs b/engine/src/handlers/status/models.rs index 3962e0c..e98eee8 100644 --- a/engine/src/handlers/status/models.rs +++ b/engine/src/handlers/status/models.rs @@ -1,8 +1,8 @@ mod branch_info; -mod grouped_status; +mod merge_entry; mod status_entry; mod status_kind; pub use branch_info::BranchInfo; -pub use grouped_status::StatusGrouping; +pub use merge_entry::MergeEntry; pub use status_entry::StatusEntry; diff --git a/engine/src/handlers/status/models/branch_info.rs b/engine/src/handlers/status/models/branch_info.rs index 28576aa..2c960bb 100644 --- a/engine/src/handlers/status/models/branch_info.rs +++ b/engine/src/handlers/status/models/branch_info.rs @@ -1,9 +1,10 @@ use std::fmt::Display; -/// Represents information about a branch state. +/// Represents information about the current branch and HEAD state. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BranchInfo { - /// The name of the current branch, if not in a detached HEAD state. + /// The name of the current branch, or the commit OID if in a detached HEAD state. + /// `None` might indicate an uninitialized repository state. pub head: Option, /// The name of the upstream branch being tracked, if any. @@ -15,33 +16,43 @@ pub struct BranchInfo { /// Number of commits the local branch is behind the upstream. pub behind: usize, - /// Indicates whether HEAD is detached (not pointing to any branch). + /// Indicates whether HEAD is detached (pointing directly to a commit, not a branch). pub is_detached: bool, + + /// True if the current branch or HEAD has at least one commit. + /// If false, indicates an orphan branch or an empty repository. + pub has_commits: bool, } impl Display for BranchInfo { - /// Formats branch information in a human-readable form. + /// Formats the branch info into a human-readable status message. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.is_detached { - return match &self.head { - Some(oid) => write!(f, "HEAD (detached at {oid})"), - None => write!(f, "HEAD (detached)"), - }; - } - if let Some(head) = &self.head { + match &self.head { + Some(oid) => writeln!(f, "HEAD (detached at {oid})")?, + None => writeln!(f, "HEAD (detached)")?, + } + } else if let Some(head) = &self.head { match &self.upstream { Some(up) if self.ahead == 0 && self.behind == 0 => { - write!(f, "On branch {head} (up to date with {up})") + writeln!(f, "On branch {head} (up to date with {up})") } - Some(up) => write!( + Some(up) => writeln!( f, "On branch {head} ({} ahead, {} behind of {})", self.ahead, self.behind, up ), - None => write!(f, "On branch {head}"), + None => writeln!(f, "On branch {head}"), + }?; + if !self.has_commits { + // This is a new/orphan branch with no commit history + writeln!(f, "\nNo commits yet")?; + return writeln!(f); } } else { - write!(f, "On unknown branch") + // Should not happen in a valid repository + writeln!(f, "On unknown branch")?; } + writeln!(f) } } diff --git a/engine/src/handlers/status/models/grouped_status.rs b/engine/src/handlers/status/models/grouped_status.rs deleted file mode 100644 index 45008a1..0000000 --- a/engine/src/handlers/status/models/grouped_status.rs +++ /dev/null @@ -1,81 +0,0 @@ -use super::{status_entry::StatusEntry, status_kind::StatusKind}; - -/// Represents a collection of `StatusEntry` items grouped by their kind: -/// - ordinary (tracked) files, -/// - untracked files, -/// - ignored files. -#[allow(dead_code)] -pub struct GroupedStatus<'a> { - /// Tracked files with or without staged/unstaged changes. - pub ordinary: Vec<&'a StatusEntry>, - - /// Files not yet tracked by the repository. - pub untracked: Vec<&'a StatusEntry>, - - /// Files explicitly ignored by ignore rules. - pub ignored: Vec<&'a StatusEntry>, -} - -#[allow(dead_code)] -impl<'a> GroupedStatus<'a> { - /// Returns the total number of entries in all groups. - pub fn total(&self) -> usize { - self.ordinary_count() + self.untracked_count() + self.ignored_count() - } - - /// Returns the number of ordinary (tracked) entries. - pub fn ordinary_count(&self) -> usize { - self.ordinary.len() - } - - /// Returns the number of untracked entries. - pub fn untracked_count(&self) -> usize { - self.untracked.len() - } - - /// Returns the number of ignored entries. - pub fn ignored_count(&self) -> usize { - self.ignored.len() - } - - /// Returns all entries from all groups in a single vector. - pub fn all(&self) -> Vec<&'a StatusEntry> { - let mut v = Vec::with_capacity(self.total()); - v.extend(self.ordinary.iter().cloned()); - v.extend(self.untracked.iter().cloned()); - v.extend(self.ignored.iter().cloned()); - v - } -} - -#[allow(dead_code)] -/// Trait for collections of status entries that can be grouped by kind. -pub trait StatusGrouping { - /// Groups status entries into `GroupedStatus` by their kind. - fn grouped(&self) -> GroupedStatus<'_>; -} - -impl StatusGrouping for T -where - T: AsRef<[StatusEntry]>, -{ - fn grouped(&self) -> GroupedStatus<'_> { - let mut ordinary = Vec::new(); - let mut untracked = Vec::new(); - let mut ignored = Vec::new(); - - for e in self.as_ref() { - match e.kind { - StatusKind::Ordinary { .. } => ordinary.push(e), - StatusKind::Untracked => untracked.push(e), - StatusKind::Ignored => ignored.push(e), - } - } - - GroupedStatus { - ordinary, - untracked, - ignored, - } - } -} diff --git a/engine/src/handlers/status/models/merge_entry.rs b/engine/src/handlers/status/models/merge_entry.rs new file mode 100644 index 0000000..b65093c --- /dev/null +++ b/engine/src/handlers/status/models/merge_entry.rs @@ -0,0 +1,19 @@ +use crate::index::index_entry::IndexEntry; + +/// Represents the different versions of a single file involved in a 3-way merge. +/// +/// This struct holds the state of a file from the common ancestor (`base`), +/// the "ours" side (e.g., the current branch), and the "theirs" side +/// (e.g., the branch being merged in). `None` indicates that the file +/// does not exist in that specific version (e.g., it was added or deleted). +#[derive(Debug, Default)] +pub struct MergeEntry { + /// The common ancestor (merge base) version of the file, if one exists. + pub base: Option, + + /// The version of the file from the "ours" side (e.g., the current branch). + pub ours: Option, + + /// The version of the file from the "theirs" side (e.g., the branch being merged). + pub theirs: Option, +} diff --git a/engine/src/handlers/status/models/status_entry.rs b/engine/src/handlers/status/models/status_entry.rs index 517250a..7f49ed7 100644 --- a/engine/src/handlers/status/models/status_entry.rs +++ b/engine/src/handlers/status/models/status_entry.rs @@ -1,108 +1,170 @@ use std::{fmt::Display, path::PathBuf}; +use owo_colors::OwoColorize; +use shared::PathToString; + use crate::diff::ChangeKind; use super::status_kind::StatusKind; -/// Represents a single file entry in the repository status. +/// Represents a single file's entry in the overall repository status. +/// +/// This struct pairs a file path with its specific [StatusKind] (e.g., +/// Staged, Unstaged, Untracked). #[derive(Debug, Clone, PartialEq, Eq)] pub struct StatusEntry { - /// The kind of status: ordinary (tracked), untracked, or ignored. + /// Specifies the status category for this file. pub kind: StatusKind, - /// The current path of the file in the working directory. + /// The file path, typically relative to the repository root. pub path: PathBuf, - - /// The original path of the file (for renames), if any. - pub orig_path: Option, } impl StatusEntry { - /// Constructs a tracked (ordinary) file entry with given staged and - /// worktree statuses, optionally specifying the original path. - pub fn ordinary( - path: impl Into, - index: ChangeKind, - worktree: ChangeKind, - orig_path: Option, - ) -> Self { + /// Creates a new [StatusEntry] for a **staged** file. + /// + /// # Arguments + /// + /// * `path` - The path of the file. + /// * `change` - The `ChangeKind` (e.g., Added, Modified) relative to HEAD. + pub fn staged(path: impl Into, change: ChangeKind) -> Self { Self { - kind: StatusKind::Ordinary { - index_status: index, - worktree_status: worktree, - }, + kind: StatusKind::Staged(change), path: path.into(), - orig_path, } } - /// Constructs an untracked file entry. + /// Creates a new [StatusEntry] for an **unstaged** file. + /// + /// This represents a change in the working directory that is not in the index. + /// + /// # Arguments + /// + /// * `path` - The path of the file. + /// * `change` - The `ChangeKind` (e.g., Modified, Deleted) relative to the index. + pub fn unstaged(path: impl Into, change: ChangeKind) -> Self { + Self { + kind: StatusKind::Unstaged(change), + path: path.into(), + } + } + + /// Creates a new [StatusEntry] for an **untracked** file. + /// + /// # Arguments + /// + /// * `path` - The path of the untracked file. pub fn untracked(path: impl Into) -> Self { Self { kind: StatusKind::Untracked, path: path.into(), - orig_path: None, } } - /// Constructs an ignored file entry. + /// Creates a new [StatusEntry] for an **ignored** file. + /// + /// # Arguments + /// + /// * `path` - The path of the ignored file. pub fn ignored(path: impl Into) -> Self { Self { kind: StatusKind::Ignored, path: path.into(), - orig_path: None, } } - /// Returns `true` if the file is untracked. + /// Creates a new [StatusEntry] for an **unmerged** file (in a conflict state). + /// + /// # Arguments + /// + /// * `path` - The path of the conflicting file. + /// * `ours` - The `ChangeKind` on the 'ours' side of the merge. + /// * `theirs` - The `ChangeKind` on the 'theirs' side of the merge. + pub fn unmerged(path: impl Into, ours: ChangeKind, theirs: ChangeKind) -> Self { + Self { + kind: StatusKind::Unmerged { ours, theirs }, + path: path.into(), + } + } + + /// Checks if the status kind is `Untracked`. pub fn is_untracked(&self) -> bool { matches!(self.kind, StatusKind::Untracked) } - /// Returns `true` if the file is ignored. + /// Checks if the status kind is `Ignored`. pub fn is_ignored(&self) -> bool { matches!(self.kind, StatusKind::Ignored) } - /// Returns `true` if the file is tracked (ordinary). - pub fn is_ordinary(&self) -> bool { - matches!(self.kind, StatusKind::Ordinary { .. }) + /// Checks if the status kind is `Unstaged`. + pub fn is_unstaged(&self) -> bool { + matches!(self.kind, StatusKind::Unstaged(_)) } - /// Returns `true` if the file has staged changes in the index. + /// Checks if the status kind is `Staged`. pub fn is_staged(&self) -> bool { - todo!() + matches!(self.kind, StatusKind::Staged(_)) } - /// Returns `true` if the file has changes in the working tree (unstaged). - pub fn is_worktree_changed(&self) -> bool { - todo!() + /// Renders a short, colored, two-column status string. + /// + /// # Examples + /// + /// * `M path/to/file.txt` (Staged Modification) + /// * ` M path/to/file.txt` (Unstaged Modification) + /// * `?? path/to/new.txt` (Untracked) + /// * `AD path/to/conflict.txt` (Unmerged: Added by us, Deleted by them) + pub fn render_short(&self) -> String { + match &self.kind { + StatusKind::Untracked => { + format!("{} {}", "??".bright_black(), self.path.to_utf8_string()) + } + StatusKind::Ignored => { + format!("{} {}", "!!".bright_black(), self.path.to_utf8_string()) + } + StatusKind::Staged(change) => { + format!("{} {}", change, self.path.to_utf8_string()) + } + StatusKind::Unstaged(change) => { + format!(" {} {}", change, self.path.to_utf8_string()) + } + StatusKind::Unmerged { ours: x, theirs: y } => { + let x = x.to_string(); + let y = y.to_string(); + format!("{x}{y} {}", self.path.to_utf8_string()) + } + } } - /// Renders a short-format string of the file status. - pub fn render_short(&self) -> String { + /// Renders a long-format, human-readable status string. + /// + /// This format is typically used in the main body of the `status` command output. + /// + /// # Examples + /// + /// * `Modified: path/to/file.txt` + /// * `added by both: path/to/conflict.txt` + /// * `path/to/new.txt` (for Untracked) + pub fn render_full(&self) -> String { match &self.kind { - StatusKind::Untracked => format!("??\t{}", self.path.display()), - StatusKind::Ignored => format!("!!\t{}", self.path.display()), - StatusKind::Ordinary { - index_status, - worktree_status, - } => { - let x = index_status.to_string(); - let y = worktree_status.to_string(); - match &self.orig_path { - Some(orig) => { - format!("{}{}\t{} -> {}", x, y, orig.display(), self.path.display()) - } - None => format!("{}{}\t{}", x, y, self.path.display()), - } + StatusKind::Untracked | StatusKind::Ignored => self.path.to_utf8_string(), + StatusKind::Staged(change) | StatusKind::Unstaged(change) => { + format!("{} {}", change.render_full(), self.path.to_utf8_string()) + } + StatusKind::Unmerged { ours, theirs } => { + format!( + "{} {}", + ChangeKind::render_unmerged_state(*ours, *theirs), + self.path.to_utf8_string() + ) } } } } impl Display for StatusEntry { - /// Formats the `StatusEntry` using the short-format representation. + /// Formats the [StatusEntry] using the short-format representation (`render_short`). fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.render_short()) } diff --git a/engine/src/handlers/status/models/status_kind.rs b/engine/src/handlers/status/models/status_kind.rs index b68c2be..47178cd 100644 --- a/engine/src/handlers/status/models/status_kind.rs +++ b/engine/src/handlers/status/models/status_kind.rs @@ -1,19 +1,31 @@ use crate::diff::ChangeKind; -/// Represents the type of a file's status in the working tree and index. +/// Represents the specific status of a file, categorizing its state +/// relative to HEAD, the index (staging area), and the working directory. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StatusKind { - /// A file that is tracked in the index and may have changes in the index - /// or working tree. - Ordinary { - /// The state of the file in the index (staged changes). - index_status: ChangeKind, + /// The file has a change that is staged (in the index) and ready for the next commit. + /// The `ChangeKind` describes the difference between HEAD and the index. + Staged(ChangeKind), + + /// The file has a change in the working directory that is not staged (not in the index). + /// The `ChangeKind` describes the difference between the index and the working directory. + Unstaged(ChangeKind), - /// The state of the file in the working tree (unstaged changes). - worktree_status: ChangeKind, - }, /// A file that exists in the working directory but is not tracked in the index. Untracked, + /// A file that matches ignore rules and is intentionally ignored. Ignored, + + /// Represents a file that is currently in a merge conflict state. + /// + /// This variant holds the state of the file from both "ours" (e.g., the current branch) + /// and "theirs" (e.g., the branch being merged) sides to describe the conflict. + Unmerged { + /// The kind of change on the "ours" side of the conflict. + ours: ChangeKind, + /// The kind of change on the "theirs" side of the conflict. + theirs: ChangeKind, + }, } diff --git a/engine/src/handlers/status/operations.rs b/engine/src/handlers/status/operations.rs index 46954f1..fad242e 100644 --- a/engine/src/handlers/status/operations.rs +++ b/engine/src/handlers/status/operations.rs @@ -2,42 +2,141 @@ use super::models::{BranchInfo, StatusEntry}; use crate::errors::EngineResult; -#[derive(Clone)] +/// Holds configuration options for a `status` operation. +/// +/// This struct is constructed from command-line flags (or other inputs) +/// and passed to the `status` method to control its behavior. +#[derive(Clone, Debug)] pub struct Request { + /// If true, the status output will include branch information (e.g., current branch, ahead/behind count). pub show_branch: bool, - pub show_untracked: bool, + /// If true, ignored files will be included in the status output. pub show_ignored: bool, + /// If true, the status will be rendered in the short, one-line-per-file format. pub short_format: bool, } impl Request { - pub fn from_flags( - short: bool, - branch: Option, - no_branch: bool, - untracked: bool, - ignored: bool, - ) -> Self { - let show_branch = match (no_branch, branch) { - (true, _) => false, - (false, Some(b)) => b, - (false, None) => !short, + /// Creates a new [Request] from a set of raw boolean flags. + /// + /// This constructor correctly resolves the logic for showing branch information, + /// which depends on multiple flags. + /// + /// # Arguments + /// + /// * `short` - Corresponds to the short format flag (e.g., `-s`). + /// * `branch` - An explicit flag to control branch visibility (e.g., `--branch`). + /// * `no_branch` - An explicit flag to disable branch visibility (e.g., `--no-branch`). + /// * `ignored` - A flag to show ignored files (e.g., `--ignored`). + pub fn from_flags(short: bool, branch: bool, no_branch: bool, ignored: bool) -> Self { + let show_branch = match (no_branch, branch, short) { + (true, _, _) => false, + (false, true, _) => true, + (false, _, true) => false, + (false, _, false) => true, }; Self { show_branch, - show_untracked: untracked, show_ignored: ignored, short_format: short, } } } +/// Contains the categorized results of a status operation. +/// +/// This struct is populated by the `status` operation and contains all the +/// data necessary to render a complete status report. +#[derive(Debug, Default)] pub struct Response { + /// Information about the current branch and HEAD state. pub branch: Option, - pub entries: Vec, + /// A list of files with changes staged for commit (HEAD vs. Index). + pub staged: Option>, + /// A list of files with changes not staged for commit (Index vs. Working Directory). + pub unstaged: Option>, + /// A list of files present in the working directory but not tracked by the index. + pub untracked: Option>, + /// A list of files that are ignored by configuration. + /// This is typically only populated if [Request::show_ignored] was true. + pub ignored: Option>, + /// A list of files currently in a merge conflict (unmerged) state. + pub unmerged: Option>, +} + +impl Response { + /// A private helper function to format and render a single section of the status output. + /// + /// If the `entries` list is `None` or empty, this returns an empty string. + /// Otherwise, it returns a formatted block containing the title and all entries. + /// + /// # Arguments + /// + /// * `title` - The header text for this section (e.g., "Changes to be committed"). + /// * `entries` - The list of `StatusEntry` items to render. + /// * `short` - Whether to use the short (`render_short`) or long (`render_full`) format. + fn render_section( + &self, + title: &str, + entries: &Option>, + short: bool, + ) -> String { + match entries { + None => String::new(), + Some(entries) if entries.is_empty() => String::new(), + Some(entries) => { + let mut out = String::new(); + if !short { + out.push_str(&format!("{title}:\n")); + } + for entry in entries { + let rendered = match short { + true => format!("{}\n", entry.render_short()), + false => format!("\t{}\n", entry.render_full()), + }; + out.push_str(&rendered); + } + if !short { + out.push('\n'); + } + out + } + } + } + + /// Renders the complete, human-readable status report from the data in this [Response]. + /// + /// # Arguments + /// + /// * `short` - An `Option` to override the output format. + /// If `Some(true)`, forces short format. + /// If `Some(false)` or `None`, defaults to the long (standard) format. + pub fn render_status(&self, short: Option, branch: Option) -> String { + let short = short.unwrap_or(false); + let branch = branch.unwrap_or(true); + let mut out = String::new(); + + if branch && let Some(branch) = &self.branch { + out.push_str(&format!("{branch}")); + } + + out.push_str(&self.render_section("Changes to be committed", &self.staged, short)); + out.push_str(&self.render_section("Changes not staged for commit", &self.unstaged, short)); + out.push_str(&self.render_section("Unmerged paths", &self.unmerged, short)); + out.push_str(&self.render_section("Untracked files", &self.untracked, short)); + out.push_str(&self.render_section("Ignored files", &self.ignored, short)); + + if out.trim().is_empty() { + out.push_str("Nothing to commit, working tree clean\n"); + } + + out + } } +/// Defines the behavior for an object that can perform a repository status operation. pub trait StatusOperations { + /// Performs the status operation based on the provided [Request] options. fn status(&self, request: Request) -> EngineResult; } diff --git a/engine/src/index.rs b/engine/src/index.rs index 9a78697..d9090cf 100644 --- a/engine/src/index.rs +++ b/engine/src/index.rs @@ -11,4 +11,4 @@ use stage::Stage; pub use file_mode::FileMode; pub use meva_index::MevaIndex; -pub use working_dir::WorkingDir; +pub use working_dir::{WorkingDir, WorkingDirEntry}; diff --git a/engine/src/index/file_mode.rs b/engine/src/index/file_mode.rs index 306ad97..0fe0d9b 100644 --- a/engine/src/index/file_mode.rs +++ b/engine/src/index/file_mode.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; /// These values are used to differentiate between regular /// files, executables, symbolic links, /// and submodules (gitlinks). -#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, Eq, PartialEq)] pub enum FileMode { /// A regular non-executable file (`100644`). Normal = 0o100644, diff --git a/engine/src/index/meva_index.rs b/engine/src/index/meva_index.rs index c7bfca6..d06b1ed 100644 --- a/engine/src/index/meva_index.rs +++ b/engine/src/index/meva_index.rs @@ -82,6 +82,11 @@ impl<'a> MevaIndex<'a> { self.entries.values().collect() } + /// Returns all entries currently tracked in the index as owned objects. + pub fn get_entries_owned(&self) -> Vec { + self.entries.values().cloned().collect() + } + /// Returns a map of index entries, optionally filtered by a list of paths. /// /// # Arguments @@ -168,7 +173,7 @@ impl<'a> MevaIndex<'a> { let workdir_set = dir_files .iter() - .map(|p| p.to_utf8_string()) + .map(|(p, _)| p.to_utf8_string()) .collect::>(); files_in_index @@ -188,9 +193,7 @@ impl<'a> MevaIndex<'a> { /// A vector of [`PathBuf`]s for each untracked file. pub fn get_untracked_files(&self) -> Vec { let dir_path = self.repo_layout.working_dir(); - let dir_files = self - .working_dir - .collect_all_files_including_ignored(dir_path); + let dir_files = self.working_dir.collect_all_files_including_ignored(); let files_in_index = self .entries @@ -201,7 +204,12 @@ impl<'a> MevaIndex<'a> { dir_files .into_iter() - .filter(|p| !files_in_index.contains(&p.to_utf8_string())) + .filter_map( + |(p, _)| match !files_in_index.contains(&p.to_utf8_string()) { + true => Some(p), + false => None, + }, + ) .collect::>() } @@ -229,7 +237,12 @@ impl<'a> MevaIndex<'a> { add_ignored: bool, verbose: bool, ) -> EngineResult<(Vec, Vec, Vec)> { - let files_abs = self.working_dir.collect_files(path_abs, add_ignored); + let files_abs = self + .working_dir + .collect_files(path_abs, add_ignored) + .iter() + .map(|(p, _)| p.clone()) + .collect(); let (mut new_entries, mut modified_entries) = self.group_files(&files_abs)?; diff --git a/engine/src/index/working_dir.rs b/engine/src/index/working_dir.rs index 56bcff0..724f5b6 100644 --- a/engine/src/index/working_dir.rs +++ b/engine/src/index/working_dir.rs @@ -13,7 +13,7 @@ use crate::{ }; /// Represents metadata for a single file entry in the working directory. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct WorkingDirEntry { /// The relative path of the file within the working directory. pub path: PathBuf, @@ -23,6 +23,7 @@ pub struct WorkingDirEntry { pub file_size: u64, /// The file's mode, indicating if it's a regular file, executable, etc. pub mode: FileMode, + pub is_ignored: bool, } /// Provides an interface to the repository's working directory. @@ -73,44 +74,47 @@ impl<'a> WorkingDir<'a> { self.repo_layout.working_dir() } - /// Collects all files under the given path, respecting ignore rules. + /// Collects all files under the working directory, respecting ignore rules. /// This is a convenience method that delegates to `collect_files`. - /// - /// # Arguments - /// - /// * `path` - The starting path for file collection. #[allow(dead_code)] - pub fn collect_tracked_or_untracked_files(&self, path: &Path) -> Vec { + pub fn collect_tracked_or_untracked_files(&self) -> Vec { + let path = self.repo_layout.working_dir(); self.collect_files(path, false) + .into_iter() + .map(|(p, _)| p) + .collect() } - /// Collects **all files** under the given path, including those that would - /// normally be ignored by `.mevaignore` files. - /// - /// # Arguments + /// Returns a collection of all files in the working directory, including those that + /// would normally be ignored by `.mevaignore` rules. /// - /// * `path` - The starting path for file collection. - pub fn collect_all_files_including_ignored(&self, path: &Path) -> Vec { + /// This method returns a vector of tuples, where each tuple contains: + /// - An absolute path to a file (`PathBuf`). + /// - A boolean flag indicating whether the file is ignored (`true` means ignored). + pub fn collect_all_files_including_ignored(&self) -> Vec<(PathBuf, bool)> { + let path = self.repo_layout.working_dir(); self.collect_files(path, true) } - /// Recursively traverses a directory and collects the paths of all files it contains. + /// Recursively searches for files under the given path, optionally including ignored files. /// - /// This method performs two main filtering operations: - /// 1. It always excludes the internal `.meva/` repository directory. - /// 2. It applies ignore rules from the initialized `ignore_services` unless `include_ignored` is `true`. + /// This method returns a vector of tuples, each representing a file found and whether it is ignored. + /// The first element is the absolute path to the file; the second element is a boolean flag + /// indicating whether the file is ignored by any active ignore services. /// /// # Arguments /// - /// * `path` - The root path from which to start collecting files. - /// * `include_ignored` - If `true`, files matching patterns in `.mevaignore` will be included. + /// * `path` - The root directory path to start the search. + /// * `include_ignored` - If `true`, files matching ignore patterns will be included in the result. /// /// # Returns /// - /// A vector of [`PathBuf`]s, each representing an absolute path to a file. - pub fn collect_files(&self, path: &Path, include_ignored: bool) -> Vec { + /// A vector of tuples of type `(PathBuf, bool)`: + /// - `PathBuf`: the absolute file path. + /// - `bool`: whether the file is ignored (`true` if ignored). + pub fn collect_files(&self, path: &Path, include_ignored: bool) -> Vec<(PathBuf, bool)> { self.get_filtered_entries(path, include_ignored) - .map(|e| e.path().to_path_buf()) + .map(|(e, i)| (e.into_path(), i)) .collect() } @@ -139,7 +143,7 @@ impl<'a> WorkingDir<'a> { let entries = self.get_filtered_entries(base_path, include_ignored); - for entry in entries { + for (entry, is_ignored) in entries { let meta = entry.metadata()?; let abs_path = entry.path(); let rel_path = abs_path.strip_base(base_path).to_path_buf(); @@ -158,6 +162,7 @@ impl<'a> WorkingDir<'a> { mtime: meta.modified()?.into(), file_size: meta.len(), mode: FileMode::try_from(abs_path)?, + is_ignored, }, ); } @@ -165,29 +170,51 @@ impl<'a> WorkingDir<'a> { Ok(result) } - /// Creates and returns a configured iterator over directory entries. + /// Creates an iterator over directory entries filtered by ignore rules. /// - /// This iterator is pre-filtered to: - /// 1. Always skip the internal repository directory (`.meva/`). - /// 2. Optionally skip files ignored by `.mevaignore` rules. - /// 3. Yield only entries that are files (not directories). + /// The iterator skips internal repository directories and, unless `include_ignored` is true, + /// filters out files ignored by `.mevaignore`. It yields only file entries, not directories. + /// + /// Each item yielded is a tuple containing: + /// - A `walkdir::DirEntry` representing the file entry. + /// - A boolean flag that indicates if the file is ignored (`true` if ignored). + /// + /// # Returns + /// + /// An iterator yielding items of type `(walkdir::DirEntry, bool)`: + /// - `DirEntry`: a directory entry representing a file. + /// - `bool`: whether the file is ignored. fn get_filtered_entries( &self, path: &Path, include_ignored: bool, - ) -> impl Iterator { + ) -> impl Iterator { WalkDir::new(path) .into_iter() .filter_entry(move |e| { - // Always skip the repository's internal directory. let is_repo_dir = e.file_type().is_dir() && e.path() == self.repo_layout.repository_dir(); - let should_include = include_ignored || !self.is_path_ignored(e.path()); + if is_repo_dir { + return false; + } + + if !include_ignored && e.file_type().is_dir() && self.is_path_ignored(e.path()) { + return false; + } - !is_repo_dir && should_include + true + }) + .filter_map(move |result| { + let entry = result.ok()?; + if !entry.file_type().is_file() { + return None; + } + let is_ignored = self.is_path_ignored(entry.path()); + if !include_ignored && is_ignored { + return None; + } + Some((entry, is_ignored)) }) - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file()) } /// Checks if a given path is ignored by any of the active ignore services. diff --git a/engine/src/ref_manager.rs b/engine/src/ref_manager.rs index f4f23df..b76cd8b 100644 --- a/engine/src/ref_manager.rs +++ b/engine/src/ref_manager.rs @@ -25,11 +25,29 @@ pub trait RefManager { /// Resolves the current head to the commit hash it points to. /// /// # Returns + /// /// - `Ok(Some(hash))` if the head points to a commit. /// - `Ok(None)` if there is no head (e.g., empty repository). /// - `Err` if there is an I/O or storage error. fn resolve_head(&self) -> EngineResult>; + /// Resolves a given [`Head`] object to the specific commit hash it points to. + /// + /// If the `Head` is symbolic (e.g., points to "refs/heads/main"), this method + /// will follow the reference to find the commit hash. If the `Head` is direct + /// (detached), it will return the hash it contains. + /// + /// # Arguments + /// + /// * `head` - A reference to the [`Head`] to resolve. + /// + /// # Returns + /// + /// - `Ok(Some(hash))` if the head successfully resolves to a commit hash. + /// - `Ok(None)` if the head points to a ref that does not exist or the repository is empty. + /// - `Err` if there is an I/O or parsing error. + fn resolve_commit_hash(&self, head: &Head) -> EngineResult>; + /// Updates the [`Head`] reference with a new value, changing the active branch /// or commit pointer. fn update_head(&self, head: Head) -> EngineResult<()>; diff --git a/engine/src/ref_manager/head.rs b/engine/src/ref_manager/head.rs index 5b81255..4b28d3d 100644 --- a/engine/src/ref_manager/head.rs +++ b/engine/src/ref_manager/head.rs @@ -1,6 +1,9 @@ +use std::path::PathBuf; + use crate::ref_manager::head_mode::HeadMode; use crate::serialize_deserialize::MevaEncode; use serde::{Deserialize, Serialize}; +use shared::PathToString; /// Represents the current branch reference (HEAD) in the repository. /// @@ -21,6 +24,18 @@ impl Head { target: target.to_string(), } } + + pub fn extract_branch_name(&self) -> Option { + match self.mode == HeadMode::Symbolic { + true => Some( + PathBuf::from(&self.target) + .file_name() + .unwrap() + .to_utf8_string(), + ), + false => None, + } + } } impl Default for Head { diff --git a/engine/src/ref_manager/meva_ref_manager.rs b/engine/src/ref_manager/meva_ref_manager.rs index e082850..e1dbe05 100644 --- a/engine/src/ref_manager/meva_ref_manager.rs +++ b/engine/src/ref_manager/meva_ref_manager.rs @@ -53,13 +53,20 @@ impl RefManager for MevaRefManager<'_> { /// Resolves the current `HEAD` reference to the commit hash it points to. /// - /// - Returns `Ok(Some(hash))` if `HEAD` points to a commit. - /// - Returns `Ok(None)` if `HEAD` is missing or empty. - /// - Returns `Err` if reading `HEAD` or the target reference fails. + /// # Returns + /// + /// - `Ok(Some(hash))` if `HEAD` points to a commit. + /// - `Ok(None)` if `HEAD` is missing or empty. + /// - `Err` if reading `HEAD` or the target reference fails. fn resolve_head(&self) -> EngineResult> { let head = self.read_head()?; + self.resolve_commit_hash(&head) + } + + /// Resolves a given [`Head`] object to the specific commit hash it points to. + fn resolve_commit_hash(&self, head: &Head) -> EngineResult> { let hash = match head.mode { - HeadMode::Direct => Some(head.target), + HeadMode::Direct => Some(head.target.clone()), HeadMode::Symbolic => self.read_ref(&head.target)?.map(|e| e.commit_hash), }; Ok(hash) From 697e194cbf0a14a137717f8b1c9f57918a5da540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Tue, 28 Oct 2025 20:59:24 +0100 Subject: [PATCH 18/42] `add` for deleted files (#26) --- Cargo.lock | 20 ++++++ Cargo.toml | 1 + cli/src/commands/add.rs | 2 +- engine/Cargo.toml | 1 + .../src/commit_builder/meva_commit_builder.rs | 5 +- engine/src/errors/index_error.rs | 24 ++++++- engine/src/handlers/add/handlers.rs | 23 +++---- engine/src/ignore/ignore_service.rs | 5 +- engine/src/index/meva_index.rs | 61 +++++++++++++++--- engine/src/index/working_dir.rs | 32 +++++++++- shared/Cargo.toml | 1 + shared/src/extensions.rs | 4 -- shared/src/extensions/canonicalize_clean.rs | 63 ------------------- shared/src/extensions/is_within.rs | 16 ++--- .../src/extensions/remove_windows_prefix.rs | 60 ------------------ shared/src/lib.rs | 3 +- 16 files changed, 150 insertions(+), 171 deletions(-) delete mode 100644 shared/src/extensions/canonicalize_clean.rs delete mode 100644 shared/src/extensions/remove_windows_prefix.rs diff --git a/Cargo.lock b/Cargo.lock index 6540050..4332076 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -438,6 +438,7 @@ dependencies = [ "hex", "mockall", "owo-colors", + "path-absolutize", "plugins", "pretty_assertions", "rayon", @@ -848,6 +849,24 @@ version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +[[package]] +name = "path-absolutize" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4af381fe79fa195b4909485d99f73a80792331df0625188e707854f0b3383f5" +dependencies = [ + "path-dedot", +] + +[[package]] +name = "path-dedot" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397" +dependencies = [ + "once_cell", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1188,6 +1207,7 @@ version = "0.1.0" dependencies = [ "editor-command", "mockall", + "path-absolutize", "pretty_assertions", "rstest", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 97dd6d2..2fc74c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,3 +27,4 @@ strum_macros = "0.27" regex = "1.11.3" similar = "2.7.0" owo-colors = "4.2.3" +path-absolutize = { version = "3.1.1", features = ["once_cell_cache"] } diff --git a/cli/src/commands/add.rs b/cli/src/commands/add.rs index f01ebfc..e82b9ac 100644 --- a/cli/src/commands/add.rs +++ b/cli/src/commands/add.rs @@ -109,7 +109,7 @@ impl MevaCommand for AddCommand { ) .group( ArgGroup::new("path_group") - .args([Self::ARG_PATH, Self::ARG_ALL]) + .args([Self::ARG_PATH, Self::ARG_ALL, Self::ARG_UPDATE]) .required(true) .multiple(true), ) diff --git a/engine/Cargo.toml b/engine/Cargo.toml index b30cd57..5b18c8d 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -28,6 +28,7 @@ chardetng = "0.1.17" tree-ds = "0.2.0" similar.workspace = true owo-colors.workspace = true +path-absolutize.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/engine/src/commit_builder/meva_commit_builder.rs b/engine/src/commit_builder/meva_commit_builder.rs index 16ce23a..1deab1a 100644 --- a/engine/src/commit_builder/meva_commit_builder.rs +++ b/engine/src/commit_builder/meva_commit_builder.rs @@ -5,8 +5,9 @@ use crate::index::MevaIndex; use crate::index::file_mode::FileMode; use crate::objects::{MevaCommit, MevaTree, Person, TreeEntry}; use chrono::Utc; +use path_absolutize::Absolutize; use shared::StripBase; -use shared::{CanonicalizeClean, CumulativePaths, PathToString}; +use shared::{CumulativePaths, PathToString}; use std::path::Path; use tree_ds::prelude::{Node, Tree}; @@ -151,7 +152,7 @@ impl<'a> MevaCommitBuilder<'a> { fn build_tree_from_index(&self) -> EngineResult> { let index = MevaIndex::from_disk(self.repo_layout, None)?; let entries = index.get_entries(); - let meva_repository_dir = self.repo_layout.working_dir().canonicalize_clean()?; + let meva_repository_dir = self.repo_layout.working_dir().absolutize()?; let mut tree: Tree = Tree::new(None); let root_key = meva_repository_dir.to_utf8_string(); diff --git a/engine/src/errors/index_error.rs b/engine/src/errors/index_error.rs index 4ea0142..922db68 100644 --- a/engine/src/errors/index_error.rs +++ b/engine/src/errors/index_error.rs @@ -4,9 +4,8 @@ use thiserror::Error; /// Represents errors that can occur when working with the index file. /// /// The index file is a core component of the repository that tracks the -/// current state of files. -/// Errors here typically indicate that the index cannot be found, -/// accessed, or is otherwise missing. +/// current state of files. Errors here typically indicate issues with +/// locating, accessing, or matching paths in the repository. #[derive(Error, Debug)] pub enum IndexError { /// Indicates that the index file could not be located at the expected path. @@ -18,4 +17,23 @@ pub enum IndexError { /// The absolute or relative path where the index file was expected. path: PathBuf, }, + + /// The provided path did not match any files in the working directory or index. + /// + /// This typically means the user specified a path that does not exist, + /// or it lies outside the repository’s working tree. + #[error("Path `{path}` did not match any files")] + PathDidNotMatch { + /// The path that failed to match any files. + path: PathBuf, + }, + + /// The provided path matches a file that is ignored by `.mevaignore` rules. + /// + /// Such files are intentionally excluded from tracking unless explicitly overridden. + #[error("Path `{path}` matches an ignored file")] + PathIgnored { + /// The ignored path. + path: PathBuf, + }, } diff --git a/engine/src/handlers/add/handlers.rs b/engine/src/handlers/add/handlers.rs index 7745eb0..6d4cdfa 100644 --- a/engine/src/handlers/add/handlers.rs +++ b/engine/src/handlers/add/handlers.rs @@ -10,7 +10,7 @@ use plugins::{ AddPostPayload, AddPrePayload, CommandType, InvocationInput, InvocationPostPayload, InvocationPrePayload, MevaPluginsLayout, PluginError, }; -use shared::{CanonicalizeClean, IsWithin}; +use shared::IsWithin; pub struct AddHandler; @@ -27,27 +27,22 @@ impl AddHandler { impl AddOperations for AddHandler { fn add(&self, request: AddRequest) -> EngineResult { - let workdir_abs = std::env::current_dir()?; let repo_layout = MevaRepositoryLayout::discover()?; - let path_abs = match request.path_arg { - Some(val) if val.is_absolute() => val.clone(), - Some(val) => workdir_abs.join(val), - None => workdir_abs.clone(), - }; + let path = request.path_arg.clone().unwrap_or(".".into()); - let path_abs = path_abs.canonicalize_clean()?; - - if !path_abs.is_within(repo_layout.working_dir())? { + if !path.is_within(repo_layout.working_dir())? { return Err(PathError::NotInBaseDirectory { - path: path_abs, + path, path_base: repo_layout.working_dir().into(), } .into()); } - let add_new = request.all_flag || !request.update_flag; - let add_deleted = request.all_flag || request.update_flag; + let all = request.all_flag || request.path_arg.is_some(); + + let add_new = all && !request.update_flag; + let add_deleted = all || request.update_flag; let add_ignored = request.force_flag; let verbose = request.dry_run_flag || request.verbose_flag; @@ -56,7 +51,7 @@ impl AddOperations for AddHandler { index.load_from_disk(None)?; let (added, modified, removed) = - index.add(&path_abs, add_new, add_deleted, add_ignored, verbose)?; + index.add(&path, add_new, add_deleted, add_ignored, verbose)?; if !request.dry_run_flag { index.save()?; diff --git a/engine/src/ignore/ignore_service.rs b/engine/src/ignore/ignore_service.rs index 40d752a..2f9138c 100644 --- a/engine/src/ignore/ignore_service.rs +++ b/engine/src/ignore/ignore_service.rs @@ -6,13 +6,14 @@ use std::{ }; use globset::{Glob, GlobSetBuilder}; +use path_absolutize::Absolutize; use walkdir::WalkDir; use crate::{ errors::{EngineResult, IgnoreError}, ignore::{IgnoreOperations, IgnoreResult}, }; -use shared::{CanonicalizeClean, IsWithin, UpwardSearch}; +use shared::{IsWithin, UpwardSearch}; /// Service implementing ignore file operations for Meva repositories. /// @@ -210,7 +211,7 @@ impl IgnoreOperations for IgnoreService { return Ok(IgnoreResult::NotIgnored { path: checked_path }); } checked_path = checked_path - .canonicalize_clean()? + .absolutize()? .strip_prefix( cached_set .path_abs diff --git a/engine/src/index/meva_index.rs b/engine/src/index/meva_index.rs index d06b1ed..d330e93 100644 --- a/engine/src/index/meva_index.rs +++ b/engine/src/index/meva_index.rs @@ -1,3 +1,7 @@ +use super::{IndexEntry, WorkingDir, deserialize_entries, deserialize_entries_with_absolute_keys}; +use path_absolutize::Absolutize; +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use serde_json::Deserializer; use std::{ collections::{HashMap, HashSet}, fs::{self, OpenOptions}, @@ -5,17 +9,12 @@ use std::{ path::{Path, PathBuf}, }; -use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; -use serde_json::Deserializer; - -use super::{IndexEntry, WorkingDir, deserialize_entries, deserialize_entries_with_absolute_keys}; - use crate::errors::{EngineResult, IndexError}; use crate::object_storage::{MevaObjectStorage, ObjectStorage}; use crate::objects::MevaBlob; use crate::{RepositoryLayout, diff::FileChange}; -use shared::{CanonicalizeClean, PathToString, StripBase}; +use shared::{PathToString, StripBase}; /// Represents the in-memory index (staging area) of tracked files in a Meva repository. /// @@ -111,6 +110,32 @@ impl<'a> MevaIndex<'a> { }) .collect::>() } + + /// Checks whether a given file or directory is tracked in the Meva index. + /// + /// # Returns + /// - `true` if the exact file is tracked. + /// - `true` for a directory if at least one tracked file exists inside it. + /// + /// The path may be absolute or relative. It will be normalized against + /// the repository’s working directory before comparison. + pub fn is_tracked(&self, path: &Path) -> EngineResult { + let abs_path = path.absolutize()?; + + // Case 1: Exact match (file directly tracked) + if self.entries.contains_key(&abs_path.to_utf8_string()) { + return Ok(true); + } + + // Case 2: Directory — check if any tracked path starts with it + let tracked = self + .entries + .keys() + .any(|p| Path::new(p).starts_with(&abs_path)); + + Ok(tracked) + } + /// Loads the index file from disk into memory, populating the `entries` map. /// /// If the file does not exist, it will be created. If it is empty, the in-memory index will be cleared. @@ -231,15 +256,31 @@ impl<'a> MevaIndex<'a> { /// A tuple containing vectors of paths for `(new_files, modified_files, deleted_files)`. pub fn add( &mut self, - path_abs: &PathBuf, + path: &PathBuf, add_new: bool, add_deleted: bool, add_ignored: bool, verbose: bool, ) -> EngineResult<(Vec, Vec, Vec)> { + let in_workdir = self.working_dir.is_path_in_working_dir(path, true)?; + let in_index = self.is_tracked(path)?; + + if !in_workdir && !in_index { + return Err(IndexError::PathDidNotMatch { + path: path.to_owned(), + })?; + } + + if !add_ignored && self.working_dir.is_path_ignored(path) && in_workdir { + return Err(IndexError::PathIgnored { + path: path.to_owned(), + })?; + } + + let path_abs = path.absolutize()?.to_path_buf(); let files_abs = self .working_dir - .collect_files(path_abs, add_ignored) + .collect_files(&path_abs, add_ignored) .iter() .map(|(p, _)| p.clone()) .collect(); @@ -255,7 +296,7 @@ impl<'a> MevaIndex<'a> { if add_deleted { deleted_files = self - .remove_deleted_files(path_abs, &files_abs, verbose) + .remove_deleted_files(&path_abs, &files_abs, verbose) .into_iter() .map(PathBuf::from) .collect(); @@ -292,7 +333,7 @@ impl<'a> MevaIndex<'a> { } .into()); } - let meva_repository_dir = self.repo_layout.working_dir().canonicalize_clean()?; + let meva_repository_dir = self.repo_layout.working_dir().absolutize()?; for entry in self.entries.values_mut() { let entry_absolute_path = Path::new(&entry.path); diff --git a/engine/src/index/working_dir.rs b/engine/src/index/working_dir.rs index 724f5b6..68fbc1c 100644 --- a/engine/src/index/working_dir.rs +++ b/engine/src/index/working_dir.rs @@ -4,7 +4,8 @@ use std::{ }; use chrono::{DateTime, Utc}; -use shared::StripBase; +use path_absolutize::Absolutize; +use shared::{IsWithin, StripBase}; use walkdir::WalkDir; use crate::{ @@ -222,7 +223,7 @@ impl<'a> WorkingDir<'a> { /// # Returns /// /// `true` if the path matches an ignore pattern, `false` otherwise. - fn is_path_ignored(&self, path: &Path) -> bool { + pub fn is_path_ignored(&self, path: &Path) -> bool { self.ignore_services.iter().any(|service| { matches!( service.check_cached(&path), @@ -231,6 +232,33 @@ impl<'a> WorkingDir<'a> { }) } + /// Checks whether the given path exists within the working directory. + /// + /// This function returns `true` if: + /// - the path is inside the working directory, and + /// - it exists in the filesystem, and + /// - (optionally) it is not ignored by `.mevaignore` rules. + pub fn is_path_in_working_dir(&self, path: &Path, include_ignored: bool) -> EngineResult { + let abs_path = path.absolutize()?; + + if !abs_path + .is_within(self.get_absolute_path()) + .unwrap_or(false) + { + return Ok(false); + } + + if !abs_path.exists() { + return Ok(false); + } + + if !include_ignored && self.is_path_ignored(&abs_path) { + return Ok(false); + } + + Ok(true) + } + /// Discovers all `.mevaignore` files within the repository and creates an `IgnoreService` for each. /// /// This function scans the entire working directory for files matching the ignore file name diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 754c9e7..d5d8d00 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -8,6 +8,7 @@ authors.workspace = true tempfile.workspace = true up_finder.workspace = true editor-command.workspace = true +path-absolutize.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/shared/src/extensions.rs b/shared/src/extensions.rs index 9a126f1..126219f 100644 --- a/shared/src/extensions.rs +++ b/shared/src/extensions.rs @@ -1,19 +1,15 @@ -pub mod canonicalize_clean; pub mod cumulative_paths; pub mod fs; pub mod is_within; pub mod open_in_editor; pub mod path_to_string; -pub mod remove_windows_prefix; pub mod strip_base; pub mod upward_search; -pub use canonicalize_clean::CanonicalizeClean; pub use cumulative_paths::CumulativePaths; pub use fs::create_file_with_dirs; pub use is_within::IsWithin; pub use open_in_editor::OpenInEditor; pub use path_to_string::PathToString; -pub use remove_windows_prefix::RemoveWindowsPrefix; pub use strip_base::StripBase; pub use upward_search::UpwardSearch; diff --git a/shared/src/extensions/canonicalize_clean.rs b/shared/src/extensions/canonicalize_clean.rs deleted file mode 100644 index 7e09ad5..0000000 --- a/shared/src/extensions/canonicalize_clean.rs +++ /dev/null @@ -1,63 +0,0 @@ -use crate::extensions::remove_windows_prefix::RemoveWindowsPrefix; -use std::io; -use std::path::Path; -use std::path::PathBuf; - -/// A trait providing a helper method to canonicalize paths -/// and remove the Windows UNC prefix (`\\?\`) if present. -/// -/// This trait allows you to call `canonicalize_clean()` directly on any `Path` -/// without manually handling Windows path normalization. -pub trait CanonicalizeClean { - /// Canonicalizes the path and removes the Windows UNC prefix. - /// - /// # Returns - /// - /// * `Result` - Returns the canonicalized and normalized path, - /// or an `io::Error` if canonicalization fails. - /// - /// # Example - /// - /// ```no_run - /// use std::path::Path; - /// use shared::extensions::canonicalize_clean::CanonicalizeClean; - /// - /// let path = Path::new("./some/path"); - /// let clean_path = path.canonicalize_clean().unwrap(); - /// println!("Canonicalized path: {}", clean_path.display()); - /// ``` - fn canonicalize_clean(&self) -> Result; -} - -impl CanonicalizeClean for Path { - fn canonicalize_clean(&self) -> Result { - let canonical = self.canonicalize()?; - Ok(canonical.remove_windows_prefix()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::env; - use std::fs; - - #[test] - fn canonicalize_existing_relative_path() { - let current_dir = env::current_dir().unwrap(); - let relative = Path::new("."); - let clean = relative.canonicalize_clean().unwrap(); - - assert_eq!(clean, current_dir); - } - - #[test] - fn canonicalize_nested_file() { - let temp_dir = tempfile::tempdir().unwrap(); - let file_path = temp_dir.path().join("test.txt"); - fs::write(&file_path, "hello").unwrap(); - - let clean = file_path.canonicalize_clean().unwrap(); - assert!(clean.ends_with("test.txt")); - } -} diff --git a/shared/src/extensions/is_within.rs b/shared/src/extensions/is_within.rs index 1ded6f4..6896889 100644 --- a/shared/src/extensions/is_within.rs +++ b/shared/src/extensions/is_within.rs @@ -1,5 +1,6 @@ +use path_absolutize::Absolutize; use std::io; -use std::path::{Path, PathBuf}; +use std::path::Path; /// Extension trait for checking whether a given path /// is located within another path. @@ -8,7 +9,7 @@ use std::path::{Path, PathBuf}; /// ensuring that files are inside a specific directory, etc. pub trait IsWithin { /// Returns `Ok(true)` if `self` (the candidate path) is located - /// within the given `path` (the base directory). + /// within the given `base` (the base directory). /// /// Both paths are canonicalized before comparison, so symbolic /// links and relative paths are resolved. @@ -16,14 +17,13 @@ pub trait IsWithin { /// # Errors /// /// Returns an [`io::Error`] if canonicalization of either path fails. - fn is_within(&self, path: &Path) -> Result; + fn is_within>(&self, base: P) -> Result; } -impl IsWithin for PathBuf { - fn is_within(&self, path: &Path) -> Result { - let parent_can = path.canonicalize()?; - let child_can = self.canonicalize()?; - +impl> IsWithin for T { + fn is_within>(&self, base: P) -> Result { + let parent_can = base.as_ref().absolutize()?; + let child_can = self.as_ref().absolutize()?; Ok(child_can.starts_with(&parent_can)) } } diff --git a/shared/src/extensions/remove_windows_prefix.rs b/shared/src/extensions/remove_windows_prefix.rs deleted file mode 100644 index d30ae48..0000000 --- a/shared/src/extensions/remove_windows_prefix.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::path::PathBuf; - -/// Extension trait for removing the Windows long path prefix (`\\?\`) from a path. -/// -/// On Windows, paths can be prefixed with `\\?\` to allow long paths (> 260 characters) -/// or to use extended path syntax. This trait provides a convenient way to strip -/// that prefix, returning a normalized `PathBuf`. -/// # Example -/// ``` -/// use std::path::PathBuf; -/// use shared::extensions::remove_windows_prefix::RemoveWindowsPrefix; -/// -/// let path = PathBuf::from(r"\\?\C:\Users\example\file.txt"); -/// let stripped = path.remove_windows_prefix(); -/// assert_eq!(stripped, PathBuf::from(r"C:\Users\example\file.txt")); -/// ``` -pub trait RemoveWindowsPrefix { - /// Returns a new `PathBuf` with the `\\?\` prefix removed if present. - /// - /// If the path does not start with the Windows long path prefix, the original - /// path is returned as a clone. - fn remove_windows_prefix(&self) -> PathBuf; -} - -impl RemoveWindowsPrefix for PathBuf { - fn remove_windows_prefix(&self) -> PathBuf { - let s = self.as_os_str().to_string_lossy(); - if let Some(stripped) = s.strip_prefix(r"\\?\") { - PathBuf::from(stripped) - } else { - self.clone() - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn removes_windows_prefix_if_present() { - let path = PathBuf::from(r"\\?\C:\Users\example\file.txt"); - let stripped = path.remove_windows_prefix(); - assert_eq!(stripped, PathBuf::from(r"C:\Users\example\file.txt")); - } - - #[test] - fn leaves_path_unchanged_if_no_prefix() { - let path = PathBuf::from(r"C:\Users\example\file.txt"); - let stripped = path.remove_windows_prefix(); - assert_eq!(stripped, path); - } - - #[test] - fn works_with_non_windows_like_path() { - let path = PathBuf::from("/home/user/file.txt"); - let stripped = path.remove_windows_prefix(); - assert_eq!(stripped, path); - } -} diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 95f0f29..165f93a 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -3,7 +3,6 @@ mod pretty_field; pub mod extensions; pub use extensions::{ - CanonicalizeClean, CumulativePaths, IsWithin, OpenInEditor, PathToString, RemoveWindowsPrefix, - StripBase, UpwardSearch, fs, + CumulativePaths, IsWithin, OpenInEditor, PathToString, StripBase, UpwardSearch, fs, }; pub use pretty_field::PrettyField; From 7aa9864d42f0adffa0dd5c4967714df8af2e44fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Sat, 15 Nov 2025 13:41:04 +0100 Subject: [PATCH 19/42] Feature/command restore (#28) --- Cargo.lock | 4 +- Cargo.toml | 2 +- cli/src/commands.rs | 2 + cli/src/commands/restore.rs | 121 +++++++++ cli/src/main.rs | 3 +- engine/src/engine_container.rs | 11 +- engine/src/handlers.rs | 1 + engine/src/handlers/ls_tree/handlers.rs | 120 ++------- engine/src/handlers/restore.rs | 5 + engine/src/handlers/restore/handlers.rs | 253 ++++++++++++++++++ engine/src/handlers/restore/operations.rs | 28 ++ engine/src/handlers/status/operations.rs | 25 +- engine/src/index.rs | 4 +- engine/src/index/meva_index.rs | 75 ++++++ engine/src/lib.rs | 1 + engine/src/objects.rs | 1 + engine/src/traversal.rs | 5 + .../src/traversal/meva_commit_tree_walker.rs | 182 +++++++++++++ engine/src/traversal/traits.rs | 39 +++ 19 files changed, 765 insertions(+), 117 deletions(-) create mode 100644 cli/src/commands/restore.rs create mode 100644 engine/src/handlers/restore.rs create mode 100644 engine/src/handlers/restore/handlers.rs create mode 100644 engine/src/handlers/restore/operations.rs create mode 100644 engine/src/traversal.rs create mode 100644 engine/src/traversal/meva_commit_tree_walker.rs create mode 100644 engine/src/traversal/traits.rs diff --git a/Cargo.lock b/Cargo.lock index 4332076..cb6f9e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1305,9 +1305,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.22.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", "getrandom 0.3.3", diff --git a/Cargo.toml b/Cargo.toml index 2fc74c3..11d6b3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ thiserror = "2" rstest = "0.25.0" mockall = "0.13.1" pretty_assertions = "1.4.1" -tempfile = "3.20.0" +tempfile = "3.23.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.143" up_finder = "0.0.4" diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 27311fe..34a5b5d 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -9,6 +9,7 @@ pub mod ls_files; pub mod ls_tree; pub mod meva_command; pub mod plugins; +pub mod restore; pub mod show; pub mod status; @@ -22,6 +23,7 @@ pub use log::LogCommand; pub use ls_files::LsFilesCommand; pub use ls_tree::LsTreeCommand; pub use plugins::PluginsCommand; +pub use restore::RestoreCommand; pub use show::ShowCommand; pub use status::StatusCommand; diff --git a/cli/src/commands/restore.rs b/cli/src/commands/restore.rs new file mode 100644 index 0000000..2ac51f9 --- /dev/null +++ b/cli/src/commands/restore.rs @@ -0,0 +1,121 @@ +use crate::commands::MevaCommand; +use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; +use engine::engine_container::{EngineContainer, MevaContainer}; +use engine::handlers::restore::Request; +use engine::revision_parsing::Revision; +use miette::{IntoDiagnostic, Result}; +use std::path::PathBuf; + +/// CLI command for restoring files in the working tree or staging area. +/// +/// The [RestoreCommand] allows users to restore files from a specific snapshot (commit) +/// either to the working directory, the staging area, or both. +/// +/// # Supported arguments +/// - **`--staged`**: Restore changes in the staging area (index). +/// - **`--worktree`**: Restore changes in the working tree. +/// - **`--source `**: Specify the source snapshot (commit) to restore from. +/// Defaults to `HEAD` if not provided. +/// - **``**: Optional list of file or directory paths to restore. +/// If omitted, the entire repository is restored. +pub struct RestoreCommand; + +impl RestoreCommand { + /// Creates a new instance of the [RestoreCommand]. + pub fn new() -> Self { + Self + } + + /// `--staged` flag key. + const ARG_STAGED: &'static str = "staged"; + + /// `--worktree` flag key. + const ARG_WORKTREE: &'static str = "worktree"; + + /// `--source` option key. + const ARG_SOURCE: &'static str = "source"; + + /// `` argument key. + const ARG_PATHS: &'static str = "paths"; +} + +impl MevaCommand for RestoreCommand { + fn name(&self) -> &'static str { + "restore" + } + fn about(&self) -> &'static str { + "Restore files in the working tree or staging area from a snapshot" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the underlying Clap [`Command`] definition for this command. + /// + /// This defines supported flags, arguments, and default behaviors + /// for restoring files from a snapshot. + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_STAGED) + .long(Self::ARG_STAGED) + .help("Restore changes in the staging area (index)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_WORKTREE) + .long(Self::ARG_WORKTREE) + .help("Restore changes in the working tree") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_SOURCE) + .value_name("SOURCE") + .long(Self::ARG_SOURCE) + .help("Specify the source snapshot (commit) to restore from. Defaults to HEAD.") + .default_value("HEAD") + .value_parser(value_parser!(Revision)), + ) + .arg( + Arg::new(Self::ARG_PATHS) + .value_name("PATHS") + .help("Optional paths to restore. If not specified, the whole repository is restored.") + .num_args(0..) + .last(true), + ) + } + + /// Executes the `restore` command. + /// + /// - Reads all flags and arguments from `matches`. + /// - Constructs a [`Request`] with the selected restore options. + /// - Delegates to the repository’s [`restore_handler`] to perform the restore operation. + /// + /// Returns a [`miette::Result`] with diagnostic information if an error occurs. + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + let staged = matches.get_flag(Self::ARG_STAGED); + let worktree = matches.get_flag(Self::ARG_WORKTREE); + let source = matches + .get_one::(Self::ARG_SOURCE) + .cloned() + .unwrap(); + let paths = match matches.get_many::(Self::ARG_PATHS) { + Some(vals) => vals.map(PathBuf::from).collect(), + None => Vec::new(), + }; + + let handler = container.restore_handler().into_diagnostic()?; + + let request = Request { + staged, + worktree, + source, + paths, + }; + + handler.handle_restore(request).into_diagnostic()?; + + Ok(()) + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 893d0e7..9dbe231 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -5,7 +5,7 @@ mod meva_cli; use crate::{commands::MevaCommand, meva_cli::MevaCli}; use commands::{ AddCommand, CommitCommand, ConfigCommand, DiffCommand, IgnoreCommand, InitCommand, LogCommand, - LsFilesCommand, LsTreeCommand, PluginsCommand, ShowCommand, StatusCommand, + LsFilesCommand, LsTreeCommand, PluginsCommand, RestoreCommand, ShowCommand, StatusCommand, }; use engine::engine_container::MevaContainer; use miette::Result; @@ -26,6 +26,7 @@ fn main() -> Result<()> { Box::new(DiffCommand::new()), Box::new(LsTreeCommand::new()), Box::new(LogCommand::new()), + Box::new(RestoreCommand::new()), ]; let container = MevaContainer; diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index d9f8e0f..74cb2a4 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -5,7 +5,7 @@ use crate::ConfigLoader; use crate::handlers::{ add::AddHandler, commit::CommitHandler, config::ConfigHandler, init::InitHandler, log::LogHandler, ls_files::LsFilesHandler, ls_tree::LsTreeHandler, plugins::PluginsHandler, - status::StatusHandler, + restore::RestoreHandler, status::StatusHandler, }; use crate::{ errors::EngineResult, plugins_interceptor::PluginsInterceptor, @@ -52,6 +52,9 @@ pub trait EngineContainer { /// Return the handler responsible for log command fn log_handler(&self) -> EngineResult; + + // Return the handler responsible for restore command + fn restore_handler(&self) -> EngineResult; } /// Concrete implementation of `EngineContainer` for Meva. @@ -131,4 +134,10 @@ impl EngineContainer for MevaContainer { fn log_handler(&self) -> EngineResult { Ok(LogHandler::new(Box::new(MevaRepositoryLayout::discover()?))) } + + fn restore_handler(&self) -> EngineResult { + Ok(RestoreHandler::new(Box::new( + MevaRepositoryLayout::discover()?, + ))) + } } diff --git a/engine/src/handlers.rs b/engine/src/handlers.rs index 918cbda..136aafa 100644 --- a/engine/src/handlers.rs +++ b/engine/src/handlers.rs @@ -7,5 +7,6 @@ pub mod log; pub mod ls_files; pub mod ls_tree; pub mod plugins; +pub mod restore; pub mod show; pub mod status; diff --git a/engine/src/handlers/ls_tree/handlers.rs b/engine/src/handlers/ls_tree/handlers.rs index 7b6a087..68064da 100644 --- a/engine/src/handlers/ls_tree/handlers.rs +++ b/engine/src/handlers/ls_tree/handlers.rs @@ -1,14 +1,10 @@ +use super::{DisplayMode, LsTreeEntry}; use super::{LsTreeOperations, Request, Response}; use crate::RepositoryLayout; use crate::errors::EngineResult; -use crate::object_storage::{MevaObjectStorage, ObjectStorage}; -use crate::objects::tree_entry_type::TreeEntryType; -use crate::objects::{MevaBlob, MevaCommit, MevaTree, ObjectEntry, TreeEntry}; +use crate::object_storage::MevaObjectStorage; use crate::revision_parsing::RevisionResolver; - -use super::{DisplayMode, LsTreeEntry}; - -use std::path::PathBuf; +use crate::traversal::{CommitTreeWalker, MevaCommitTreeWalker}; /// Handles the `ls-tree` command logic. /// @@ -32,113 +28,27 @@ impl LsTreeHandler { pub fn handle_ls_tree(&self, request: Request) -> EngineResult { self.ls_tree(request) } - - /// Lists all entries (files and optionally directories) from a specific commit. - /// - /// Resolves the commit tree from the commit hash, then lists its contents. - fn list_commit( - &self, - commit_hash: String, - include_trees: bool, - recursive: bool, - ) -> EngineResult> { - let object_storage = MevaObjectStorage::new(self.repo_layout.as_ref()); - - let commit_object = object_storage.get_object(&commit_hash)?; - let commit = MevaCommit::try_from(commit_object)?; - - Self::list_tree_recursive( - PathBuf::new(), - commit.tree, - &object_storage, - include_trees, - recursive, - ) - } - - /// Recursively traverses and lists the contents of a tree object. - /// - /// Returns all matching [`ObjectEntry`] objects, optionally including nested - /// directories and tree entries. - /// - /// # Errors - /// Returns an [`EngineError`] if tree or blob deserialization fails, - /// or if an object referenced by the tree cannot be found. - fn list_tree_recursive( - path: PathBuf, - tree_hash: String, - object_storage: &MevaObjectStorage, - include_trees: bool, - recursive: bool, - ) -> EngineResult> { - let mut listed_entries: Vec = Vec::new(); - let mut dirs_to_list: Vec = Vec::new(); - let tree_object = object_storage.get_object(&tree_hash)?; - let tree = MevaTree::try_from(tree_object)?; - for entry in tree.tree_entries { - match entry.entry_type { - TreeEntryType::Tree => { - if include_trees { - let object_entry = ObjectEntry { - entry_type: entry.entry_type.clone(), - hash: entry.object_hash.clone(), - size: None, - path: path.join(entry.name.clone()), - }; - listed_entries.push(object_entry); - } - dirs_to_list.push(entry); - } - _ => { - let blob_object = object_storage.get_object(&entry.object_hash)?; - let blob = MevaBlob::try_from(blob_object)?; - let object_entry = ObjectEntry { - entry_type: entry.entry_type, - hash: entry.object_hash, - size: Some(blob.size()), - path: path.join(entry.name), - }; - listed_entries.push(object_entry); - } - } - } - if recursive { - for entry in dirs_to_list { - let listed = Self::list_tree_recursive( - path.join(entry.name), - entry.object_hash, - object_storage, - include_trees, - recursive, - )?; - listed_entries.extend(listed); - } - } - - Ok(listed_entries) - } } impl LsTreeOperations for LsTreeHandler { fn ls_tree(&self, request: Request) -> EngineResult { let revision_resolver = RevisionResolver::new(self.repo_layout.as_ref()); + let object_storage = MevaObjectStorage::new(self.repo_layout.as_ref()); + let commit_tree_walker = MevaCommitTreeWalker::new(Box::new(object_storage)); let commit_hash = revision_resolver.resolve_hash(&request.revision)?; let include_trees = request.tree || !request.recursive; - let recursive = request.recursive; - - let mut listed_entries = self.list_commit(commit_hash, include_trees, recursive)?; + let max_depth = match request.recursive { + true => None, + false => Some(1), + }; + let path_filter = match request.paths.is_empty() { + true => None, + false => Some(request.paths.as_ref()), + }; - if !request.paths.is_empty() { - listed_entries.retain(|e| { - for path in &request.paths { - if e.path.starts_with(path) { - return true; - } - } - false - }); - } + let listed_entries = + commit_tree_walker.walk_commit(&commit_hash, include_trees, max_depth, path_filter)?; let display_mode = if request.name_only { DisplayMode::NameOnly diff --git a/engine/src/handlers/restore.rs b/engine/src/handlers/restore.rs new file mode 100644 index 0000000..c5f8323 --- /dev/null +++ b/engine/src/handlers/restore.rs @@ -0,0 +1,5 @@ +mod handlers; +mod operations; + +pub use handlers::RestoreHandler; +pub use operations::*; diff --git a/engine/src/handlers/restore/handlers.rs b/engine/src/handlers/restore/handlers.rs new file mode 100644 index 0000000..f7564c6 --- /dev/null +++ b/engine/src/handlers/restore/handlers.rs @@ -0,0 +1,253 @@ +use super::{Request, Response, RestoreOperations}; +use crate::RepositoryLayout; +use crate::errors::EngineResult; +use crate::index::{FileMode, IndexEntry, MevaIndex, Stage, WorkingDir}; +use crate::object_storage::{MevaObjectStorage, ObjectStorage}; +use crate::objects::{MevaBlob, MevaObject, ObjectEntry}; +use crate::revision_parsing::RevisionResolver; +use crate::traversal::{CommitTreeWalker, MevaCommitTreeWalker}; +use chrono::Utc; +use shared::{PathToString, StripBase}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// Handles the `meva restore` command execution flow. +/// +/// The [RestoreHandler] is responsible for restoring files from a specified snapshot +/// (commit) either to the working directory, the staging area, or both. +/// +/// It performs the following tasks: +/// - Resolves the source commit or snapshot from which to restore files. +/// - Maps snapshot objects to file paths for easy access. +/// - Updates the index (staging area) when `--staged` is specified. +/// - Updates the working tree when `--worktree` is specified. +/// - Writes files either atomically or directly, ensuring directory creation. +/// - Removes files that exist in the working tree but not in the source snapshot. +/// +/// The handler supports partial restores (specific paths) or full repository restores. +pub struct RestoreHandler { + repo_layout: Box, +} + +impl RestoreHandler { + /// Creates a new [`RestoreHandler`] instance with the given repository layout. + pub fn new(repo_layout: Box) -> Self { + Self { repo_layout } + } + + /// Executes the restore flow. + /// + /// Delegates the work to `restore` and returns a [`Response`] indicating completion. + pub fn handle_restore(&self, request: Request) -> EngineResult { + self.restore(request) + } + + // Maps a list of source objects to a (path: object) map. + fn map_source_files(source_files: &[ObjectEntry]) -> HashMap { + source_files.iter().map(|o| (o.path.clone(), o)).collect() + } + + // Determines which entries need to be added to the index. + fn build_index_entries_to_add( + source_files_map: &HashMap, + index_entries_map: &HashMap, + ) -> EngineResult> { + let now = Utc::now(); + let mut entries = Vec::with_capacity(source_files_map.len()); + + for (file_path, object_entry) in source_files_map { + if let Some(index_entry) = index_entries_map.get(file_path) + && index_entry.sha1 == object_entry.hash + { + continue; + } + + let entry = IndexEntry { + ctime: now, + mtime: now, + mode: FileMode::try_from(&object_entry.entry_type)?, + file_size: object_entry.size.unwrap_or(0), + sha1: object_entry.hash.clone(), + stage: Stage::Normal, + path: file_path.to_utf8_string(), + }; + + entries.push(entry); + } + + Ok(entries) + } + + // Determines which entries need to be removed from the index. + fn build_index_entries_to_remove( + index_entries_map: &HashMap, + source_files_map: &HashMap, + ) -> Vec { + index_entries_map + .iter() + .filter(|(path, _)| !source_files_map.contains_key(*path)) + .map(|(path, _)| path.to_utf8_string()) + .collect() + } + + /// Restores files in the index (staging area) based on the source snapshot. + /// + /// Adds missing files, updates modified files, and removes files not present in the source. + fn restore_index( + &self, + source_files: &[ObjectEntry], + paths: Option<&[PathBuf]>, + ) -> EngineResult<()> { + let mut index = MevaIndex::from_disk(self.repo_layout.as_ref(), Some(false))?; + let source_files_map = Self::map_source_files(source_files); + let index_entries_map = index.get_entries_map(paths); + let index_entries_to_add = + Self::build_index_entries_to_add(&source_files_map, &index_entries_map)?; + let index_entries_to_remove = + Self::build_index_entries_to_remove(&index_entries_map, &source_files_map); + + index.insert_entries(index_entries_to_add); + index.remove_entries(index_entries_to_remove); + + index.save()?; + Ok(()) + } + + // Collects all files under the specified worktree paths. + fn build_workdir_files(&self, workdir: &WorkingDir, paths: &[PathBuf]) -> HashSet { + let mut workdir_files = HashSet::new(); + + for path in paths { + let collected_files: Vec = workdir + .collect_files(path, false) + .into_iter() + .map(|(entry_path, _)| { + entry_path + .strip_base(self.repo_layout.working_dir()) + .to_path_buf() + }) + .collect(); + workdir_files.extend(collected_files); + } + dbg!(&workdir_files); + workdir_files + } + + // Resolves paths to restore, falling back to a default path if none are provided. + fn resolve_workdir_paths(paths: Option<&[PathBuf]>, default: &Path) -> Vec { + match paths { + Some(p) => p.to_vec(), + None => vec![default.to_path_buf()], + } + } + + // Writes a file atomically. + fn write_file_atomically(path: &Path, data: &[u8]) -> EngineResult<()> { + let parent = path.parent(); + if let Some(parent) = parent { + fs::create_dir_all(parent)?; + } + + let mut tmp = tempfile::NamedTempFile::new_in(parent.unwrap_or(Path::new(".")))?; + tmp.write_all(data)?; + tmp.flush()?; + fs::rename(tmp.path(), path)?; + Ok(()) + } + + // Writes a file directly. + fn write_file_direct(path: &Path, data: &[u8]) -> EngineResult<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut file = fs::File::create(path)?; + file.write_all(data)?; + Ok(()) + } + + // Removes files from the working directory that are not present in the source. + fn remove_extra_files( + workdir_files: &HashSet, + source_files_map: &HashMap, + ) -> std::io::Result<()> { + for workdir_file in workdir_files { + if !source_files_map.contains_key(workdir_file) { + fs::remove_file(workdir_file)?; + } + } + Ok(()) + } + + /// Restores files in the working tree (filesystem) based on the source snapshot. + /// + /// Compares file contents to avoid unnecessary writes, writes updated files, + /// and removes files not present in the snapshot. + fn restore_working_tree( + &self, + source_files: &[ObjectEntry], + paths: Option<&[PathBuf]>, + ) -> EngineResult<()> { + let object_storage = MevaObjectStorage::new(self.repo_layout.as_ref()); + let workdir = WorkingDir::with_ignore_services(self.repo_layout.as_ref()); + + let source_files_map: HashMap = Self::map_source_files(source_files); + let workdir_paths = Self::resolve_workdir_paths(paths, self.repo_layout.working_dir()); + let workdir_files = self.build_workdir_files(&workdir, &workdir_paths); + + for (file_path, object_entry) in &source_files_map { + let source_blob = object_storage.get_object(&object_entry.hash)?; + + if workdir_files.contains(file_path) { + let workdir_blob = MevaObject::try_from(MevaBlob::from_file(file_path)?)?; + + if source_blob.sha1() == workdir_blob.sha1() { + continue; + } + + let source_blob = MevaBlob::try_from(source_blob)?; + Self::write_file_atomically(file_path, &source_blob.data)?; + } else { + let source_blob = MevaBlob::try_from(source_blob)?; + Self::write_file_direct(file_path, &source_blob.data)?; + } + } + + Self::remove_extra_files(&workdir_files, &source_files_map)?; + + Ok(()) + } +} + +impl RestoreOperations for RestoreHandler { + /// Performs the restore operation based on the provided [`Request`]. + /// + /// - Resolves the source commit snapshot. + /// - Filters files by paths if specified. + /// - Restores the index when `request.staged` is `true`. + /// - Restores the working tree when `request.worktree` is `true`. + /// + /// Returns a [`Response`] after successfully restoring files. + fn restore(&self, request: Request) -> EngineResult { + let object_storage = MevaObjectStorage::new(self.repo_layout.as_ref()); + let commit_tree_walker = MevaCommitTreeWalker::new(Box::new(object_storage)); + let revision_resolver = RevisionResolver::new(self.repo_layout.as_ref()); + let commit_hash = revision_resolver.resolve_hash(&request.source)?; + let path_filter = match request.paths.len() { + 0 => None, + _ => Some(request.paths.as_slice()), + }; + let source_files = + commit_tree_walker.walk_commit(&commit_hash, false, None, path_filter)?; + + if request.staged { + self.restore_index(&source_files, path_filter)?; + } + if request.worktree { + self.restore_working_tree(&source_files, path_filter)?; + } + + Ok(Response {}) + } +} diff --git a/engine/src/handlers/restore/operations.rs b/engine/src/handlers/restore/operations.rs new file mode 100644 index 0000000..f825d0e --- /dev/null +++ b/engine/src/handlers/restore/operations.rs @@ -0,0 +1,28 @@ +use crate::errors::EngineResult; +use crate::revision_parsing::Revision; +use std::path::PathBuf; + +/// Represents a restore request for the `meva restore` command. +/// +/// Specifies which files to restore, from which snapshot, and whether to restore +/// to the staging area, working tree, or both. +pub struct Request { + /// If `true`, restores changes in the staging area (index). + pub staged: bool, + + /// If `true`, restores changes in the working tree (filesystem). + pub worktree: bool, + + /// The source commit or snapshot from which to restore files. + pub source: Revision, + + /// Optional list of specific paths to restore. + /// If empty, the entire repository is restored. + pub paths: Vec, +} + +pub struct Response; + +pub trait RestoreOperations { + fn restore(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/handlers/status/operations.rs b/engine/src/handlers/status/operations.rs index fad242e..d973b20 100644 --- a/engine/src/handlers/status/operations.rs +++ b/engine/src/handlers/status/operations.rs @@ -121,18 +121,33 @@ impl Response { out.push_str(&format!("{branch}")); } + if self.is_clean() { + out.push_str("Nothing to commit, working tree clean.\n"); + return out; + } + out.push_str(&self.render_section("Changes to be committed", &self.staged, short)); out.push_str(&self.render_section("Changes not staged for commit", &self.unstaged, short)); out.push_str(&self.render_section("Unmerged paths", &self.unmerged, short)); out.push_str(&self.render_section("Untracked files", &self.untracked, short)); out.push_str(&self.render_section("Ignored files", &self.ignored, short)); - - if out.trim().is_empty() { - out.push_str("Nothing to commit, working tree clean\n"); - } - out } + + /// Returns `true` if the working tree is clean — i.e., no staged, + /// unstaged, unmerged, untracked, or ignored entries are present. + #[inline] + fn is_clean(&self) -> bool { + [ + &self.staged, + &self.unstaged, + &self.unmerged, + &self.untracked, + &self.ignored, + ] + .into_iter() + .all(|opt| opt.as_ref().map(|v| v.is_empty()).unwrap_or(true)) + } } /// Defines the behavior for an object that can perform a repository status operation. diff --git a/engine/src/index.rs b/engine/src/index.rs index d9090cf..5f568c8 100644 --- a/engine/src/index.rs +++ b/engine/src/index.rs @@ -5,10 +5,10 @@ pub mod serde_utils; pub mod stage; pub mod working_dir; -use index_entry::IndexEntry; use serde_utils::{deserialize_entries, deserialize_entries_with_absolute_keys}; -use stage::Stage; pub use file_mode::FileMode; +pub use index_entry::IndexEntry; pub use meva_index::MevaIndex; +pub use stage::Stage; pub use working_dir::{WorkingDir, WorkingDirEntry}; diff --git a/engine/src/index/meva_index.rs b/engine/src/index/meva_index.rs index d330e93..55caa77 100644 --- a/engine/src/index/meva_index.rs +++ b/engine/src/index/meva_index.rs @@ -77,10 +77,25 @@ impl<'a> MevaIndex<'a> { /// /// The returned vector contains borrowed references to the underlying /// [`IndexEntry`] values in an unspecified order. + /// + //# TODO + //This method should be removed in the future in favor of + //[`Index::iter_entries()`], which returns an iterator and avoids + //unnecessary allocation. pub fn get_entries(&self) -> Vec<&IndexEntry> { self.entries.values().collect() } + /// Returns an iterator over all entries currently tracked in the index. + /// + /// Each item of the iterator is a borrowed reference to an [`IndexEntry`]. + /// + /// Entries are yielded in an unspecified order, which may not correspond + /// to the order in which they were added or appear on disk. + pub fn iter_entries(&self) -> impl Iterator { + self.entries.values() + } + /// Returns all entries currently tracked in the index as owned objects. pub fn get_entries_owned(&self) -> Vec { self.entries.values().cloned().collect() @@ -111,6 +126,66 @@ impl<'a> MevaIndex<'a> { .collect::>() } + /// Returns the paths of tracked entries, optionally filtered by a list of paths. + /// + /// # Arguments + /// + /// * `paths` - Optional slice of paths. Only entries starting with one of these paths are returned. + /// + /// # Returns + /// + /// A vector of [`PathBuf`] representing the paths of tracked entries. + pub fn get_entry_paths(&self, paths: Option<&[PathBuf]>) -> Vec { + self.entries + .keys() + .filter_map(|key| { + let path = PathBuf::from(key); + if let Some(allowed_paths) = paths + && !allowed_paths.iter().any(|p| path.starts_with(p)) + { + return None; + } + Some(path) + }) + .collect() + } + + /// Inserts a collection of [`IndexEntry`] objects into the in-memory index. + /// + /// Existing entries with the same path are replaced. + /// + /// **Note:** This method updates only the in-memory cache of entries. + /// To persist these changes to disk, you must call [`MevaIndex::save`]. + /// + /// # Arguments + /// + /// * `entries` - An iterable collection of [`IndexEntry`] objects to insert. + pub fn insert_entries(&mut self, entries: I) + where + I: IntoIterator, + { + for entry in entries { + self.entries.insert(entry.path.clone(), entry); + } + } + + /// Removes entries from the in-memory index by their paths. + /// + /// **Note:** This method updates only the in-memory cache of entries. + /// To persist these changes to disk, you must call [`MevaIndex::save`]. + /// + /// # Arguments + /// + /// * `keys` - An iterable collection of paths (`String`) identifying which entries to remove. + pub fn remove_entries(&mut self, keys: I) + where + I: IntoIterator, + { + for key in keys { + self.entries.remove(&key); + } + } + /// Checks whether a given file or directory is tracked in the Meva index. /// /// # Returns diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 87b58ff..928a3b6 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,6 +1,7 @@ mod commit_builder; mod object_storage; mod serialize_deserialize; +mod traversal; pub mod branch_manager; pub mod config; diff --git a/engine/src/objects.rs b/engine/src/objects.rs index ef36b3b..295e32c 100644 --- a/engine/src/objects.rs +++ b/engine/src/objects.rs @@ -18,3 +18,4 @@ pub use meva_tree::MevaTree; pub use object_entry::ObjectEntry; pub use person::Person; pub use tree_entry::TreeEntry; +pub use tree_entry_type::TreeEntryType; diff --git a/engine/src/traversal.rs b/engine/src/traversal.rs new file mode 100644 index 0000000..5f94350 --- /dev/null +++ b/engine/src/traversal.rs @@ -0,0 +1,5 @@ +mod meva_commit_tree_walker; +mod traits; + +pub use meva_commit_tree_walker::MevaCommitTreeWalker; +pub use traits::CommitTreeWalker; diff --git a/engine/src/traversal/meva_commit_tree_walker.rs b/engine/src/traversal/meva_commit_tree_walker.rs new file mode 100644 index 0000000..3eb49aa --- /dev/null +++ b/engine/src/traversal/meva_commit_tree_walker.rs @@ -0,0 +1,182 @@ +use super::CommitTreeWalker; +use crate::errors::EngineResult; +use crate::object_storage::ObjectStorage; +use crate::objects::{MevaBlob, MevaCommit, MevaTree, ObjectEntry, TreeEntry, TreeEntryType}; +use std::path::{Path, PathBuf}; + +/// A concrete implementation of [`CommitTreeWalker`] for traversing commits +/// stored in a local Meva object database. +/// +/// This walker performs a **depth-first recursive traversal** of the tree +/// structure referenced by a commit, retrieving all blobs and (optionally) trees +/// up to a given depth or restricted by a path filter. +/// +/// It operates entirely using the provided [`ObjectStorage`] backend, which +/// abstracts access to commit, tree, and blob objects. +pub struct MevaCommitTreeWalker<'a> { + /// The object storage backend providing access to commit, tree, and blob objects. + object_storage: Box, +} + +impl<'a> MevaCommitTreeWalker<'a> { + /// Creates a new [`MevaCommitTreeWalker`] backed by the given object storage. + /// + /// # Arguments + /// + /// * `object_storage` - A boxed [`ObjectStorage`] implementation used to fetch objects. + pub fn new(object_storage: Box) -> Self { + Self { object_storage } + } + + /// Helper function: Determines whether a subtree should be traversed based on the given path filter. + /// + /// This returns `true` if: + /// - There is no path filter (`None`), or + /// - The current entry path **starts with** or **is a prefix of** any of the filters. + fn should_traverse(&self, entry_path: &Path, path_filter: Option<&[PathBuf]>) -> bool { + match path_filter { + Some(filters) => filters + .iter() + .any(|f| f.starts_with(entry_path) || entry_path.starts_with(f)), + None => true, + } + } + + /// Checks whether a given entry path matches any of the provided filters. + /// + /// This is used to decide whether an individual entry (blob or tree) + /// should be **included** in the traversal result. + fn matches_entry(&self, entry_path: &Path, path_filter: Option<&[PathBuf]>) -> bool { + match path_filter { + Some(filters) => filters.iter().any(|f| entry_path.starts_with(f)), + None => true, + } + } + + /// Recursively lists all entries under a tree object. + /// + /// Traversal proceeds in depth-first order. For each tree entry: + /// - If it is a **tree** and `include_trees` is `true`, it is added to the result. + /// - If it is a **blob**, it is always added (subject to the path filter). + /// - If it matches the traversal filter, its children are recursively visited. + /// + /// # Arguments + /// + /// * `path` - The current path prefix being traversed. + /// * `tree_hash` - The hash (SHA-1) of the tree object to list. + /// * `depth` - The current recursion depth. + /// * `include_trees` - Whether to include tree entries in the result. + /// * `max_depth` - Optional recursion depth limit. + /// * `path_filter` - Optional list of path filters restricting traversal. + /// + /// # Returns + /// + /// A vector of [`ObjectEntry`]s representing all files (and optionally trees) + /// discovered under this subtree. + fn list_tree_recursive( + &self, + path: PathBuf, + tree_hash: &str, + depth: u32, + include_trees: bool, + max_depth: Option, + path_filter: Option<&[PathBuf]>, + ) -> EngineResult> { + if let Some(max_d) = max_depth + && depth > max_d + { + return Ok(Vec::new()); + } + + let mut listed_entries: Vec = Vec::new(); + let mut dirs_to_list: Vec = Vec::new(); + let tree_object = self.object_storage.get_object(tree_hash)?; + let tree = MevaTree::try_from(tree_object)?; + for entry in tree.tree_entries { + let entry_path = path.join(entry.name.clone()); + match entry.entry_type { + TreeEntryType::Tree => { + if include_trees && self.matches_entry(&entry_path, path_filter) { + let object_entry = ObjectEntry { + entry_type: entry.entry_type.clone(), + hash: entry.object_hash.clone(), + size: None, + path: entry_path.clone(), + }; + listed_entries.push(object_entry); + } + if self.should_traverse(&entry_path, path_filter) { + dirs_to_list.push(entry); + } + } + _ => { + if self.matches_entry(&entry_path, path_filter) { + let blob_object = self.object_storage.get_object(&entry.object_hash)?; + let blob = MevaBlob::try_from(blob_object)?; + let object_entry = ObjectEntry { + entry_type: entry.entry_type, + hash: entry.object_hash, + size: Some(blob.size()), + path: path.join(entry.name), + }; + listed_entries.push(object_entry); + } + } + } + } + + for entry in dirs_to_list { + let listed = self.list_tree_recursive( + path.join(entry.name), + &entry.object_hash, + depth + 1, + include_trees, + max_depth, + path_filter, + )?; + listed_entries.extend(listed); + } + + Ok(listed_entries) + } +} + +impl<'a> CommitTreeWalker for MevaCommitTreeWalker<'a> { + /// Walks the tree structure of a commit, returning all entries that match + /// the traversal rules. + /// + /// This implementation: + /// 1. Loads the commit object from storage. + /// 2. Extracts its top-level tree hash. + /// 3. Recursively lists all blobs (and optionally trees) under it. + /// + /// # Arguments + /// + /// * `commit_hash` - The hash (SHA-1) of the commit to walk. + /// * `include_trees` - If `true`, directories (trees) are included in the result. + /// * `max_depth` - Optional recursion depth limit. + /// * `path_filter` - Optional slice of `PathBuf`s used to restrict traversal. + /// + /// # Returns + /// + /// A vector of [`ObjectEntry`]s representing all entries found under the commit’s tree. + fn walk_commit( + &self, + commit_hash: &str, + include_trees: bool, + max_depth: Option, + path_filter: Option<&[PathBuf]>, + ) -> EngineResult> { + let commit_object = self.object_storage.get_object(commit_hash)?; + let commit = MevaCommit::try_from(commit_object)?; + + self.list_tree_recursive( + PathBuf::new(), + &commit.tree, + 0, + include_trees, + max_depth, + path_filter, + ) + } +} diff --git a/engine/src/traversal/traits.rs b/engine/src/traversal/traits.rs new file mode 100644 index 0000000..4172e5b --- /dev/null +++ b/engine/src/traversal/traits.rs @@ -0,0 +1,39 @@ +use crate::errors::EngineResult; +use crate::objects::ObjectEntry; +use std::path::PathBuf; + +/// Defines a generic interface for walking (traversing) the tree structure +/// of a commit in a Meva repository. +/// +/// Implementors of this trait provide a mechanism for traversing the tree +/// objects referenced by a given commit, optionally including subtrees and blobs, +/// while respecting optional filtering and traversal depth. d +pub trait CommitTreeWalker { + /// Walks the tree structure of a given commit. + /// + /// # Arguments + /// + /// * `commit_hash` - The hash of the commit whose tree should be traversed. + /// * `include_trees` - If `true`, tree objects (directories) will also be included in the result. + /// * `max_depth` - An optional maximum traversal depth. If `None`, traversal is unbounded. + /// * `path_filter` - An optional list of path prefixes (`PathBuf`s) used to restrict traversal. + /// Only paths matching or nested under one of these filters will be included. + /// + /// # Returns + /// + /// Returns a vector of [`ObjectEntry`] values representing the blobs and/or trees + /// discovered during traversal. + /// + /// # Errors + /// + /// Returns an [`EngineError`] if: + /// - The commit object or any referenced tree/blob cannot be loaded. + /// - An object is invalid or cannot be deserialized. + fn walk_commit( + &self, + commit_hash: &str, + include_trees: bool, + max_depth: Option, + path_filter: Option<&[PathBuf]>, + ) -> EngineResult>; +} From b353dfb226e0f7b96f064ff363871f8ba1618b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Sun, 16 Nov 2025 14:46:23 +0100 Subject: [PATCH 20/42] Dependency Injection Refactor (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Adam Grącikowski <01180781@pw.edu.pl> --- cli/src/commands/commit.rs | 7 +- cli/src/commands/config/subcommands/edit.rs | 5 +- cli/src/commands/diff.rs | 11 +- cli/src/commands/ignore/subcommands/edit.rs | 4 +- cli/src/commands/plugins/subcommands/edit.rs | 4 +- cli/src/commands/show.rs | 12 +- cli/src/extensions/command/with_locations.rs | 2 +- engine/src/branch_manager.rs | 4 +- .../src/branch_manager/meva_branch_manager.rs | 76 +- engine/src/commit_builder.rs | 12 + .../src/commit_builder/meva_commit_builder.rs | 102 +-- engine/src/config.rs | 2 +- engine/src/config/config_loader.rs | 173 +++-- engine/src/diff.rs | 8 - engine/src/diff_builder.rs | 106 +++ .../meva_diff_builder.rs} | 689 +++++++++--------- engine/src/{diff => diff_builder}/models.rs | 0 .../models/change_kind.rs | 4 +- .../models/diff_mode.rs | 0 .../models/diff_stat.rs | 2 +- .../models/file_change.rs | 0 .../models/file_change_kind.rs | 0 .../models/file_diff_stat.rs | 0 .../src/{diff => diff_builder}/models/hunk.rs | 0 .../models/revision_range.rs | 6 +- .../models/revision_side.rs | 0 engine/src/engine_container.rs | 309 ++++++-- engine/src/handlers/add/handlers.rs | 52 +- engine/src/handlers/commit/handlers.rs | 101 +-- engine/src/handlers/commit/operations.rs | 2 +- engine/src/handlers/config/handlers.rs | 14 +- engine/src/handlers/diff/handlers.rs | 29 +- engine/src/handlers/diff/operations.rs | 9 +- engine/src/handlers/init/handlers.rs | 22 +- engine/src/handlers/log/handlers.rs | 60 +- engine/src/handlers/log/operations.rs | 2 +- engine/src/handlers/ls_files/handlers.rs | 50 +- engine/src/handlers/ls_tree/handlers.rs | 45 +- engine/src/handlers/plugins/handlers.rs | 10 +- engine/src/handlers/restore/handlers.rs | 81 +- engine/src/handlers/show/handlers.rs | 36 +- engine/src/handlers/show/models/patch_mode.rs | 2 +- engine/src/handlers/show/operations.rs | 2 +- engine/src/handlers/status/handlers.rs | 106 +-- .../handlers/status/models/status_entry.rs | 2 +- .../src/handlers/status/models/status_kind.rs | 2 +- engine/src/index.rs | 135 +++- engine/src/index/meva_index.rs | 488 +++++-------- engine/src/index/working_dir.rs | 264 ++++--- engine/src/lib.rs | 14 +- engine/src/object_storage.rs | 12 +- .../meva_dry_run_object_storage.rs | 8 +- .../src/object_storage/meva_object_storage.rs | 16 +- engine/src/objects/meva_object.rs | 2 +- engine/src/plugins_interceptor.rs | 31 +- engine/src/ref_manager.rs | 2 +- engine/src/ref_manager/meva_ref_manager.rs | 11 +- engine/src/repositories.rs | 1 - engine/src/repositories/meva_repository.rs | 100 +-- .../repositories/meva_repository_layout.rs | 22 +- engine/src/repositories/repository_layout.rs | 32 +- engine/src/revision_parsing.rs | 5 + .../meva_revision_resolver.rs | 121 +++ .../src/revision_parsing/revision_resolver.rs | 126 +--- .../src/traversal/meva_commit_tree_walker.rs | 11 +- plugins/src/layout.rs | 2 +- plugins/src/lib.rs | 4 +- plugins/src/plugins_discovery.rs | 56 +- plugins/src/plugins_engine.rs | 139 ++-- 69 files changed, 2153 insertions(+), 1614 deletions(-) delete mode 100644 engine/src/diff.rs create mode 100644 engine/src/diff_builder.rs rename engine/src/{diff/diff_builder.rs => diff_builder/meva_diff_builder.rs} (93%) rename engine/src/{diff => diff_builder}/models.rs (100%) rename engine/src/{diff => diff_builder}/models/change_kind.rs (96%) rename engine/src/{diff => diff_builder}/models/diff_mode.rs (100%) rename engine/src/{diff => diff_builder}/models/diff_stat.rs (99%) rename engine/src/{diff => diff_builder}/models/file_change.rs (100%) rename engine/src/{diff => diff_builder}/models/file_change_kind.rs (100%) rename engine/src/{diff => diff_builder}/models/file_diff_stat.rs (100%) rename engine/src/{diff => diff_builder}/models/hunk.rs (100%) rename engine/src/{diff => diff_builder}/models/revision_range.rs (88%) rename engine/src/{diff => diff_builder}/models/revision_side.rs (100%) create mode 100644 engine/src/revision_parsing/meva_revision_resolver.rs diff --git a/cli/src/commands/commit.rs b/cli/src/commands/commit.rs index 7435dc7..5e25f7a 100644 --- a/cli/src/commands/commit.rs +++ b/cli/src/commands/commit.rs @@ -1,6 +1,6 @@ use crate::commands::MevaCommand; use clap::{Arg, ArgAction, ArgMatches, Command}; -use engine::diff::{DiffStat, FileChangeKind}; +use engine::diff_builder::{DiffStat, FileChangeKind}; use engine::errors::{CommitError, EngineError}; use engine::handlers::{add::AddRequest, commit::Request, commit::Response}; use engine::objects::Person; @@ -212,7 +212,6 @@ impl MevaCommand for CommitCommand { .into_diagnostic()?; } - let commit_handler = container.commit_handler().into_diagnostic()?; let commit_request = Request { dry_run_arg, amend_arg, @@ -220,6 +219,10 @@ impl MevaCommand for CommitCommand { author_arg: author_arg.cloned(), }; + let commit_handler = container + .commit_handler(&commit_request) + .into_diagnostic()?; + let response = match commit_handler.handle_commit(commit_request, &interceptor) { Ok(val) => val, Err(err @ EngineError::Commit(CommitError::NothingToCommit)) => { diff --git a/cli/src/commands/config/subcommands/edit.rs b/cli/src/commands/config/subcommands/edit.rs index 4695696..31bc2ef 100644 --- a/cli/src/commands/config/subcommands/edit.rs +++ b/cli/src/commands/config/subcommands/edit.rs @@ -1,8 +1,7 @@ use clap::{ArgMatches, Command}; use engine::engine_container::MevaContainer; +use engine::{ConfigDocument, ConfigLoader, MevaConfigLoader}; use miette::{Context, IntoDiagnostic}; - -use engine::{ConfigDocument, ConfigLoader}; use shared::OpenInEditor; use crate::commands::MevaCommand; @@ -44,7 +43,7 @@ impl MevaCommand for ConfigEditCommand { } fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> miette::Result<()> { - let loader = ConfigLoader::default(); + let loader = MevaConfigLoader::default(); let override_cmd = loader.get("core.editor", None).ok(); let location = matches .get_config_location() diff --git a/cli/src/commands/diff.rs b/cli/src/commands/diff.rs index 3b89134..b26b1dc 100644 --- a/cli/src/commands/diff.rs +++ b/cli/src/commands/diff.rs @@ -2,10 +2,10 @@ use std::path::PathBuf; use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; use engine::{ - diff::DiffMode, + EngineContainer, + diff_builder::DiffMode, engine_container::MevaContainer, - handlers::diff::{DiffHandler, Request, Response}, - repositories::meva_repository_layout::MevaRepositoryLayout, + handlers::diff::{Request, Response}, revision_parsing::Revision, }; @@ -147,7 +147,7 @@ impl MevaCommand for DiffCommand { } /// Executes the `diff` command. - fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> Result<()> { + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { let cached = matches.get_flag(Self::ARG_CACHED); let from = matches.get_one::(Self::ARG_RANGE1); let to = matches.get_one::(Self::ARG_RANGE2); @@ -165,8 +165,7 @@ impl MevaCommand for DiffCommand { let request = Request::new(from.cloned(), to.cloned(), cached, mode, paths); - let layout = MevaRepositoryLayout::discover().into_diagnostic()?; - let handler = DiffHandler::new(&layout).into_diagnostic()?; + let handler = container.diff_handler().into_diagnostic()?; let response = handler.handle_diff(request).into_diagnostic()?; self.display_response(&mode, &response); diff --git a/cli/src/commands/ignore/subcommands/edit.rs b/cli/src/commands/ignore/subcommands/edit.rs index ac6e76a..d0f70c3 100644 --- a/cli/src/commands/ignore/subcommands/edit.rs +++ b/cli/src/commands/ignore/subcommands/edit.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use clap::{ArgMatches, Command}; use engine::{ - ConfigLoader, IgnoreOperations, IgnoreService, RepositoryLayout, + ConfigLoader, IgnoreOperations, IgnoreService, MevaConfigLoader, RepositoryLayout, engine_container::MevaContainer, repositories::meva_repository_layout::MevaRepositoryLayout, }; use miette::IntoDiagnostic; @@ -48,7 +48,7 @@ impl MevaCommand for IgnoreEditCommand { let layout = MevaRepositoryLayout::from_env().into_diagnostic()?; let ignore_service = IgnoreService::new(layout.ignore_file_name()); - let loader = ConfigLoader::default(); + let loader = MevaConfigLoader::default(); let override_cmd = loader.get("core.editor", None).ok(); let ignore_file = match file { diff --git a/cli/src/commands/plugins/subcommands/edit.rs b/cli/src/commands/plugins/subcommands/edit.rs index 3eba283..0300d4c 100644 --- a/cli/src/commands/plugins/subcommands/edit.rs +++ b/cli/src/commands/plugins/subcommands/edit.rs @@ -1,6 +1,6 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; use engine::{ - ConfigLoader, EngineContainer, + ConfigLoader, EngineContainer, MevaConfigLoader, engine_container::MevaContainer, handlers::plugins::{EditRequest, PluginsOperations}, }; @@ -92,7 +92,7 @@ impl MevaCommand for PluginsEditCommand { let response = handler.edit(request).into_diagnostic()?; if enabled.is_none() { - let loader = ConfigLoader::default(); + let loader = MevaConfigLoader::default(); let override_cmd = loader.get("core.editor", None).ok(); response .source_file diff --git a/cli/src/commands/show.rs b/cli/src/commands/show.rs index 30a53f6..bc3cf75 100644 --- a/cli/src/commands/show.rs +++ b/cli/src/commands/show.rs @@ -1,9 +1,9 @@ use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command, value_parser}; use engine::{ - diff::ChangeKind, + EngineContainer, + diff_builder::ChangeKind, engine_container::MevaContainer, - handlers::show::{PatchMode, Request, Response, ShowHandler}, - repositories::meva_repository_layout::MevaRepositoryLayout, + handlers::show::{PatchMode, Request, Response}, revision_parsing::Revision, }; use miette::{IntoDiagnostic, Result}; @@ -179,12 +179,10 @@ impl MevaCommand for ShowCommand { } /// Executes the `show` command. - fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> Result<()> { + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { let snapshot_id = matches.get_one::(Self::ARG_SNAPSHOT_ID).unwrap(); - // let handler = container.show_handler().into_diagnostic()?; - let layout = MevaRepositoryLayout::discover().into_diagnostic()?; - let handler = ShowHandler::new(&layout).into_diagnostic()?; + let handler = container.show_handler().into_diagnostic()?; let mode = if matches.get_flag(Self::ARG_PATCH) { PatchMode::Patch diff --git a/cli/src/extensions/command/with_locations.rs b/cli/src/extensions/command/with_locations.rs index 1b479b6..25f9901 100644 --- a/cli/src/extensions/command/with_locations.rs +++ b/cli/src/extensions/command/with_locations.rs @@ -18,7 +18,7 @@ pub trait WithLocations { /// Extend a `Command` with location-selection arguments. /// - /// # Parameters + /// # Arguments /// /// * `self` - The `Command` to augment. /// * `global_help` - Help text for the global flag. diff --git a/engine/src/branch_manager.rs b/engine/src/branch_manager.rs index 69c0ae2..7ab01a6 100644 --- a/engine/src/branch_manager.rs +++ b/engine/src/branch_manager.rs @@ -4,10 +4,12 @@ use crate::errors::EngineResult; use crate::objects::MevaCommit; pub use meva_branch_manager::MevaBranchManager; + /// Defines a common interface for managing commits within a branch. /// /// Currently, this trait focuses on commit-level operations: /// - adding new commits, +/// - amending the latest commit, /// - retrieving the latest commit hash or object. /// /// In the future, it can be extended to support full branch operations, @@ -19,7 +21,7 @@ pub use meva_branch_manager::MevaBranchManager; /// - `add_commit` – adds a commit to the branch and returns its SHA-1 hash. /// - `last_commit_hash` – retrieves the SHA-1 hash of the most recent commit, if any. /// - `last_commit` – retrieves the most recent commit object, if any. -pub trait BranchManger { +pub trait BranchManager { /// Adds a new commit to the branch. /// /// # Arguments diff --git a/engine/src/branch_manager/meva_branch_manager.rs b/engine/src/branch_manager/meva_branch_manager.rs index b01f830..84f2b9c 100644 --- a/engine/src/branch_manager/meva_branch_manager.rs +++ b/engine/src/branch_manager/meva_branch_manager.rs @@ -1,15 +1,12 @@ -use crate::ObjectStorage; -use crate::RefManager; -use crate::RepositoryLayout; -use crate::branch_manager::BranchManger; use crate::errors::{CommitError, EngineResult}; -use crate::object_storage::MevaObjectStorage; use crate::objects::MevaCommit; -use crate::ref_manager::{Head, HeadMode, MevaRefManager, RefEntry}; +use crate::ref_manager::{Head, HeadMode, RefEntry}; +use crate::{BranchManager, ObjectStorage, RefManager}; +use std::sync::Arc; /// Manages commits within a Meva branch. /// -/// `MevaBranchManager` is a concrete implementation of [`BranchManger`] +/// `MevaBranchManager` is a concrete implementation of [`BranchManager`] /// that provides commit-level operations for a branch using: /// - [`MevaObjectStorage`] for persistent object storage, /// - [`MevaRefManager`] for reading and updating branch references (HEAD). @@ -17,25 +14,28 @@ use crate::ref_manager::{Head, HeadMode, MevaRefManager, RefEntry}; /// Currently, this manager handles adding commits and querying the latest commit. /// In the future, it can be extended to support full branch-level operations, /// such as creating new branches, switching branches (checkout), or merging. -pub struct MevaBranchManager<'a> { +pub struct MevaBranchManager { /// Provides storage for commit objects. - object_storage: MevaObjectStorage<'a>, + object_storage: Arc, /// Provides access to branch references (HEAD, etc.). - ref_manager: MevaRefManager<'a>, + ref_manager: Arc, } -impl<'a> MevaBranchManager<'a> { - /// Creates a new `MevaBranchManager` for the given repository layout. + +impl MevaBranchManager { + /// Creates a new [`MevaBranchManager`] for the given repository layout. /// /// # Arguments /// - /// * `repo_layout` – Provides repository paths and layout information. - pub fn new(repo_layout: &'a dyn RepositoryLayout) -> Self { + /// * `object_storage` – Provides storage for commit objects. + /// * `ref_manager` - Provides access to branch references. + pub fn new(object_storage: Arc, ref_manager: Arc) -> Self { Self { - object_storage: MevaObjectStorage::new(repo_layout), - ref_manager: MevaRefManager::new(repo_layout), + object_storage, + ref_manager, } } + /// Updates the branch head to point to a new commit. /// /// Depending on the current [`HeadMode`], this method either: @@ -73,19 +73,7 @@ impl<'a> MevaBranchManager<'a> { } } -impl BranchManger for MevaBranchManager<'_> { - /// Adds a commit to the branch. - /// - /// Automatically sets the `parent` field of the commit to the - /// current latest commit, if one exists. - /// - /// # Returns - /// - /// The SHA-1 hash of the newly added commit. - /// - /// # Errors - /// - /// Returns an [`EngineResult`] if storing the commit fails. +impl BranchManager for MevaBranchManager { fn add_commit(&self, mut commit: MevaCommit) -> EngineResult { let last_commit_hash = self.last_commit_hash()?; match last_commit_hash { @@ -104,18 +92,6 @@ impl BranchManger for MevaBranchManager<'_> { Ok(hash) } - /// Amends the most recent commit in the branch. - /// - /// Replaces the latest commit with a new one while preserving its parent commits. - /// - /// # Returns - /// - /// The SHA-1 hash of the amended commit. - /// - /// # Errors - /// - /// Returns [`CommitError::NoCommitToAmend`] if the branch has no commits yet, - /// or an [`EngineResult`] if an error occurs during commit storage or reference update. fn amend_last_commit(&self, mut commit: MevaCommit) -> EngineResult { let last_commit = self.last_commit()?.ok_or(CommitError::NoCommitToAmend)?; @@ -126,15 +102,6 @@ impl BranchManger for MevaBranchManager<'_> { Ok(hash) } - /// Returns the SHA-1 hash of the most recent commit in the branch. - /// - /// # Returns - /// - /// `Ok(Some(hash))` if there is at least one commit, `Ok(None)` if the branch is empty. - /// - /// # Errors - /// - /// Returns an [`EngineResult`] if reading branch references fails. fn last_commit_hash(&self) -> EngineResult> { let head = self.ref_manager.read_head()?; Ok(match head.mode { @@ -146,15 +113,6 @@ impl BranchManger for MevaBranchManager<'_> { }) } - /// Returns the most recent commit object in the branch. - /// - /// # Returns - /// - /// `Ok(Some(commit))` if there is at least one commit, `Ok(None)` if the branch is empty. - /// - /// # Errors - /// - /// Returns an [`EngineResult`] if retrieving the commit object fails. fn last_commit(&self) -> EngineResult> { if let Some(hash) = self.last_commit_hash()? { let object = self.object_storage.get_object(&hash)?; diff --git a/engine/src/commit_builder.rs b/engine/src/commit_builder.rs index 52c528b..278a06d 100644 --- a/engine/src/commit_builder.rs +++ b/engine/src/commit_builder.rs @@ -1,3 +1,15 @@ pub mod meva_commit_builder; +use crate::errors::EngineResult; +use crate::objects::{MevaCommit, Person}; + pub use meva_commit_builder::MevaCommitBuilder; + +/// Defines the interface for constructing commits from the current repository state. +/// +/// A [`CommitBuilder`] is responsible for producing a fully-formed [`MevaCommit`] +/// by assembling all necessary metadata and referencing the tree representing +/// the repository contents. +pub trait CommitBuilder { + fn build_commit(&self, message: String, author: Person) -> EngineResult; +} diff --git a/engine/src/commit_builder/meva_commit_builder.rs b/engine/src/commit_builder/meva_commit_builder.rs index 1deab1a..d63c09d 100644 --- a/engine/src/commit_builder/meva_commit_builder.rs +++ b/engine/src/commit_builder/meva_commit_builder.rs @@ -1,14 +1,14 @@ +use crate::CommitBuilder; use crate::ObjectStorage; -use crate::RepositoryLayout; use crate::errors::{EngineError, EngineResult, NodeError, PathError, TreeError}; -use crate::index::MevaIndex; -use crate::index::file_mode::FileMode; +use crate::index::{Index, file_mode::FileMode}; use crate::objects::{MevaCommit, MevaTree, Person, TreeEntry}; use chrono::Utc; use path_absolutize::Absolutize; use shared::StripBase; use shared::{CumulativePaths, PathToString}; use std::path::Path; +use std::sync::Arc; use tree_ds::prelude::{Node, Tree}; /// Builder responsible for constructing and storing a commit tree in memory. @@ -23,68 +23,40 @@ use tree_ds::prelude::{Node, Tree}; /// Finally, it stores the resulting tree objects in the object storage. /// /// This structure is the core mechanism for assembling commits in Meva. -pub struct MevaCommitBuilder<'a> { - /// Repository layout (provides access to working dir, index file, etc.). - repo_layout: &'a dyn RepositoryLayout, +pub struct MevaCommitBuilder { + /// The object storage backend responsible for persisting objects (trees, blobs, commits). + object_storage: Arc, - /// Responsible for persisting objects (trees, blobs, commits). - object_storage: &'a dyn ObjectStorage, + /// The index providing the list of tracked files and their object hashes. + index: Arc, } -/// Helper structure representing a single file entry in a commit tree. +/// Represents the value stored in a file node within the constructed commit tree. /// -/// Stores the blob’s SHA-1 hash and its file mode. -/// Used internally by [`MevaCommitBuilder`] when assembling tree objects. +/// Each [`TreeValue`] corresponds to a single file entry and stores: +/// - `hash`: The SHA-1 hash of the blob. +/// - `mode`: The file mode (executable, normal file, etc.). +/// +/// This type is used internally when building tree hierarchies. #[derive(Debug, Clone, Eq, PartialEq)] struct TreeValue { pub hash: String, pub mode: FileMode, } -impl<'a> MevaCommitBuilder<'a> { - /// Creates a new [`MevaCommitBuilder`] instance. +impl MevaCommitBuilder { + /// Creates a new [`MevaCommitBuilder`]. /// /// # Arguments - /// - /// * `repo_layout` – Reference to the repository layout implementation. - /// * `object_storage` – Object storage backend for persisting commit components. - pub fn new( - repo_layout: &'a dyn RepositoryLayout, - object_storage: &'a dyn ObjectStorage, - ) -> Self { + /// * `object_storage` — Backend responsible for writing commit-related objects. + /// * `index` — Source of tracked files used to construct the commit tree. + pub fn new(object_storage: Arc, index: Arc) -> Self { Self { - repo_layout, object_storage, + index, } } - /// Builds a new [`MevaCommit`] from the current index. - /// - /// Constructs a full tree from the index, serializes and stores all - /// related [`MevaTree`] objects, and returns a new commit referencing - /// the root tree. - /// - /// The resulting commit **does not have its parent set** — this must be - /// handled externally (e.g., by the branch manager). - /// - /// # Arguments - /// - /// * `message` – Commit message describing the change. - /// * `author` – Author information (name and email). - /// * `verbose` – If true, prints information about stored objects. - /// - /// # Errors - /// - /// Returns an [`EngineResult`] if index loading, tree construction, - /// or object storage fails. - pub fn build_commit(&self, message: String, author: Person) -> EngineResult { - let tree = self.build_tree_from_index()?; - - let root_tree_hash = self.store_tree_objects(tree)?; - - Ok(MevaCommit::new(root_tree_hash, message, author, Utc::now())) - } - /// Stores a complete [`Tree`] of repository contents recursively in object storage. /// /// Returns the hash of the root tree object. @@ -150,9 +122,9 @@ impl<'a> MevaCommitBuilder<'a> { /// Returns an [`EngineResult`] if index loading, path resolution, /// or tree construction fails. fn build_tree_from_index(&self) -> EngineResult> { - let index = MevaIndex::from_disk(self.repo_layout, None)?; - let entries = index.get_entries(); - let meva_repository_dir = self.repo_layout.working_dir().absolutize()?; + let entries = self.index.get_entries(); + let working_dir = self.object_storage.layout().working_dir(); + let meva_repository_dir = working_dir.absolutize()?; let mut tree: Tree = Tree::new(None); let root_key = meva_repository_dir.to_utf8_string(); @@ -214,3 +186,31 @@ impl<'a> MevaCommitBuilder<'a> { Ok(tree) } } + +impl CommitBuilder for MevaCommitBuilder { + /// Builds a new [`MevaCommit`] from the current index. + /// + /// Constructs a full tree from the index, serializes and stores all + /// related [`MevaTree`] objects, and returns a new commit referencing + /// the root tree. + /// + /// The resulting commit **does not have its parent set** — this must be + /// handled externally (e.g., by the branch manager). + /// + /// # Arguments + /// + /// * `message` – Commit message describing the change. + /// * `author` – Author information (name and email). + /// + /// # Errors + /// + /// Returns an [`EngineResult`] if index loading, tree construction, + /// or object storage fails. + fn build_commit(&self, message: String, author: Person) -> EngineResult { + let tree = self.build_tree_from_index()?; + + let root_tree_hash = self.store_tree_objects(tree)?; + + Ok(MevaCommit::new(root_tree_hash, message, author, Utc::now())) + } +} diff --git a/engine/src/config.rs b/engine/src/config.rs index bd9d9b4..9c74b2f 100644 --- a/engine/src/config.rs +++ b/engine/src/config.rs @@ -5,5 +5,5 @@ pub mod config_location; pub use config_document::ConfigDocument; pub use config_document_operations::ConfigDocumentOperations; -pub use config_loader::ConfigLoader; +pub use config_loader::{ConfigLoader, ConfigLoaderExtensions, MevaConfigLoader}; pub use config_location::ConfigLocation; diff --git a/engine/src/config/config_loader.rs b/engine/src/config/config_loader.rs index c99e097..3aa67c7 100644 --- a/engine/src/config/config_loader.rs +++ b/engine/src/config/config_loader.rs @@ -8,14 +8,46 @@ use crate::{ errors::{ConfigError, EngineError, EngineResult}, }; +pub trait ConfigLoader: Send + Sync { + /// Retrieve a configuration value by key, searching through each + /// configured location until found or falling back to default. + /// + /// # Arguments + /// + /// * `key_path` - Dot-separated key string (e.g., "section.key"). + /// * `default` - Optional default value if key is missing in all locations. + /// + /// # Returns + /// + /// The found `String` value or an error if not found or on I/O issues. + fn get(&self, key_path: &str, default: Option<&String>) -> EngineResult; + + /// Create a new local configuration file with default settings. + /// + /// # Arguments + /// + /// * `path` - Path where the local config file should be created. + fn create_local_config(&self, path: &Path) -> EngineResult<()>; + + /// Create a new global configuration file with default settings. + /// If the file already exists, it will not overwrite it. + fn create_global_config(&self) -> EngineResult<()>; + + /// Returns the default content for a local configuration file. + fn get_default_local_config(&self) -> &str; + + /// Returns the default content for a global configuration file. + fn get_default_global_config(&self) -> &str; +} + /// Responsible for loading configuration values from multiple sources /// in a defined search order (e.g., local, global). -pub struct ConfigLoader { +pub struct MevaConfigLoader { /// Ordered list of locations to search for configuration files. locations: Vec, } -impl Default for ConfigLoader { +impl Default for MevaConfigLoader { /// Creates a loader with the default search order. fn default() -> Self { Self { @@ -24,28 +56,46 @@ impl Default for ConfigLoader { } } -impl ConfigLoader { +impl MevaConfigLoader { /// Creates a loader with a custom list of locations. /// /// # Arguments /// /// * `locations` - A vector of `ConfigLocation` enums defining the search order. - pub fn with_locations(locations: Vec) -> Self { + #[allow(dead_code)] + fn with_locations(locations: Vec) -> Self { Self { locations } } - /// Retrieve a configuration value by key, searching through each - /// configured location until found or falling back to default. + /// Attempt to load a configuration value from a single location. /// /// # Arguments /// - /// * `key_path` - Dot-separated key string (e.g., "section.key"). - /// * `default` - Optional default value if key is missing in all locations. + /// * `loc` - The `ConfigLocation` to load from (local, global, system). + /// * `key_path` - The dot-separated key string. + /// * `default` - Optional default value if the key is missing. /// /// # Returns /// - /// The found `String` value or an error if not found or on I/O issues. - pub fn get(&self, key_path: &str, default: Option<&String>) -> EngineResult { + /// The found value or an error wrapped in `EngineResult`. + fn try_load_from( + &self, + location: &ConfigLocation, + key_path: &str, + default: Option<&String>, + ) -> EngineResult { + match location.get_default_path() { + Ok(path) => { + let doc = ConfigDocument::load(&path)?; + doc.get(key_path, default) + } + Err(err) => Err(err), + } + } +} + +impl ConfigLoader for MevaConfigLoader { + fn get(&self, key_path: &str, default: Option<&String>) -> EngineResult { let mut last_key_not_found = None; for loc in &self.locations { @@ -77,64 +127,7 @@ impl ConfigLoader { } } - /// Retrieve a configuration value and parse it into a specific type. - /// - /// # Arguments - /// - /// * `key` - Dot-separated key string to locate the configuration value. - /// * `default` - Default value returned if key is missing. - /// - /// # Returns - /// - /// Parsed value of type `T`, or an error if parsing fails. - pub fn get_parsed(&self, key: &str, default: T) -> EngineResult - where - T: FromStr + ToString, - ::Err: std::fmt::Display, - { - let default_string = default.to_string(); - - let raw_value = self.get(key, Some(&default_string))?; - - raw_value.parse::().map_err(|e| { - EngineError::Config(ConfigError::InvalidValueType { - type_name: format!("{}: {}", std::any::type_name::(), e), - }) - }) - } - - /// Attempt to load a configuration value from a single location. - /// - /// # Arguments - /// - /// * `loc` - The `ConfigLocation` to load from (local, global, system). - /// * `key_path` - The dot-separated key string. - /// * `default` - Optional default value if the key is missing. - /// - /// # Returns - /// - /// The found value or an error wrapped in `EngineResult`. - fn try_load_from( - &self, - location: &ConfigLocation, - key_path: &str, - default: Option<&String>, - ) -> EngineResult { - match location.get_default_path() { - Ok(path) => { - let doc = ConfigDocument::load(&path)?; - doc.get(key_path, default) - } - Err(err) => Err(err), - } - } - - /// Create a new local configuration file with default settings. - /// - /// # Arguments - /// - /// * `path` - Path where the local config file should be created. - pub fn create_local_config(&self, path: &Path) -> EngineResult<()> { + fn create_local_config(&self, path: &Path) -> EngineResult<()> { let (mut config_file, created) = create_file_with_dirs(path)?; if created { @@ -144,9 +137,7 @@ impl ConfigLoader { Ok(()) } - /// Create a new global configuration file with default settings. - /// If the file already exists, it will not overwrite it. - pub fn create_global_config(&self) -> EngineResult<()> { + fn create_global_config(&self) -> EngineResult<()> { let path = ConfigLocation::Global.get_default_path()?; let (mut config_file, created) = create_file_with_dirs(path)?; @@ -157,8 +148,7 @@ impl ConfigLoader { Ok(()) } - /// Returns the default content for a local configuration file. - pub fn get_default_local_config(&self) -> &str { + fn get_default_local_config(&self) -> &str { let default_config = concat!( "# Meva Configuration File\n", "# Edit this file to customize your local settings\n", @@ -172,8 +162,7 @@ impl ConfigLoader { default_config } - /// Returns the default content for a global configuration file. - pub fn get_default_global_config(&self) -> &str { + fn get_default_global_config(&self) -> &str { let default_config = concat!( "# Meva Configuration File\n", "# Edit this file to customize your global settings\n", @@ -194,3 +183,37 @@ impl ConfigLoader { default_config } } + +pub trait ConfigLoaderExtensions: ConfigLoader { + /// Retrieve a configuration value and parse it into a specific type. + /// + /// # Arguments + /// + /// * `key` - Dot-separated key string to locate the configuration value. + /// * `default` - Default value returned if key is missing. + /// + /// # Returns + /// + /// Parsed value of type `T`, or an error if parsing fails. + fn get_parsed(&self, key: &str, default: T) -> EngineResult + where + T: FromStr + ToString, + ::Err: std::fmt::Display; +} + +impl ConfigLoaderExtensions for L { + fn get_parsed(&self, key: &str, default: T) -> EngineResult + where + T: FromStr + ToString, + ::Err: std::fmt::Display, + { + let default_string = default.to_string(); + let raw_value = self.get(key, Some(&default_string))?; + + raw_value.parse::().map_err(|e| { + EngineError::Config(ConfigError::InvalidValueType { + type_name: format!("{}: {}", std::any::type_name::(), e), + }) + }) + } +} diff --git a/engine/src/diff.rs b/engine/src/diff.rs deleted file mode 100644 index e865e8e..0000000 --- a/engine/src/diff.rs +++ /dev/null @@ -1,8 +0,0 @@ -mod diff_builder; -mod models; - -pub use diff_builder::DiffBuilder; -pub use models::{ - ChangeKind, DiffMode, DiffStat, FileChange, FileChangeKind, Hunk, HunkLine, HunkLineType, - RevisionRange, RevisionSide, -}; diff --git a/engine/src/diff_builder.rs b/engine/src/diff_builder.rs new file mode 100644 index 0000000..b2ca4f1 --- /dev/null +++ b/engine/src/diff_builder.rs @@ -0,0 +1,106 @@ +mod meva_diff_builder; +mod models; + +use crate::errors::EngineResult; +use crate::index::WorkingDirEntry; +use crate::index::index_entry::IndexEntry; +use crate::objects::{MevaCommit, ObjectEntry}; +use crate::revision_parsing::Revision; +pub use meva_diff_builder::MevaDiffBuilder; +pub use models::{ + ChangeKind, DiffMode, DiffStat, FileChange, FileChangeKind, Hunk, HunkLine, HunkLineType, + RevisionRange, RevisionSide, +}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Defines the interface for computing diffs between repository states. +/// +/// A [`DiffBuilder`] is responsible for comparing snapshots of the repository: +/// commits, the index, and the working directory. +/// +/// Implementations of this trait provide the full set of comparison strategies +/// needed to detect added, removed, and modified files. The output is always a +/// list of [`FileChange`] structures representing semantic differences between +/// snapshots. +pub trait DiffBuilder: Send + Sync { + fn diff_snapshots( + &self, + from: &Revision, + to: &Revision, + mode: &DiffMode, + paths: Option<&[PathBuf]>, + ) -> EngineResult>; + + fn diff_snapshot_with_parent( + &self, + child: &Revision, + mode: &DiffMode, + include_changes: bool, + ) -> EngineResult<(MevaCommit, String, Vec)>; + + fn diff_snapshot_working_dir( + &self, + from: &Revision, + mode: &DiffMode, + paths: Option<&[PathBuf]>, + ) -> EngineResult>; + + fn diff_snapshot_index( + &self, + from: &Revision, + mode: &DiffMode, + paths: Option<&[PathBuf]>, + ) -> EngineResult>; + + fn diff_index_working_dir( + &self, + mode: &DiffMode, + paths: Option<&[PathBuf]>, + index_map: Option>, + working_map: Option>, + ) -> EngineResult>; + + fn diff_tree_vs_tree( + &self, + from_objects: &HashMap, + to_objects: &HashMap, + mode: &DiffMode, + ) -> EngineResult>; + + fn diff_object_maps<'b>( + &self, + from_objects: &'b HashMap, + to_objects: &'b HashMap, + ) -> ( + Vec<&'b ObjectEntry>, + Vec<&'b ObjectEntry>, + Vec<(&'b ObjectEntry, &'b ObjectEntry)>, + ); + + fn added_to_file_changes( + &self, + added: Vec<&ObjectEntry>, + mode: &DiffMode, + ) -> EngineResult>; + + fn deleted_to_file_changes( + &self, + deleted: Vec<&ObjectEntry>, + mode: &DiffMode, + ) -> EngineResult>; + + fn modified_to_file_changes( + &self, + modified: Vec<(&ObjectEntry, &ObjectEntry)>, + mode: &DiffMode, + ) -> EngineResult>; + + //TODO: Move this commit tree walker? + fn collect_object_entries( + &self, + path: PathBuf, + tree_hash: &str, + paths: Option<&[PathBuf]>, + ) -> EngineResult>; +} diff --git a/engine/src/diff/diff_builder.rs b/engine/src/diff_builder/meva_diff_builder.rs similarity index 93% rename from engine/src/diff/diff_builder.rs rename to engine/src/diff_builder/meva_diff_builder.rs index fefed6a..7c9a964 100644 --- a/engine/src/diff/diff_builder.rs +++ b/engine/src/diff_builder/meva_diff_builder.rs @@ -1,24 +1,18 @@ -use std::{collections::HashMap, path::PathBuf}; - use rayon::{ iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator}, join, }; use similar::{ChangeTag, TextDiff}; +use std::{collections::HashMap, path::PathBuf, sync::Arc}; use super::models::{DiffMode, FileChange, FileChangeKind, Hunk, HunkLine, HunkLineType}; -use crate::{ - RepositoryLayout, - errors::EngineResult, - index::{ - FileMode, MevaIndex, WorkingDir, index_entry::IndexEntry, working_dir::WorkingDirEntry, - }, - object_storage::{MevaObjectStorage, ObjectStorage}, - objects::{ - MevaBlob, MevaCommit, MevaObject, MevaTree, ObjectEntry, tree_entry_type::TreeEntryType, - }, - revision_parsing::{Revision, RevisionResolver}, +use crate::errors::EngineResult; +use crate::index::{FileMode, index_entry::IndexEntry, working_dir::WorkingDirEntry}; +use crate::objects::{ + MevaBlob, MevaCommit, MevaObject, MevaTree, ObjectEntry, tree_entry_type::TreeEntryType, }; +use crate::revision_parsing::Revision; +use crate::{DiffBuilder, Index, ObjectStorage, RevisionResolver, WorkingDir}; /// A helper struct to group arguments required for building a [FileChange] for a modified file. struct ModifiedFileArgs<'a> { @@ -41,50 +35,292 @@ struct ModifiedFileArgs<'a> { } /// Builds diffs between different states of the repository (commits, index, working directory). -pub struct DiffBuilder<'a> { +pub struct MevaDiffBuilder { /// The object storage service for accessing repository objects. - object_storage: MevaObjectStorage<'a>, + object_storage: Arc, /// Resolver for parsing revision strings (e.g., "HEAD", "main", SHA hashes) into commit hashes. - revision_resolver: RevisionResolver<'a>, + revision_resolver: Arc, /// The staging area (index) of the repository. - index: MevaIndex<'a>, + index: Arc, /// Represents the working directory, providing access to its files. - pub working_dir: WorkingDir<'a>, + pub working_dir: Arc, } -impl<'a> DiffBuilder<'a> { - /// Creates a new `DiffBuilder` instance for a given repository layout. +impl MevaDiffBuilder { + /// Creates a new [`MevaDiffBuilder`] instance. + /// + /// This builder is responsible for constructing diffs between the working + /// directory, index, and historical revisions. + /// + /// The method wires together all required components, but performs no + /// diff-related computation by itself — it merely prepares the builder + /// for later use. /// /// # Arguments /// - /// * `repo_layout` - A reference to the repository's layout, providing paths to its components. - pub fn new(repo_layout: &'a dyn RepositoryLayout) -> EngineResult { + /// * `object_storage` — Service used to read tree and blob objects. + /// * `revision_resolver` — Component responsible for resolving commits, + /// branches, and other revision identifiers. + /// * `working_dir` — Abstraction over the working directory for reading + /// file contents and performing status comparisons. + /// * `index` — The in-memory index containing staged file states. + pub fn new( + object_storage: Arc, + revision_resolver: Arc, + working_dir: Arc, + index: Arc, + ) -> EngineResult { Ok(Self { - object_storage: MevaObjectStorage::new(repo_layout), - revision_resolver: RevisionResolver::new(repo_layout), - index: MevaIndex::from_disk(repo_layout, Some(false))?, - working_dir: WorkingDir::with_ignore_services(repo_layout), + object_storage, + revision_resolver, + working_dir, + index, }) } - /// Creates a new `DiffBuilder` instance using an existing `MevaIndex`. - /// - /// This constructor is useful when the index is already loaded in memory, - /// avoiding the cost of reading it from disk again. + /// A common utility to generate diff details (hunks, stats) from old and new text content. /// /// # Arguments /// - /// * `repo_layout` - A reference to the repository's layout. - /// * `index` - The pre-loaded `MevaIndex` instance to use for diffing. - pub fn with_index(repo_layout: &'a dyn RepositoryLayout, index: MevaIndex<'a>) -> Self { - Self { - object_storage: MevaObjectStorage::new(repo_layout), - revision_resolver: RevisionResolver::new(repo_layout), - index, - working_dir: WorkingDir::with_ignore_services(repo_layout), + /// * `old_content` - The original string content. + /// * `new_content` - The modified string content. + /// * `collect_details` - If `true`, generates detailed hunks and a unified diff string. + /// If `false`, only calculates total insertions and deletions. + /// + /// # Returns + /// + /// A tuple containing: + /// - An `Option` with a vector of [`Hunk`]s. + /// - An `Option` with the unified diff string. + /// - The total number of insertions. + /// - The total number of deletions. + fn build_file_change_common( + &self, + old_content: &str, + new_content: &str, + collect_details: bool, + ) -> (Option>, Option, usize, usize) { + let diff = TextDiff::from_lines(old_content, new_content); + let (total_insertions, total_deletions) = + diff.ops().iter().fold((0, 0), |(ins, del), op| { + (ins + op.new_range().len(), del + op.old_range().len()) + }); + + if collect_details { + let (hunks, unified_diff) = join( + || { + diff.grouped_ops(3) + .into_iter() + .map(|group| { + let old_start_idx = + group.first().map(|op| op.old_range().start).unwrap_or(0); + let new_start_idx = + group.first().map(|op| op.new_range().start).unwrap_or(0); + let mut lines = Vec::new(); + let mut old_count = 0; + let mut new_count = 0; + + for op in group { + for change in diff.iter_changes(&op) { + let line_type = match change.tag() { + ChangeTag::Delete => { + old_count += 1; + HunkLineType::Deletion + } + ChangeTag::Insert => { + new_count += 1; + HunkLineType::Addition + } + ChangeTag::Equal => { + old_count += 1; + new_count += 1; + HunkLineType::Context + } + }; + lines.push(HunkLine { + line_type, + content: change.to_string(), + }); + } + } + + let old_start = if old_count == 0 { 0 } else { old_start_idx + 1 }; + let new_start = if new_count == 0 { 0 } else { new_start_idx + 1 }; + + Hunk { + old_start, + new_start, + old_lines: old_count, + new_lines: new_count, + lines, + } + }) + .collect::>() + }, + || diff.unified_diff().to_string(), + ); + + ( + Some(hunks), + Some(unified_diff), + total_insertions, + total_deletions, + ) + } else { + (None, None, total_insertions, total_deletions) + } + } + + /// Constructs a [`FileChange`] object for a modified file. + fn build_file_change_modified(&self, args: ModifiedFileArgs) -> FileChange { + let (hunks, unified_diff, insertions, deletions) = + self.build_file_change_common(args.old_content, args.new_content, args.collect_details); + + FileChange { + kind: FileChangeKind::Modified { + old_path: args.old_path, + new_path: args.new_path, + old_hash: args.old_hash, + new_hash: args.new_hash, + mode: Some(*args.file_mode), + insertions, + deletions, + }, + hunks, + unified_diff, + } + } + + /// Constructs a [`FileChange`] object for an added file. + fn build_file_change_added( + &self, + new_content: &str, + new_path: PathBuf, + new_hash: String, + file_mode: &FileMode, + collect_details: bool, + ) -> FileChange { + let (hunks, unified_diff, insertions, _) = + self.build_file_change_common("", new_content, collect_details); + + FileChange { + kind: FileChangeKind::Added { + new_path, + mode: Some(*file_mode), + hash: Some(new_hash), + insertions, + }, + hunks, + unified_diff, + } + } + + /// Constructs a [`FileChange`] object for a deleted file. + fn build_file_change_deleted( + &self, + old_content: &str, + old_path: PathBuf, + old_hash: String, + file_mode: &FileMode, + collect_details: bool, + ) -> FileChange { + let (hunks, unified_diff, _, deletions) = + self.build_file_change_common(old_content, "", collect_details); + + FileChange { + kind: FileChangeKind::Deleted { + old_path, + mode: Some(*file_mode), + hash: Some(old_hash), + deletions, + }, + hunks, + unified_diff, } } + /// Merges vectors of added, deleted, and modified changes into a single vector, + /// then sorts it by file path. + fn merge_and_sort( + &self, + added_changes: Vec, + deleted_changes: Vec, + modified_changes: Vec, + ) -> Vec { + let mut all = Vec::with_capacity( + added_changes.len() + deleted_changes.len() + modified_changes.len(), + ); + all.extend(added_changes); + all.extend(deleted_changes); + all.extend(modified_changes); + all.sort_by(|first, second| first.path().cmp(second.path())); + all + } + + /// Resolves a revision to a commit hash and retrieves the corresponding [`MevaCommit`] object. + fn get_commit(&self, revision: &Revision) -> EngineResult { + let hash = self.revision_resolver.resolve_hash(revision)?; + self.get_commit_by_hash(&hash) + } + + /// Resolves a revision to a commit object and its corresponding hash. + fn get_commit_with_hash(&self, revision: &Revision) -> EngineResult<(MevaCommit, String)> { + let hash = self.revision_resolver.resolve_hash(revision)?; + Ok((self.get_commit_by_hash(&hash)?, hash)) + } + + /// Retrieves a commit object directly by its SHA-1 hash. + fn get_commit_by_hash(&self, hash: &str) -> EngineResult { + let commit_object = self.object_storage.get_object(hash)?; + MevaCommit::try_from(commit_object) + } + + /// Retrieves the string content of a blob object given its hash. + fn get_text_content(&self, hash: &str) -> EngineResult { + let object = self.object_storage.get_object(hash)?; + let blob = MevaBlob::try_from(object)?; + blob.text_content() + } + + /// Collects all file entries from the index into a map. + /// + /// # Arguments + /// + /// * `paths` - An optional filter to only include specified paths. + /// + /// # Returns + /// + /// A [`HashMap`] mapping file paths to their [`ObjectEntry`] data from the index. + pub fn collect_object_entries_from_index( + &self, + paths: Option<&[PathBuf]>, + ) -> HashMap { + self.index + .get_entries() + .par_iter() + .filter_map(|entry| { + let path = PathBuf::from(&entry.path); + if let Some(allowed_paths) = paths + && !allowed_paths.iter().any(|p| path.starts_with(p)) + { + return None; + } + + Some(( + path.clone(), + ObjectEntry { + entry_type: entry.mode.into(), + hash: entry.sha1.clone(), + size: Some(entry.file_size), + path, + }, + )) + }) + .collect() + } +} + +impl DiffBuilder for MevaDiffBuilder { /// Computes the difference between two repository snapshots (commits). /// /// # Arguments @@ -97,7 +333,7 @@ impl<'a> DiffBuilder<'a> { /// # Returns /// /// A [`EngineResult`] containing a vector of [`FileChange`] objects representing the differences. - pub fn diff_snapshots( + fn diff_snapshots( &self, from: &Revision, to: &Revision, @@ -125,7 +361,7 @@ impl<'a> DiffBuilder<'a> { /// # Returns /// /// A tuple containing the resolved child commit, its hash, and a vector of file changes. - pub fn diff_snapshot_with_parent( + fn diff_snapshot_with_parent( &self, child: &Revision, mode: &DiffMode, @@ -166,7 +402,7 @@ impl<'a> DiffBuilder<'a> { /// # Returns /// /// A [`EngineResult`] containing a vector of [`FileChange`] objects. - pub fn diff_snapshot_working_dir( + fn diff_snapshot_working_dir( &self, from: &Revision, mode: &DiffMode, @@ -290,7 +526,7 @@ impl<'a> DiffBuilder<'a> { /// # Returns /// /// A [`EngineResult`] containing a vector of [`FileChange`] objects. - pub fn diff_snapshot_index( + fn diff_snapshot_index( &self, from: &Revision, mode: &DiffMode, @@ -322,7 +558,7 @@ impl<'a> DiffBuilder<'a> { /// # Returns /// /// A [`EngineResult`] containing a vector of [`FileChange`] objects. - pub fn diff_index_working_dir( + fn diff_index_working_dir( &self, mode: &DiffMode, paths: Option<&[PathBuf]>, @@ -467,7 +703,7 @@ impl<'a> DiffBuilder<'a> { /// # Returns /// /// A [`EngineResult`] containing a vector of [`FileChange`] objects. - pub fn diff_tree_vs_tree( + fn diff_tree_vs_tree( &self, from_objects: &HashMap, to_objects: &HashMap, @@ -482,308 +718,12 @@ impl<'a> DiffBuilder<'a> { Ok(self.merge_and_sort(added_changes, deleted_changes, modified_changes)) } - /// A common utility to generate diff details (hunks, stats) from old and new text content. + /// Compares two maps of object entries to determine added, deleted, and modified files. /// /// # Arguments /// - /// * `old_content` - The original string content. - /// * `new_content` - The modified string content. - /// * `collect_details` - If `true`, generates detailed hunks and a unified diff string. - /// If `false`, only calculates total insertions and deletions. - /// - /// # Returns - /// - /// A tuple containing: - /// - An `Option` with a vector of [`Hunk`]s. - /// - An `Option` with the unified diff string. - /// - The total number of insertions. - /// - The total number of deletions. - fn build_file_change_common( - &self, - old_content: &str, - new_content: &str, - collect_details: bool, - ) -> (Option>, Option, usize, usize) { - let diff = TextDiff::from_lines(old_content, new_content); - let (total_insertions, total_deletions) = - diff.ops().iter().fold((0, 0), |(ins, del), op| { - (ins + op.new_range().len(), del + op.old_range().len()) - }); - - if collect_details { - let (hunks, unified_diff) = join( - || { - diff.grouped_ops(3) - .into_iter() - .map(|group| { - let old_start_idx = - group.first().map(|op| op.old_range().start).unwrap_or(0); - let new_start_idx = - group.first().map(|op| op.new_range().start).unwrap_or(0); - let mut lines = Vec::new(); - let mut old_count = 0; - let mut new_count = 0; - - for op in group { - for change in diff.iter_changes(&op) { - let line_type = match change.tag() { - ChangeTag::Delete => { - old_count += 1; - HunkLineType::Deletion - } - ChangeTag::Insert => { - new_count += 1; - HunkLineType::Addition - } - ChangeTag::Equal => { - old_count += 1; - new_count += 1; - HunkLineType::Context - } - }; - lines.push(HunkLine { - line_type, - content: change.to_string(), - }); - } - } - - let old_start = if old_count == 0 { 0 } else { old_start_idx + 1 }; - let new_start = if new_count == 0 { 0 } else { new_start_idx + 1 }; - - Hunk { - old_start, - new_start, - old_lines: old_count, - new_lines: new_count, - lines, - } - }) - .collect::>() - }, - || diff.unified_diff().to_string(), - ); - - ( - Some(hunks), - Some(unified_diff), - total_insertions, - total_deletions, - ) - } else { - (None, None, total_insertions, total_deletions) - } - } - - /// Constructs a [`FileChange`] object for a modified file. - fn build_file_change_modified(&self, args: ModifiedFileArgs) -> FileChange { - let (hunks, unified_diff, insertions, deletions) = - self.build_file_change_common(args.old_content, args.new_content, args.collect_details); - - FileChange { - kind: FileChangeKind::Modified { - old_path: args.old_path, - new_path: args.new_path, - old_hash: args.old_hash, - new_hash: args.new_hash, - mode: Some(*args.file_mode), - insertions, - deletions, - }, - hunks, - unified_diff, - } - } - - /// Constructs a [`FileChange`] object for an added file. - fn build_file_change_added( - &self, - new_content: &str, - new_path: PathBuf, - new_hash: String, - file_mode: &FileMode, - collect_details: bool, - ) -> FileChange { - let (hunks, unified_diff, insertions, _) = - self.build_file_change_common("", new_content, collect_details); - - FileChange { - kind: FileChangeKind::Added { - new_path, - mode: Some(*file_mode), - hash: Some(new_hash), - insertions, - }, - hunks, - unified_diff, - } - } - - /// Constructs a [`FileChange`] object for a deleted file. - fn build_file_change_deleted( - &self, - old_content: &str, - old_path: PathBuf, - old_hash: String, - file_mode: &FileMode, - collect_details: bool, - ) -> FileChange { - let (hunks, unified_diff, _, deletions) = - self.build_file_change_common(old_content, "", collect_details); - - FileChange { - kind: FileChangeKind::Deleted { - old_path, - mode: Some(*file_mode), - hash: Some(old_hash), - deletions, - }, - hunks, - unified_diff, - } - } - - /// Merges vectors of added, deleted, and modified changes into a single vector, - /// then sorts it by file path. - fn merge_and_sort( - &self, - added_changes: Vec, - deleted_changes: Vec, - modified_changes: Vec, - ) -> Vec { - let mut all = Vec::with_capacity( - added_changes.len() + deleted_changes.len() + modified_changes.len(), - ); - all.extend(added_changes); - all.extend(deleted_changes); - all.extend(modified_changes); - all.sort_by(|first, second| first.path().cmp(second.path())); - all - } - - /// Recursively traverses a tree object and collects all file entries into a map. - /// - /// # Arguments - /// - /// * `path` - The current directory path being traversed. - /// * `tree_hash` - The hash of the tree object to process. - /// * `paths` - An optional filter to only include specified paths. - /// - /// # Returns - /// - /// A [`EngineResult`] with a [`HashMap`] mapping file paths to their [`ObjectEntry`] data. - pub fn collect_object_entries( - &self, - path: PathBuf, - tree_hash: &str, - paths: Option<&[PathBuf]>, - ) -> EngineResult> { - let mut collected: HashMap = HashMap::new(); - let tree_object = self.object_storage.get_object(tree_hash)?; - let tree = MevaTree::try_from(tree_object)?; - - for entry in tree.tree_entries { - match entry.entry_type { - TreeEntryType::Tree => { - let inner = self.collect_object_entries( - path.join(&entry.name), - &entry.object_hash, - paths, - )?; - collected.extend(inner); - } - _ => { - let key = path.join(&entry.name); - if let Some(paths) = paths - && !paths.iter().any(|p| key.starts_with(p)) - { - continue; - } - - let blob_object = self.object_storage.get_object(&entry.object_hash)?; - let blob = MevaBlob::try_from(blob_object)?; - let value = ObjectEntry { - entry_type: entry.entry_type, - hash: entry.object_hash, - size: Some(blob.size()), - path: key.clone(), - }; - collected.insert(key, value); - } - } - } - - Ok(collected) - } - - /// Resolves a revision to a commit hash and retrieves the corresponding [`MevaCommit`] object. - fn get_commit(&self, revision: &Revision) -> EngineResult { - let hash = self.revision_resolver.resolve_hash(revision)?; - self.get_commit_by_hash(&hash) - } - - /// Resolves a revision to a commit object and its corresponding hash. - fn get_commit_with_hash(&self, revision: &Revision) -> EngineResult<(MevaCommit, String)> { - let hash = self.revision_resolver.resolve_hash(revision)?; - Ok((self.get_commit_by_hash(&hash)?, hash)) - } - - /// Retrieves a commit object directly by its SHA-1 hash. - fn get_commit_by_hash(&self, hash: &str) -> EngineResult { - let commit_object = self.object_storage.get_object(hash)?; - MevaCommit::try_from(commit_object) - } - - /// Retrieves the string content of a blob object given its hash. - fn get_text_content(&self, hash: &str) -> EngineResult { - let object = self.object_storage.get_object(hash)?; - let blob = MevaBlob::try_from(object)?; - blob.text_content() - } - - /// Collects all file entries from the index into a map. - /// - /// # Arguments - /// - /// * `paths` - An optional filter to only include specified paths. - /// - /// # Returns - /// - /// A [`HashMap`] mapping file paths to their [`ObjectEntry`] data from the index. - pub fn collect_object_entries_from_index( - &self, - paths: Option<&[PathBuf]>, - ) -> HashMap { - self.index - .get_entries() - .par_iter() - .filter_map(|entry| { - let path = PathBuf::from(&entry.path); - if let Some(allowed_paths) = paths - && !allowed_paths.iter().any(|p| path.starts_with(p)) - { - return None; - } - - Some(( - path.clone(), - ObjectEntry { - entry_type: entry.mode.into(), - hash: entry.sha1.clone(), - size: Some(entry.file_size), - path, - }, - )) - }) - .collect() - } - - /// Compares two maps of object entries to determine added, deleted, and modified files. - /// - /// # Arguments - /// - /// * `from_objects` - The map representing the "old" state. - /// * `to_objects` - The map representing the "new" state. + /// * `from_objects` - The map representing the "old" state. + /// * `to_objects` - The map representing the "new" state. /// /// # Returns /// @@ -791,7 +731,7 @@ impl<'a> DiffBuilder<'a> { /// - `added`: [`ObjectEntry`]s present in `to_objects` but not `from_objects`. /// - `deleted`: [`ObjectEntry`]s present in `from_objects` but not `to_objects`. /// - `modified`: Tuples of (`from_entry`, `to_entry`) for entries present in both but with different hashes. - pub fn diff_object_maps<'b>( + fn diff_object_maps<'b>( &self, from_objects: &'b HashMap, to_objects: &'b HashMap, @@ -827,7 +767,7 @@ impl<'a> DiffBuilder<'a> { } /// Converts a list of added [`ObjectEntry`]s into a vector of [`FileChange`]s. - pub fn added_to_file_changes( + fn added_to_file_changes( &self, added: Vec<&ObjectEntry>, mode: &DiffMode, @@ -856,7 +796,7 @@ impl<'a> DiffBuilder<'a> { } /// Converts a list of deleted [`ObjectEntry`]s into a vector of [`FileChange`]s. - pub fn deleted_to_file_changes( + fn deleted_to_file_changes( &self, deleted: Vec<&ObjectEntry>, mode: &DiffMode, @@ -885,7 +825,7 @@ impl<'a> DiffBuilder<'a> { } /// Converts a list of modified [`ObjectEntry`] pairs into a vector of [`FileChange`]s. - pub fn modified_to_file_changes( + fn modified_to_file_changes( &self, modified: Vec<(&ObjectEntry, &ObjectEntry)>, mode: &DiffMode, @@ -925,4 +865,59 @@ impl<'a> DiffBuilder<'a> { .collect(), } } + + /// Recursively traverses a tree object and collects all file entries into a map. + /// + /// # Arguments + /// + /// * `path` - The current directory path being traversed. + /// * `tree_hash` - The hash of the tree object to process. + /// * `paths` - An optional filter to only include specified paths. + /// + /// # Returns + /// + /// A [`EngineResult`] with a [`HashMap`] mapping file paths to their [`ObjectEntry`] data. + fn collect_object_entries( + &self, + path: PathBuf, + tree_hash: &str, + paths: Option<&[PathBuf]>, + ) -> EngineResult> { + let mut collected: HashMap = HashMap::new(); + let tree_object = self.object_storage.get_object(tree_hash)?; + let tree = MevaTree::try_from(tree_object)?; + + for entry in tree.tree_entries { + match entry.entry_type { + TreeEntryType::Tree => { + let inner = self.collect_object_entries( + path.join(&entry.name), + &entry.object_hash, + paths, + )?; + collected.extend(inner); + } + _ => { + let key = path.join(&entry.name); + if let Some(paths) = paths + && !paths.iter().any(|p| key.starts_with(p)) + { + continue; + } + + let blob_object = self.object_storage.get_object(&entry.object_hash)?; + let blob = MevaBlob::try_from(blob_object)?; + let value = ObjectEntry { + entry_type: entry.entry_type, + hash: entry.object_hash, + size: Some(blob.size()), + path: key.clone(), + }; + collected.insert(key, value); + } + } + } + + Ok(collected) + } } diff --git a/engine/src/diff/models.rs b/engine/src/diff_builder/models.rs similarity index 100% rename from engine/src/diff/models.rs rename to engine/src/diff_builder/models.rs diff --git a/engine/src/diff/models/change_kind.rs b/engine/src/diff_builder/models/change_kind.rs similarity index 96% rename from engine/src/diff/models/change_kind.rs rename to engine/src/diff_builder/models/change_kind.rs index cec8208..f6f343c 100644 --- a/engine/src/diff/models/change_kind.rs +++ b/engine/src/diff_builder/models/change_kind.rs @@ -3,7 +3,7 @@ use std::fmt::Display; use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; -use crate::diff::FileChangeKind; +use crate::diff_builder::FileChangeKind; /// Represents the kind of change a file has undergone. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -88,7 +88,7 @@ impl Display for ChangeKind { /// Converts a reference to `FileChangeKind` into a `ChangeKind`. /// -/// This provides a convenient way to map a detailed diff change (which might +/// This provides a convenient way to map a detailed diff_builder change (which might /// include more data) into this simpler status-oriented `ChangeKind`. impl From<&FileChangeKind> for ChangeKind { /// Performs the conversion from `&FileChangeKind` to `ChangeKind`. diff --git a/engine/src/diff/models/diff_mode.rs b/engine/src/diff_builder/models/diff_mode.rs similarity index 100% rename from engine/src/diff/models/diff_mode.rs rename to engine/src/diff_builder/models/diff_mode.rs diff --git a/engine/src/diff/models/diff_stat.rs b/engine/src/diff_builder/models/diff_stat.rs similarity index 99% rename from engine/src/diff/models/diff_stat.rs rename to engine/src/diff_builder/models/diff_stat.rs index 8a534e3..7ece81b 100644 --- a/engine/src/diff/models/diff_stat.rs +++ b/engine/src/diff_builder/models/diff_stat.rs @@ -4,7 +4,7 @@ use owo_colors::OwoColorize; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use serde::{Deserialize, Serialize}; -use crate::diff::FileChange; +use crate::diff_builder::FileChange; use super::{file_change_kind::FileChangeKind, file_diff_stat::FileDiffStat}; diff --git a/engine/src/diff/models/file_change.rs b/engine/src/diff_builder/models/file_change.rs similarity index 100% rename from engine/src/diff/models/file_change.rs rename to engine/src/diff_builder/models/file_change.rs diff --git a/engine/src/diff/models/file_change_kind.rs b/engine/src/diff_builder/models/file_change_kind.rs similarity index 100% rename from engine/src/diff/models/file_change_kind.rs rename to engine/src/diff_builder/models/file_change_kind.rs diff --git a/engine/src/diff/models/file_diff_stat.rs b/engine/src/diff_builder/models/file_diff_stat.rs similarity index 100% rename from engine/src/diff/models/file_diff_stat.rs rename to engine/src/diff_builder/models/file_diff_stat.rs diff --git a/engine/src/diff/models/hunk.rs b/engine/src/diff_builder/models/hunk.rs similarity index 100% rename from engine/src/diff/models/hunk.rs rename to engine/src/diff_builder/models/hunk.rs diff --git a/engine/src/diff/models/revision_range.rs b/engine/src/diff_builder/models/revision_range.rs similarity index 88% rename from engine/src/diff/models/revision_range.rs rename to engine/src/diff_builder/models/revision_range.rs index 53ee01a..9927808 100644 --- a/engine/src/diff/models/revision_range.rs +++ b/engine/src/diff_builder/models/revision_range.rs @@ -18,7 +18,6 @@ pub struct RevisionRange { impl Default for RevisionRange { /// Creates a default `RevisionRange` that represents the changes /// between the index (staging area) and the working directory. - /// This is equivalent to `git diff`. fn default() -> Self { Self { from: RevisionSide::Index, @@ -29,7 +28,6 @@ impl Default for RevisionRange { impl RevisionRange { /// Creates a `RevisionRange` between two specific revisions (snapshots). - /// This is equivalent to `git diff `. /// /// # Arguments /// @@ -48,8 +46,8 @@ impl RevisionRange { /// # Arguments /// /// * `from` - The revision to compare against. - /// * `cached` - If `true`, compares the revision against the index (`git diff --cached `). - /// If `false`, compares it against the working directory (`git diff `). + /// * `cached` - If `true`, compares the revision against the index. + /// If `false`, compares it against the working directory. pub fn single(from: Revision, cached: bool) -> Self { Self { from: RevisionSide::Snapshot { revision: from }, diff --git a/engine/src/diff/models/revision_side.rs b/engine/src/diff_builder/models/revision_side.rs similarity index 100% rename from engine/src/diff/models/revision_side.rs rename to engine/src/diff_builder/models/revision_side.rs diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 74cb2a4..4b3c4b4 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -1,24 +1,30 @@ -use plugins::{MevaPluginsLayout, PluginsDiscovery, PluginsEngine}; +use std::sync::{Arc, RwLock}; -use crate::ConfigLoader; +use plugins::{MevaPluginsDiscovery, MevaPluginsEngine, MevaPluginsLayout, PluginsLayout}; +use crate::branch_manager::MevaBranchManager; +use crate::commit_builder::MevaCommitBuilder; +use crate::diff_builder::MevaDiffBuilder; +use crate::errors::EngineResult; use crate::handlers::{ - add::AddHandler, commit::CommitHandler, config::ConfigHandler, init::InitHandler, - log::LogHandler, ls_files::LsFilesHandler, ls_tree::LsTreeHandler, plugins::PluginsHandler, - restore::RestoreHandler, status::StatusHandler, -}; -use crate::{ - errors::EngineResult, plugins_interceptor::PluginsInterceptor, - repositories::meva_repository_layout::MevaRepositoryLayout, + add::AddHandler, commit, commit::CommitHandler, config::ConfigHandler, diff::DiffHandler, + init::InitHandler, log::LogHandler, ls_files::LsFilesHandler, ls_tree::LsTreeHandler, + plugins::PluginsHandler, restore::RestoreHandler, show::ShowHandler, status::StatusHandler, }; +use crate::index::{MevaIndex, MevaWorkingDir}; +use crate::object_storage::{MevaDryRunObjectStorage, MevaObjectStorage}; +use crate::plugins_interceptor::PluginsInterceptor; +use crate::ref_manager::MevaRefManager; +use crate::repositories::meva_repository_layout::MevaRepositoryLayout; +use crate::revision_parsing::MevaRevisionResolver; +use crate::traversal::MevaCommitTreeWalker; +use crate::{MevaConfigLoader, MevaRepository, RepositoryLayout}; /// Dependency injection container for the engine. pub trait EngineContainer { /// Creates a plugin interceptor, which executes plugins /// before and after engine commands. - fn plugins_interceptor( - &self, - ) -> EngineResult>; + fn plugins_interceptor(&self) -> EngineResult; /// Returns the handler responsible for repository initialization. fn init_handler(&self) -> EngineResult; @@ -38,22 +44,22 @@ pub trait EngineContainer { /// Returns the handler responsible for ls-tree command fn ls_tree_handler(&self) -> EngineResult; - // /// Returns the handler responsible for show command - // fn show_handler(&self) -> EngineResult; - /// Returns the handler responsible for status command fn status_handler(&self) -> EngineResult; /// Return the handler responsible for commit creation - fn commit_handler(&self) -> EngineResult; - - // /// Return the handler responsible for diff operations - // fn diff_handler(&self) -> EngineResult; + fn commit_handler(&self, request: &commit::Request) -> EngineResult; /// Return the handler responsible for log command fn log_handler(&self) -> EngineResult; - // Return the handler responsible for restore command + /// Return the handler responsible for diff command + fn diff_handler(&self) -> EngineResult; + + /// Return the handler responsible for show command + fn show_handler(&self) -> EngineResult; + + /// Return the handler responsible for restore command fn restore_handler(&self) -> EngineResult; } @@ -62,37 +68,42 @@ pub struct MevaContainer; impl MevaContainer { /// Returns the Meva-specific plugin layout. - fn plugins_layout(&self) -> EngineResult { - Ok(MevaPluginsLayout) + fn plugins_layout(&self) -> EngineResult> { + Ok(Arc::new(MevaPluginsLayout)) } /// Returns the Meva-specific repository layout. - fn repository_layout(&self) -> EngineResult { - MevaRepositoryLayout::from_env() + fn repository_layout_env(&self) -> EngineResult> { + Ok(Arc::new(MevaRepositoryLayout::from_env()?)) } /// Builds a [`PluginsEngine`] instance used by Meva. - fn plugins_engine(&self) -> EngineResult> { - Ok(PluginsEngine { - discovery: PluginsDiscovery { + fn plugins_engine(&self) -> EngineResult> { + Ok(Arc::new(MevaPluginsEngine { + discovery: Arc::new(MevaPluginsDiscovery { layout: self.plugins_layout()?, - }, - }) + }), + })) } } impl EngineContainer for MevaContainer { - fn plugins_interceptor( - &self, - ) -> EngineResult> { - Ok(PluginsInterceptor::new( - self.plugins_engine()?, - self.repository_layout()?, - )) + fn plugins_interceptor(&self) -> EngineResult { + let plugins_engine = self.plugins_engine()?; + let repository_layout_env = self.repository_layout_env()?; + let config_loader = Arc::new(MevaConfigLoader::default()); + let interceptor = + PluginsInterceptor::new(plugins_engine, repository_layout_env, config_loader); + + Ok(interceptor) } fn init_handler(&self) -> EngineResult { - Ok(InitHandler) + let layout = Arc::new(MevaRepositoryLayout::from_env()?); + let config_loader = Arc::new(MevaConfigLoader::default()); + let repository = Arc::new(MevaRepository::new(layout, config_loader)); + let handler = InitHandler { repository }; + Ok(handler) } fn config_handler(&self) -> EngineResult { @@ -100,44 +111,230 @@ impl EngineContainer for MevaContainer { } fn plugins_handler(&self) -> EngineResult { - Ok(PluginsHandler { - plugins_repository: Box::new(self.plugins_engine()?), - repository_layout: Box::new(self.repository_layout()?), - }) + let plugins_repository = self.plugins_engine()?; + let repository_layout = Arc::new(MevaRepositoryLayout::from_env()?); + let handler = PluginsHandler::new(plugins_repository, repository_layout); + + Ok(handler) } fn add_handler(&self) -> EngineResult { - Ok(AddHandler) + let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let working_dir = Arc::new(MevaWorkingDir::with_ignore_services(repo_layout.clone())); + let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); + let index = Arc::new(RwLock::new(MevaIndex::from_disk( + working_dir, + object_storage, + None, + )?)); + let handler = AddHandler::new(repo_layout, index); + + Ok(handler) } fn ls_files_handler(&self) -> EngineResult { - Ok(LsFilesHandler) + let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let working_dir = Arc::new(MevaWorkingDir::with_ignore_services(repo_layout.clone())); + let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); + let index = Arc::new(MevaIndex::from_disk(working_dir, object_storage, None)?); + let handler = LsFilesHandler::new(repo_layout, index); + + Ok(handler) } fn ls_tree_handler(&self) -> EngineResult { - Ok(LsTreeHandler::new(Box::new( - MevaRepositoryLayout::discover()?, - ))) + let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); + let refs_manager = Arc::new(MevaRefManager::new(repo_layout)); + let revision_resolver = Arc::new(MevaRevisionResolver::new( + object_storage.clone(), + refs_manager, + )); + let commit_tree_walker = Arc::new(MevaCommitTreeWalker::new(object_storage)); + let handler = LsTreeHandler::new(commit_tree_walker, revision_resolver); + + Ok(handler) } fn status_handler(&self) -> EngineResult { - Ok(StatusHandler) + let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let working_dir = Arc::new(MevaWorkingDir::with_ignore_services(repo_layout.clone())); + let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); + let index = Arc::new(MevaIndex::from_disk( + working_dir.clone(), + object_storage.clone(), + Some(false), + )?); + let refs_manager = Arc::new(MevaRefManager::new(repo_layout)); + let revision_resolver = Arc::new(MevaRevisionResolver::new( + object_storage.clone(), + refs_manager.clone(), + )); + let diff_builder = Arc::new(MevaDiffBuilder::new( + object_storage.clone(), + revision_resolver, + working_dir.clone(), + index.clone(), + )?); + let handler = StatusHandler::new( + working_dir, + index, + refs_manager, + object_storage, + diff_builder, + ); + + Ok(handler) } - fn commit_handler(&self) -> EngineResult { - Ok(CommitHandler::new( - Box::new(MevaRepositoryLayout::discover()?), - ConfigLoader::default(), - )) + fn commit_handler(&self, request: &commit::Request) -> EngineResult { + let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let refs_manager = Arc::new(MevaRefManager::new(repo_layout.clone())); + let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); + let revision_resolver = Arc::new(MevaRevisionResolver::new( + object_storage.clone(), + refs_manager.clone(), + )); + let working_dir = Arc::new(MevaWorkingDir::with_ignore_services(repo_layout)); + let index_rel = Arc::new(MevaIndex::from_disk( + working_dir.clone(), + object_storage.clone(), + Some(false), + )?); + let index_abs = Arc::new(MevaIndex::from_disk( + working_dir.clone(), + object_storage.clone(), + None, + )?); + let diff_builder = Arc::new(MevaDiffBuilder::new( + object_storage.clone(), + revision_resolver, + working_dir, + index_rel.clone(), + )?); + let branch_manager = Arc::new(MevaBranchManager::new( + object_storage.clone(), + refs_manager.clone(), + )); + let commit_builder = match request.dry_run_arg { + true => { + let dry_run_object_storage = Arc::new(MevaDryRunObjectStorage::new()); + Arc::new(MevaCommitBuilder::new(dry_run_object_storage, index_abs)) + } + false => Arc::new(MevaCommitBuilder::new(object_storage, index_abs)), + }; + let config_loader = Arc::new(MevaConfigLoader::default()); + let handler = CommitHandler::new( + diff_builder, + refs_manager, + branch_manager, + commit_builder, + index_rel, + config_loader, + ); + + Ok(handler) } fn log_handler(&self) -> EngineResult { - Ok(LogHandler::new(Box::new(MevaRepositoryLayout::discover()?))) + let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); + let refs_manager = Arc::new(MevaRefManager::new(repo_layout.clone())); + let revision_resolver = Arc::new(MevaRevisionResolver::new( + object_storage.clone(), + refs_manager, + )); + let working_dir = Arc::new(MevaWorkingDir::with_ignore_services(repo_layout)); + let index = Arc::new(MevaIndex::from_disk( + working_dir.clone(), + object_storage.clone(), + Some(false), + )?); + let diff_builder = Arc::new(MevaDiffBuilder::new( + object_storage.clone(), + revision_resolver.clone(), + working_dir, + index, + )?); + + let handler = LogHandler::new(object_storage, revision_resolver, diff_builder); + + Ok(handler) + } + + fn diff_handler(&self) -> EngineResult { + let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); + let refs_manager = Arc::new(MevaRefManager::new(repo_layout.clone())); + let revision_resolver = Arc::new(MevaRevisionResolver::new( + object_storage.clone(), + refs_manager, + )); + let working_dir = Arc::new(MevaWorkingDir::with_ignore_services(repo_layout)); + let index = Arc::new(MevaIndex::from_disk( + working_dir.clone(), + object_storage.clone(), + Some(false), + )?); + let diff_builder = Arc::new(MevaDiffBuilder::new( + object_storage, + revision_resolver, + working_dir, + index, + )?); + let handler = DiffHandler::new(diff_builder)?; + + Ok(handler) + } + + fn show_handler(&self) -> EngineResult { + let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); + let refs_manager = Arc::new(MevaRefManager::new(repo_layout.clone())); + let revision_resolver = Arc::new(MevaRevisionResolver::new( + object_storage.clone(), + refs_manager, + )); + let working_dir = Arc::new(MevaWorkingDir::with_ignore_services(repo_layout)); + let index = Arc::new(MevaIndex::from_disk( + working_dir.clone(), + object_storage.clone(), + Some(false), + )?); + let diff_builder = Arc::new(MevaDiffBuilder::new( + object_storage, + revision_resolver, + working_dir, + index, + )?); + let handler = ShowHandler::new(diff_builder)?; + + Ok(handler) } fn restore_handler(&self) -> EngineResult { - Ok(RestoreHandler::new(Box::new( - MevaRepositoryLayout::discover()?, - ))) + let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); + let working_dir = Arc::new(MevaWorkingDir::with_ignore_services(repo_layout.clone())); + let index = Arc::new(RwLock::new(MevaIndex::from_disk( + working_dir.clone(), + object_storage.clone(), + Some(false), + )?)); + let refs_manager = Arc::new(MevaRefManager::new(repo_layout)); + let revision_resolver = Arc::new(MevaRevisionResolver::new( + object_storage.clone(), + refs_manager, + )); + let commit_tree_walker = Arc::new(MevaCommitTreeWalker::new(object_storage.clone())); + let handler = RestoreHandler::new( + index, + revision_resolver, + commit_tree_walker, + working_dir, + object_storage, + ); + + Ok(handler) } } diff --git a/engine/src/handlers/add/handlers.rs b/engine/src/handlers/add/handlers.rs index 6d4cdfa..61dbbdc 100644 --- a/engine/src/handlers/add/handlers.rs +++ b/engine/src/handlers/add/handlers.rs @@ -1,24 +1,41 @@ -use crate::RepositoryLayout; +use super::{AddOperations, AddRequest, AddResponse}; use crate::errors::{EngineError, EngineResult, PathError}; -use crate::index::MevaIndex; use crate::plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}; -use crate::repositories::meva_repository_layout::MevaRepositoryLayout; - -use super::{AddOperations, AddRequest, AddResponse}; - +use crate::{Index, RepositoryLayout}; use plugins::{ AddPostPayload, AddPrePayload, CommandType, InvocationInput, InvocationPostPayload, - InvocationPrePayload, MevaPluginsLayout, PluginError, + InvocationPrePayload, PluginError, }; use shared::IsWithin; - -pub struct AddHandler; +use std::sync::{Arc, RwLock}; + +/// Handles the logic for the `add` command. +/// +/// [`AddHandler`] is responsible for adding files to the index. +/// It integrates with the plugin system and delegates the actual add logic +/// to an internal method (`add`), which is wrapped by the interceptor mechanism. +pub struct AddHandler { + /// Provides access to repository directory structure and layout. + repo_layout: Arc, + + /// Thread-safe, shared access to the index. + index: Arc>, +} impl AddHandler { + /// Creates a new [`AddHandler`]. + /// + /// # Arguments + /// * `repo_layout` — Repository directory layout abstraction. + /// * `index` — Shared, thread-safe index used to track staged files. + pub fn new(repo_layout: Arc, index: Arc>) -> Self { + Self { repo_layout, index } + } + pub fn handle_add( &self, request: AddRequest, - interceptor: &PluginsInterceptor, + interceptor: &PluginsInterceptor, ) -> EngineResult { interceptor .intercept_with_plugins(CommandType::Add, None, request, self, |req| self.add(req)) @@ -27,34 +44,29 @@ impl AddHandler { impl AddOperations for AddHandler { fn add(&self, request: AddRequest) -> EngineResult { - let repo_layout = MevaRepositoryLayout::discover()?; - let path = request.path_arg.clone().unwrap_or(".".into()); - if !path.is_within(repo_layout.working_dir())? { + if !path.is_within(self.repo_layout.working_dir())? { return Err(PathError::NotInBaseDirectory { path, - path_base: repo_layout.working_dir().into(), + path_base: self.repo_layout.working_dir(), } .into()); } let all = request.all_flag || request.path_arg.is_some(); - let add_new = all && !request.update_flag; let add_deleted = all || request.update_flag; let add_ignored = request.force_flag; - let verbose = request.dry_run_flag || request.verbose_flag; - let mut index = MevaIndex::new(&repo_layout)?; + let mut index_write_guard = self.index.write().unwrap(); - index.load_from_disk(None)?; let (added, modified, removed) = - index.add(&path, add_new, add_deleted, add_ignored, verbose)?; + index_write_guard.add(&path, add_new, add_deleted, add_ignored, verbose)?; if !request.dry_run_flag { - index.save()?; + index_write_guard.save()?; } Ok(AddResponse { diff --git a/engine/src/handlers/commit/handlers.rs b/engine/src/handlers/commit/handlers.rs index 5dcd03a..f24053c 100644 --- a/engine/src/handlers/commit/handlers.rs +++ b/engine/src/handlers/commit/handlers.rs @@ -1,23 +1,17 @@ use super::{CommitOperations, Request, Response}; -use std::path::PathBuf; - -use crate::branch_manager::{BranchManger, MevaBranchManager}; -use crate::commit_builder::MevaCommitBuilder; -use crate::diff::{DiffBuilder, DiffMode, FileChange, FileChangeKind}; +use crate::diff_builder::{DiffMode, FileChange, FileChangeKind}; use crate::errors::{CommitError, EngineError, EngineResult}; -use crate::index::{FileMode, MevaIndex}; -use crate::object_storage::{MevaDryRunObjectStorage, MevaObjectStorage, ObjectStorage}; +use crate::index::FileMode; use crate::objects::{MevaCommit, ObjectEntry, Person}; use crate::plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}; -use crate::ref_manager::{MevaRefManager, RefManager}; -use crate::repositories::meva_repository_layout::MevaRepositoryLayout; use crate::revision_parsing::Revision; -use crate::{ConfigLoader, RepositoryLayout}; +use crate::{BranchManager, CommitBuilder, ConfigLoader, DiffBuilder, Index, RefManager}; use plugins::{ CommandType, CommitAuthor, CommitContent, CommitFileChange, CommitFileMode, CommitPostPayload, - CommitPrePayload, InvocationInput, InvocationPostPayload, InvocationPrePayload, - MevaPluginsLayout, PluginError, + CommitPrePayload, InvocationInput, InvocationPostPayload, InvocationPrePayload, PluginError, }; +use std::path::PathBuf; +use std::sync::Arc; /// Handles the `meva commit` command execution flow. /// @@ -32,16 +26,40 @@ use plugins::{ /// The `config_loader` field provides access to repository or global configuration, /// allowing the handler to resolve author information and other commit metadata. pub struct CommitHandler { - repo_layout: Box, - config_loader: ConfigLoader, + diff_builder: Arc, + refs_manager: Arc, + branch_manager: Arc, + commit_builder: Arc, + index: Arc, + config_loader: Arc, } impl CommitHandler { - /// Creates a new [`CommitHandler`] instance with the given repository layout - /// and configuration loader. - pub fn new(repo_layout: Box, config_loader: ConfigLoader) -> Self { + /// Creates a new [`CommitHandler`] and wires together all dependencies + /// required for performing commit operations. + /// + /// # Arguments + /// + /// * `diff_builder` — Used to detect whether the index contains staged changes. + /// * `refs_manager` — Handles HEAD updates and reference resolution. + /// * `branch_manager` — Determines the current branch and commit ancestry. + /// * `commit_builder` — Constructs commit objects from the index. + /// * `index` — The staging area containing tracked file states. + /// * `config_loader` — Resolves author metadata and commit-related settings. + pub fn new( + diff_builder: Arc, + refs_manager: Arc, + branch_manager: Arc, + commit_builder: Arc, + index: Arc, + config_loader: Arc, + ) -> Self { Self { - repo_layout, + diff_builder, + refs_manager, + branch_manager, + commit_builder, + index, config_loader, } } @@ -59,7 +77,7 @@ impl CommitHandler { pub fn handle_commit( &self, request: Request, - interceptor: &PluginsInterceptor, + interceptor: &PluginsInterceptor, ) -> EngineResult { match self.should_execute(&request)? { false => Err(CommitError::NothingToCommit.into()), @@ -87,21 +105,18 @@ impl CommitHandler { /// Returns an [`EngineError`](EngineError) if reading the index /// or computing the diff fails. fn should_execute(&self, request: &Request) -> EngineResult { - let diff_builder = DiffBuilder::new(self.repo_layout.as_ref())?; - let refs_manager = MevaRefManager::new(self.repo_layout.as_ref()); - let head_hash = refs_manager.resolve_head()?; + let head_hash = self.refs_manager.resolve_head()?; match head_hash { - None => { - let index = MevaIndex::from_disk(self.repo_layout.as_ref(), Some(false))?; - Ok(!index.get_entries().is_empty()) - } + None => Ok(!self.index.get_entries().is_empty()), Some(hash) => { if request.amend_arg { return Ok(true); } let revision = Revision::hash(&hash, Vec::new()); let diff_mode = DiffMode::NameOnly; - let diff = diff_builder.diff_snapshot_index(&revision, &diff_mode, None)?; + let diff = self + .diff_builder + .diff_snapshot_index(&revision, &diff_mode, None)?; Ok(!diff.is_empty()) } } @@ -122,22 +137,19 @@ impl CommitHandler { /// Collects file changes related to the specified commit. /// /// - If `commit_hash` is `None` and no commits exist, all indexed files are treated as added. - /// - If `commit_hash` is `None` but a commit exists, computes the diff between the index and `HEAD`. + /// - If `commit_hash` is `None` but a commit exists, computes the diff between the index and `HEAD`. /// - If `commit_hash` is `Some`, returns changes introduced by that commit. /// /// # Errors /// /// Returns an [`EngineError`] if reading the index or computing diffs fails. fn collect_commit_changes(&self, commit_hash: Option) -> EngineResult> { - let diff_builder = DiffBuilder::new(self.repo_layout.as_ref())?; match commit_hash { None => { - let branch_manager = MevaBranchManager::new(self.repo_layout.as_ref()); - let head = branch_manager.last_commit_hash()?; + let head = self.branch_manager.last_commit_hash()?; match head { None => { - let index = MevaIndex::from_disk(self.repo_layout.as_ref(), Some(false))?; - let entries = index.get_entries(); + let entries = self.index.get_entries(); let object_entries = entries .into_iter() .map(|entry| ObjectEntry { @@ -148,19 +160,20 @@ impl CommitHandler { }) .collect::>(); - diff_builder.added_to_file_changes( + self.diff_builder.added_to_file_changes( object_entries.iter().collect(), &DiffMode::NameOnly, ) } Some(_) => { let revision = Revision::head(Vec::new()); - diff_builder.diff_snapshot_index(&revision, &DiffMode::NameOnly, None) + self.diff_builder + .diff_snapshot_index(&revision, &DiffMode::NameOnly, None) } } } Some(hash) => { - let (_, _, changes) = diff_builder.diff_snapshot_with_parent( + let (_, _, changes) = self.diff_builder.diff_snapshot_with_parent( &Revision::hash(&hash, Vec::new()), &DiffMode::Stat, true, @@ -173,14 +186,6 @@ impl CommitHandler { impl CommitOperations for CommitHandler { fn commit(&self, request: Request) -> EngineResult { - let object_storage: Box = match request.dry_run_arg { - true => Box::new(MevaDryRunObjectStorage::new()), - false => Box::new(MevaObjectStorage::new(self.repo_layout.as_ref())), - }; - let branch_manager = MevaBranchManager::new(self.repo_layout.as_ref()); - let commit_builder = - MevaCommitBuilder::new(self.repo_layout.as_ref(), object_storage.as_ref()); - let author = match request.author_arg.clone() { Some(a) => a, None => Person { @@ -189,14 +194,16 @@ impl CommitOperations for CommitHandler { }, }; - let commit = commit_builder.build_commit(request.message_arg.clone(), author)?; + let commit = self + .commit_builder + .build_commit(request.message_arg.clone(), author)?; let commit_hash = if request.dry_run_arg { None } else if request.amend_arg { - Some(branch_manager.amend_last_commit(commit.clone())?) + Some(self.branch_manager.amend_last_commit(commit.clone())?) } else { - Some(branch_manager.add_commit(commit.clone())?) + Some(self.branch_manager.add_commit(commit.clone())?) }; let changes = self.collect_commit_changes(commit_hash.clone())?; diff --git a/engine/src/handlers/commit/operations.rs b/engine/src/handlers/commit/operations.rs index c7ba5df..3ad3e61 100644 --- a/engine/src/handlers/commit/operations.rs +++ b/engine/src/handlers/commit/operations.rs @@ -1,4 +1,4 @@ -use crate::diff::FileChange; +use crate::diff_builder::FileChange; use crate::errors::EngineResult; use crate::objects::{MevaCommit, Person}; diff --git a/engine/src/handlers/config/handlers.rs b/engine/src/handlers/config/handlers.rs index 08e80dc..0bac8f7 100644 --- a/engine/src/handlers/config/handlers.rs +++ b/engine/src/handlers/config/handlers.rs @@ -1,7 +1,4 @@ -use plugins::{ - CommandType, InvocationPostPayload, InvocationPrePayload, MevaPluginsLayout, PluginError, - models::*, -}; +use plugins::{CommandType, InvocationPostPayload, InvocationPrePayload, PluginError, models::*}; use super::{ ConfigOperations, GetRequest, GetResponse, ListRequest, ListResponse, SetRequest, SetResponse, @@ -13,7 +10,6 @@ use crate::{ config::{ConfigDocument, ConfigDocumentOperations}, errors::{EngineError, EngineResult}, plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}, - repositories::meva_repository_layout::MevaRepositoryLayout, }; pub struct ConfigHandler; @@ -22,7 +18,7 @@ impl ConfigHandler { pub fn handle_get( &self, request: GetRequest, - interceptor: &PluginsInterceptor, + interceptor: &PluginsInterceptor, ) -> EngineResult { interceptor.intercept_with_plugins(CommandType::ConfigGet, None, request, self, |req| { self.get(req) @@ -32,7 +28,7 @@ impl ConfigHandler { pub fn handle_set( &self, request: SetRequest, - interceptor: &PluginsInterceptor, + interceptor: &PluginsInterceptor, ) -> EngineResult { interceptor.intercept_with_plugins(CommandType::ConfigSet, None, request, self, |req| { self.set(req) @@ -42,7 +38,7 @@ impl ConfigHandler { pub fn handle_unset( &self, request: UnsetRequest, - interceptor: &PluginsInterceptor, + interceptor: &PluginsInterceptor, ) -> EngineResult { interceptor.intercept_with_plugins(CommandType::ConfigUnset, None, request, self, |req| { self.unset(req) @@ -52,7 +48,7 @@ impl ConfigHandler { pub fn handle_list( &self, request: ListRequest, - interceptor: &PluginsInterceptor, + interceptor: &PluginsInterceptor, ) -> EngineResult { interceptor.intercept_with_plugins(CommandType::ConfigList, None, request, self, |req| { self.list(req) diff --git a/engine/src/handlers/diff/handlers.rs b/engine/src/handlers/diff/handlers.rs index 19540cc..c37753e 100644 --- a/engine/src/handlers/diff/handlers.rs +++ b/engine/src/handlers/diff/handlers.rs @@ -1,31 +1,30 @@ +use super::{DiffOperations, Request, Response}; use crate::{ - RepositoryLayout, - diff::{DiffBuilder, DiffStat, RevisionRange, RevisionSide}, + diff_builder::{DiffBuilder, DiffStat, RevisionRange, RevisionSide}, errors::EngineResult, }; - -use super::{DiffOperations, Request, Response}; +use std::sync::Arc; /// Handles diffing operations by orchestrating the `DiffBuilder`. /// /// This struct acts as a high-level interface for processing diff requests, /// determining the correct diffing strategy based on the specified revision range, /// and returning the appropriate response. -pub struct DiffHandler<'a> { +pub struct DiffHandler { /// The underlying builder responsible for the actual diff computation. - diff_builder: DiffBuilder<'a>, + diff_builder: Arc, } -impl<'a> DiffHandler<'a> { - /// Creates a new `DiffHandler` instance. +impl DiffHandler { + /// Creates a new [`DiffHandler`] instance. + /// + /// This constructor performs no I/O and only stores the provided diff builder. /// /// # Arguments /// - /// * `repo_layout` - A reference to the repository's layout, used to initialize the `DiffBuilder`. - pub fn new(repo_layout: &'a dyn RepositoryLayout) -> EngineResult { - Ok(Self { - diff_builder: DiffBuilder::new(repo_layout)?, - }) + /// * `diff_builder` — The component responsible for all diff computations. + pub fn new(diff_builder: Arc) -> EngineResult { + Ok(Self { diff_builder }) } /// Public entry point for handling a diff request. @@ -39,7 +38,7 @@ impl<'a> DiffHandler<'a> { } } -impl<'a> DiffOperations for DiffHandler<'a> { +impl DiffOperations for DiffHandler { /// Computes the difference between two repository states as specified in the request. /// /// It determines the type of diff to perform (e.g., snapshot-to-snapshot, index-to-workdir) @@ -53,7 +52,7 @@ impl<'a> DiffOperations for DiffHandler<'a> { /// * `request` - The request detailing the desired diff operation. fn diff(&self, request: Request) -> EngineResult { let RevisionRange { from, to } = request.range; - let is_stat_mode = matches!(request.mode, crate::diff::DiffMode::Stat); + let is_stat_mode = matches!(request.mode, crate::diff_builder::DiffMode::Stat); let file_changes = match (from, to) { diff --git a/engine/src/handlers/diff/operations.rs b/engine/src/handlers/diff/operations.rs index 873893c..ade7267 100644 --- a/engine/src/handlers/diff/operations.rs +++ b/engine/src/handlers/diff/operations.rs @@ -1,11 +1,8 @@ +use crate::diff_builder::{DiffMode, DiffStat, FileChange, RevisionRange}; +use crate::errors::EngineResult; +use crate::revision_parsing::Revision; use std::path::PathBuf; -use crate::{ - diff::{DiffMode, DiffStat, FileChange, RevisionRange}, - errors::EngineResult, - revision_parsing::Revision, -}; - #[derive(Debug)] pub struct Request { pub mode: DiffMode, diff --git a/engine/src/handlers/init/handlers.rs b/engine/src/handlers/init/handlers.rs index efbb700..ae41a5e 100644 --- a/engine/src/handlers/init/handlers.rs +++ b/engine/src/handlers/init/handlers.rs @@ -1,23 +1,26 @@ +use std::sync::Arc; + use plugins::{ CommandType, InitPostPayload, InitPrePayload, InvocationInput, InvocationPostPayload, - InvocationPrePayload, MevaPluginsLayout, PluginError, + InvocationPrePayload, PluginError, }; use super::{InitOperations, Request, Response}; use crate::{ - ConfigLoader, MevaRepository, errors::{EngineError, EngineResult}, plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}, - repositories::meva_repository_layout::MevaRepositoryLayout, + repositories::meva_repository::Repository, }; -pub struct InitHandler; +pub struct InitHandler { + pub repository: Arc, +} impl InitHandler { pub fn handle_init( &self, request: Request, - interceptor: &PluginsInterceptor, + interceptor: &PluginsInterceptor, ) -> EngineResult { interceptor.intercept_with_plugins( CommandType::Init, @@ -31,11 +34,10 @@ impl InitHandler { impl InitOperations for InitHandler { fn init(&self, request: Request) -> EngineResult { - let layout = Box::new(MevaRepositoryLayout::new(request.working_dir)?); - let config_loader = ConfigLoader::default(); - let repository = MevaRepository::new(layout, config_loader); - - let repository_dir = repository.init(&request.initial_branch)?; + self.repository + .layout() + .set_working_dir(request.working_dir); + let repository_dir = self.repository.init(&request.initial_branch)?; let response = Response { repository_dir }; diff --git a/engine/src/handlers/log/handlers.rs b/engine/src/handlers/log/handlers.rs index 2e7e8fa..cb28362 100644 --- a/engine/src/handlers/log/handlers.rs +++ b/engine/src/handlers/log/handlers.rs @@ -1,14 +1,14 @@ -use crate::RepositoryLayout; -use crate::diff::{DiffBuilder, DiffMode, DiffStat}; +use crate::ObjectStorage; +use crate::diff_builder::{DiffBuilder, DiffMode, DiffStat}; use crate::errors::EngineResult; use crate::handlers::{ log::{LogEntry, LogOperations, Request, Response}, show::Snapshot, }; -use crate::object_storage::{MevaObjectStorage, ObjectStorage}; use crate::objects::MevaCommit; use crate::revision_parsing::{Revision, RevisionResolver}; use chrono::Utc; +use std::sync::Arc; /// Handles the `meva log` command execution flow. /// @@ -22,14 +22,33 @@ use chrono::Utc; /// This handler forms the core of commit history inspection and supports both /// detailed (`--stat`) and condensed (`--oneline`) display modes. pub struct LogHandler { - /// Provides access to repository layout and object storage. - repo_layout: Box, + object_storage: Arc, + revision_resolver: Arc, + diff_builder: Arc, } impl LogHandler { - /// Creates a new [`LogHandler`] using the provided repository layout. - pub fn new(repo_layout: Box) -> Self { - Self { repo_layout } + /// Creates a new [`LogHandler`] wired with storage, revision resolution, + /// and diffing capabilities. + /// + /// This constructor does not perform any validation or I/O; it only stores + /// references to the required subsystems. + /// + /// # Arguments + /// + /// * `object_storage` — Provides access to commit, tree, and blob objects. + /// * `revision_resolver` — Resolves revision identifiers into commit hashes. + /// * `diff_builder` — Used for computing file-change statistics when required. + pub fn new( + object_storage: Arc, + revision_resolver: Arc, + diff_builder: Arc, + ) -> Self { + Self { + object_storage, + revision_resolver, + diff_builder, + } } /// Executes the `log` command logic. @@ -42,12 +61,12 @@ impl LogHandler { /// Helper function for loading the next commit in history. fn load_next_commit( - object_storage: &MevaObjectStorage, + &self, next_commit_hash: &Option, ) -> EngineResult<(Option, Option)> { match next_commit_hash { Some(hash) => { - let commit_object = object_storage.get_object(hash)?; + let commit_object = self.object_storage.get_object(hash)?; let commit = MevaCommit::try_from(commit_object)?; let next_hash = commit.parents.first().cloned(); Ok((Some(commit), next_hash)) @@ -74,7 +93,7 @@ impl LogOperations for LogHandler { /// /// - The traversal proceeds linearly through parent links. /// - For each matching commit, a [`LogEntry`] is created. - /// - If `--stat` is active, file statistics are computed using [`DiffBuilder`]. + /// - If `--stat` is active, file statistics are computed using [`MevaDiffBuilder`]. /// /// # Errors /// @@ -85,15 +104,11 @@ impl LogOperations for LogHandler { entries: Vec::new(), }; - let object_storage = MevaObjectStorage::new(self.repo_layout.as_ref()); - let revision_resolver = RevisionResolver::new(self.repo_layout.as_ref()); - let diff_builder = DiffBuilder::new(self.repo_layout.as_ref())?; - let revision = request .revision .unwrap_or_else(|| Revision::head(Vec::new())); - let commit_object = revision_resolver.resolve_object(&revision)?; + let commit_object = self.revision_resolver.resolve_object(&revision)?; let mut current_commit_hash = Some(commit_object.sha1().clone()); let mut current_commit = Some(MevaCommit::try_from(commit_object)?); let mut next_commit_hash = match current_commit.as_ref() { @@ -109,8 +124,7 @@ impl LogOperations for LogHandler { break; } current_commit_hash = next_commit_hash; - (current_commit, next_commit_hash) = - Self::load_next_commit(&object_storage, ¤t_commit_hash)?; + (current_commit, next_commit_hash) = self.load_next_commit(¤t_commit_hash)?; skip -= 1; } @@ -138,8 +152,11 @@ impl LogOperations for LogHandler { if !request.oneline && request.stat { let revision = Revision::hash(¤t_commit_hash.clone().unwrap(), Vec::new()); - let (_, _, changes) = - diff_builder.diff_snapshot_with_parent(&revision, &DiffMode::Stat, true)?; + let (_, _, changes) = self.diff_builder.diff_snapshot_with_parent( + &revision, + &DiffMode::Stat, + true, + )?; new_log_entry.stats = match request.stat { true => Some(DiffStat::from(changes.as_slice())), false => None, @@ -150,8 +167,7 @@ impl LogOperations for LogHandler { } current_commit_hash = next_commit_hash; - (current_commit, next_commit_hash) = - Self::load_next_commit(&object_storage, ¤t_commit_hash)?; + (current_commit, next_commit_hash) = self.load_next_commit(¤t_commit_hash)?; cnt += 1; } diff --git a/engine/src/handlers/log/operations.rs b/engine/src/handlers/log/operations.rs index 27e1aad..231d3fc 100644 --- a/engine/src/handlers/log/operations.rs +++ b/engine/src/handlers/log/operations.rs @@ -1,4 +1,4 @@ -use crate::diff::DiffStat; +use crate::diff_builder::DiffStat; use crate::errors::EngineResult; use crate::handlers::show::Snapshot; use crate::revision_parsing::Revision; diff --git a/engine/src/handlers/ls_files/handlers.rs b/engine/src/handlers/ls_files/handlers.rs index f002933..258953b 100644 --- a/engine/src/handlers/ls_files/handlers.rs +++ b/engine/src/handlers/ls_files/handlers.rs @@ -2,21 +2,35 @@ use super::{ LsFilesOperations, Request, Response, models::{LsFilesEntry, LsFilesFilter}, }; - -use crate::{ - RepositoryLayout, - errors::EngineResult, - index::{MevaIndex, index_entry::IndexEntry}, - repositories::meva_repository_layout::MevaRepositoryLayout, -}; - +use crate::errors::EngineResult; +use crate::index::index_entry::IndexEntry; +use crate::{Index, RepositoryLayout}; use shared::{PathToString, StripBase}; use std::path::Path; +use std::sync::Arc; -/// Handles `ls-files` operations. -pub struct LsFilesHandler; +/// Handles the execution flow of the `meva ls-files` command. +/// +/// [`LsFilesHandler`] is responsible for listing files known to the repository +/// in various states, depending on the options provided by the user. +/// Its primary purpose is to expose the contents of the in-memory index +/// and, optionally, the working directory or ignored/untracked files. +pub struct LsFilesHandler { + repo_layout: Arc, + index: Arc, +} impl LsFilesHandler { + /// Creates a new [`LsFilesHandler`] with repository layout and index access. + /// + /// # Arguments + /// + /// * `repo_layout` — Repository layout providing filesystem paths. + /// * `index` — Interface for accessing tracked index entries. + pub fn new(repo_layout: Arc, index: Arc) -> Self { + Self { repo_layout, index } + } + /// Entry point for executing the `ls-files` operation. pub fn handle_ls_files(&self, request: Request) -> EngineResult { self.ls_files(request) @@ -79,36 +93,34 @@ impl LsFilesHandler { impl LsFilesOperations for LsFilesHandler { fn ls_files(&self, request: Request) -> EngineResult { - let repo_layout = MevaRepositoryLayout::discover()?; - let working_dir = repo_layout.working_dir(); - let index = MevaIndex::from_disk(&repo_layout, None)?; + let working_dir = self.repo_layout.working_dir(); let abbrev = request.abbrev.unwrap_or(40) as usize; let response = match request.filter { LsFilesFilter::Deleted => { - let entries = index.get_deleted_files(); + let entries = self.index.get_deleted_files(); self.build_response_from_index_entries( &entries, abbrev, request.stage, - working_dir, + &working_dir, request.full_name, ) } LsFilesFilter::Cached => { - let entries = index.get_entries(); + let entries = self.index.get_entries(); self.build_response_from_index_entries( &entries, abbrev, request.stage, - working_dir, + &working_dir, request.full_name, ) } LsFilesFilter::Others => { - let untracked = index.get_untracked_files(); - self.build_response_from_paths(&untracked, working_dir, request.full_name) + let untracked = self.index.get_untracked_files(); + self.build_response_from_paths(&untracked, &working_dir, request.full_name) } }; diff --git a/engine/src/handlers/ls_tree/handlers.rs b/engine/src/handlers/ls_tree/handlers.rs index 68064da..7c157f2 100644 --- a/engine/src/handlers/ls_tree/handlers.rs +++ b/engine/src/handlers/ls_tree/handlers.rs @@ -1,10 +1,9 @@ use super::{DisplayMode, LsTreeEntry}; use super::{LsTreeOperations, Request, Response}; -use crate::RepositoryLayout; use crate::errors::EngineResult; -use crate::object_storage::MevaObjectStorage; use crate::revision_parsing::RevisionResolver; -use crate::traversal::{CommitTreeWalker, MevaCommitTreeWalker}; +use crate::traversal::CommitTreeWalker; +use std::sync::Arc; /// Handles the `ls-tree` command logic. /// @@ -12,14 +11,30 @@ use crate::traversal::{CommitTreeWalker, MevaCommitTreeWalker}; /// fetches commit and tree objects from storage, and formats them for output. /// It supports recursive listing, filtering by path, and different display modes. pub struct LsTreeHandler { - /// Repository layout providing access to `.meva` directory and object storage. - repo_layout: Box, + commit_tree_walker: Arc, + revision_resolver: Arc, } impl LsTreeHandler { - /// Creates a new [`LsTreeHandler`] with the given repository layout. - pub fn new(repo_layout: Box) -> Self { - Self { repo_layout } + /// Creates a new [`LsTreeHandler`] instance. + /// + /// This constructor only wires required components; it does not load or + /// validate any repository data. + /// + /// # Parameters + /// + /// * `commit_tree_walker` — Component responsible for traversing tree objects + /// and retrieving the corresponding entries. + /// * `revision_resolver` — Resolves names such as `HEAD`, branch names, tags, + /// or SHA identifiers into actual commit references. + pub fn new( + commit_tree_walker: Arc, + revision_resolver: Arc, + ) -> Self { + Self { + commit_tree_walker, + revision_resolver, + } } /// Dispatches the `ls-tree` operation based on the provided [`Request`]. @@ -32,11 +47,7 @@ impl LsTreeHandler { impl LsTreeOperations for LsTreeHandler { fn ls_tree(&self, request: Request) -> EngineResult { - let revision_resolver = RevisionResolver::new(self.repo_layout.as_ref()); - let object_storage = MevaObjectStorage::new(self.repo_layout.as_ref()); - let commit_tree_walker = MevaCommitTreeWalker::new(Box::new(object_storage)); - - let commit_hash = revision_resolver.resolve_hash(&request.revision)?; + let commit_hash = self.revision_resolver.resolve_hash(&request.revision)?; let include_trees = request.tree || !request.recursive; let max_depth = match request.recursive { true => None, @@ -47,8 +58,12 @@ impl LsTreeOperations for LsTreeHandler { false => Some(request.paths.as_ref()), }; - let listed_entries = - commit_tree_walker.walk_commit(&commit_hash, include_trees, max_depth, path_filter)?; + let listed_entries = self.commit_tree_walker.walk_commit( + &commit_hash, + include_trees, + max_depth, + path_filter, + )?; let display_mode = if request.name_only { DisplayMode::NameOnly diff --git a/engine/src/handlers/plugins/handlers.rs b/engine/src/handlers/plugins/handlers.rs index 878c8cb..0f62324 100644 --- a/engine/src/handlers/plugins/handlers.rs +++ b/engine/src/handlers/plugins/handlers.rs @@ -1,4 +1,4 @@ -use std::{env, path::PathBuf}; +use std::{env, path::PathBuf, sync::Arc}; use plugins::{PluginConfiguration, PluginsRepository, ScopeType}; use shared::UpwardSearch; @@ -13,14 +13,14 @@ use crate::{ }; pub struct PluginsHandler { - pub plugins_repository: Box, - pub repository_layout: Box, + pub plugins_repository: Arc, + pub repository_layout: Arc, } impl PluginsHandler { pub fn new( - plugins_repository: Box, - repository_layout: Box, + plugins_repository: Arc, + repository_layout: Arc, ) -> Self { Self { plugins_repository, diff --git a/engine/src/handlers/restore/handlers.rs b/engine/src/handlers/restore/handlers.rs index f7564c6..3c4c0d2 100644 --- a/engine/src/handlers/restore/handlers.rs +++ b/engine/src/handlers/restore/handlers.rs @@ -1,17 +1,15 @@ use super::{Request, Response, RestoreOperations}; -use crate::RepositoryLayout; use crate::errors::EngineResult; -use crate::index::{FileMode, IndexEntry, MevaIndex, Stage, WorkingDir}; -use crate::object_storage::{MevaObjectStorage, ObjectStorage}; +use crate::index::{FileMode, index_entry::IndexEntry, stage::Stage}; use crate::objects::{MevaBlob, MevaObject, ObjectEntry}; -use crate::revision_parsing::RevisionResolver; -use crate::traversal::{CommitTreeWalker, MevaCommitTreeWalker}; +use crate::{CommitTreeWalker, Index, ObjectStorage, RevisionResolver, WorkingDir}; use chrono::Utc; use shared::{PathToString, StripBase}; use std::collections::{HashMap, HashSet}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; /// Handles the `meva restore` command execution flow. /// @@ -28,13 +26,40 @@ use std::path::{Path, PathBuf}; /// /// The handler supports partial restores (specific paths) or full repository restores. pub struct RestoreHandler { - repo_layout: Box, + index: Arc>, + revision_resolver: Arc, + commit_tree_walker: Arc, + working_dir: Arc, + object_storage: Arc, } impl RestoreHandler { - /// Creates a new [`RestoreHandler`] instance with the given repository layout. - pub fn new(repo_layout: Box) -> Self { - Self { repo_layout } + /// Creates a new [`RestoreHandler`] and wires all required subsystems. + /// + /// This constructor does not perform any I/O; it only stores references + /// to the necessary repository components. + /// + /// # Arguments + /// + /// * `index` — Thread-safe (RwLock-backed) access to the index for updates. + /// * `revision_resolver` — Resolves revision identifiers into commits. + /// * `commit_tree_walker` — Walks tree objects to find entries to restore. + /// * `working_dir` — Writes files into the working tree when requested. + /// * `object_storage` — Provides access to blob contents needed to restore files. + pub fn new( + index: Arc>, + revision_resolver: Arc, + commit_tree_walker: Arc, + working_dir: Arc, + object_storage: Arc, + ) -> Self { + Self { + index, + revision_resolver, + commit_tree_walker, + working_dir, + object_storage, + } } /// Executes the restore flow. @@ -100,32 +125,36 @@ impl RestoreHandler { source_files: &[ObjectEntry], paths: Option<&[PathBuf]>, ) -> EngineResult<()> { - let mut index = MevaIndex::from_disk(self.repo_layout.as_ref(), Some(false))?; let source_files_map = Self::map_source_files(source_files); - let index_entries_map = index.get_entries_map(paths); + + let index_read_guard = self.index.read().unwrap(); + + let index_entries_map = index_read_guard.get_entries_map(paths); let index_entries_to_add = Self::build_index_entries_to_add(&source_files_map, &index_entries_map)?; let index_entries_to_remove = Self::build_index_entries_to_remove(&index_entries_map, &source_files_map); - index.insert_entries(index_entries_to_add); - index.remove_entries(index_entries_to_remove); + let mut index_write_guard = self.index.write().unwrap(); + index_write_guard.insert_entries(index_entries_to_add); + index_write_guard.remove_entries(index_entries_to_remove); - index.save()?; + index_write_guard.save()?; Ok(()) } // Collects all files under the specified worktree paths. - fn build_workdir_files(&self, workdir: &WorkingDir, paths: &[PathBuf]) -> HashSet { + fn build_workdir_files(&self, paths: &[PathBuf]) -> HashSet { let mut workdir_files = HashSet::new(); for path in paths { - let collected_files: Vec = workdir + let collected_files: Vec = self + .working_dir .collect_files(path, false) .into_iter() .map(|(entry_path, _)| { entry_path - .strip_base(self.repo_layout.working_dir()) + .strip_base(&self.working_dir.layout().working_dir()) .to_path_buf() }) .collect(); @@ -189,15 +218,13 @@ impl RestoreHandler { source_files: &[ObjectEntry], paths: Option<&[PathBuf]>, ) -> EngineResult<()> { - let object_storage = MevaObjectStorage::new(self.repo_layout.as_ref()); - let workdir = WorkingDir::with_ignore_services(self.repo_layout.as_ref()); - let source_files_map: HashMap = Self::map_source_files(source_files); - let workdir_paths = Self::resolve_workdir_paths(paths, self.repo_layout.working_dir()); - let workdir_files = self.build_workdir_files(&workdir, &workdir_paths); + let workdir_paths = + Self::resolve_workdir_paths(paths, &self.working_dir.layout().working_dir()); + let workdir_files = self.build_workdir_files(&workdir_paths); for (file_path, object_entry) in &source_files_map { - let source_blob = object_storage.get_object(&object_entry.hash)?; + let source_blob = self.object_storage.get_object(&object_entry.hash)?; if workdir_files.contains(file_path) { let workdir_blob = MevaObject::try_from(MevaBlob::from_file(file_path)?)?; @@ -230,16 +257,14 @@ impl RestoreOperations for RestoreHandler { /// /// Returns a [`Response`] after successfully restoring files. fn restore(&self, request: Request) -> EngineResult { - let object_storage = MevaObjectStorage::new(self.repo_layout.as_ref()); - let commit_tree_walker = MevaCommitTreeWalker::new(Box::new(object_storage)); - let revision_resolver = RevisionResolver::new(self.repo_layout.as_ref()); - let commit_hash = revision_resolver.resolve_hash(&request.source)?; + let commit_hash = self.revision_resolver.resolve_hash(&request.source)?; let path_filter = match request.paths.len() { 0 => None, _ => Some(request.paths.as_slice()), }; let source_files = - commit_tree_walker.walk_commit(&commit_hash, false, None, path_filter)?; + self.commit_tree_walker + .walk_commit(&commit_hash, false, None, path_filter)?; if request.staged { self.restore_index(&source_files, path_filter)?; diff --git a/engine/src/handlers/show/handlers.rs b/engine/src/handlers/show/handlers.rs index 1e924e1..456c856 100644 --- a/engine/src/handlers/show/handlers.rs +++ b/engine/src/handlers/show/handlers.rs @@ -1,27 +1,27 @@ -use crate::{ - RepositoryLayout, - diff::{DiffBuilder, DiffMode, DiffStat}, - errors::EngineResult, - handlers::show::{PatchMode, models::Snapshot}, -}; - use super::{Request, Response, ShowOperations}; +use crate::diff_builder::{DiffBuilder, DiffMode, DiffStat}; +use crate::errors::EngineResult; +use crate::handlers::show::{PatchMode, models::Snapshot}; +use std::sync::Arc; -pub struct ShowHandler<'a> { +/// Handles the execution flow of the `meva show` command. +/// +/// [`ShowHandler`] is responsible for displaying commit information, +/// diff output, or object metadata depending on what the user specifies. +pub struct ShowHandler { /// The underlying builder responsible for the diff computation. - diff_builder: DiffBuilder<'a>, + diff_builder: Arc, } -impl<'a> ShowHandler<'a> { - /// Creates a new `ShowHandler` instance. +impl ShowHandler { + /// Creates a new [`ShowHandler`] instance. /// - /// # Arguments + /// # Parameters /// - /// * `repo_layout` - A reference to the repository's layout, used to initialize the `DiffBuilder`. - pub fn new(repo_layout: &'a dyn RepositoryLayout) -> EngineResult { - Ok(Self { - diff_builder: DiffBuilder::new(repo_layout)?, - }) + /// * `diff_builder` — The diff computation backend used to generate + /// file patches and change statistics as part of `meva show` output. + pub fn new(diff_builder: Arc) -> EngineResult { + Ok(Self { diff_builder }) } pub fn handle_show(&self, request: Request) -> EngineResult { @@ -29,7 +29,7 @@ impl<'a> ShowHandler<'a> { } } -impl<'a> ShowOperations for ShowHandler<'a> { +impl ShowOperations for ShowHandler { fn show(&self, request: Request) -> EngineResult { let include_changes = !matches!(&request.mode, PatchMode::NoPatch); let mode = DiffMode::from(request.mode); diff --git a/engine/src/handlers/show/models/patch_mode.rs b/engine/src/handlers/show/models/patch_mode.rs index 83c29b6..dd610d5 100644 --- a/engine/src/handlers/show/models/patch_mode.rs +++ b/engine/src/handlers/show/models/patch_mode.rs @@ -4,7 +4,7 @@ /// are presented to the user. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PatchMode { - /// Display the full diff patch, including added and removed lines. + /// Display the ful diff patch, including added and removed lines. Patch, /// Show only the commit message and metadata, without any diff output. NoPatch, diff --git a/engine/src/handlers/show/operations.rs b/engine/src/handlers/show/operations.rs index f2533a4..3ff7dbd 100644 --- a/engine/src/handlers/show/operations.rs +++ b/engine/src/handlers/show/operations.rs @@ -1,5 +1,5 @@ use crate::{ - diff::{DiffStat, FileChange}, + diff_builder::{DiffStat, FileChange}, errors::EngineResult, revision_parsing::Revision, }; diff --git a/engine/src/handlers/status/handlers.rs b/engine/src/handlers/status/handlers.rs index 1a37b3e..85affec 100644 --- a/engine/src/handlers/status/handlers.rs +++ b/engine/src/handlers/status/handlers.rs @@ -1,35 +1,62 @@ +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use std::sync::Arc; use std::{ collections::{HashMap, HashSet}, path::PathBuf, }; -use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; - -use crate::{ - diff::{ChangeKind, DiffBuilder, DiffMode}, - errors::EngineResult, - index::{ - MevaIndex, WorkingDir, index_entry::IndexEntry, stage::Stage, working_dir::WorkingDirEntry, - }, - object_storage::{MevaObjectStorage, ObjectStorage}, - objects::{MevaCommit, ObjectEntry}, - ref_manager::{Head, HeadMode, MevaRefManager, RefManager}, - repositories::meva_repository_layout::MevaRepositoryLayout, -}; - use super::{ Request, Response, StatusOperations, models::{BranchInfo, MergeEntry, StatusEntry}, }; +use crate::{DiffBuilder, Index, ObjectStorage, RefManager, WorkingDir}; +use crate::{ + diff_builder::{ChangeKind, DiffMode}, + errors::EngineResult, + index::{index_entry::IndexEntry, stage::Stage, working_dir::WorkingDirEntry}, + objects::{MevaCommit, ObjectEntry}, + ref_manager::{Head, HeadMode}, +}; /// Handles the logic for calculating and reporting the repository status. /// /// This struct is stateless and provides the main entry point (`status`) /// for orchestrating the complex task of comparing HEAD, the index, /// and the working directory. -pub struct StatusHandler; +pub struct StatusHandler { + working_dir: Arc, + index: Arc, + ref_manager: Arc, + object_storage: Arc, + diff_builder: Arc, +} impl StatusHandler { + /// Creates a new [`StatusHandler`] instance. + /// + /// # Arguments + /// + /// * `working_dir` — Provides file state from the live filesystem. + /// * `index` — Gives access to staged file entries. + /// * `ref_manager` — Resolves HEAD or other relevant commit references. + /// * `object_storage` — Reads committed objects for comparison. + /// * `diff_builder` — Produces diffs used to classify staged and unstaged changes. + pub fn new( + working_dir: Arc, + index: Arc, + ref_manager: Arc, + object_storage: Arc, + diff_builder: Arc, + ) -> Self { + Self { + working_dir, + index, + ref_manager, + object_storage, + diff_builder, + } + } + /// Public entry point to handle a status request. /// /// This is a simple wrapper that delegates to the [StatusOperations] @@ -151,13 +178,12 @@ impl StatusHandler { /// Otherwise, it returns only the non-ignored files. fn collect_working_dir( &self, - working_dir: &WorkingDir, show_ignored: bool, response: &mut Response, ) -> EngineResult> { match show_ignored { true => { - let files = working_dir.collect_files_with_metadata(true, None)?; + let files = self.working_dir.collect_files_with_metadata(true, None)?; let (ignored_files, files): (Vec<_>, Vec<_>) = files.into_iter().partition(|(_, e)| e.is_ignored); @@ -169,7 +195,7 @@ impl StatusHandler { response.ignored = Some(ignored_files); Ok(files.into_iter().collect()) } - false => working_dir.collect_files_with_metadata(false, None), + false => self.working_dir.collect_files_with_metadata(false, None), } } @@ -218,7 +244,6 @@ impl StatusHandler { /// It filters out `Added` changes, as those are handled as `Untracked` files. fn collect_unstaged_changes( &self, - diff_builder: &DiffBuilder, normal_entries: &[IndexEntry], working_map: HashMap, response: &mut Response, @@ -228,7 +253,7 @@ impl StatusHandler { .map(|entry| (PathBuf::from(&entry.path), entry)) .collect::>(); - let changes = diff_builder.diff_index_working_dir( + let changes = self.diff_builder.diff_index_working_dir( &DiffMode::NameOnly, None, Some(index_map), @@ -256,8 +281,6 @@ impl StatusHandler { /// currently in the index. fn collect_staged_changes( &self, - diff_builder: &DiffBuilder, - object_storage: &MevaObjectStorage, normal_entries: &[IndexEntry], head_hash: &Option, response: &mut Response, @@ -280,14 +303,18 @@ impl StatusHandler { let head_objects = match head_hash { Some(head_hash) => { - let head_commit = MevaCommit::try_from(object_storage.get_object(head_hash)?)?; - diff_builder.collect_object_entries(PathBuf::new(), &head_commit.tree, None)? + let head_commit = MevaCommit::try_from(self.object_storage.get_object(head_hash)?)?; + self.diff_builder + .collect_object_entries(PathBuf::new(), &head_commit.tree, None)? } None => HashMap::::new(), }; - let changes = - diff_builder.diff_tree_vs_tree(&head_objects, &normal_objects, &DiffMode::NameOnly)?; + let changes = self.diff_builder.diff_tree_vs_tree( + &head_objects, + &normal_objects, + &DiffMode::NameOnly, + )?; let staged = changes .iter() @@ -304,14 +331,8 @@ impl StatusOperations for StatusHandler { fn status(&self, request: Request) -> EngineResult { let mut response = Response::default(); - let repo_layout = MevaRepositoryLayout::discover()?; - let working_dir = WorkingDir::with_ignore_services(&repo_layout); - let index = MevaIndex::from_disk(&repo_layout, Some(false))?; - let ref_manager = MevaRefManager::new(&repo_layout); - let object_storage = MevaObjectStorage::new(&repo_layout); - - let head = ref_manager.read_head()?; - let head_result = ref_manager.resolve_commit_hash(&head); + let head = self.ref_manager.read_head()?; + let head_result = self.ref_manager.resolve_commit_hash(&head); if request.show_branch { response.branch = Some(self.get_branch_info(&head, &head_result)); @@ -319,10 +340,9 @@ impl StatusOperations for StatusHandler { let head_hash = head_result.unwrap_or(None); - let working_map = - self.collect_working_dir(&working_dir, request.show_ignored, &mut response)?; + let working_map = self.collect_working_dir(request.show_ignored, &mut response)?; - let entries = index.get_entries_owned(); + let entries = self.index.get_entries_owned(); let (normal, unmerged) = self.split_index_entries(entries); let normal_paths = normal @@ -334,16 +354,8 @@ impl StatusOperations for StatusHandler { self.collect_unmerged_files(unmerged, &mut response); if !normal.is_empty() { - let diff_builder = DiffBuilder::with_index(&repo_layout, index); - - self.collect_unstaged_changes(&diff_builder, &normal, working_map, &mut response)?; - self.collect_staged_changes( - &diff_builder, - &object_storage, - &normal, - &head_hash, - &mut response, - )?; + self.collect_unstaged_changes(&normal, working_map, &mut response)?; + self.collect_staged_changes(&normal, &head_hash, &mut response)?; } Ok(response) diff --git a/engine/src/handlers/status/models/status_entry.rs b/engine/src/handlers/status/models/status_entry.rs index 7f49ed7..e9e3d6c 100644 --- a/engine/src/handlers/status/models/status_entry.rs +++ b/engine/src/handlers/status/models/status_entry.rs @@ -3,7 +3,7 @@ use std::{fmt::Display, path::PathBuf}; use owo_colors::OwoColorize; use shared::PathToString; -use crate::diff::ChangeKind; +use crate::diff_builder::ChangeKind; use super::status_kind::StatusKind; diff --git a/engine/src/handlers/status/models/status_kind.rs b/engine/src/handlers/status/models/status_kind.rs index 47178cd..726dba3 100644 --- a/engine/src/handlers/status/models/status_kind.rs +++ b/engine/src/handlers/status/models/status_kind.rs @@ -1,4 +1,4 @@ -use crate::diff::ChangeKind; +use crate::diff_builder::ChangeKind; /// Represents the specific status of a file, categorizing its state /// relative to HEAD, the index (staging area), and the working directory. diff --git a/engine/src/index.rs b/engine/src/index.rs index 5f568c8..3e15bc5 100644 --- a/engine/src/index.rs +++ b/engine/src/index.rs @@ -5,10 +5,139 @@ pub mod serde_utils; pub mod stage; pub mod working_dir; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use index_entry::IndexEntry; use serde_utils::{deserialize_entries, deserialize_entries_with_absolute_keys}; +use stage::Stage; pub use file_mode::FileMode; -pub use index_entry::IndexEntry; pub use meva_index::MevaIndex; -pub use stage::Stage; -pub use working_dir::{WorkingDir, WorkingDirEntry}; +pub use working_dir::{MevaWorkingDir, WorkingDirEntry}; + +use crate::errors::EngineResult; + +pub trait Index: Send + Sync { + /// Returns all entries currently tracked in the index. + /// + /// The returned vector contains borrowed references to the underlying + /// [`IndexEntry`] values in an unspecified order. + fn get_entries(&self) -> Vec<&IndexEntry>; + + /// Returns all entries currently tracked in the index as owned objects. + fn get_entries_owned(&self) -> Vec; + + /// Returns a map of index entries, optionally filtered by a list of paths. + /// + /// # Arguments + /// + /// * `paths` - An optional slice of [`PathBuf`]s. If provided, only entries whose paths match + /// one of the allowed paths will be included. + /// + /// # Returns + /// + /// A [`HashMap`] where keys are the [`PathBuf`] of the entries and values are references to the [`IndexEntry`]. + fn get_entries_map(&self, paths: Option<&[PathBuf]>) -> HashMap; + + /// Checks whether a given file or directory is tracked in the Meva index. + /// + /// # Returns + /// - `true` if the exact file is tracked. + /// - `true` for a directory if at least one tracked file exists inside it. + /// + /// The path may be absolute or relative. It will be normalized against + /// the repository’s working directory before comparison. + fn is_tracked(&self, path: &Path) -> EngineResult; + + /// Loads the index file from disk into memory, populating the `entries` map. + /// + /// If the file does not exist, it will be created. If it is empty, the in-memory index will be cleared. + /// + /// # Arguments + /// + /// * `with_absolute_keys` - If `true`, paths are converted from relative (on disk) to absolute (in memory). + /// Defaults to `true`. + fn load_from_disk(&mut self, with_absolute_keys: Option) -> EngineResult<()>; + + /// Identifies tracked files that no longer exist in the working directory. + /// + /// It compares the list of files in the index with the actual files on the filesystem. + /// + /// # Returns + /// + /// A vector of [`IndexEntry`] references representing the deleted files. + fn get_deleted_files(&self) -> Vec<&IndexEntry>; + + /// Scans the working directory for files that are **not** currently tracked in the index. + /// + /// This method respects the ignore rules defined in `.mevaignore` files, so ignored files + /// will not be reported as untracked unless they were explicitly tracked before. + /// + /// # Returns + /// + /// A vector of [`PathBuf`]s for each untracked file. + fn get_untracked_files(&self) -> Vec; + + /// Adds files to the index under a given path, respecting various flags. + /// + /// This is the core staging operation. It scans for files, categorizes them as new or modified, + /// handles deletions, updates hashes, and modifies the in-memory index. + /// + /// # Arguments + /// + /// * `path_abs` - The absolute path to a file or directory to add. + /// * `add_new` - If `true`, new (untracked) files will be added to the index. + /// * `add_deleted` - If `true`, files that are tracked but missing from the working directory will be removed. + /// * `add_ignored` - If `true`, ignore rules will be bypassed. + /// * `verbose` - If `true`, prints the name of each file being added or removed. + /// + /// # Returns + /// + /// A tuple containing vectors of paths for `(new_files, modified_files, deleted_files)`. + fn add( + &mut self, + path: &Path, + add_new: bool, + add_deleted: bool, + add_ignored: bool, + verbose: bool, + ) -> EngineResult<(Vec, Vec, Vec)>; + + /// Saves the in-memory index back to the index file on disk. + /// + /// Before writing, it converts all absolute paths in the entries into paths + /// relative to the repository's working directory. The data is then serialized + /// as a pretty-printed JSON array. + /// + /// # Errors + /// + /// - [`IndexError::IndexFileNotFound`]: if the index file does not exist. + /// - Fails if a path in the index lies outside the repository root. + /// - Fails if serialization or file writing fails. + fn save(&mut self) -> EngineResult<()>; + + /// Inserts a collection of [`IndexEntry`] objects into the in-memory index. + /// + /// Existing entries with the same path are replaced. + /// + /// **Note:** This method updates only the in-memory cache of entries. + /// To persist these changes to disk, you must call [`MevaIndex::save`]. + /// + /// # Arguments + /// + /// * `entries` - An iterable collection of [`IndexEntry`] objects to insert. + fn insert_entries(&mut self, entries: Vec); + + /// Removes entries from the in-memory index by their paths. + /// + /// **Note:** This method updates only the in-memory cache of entries. + /// To persist these changes to disk, you must call [`MevaIndex::save`]. + /// + /// # Arguments + /// + /// * `keys` - An iterable collection of paths (`String`) identifying which entries to remove. + fn remove_entries(&mut self, keys: Vec); +} diff --git a/engine/src/index/meva_index.rs b/engine/src/index/meva_index.rs index 55caa77..2e1bf08 100644 --- a/engine/src/index/meva_index.rs +++ b/engine/src/index/meva_index.rs @@ -1,4 +1,4 @@ -use super::{IndexEntry, WorkingDir, deserialize_entries, deserialize_entries_with_absolute_keys}; +use super::{IndexEntry, deserialize_entries, deserialize_entries_with_absolute_keys}; use path_absolutize::Absolutize; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use serde_json::Deserializer; @@ -7,12 +7,13 @@ use std::{ fs::{self, OpenOptions}, io::{self, Read}, path::{Path, PathBuf}, + sync::Arc, }; +use crate::diff_builder::FileChange; use crate::errors::{EngineResult, IndexError}; -use crate::object_storage::{MevaObjectStorage, ObjectStorage}; use crate::objects::MevaBlob; -use crate::{RepositoryLayout, diff::FileChange}; +use crate::{Index, ObjectStorage, WorkingDir}; use shared::{PathToString, StripBase}; @@ -24,177 +25,221 @@ use shared::{PathToString, StripBase}; /// - Tracking new, modified, and deleted files. /// - Respecting ignore rules defined in `.mevaignore` files. /// - Updating file hashes (SHA-1) and storing corresponding blob objects. -pub struct MevaIndex<'a> { - /// Layout of the repository, providing paths to the working directory, index file, etc. - repo_layout: &'a dyn RepositoryLayout, - +pub struct MevaIndex { /// A helper for interacting with the working directory, including file collection and ignore rule processing. - working_dir: WorkingDir<'a>, + working_dir: Arc, + + object_storage: Arc, /// Map of tracked entries, keyed by their path. /// The path can be absolute when in memory and is converted to relative on save. entries: HashMap, } -impl<'a> MevaIndex<'a> { - /// Creates a new, empty `MevaIndex` bound to the given repository layout. +impl MevaIndex { + /// Creates a new, empty [`MevaIndex`] instance using the provided + /// working directory and object storage backends. + /// + /// The index starts with an empty in-memory entry map and can be populated + /// through operations such as `add`, `remove`, or loading from disk. /// /// # Arguments /// - /// * `repo_layout` - A reference to the repository's layout configuration. - pub fn new(repo_layout: &'a dyn RepositoryLayout) -> EngineResult { + /// * `working_dir` — The working directory abstraction used to gather + /// file metadata and evaluate ignore rules. + /// * `object_storage` — The object storage used to persist and retrieve + /// blobs corresponding to tracked files. + pub fn new( + working_dir: Arc, + object_storage: Arc, + ) -> EngineResult { Ok(Self { - repo_layout, - working_dir: WorkingDir::with_ignore_services(repo_layout), + working_dir, + object_storage, entries: HashMap::new(), }) } - /// Creates a new `MevaIndex` and immediately loads its state from the on-disk index file. - /// - /// This is a convenience method equivalent to calling [`MevaIndex::new`] followed by [`MevaIndex::load_from_disk`]. + /// Creates a new [`MevaIndex`] and immediately loads its state from the + /// on-disk index file. /// - /// # Arguments + /// This convenience constructor behaves equivalently to calling + /// [`MevaIndex::new`] with the provided components, followed by + /// [`MevaIndex::load_from_disk`]. /// - /// * `repo_layout` - A reference to the repository's layout configuration. - /// * `with_absolute_keys` - If `true`, assumes paths in the index file are relative and converts them to absolute. - /// If `false`, loads paths as they are. Defaults to `true`. + /// The method initializes an empty in-memory index and then replaces its + /// contents with data parsed from the index file stored in the repository. /// - /// # Errors + /// # Arguments /// - /// Returns an error if the index file cannot be read or parsed. + /// * `working_dir` — The working directory abstraction used for path + /// resolution and ignore-rule handling. + /// * `object_storage` — The object storage backend used for blob hashing + /// and retrieval associated with indexed entries. + /// * `with_absolute_keys` — If `Some(true)`, paths loaded from disk + /// are converted to absolute forms. + /// If `Some(false)`, paths are kept exactly as stored. + /// If `None`, the default behavior is to treat paths as relative and + /// convert them to absolute (same as passing `Some(true)`). pub fn from_disk( - repo_layout: &'a dyn RepositoryLayout, + working_dir: Arc, + object_storage: Arc, with_absolute_keys: Option, ) -> EngineResult { - let mut meva_index = MevaIndex::new(repo_layout)?; + let mut meva_index = MevaIndex::new(working_dir, object_storage)?; meva_index.load_from_disk(with_absolute_keys)?; Ok(meva_index) } - /// Returns all entries currently tracked in the index. + /// Removes entries from the index that correspond to files no longer present on the filesystem. /// - /// The returned vector contains borrowed references to the underlying - /// [`IndexEntry`] values in an unspecified order. + /// # Arguments /// - //# TODO - //This method should be removed in the future in favor of - //[`Index::iter_entries()`], which returns an iterator and avoids - //unnecessary allocation. - pub fn get_entries(&self) -> Vec<&IndexEntry> { - self.entries.values().collect() - } - - /// Returns an iterator over all entries currently tracked in the index. + /// * `dir_path` - The directory path to check for deletions. + /// * `dir_files` - A slice of paths to files that are known to still exist. + /// * `verbose` - If `true`, prints the path of each removed file. /// - /// Each item of the iterator is a borrowed reference to an [`IndexEntry`]. + /// # Returns /// - /// Entries are yielded in an unspecified order, which may not correspond - /// to the order in which they were added or appear on disk. - pub fn iter_entries(&self) -> impl Iterator { - self.entries.values() - } + /// A vector of strings containing the paths of the removed files. + fn remove_deleted_files( + &mut self, + dir_path: &PathBuf, + dir_files: &[PathBuf], + verbose: bool, + ) -> Vec { + let files_in_index = self + .entries + .keys() + .filter(|k| Path::new(&k).starts_with(dir_path)) + .cloned() + .collect::>(); - /// Returns all entries currently tracked in the index as owned objects. - pub fn get_entries_owned(&self) -> Vec { - self.entries.values().cloned().collect() + let workdir_set = dir_files + .iter() + .map(|p| p.to_utf8_string()) + .collect::>(); + + let deleted_files = files_in_index + .into_iter() + .filter(|k| !workdir_set.contains(k)) + .collect::>(); + + for file in &deleted_files { + if verbose { + println!("remove: {file}") + } + self.entries.remove(file); + } + + deleted_files } - /// Returns a map of index entries, optionally filtered by a list of paths. - /// - /// # Arguments + /// Updates or inserts a list of index entries, calculating their SHA-1 hashes and storing blobs. /// - /// * `paths` - An optional slice of [`PathBuf`]s. If provided, only entries whose paths match - /// one of the allowed paths will be included. + /// This function processes the provided entries in parallel to speed up file reading and hashing. + /// For each entry, it creates a [`MevaBlob`], adds it to the object store, gets the resulting hash, + /// and updates the entry. Finally, it inserts the updated entry into the in-memory index. /// - /// # Returns + /// # Arguments /// - /// A [`HashMap`] where keys are the [`PathBuf`] of the entries and values are references to the [`IndexEntry`]. - pub fn get_entries_map(&self, paths: Option<&[PathBuf]>) -> HashMap { - self.entries - .iter() - .filter_map(|(key, entry)| { - let path = PathBuf::from(&key); - if let Some(allowed_paths) = paths - && !allowed_paths.iter().any(|p| path.starts_with(p)) - { - return None; + /// * `index_entries` - A mutable vector of [`IndexEntry`]s to process. + /// * `verbose` - If `true`, prints the path of each added file. + fn update_index( + &mut self, + index_entries: &mut Vec, + verbose: bool, + ) -> EngineResult<()> { + let result: Vec<_> = index_entries + .par_iter() + .map(|index_entry| { + let mut updated_entry = index_entry.clone(); + let blob = MevaBlob::from_file(index_entry.path.as_ref())?; + updated_entry.sha1 = self.object_storage.add_blob(blob)?; + if verbose { + println!("add: {}", updated_entry.path) } - Some((path, entry)) + Ok(updated_entry) }) - .collect::>() + .collect::>()?; + + for entry in result { + self.entries.insert(entry.path.clone(), entry); + } + + Ok(()) } - /// Returns the paths of tracked entries, optionally filtered by a list of paths. + /// Groups a list of files into two categories: new or modified. + /// + /// A file is considered **modified** if it's already in the index but its + /// modification time (`mtime`) or file size differs from the stored entry. + /// A file is considered **new** if it is not present in the index. /// /// # Arguments /// - /// * `paths` - Optional slice of paths. Only entries starting with one of these paths are returned. + /// * `files` - A vector of [`PathBuf`]s to analyze. /// /// # Returns /// - /// A vector of [`PathBuf`] representing the paths of tracked entries. - pub fn get_entry_paths(&self, paths: Option<&[PathBuf]>) -> Vec { + /// A tuple containing two vectors of [`IndexEntry`]s: `(new_files, modified_files)`. + fn group_files( + &self, + files: &Vec, + ) -> EngineResult<(Vec, Vec)> { + let mut new_files = Vec::::new(); + let mut modified_files = Vec::::new(); + + for file_path in files { + let new_entry = IndexEntry::from_path_without_hash(file_path)?; + + if let Some(old) = self.entries.get(&new_entry.path) { + // Heuristic check for modification to avoid re-hashing unchanged files. + let modified = FileChange::is_modified_heuristic( + new_entry.file_size, + &new_entry.mtime, + old.file_size, + &old.mtime, + ); + + if modified { + modified_files.push(new_entry); + } + } else { + new_files.push(new_entry); + } + } + + Ok((new_files, modified_files)) + } +} + +impl Index for MevaIndex { + fn get_entries(&self) -> Vec<&IndexEntry> { + self.entries.values().collect() + } + + fn get_entries_owned(&self) -> Vec { + self.entries.values().cloned().collect() + } + + fn get_entries_map(&self, paths: Option<&[PathBuf]>) -> HashMap { self.entries - .keys() - .filter_map(|key| { - let path = PathBuf::from(key); + .iter() + .filter_map(|(key, entry)| { + let path = PathBuf::from(&key); if let Some(allowed_paths) = paths && !allowed_paths.iter().any(|p| path.starts_with(p)) { return None; } - Some(path) + Some((path, entry)) }) - .collect() - } - - /// Inserts a collection of [`IndexEntry`] objects into the in-memory index. - /// - /// Existing entries with the same path are replaced. - /// - /// **Note:** This method updates only the in-memory cache of entries. - /// To persist these changes to disk, you must call [`MevaIndex::save`]. - /// - /// # Arguments - /// - /// * `entries` - An iterable collection of [`IndexEntry`] objects to insert. - pub fn insert_entries(&mut self, entries: I) - where - I: IntoIterator, - { - for entry in entries { - self.entries.insert(entry.path.clone(), entry); - } - } - - /// Removes entries from the in-memory index by their paths. - /// - /// **Note:** This method updates only the in-memory cache of entries. - /// To persist these changes to disk, you must call [`MevaIndex::save`]. - /// - /// # Arguments - /// - /// * `keys` - An iterable collection of paths (`String`) identifying which entries to remove. - pub fn remove_entries(&mut self, keys: I) - where - I: IntoIterator, - { - for key in keys { - self.entries.remove(&key); - } + .collect::>() } - /// Checks whether a given file or directory is tracked in the Meva index. - /// - /// # Returns - /// - `true` if the exact file is tracked. - /// - `true` for a directory if at least one tracked file exists inside it. - /// - /// The path may be absolute or relative. It will be normalized against - /// the repository’s working directory before comparison. - pub fn is_tracked(&self, path: &Path) -> EngineResult { + fn is_tracked(&self, path: &Path) -> EngineResult { let abs_path = path.absolutize()?; // Case 1: Exact match (file directly tracked) @@ -211,22 +256,14 @@ impl<'a> MevaIndex<'a> { Ok(tracked) } - /// Loads the index file from disk into memory, populating the `entries` map. - /// - /// If the file does not exist, it will be created. If it is empty, the in-memory index will be cleared. - /// - /// # Arguments - /// - /// * `with_absolute_keys` - If `true`, paths are converted from relative (on disk) to absolute (in memory). - /// Defaults to `true`. - pub fn load_from_disk(&mut self, with_absolute_keys: Option) -> EngineResult<()> { + fn load_from_disk(&mut self, with_absolute_keys: Option) -> EngineResult<()> { let with_absolute_keys = with_absolute_keys.unwrap_or(true); let mut file = OpenOptions::new() .write(true) .read(true) .create(true) .truncate(false) - .open(self.repo_layout.index_file())?; + .open(self.working_dir.layout().index_file())?; let mut data = String::new(); file.read_to_string(&mut data)?; @@ -242,7 +279,7 @@ impl<'a> MevaIndex<'a> { true => { self.entries = deserialize_entries_with_absolute_keys( deserializer, - self.repo_layout.working_dir(), + &self.working_dir.layout().working_dir(), ) .map_err(io::Error::other)?; } @@ -254,21 +291,14 @@ impl<'a> MevaIndex<'a> { Ok(()) } - /// Identifies tracked files that no longer exist in the working directory. - /// - /// It compares the list of files in the index with the actual files on the filesystem. - /// - /// # Returns - /// - /// A vector of [`IndexEntry`] references representing the deleted files. - pub fn get_deleted_files(&self) -> Vec<&IndexEntry> { - let dir_path = self.repo_layout.working_dir(); + fn get_deleted_files(&self) -> Vec<&IndexEntry> { + let dir_path = self.working_dir.layout().working_dir(); - let dir_files = self.working_dir.collect_files(dir_path, false); + let dir_files = self.working_dir.collect_files(&dir_path, false); let files_in_index = self .entries .iter() - .filter(|(key, _)| Path::new(&key).starts_with(dir_path)) + .filter(|(key, _)| Path::new(&key).starts_with(dir_path.clone())) .collect::>(); let workdir_set = dir_files @@ -283,22 +313,14 @@ impl<'a> MevaIndex<'a> { .collect::>() } - /// Scans the working directory for files that are **not** currently tracked in the index. - /// - /// This method respects the ignore rules defined in `.mevaignore` files, so ignored files - /// will not be reported as untracked unless they were explicitly tracked before. - /// - /// # Returns - /// - /// A vector of [`PathBuf`]s for each untracked file. - pub fn get_untracked_files(&self) -> Vec { - let dir_path = self.repo_layout.working_dir(); + fn get_untracked_files(&self) -> Vec { + let dir_path = self.working_dir.layout().working_dir(); let dir_files = self.working_dir.collect_all_files_including_ignored(); let files_in_index = self .entries .keys() - .filter(|k| Path::new(&k).starts_with(dir_path)) + .filter(|k| Path::new(&k).starts_with(&dir_path)) .cloned() .collect::>(); @@ -313,25 +335,9 @@ impl<'a> MevaIndex<'a> { .collect::>() } - /// Adds files to the index under a given path, respecting various flags. - /// - /// This is the core staging operation. It scans for files, categorizes them as new or modified, - /// handles deletions, updates hashes, and modifies the in-memory index. - /// - /// # Arguments - /// - /// * `path_abs` - The absolute path to a file or directory to add. - /// * `add_new` - If `true`, new (untracked) files will be added to the index. - /// * `add_deleted` - If `true`, files that are tracked but missing from the working directory will be removed. - /// * `add_ignored` - If `true`, ignore rules will be bypassed. - /// * `verbose` - If `true`, prints the name of each file being added or removed. - /// - /// # Returns - /// - /// A tuple containing vectors of paths for `(new_files, modified_files, deleted_files)`. - pub fn add( + fn add( &mut self, - path: &PathBuf, + path: &Path, add_new: bool, add_deleted: bool, add_ignored: bool, @@ -390,25 +396,15 @@ impl<'a> MevaIndex<'a> { Ok((new_files, modified_files, deleted_files)) } - /// Saves the in-memory index back to the index file on disk. - /// - /// Before writing, it converts all absolute paths in the entries into paths - /// relative to the repository's working directory. The data is then serialized - /// as a pretty-printed JSON array. - /// - /// # Errors - /// - /// - [`IndexError::IndexFileNotFound`]: if the index file does not exist. - /// - Fails if a path in the index lies outside the repository root. - /// - Fails if serialization or file writing fails. - pub fn save(&mut self) -> EngineResult<()> { - if !self.repo_layout.index_file().exists() { + fn save(&mut self) -> EngineResult<()> { + if !self.working_dir.layout().index_file().exists() { return Err(IndexError::IndexFileNotFound { - path: self.repo_layout.index_file(), + path: self.working_dir.layout().index_file(), } .into()); } - let meva_repository_dir = self.repo_layout.working_dir().absolutize()?; + let working_dir = self.working_dir.layout().working_dir(); + let meva_repository_dir = working_dir.absolutize()?; for entry in self.entries.values_mut() { let entry_absolute_path = Path::new(&entry.path); @@ -420,131 +416,27 @@ impl<'a> MevaIndex<'a> { let json = serde_json::to_string_pretty(&self.entries.values().collect::>())?; - fs::write(self.repo_layout.index_file(), json)?; + fs::write(self.working_dir.layout().index_file(), json)?; Ok(()) } - /// Removes entries from the index that correspond to files no longer present on the filesystem. - /// - /// # Arguments - /// - /// * `dir_path` - The directory path to check for deletions. - /// * `dir_files` - A slice of paths to files that are known to still exist. - /// * `verbose` - If `true`, prints the path of each removed file. - /// - /// # Returns - /// - /// A vector of strings containing the paths of the removed files. - fn remove_deleted_files( - &mut self, - dir_path: &PathBuf, - dir_files: &[PathBuf], - verbose: bool, - ) -> Vec { - let files_in_index = self - .entries - .keys() - .filter(|k| Path::new(&k).starts_with(dir_path)) - .cloned() - .collect::>(); - - let workdir_set = dir_files - .iter() - .map(|p| p.to_utf8_string()) - .collect::>(); - - let deleted_files = files_in_index - .into_iter() - .filter(|k| !workdir_set.contains(k)) - .collect::>(); - - for file in &deleted_files { - if verbose { - println!("remove: {file}") - } - self.entries.remove(file); - } - - deleted_files - } - - /// Updates or inserts a list of index entries, calculating their SHA-1 hashes and storing blobs. - /// - /// This function processes the provided entries in parallel to speed up file reading and hashing. - /// For each entry, it creates a [`MevaBlob`], adds it to the object store, gets the resulting hash, - /// and updates the entry. Finally, it inserts the updated entry into the in-memory index. - /// - /// # Arguments - /// - /// * `index_entries` - A mutable vector of [`IndexEntry`]s to process. - /// * `verbose` - If `true`, prints the path of each added file. - fn update_index( - &mut self, - index_entries: &mut Vec, - verbose: bool, - ) -> EngineResult<()> { - let object_storage = MevaObjectStorage::new(self.repo_layout); - - let result: Vec<_> = index_entries - .par_iter() - .map(|index_entry| { - let mut updated_entry = index_entry.clone(); - let blob = MevaBlob::from_file(index_entry.path.as_ref())?; - updated_entry.sha1 = object_storage.add_blob(blob)?; - if verbose { - println!("add: {}", updated_entry.path) - } - Ok(updated_entry) - }) - .collect::>()?; - - for entry in result { + fn insert_entries(&mut self, entries: Vec) { + for entry in entries { self.entries.insert(entry.path.clone(), entry); } - - Ok(()) } - /// Groups a list of files into two categories: new or modified. + /// Removes entries from the in-memory index by their paths. /// - /// A file is considered **modified** if it's already in the index but its - /// modification time (`mtime`) or file size differs from the stored entry. - /// A file is considered **new** if it is not present in the index. + /// **Note:** This method updates only the in-memory cache of entries. + /// To persist these changes to disk, you must call [`MevaIndex::save`]. /// /// # Arguments /// - /// * `files` - A vector of [`PathBuf`]s to analyze. - /// - /// # Returns - /// - /// A tuple containing two vectors of [`IndexEntry`]s: `(new_files, modified_files)`. - fn group_files( - &self, - files: &Vec, - ) -> EngineResult<(Vec, Vec)> { - let mut new_files = Vec::::new(); - let mut modified_files = Vec::::new(); - - for file_path in files { - let new_entry = IndexEntry::from_path_without_hash(file_path)?; - - if let Some(old) = self.entries.get(&new_entry.path) { - // Heuristic check for modification to avoid re-hashing unchanged files. - let modified = FileChange::is_modified_heuristic( - new_entry.file_size, - &new_entry.mtime, - old.file_size, - &old.mtime, - ); - - if modified { - modified_files.push(new_entry); - } - } else { - new_files.push(new_entry); - } + /// * `keys` - An iterable collection of paths (`String`) identifying which entries to remove. + fn remove_entries(&mut self, keys: Vec) { + for key in keys { + self.entries.remove(&key); } - - Ok((new_files, modified_files)) } } diff --git a/engine/src/index/working_dir.rs b/engine/src/index/working_dir.rs index 68fbc1c..359ca56 100644 --- a/engine/src/index/working_dir.rs +++ b/engine/src/index/working_dir.rs @@ -1,6 +1,7 @@ use std::{ collections::HashMap, path::{Path, PathBuf}, + sync::Arc, }; use chrono::{DateTime, Utc}; @@ -31,60 +32,16 @@ pub struct WorkingDirEntry { /// /// This struct is responsible for collecting files, applying ignore rules, /// and gathering file metadata from the filesystem. -pub struct WorkingDir<'a> { - /// Layout of the repository, providing paths to the working directory, index file, etc. - repo_layout: &'a dyn RepositoryLayout, - - /// A collection of `IgnoreService` instances, each corresponding to a found `.mevaignore` file. - ignore_services: Vec, -} - -impl<'a> WorkingDir<'a> { - /// Creates a new `WorkingDir` instance without initializing ignore services. - /// - /// # Arguments - /// - /// * `repo_layout` - A reference to the repository's layout. - pub fn new(repo_layout: &'a dyn RepositoryLayout) -> Self { - Self { - repo_layout, - ignore_services: Vec::new(), - } - } - - /// Creates a new `WorkingDir` and initializes its ignore services by scanning - /// the repository for `.mevaignore` files. - /// - /// # Arguments - /// - /// * `repo_layout` - A reference to the repository's layout. - pub fn with_ignore_services(repo_layout: &'a dyn RepositoryLayout) -> Self { - Self { - repo_layout, - ignore_services: Self::create_ignore_services(repo_layout), - } - } - +pub trait WorkingDir: Send + Sync { /// Returns a reference to the vector of initialized `IgnoreService` instances. - pub fn get_ignore_services(&self) -> &Vec { - &self.ignore_services - } + fn get_ignore_services(&self) -> &Vec; /// Returns the absolute path to the root of the working directory. - pub fn get_absolute_path(&self) -> &Path { - self.repo_layout.working_dir() - } + fn get_absolute_path(&self) -> PathBuf; /// Collects all files under the working directory, respecting ignore rules. /// This is a convenience method that delegates to `collect_files`. - #[allow(dead_code)] - pub fn collect_tracked_or_untracked_files(&self) -> Vec { - let path = self.repo_layout.working_dir(); - self.collect_files(path, false) - .into_iter() - .map(|(p, _)| p) - .collect() - } + fn collect_tracked_or_untracked_files(&self) -> Vec; /// Returns a collection of all files in the working directory, including those that /// would normally be ignored by `.mevaignore` rules. @@ -92,10 +49,7 @@ impl<'a> WorkingDir<'a> { /// This method returns a vector of tuples, where each tuple contains: /// - An absolute path to a file (`PathBuf`). /// - A boolean flag indicating whether the file is ignored (`true` means ignored). - pub fn collect_all_files_including_ignored(&self) -> Vec<(PathBuf, bool)> { - let path = self.repo_layout.working_dir(); - self.collect_files(path, true) - } + fn collect_all_files_including_ignored(&self) -> Vec<(PathBuf, bool)>; /// Recursively searches for files under the given path, optionally including ignored files. /// @@ -113,11 +67,7 @@ impl<'a> WorkingDir<'a> { /// A vector of tuples of type `(PathBuf, bool)`: /// - `PathBuf`: the absolute file path. /// - `bool`: whether the file is ignored (`true` if ignored). - pub fn collect_files(&self, path: &Path, include_ignored: bool) -> Vec<(PathBuf, bool)> { - self.get_filtered_entries(path, include_ignored) - .map(|(e, i)| (e.into_path(), i)) - .collect() - } + fn collect_files(&self, path: &Path, include_ignored: bool) -> Vec<(PathBuf, bool)>; /// Collects files from the working directory along with their metadata. /// @@ -134,41 +84,63 @@ impl<'a> WorkingDir<'a> { /// /// An [`EngineResult`] containing a [`HashMap`] that maps a file's relative path to its /// [`WorkingDirEntry`] metadata. - pub fn collect_files_with_metadata( + fn collect_files_with_metadata( &self, include_ignored: bool, paths: Option<&[PathBuf]>, - ) -> EngineResult> { - let base_path = self.repo_layout.working_dir(); - let mut result = HashMap::new(); + ) -> EngineResult>; - let entries = self.get_filtered_entries(base_path, include_ignored); + /// Checks if a given path is ignored by any of the active ignore services. + /// + /// # Returns + /// + /// `true` if the path matches an ignore pattern, `false` otherwise. + fn is_path_ignored(&self, path: &Path) -> bool; - for (entry, is_ignored) in entries { - let meta = entry.metadata()?; - let abs_path = entry.path(); - let rel_path = abs_path.strip_base(base_path).to_path_buf(); + /// Checks whether the given path exists within the working directory. + /// + /// This function returns `true` if: + /// - the path is inside the working directory, and + /// - it exists in the filesystem, and + /// - (optionally) it is not ignored by `.mevaignore` rules. + fn is_path_in_working_dir(&self, path: &Path, include_ignored: bool) -> EngineResult; - // If a path filter is provided, skip files not in the list. - if let Some(allowed_paths) = paths - && !allowed_paths.iter().any(|p| rel_path.starts_with(p)) - { - continue; - } + fn layout(&self) -> &Arc; +} - result.insert( - rel_path.clone(), - WorkingDirEntry { - path: rel_path, - mtime: meta.modified()?.into(), - file_size: meta.len(), - mode: FileMode::try_from(abs_path)?, - is_ignored, - }, - ); +pub struct MevaWorkingDir { + /// Layout of the repository, providing paths to the working directory, index file, etc. + repo_layout: Arc, + + /// A collection of [`IgnoreService`] instances, each corresponding to a found `.mevaignore` file. + ignore_services: Vec, +} + +impl MevaWorkingDir { + /// Creates a new [`WorkingDir`] instance without initializing ignore services. + /// + /// # Arguments + /// + /// * `repo_layout` - A reference to the repository's layout. + pub fn new(repo_layout: Arc) -> Self { + Self { + repo_layout, + ignore_services: Vec::new(), } + } - Ok(result) + /// Creates a new `WorkingDir` and initializes its ignore services by scanning + /// the repository for `.mevaignore` files. + /// + /// # Arguments + /// + /// * `repo_layout` - A reference to the repository's layout. + pub fn with_ignore_services(repo_layout: Arc) -> Self { + let ignore_services = Self::create_ignore_services(&repo_layout); + Self { + repo_layout, + ignore_services, + } } /// Creates an iterator over directory entries filtered by ignore rules. @@ -218,12 +190,99 @@ impl<'a> WorkingDir<'a> { }) } - /// Checks if a given path is ignored by any of the active ignore services. + /// Discovers all `.mevaignore` files within the repository and creates an `IgnoreService` for each. + /// + /// This function scans the entire working directory for files matching the ignore file name + /// and initializes a cached service for each one found. + /// + /// # Arguments + /// + /// * `repo_layout` - The repository layout, used to get the working directory path and ignore file name. /// /// # Returns /// - /// `true` if the path matches an ignore pattern, `false` otherwise. - pub fn is_path_ignored(&self, path: &Path) -> bool { + /// A vector of initialized [`IgnoreService`] instances. + fn create_ignore_services(repo_layout: &Arc) -> Vec { + let ignore_service = IgnoreService::new(repo_layout.ignore_file_name()); + let ignore_files_abs: Vec = + ignore_service.find_ignore_files_down(&repo_layout.working_dir()); + + ignore_files_abs + .iter() + .filter_map(|path| { + IgnoreService::with_cache(repo_layout.ignore_file_name(), Some(path)).ok() + }) + .collect() + } +} + +impl WorkingDir for MevaWorkingDir { + fn get_ignore_services(&self) -> &Vec { + &self.ignore_services + } + + fn get_absolute_path(&self) -> PathBuf { + self.repo_layout.working_dir() + } + + #[allow(dead_code)] + fn collect_tracked_or_untracked_files(&self) -> Vec { + let path = self.repo_layout.working_dir(); + self.collect_files(&path, false) + .into_iter() + .map(|(p, _)| p) + .collect() + } + + fn collect_all_files_including_ignored(&self) -> Vec<(PathBuf, bool)> { + let path = self.repo_layout.working_dir(); + self.collect_files(&path, true) + } + + fn collect_files(&self, path: &Path, include_ignored: bool) -> Vec<(PathBuf, bool)> { + self.get_filtered_entries(path, include_ignored) + .map(|(e, i)| (e.into_path(), i)) + .collect() + } + + fn collect_files_with_metadata( + &self, + include_ignored: bool, + paths: Option<&[PathBuf]>, + ) -> EngineResult> { + let base_path = self.repo_layout.working_dir(); + let mut result = HashMap::new(); + + let entries = self.get_filtered_entries(&base_path, include_ignored); + + for (entry, is_ignored) in entries { + let meta = entry.metadata()?; + let abs_path = entry.path(); + let rel_path = abs_path.strip_base(&base_path).to_path_buf(); + + // If a path filter is provided, skip files not in the list. + if let Some(allowed_paths) = paths + && !allowed_paths.iter().any(|p| rel_path.starts_with(p)) + { + continue; + } + + result.insert( + rel_path.clone(), + WorkingDirEntry { + path: rel_path, + mtime: meta.modified()?.into(), + file_size: meta.len(), + mode: FileMode::try_from(abs_path)?, + is_ignored, + }, + ); + } + + Ok(result) + } + + fn is_path_ignored(&self, path: &Path) -> bool { self.ignore_services.iter().any(|service| { matches!( service.check_cached(&path), @@ -232,13 +291,7 @@ impl<'a> WorkingDir<'a> { }) } - /// Checks whether the given path exists within the working directory. - /// - /// This function returns `true` if: - /// - the path is inside the working directory, and - /// - it exists in the filesystem, and - /// - (optionally) it is not ignored by `.mevaignore` rules. - pub fn is_path_in_working_dir(&self, path: &Path, include_ignored: bool) -> EngineResult { + fn is_path_in_working_dir(&self, path: &Path, include_ignored: bool) -> EngineResult { let abs_path = path.absolutize()?; if !abs_path @@ -259,28 +312,7 @@ impl<'a> WorkingDir<'a> { Ok(true) } - /// Discovers all `.mevaignore` files within the repository and creates an `IgnoreService` for each. - /// - /// This function scans the entire working directory for files matching the ignore file name - /// and initializes a cached service for each one found. - /// - /// # Arguments - /// - /// * `repo_layout` - The repository layout, used to get the working directory path and ignore file name. - /// - /// # Returns - /// - /// A vector of initialized [`IgnoreService`] instances. - fn create_ignore_services(repo_layout: &'a dyn RepositoryLayout) -> Vec { - let ignore_service = IgnoreService::new(repo_layout.ignore_file_name()); - let ignore_files_abs: Vec = - ignore_service.find_ignore_files_down(repo_layout.working_dir()); - - ignore_files_abs - .iter() - .filter_map(|path| { - IgnoreService::with_cache(repo_layout.ignore_file_name(), Some(path)).ok() - }) - .collect() + fn layout(&self) -> &Arc { + &self.repo_layout } } diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 928a3b6..72112d5 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -5,7 +5,7 @@ mod traversal; pub mod branch_manager; pub mod config; -pub mod diff; +pub mod diff_builder; pub mod engine_container; pub mod errors; pub mod handlers; @@ -22,8 +22,16 @@ use errors::{EngineError, EngineResult, InitError}; use object_storage::ObjectStorage; use ref_manager::RefManager; -pub use config::{ConfigDocument, ConfigLoader, ConfigLocation}; +pub use branch_manager::BranchManager; +pub use commit_builder::CommitBuilder; +pub use config::{ + ConfigDocument, ConfigLoader, ConfigLoaderExtensions, ConfigLocation, MevaConfigLoader, +}; +pub use diff_builder::DiffBuilder; pub use engine_container::EngineContainer; pub use hasher::Hasher; pub use ignore::{IgnoreOperations, IgnoreResult, IgnoreService}; -pub use repositories::{MevaRepository, MutableRepositoryLayout, RepositoryLayout}; +pub use index::{Index, working_dir::WorkingDir}; +pub use repositories::{MevaRepository, RepositoryLayout}; +pub use revision_parsing::RevisionResolver; +pub use traversal::CommitTreeWalker; diff --git a/engine/src/object_storage.rs b/engine/src/object_storage.rs index 505f86b..a771f40 100644 --- a/engine/src/object_storage.rs +++ b/engine/src/object_storage.rs @@ -1,11 +1,11 @@ pub mod meva_dry_run_object_storage; pub mod meva_object_storage; +use std::sync::Arc; + +use crate::RepositoryLayout; use crate::errors::EngineResult; -use crate::objects::meva_blob::MevaBlob; -use crate::objects::meva_commit::MevaCommit; -use crate::objects::meva_object::MevaObject; -use crate::objects::meva_tree::MevaTree; +use crate::objects::{MevaBlob, MevaCommit, MevaObject, MevaTree}; pub use meva_dry_run_object_storage::MevaDryRunObjectStorage; pub use meva_object_storage::MevaObjectStorage; @@ -27,7 +27,7 @@ pub use meva_object_storage::MevaObjectStorage; /// /// All methods return an [`EngineResult`] that may contain serialization /// or processing errors. -pub trait ObjectStorage { +pub trait ObjectStorage: Send + Sync { /// Processes a [`MevaBlob`] (raw file data) and returns its hash. fn add_blob(&self, blob: MevaBlob) -> EngineResult; @@ -39,4 +39,6 @@ pub trait ObjectStorage { /// Retrieves and deserializes a [`MevaObject`] from storage by its hash. fn get_object(&self, hash: &str) -> EngineResult; + + fn layout(&self) -> &Arc; } diff --git a/engine/src/object_storage/meva_dry_run_object_storage.rs b/engine/src/object_storage/meva_dry_run_object_storage.rs index 832f159..8f8e80e 100644 --- a/engine/src/object_storage/meva_dry_run_object_storage.rs +++ b/engine/src/object_storage/meva_dry_run_object_storage.rs @@ -1,6 +1,8 @@ -use crate::ObjectStorage; +use std::sync::Arc; + use crate::errors::EngineResult; use crate::objects::{MevaBlob, MevaCommit, MevaObject, MevaTree}; +use crate::{ObjectStorage, RepositoryLayout}; /// A no-op implementation of [`ObjectStorage`] that performs /// all transformations in memory without writing any data to disk. @@ -50,4 +52,8 @@ impl ObjectStorage for MevaDryRunObjectStorage { fn get_object(&self, _hash: &str) -> EngineResult { Ok(MevaObject::default()) } + + fn layout(&self) -> &Arc { + unimplemented!() + } } diff --git a/engine/src/object_storage/meva_object_storage.rs b/engine/src/object_storage/meva_object_storage.rs index 3f59b46..0e8f90c 100644 --- a/engine/src/object_storage/meva_object_storage.rs +++ b/engine/src/object_storage/meva_object_storage.rs @@ -6,6 +6,7 @@ use crate::serialize_deserialize::BinaryCompress; use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::path::PathBuf; +use std::sync::Arc; /// Provides low-level object storage for the Meva repository. /// @@ -23,18 +24,19 @@ use std::path::PathBuf; /// Objects are compressed before being written to disk for efficient storage. /// If an object with the same hash already exists, it is not overwritten, /// because identical hashes guarantee identical content. -pub struct MevaObjectStorage<'a> { +pub struct MevaObjectStorage { /// Provides access to repository paths such as the `objects` directory. - repo_layout: &'a dyn RepositoryLayout, + repo_layout: Arc, } -impl<'a> MevaObjectStorage<'a> { + +impl MevaObjectStorage { /// Creates a new [`MevaObjectStorage`] instance for the given repository layout. /// /// # Arguments /// /// * `repo_layout` – A [`RepositoryLayout`] that defines where objects /// should be stored on disk. - pub fn new(repo_layout: &'a dyn RepositoryLayout) -> Self { + pub fn new(repo_layout: Arc) -> Self { Self { repo_layout } } @@ -96,7 +98,7 @@ impl<'a> MevaObjectStorage<'a> { } } -impl<'a> ObjectStorage for MevaObjectStorage<'a> { +impl ObjectStorage for MevaObjectStorage { /// Converts a [`MevaBlob`] into a [`MevaObject`] and stores it on disk. /// /// Returns the SHA-1 hash of the stored object. @@ -137,4 +139,8 @@ impl<'a> ObjectStorage for MevaObjectStorage<'a> { MevaObject::from_compressed_bytes_with_context(&compressed, &context) } + + fn layout(&self) -> &Arc { + &self.repo_layout + } } diff --git a/engine/src/objects/meva_object.rs b/engine/src/objects/meva_object.rs index 2778ff7..1544e74 100644 --- a/engine/src/objects/meva_object.rs +++ b/engine/src/objects/meva_object.rs @@ -109,7 +109,7 @@ impl MevaObject { /// The object is first serialized into a binary representation via [`Self::to_bytes`]. /// The resulting bytes are then passed to the given `hasher` to compute the SHA-1 hash. /// - /// # Parameters + /// # Arguments /// /// * `hasher` - A reference to an object implementing [`Hasher`] used to perform /// the SHA-1 hashing. This allows for custom or mock hashers in testing. diff --git a/engine/src/plugins_interceptor.rs b/engine/src/plugins_interceptor.rs index c2a342a..c49df79 100644 --- a/engine/src/plugins_interceptor.rs +++ b/engine/src/plugins_interceptor.rs @@ -1,15 +1,15 @@ -use std::{env, fs, path::PathBuf}; +use std::{env, fs, path::PathBuf, sync::Arc}; use chrono::Utc; use plugins::{ CommandType, EventType, InvocationContext, InvocationInput, InvocationOutput, - InvocationPostPayload, InvocationPrePayload, PluginError, PluginsEngine, PluginsLayout, - PluginsRunner, ScopeType, + InvocationPostPayload, InvocationPrePayload, PluginError, PluginsEngine, PluginsRunner, + ScopeType, }; use shared::UpwardSearch; use crate::{ - ConfigLoader, RepositoryLayout, + ConfigLoader, ConfigLoaderExtensions, RepositoryLayout, errors::{EngineError, EngineResult, RepositoryError}, }; @@ -38,28 +38,27 @@ pub trait PluginsInvocationMapper { /// - Creating invocation contexts, /// - Running plugins in order for `PreExecute` and `PostExecute` events, /// - Handling errors, timeouts, and logging. -pub struct PluginsInterceptor -where - T: PluginsLayout, - E: RepositoryLayout, -{ +pub struct PluginsInterceptor { /// The engine responsible for managing plugin lifecycle and execution. - pub plugins_engine: PluginsEngine, + pub plugins_engine: Arc, - /// Layout abstraction to locate repository-specific paths. - pub repository_layout: E, + pub repository_layout: Arc, /// Configuration loader used for plugin-related settings. - pub config_loader: ConfigLoader, + pub config_loader: Arc, } -impl PluginsInterceptor { +impl PluginsInterceptor { /// Creates a new `PluginsInterceptor` with the provided plugin engine and repository layout. - pub fn new(plugins_engine: PluginsEngine, repository_layout: E) -> Self { + pub fn new( + plugins_engine: Arc, + repository_layout: Arc, + config_loader: Arc, + ) -> Self { Self { plugins_engine, repository_layout, - config_loader: ConfigLoader::default(), + config_loader, } } diff --git a/engine/src/ref_manager.rs b/engine/src/ref_manager.rs index b76cd8b..f08831b 100644 --- a/engine/src/ref_manager.rs +++ b/engine/src/ref_manager.rs @@ -17,7 +17,7 @@ pub use ref_entry::RefEntry; /// the `HEAD` reference and named references. /// /// All methods return an [`EngineResult`] to handle potential I/O or parsing errors. -pub trait RefManager { +pub trait RefManager: Send + Sync { /// Reads the current [`Head`] reference, which indicates the active branch /// or commit the repository is currently pointing to. fn read_head(&self) -> EngineResult; diff --git a/engine/src/ref_manager/meva_ref_manager.rs b/engine/src/ref_manager/meva_ref_manager.rs index e1dbe05..1f2a324 100644 --- a/engine/src/ref_manager/meva_ref_manager.rs +++ b/engine/src/ref_manager/meva_ref_manager.rs @@ -6,6 +6,7 @@ use std::fs; use std::fs::File; use std::io::{Read, Write}; use std::path::Path; +use std::sync::Arc; /// Manages repository references (`HEAD`, branches, and tags) for the Meva repository. /// @@ -17,14 +18,14 @@ use std::path::Path; /// /// Directory structure and file paths are derived from the provided /// [`RepositoryLayout`]. -pub struct MevaRefManager<'a> { +pub struct MevaRefManager { /// Provides access to repository paths such as `.meva/HEAD` and `refs/`. - repo_layout: &'a dyn RepositoryLayout, + repo_layout: Arc, } -impl<'a> MevaRefManager<'a> { +impl MevaRefManager { /// Creates a new reference manager instance for a given repository layout. - pub fn new(repo_layout: &'a dyn RepositoryLayout) -> Self { + pub fn new(repo_layout: Arc) -> Self { Self { repo_layout } } @@ -44,7 +45,7 @@ impl<'a> MevaRefManager<'a> { } } -impl RefManager for MevaRefManager<'_> { +impl RefManager for MevaRefManager { /// Reads the repository’s `HEAD` file and deserializes it into a [`Head`] structure. fn read_head(&self) -> EngineResult { let content = Self::read_file(&self.repo_layout.head_file())?; diff --git a/engine/src/repositories.rs b/engine/src/repositories.rs index 5a6b5c3..153583e 100644 --- a/engine/src/repositories.rs +++ b/engine/src/repositories.rs @@ -3,5 +3,4 @@ pub mod meva_repository_layout; pub mod repository_layout; pub use meva_repository::MevaRepository; -pub use repository_layout::MutableRepositoryLayout; pub use repository_layout::RepositoryLayout; diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index 58c2dc8..dba749f 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -2,66 +2,49 @@ use std::{ fs, io::Write, path::{Path, PathBuf}, + sync::Arc, }; use tempfile::TempDir; -use crate::ref_manager::{Head, HeadMode}; use crate::repositories::RepositoryLayout; use crate::serialize_deserialize::MevaEncode; -use crate::{ConfigLoader, EngineError, EngineResult, InitError}; +use crate::{EngineError, EngineResult, InitError}; +use crate::{ + config::config_loader::ConfigLoader, + ref_manager::{Head, HeadMode}, +}; use shared::fs::create_file_with_dirs; +pub trait Repository: Send + Sync { + /// Initializes a new repository with the given initial branch. + /// + /// Creates required directories and files under `.meva/`. + /// Returns an error if the repository is already initialized + /// or the working directory is invalid. + fn init(&self, initial_branch: &str) -> EngineResult; + + fn layout(&self) -> &Arc; +} + /// Represents a Meva repository on disk. /// /// Provides functionality to initialize repository layout and manage /// directory structure and configuration files. pub struct MevaRepository { - pub layout: Box, - pub config_loader: ConfigLoader, + pub layout: Arc, + pub config_loader: Arc, } impl MevaRepository { /// Creates a new `MevaRepository` for the given working directory. - pub fn new(layout: Box, config_loader: ConfigLoader) -> Self { + pub fn new(layout: Arc, config_loader: Arc) -> Self { Self { layout, config_loader, } } - /// Initializes a new repository with the given initial branch. - /// - /// Creates required directories and files under `.meva/`. - /// Returns an error if the repository is already initialized - /// or the working directory is invalid. - pub fn init(&self, initial_branch: &str) -> EngineResult { - self.config_loader.create_global_config()?; - - let repository_dir = self.layout.repository_dir(); - - if repository_dir.exists() { - return Err(InitError::AlreadyInitialized { - path: repository_dir.to_string_lossy().into(), - } - .into()); - } - - let tmp_parent = &self.layout.working_dir(); - let tmp_dir = TempDir::new_in(tmp_parent).map_err(EngineError::Io)?; - - let tmp_repo = tmp_dir.path().join(self.layout.repository_dir_name()); - - self.create_dirs_at(&tmp_dir)?; - self.create_files_at(&tmp_dir, initial_branch)?; - - let final_repo = self.layout.repository_dir(); - - fs::rename(&tmp_repo, &final_repo).map_err(EngineError::Io)?; - - Ok(final_repo) - } - /// Creates the internal repository directories. fn create_dirs_at>(&self, root: P) -> EngineResult<()> { fs::create_dir_all(root.as_ref().join(self.layout.objects_dir_rel()))?; @@ -110,8 +93,42 @@ impl MevaRepository { } } +impl Repository for MevaRepository { + fn init(&self, initial_branch: &str) -> EngineResult { + self.config_loader.create_global_config()?; + + let repository_dir = self.layout.repository_dir(); + + if repository_dir.exists() { + return Err(InitError::AlreadyInitialized { + path: repository_dir.to_string_lossy().into(), + } + .into()); + } + + let tmp_parent = &self.layout.working_dir(); + let tmp_dir = TempDir::new_in(tmp_parent).map_err(EngineError::Io)?; + + let tmp_repo = tmp_dir.path().join(self.layout.repository_dir_name()); + + self.create_dirs_at(&tmp_dir)?; + self.create_files_at(&tmp_dir, initial_branch)?; + + let final_repo = self.layout.repository_dir(); + + fs::rename(&tmp_repo, &final_repo).map_err(EngineError::Io)?; + + Ok(final_repo) + } + + fn layout(&self) -> &Arc { + &self.layout + } +} + #[cfg(test)] mod tests { + use crate::MevaConfigLoader; use crate::repositories::meva_repository_layout::MevaRepositoryLayout; use super::*; @@ -131,11 +148,10 @@ mod tests { } fn get_repo(path: &Path) -> MevaRepository { - let layout = Box::new(MevaRepositoryLayout { - working_dir: path.to_path_buf(), - }); - - MevaRepository::new(layout, ConfigLoader::default()) + let layout = + Arc::new(MevaRepositoryLayout::new(path.to_path_buf()).expect("should not fail")); + let config_loader = Arc::new(MevaConfigLoader::default()); + MevaRepository::new(layout, config_loader) } #[rstest] diff --git a/engine/src/repositories/meva_repository_layout.rs b/engine/src/repositories/meva_repository_layout.rs index 9cbb6d8..82cda5b 100644 --- a/engine/src/repositories/meva_repository_layout.rs +++ b/engine/src/repositories/meva_repository_layout.rs @@ -1,10 +1,11 @@ +use crate::RepositoryLayout; use crate::errors::{EngineResult, InitError, PathError, RepositoryError}; -use crate::{MutableRepositoryLayout, RepositoryLayout}; use shared::{PathToString, UpwardSearch}; +use std::sync::RwLock; use std::{env, path::PathBuf}; pub struct MevaRepositoryLayout { - pub working_dir: PathBuf, + pub working_dir: RwLock, } impl MevaRepositoryLayout { @@ -21,14 +22,16 @@ impl MevaRepositoryLayout { } .into()); } - Ok(Self { working_dir }) + Ok(Self { + working_dir: RwLock::new(working_dir), + }) } /// Creates a `MevaRepositoryLayout` from the current working directory /// of the running process. pub fn from_env() -> EngineResult { Ok(Self { - working_dir: env::current_dir()?, + working_dir: RwLock::new(env::current_dir()?), }) } @@ -105,13 +108,12 @@ impl RepositoryLayout for MevaRepositoryLayout { } /// Returns the repository’s working directory path. - fn working_dir(&self) -> &std::path::Path { - &self.working_dir + fn working_dir(&self) -> PathBuf { + self.working_dir.read().unwrap().clone() } -} -impl MutableRepositoryLayout for MevaRepositoryLayout { - fn set_working_dir(&mut self, new_working_dir: PathBuf) { - self.working_dir = new_working_dir; + fn set_working_dir(&self, new_working_dir: PathBuf) { + let mut w = self.working_dir.write().unwrap(); + *w = new_working_dir; } } diff --git a/engine/src/repositories/repository_layout.rs b/engine/src/repositories/repository_layout.rs index 9dbda92..5d923a8 100644 --- a/engine/src/repositories/repository_layout.rs +++ b/engine/src/repositories/repository_layout.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::PathBuf; /// Defines the directory layout of a Meva repository. /// @@ -40,7 +40,10 @@ pub trait RepositoryLayout: Send + Sync { fn index_file_name(&self) -> &str; /// Returns the working directory where the repository is located. - fn working_dir(&self) -> &Path; + fn working_dir(&self) -> PathBuf; + + /// Sets a new working directory for the repository. + fn set_working_dir(&self, new_working_dir: PathBuf); // --- relative paths --- @@ -157,26 +160,22 @@ pub trait RepositoryLayout: Send + Sync { } } -/// Extension of [`RepositoryLayout`] that allows **mutating** the -/// repository’s working directory. -pub trait MutableRepositoryLayout: RepositoryLayout { - fn set_working_dir(&mut self, new_working_dir: PathBuf); -} - #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; use rstest::rstest; - use std::path::PathBuf; + use std::{path::PathBuf, sync::RwLock}; struct TestRepoLayout { - dir: PathBuf, + dir: RwLock, } impl TestRepoLayout { fn new>(path: P) -> Self { - Self { dir: path.into() } + Self { + dir: RwLock::new(path.into()), + } } } @@ -225,14 +224,13 @@ mod tests { "index" } - fn working_dir(&self) -> &Path { - &self.dir + fn working_dir(&self) -> PathBuf { + self.dir.read().unwrap().clone() } - } - impl MutableRepositoryLayout for TestRepoLayout { - fn set_working_dir(&mut self, new_working_dir: PathBuf) { - self.dir = new_working_dir; + fn set_working_dir(&self, new_working_dir: PathBuf) { + let mut w = self.dir.write().unwrap(); + *w = new_working_dir; } } diff --git a/engine/src/revision_parsing.rs b/engine/src/revision_parsing.rs index f4cff94..83b4208 100644 --- a/engine/src/revision_parsing.rs +++ b/engine/src/revision_parsing.rs @@ -1,8 +1,13 @@ mod base_reference; +mod meva_revision_resolver; mod revision; mod revision_modifier; mod revision_parse_error; mod revision_resolver; +use base_reference::BaseReference; +use revision_modifier::RevisionModifier; + +pub use meva_revision_resolver::MevaRevisionResolver; pub use revision::Revision; pub use revision_resolver::RevisionResolver; diff --git a/engine/src/revision_parsing/meva_revision_resolver.rs b/engine/src/revision_parsing/meva_revision_resolver.rs new file mode 100644 index 0000000..765ae83 --- /dev/null +++ b/engine/src/revision_parsing/meva_revision_resolver.rs @@ -0,0 +1,121 @@ +use crate::errors::{EngineResult, RevisionError}; +use crate::objects::{MevaCommit, MevaObject}; +use crate::revision_parsing::{BaseReference, Revision, RevisionModifier}; +use crate::{ObjectStorage, RefManager, RevisionResolver}; +use std::sync::Arc; + +/// Resolves revisions (like `HEAD`, commit hashes, branches, or modifiers) into commit hashes. +/// +/// This struct provides methods to interpret a [`Revision`] and apply +/// modifiers such as `^N` (nth parent) or `~N` (nth ancestor) to compute +/// the final commit hash. +pub struct MevaRevisionResolver { + object_storage: Arc, + ref_manager: Arc, +} + +impl MevaRevisionResolver { + /// Creates a new [`MevaRevisionResolver`] instance. + /// + /// # Arguments + /// + /// * `object_storage` — Backend used to validate resolved hashes + /// and access commit objects or verify that referenced commits exist. + /// * `ref_manager` — Reference manager used for resolving symbolic names + /// such as branches, tags, `HEAD`, or the currently checked-out commit. + pub fn new(object_storage: Arc, ref_manager: Arc) -> Self { + Self { + object_storage, + ref_manager, + } + } + + /// Applies modifiers (`Parent`, `Ancestor`) to a base commit hash. + /// + /// This is used internally by `resolve_hash` to traverse the commit graph. + fn resolve_modifiers( + &self, + hash: &str, + modifiers: &[RevisionModifier], + ) -> EngineResult { + let mut hash = hash.to_owned(); + + for modifier in modifiers { + match modifier { + RevisionModifier::Parent(val) => { + let object = self.object_storage.get_object(&hash)?; + let commit = MevaCommit::try_from(object)?; + hash = commit.parents.get(val - 1).cloned().ok_or( + RevisionError::ParentOutOfRange { + commit: hash, + index: val - 1, + }, + )?; + } + RevisionModifier::Ancestor(val) => { + for _ in 0..*val as u32 { + let object = self.object_storage.get_object(&hash)?; + let commit = MevaCommit::try_from(object)?; + hash = commit.parents.first().cloned().ok_or( + RevisionError::ParentOutOfRange { + commit: hash, + index: 0, + }, + )?; + } + } + } + } + + Ok(hash) + } + + /// Resolves the base reference of a revision to a commit hash. + /// + /// Handles [`BaseReference::Hash`], [`BaseReference::Head`], and [`BaseReference::Ref`]. + fn resolve_base(&self, base: &BaseReference) -> EngineResult { + let layout = self.object_storage.layout(); + let res: String = match base { + BaseReference::Hash(val) => val.clone(), + BaseReference::Head => { + let hash = self.ref_manager.resolve_head()?; + hash.ok_or(RevisionError::HeadNotFound)? + } + BaseReference::Ref(ref_val) => { + let refs = vec![ + self.ref_manager.read_ref(ref_val), + self.ref_manager.read_ref(&format!( + "{}/{}/{ref_val}", + layout.refs_dir_name(), + layout.heads_dir_name(), + )), + self.ref_manager.read_ref(&format!( + "{}/{}/{ref_val}", + layout.refs_dir_name(), + layout.remotes_dir_name() + )), + ]; + + let entry = refs + .into_iter() + .find_map(|r| r.ok().flatten()) + .ok_or_else(|| RevisionError::RevisionNotFound(ref_val.to_string()))?; + + entry.commit_hash + } + }; + Ok(res) + } +} + +impl RevisionResolver for MevaRevisionResolver { + fn resolve_hash(&self, revision: &Revision) -> EngineResult { + let base_hash = self.resolve_base(&revision.base)?; + self.resolve_modifiers(&base_hash, &revision.modifiers) + } + + fn resolve_object(&self, revision: &Revision) -> EngineResult { + let hash = self.resolve_hash(revision)?; + self.object_storage.get_object(&hash) + } +} diff --git a/engine/src/revision_parsing/revision_resolver.rs b/engine/src/revision_parsing/revision_resolver.rs index 916052a..a171888 100644 --- a/engine/src/revision_parsing/revision_resolver.rs +++ b/engine/src/revision_parsing/revision_resolver.rs @@ -1,135 +1,27 @@ -use super::{Revision, base_reference::BaseReference, revision_modifier::RevisionModifier}; -use crate::RepositoryLayout; -use crate::errors::{EngineResult, RevisionError}; -use crate::object_storage::{MevaObjectStorage, ObjectStorage}; -use crate::objects::{MevaCommit, MevaObject}; -use crate::ref_manager::{MevaRefManager, RefManager}; - -/// Resolves revisions (like `HEAD`, commit hashes, branches, or modifiers) into commit hashes. -/// -/// This struct provides methods to interpret a `Revision` and apply -/// modifiers such as `^N` (nth parent) or `~N` (nth ancestor) to compute -/// the final commit hash. -pub struct RevisionResolver<'a> { - repo_layout: &'a dyn RepositoryLayout, - object_storage: MevaObjectStorage<'a>, - ref_manager: MevaRefManager<'a>, -} - -impl<'a> RevisionResolver<'a> { - /// Creates a new `RevisionResolver` for the given repository layout. - /// - /// # Parameters - /// - `repo_layout`: a reference to an object implementing [`RepositoryLayout`]. - pub fn new(repo_layout: &'a dyn RepositoryLayout) -> Self { - Self { - repo_layout, - object_storage: MevaObjectStorage::new(repo_layout), - ref_manager: MevaRefManager::new(repo_layout), - } - } +use super::Revision; +use crate::errors::EngineResult; +use crate::objects::MevaObject; +pub trait RevisionResolver: Send + Sync { /// Resolves a revision to its commit hash. /// - /// # Parameters + /// # Arguments /// - `revision`: The revision to resolve. /// /// # Returns /// - `Ok(String)` containing the resolved commit hash. /// - `Err(RevisionError)` if the revision cannot be resolved, /// for example if a ref or `HEAD` does not exist, or a parent index is out of range. - pub fn resolve_hash(&self, revision: &Revision) -> EngineResult { - let base_hash = self.resolve_base(&revision.base)?; - self.resolve_modifiers(&base_hash, &revision.modifiers) - } + fn resolve_hash(&self, revision: &Revision) -> EngineResult; - /// Resolves a revision to the corresponding repository object (`MevaObject`). + /// Resolves a revision to the corresponding repository object ([`MevaObject`]). /// - /// # Parameters + /// # Arguments /// - `revision`: The revision to resolve. /// /// # Returns /// - `Ok(MevaObject)` containing the object the revision points to. /// - `Err(RevisionError)` if the revision cannot be resolved /// or if retrieving the object fails. - pub fn resolve_object(&self, revision: &Revision) -> EngineResult { - let hash = self.resolve_hash(revision)?; - self.object_storage.get_object(&hash) - } - - /// Applies modifiers (`Parent`, `Ancestor`) to a base commit hash. - /// - /// This is used internally by `resolve_hash` to traverse the commit graph. - fn resolve_modifiers( - &self, - hash: &str, - modifiers: &[RevisionModifier], - ) -> EngineResult { - let mut hash = hash.to_owned(); - - for modifier in modifiers { - match modifier { - RevisionModifier::Parent(val) => { - let object = self.object_storage.get_object(&hash)?; - let commit = MevaCommit::try_from(object)?; - hash = commit.parents.get(val - 1).cloned().ok_or( - RevisionError::ParentOutOfRange { - commit: hash, - index: val - 1, - }, - )?; - } - RevisionModifier::Ancestor(val) => { - for _ in 0..*val as u32 { - let object = self.object_storage.get_object(&hash)?; - let commit = MevaCommit::try_from(object)?; - hash = commit.parents.first().cloned().ok_or( - RevisionError::ParentOutOfRange { - commit: hash, - index: 0, - }, - )?; - } - } - } - } - - Ok(hash) - } - - /// Resolves the base reference of a revision to a commit hash. - /// - /// Handles `BaseReference::Hash`, `BaseReference::Head`, and `BaseReference::Ref`. - fn resolve_base(&self, base: &BaseReference) -> EngineResult { - let res: String = match base { - BaseReference::Hash(val) => val.clone(), - BaseReference::Head => { - let hash = self.ref_manager.resolve_head()?; - hash.ok_or(RevisionError::HeadNotFound)? - } - BaseReference::Ref(ref_val) => { - let refs = vec![ - self.ref_manager.read_ref(ref_val), - self.ref_manager.read_ref(&format!( - "{}/{}/{ref_val}", - self.repo_layout.refs_dir_name(), - self.repo_layout.heads_dir_name(), - )), - self.ref_manager.read_ref(&format!( - "{}/{}/{ref_val}", - self.repo_layout.refs_dir_name(), - self.repo_layout.remotes_dir_name() - )), - ]; - - let entry = refs - .into_iter() - .find_map(|r| r.ok().flatten()) - .ok_or_else(|| RevisionError::RevisionNotFound(ref_val.to_string()))?; - - entry.commit_hash - } - }; - Ok(res) - } + fn resolve_object(&self, revision: &Revision) -> EngineResult; } diff --git a/engine/src/traversal/meva_commit_tree_walker.rs b/engine/src/traversal/meva_commit_tree_walker.rs index 3eb49aa..e0d618c 100644 --- a/engine/src/traversal/meva_commit_tree_walker.rs +++ b/engine/src/traversal/meva_commit_tree_walker.rs @@ -3,6 +3,7 @@ use crate::errors::EngineResult; use crate::object_storage::ObjectStorage; use crate::objects::{MevaBlob, MevaCommit, MevaTree, ObjectEntry, TreeEntry, TreeEntryType}; use std::path::{Path, PathBuf}; +use std::sync::Arc; /// A concrete implementation of [`CommitTreeWalker`] for traversing commits /// stored in a local Meva object database. @@ -13,18 +14,18 @@ use std::path::{Path, PathBuf}; /// /// It operates entirely using the provided [`ObjectStorage`] backend, which /// abstracts access to commit, tree, and blob objects. -pub struct MevaCommitTreeWalker<'a> { +pub struct MevaCommitTreeWalker { /// The object storage backend providing access to commit, tree, and blob objects. - object_storage: Box, + object_storage: Arc, } -impl<'a> MevaCommitTreeWalker<'a> { +impl MevaCommitTreeWalker { /// Creates a new [`MevaCommitTreeWalker`] backed by the given object storage. /// /// # Arguments /// /// * `object_storage` - A boxed [`ObjectStorage`] implementation used to fetch objects. - pub fn new(object_storage: Box) -> Self { + pub fn new(object_storage: Arc) -> Self { Self { object_storage } } @@ -141,7 +142,7 @@ impl<'a> MevaCommitTreeWalker<'a> { } } -impl<'a> CommitTreeWalker for MevaCommitTreeWalker<'a> { +impl CommitTreeWalker for MevaCommitTreeWalker { /// Walks the tree structure of a commit, returning all entries that match /// the traversal rules. /// diff --git a/plugins/src/layout.rs b/plugins/src/layout.rs index 7da037b..689fa1c 100644 --- a/plugins/src/layout.rs +++ b/plugins/src/layout.rs @@ -1,7 +1,7 @@ use crate::EventType; /// Defines the file and directory layout for plugin management. -pub trait PluginsLayout { +pub trait PluginsLayout: Send + Sync { /// Returns the name of the directory where plugin invocation records are stored. fn invocations_dir_name(&self) -> &'static str; diff --git a/plugins/src/lib.rs b/plugins/src/lib.rs index 888d405..2769161 100644 --- a/plugins/src/lib.rs +++ b/plugins/src/lib.rs @@ -12,7 +12,7 @@ pub use enums::{CommandType, EventType, InvocationPostPayload, InvocationPrePayl pub use errors::PluginError; pub use layout::{MevaPluginsLayout, PluginsLayout}; pub use models::*; -pub use plugins_discovery::PluginsDiscovery; -pub use plugins_engine::PluginsEngine; +pub use plugins_discovery::MevaPluginsDiscovery; +pub use plugins_engine::{MevaPluginsEngine, PluginsEngine}; pub use plugins_repository::PluginsRepository; pub use plugins_runner::PluginsRunner; diff --git a/plugins/src/plugins_discovery.rs b/plugins/src/plugins_discovery.rs index 433ccb0..07f7664 100644 --- a/plugins/src/plugins_discovery.rs +++ b/plugins/src/plugins_discovery.rs @@ -2,29 +2,56 @@ use std::{ fs, io::ErrorKind, path::{Path, PathBuf}, + sync::Arc, }; use crate::{ CommandType, PluginError, PluginsLayout, errors::PluginResult, models::PluginsConfiguration, }; +pub trait PluginsDiscovery: Send + Sync { + /// Retrieves plugins for a specific command, returning local and global plugins separately. + fn get_plugins_for_command( + &self, + local_plugins_dir: &Option, + global_plugins_dir: &Path, + command: &CommandType, + ) -> PluginResult<(PluginsConfiguration, PluginsConfiguration)>; + + /// Loads plugins from a given plugins JSON file. + fn get_plugins(&self, plugins_file: &Path) -> PluginResult<(PluginsConfiguration, bool)>; + + /// Returns the directory path for a specific command, creating it if necessary. + fn get_command_dir(&self, command: &CommandType, plugins_dir: &Path) -> PluginResult; + + /// Returns the command directory, plugins file path, and loaded plugins for a command. + fn get_command_dir_and_plugins( + &self, + command: &CommandType, + plugins_dir: &Path, + ) -> PluginResult<(PathBuf, PathBuf, PluginsConfiguration)>; + + fn layout(&self) -> &Arc; +} + /// Responsible for discovering and loading plugins from local and global directories. /// /// `PluginsDiscovery` uses a layout (`PluginsLayout`) to locate plugin files and directories, /// read their configuration, and provide structured plugin lists for a given command. -pub struct PluginsDiscovery { +pub struct MevaPluginsDiscovery { /// Layout defining standard plugin directory and file names. - pub layout: T, + pub layout: Arc, } -impl PluginsDiscovery { +impl MevaPluginsDiscovery { /// Creates a new `PluginsDiscovery` instance with the given layout. - pub fn new(layout: T) -> Self { + pub fn new(layout: Arc) -> Self { Self { layout } } +} - /// Retrieves plugins for a specific command, returning local and global plugins separately. - pub fn get_plugins_for_command( +impl PluginsDiscovery for MevaPluginsDiscovery { + fn get_plugins_for_command( &self, local_plugins_dir: &Option, global_plugins_dir: &Path, @@ -47,8 +74,7 @@ impl PluginsDiscovery { Ok((local_plugins, global_plugins)) } - /// Loads plugins from a given plugins JSON file. - pub fn get_plugins(&self, plugins_file: &Path) -> PluginResult<(PluginsConfiguration, bool)> { + fn get_plugins(&self, plugins_file: &Path) -> PluginResult<(PluginsConfiguration, bool)> { match fs::read_to_string(plugins_file) { Ok(content) => Ok((PluginsConfiguration::from_json(&content)?, true)), Err(ref err) if err.kind() == ErrorKind::NotFound => { @@ -58,12 +84,7 @@ impl PluginsDiscovery { } } - /// Returns the directory path for a specific command, creating it if necessary. - pub fn get_command_dir( - &self, - command: &CommandType, - plugins_dir: &Path, - ) -> PluginResult { + fn get_command_dir(&self, command: &CommandType, plugins_dir: &Path) -> PluginResult { let command_str = command.to_string(); let command_dir = plugins_dir.join(command_str); @@ -72,8 +93,7 @@ impl PluginsDiscovery { Ok(command_dir) } - /// Returns the command directory, plugins file path, and loaded plugins for a command. - pub fn get_command_dir_and_plugins( + fn get_command_dir_and_plugins( &self, command: &CommandType, plugins_dir: &Path, @@ -83,4 +103,8 @@ impl PluginsDiscovery { let (plugins, _) = self.get_plugins(&plugins_file)?; Ok((command_dir, plugins_file, plugins)) } + + fn layout(&self) -> &Arc { + &self.layout + } } diff --git a/plugins/src/plugins_engine.rs b/plugins/src/plugins_engine.rs index b5fcf75..85b4f21 100644 --- a/plugins/src/plugins_engine.rs +++ b/plugins/src/plugins_engine.rs @@ -2,28 +2,67 @@ use std::{ fs::{self}, io::Write, path::{Path, PathBuf}, + sync::Arc, }; use chrono::{DateTime, Utc}; use shared::fs::create_file_with_dirs; use crate::{ - CommandType, EventType, PluginsConfiguration, PluginsLayout, PluginsRepository, + CommandType, EventType, PluginsConfiguration, PluginsRepository, errors::{ConfigurationError, PluginError, PluginResult}, models::{InvocationInput, InvocationLogger, PluginEntry}, plugins_discovery::PluginsDiscovery, }; use crate::{errors::RegisterError, models::PluginConfiguration}; +pub trait PluginsEngine { + /// Retrieves plugins configured for a specific command, separating local and global plugins. + fn get_plugins_for_command( + &self, + local_plugins_dir: &Option, + global_plugins_dir: &Path, + command: &CommandType, + ) -> PluginResult<(PluginsConfiguration, PluginsConfiguration)>; + + /// Creates a directory for a plugin invocation, structured by timestamp and command. + fn create_invocation_dir( + &self, + timestamp: &DateTime, + plugins_dir: &Path, + command: &CommandType, + ) -> PluginResult; + + /// Creates the invocation JSON file to pass to a plugin. + fn create_invocation_file( + &self, + invocation: &InvocationInput, + invocation_dir: &Path, + ) -> PluginResult; + + /// Creates a logger for capturing stdout, stderr, and invocation logs. + fn create_logger( + &self, + invocation_dir: &Path, + log_to_files: bool, + ) -> PluginResult; +} + /// Core engine responsible for managing plugins and their invocations -pub struct PluginsEngine { +pub struct MevaPluginsEngine { /// Discovery component used to locate plugins in local and global directories. - pub discovery: PluginsDiscovery, + pub discovery: Arc, } -impl PluginsEngine { - /// Retrieves plugins configured for a specific command, separating local and global plugins. - pub fn get_plugins_for_command( +impl MevaPluginsEngine { + /// Formats a timestamp into a string suitable for directory or file names. + fn formatted_timestamp(&self, timestamp: &DateTime) -> String { + timestamp.format("%Y%m%d-%H%M%S").to_string() + } +} + +impl PluginsEngine for MevaPluginsEngine { + fn get_plugins_for_command( &self, local_plugins_dir: &Option, global_plugins_dir: &Path, @@ -33,8 +72,7 @@ impl PluginsEngine { .get_plugins_for_command(local_plugins_dir, global_plugins_dir, command) } - // Creates a directory for a plugin invocation, structured by timestamp and command. - pub fn create_invocation_dir( + fn create_invocation_dir( &self, timestamp: &DateTime, plugins_dir: &Path, @@ -42,7 +80,7 @@ impl PluginsEngine { ) -> PluginResult { let formatted_timestamp = self.formatted_timestamp(timestamp); let invocation_dir = plugins_dir - .join(self.discovery.layout.invocations_dir_name()) + .join(self.discovery.layout().invocations_dir_name()) .join(command.to_string()) .join(formatted_timestamp); @@ -51,17 +89,16 @@ impl PluginsEngine { Ok(invocation_dir) } - /// Creates the invocation JSON file to pass to a plugin. - pub fn create_invocation_file( + fn create_invocation_file( &self, invocation: &InvocationInput, - invocation_dir: &PathBuf, + invocation_dir: &Path, ) -> PluginResult { fs::create_dir_all(invocation_dir)?; let context_file = invocation_dir.join( self.discovery - .layout + .layout() .context_file_name(&invocation.context.event), ); let mut context = fs::File::create(&context_file)?; @@ -71,17 +108,16 @@ impl PluginsEngine { Ok(context_file) } - /// Creates a logger for capturing stdout, stderr, and invocation logs. - pub fn create_logger( + fn create_logger( &self, invocation_dir: &Path, log_to_files: bool, ) -> PluginResult { let logger = if log_to_files { InvocationLogger::from_paths( - &invocation_dir.join(self.discovery.layout.stdout_log_name()), - &invocation_dir.join(self.discovery.layout.stderr_log_name()), - &invocation_dir.join(self.discovery.layout.invocation_log_name()), + &invocation_dir.join(self.discovery.layout().stdout_log_name()), + &invocation_dir.join(self.discovery.layout().stderr_log_name()), + &invocation_dir.join(self.discovery.layout().invocation_log_name()), )? } else { InvocationLogger::ConsoleOnly @@ -89,14 +125,9 @@ impl PluginsEngine { Ok(logger) } - - /// Formats a timestamp into a string suitable for directory or file names. - fn formatted_timestamp(&self, timestamp: &DateTime) -> String { - timestamp.format("%Y%m%d-%H%M%S").to_string() - } } -impl PluginsRepository for PluginsEngine { +impl PluginsRepository for MevaPluginsEngine { fn register( &self, plugin: PluginConfiguration, @@ -181,6 +212,36 @@ impl PluginsRepository for PluginsEngine { Ok(PluginEntry::new(plugin, source_code_file)) } + fn list( + &self, + command: &CommandType, + event: &EventType, + plugins_dir: &Path, + include_enabled: bool, + include_disabled: bool, + ) -> PluginResult> { + let (command_dir, _, plugins) = self + .discovery + .get_command_dir_and_plugins(command, plugins_dir)?; + + let mut entries = Vec::new(); + + for plugin in &plugins.plugins { + if plugin.event != *event { + continue; + } + + if (plugin.enabled && include_enabled) || (!plugin.enabled && include_disabled) { + let script_path = &plugin.file; + let source_file = command_dir.join(script_path); + + entries.push(PluginEntry::new(plugin.clone(), source_file)); + } + } + + Ok(entries) + } + fn update_enabled( &self, command: &CommandType, @@ -219,34 +280,4 @@ impl PluginsRepository for PluginsEngine { Ok(PluginEntry::new(plugin, source_code_file)) } - - fn list( - &self, - command: &CommandType, - event: &EventType, - plugins_dir: &Path, - include_enabled: bool, - include_disabled: bool, - ) -> PluginResult> { - let (command_dir, _, plugins) = self - .discovery - .get_command_dir_and_plugins(command, plugins_dir)?; - - let mut entries = Vec::new(); - - for plugin in &plugins.plugins { - if plugin.event != *event { - continue; - } - - if (plugin.enabled && include_enabled) || (!plugin.enabled && include_disabled) { - let script_path = &plugin.file; - let source_file = command_dir.join(script_path); - - entries.push(PluginEntry::new(plugin.clone(), source_file)); - } - } - - Ok(entries) - } } From 76ea67b1bd205b1f03bc24be2732a958b824fac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:56:39 +0100 Subject: [PATCH 21/42] SSH Server & `clone` command (#30) --- .gitignore | 1 + Cargo.lock | 1634 ++++++++++++++++- Cargo.toml | 9 +- README.md | 20 +- cli/Cargo.toml | 3 + cli/src/commands.rs | 26 + cli/src/commands/add.rs | 22 +- cli/src/commands/clone.rs | 121 ++ cli/src/commands/commit.rs | 12 +- cli/src/commands/config.rs | 16 +- cli/src/commands/config/subcommands/edit.rs | 12 +- cli/src/commands/config/subcommands/get.rs | 12 +- cli/src/commands/config/subcommands/list.rs | 12 +- cli/src/commands/config/subcommands/set.rs | 12 +- cli/src/commands/config/subcommands/unset.rs | 12 +- cli/src/commands/diff.rs | 8 +- cli/src/commands/fetch.rs | 91 + cli/src/commands/ignore.rs | 16 +- cli/src/commands/ignore/subcommands/add.rs | 12 +- cli/src/commands/ignore/subcommands/check.rs | 12 +- cli/src/commands/ignore/subcommands/edit.rs | 12 +- cli/src/commands/ignore/subcommands/remove.rs | 12 +- cli/src/commands/init.rs | 8 +- cli/src/commands/log.rs | 8 +- cli/src/commands/ls_files.rs | 8 +- cli/src/commands/ls_tree.rs | 8 +- cli/src/commands/meva_command.rs | 44 +- cli/src/commands/plugins.rs | 16 +- cli/src/commands/plugins/subcommands/edit.rs | 12 +- cli/src/commands/plugins/subcommands/info.rs | 12 +- cli/src/commands/plugins/subcommands/list.rs | 12 +- .../commands/plugins/subcommands/register.rs | 12 +- .../plugins/subcommands/unregister.rs | 12 +- cli/src/commands/remote.rs | 107 ++ cli/src/commands/remote/subcommands.rs | 13 + cli/src/commands/remote/subcommands/add.rs | 91 + .../commands/remote/subcommands/get_url.rs | 92 + cli/src/commands/remote/subcommands/remove.rs | 68 + cli/src/commands/remote/subcommands/rename.rs | 89 + .../commands/remote/subcommands/set_url.rs | 102 + cli/src/commands/remote/subcommands/show.rs | 81 + cli/src/commands/restore.rs | 8 +- cli/src/commands/show.rs | 8 +- cli/src/commands/status.rs | 8 +- cli/src/extensions/command.rs | 16 +- cli/src/extensions/command/with_name.rs | 79 + cli/src/extensions/command/with_verbose.rs | 33 + cli/src/main.rs | 21 +- cli/src/meva_cli.rs | 59 +- engine/Cargo.toml | 8 +- engine/src/config/config_loader.rs | 6 +- engine/src/engine_container.rs | 49 +- engine/src/errors.rs | 4 + engine/src/errors/clone_error.rs | 14 + engine/src/errors/engine_error.rs | 17 +- engine/src/errors/network_error.rs | 98 + engine/src/handlers.rs | 1 + engine/src/handlers/clone.rs | 5 + engine/src/handlers/clone/handlers.rs | 136 ++ engine/src/handlers/clone/operations.rs | 21 + engine/src/handlers/init/handlers.rs | 2 +- engine/src/hasher.rs | 15 +- engine/src/hasher/meva_hasher.rs | 6 + engine/src/lib.rs | 5 +- engine/src/network.rs | 15 + engine/src/network/client_handler.rs | 54 + engine/src/network/common.rs | 7 + engine/src/network/common/channel_band.rs | 70 + engine/src/network/common/pkt_line.rs | 38 + .../src/network/common/session_extension.rs | 96 + engine/src/network/packfiles.rs | 5 + .../src/network/packfiles/packfile_codec.rs | 142 ++ .../src/network/packfiles/packfile_error.rs | 46 + engine/src/network/protocols.rs | 3 + engine/src/network/protocols/upload_pack.rs | 252 +++ .../upload_pack/upload_pack_result.rs | 21 + .../upload_pack/upload_pack_state.rs | 29 + engine/src/network/ssh_connection_params.rs | 77 + engine/src/network/ssh_service.rs | 84 + engine/src/network/ssh_session.rs | 112 ++ engine/src/object_storage.rs | 29 + .../meva_dry_run_object_storage.rs | 19 +- .../src/object_storage/meva_object_storage.rs | 118 +- engine/src/objects/meva_object.rs | 11 +- .../src/objects/meva_object_decode_context.rs | 4 +- engine/src/objects/meva_object_type.rs | 40 +- engine/src/ref_manager.rs | 9 + engine/src/ref_manager/meva_ref_manager.rs | 55 +- engine/src/ref_manager/ref_entry.rs | 2 +- engine/src/repositories/meva_repository.rs | 206 ++- .../repositories/meva_repository_layout.rs | 1 + engine/src/repositories/repository_layout.rs | 4 +- server/Cargo.toml | 25 + server/src/auth.rs | 210 +++ server/src/auth/auth_result.rs | 13 + server/src/auth/repository_access.rs | 13 + server/src/config.rs | 26 + server/src/config/access_config.rs | 11 + server/src/config/logging_config.rs | 25 + server/src/config/server_config.rs | 24 + server/src/enums.rs | 13 + server/src/enums/active_protocol.rs | 18 + server/src/enums/channel_state.rs | 27 + server/src/enums/protocol_command.rs | 26 + server/src/enums/receive_pack_state.rs | 14 + server/src/enums/upload_pack_command.rs | 42 + server/src/enums/upload_pack_state.rs | 28 + server/src/errors.rs | 5 + server/src/errors/server_error.rs | 33 + server/src/errors/upload_pack_error.rs | 12 + server/src/lib.rs | 15 + server/src/logging.rs | 92 + server/src/main.rs | 67 + server/src/server_handler.rs | 471 +++++ server/src/validation.rs | 3 + server/src/validation/repository_name.rs | 39 + 116 files changed, 6006 insertions(+), 296 deletions(-) create mode 100644 cli/src/commands/clone.rs create mode 100644 cli/src/commands/fetch.rs create mode 100644 cli/src/commands/remote.rs create mode 100644 cli/src/commands/remote/subcommands.rs create mode 100644 cli/src/commands/remote/subcommands/add.rs create mode 100644 cli/src/commands/remote/subcommands/get_url.rs create mode 100644 cli/src/commands/remote/subcommands/remove.rs create mode 100644 cli/src/commands/remote/subcommands/rename.rs create mode 100644 cli/src/commands/remote/subcommands/set_url.rs create mode 100644 cli/src/commands/remote/subcommands/show.rs create mode 100644 cli/src/extensions/command/with_name.rs create mode 100644 cli/src/extensions/command/with_verbose.rs create mode 100644 engine/src/errors/clone_error.rs create mode 100644 engine/src/errors/network_error.rs create mode 100644 engine/src/handlers/clone.rs create mode 100644 engine/src/handlers/clone/handlers.rs create mode 100644 engine/src/handlers/clone/operations.rs create mode 100644 engine/src/network.rs create mode 100644 engine/src/network/client_handler.rs create mode 100644 engine/src/network/common.rs create mode 100644 engine/src/network/common/channel_band.rs create mode 100644 engine/src/network/common/pkt_line.rs create mode 100644 engine/src/network/common/session_extension.rs create mode 100644 engine/src/network/packfiles.rs create mode 100644 engine/src/network/packfiles/packfile_codec.rs create mode 100644 engine/src/network/packfiles/packfile_error.rs create mode 100644 engine/src/network/protocols.rs create mode 100644 engine/src/network/protocols/upload_pack.rs create mode 100644 engine/src/network/protocols/upload_pack/upload_pack_result.rs create mode 100644 engine/src/network/protocols/upload_pack/upload_pack_state.rs create mode 100644 engine/src/network/ssh_connection_params.rs create mode 100644 engine/src/network/ssh_service.rs create mode 100644 engine/src/network/ssh_session.rs create mode 100644 server/Cargo.toml create mode 100644 server/src/auth.rs create mode 100644 server/src/auth/auth_result.rs create mode 100644 server/src/auth/repository_access.rs create mode 100644 server/src/config.rs create mode 100644 server/src/config/access_config.rs create mode 100644 server/src/config/logging_config.rs create mode 100644 server/src/config/server_config.rs create mode 100644 server/src/enums.rs create mode 100644 server/src/enums/active_protocol.rs create mode 100644 server/src/enums/channel_state.rs create mode 100644 server/src/enums/protocol_command.rs create mode 100644 server/src/enums/receive_pack_state.rs create mode 100644 server/src/enums/upload_pack_command.rs create mode 100644 server/src/enums/upload_pack_state.rs create mode 100644 server/src/errors.rs create mode 100644 server/src/errors/server_error.rs create mode 100644 server/src/errors/upload_pack_error.rs create mode 100644 server/src/lib.rs create mode 100644 server/src/logging.rs create mode 100644 server/src/main.rs create mode 100644 server/src/server_handler.rs create mode 100644 server/src/validation.rs create mode 100644 server/src/validation/repository_name.rs diff --git a/.gitignore b/.gitignore index 116b1a2..a771574 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ target .idea/**/uiDesigner.xml .idea/**/dbnavigator.xml +/server_files \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index cb6f9e5..58f2550 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.6", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -91,6 +126,17 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -121,6 +167,29 @@ dependencies = [ "backtrace", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2 0.12.2", + "sha2", +] + [[package]] name = "bincode" version = "2.0.1" @@ -147,6 +216,15 @@ version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.11.0-rc.5" @@ -156,6 +234,25 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bstr" version = "1.12.0" @@ -172,6 +269,27 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.37" @@ -188,6 +306,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "chardetng" version = "0.1.17" @@ -213,6 +342,16 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.6", + "inout", +] + [[package]] name = "clap" version = "4.5.47" @@ -257,6 +396,7 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" name = "cli" version = "0.1.0" dependencies = [ + "async-trait", "clap", "dateparser", "engine", @@ -271,7 +411,9 @@ dependencies = [ "shared", "strum", "strum_macros", - "thiserror", + "thiserror 2.0.16", + "tokio", + "url", ] [[package]] @@ -280,6 +422,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.1" @@ -335,6 +483,29 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.0-rc.4" @@ -344,6 +515,58 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "cryptovec" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae2b4855ffe5a3fe35d5aa2d91b719fbcae83f3b90b97c4dac9d797282e3a7d7" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "dateparser" version = "0.2.1" @@ -356,21 +579,53 @@ dependencies = [ "regex", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "diff" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "subtle", +] + [[package]] name = "digest" version = "0.11.0-rc.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6749b668519cd7149ee3d11286a442a8a8bdc3a9d529605f579777bfccc5a4bc" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.11.0-rc.5", + "const-oid 0.10.1", + "crypto-common 0.2.0-rc.4", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", ] [[package]] @@ -379,7 +634,19 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -390,16 +657,66 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.0", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "downcast" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "editor-command" version = "1.0.0" @@ -415,6 +732,27 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -428,10 +766,12 @@ dependencies = [ name = "engine" version = "0.1.0" dependencies = [ + "async-trait", "bincode", "chardetng", "chrono", - "dirs", + "cryptovec", + "dirs 6.0.0", "encoding_rs", "flate2", "globset", @@ -444,16 +784,20 @@ dependencies = [ "rayon", "regex", "rstest", + "russh", + "russh-keys", "serde", "serde_json", - "sha1", + "sha1 0.11.0-rc.2", "shared", "similar", "tempfile", - "thiserror", + "thiserror 2.0.16", + "tokio", "toml", "toml_edit", "tree-ds", + "url", "walkdir", ] @@ -479,6 +823,22 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.1" @@ -495,18 +855,82 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flexi_logger" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e5335674a3a259527f97e9176a3767dcc9b220b8e29d643daeb2d6c72caf8b" +dependencies = [ + "chrono", + "log", + "nu-ansi-term", + "regex", + "thiserror 2.0.16", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fragile" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + [[package]] name = "futures-macro" version = "0.3.31" @@ -518,6 +942,12 @@ dependencies = [ "syn", ] +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + [[package]] name = "futures-task" version = "0.3.31" @@ -536,14 +966,29 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -567,6 +1012,16 @@ dependencies = [ "wasi 0.14.7+wasi-0.2.4", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.31.1" @@ -593,8 +1048,19 @@ dependencies = [ ] [[package]] -name = "gui" -version = "0.1.0" +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "gui" +version = "0.1.0" dependencies = [ "engine", "mockall", @@ -622,6 +1088,30 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hybrid-array" version = "0.4.1" @@ -655,6 +1145,108 @@ 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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[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 = "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 = "indexmap" version = "2.11.3" @@ -665,6 +1257,16 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "is_ci" version = "1.2.0" @@ -698,6 +1300,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] [[package]] name = "libc" @@ -705,6 +1310,12 @@ version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "libredox" version = "0.1.10" @@ -721,6 +1332,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" @@ -736,6 +1353,12 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.7.5" @@ -781,6 +1404,17 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.0", +] + [[package]] name = "mockall" version = "0.13.1" @@ -807,6 +1441,62 @@ dependencies = [ "syn", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.0", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", + "rand", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82c79c15c05d4bf82b6f5ef163104cc81a760d8e874d38ac50ab67c8877b647b" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -814,6 +1504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -837,6 +1528,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" @@ -849,6 +1546,78 @@ version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core", + "sha2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.0", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "path-absolutize" version = "3.1.1" @@ -867,6 +1636,43 @@ dependencies = [ "once_cell", ] +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.7", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -879,6 +1685,44 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der", + "pbkdf2 0.12.2", + "scrypt", + "sha2", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "pkcs5", + "rand_core", + "spki", +] + [[package]] name = "plugins" version = "0.1.0" @@ -892,10 +1736,51 @@ dependencies = [ "shared", "strum", "strum_macros", - "thiserror", + "thiserror 2.0.16", "wait-timeout", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "3.1.3" @@ -932,6 +1817,15 @@ dependencies = [ "yansi", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -965,6 +1859,36 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + [[package]] name = "rayon" version = "1.11.0" @@ -985,6 +1909,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -993,7 +1937,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror", + "thiserror 2.0.16", ] [[package]] @@ -1031,6 +1975,37 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rstest" version = "0.25.0" @@ -1061,6 +2036,109 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "russh" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6500eedfaf8cd81597899d896908a4b9cd5cb566db875e843c04ccf92add2c16" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bitflags", + "byteorder", + "cbc", + "chacha20", + "ctr", + "curve25519-dalek", + "digest 0.10.7", + "elliptic-curve", + "flate2", + "futures", + "generic-array", + "hex-literal", + "hmac", + "log", + "num-bigint", + "once_cell", + "p256", + "p384", + "p521", + "poly1305", + "rand", + "rand_core", + "russh-cryptovec", + "russh-keys", + "sha1 0.10.6", + "sha2", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "russh-cryptovec" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fadd2c0ab350e21c66556f94ee06f766d8bdae3213857ba7610bfd8e10e51880" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "russh-keys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8c0bfe024d4edd242f65a2ac6c8bf38a892930050b9eb90909d8fc2c413c8d" +dependencies = [ + "aes", + "async-trait", + "bcrypt-pbkdf", + "block-padding", + "byteorder", + "cbc", + "ctr", + "data-encoding", + "der", + "digest 0.10.7", + "dirs 5.0.1", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "futures", + "hmac", + "inout", + "log", + "md5", + "num-integer", + "p256", + "p384", + "p521", + "pbkdf2 0.11.0", + "pkcs1", + "pkcs5", + "pkcs8", + "rand", + "rand_core", + "rsa", + "russh-cryptovec", + "sec1", + "serde", + "sha1 0.10.6", + "sha2", + "spki", + "ssh-encoding", + "ssh-key", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "typenum", + "zeroize", +] + [[package]] name = "rustc-demangle" version = "0.1.26" @@ -1107,6 +2185,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -1122,6 +2209,31 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2 0.12.2", + "salsa20", + "sha2", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.27" @@ -1178,16 +2290,58 @@ dependencies = [ "memchr", "ryu", "serde", - "serde_core", + "serde_core", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2789234a13a53fc4be1b51ea1bab45a3c338bdb884862a257d10e5a74ae009e6" +dependencies = [ + "serde_core", +] + +[[package]] +name = "server" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "cryptovec", + "engine", + "flexi_logger", + "log", + "mockall", + "pretty_assertions", + "rstest", + "russh", + "russh-keys", + "serde", + "serde_plain", + "thiserror 2.0.16", + "tokio", + "toml", ] [[package]] -name = "serde_spanned" -version = "1.0.1" +name = "sha1" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2789234a13a53fc4be1b51ea1bab45a3c338bdb884862a257d10e5a74ae009e6" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "serde_core", + "cfg-if", + "cpufeatures", + "digest 0.10.7", ] [[package]] @@ -1198,7 +2352,18 @@ checksum = "c5e046edf639aa2e7afb285589e5405de2ef7e61d4b0ac1e30256e3eab911af9" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.11.0-rc.2", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", ] [[package]] @@ -1226,6 +2391,25 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core", +] + [[package]] name = "similar" version = "2.7.0" @@ -1238,6 +2422,28 @@ version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spin" version = "0.10.0" @@ -1247,6 +2453,73 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "bcrypt-pbkdf", + "ed25519-dalek", + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1271,6 +2544,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "supports-color" version = "3.0.2" @@ -1303,6 +2582,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.23.0" @@ -1342,13 +2632,33 @@ dependencies = [ "unicode-width 0.2.1", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.16", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1362,6 +2672,55 @@ dependencies = [ "syn", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.0", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.9.6" @@ -1423,8 +2782,8 @@ dependencies = [ "lazy_static", "sequential_gen", "serde", - "spin", - "thiserror", + "spin 0.10.0", + "thiserror 2.0.16", ] [[package]] @@ -1477,6 +2836,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.6", + "subtle", +] + [[package]] name = "unty" version = "0.0.4" @@ -1493,6 +2862,24 @@ dependencies = [ "typed-builder", ] +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +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" @@ -1509,6 +2896,12 @@ dependencies = [ "serde", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "virtue" version = "0.0.18" @@ -1617,6 +3010,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -1626,6 +3035,12 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.0" @@ -1691,6 +3106,15 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -1709,6 +3133,21 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -1742,6 +3181,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -1754,6 +3199,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -1766,6 +3217,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1790,6 +3247,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -1802,6 +3265,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -1814,6 +3283,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -1826,6 +3301,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -1853,8 +3334,117 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + [[package]] name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[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 = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[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", +] diff --git a/Cargo.toml b/Cargo.toml index 11d6b3a..cf5c8c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ resolver = "3" -members = ["engine", "cli", "gui", "plugins", "shared"] +members = ["engine", "cli", "gui", "plugins", "shared", "server"] [workspace.package] authors = ["Mikołaj Karbowski", "Adam Grącikowski"] @@ -15,6 +15,7 @@ pretty_assertions = "1.4.1" tempfile = "3.23.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.143" +toml = "0.9.2" up_finder = "0.0.4" dirs = "6.0.0" editor-command = "1.0.0" @@ -28,3 +29,9 @@ regex = "1.11.3" similar = "2.7.0" owo-colors = "4.2.3" path-absolutize = { version = "3.1.1", features = ["once_cell_cache"] } +async-trait = "0.1.89" +tokio = { version = "1", features = ["full"] } +russh = "0.44.0" +russh-keys = "0.44.0" +cryptovec = "0.7.0" +url = "2.5.7" diff --git a/README.md b/README.md index ad9c610..94ba311 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ meva/ │ ├── Cargo.toml │ └── src/ │ └── ... +├── server/ +│ ├── Cargo.toml +│ └── src/ +│ └── ... ├── shared/ │ ├── Cargo.toml │ └── src/ @@ -54,7 +58,7 @@ To build a specific crate only: cargo build -p ``` -Replace `` with one of: `engine`, `cli`, `gui`, `plugins`, or `shared`. +Replace `` with one of: `engine`, `cli`, `gui`, `plugins`, `server`, or `shared`. ### Running the project @@ -70,6 +74,20 @@ To run the GUI binary: cargo run -p gui ``` +To run the Server binary: + +> On Windows + +```bash +$env:RUST_LOG = "info"; cargo run --bin server -- ".\path\to\mevaserverconfig.toml" +``` + +> On Linux + +```bash +RUST_LOG=info cargo run --bin server -- ./path/to/mevaserverconfig.toml +``` + ### Running tests To run all tests in the workspace: diff --git a/cli/Cargo.toml b/cli/Cargo.toml index efb6276..1890361 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -21,6 +21,9 @@ strum.workspace = true strum_macros.workspace = true owo-colors.workspace = true regex.workspace = true +async-trait.workspace = true +tokio.workspace = true +url.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 34a5b5d..5e15d38 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1,7 +1,9 @@ pub mod add; +pub mod clone; pub mod commit; pub mod config; pub mod diff; +pub mod fetch; pub mod ignore; pub mod init; pub mod log; @@ -9,11 +11,13 @@ pub mod ls_files; pub mod ls_tree; pub mod meva_command; pub mod plugins; +pub mod remote; pub mod restore; pub mod show; pub mod status; pub use add::AddCommand; +pub use clone::CloneCommand; pub use commit::CommitCommand; pub use config::ConfigCommand; pub use diff::DiffCommand; @@ -23,8 +27,30 @@ pub use log::LogCommand; pub use ls_files::LsFilesCommand; pub use ls_tree::LsTreeCommand; pub use plugins::PluginsCommand; +pub use remote::RemoteCommand; pub use restore::RestoreCommand; pub use show::ShowCommand; pub use status::StatusCommand; +use clap::ArgMatches; +use miette::{Context, Result}; + +use engine::engine_container::MevaContainer; pub use meva_command::MevaCommand; + +pub async fn execute_multiple( + matches: &ArgMatches, + container: &MevaContainer, + subcommands: Vec>>, +) -> Result<()> { + if let Some((name, sub_matches)) = matches.subcommand() + && let Some(sub) = subcommands.into_iter().find(|c| c.name() == name) + { + return sub + .execute(sub_matches, container) + .await + .wrap_err(format!("Error running `{name}` subcommand")); + } + + Ok(()) +} diff --git a/cli/src/commands/add.rs b/cli/src/commands/add.rs index e82b9ac..9fedeaf 100644 --- a/cli/src/commands/add.rs +++ b/cli/src/commands/add.rs @@ -1,9 +1,11 @@ use std::path::PathBuf; +use async_trait::async_trait; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command, value_parser}; use miette::{IntoDiagnostic, Result}; use crate::commands::MevaCommand; +use crate::extensions::WithVerbose; use engine::EngineContainer; use engine::engine_container::MevaContainer; use engine::handlers::add::AddRequest; @@ -42,12 +44,12 @@ impl AddCommand { /// `--dry-run` flag key. const ARG_DRY_RUN: &'static str = "dry-run"; - - /// `--verbose` flag key. - const ARG_VERBOSE: &'static str = "verbose"; } -impl MevaCommand for AddCommand { +#[async_trait] +impl MevaCommand for AddCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "add" } @@ -100,13 +102,7 @@ impl MevaCommand for AddCommand { .help("Show files to be added without adding them") .action(ArgAction::SetTrue), ) - .arg( - Arg::new(Self::ARG_VERBOSE) - .short('v') - .long(Self::ARG_VERBOSE) - .help("Be verbose") - .action(ArgAction::SetTrue), - ) + .with_verbose_arg("Enable verbose output") .group( ArgGroup::new("path_group") .args([Self::ARG_PATH, Self::ARG_ALL, Self::ARG_UPDATE]) @@ -122,12 +118,12 @@ impl MevaCommand for AddCommand { /// - Delegates to the repository's [`add_handler`] to perform the operation. /// /// Returns a [`miette::Result`] with diagnostic information if an error occurs. - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { let all_flag = matches.get_flag(Self::ARG_ALL); let force_flag = matches.get_flag(Self::ARG_FORCE); let update_flag = matches.get_flag(Self::ARG_UPDATE); let dry_run_flag = matches.get_flag(Self::ARG_DRY_RUN); - let verbose_flag = matches.get_flag(Self::ARG_VERBOSE); + let verbose_flag = matches.get_flag(Command::ARG_VERBOSE); let path_arg = matches.get_one::(Self::ARG_PATH); let handler = container.add_handler().into_diagnostic()?; diff --git a/cli/src/commands/clone.rs b/cli/src/commands/clone.rs new file mode 100644 index 0000000..c4b0d92 --- /dev/null +++ b/cli/src/commands/clone.rs @@ -0,0 +1,121 @@ +use crate::commands::MevaCommand; +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint}; +use engine::{EngineContainer, engine_container::MevaContainer, handlers::clone::Request}; +use miette::{IntoDiagnostic, Result}; +use std::path::PathBuf; +use url::Url; + +/// Implements the `clone` command for Meva DVCS. +/// +/// Clones a remote repository into a new directory, creates a +/// tracking connection to the remote repository (origin), and checks out +/// the default branch. +pub struct CloneCommand; + +impl CloneCommand { + /// Creates a new instance of the [`CloneCommand`]. + pub fn new() -> Self { + Self {} + } + + /// Argument name for specifying the remote repository URL. + const ARG_REPOSITORY: &'static str = "repository"; + + /// Argument name for specifying the target directory. + const ARG_DIRECTORY: &'static str = "directory"; + + /// Argument name for specifying the origin name. + const ARG_ORIGIN: &'static str = "origin"; + + /// Argument name for specifying the server public key. + const ARG_SERVER_KEY: &'static str = "server-key"; + + /// Argument name for the quiet flag. + const ARG_QUIET: &'static str = "quiet"; +} + +#[async_trait] +impl MevaCommand for CloneCommand { + type Container = MevaContainer; + + fn name(&self) -> &'static str { + "clone" + } + + fn about(&self) -> &'static str { + "Clones a remote repository into a local directory" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `clone` command using `clap`. + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_REPOSITORY) + .value_name("REPOSITORY") + .index(1) + .required(true) + .value_parser(clap::value_parser!(Url)) + .help("Address of the repository to clone"), + ) + .arg( + Arg::new(Self::ARG_DIRECTORY) + .value_name("DIRECTORY") + .index(2) + .value_hint(ValueHint::DirPath) + .value_parser(clap::value_parser!(PathBuf)) + .help("Directory to clone into"), + ) + .arg( + Arg::new(Self::ARG_ORIGIN) + .long(Self::ARG_ORIGIN) + .value_name("ORIGIN") + .default_value("origin") + .help("Optional origin name to set for the cloned repository"), + ) + .arg( + Arg::new(Self::ARG_SERVER_KEY) + .long(Self::ARG_SERVER_KEY) + .short('s') + .required(true) + .value_hint(ValueHint::FilePath) + .value_parser(clap::value_parser!(PathBuf)) + .help("Path to server's public key"), + ) + .arg( + Arg::new(Self::ARG_QUIET) + .long(Self::ARG_QUIET) + .short('q') + .action(ArgAction::SetTrue) + .help("Suppress non-error output"), + ) + } + + /// Executes the `clone` command. + /// + /// # Returns + /// * `Result<()>`: Indicates success or returns a detailed error if the clone fails. + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { + let request = Request { + url: matches + .get_one::(Self::ARG_REPOSITORY) + .unwrap() + .clone(), + directory: matches.get_one::(Self::ARG_DIRECTORY).cloned(), + origin: matches.get_one::(Self::ARG_ORIGIN).cloned(), + server_key: matches + .get_one::(Self::ARG_SERVER_KEY) + .unwrap() + .clone(), + quiet: matches.get_flag(Self::ARG_QUIET), + }; + + let handler = container.clone_handler().into_diagnostic()?; + let _response = handler.handle_clone(request).await.into_diagnostic()?; + Ok(()) + } +} diff --git a/cli/src/commands/commit.rs b/cli/src/commands/commit.rs index 5e25f7a..17cbb67 100644 --- a/cli/src/commands/commit.rs +++ b/cli/src/commands/commit.rs @@ -1,4 +1,5 @@ use crate::commands::MevaCommand; +use async_trait::async_trait; use clap::{Arg, ArgAction, ArgMatches, Command}; use engine::diff_builder::{DiffStat, FileChangeKind}; use engine::errors::{CommitError, EngineError}; @@ -111,7 +112,10 @@ impl CommitCommand { } } -impl MevaCommand for CommitCommand { +#[async_trait] +impl MevaCommand for CommitCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "commit" } @@ -188,7 +192,11 @@ impl MevaCommand for CommitCommand { /// /// # Returns /// * [`miette::Result`]: Indicates success or a diagnostic error if the commit operation fails. - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + async fn execute( + &self, + matches: &ArgMatches, + container: &Self::Container, + ) -> miette::Result<()> { let message_arg = matches.get_one::(Self::ARG_MESSAGE).unwrap(); let author_arg = matches.get_one::(Self::ARG_AUTHOR); let all_arg = matches.get_flag(Self::ARG_ALL); diff --git a/cli/src/commands/config.rs b/cli/src/commands/config.rs index 0d0ef1b..b6c2021 100644 --- a/cli/src/commands/config.rs +++ b/cli/src/commands/config.rs @@ -1,8 +1,11 @@ pub mod subcommands; -use crate::commands::MevaCommand; +use crate::commands::{MevaCommand, execute_multiple}; +use async_trait::async_trait; +use clap::ArgMatches; use engine::engine_container::MevaContainer; +use miette::Result; use subcommands::*; /// Implements the `config` command for Meva DVCS. @@ -18,7 +21,10 @@ impl ConfigCommand { } } -impl MevaCommand for ConfigCommand { +#[async_trait] +impl MevaCommand for ConfigCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "config" } @@ -34,7 +40,7 @@ impl MevaCommand for ConfigCommand { /// Define the set of subcommands under `config` namespace. /// /// Returns boxed instances of each config operation command. - fn subcommands(&self) -> Vec>> { + fn subcommands(&self) -> Vec>> { vec![ Box::new(ConfigListCommand), Box::new(ConfigGetCommand), @@ -43,6 +49,10 @@ impl MevaCommand for ConfigCommand { Box::new(ConfigEditCommand), ] } + + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { + execute_multiple(matches, container, self.subcommands()).await + } } #[cfg(test)] diff --git a/cli/src/commands/config/subcommands/edit.rs b/cli/src/commands/config/subcommands/edit.rs index 31bc2ef..72ee690 100644 --- a/cli/src/commands/config/subcommands/edit.rs +++ b/cli/src/commands/config/subcommands/edit.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use clap::{ArgMatches, Command}; use engine::engine_container::MevaContainer; use engine::{ConfigDocument, ConfigLoader, MevaConfigLoader}; @@ -21,7 +22,10 @@ impl ConfigEditCommand { } } -impl MevaCommand for ConfigEditCommand { +#[async_trait] +impl MevaCommand for ConfigEditCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "edit" } @@ -42,7 +46,11 @@ impl MevaCommand for ConfigEditCommand { ) } - fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> miette::Result<()> { + async fn execute( + &self, + matches: &ArgMatches, + _container: &Self::Container, + ) -> miette::Result<()> { let loader = MevaConfigLoader::default(); let override_cmd = loader.get("core.editor", None).ok(); let location = matches diff --git a/cli/src/commands/config/subcommands/get.rs b/cli/src/commands/config/subcommands/get.rs index 1b38d86..d3e0b9d 100644 --- a/cli/src/commands/config/subcommands/get.rs +++ b/cli/src/commands/config/subcommands/get.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use clap::{Arg, ArgMatches, Command}; use engine::EngineContainer; use engine::engine_container::MevaContainer; @@ -25,7 +26,10 @@ impl ConfigGetCommand { const ARG_DEFAULT: &'static str = "default"; } -impl MevaCommand for ConfigGetCommand { +#[async_trait] +impl MevaCommand for ConfigGetCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "get" } @@ -61,7 +65,11 @@ impl MevaCommand for ConfigGetCommand { ) } - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + async fn execute( + &self, + matches: &ArgMatches, + container: &Self::Container, + ) -> miette::Result<()> { let location = matches.get_config_location(); let key = matches.get_one::(Self::ARG_KEY).unwrap(); let default = matches.get_one::(Self::ARG_DEFAULT); diff --git a/cli/src/commands/config/subcommands/list.rs b/cli/src/commands/config/subcommands/list.rs index 6376f11..3351105 100644 --- a/cli/src/commands/config/subcommands/list.rs +++ b/cli/src/commands/config/subcommands/list.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use clap::{ArgMatches, Command}; use engine::EngineContainer; use engine::engine_container::MevaContainer; @@ -21,7 +22,10 @@ impl ConfigListCommand { } } -impl MevaCommand for ConfigListCommand { +#[async_trait] +impl MevaCommand for ConfigListCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "list" } @@ -42,7 +46,11 @@ impl MevaCommand for ConfigListCommand { ) } - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + async fn execute( + &self, + matches: &ArgMatches, + container: &Self::Container, + ) -> miette::Result<()> { let location = matches.get_config_location(); let config_handler = container.config_handler().into_diagnostic()?; diff --git a/cli/src/commands/config/subcommands/set.rs b/cli/src/commands/config/subcommands/set.rs index bd0235f..bb205cf 100644 --- a/cli/src/commands/config/subcommands/set.rs +++ b/cli/src/commands/config/subcommands/set.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use clap::{Arg, ArgMatches, Command}; use engine::EngineContainer; use engine::engine_container::MevaContainer; @@ -23,7 +24,10 @@ impl ConfigSetCommand { const ARG_VALUE: &'static str = "value"; } -impl MevaCommand for ConfigSetCommand { +#[async_trait] +impl MevaCommand for ConfigSetCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "set" } @@ -53,7 +57,11 @@ impl MevaCommand for ConfigSetCommand { ) } - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + async fn execute( + &self, + matches: &ArgMatches, + container: &Self::Container, + ) -> miette::Result<()> { let key = matches.get_one::(Command::ARG_KEY).unwrap(); let value = matches.get_one::(Self::ARG_VALUE).unwrap(); let location = matches.get_config_location(); diff --git a/cli/src/commands/config/subcommands/unset.rs b/cli/src/commands/config/subcommands/unset.rs index 529a3b1..1669166 100644 --- a/cli/src/commands/config/subcommands/unset.rs +++ b/cli/src/commands/config/subcommands/unset.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use clap::{ArgMatches, Command}; use engine::EngineContainer; use engine::engine_container::MevaContainer; @@ -20,7 +21,10 @@ impl ConfigUnsetCommand { } } -impl MevaCommand for ConfigUnsetCommand { +#[async_trait] +impl MevaCommand for ConfigUnsetCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "unset" } @@ -43,7 +47,11 @@ impl MevaCommand for ConfigUnsetCommand { .with_key_arg("TOML path to the config entry") } - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + async fn execute( + &self, + matches: &ArgMatches, + container: &Self::Container, + ) -> miette::Result<()> { let key = matches.get_one::(Command::ARG_KEY).unwrap(); let location = matches.get_config_location(); diff --git a/cli/src/commands/diff.rs b/cli/src/commands/diff.rs index b26b1dc..4b43501 100644 --- a/cli/src/commands/diff.rs +++ b/cli/src/commands/diff.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use async_trait::async_trait; use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; use engine::{ EngineContainer, @@ -77,7 +78,10 @@ impl DiffCommand { } } -impl MevaCommand for DiffCommand { +#[async_trait] +impl MevaCommand for DiffCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "diff" } @@ -147,7 +151,7 @@ impl MevaCommand for DiffCommand { } /// Executes the `diff` command. - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { let cached = matches.get_flag(Self::ARG_CACHED); let from = matches.get_one::(Self::ARG_RANGE1); let to = matches.get_one::(Self::ARG_RANGE2); diff --git a/cli/src/commands/fetch.rs b/cli/src/commands/fetch.rs new file mode 100644 index 0000000..bb246ac --- /dev/null +++ b/cli/src/commands/fetch.rs @@ -0,0 +1,91 @@ +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use engine::engine_container::MevaContainer; + +use miette::Result; + +use crate::{commands::MevaCommand, extensions::WithVerbose}; + +pub struct FetchCommand; + +impl FetchCommand { + /// Creates a new instance of the [`FetchCommand`]. + pub fn new() -> Self { + Self {} + } + + const ARG_PRUNE: &'static str = "prune"; + + const ARG_REPOSITORY: &'static str = "repository"; + + const ARG_BRANCH: &'static str = "branch"; +} + +#[async_trait] +impl MevaCommand for FetchCommand { + type Container = MevaContainer; + + fn name(&self) -> &'static str { + "fetch" + } + + fn about(&self) -> &'static str { + "Download objects and refs from a remote repository" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `fetch` command using `clap`. + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_REPOSITORY) + .value_name("REPOSITORY") + .index(1) + .required(false) + .default_value("origin") + .help("Remote name to fetch from"), + ) + .arg( + Arg::new(Self::ARG_BRANCH) + .value_name("BRANCH") + .index(2) + .required(false) + .default_value("master") + .help("Branch to fetch"), + ) + .arg( + Arg::new(Self::ARG_PRUNE) + .long(Self::ARG_PRUNE) + .short('p') + .action(ArgAction::SetTrue) + .help("Remove remote-tracking branches that no longer exist on the remote"), + ) + .with_verbose_arg("Enable verbose output") + } + + /// Executes the `fetch` command. + async fn execute(&self, _matches: &ArgMatches, _container: &Self::Container) -> Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = FetchCommand::new(); + assert_eq!(cmd.name(), "fetch"); + assert_eq!( + cmd.about(), + "Download objects and refs from a remote repository" + ); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/ignore.rs b/cli/src/commands/ignore.rs index c7f7aff..de78b4a 100644 --- a/cli/src/commands/ignore.rs +++ b/cli/src/commands/ignore.rs @@ -1,8 +1,11 @@ pub mod subcommands; -use crate::commands::MevaCommand; +use crate::commands::{MevaCommand, execute_multiple}; +use async_trait::async_trait; +use clap::ArgMatches; use engine::engine_container::MevaContainer; +use miette::Result; use subcommands::*; /// Implements the `ignore` command for Meva DVCS. @@ -18,7 +21,10 @@ impl IgnoreCommand { } } -impl MevaCommand for IgnoreCommand { +#[async_trait] +impl MevaCommand for IgnoreCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "ignore" } @@ -34,7 +40,7 @@ impl MevaCommand for IgnoreCommand { /// Define the set of subcommands under `ignore` namespace. /// /// Returns boxed instances of each ignore operation command. - fn subcommands(&self) -> Vec>> { + fn subcommands(&self) -> Vec>> { vec![ Box::new(IgnoreAddCommand), Box::new(IgnoreCheckCommand), @@ -42,6 +48,10 @@ impl MevaCommand for IgnoreCommand { Box::new(IgnoreRemoveCommand), ] } + + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { + execute_multiple(matches, container, self.subcommands()).await + } } #[cfg(test)] diff --git a/cli/src/commands/ignore/subcommands/add.rs b/cli/src/commands/ignore/subcommands/add.rs index b33bf85..eee0132 100644 --- a/cli/src/commands/ignore/subcommands/add.rs +++ b/cli/src/commands/ignore/subcommands/add.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use async_trait::async_trait; use clap::{ArgMatches, Command}; use engine::{ IgnoreOperations, IgnoreService, RepositoryLayout, engine_container::MevaContainer, @@ -27,7 +28,10 @@ impl IgnoreAddCommand { } } -impl MevaCommand for IgnoreAddCommand { +#[async_trait] +impl MevaCommand for IgnoreAddCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "add" } @@ -46,7 +50,11 @@ impl MevaCommand for IgnoreAddCommand { .with_file_arg("Path to a specific ignore file") } - fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> miette::Result<()> { + async fn execute( + &self, + matches: &ArgMatches, + _container: &Self::Container, + ) -> miette::Result<()> { let pattern = matches.get_one::(Command::ARG_PATTERN).unwrap(); let file = matches.get_one::(Command::ARG_FILE); diff --git a/cli/src/commands/ignore/subcommands/check.rs b/cli/src/commands/ignore/subcommands/check.rs index d4119ea..e36ad51 100644 --- a/cli/src/commands/ignore/subcommands/check.rs +++ b/cli/src/commands/ignore/subcommands/check.rs @@ -1,5 +1,6 @@ use std::path::{Path, PathBuf}; +use async_trait::async_trait; use clap::{Arg, ArgAction, ArgMatches, Command}; use engine::{ IgnoreOperations, IgnoreResult, IgnoreService, RepositoryLayout, @@ -59,7 +60,10 @@ impl IgnoreCheckCommand { } } -impl MevaCommand for IgnoreCheckCommand { +#[async_trait] +impl MevaCommand for IgnoreCheckCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "check" } @@ -91,7 +95,11 @@ impl MevaCommand for IgnoreCheckCommand { ) } - fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> miette::Result<()> { + async fn execute( + &self, + matches: &ArgMatches, + _container: &Self::Container, + ) -> miette::Result<()> { let file = matches.get_one::(Command::ARG_FILE); let explain = matches.get_flag(Self::ARG_EXPLAIN); let checked_path = matches.get_one::(Self::ARG_PATH).unwrap(); diff --git a/cli/src/commands/ignore/subcommands/edit.rs b/cli/src/commands/ignore/subcommands/edit.rs index d0f70c3..33207e9 100644 --- a/cli/src/commands/ignore/subcommands/edit.rs +++ b/cli/src/commands/ignore/subcommands/edit.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use async_trait::async_trait; use clap::{ArgMatches, Command}; use engine::{ ConfigLoader, IgnoreOperations, IgnoreService, MevaConfigLoader, RepositoryLayout, @@ -24,7 +25,10 @@ impl IgnoreEditCommand { } } -impl MevaCommand for IgnoreEditCommand { +#[async_trait] +impl MevaCommand for IgnoreEditCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "edit" } @@ -42,7 +46,11 @@ impl MevaCommand for IgnoreEditCommand { .with_file_arg("Path to a specific ignore file") } - fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> miette::Result<()> { + async fn execute( + &self, + matches: &ArgMatches, + _container: &Self::Container, + ) -> miette::Result<()> { let file = matches.get_one::(Command::ARG_FILE); let layout = MevaRepositoryLayout::from_env().into_diagnostic()?; diff --git a/cli/src/commands/ignore/subcommands/remove.rs b/cli/src/commands/ignore/subcommands/remove.rs index b65e160..562d583 100644 --- a/cli/src/commands/ignore/subcommands/remove.rs +++ b/cli/src/commands/ignore/subcommands/remove.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use async_trait::async_trait; use clap::{ArgMatches, Command}; use engine::{ IgnoreOperations, IgnoreService, RepositoryLayout, engine_container::MevaContainer, @@ -27,7 +28,10 @@ impl IgnoreRemoveCommand { } } -impl MevaCommand for IgnoreRemoveCommand { +#[async_trait] +impl MevaCommand for IgnoreRemoveCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "remove" } @@ -46,7 +50,11 @@ impl MevaCommand for IgnoreRemoveCommand { .with_file_arg("Path to a specific ignore file") } - fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> miette::Result<()> { + async fn execute( + &self, + matches: &ArgMatches, + _container: &Self::Container, + ) -> miette::Result<()> { let pattern = matches.get_one::(Command::ARG_PATTERN).unwrap(); let file = matches.get_one::(Command::ARG_FILE); diff --git a/cli/src/commands/init.rs b/cli/src/commands/init.rs index dd2bcb2..b50194b 100644 --- a/cli/src/commands/init.rs +++ b/cli/src/commands/init.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use async_trait::async_trait; use clap::{Arg, ArgMatches, Command, ValueHint}; use engine::{EngineContainer, engine_container::MevaContainer, handlers::init::Request}; use miette::{IntoDiagnostic, Result}; @@ -25,7 +26,10 @@ impl InitCommand { const ARG_PATH: &'static str = "path"; } -impl MevaCommand for InitCommand { +#[async_trait] +impl MevaCommand for InitCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "init" } @@ -78,7 +82,7 @@ impl MevaCommand for InitCommand { /// /// # Returns /// * `Result<()>`: Indicates success or detailed error if initialization fails. - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { let branch = matches.get_one::(Self::ARG_INITIAL_BRANCH).unwrap(); let target = matches.get_one::(Self::ARG_PATH).unwrap(); diff --git a/cli/src/commands/log.rs b/cli/src/commands/log.rs index 1bfec37..0bb7539 100644 --- a/cli/src/commands/log.rs +++ b/cli/src/commands/log.rs @@ -1,4 +1,5 @@ use crate::commands::MevaCommand; +use async_trait::async_trait; use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; use dateparser::DateTimeUtc; use engine::EngineContainer; @@ -74,7 +75,10 @@ impl LogCommand { } } -impl MevaCommand for LogCommand { +#[async_trait] +impl MevaCommand for LogCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "log" } @@ -170,7 +174,7 @@ impl MevaCommand for LogCommand { /// Collects CLI options and builds a [`Request`] to pass to the log handler. /// The handler is responsible for querying commits and formatting output /// according to the specified options. - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { let oneline = matches.get_flag(Self::ARG_ONELINE); let handler = container.log_handler().into_diagnostic()?; diff --git a/cli/src/commands/ls_files.rs b/cli/src/commands/ls_files.rs index 807eac8..a4aff6b 100644 --- a/cli/src/commands/ls_files.rs +++ b/cli/src/commands/ls_files.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command, value_parser}; use engine::{ EngineContainer, @@ -45,7 +46,10 @@ impl LsFilesCommand { const ARG_FULL_NAME: &'static str = "full-name"; } -impl MevaCommand for LsFilesCommand { +#[async_trait] +impl MevaCommand for LsFilesCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "ls-files" } @@ -117,7 +121,7 @@ impl MevaCommand for LsFilesCommand { } /// Executes the `ls-files` command. - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { let filter = if matches.get_flag(Self::ARG_DELETED) { LsFilesFilter::Deleted } else if matches.get_flag(Self::ARG_OTHERS) { diff --git a/cli/src/commands/ls_tree.rs b/cli/src/commands/ls_tree.rs index 9d1a112..33a52b7 100644 --- a/cli/src/commands/ls_tree.rs +++ b/cli/src/commands/ls_tree.rs @@ -1,4 +1,5 @@ use crate::commands::MevaCommand; +use async_trait::async_trait; use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; use engine::EngineContainer; use engine::engine_container::MevaContainer; @@ -51,7 +52,10 @@ impl LsTreeCommand { const ARG_NAME_ONLY: &'static str = "name-only"; } -impl MevaCommand for LsTreeCommand { +#[async_trait] +impl MevaCommand for LsTreeCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "ls-tree" } @@ -132,7 +136,7 @@ impl MevaCommand for LsTreeCommand { /// /// * [`Result`]: Indicates success or a diagnostic error /// if the tree resolution or listing fails. - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { let recursive = matches.get_flag(Self::ARG_RECURSIVE); let tree = matches.get_flag(Self::ARG_TREE); let long = matches.get_flag(Self::ARG_LONG); diff --git a/cli/src/commands/meva_command.rs b/cli/src/commands/meva_command.rs index 50b9da1..6aa2235 100644 --- a/cli/src/commands/meva_command.rs +++ b/cli/src/commands/meva_command.rs @@ -1,12 +1,13 @@ +use async_trait::async_trait; use clap::{ArgMatches, Command}; -use engine::EngineContainer; -use miette::{Context, Result}; +use engine::{EngineContainer, engine_container::MevaContainer}; +use miette::Result; /// A trait representing a top-level command in the Meva CLI. -pub trait MevaCommand -where - T: EngineContainer, -{ +#[async_trait] +pub trait MevaCommand: Send + Sync { + type Container: EngineContainer + Send + Sync; + /// Returns the unique name of the command. fn name(&self) -> &'static str; @@ -38,37 +39,24 @@ where cmd } - /// Executes this command or delegates to a matching subcommand if present. - /// - /// This method inspects the parsed `ArgMatches` to determine whether a subcommand - /// was invoked. If so, it looks for a matching registered subcommand and calls its - /// `execute` method with the corresponding argument matches. + /// Executes the command logic. /// - /// If no subcommand is matched, it returns `Ok(())` by default, meaning no operation was performed. + /// This is the entry point for the command's runtime behavior. Implementations + /// should use the provided `matches` to extract arguments and the `container` + /// to access necessary services or handlers. /// /// # Arguments - /// * `matches`: The parsed CLI arguments for this command, including any subcommand matches. + /// * `matches`: The parsed CLI arguments for this command. + /// * `container`: The dependency injection container providing access to core engine features. /// /// # Returns - /// * `Result<()>`: Indicates whether the execution succeeded or an error occurred during dispatch. - fn execute(&self, matches: &ArgMatches, container: &T) -> Result<()> { - if let Some((name, sub_matches)) = matches.subcommand() { - for sub_command in self.subcommands() { - if sub_command.name() == name { - return sub_command - .execute(sub_matches, container) - .wrap_err_with(|| format!("Error running `{name}` subcommand")); - } - } - } - - Ok(()) - } + /// * `Result<()>`: Indicates whether the execution succeeded. Errors are propagated via `miette`. + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()>; /// Returns a vector of boxed subcommands for this command. /// /// Default implementation returns an empty vector. - fn subcommands(&self) -> Vec>> { + fn subcommands(&self) -> Vec>> { Vec::new() } } diff --git a/cli/src/commands/plugins.rs b/cli/src/commands/plugins.rs index b489ea4..6266217 100644 --- a/cli/src/commands/plugins.rs +++ b/cli/src/commands/plugins.rs @@ -1,8 +1,11 @@ pub mod subcommands; -use crate::commands::MevaCommand; +use crate::commands::{MevaCommand, execute_multiple}; +use async_trait::async_trait; +use clap::ArgMatches; use engine::engine_container::MevaContainer; +use miette::Result; use subcommands::*; /// Implements the `plugins` top-level command for Meva. @@ -19,7 +22,10 @@ impl PluginsCommand { } } -impl MevaCommand for PluginsCommand { +#[async_trait] +impl MevaCommand for PluginsCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "plugins" } @@ -35,7 +41,7 @@ impl MevaCommand for PluginsCommand { /// Define the set of subcommands under `plugins` namespace. /// /// Returns boxed instances of each ignore operation command. - fn subcommands(&self) -> Vec>> { + fn subcommands(&self) -> Vec>> { vec![ Box::new(PluginsEditCommand), Box::new(PluginsInfoCommand), @@ -44,6 +50,10 @@ impl MevaCommand for PluginsCommand { Box::new(PluginsUnregisterCommand), ] } + + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { + execute_multiple(matches, container, self.subcommands()).await + } } #[cfg(test)] diff --git a/cli/src/commands/plugins/subcommands/edit.rs b/cli/src/commands/plugins/subcommands/edit.rs index 0300d4c..7bb6f0b 100644 --- a/cli/src/commands/plugins/subcommands/edit.rs +++ b/cli/src/commands/plugins/subcommands/edit.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use clap::{Arg, ArgAction, ArgMatches, Command}; use engine::{ ConfigLoader, EngineContainer, MevaConfigLoader, @@ -26,7 +27,10 @@ impl PluginsEditCommand { const ARG_DISABLE: &'static str = "disable"; } -impl MevaCommand for PluginsEditCommand { +#[async_trait] +impl MevaCommand for PluginsEditCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "edit" } @@ -64,7 +68,11 @@ impl MevaCommand for PluginsEditCommand { ) } - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + async fn execute( + &self, + matches: &ArgMatches, + container: &Self::Container, + ) -> miette::Result<()> { let command_str = matches.get_one::(Command::ARG_COMMAND).unwrap(); let name = matches.get_one::(Command::ARG_PLUGIN).unwrap(); let default_scope = ScopeType::Local.to_string(); diff --git a/cli/src/commands/plugins/subcommands/info.rs b/cli/src/commands/plugins/subcommands/info.rs index 2dd7793..1541cf5 100644 --- a/cli/src/commands/plugins/subcommands/info.rs +++ b/cli/src/commands/plugins/subcommands/info.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use clap::{ArgMatches, Command}; use engine::{ EngineContainer, @@ -21,7 +22,10 @@ impl PluginsInfoCommand { } } -impl MevaCommand for PluginsInfoCommand { +#[async_trait] +impl MevaCommand for PluginsInfoCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "info" } @@ -43,7 +47,11 @@ impl MevaCommand for PluginsInfoCommand { .with_scope_arg("Scope of the plugin") } - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + async fn execute( + &self, + matches: &ArgMatches, + container: &Self::Container, + ) -> miette::Result<()> { let command_str = matches.get_one::(Command::ARG_COMMAND).unwrap(); let default_scope = ScopeType::Local.to_string(); let scope_str = matches diff --git a/cli/src/commands/plugins/subcommands/list.rs b/cli/src/commands/plugins/subcommands/list.rs index 476a641..af96486 100644 --- a/cli/src/commands/plugins/subcommands/list.rs +++ b/cli/src/commands/plugins/subcommands/list.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; use engine::{ EngineContainer, @@ -26,7 +27,10 @@ impl PluginsListCommand { const ARG_ENABLED: &'static str = "enabled"; } -impl MevaCommand for PluginsListCommand { +#[async_trait] +impl MevaCommand for PluginsListCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "list" } @@ -79,7 +83,11 @@ impl MevaCommand for PluginsListCommand { ) } - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + async fn execute( + &self, + matches: &ArgMatches, + container: &Self::Container, + ) -> miette::Result<()> { let command_str = matches.get_one::(Self::ARG_COMMAND).unwrap(); let event_str = matches.get_one::(Self::ARG_EVENT).unwrap(); let default_scope = ScopeType::Local.to_string(); diff --git a/cli/src/commands/plugins/subcommands/register.rs b/cli/src/commands/plugins/subcommands/register.rs index f3a8e4c..fa42e18 100644 --- a/cli/src/commands/plugins/subcommands/register.rs +++ b/cli/src/commands/plugins/subcommands/register.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint, builder::PossibleValuesParser}; use engine::{ EngineContainer, @@ -40,7 +41,10 @@ impl PluginsRegisterCommand { const ARG_INTERPRETER: &'static str = "interpreter"; } -impl MevaCommand for PluginsRegisterCommand { +#[async_trait] +impl MevaCommand for PluginsRegisterCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "register" } @@ -139,7 +143,11 @@ impl MevaCommand for PluginsRegisterCommand { ) } - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + async fn execute( + &self, + matches: &ArgMatches, + container: &Self::Container, + ) -> miette::Result<()> { let command_str = matches.get_one::(Self::ARG_COMMAND).unwrap(); let event_str = matches.get_one::(Self::ARG_EVENT).unwrap(); let default_scope = ScopeType::Local.to_string(); diff --git a/cli/src/commands/plugins/subcommands/unregister.rs b/cli/src/commands/plugins/subcommands/unregister.rs index 49700de..66fdff9 100644 --- a/cli/src/commands/plugins/subcommands/unregister.rs +++ b/cli/src/commands/plugins/subcommands/unregister.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use clap::{ArgMatches, Command}; use engine::{ EngineContainer, @@ -21,7 +22,10 @@ impl PluginsUnregisterCommand { } } -impl MevaCommand for PluginsUnregisterCommand { +#[async_trait] +impl MevaCommand for PluginsUnregisterCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "unregister" } @@ -43,7 +47,11 @@ impl MevaCommand for PluginsUnregisterCommand { .with_scope_arg("Scope of the plugin") } - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + async fn execute( + &self, + matches: &ArgMatches, + container: &Self::Container, + ) -> miette::Result<()> { let command_str = matches.get_one::(Command::ARG_COMMAND).unwrap(); let default_scope = ScopeType::Local.to_string(); let scope_str = matches diff --git a/cli/src/commands/remote.rs b/cli/src/commands/remote.rs new file mode 100644 index 0000000..6666636 --- /dev/null +++ b/cli/src/commands/remote.rs @@ -0,0 +1,107 @@ +mod subcommands; + +use subcommands::*; + +use async_trait::async_trait; +use clap::{ArgMatches, Command}; +use miette::Result; + +use engine::engine_container::MevaContainer; + +use crate::{ + commands::{MevaCommand, execute_multiple}, + extensions::WithVerbose, +}; + +/// Implements the `remote` command for Meva DVCS. +/// +/// This command manages the set of tracked repositories ("remotes"). +/// It acts as a parent command for operations like adding, removing, or renaming remotes. +/// If no subcommand is provided, it defaults to listing the configured remotes. +pub struct RemoteCommand; + +impl RemoteCommand { + /// Creates a new instance of the [`RemoteCommand`]. + #[allow(dead_code)] + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl MevaCommand for RemoteCommand { + type Container = MevaContainer; + + fn name(&self) -> &'static str { + "remote" + } + + fn about(&self) -> &'static str { + "Manage remote repositories" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `remote` command. + fn build_command(&self) -> Command { + self.build_base_command() + .with_verbose_arg("Enable verbose output when listing remotes") + } + + /// Registers the list of available subcommands for `remote`. + /// + /// Includes: `add`, `get-url`, `remove`, `rename`, `set-url`, and `show`. + fn subcommands(&self) -> Vec>> { + vec![ + Box::new(RemoteAddCommand), + Box::new(RemoteGetUrlCommand), + Box::new(RemoteRemoveCommand), + Box::new(RemoteRenameCommand), + Box::new(RemoteSetUrlCommand), + Box::new(RemoteShowCommand), + ] + } + + /// Executes the `remote` command. + /// + /// Delegates execution to a subcommand if one is specified in `matches`. + /// If no subcommand is provided, it performs the default action of listing + /// the configured remotes (checking `ARG_VERBOSE` for detailed output). + /// + /// # Arguments + /// * `matches`: Parsed command-line arguments. + /// * `container`: Dependency injection container. + /// + /// # Returns + /// * `Result<()>`: Success or error during execution. + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { + // Try to execute a subcommand first + let handled = execute_multiple(matches, container, self.subcommands()).await?; + + // If no subcommand was invoked, fall back to listing remotes + if matches.subcommand_name().is_none() { + let verbose = matches.get_flag(Command::ARG_VERBOSE); + println!("{verbose}"); + // TODO: implement remotes listing logic here... + } + + Ok(handled) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = RemoteCommand::new(); + assert_eq!(cmd.name(), "remote"); + assert_eq!(cmd.about(), "Manage remote repositories"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/remote/subcommands.rs b/cli/src/commands/remote/subcommands.rs new file mode 100644 index 0000000..bdba6f5 --- /dev/null +++ b/cli/src/commands/remote/subcommands.rs @@ -0,0 +1,13 @@ +mod add; +mod get_url; +mod remove; +mod rename; +mod set_url; +mod show; + +pub use add::RemoteAddCommand; +pub use get_url::RemoteGetUrlCommand; +pub use remove::RemoteRemoveCommand; +pub use rename::RemoteRenameCommand; +pub use set_url::RemoteSetUrlCommand; +pub use show::RemoteShowCommand; diff --git a/cli/src/commands/remote/subcommands/add.rs b/cli/src/commands/remote/subcommands/add.rs new file mode 100644 index 0000000..ffbfcad --- /dev/null +++ b/cli/src/commands/remote/subcommands/add.rs @@ -0,0 +1,91 @@ +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use engine::engine_container::MevaContainer; +use url::Url; + +use crate::{commands::MevaCommand, extensions::WithName}; + +/// Implements the `remote add` subcommand for Meva DVCS. +/// +/// This command registers a new remote repository with a specific name and URL +/// in the local repository configuration. It optionally performs an immediate fetch. +pub struct RemoteAddCommand; + +impl RemoteAddCommand { + /// Creates a new instance of the [`RemoteAddCommand`]. + #[allow(dead_code)] + pub fn new() -> Self { + Self + } + + /// Argument name for the fetch flag. + const ARG_FETCH: &'static str = "fetch"; + + /// Argument name for the remote URL. + const ARG_URL: &'static str = "url"; +} + +#[async_trait] +impl MevaCommand for RemoteAddCommand { + type Container = MevaContainer; + + fn name(&self) -> &'static str { + "add" + } + + fn about(&self) -> &'static str { + "Add a new remote repository" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `remote add` command. + fn build_command(&self) -> Command { + self.build_base_command() + .with_name_arg("Name for the remote") + .arg( + Arg::new(Self::ARG_URL) + .value_name("URL") + .index(2) + .required(true) + .value_parser(clap::value_parser!(Url)) + .help("Repository URL (e.g. ssh://user@host:port/repository)"), + ) + .arg( + Arg::new(Self::ARG_FETCH) + .short('f') + .long(Self::ARG_FETCH) + .action(ArgAction::SetTrue) + .help("Fetch remote refs immediately after adding"), + ) + } + + /// Executes the `remote add` command. + /// + /// Registers the new remote configuration. If the configuration is successful + /// and the fetch flag is set, it initiates a fetch operation from the new remote. + async fn execute( + &self, + _matches: &ArgMatches, + _container: &Self::Container, + ) -> miette::Result<()> { + todo!(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = RemoteAddCommand::new(); + assert_eq!(cmd.name(), "add"); + assert_eq!(cmd.about(), "Add a new remote repository"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/remote/subcommands/get_url.rs b/cli/src/commands/remote/subcommands/get_url.rs new file mode 100644 index 0000000..098a38e --- /dev/null +++ b/cli/src/commands/remote/subcommands/get_url.rs @@ -0,0 +1,92 @@ +use async_trait::async_trait; +use clap::{Arg, ArgMatches, Command, ValueEnum, value_parser}; +use engine::engine_container::MevaContainer; + +use crate::{commands::MevaCommand, extensions::WithName}; + +/// Implements the `remote get-url` subcommand for Meva DVCS. +/// +/// This command retrieves and displays the URL associated with a tracked remote. +/// It allows filtering based on whether the URL is used for fetching or pushing. +pub struct RemoteGetUrlCommand; + +impl RemoteGetUrlCommand { + /// Creates a new instance of the [`RemoteGetUrlCommand`]. + #[allow(dead_code)] + pub fn new() -> Self { + Self + } + + /// Argument name for the mode selection (fetch/push/all). + const ARG_MODE: &'static str = "mode"; +} + +#[async_trait] +impl MevaCommand for RemoteGetUrlCommand { + type Container = MevaContainer; + + fn name(&self) -> &'static str { + "get-url" + } + + fn about(&self) -> &'static str { + "Show the URL of a remote" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `remote get-url` command. + fn build_command(&self) -> Command { + self.build_base_command() + .with_name_arg("Name for the remote") + .arg( + Arg::new(Self::ARG_MODE) + .long(Self::ARG_MODE) + .short('m') + .value_name("MODE") + .value_parser(value_parser!(UrlMode)) + .default_value("fetch") + .help("Select which URL(s) to show"), + ) + } + + /// Executes the `remote get-url` command. + /// + /// Looks up the specified remote in the configuration and prints the requested URL. + async fn execute( + &self, + _matches: &ArgMatches, + _container: &Self::Container, + ) -> miette::Result<()> { + todo!(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = RemoteGetUrlCommand::new(); + assert_eq!(cmd.name(), "get-url"); + assert_eq!(cmd.about(), "Show the URL of a remote"); + assert_eq!(cmd.version(), "1.0.0"); + } +} + +/// Specifies which URL type to retrieve for a remote repository. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, ValueEnum)] +pub enum UrlMode { + /// Retrieve the URL used for fetching data (default). + #[default] + Fetch, + /// Retrieve the URL used for pushing data. + Push, + /// Retrieve all URLs associated with the remote. + All, +} diff --git a/cli/src/commands/remote/subcommands/remove.rs b/cli/src/commands/remote/subcommands/remove.rs new file mode 100644 index 0000000..7029940 --- /dev/null +++ b/cli/src/commands/remote/subcommands/remove.rs @@ -0,0 +1,68 @@ +use async_trait::async_trait; +use clap::{ArgMatches, Command}; +use engine::engine_container::MevaContainer; + +use crate::{commands::MevaCommand, extensions::WithName}; + +/// Implements the `remote remove` subcommand for Meva DVCS. +/// +/// This command removes a remote repository configuration from the local repository. +/// Once removed, the repository will no longer track changes from that specific remote. +pub struct RemoteRemoveCommand; + +impl RemoteRemoveCommand { + /// Creates a new instance of the [`RemoteRemoveCommand`]. + #[allow(dead_code)] + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl MevaCommand for RemoteRemoveCommand { + type Container = MevaContainer; + + fn name(&self) -> &'static str { + "remove" + } + + fn about(&self) -> &'static str { + "Remove an existing remote" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `remote remove` command. + fn build_command(&self) -> Command { + self.build_base_command() + .with_name_arg("Name for the remote") + } + + /// Executes the `remote remove` command. + /// + /// Deletes the configuration for the specified remote name. + async fn execute( + &self, + _matches: &ArgMatches, + _container: &Self::Container, + ) -> miette::Result<()> { + todo!(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = RemoteRemoveCommand::new(); + assert_eq!(cmd.name(), "remove"); + assert_eq!(cmd.about(), "Remove an existing remote"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/remote/subcommands/rename.rs b/cli/src/commands/remote/subcommands/rename.rs new file mode 100644 index 0000000..ff2e78d --- /dev/null +++ b/cli/src/commands/remote/subcommands/rename.rs @@ -0,0 +1,89 @@ +use async_trait::async_trait; +use clap::{Arg, ArgMatches, Command}; +use engine::engine_container::MevaContainer; + +use crate::commands::MevaCommand; + +/// Implements the `remote rename` subcommand for Meva DVCS. +/// +/// This command updates the identifier of an existing remote repository. +/// It changes the name used to reference the remote in other commands +/// (like fetch or push) without altering the remote URL itself. +pub struct RemoteRenameCommand; + +impl RemoteRenameCommand { + /// Creates a new instance of the [`RemoteRenameCommand`]. + #[allow(dead_code)] + pub fn new() -> Self { + Self + } + + /// Argument name for the new name of the remote. + const ARG_NEW_NAME: &'static str = "new-name"; + + /// Argument name for the current (old) name of the remote. + const ARG_OLD_NAME: &'static str = "old-name"; +} + +#[async_trait] +impl MevaCommand for RemoteRenameCommand { + type Container = MevaContainer; + + fn name(&self) -> &'static str { + "rename" + } + + fn about(&self) -> &'static str { + "Rename a remote" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `remote rename` command. + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_OLD_NAME) + .value_name("OLD_NAME") + .index(1) + .required(true) + .help("Current name of the remote"), + ) + .arg( + Arg::new(Self::ARG_NEW_NAME) + .value_name("NEW_NAME") + .index(2) + .required(true) + .help("New name for the remote"), + ) + } + + /// Executes the `remote rename` command. + /// + /// Validates that the old name exists and the new name is not already taken, + /// then updates the repository configuration to reflect the name change. + async fn execute( + &self, + _matches: &ArgMatches, + _container: &Self::Container, + ) -> miette::Result<()> { + todo!(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = RemoteRenameCommand::new(); + assert_eq!(cmd.name(), "rename"); + assert_eq!(cmd.about(), "Rename a remote"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/remote/subcommands/set_url.rs b/cli/src/commands/remote/subcommands/set_url.rs new file mode 100644 index 0000000..6cb6293 --- /dev/null +++ b/cli/src/commands/remote/subcommands/set_url.rs @@ -0,0 +1,102 @@ +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use engine::engine_container::MevaContainer; +use url::Url; + +use crate::{commands::MevaCommand, extensions::WithName}; + +/// Implements the `remote set-url` subcommand for Meva DVCS. +/// +/// This command updates the URL for an existing remote repository. +/// It supports changing either the fetch URL (default) or the push URL, +/// and can selectively replace a specific URL if the remote has multiple. +pub struct RemoteSetUrlCommand; + +impl RemoteSetUrlCommand { + /// Creates a new instance of the [`RemoteSetUrlCommand`]. + #[allow(dead_code)] + pub fn new() -> Self { + Self + } + + /// Argument name for the push flag. + const ARG_PUSH: &'static str = "push"; + + /// Argument name for the new URL. + const ARG_NEW_URL: &'static str = "new-url"; + + /// Argument name for the old URL (optional filter). + const ARG_OLD_URL: &'static str = "old-url"; +} + +#[async_trait] +impl MevaCommand for RemoteSetUrlCommand { + type Container = MevaContainer; + + fn name(&self) -> &'static str { + "set-url" + } + + fn about(&self) -> &'static str { + "Set the URL of a remote" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `remote set-url` command. + fn build_command(&self) -> Command { + self.build_base_command() + .with_name_arg("Name for the remote") + .arg( + Arg::new(Self::ARG_NEW_URL) + .value_name("NEW_URL") + .index(2) + .required(true) + .value_parser(clap::value_parser!(Url)) + .help("New repository URL (e.g. ssh://user@host:port/repository)"), + ) + .arg( + Arg::new(Self::ARG_OLD_URL) + .value_name("OLD_URL") + .index(3) + .required(false) + .value_parser(clap::value_parser!(Url)) + .help("Old URL to replace if the remote has multiple URLs"), + ) + .arg( + Arg::new(Self::ARG_PUSH) + .long(Self::ARG_PUSH) + .short('p') + .action(ArgAction::SetTrue) + .help("Modify the push-URL instead of the fetch-URL"), + ) + } + + /// Executes the `remote set-url` command. + /// + /// Updates the configuration for the specified remote. + async fn execute( + &self, + _matches: &ArgMatches, + _container: &Self::Container, + ) -> miette::Result<()> { + todo!(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = RemoteSetUrlCommand::new(); + assert_eq!(cmd.name(), "set-url"); + assert_eq!(cmd.about(), "Set the URL of a remote"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/remote/subcommands/show.rs b/cli/src/commands/remote/subcommands/show.rs new file mode 100644 index 0000000..816be87 --- /dev/null +++ b/cli/src/commands/remote/subcommands/show.rs @@ -0,0 +1,81 @@ +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use engine::engine_container::MevaContainer; + +use crate::{commands::MevaCommand, extensions::WithName}; + +/// Implements the `remote show` subcommand for Meva DVCS. +/// +/// This command displays detailed information about a specific remote repository, +/// such as its URLs and the status of tracked branches. It can optionally retrieve +/// live data from the remote or rely solely on local configuration. +pub struct RemoteShowCommand; + +impl RemoteShowCommand { + /// Creates a new instance of the [`RemoteShowCommand`]. + #[allow(dead_code)] + pub fn new() -> Self { + Self + } + + /// Argument name for the no-fetch flag. + const ARG_NO_FETCH: &'static str = "no-fetch"; +} + +#[async_trait] +impl MevaCommand for RemoteShowCommand { + type Container = MevaContainer; + + fn name(&self) -> &'static str { + "show" + } + + fn about(&self) -> &'static str { + "Show the URL of a remote" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `remote show` command. + fn build_command(&self) -> Command { + self.build_base_command() + .with_name_arg("Name for the remote") + .arg( + Arg::new(Self::ARG_NO_FETCH) + .short('n') + .long("no-fetch") + .action(ArgAction::SetTrue) + .help("Use local configuration for tracked branches"), + ) + } + + /// Executes the `remote show` command. + /// + /// Retrieves and prints details about the specified remote. + /// Unless `--no-fetch` is specified, this command may attempt to contact + /// the remote server to display the most up-to-date branch status. + async fn execute( + &self, + _matches: &ArgMatches, + _container: &Self::Container, + ) -> miette::Result<()> { + todo!(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = RemoteShowCommand::new(); + assert_eq!(cmd.name(), "show"); + assert_eq!(cmd.about(), "Show the URL of a remote"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/restore.rs b/cli/src/commands/restore.rs index 2ac51f9..0ddffd7 100644 --- a/cli/src/commands/restore.rs +++ b/cli/src/commands/restore.rs @@ -1,4 +1,5 @@ use crate::commands::MevaCommand; +use async_trait::async_trait; use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; use engine::engine_container::{EngineContainer, MevaContainer}; use engine::handlers::restore::Request; @@ -39,7 +40,10 @@ impl RestoreCommand { const ARG_PATHS: &'static str = "paths"; } -impl MevaCommand for RestoreCommand { +#[async_trait] +impl MevaCommand for RestoreCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "restore" } @@ -93,7 +97,7 @@ impl MevaCommand for RestoreCommand { /// - Delegates to the repository’s [`restore_handler`] to perform the restore operation. /// /// Returns a [`miette::Result`] with diagnostic information if an error occurs. - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + async fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { let staged = matches.get_flag(Self::ARG_STAGED); let worktree = matches.get_flag(Self::ARG_WORKTREE); let source = matches diff --git a/cli/src/commands/show.rs b/cli/src/commands/show.rs index bc3cf75..8674cd2 100644 --- a/cli/src/commands/show.rs +++ b/cli/src/commands/show.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command, value_parser}; use engine::{ EngineContainer, @@ -102,7 +103,10 @@ impl ShowCommand { } } -impl MevaCommand for ShowCommand { +#[async_trait] +impl MevaCommand for ShowCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "show" } @@ -179,7 +183,7 @@ impl MevaCommand for ShowCommand { } /// Executes the `show` command. - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { let snapshot_id = matches.get_one::(Self::ARG_SNAPSHOT_ID).unwrap(); let handler = container.show_handler().into_diagnostic()?; diff --git a/cli/src/commands/status.rs b/cli/src/commands/status.rs index d6edc83..b9ebf8c 100644 --- a/cli/src/commands/status.rs +++ b/cli/src/commands/status.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use clap::{Arg, ArgAction, ArgMatches, Command}; use engine::{EngineContainer, engine_container::MevaContainer, handlers::status::Request}; @@ -30,7 +31,10 @@ impl StatusCommand { const ARG_IGNORED: &'static str = "ignored"; } -impl MevaCommand for StatusCommand { +#[async_trait] +impl MevaCommand for StatusCommand { + type Container = MevaContainer; + fn name(&self) -> &'static str { "status" } @@ -87,7 +91,7 @@ impl MevaCommand for StatusCommand { } /// Executes the `status` command. - fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { let request = Request::from_flags( matches.get_flag(Self::ARG_SHORT), matches.get_flag(Self::ARG_BRANCH), diff --git a/cli/src/extensions/command.rs b/cli/src/extensions/command.rs index eaf038e..6e6946c 100644 --- a/cli/src/extensions/command.rs +++ b/cli/src/extensions/command.rs @@ -1,13 +1,17 @@ -pub mod with_command_and_plugin; -pub mod with_file; -pub mod with_key; -pub mod with_locations; -pub mod with_pattern; -pub mod with_scope; +mod with_command_and_plugin; +mod with_file; +mod with_key; +mod with_locations; +mod with_name; +mod with_pattern; +mod with_scope; +mod with_verbose; pub use with_command_and_plugin::WithCommandPlugin; pub use with_file::WithFile; pub use with_key::WithKey; pub use with_locations::WithLocations; +pub use with_name::WithName; pub use with_pattern::WithPattern; pub use with_scope::WithScope; +pub use with_verbose::WithVerbose; diff --git a/cli/src/extensions/command/with_name.rs b/cli/src/extensions/command/with_name.rs new file mode 100644 index 0000000..ce4f642 --- /dev/null +++ b/cli/src/extensions/command/with_name.rs @@ -0,0 +1,79 @@ +use clap::{Arg, Command}; + +/// Trait to add a required `name` argument to a Clap command. +pub trait WithName { + /// Constant name of the CLI argument for the name. + const ARG_NAME: &'static str; + + /// Extends a `Command` by adding a positional `name` argument with the given help text. + /// + /// # Arguments + /// + /// * `self` - The command being extended. + /// * `key_help` - Help message describing the purpose of the `name` argument. + /// + /// # Returns + /// + /// The original `Command` with the `name` argument appended. + fn with_name_arg(self, name_help: &'static str) -> Self; +} + +impl WithName for Command { + const ARG_NAME: &'static str = "name"; + + fn with_name_arg(self, name_help: &'static str) -> Self { + self.arg( + Arg::new(Self::ARG_NAME) + .value_name("NAME") + .index(1) + .required(true) + .help(name_help), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Command; + use pretty_assertions::assert_eq; + use rstest::{fixture, rstest}; + + #[fixture] + fn cmd() -> Command { + Command::new("cmd").with_name_arg("help text") + } + + #[rstest] + fn adds_argument_with_correct_properties(cmd: Command) { + let arg = cmd + .get_positionals() + .find(|a| a.get_id() == "name") + .expect("Argument should exist"); + + assert!(arg.is_required_set()); + assert_eq!(arg.get_index(), Some(1)); + assert_eq!( + arg.get_value_names().unwrap().iter().next().unwrap(), + "NAME" + ); + } + + #[rstest] + #[case(vec!["cmd", "origin"], Some("origin"))] + #[case(vec!["cmd"], None)] + fn yields_key_arg(#[case] args: Vec<&str>, #[case] expected: Option<&str>, cmd: Command) { + let matches = cmd.clone().try_get_matches_from(args); + + match expected { + Some(key) => { + let m = matches.unwrap(); + let got: &String = m.get_one("name").expect("Should have value"); + assert_eq!(got, key); + } + None => { + assert!(matches.is_err()); + } + } + } +} diff --git a/cli/src/extensions/command/with_verbose.rs b/cli/src/extensions/command/with_verbose.rs new file mode 100644 index 0000000..8bde929 --- /dev/null +++ b/cli/src/extensions/command/with_verbose.rs @@ -0,0 +1,33 @@ +use clap::{Arg, Command}; + +/// Trait to add a `verbose` flag to a Clap command. +pub trait WithVerbose { + /// Constant name of the CLI flag for the verbose. + const ARG_VERBOSE: &'static str; + + /// Extends a `Command` by adding a `verbose` flag with the given help text. + /// + /// # Arguments + /// + /// * `self` - The command being extended. + /// * `key_help` - Help message describing the purpose of the `verbose` flag. + /// + /// # Returns + /// + /// The original `Command` with the `verbose` flag appended. + fn with_verbose_arg(self, verbose_help: &'static str) -> Self; +} + +impl WithVerbose for Command { + const ARG_VERBOSE: &'static str = "verbose"; + + fn with_verbose_arg(self, verbose_help: &'static str) -> Self { + self.arg( + Arg::new(Self::ARG_VERBOSE) + .long(Self::ARG_VERBOSE) + .short('v') + .action(clap::ArgAction::SetTrue) + .help(verbose_help), + ) + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 9dbe231..df6f6aa 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -2,18 +2,21 @@ mod commands; mod extensions; mod meva_cli; -use crate::{commands::MevaCommand, meva_cli::MevaCli}; +use miette::Result; + +use crate::{commands::RemoteCommand, meva_cli::MevaCli}; use commands::{ - AddCommand, CommitCommand, ConfigCommand, DiffCommand, IgnoreCommand, InitCommand, LogCommand, - LsFilesCommand, LsTreeCommand, PluginsCommand, RestoreCommand, ShowCommand, StatusCommand, + AddCommand, CloneCommand, CommitCommand, ConfigCommand, DiffCommand, IgnoreCommand, + InitCommand, LogCommand, LsFilesCommand, LsTreeCommand, MevaCommand, PluginsCommand, + RestoreCommand, ShowCommand, StatusCommand, fetch::FetchCommand, }; use engine::engine_container::MevaContainer; -use miette::Result; -fn main() -> Result<()> { +#[tokio::main] +async fn main() -> Result<()> { miette::set_panic_hook(); - let commands: Vec>> = vec![ + let commands: Vec>> = vec![ Box::new(InitCommand::new()), Box::new(ConfigCommand::new()), Box::new(IgnoreCommand::new()), @@ -27,6 +30,9 @@ fn main() -> Result<()> { Box::new(LsTreeCommand::new()), Box::new(LogCommand::new()), Box::new(RestoreCommand::new()), + Box::new(CloneCommand::new()), + Box::new(RemoteCommand::new()), + Box::new(FetchCommand::new()), ]; let container = MevaContainer; @@ -36,5 +42,6 @@ fn main() -> Result<()> { cli.add_command(command); } - cli.run() + cli.run().await?; + Ok(()) } diff --git a/cli/src/meva_cli.rs b/cli/src/meva_cli.rs index f275631..ed2444c 100644 --- a/cli/src/meva_cli.rs +++ b/cli/src/meva_cli.rs @@ -1,53 +1,54 @@ use std::collections::HashMap; use clap::{Command, error::ErrorKind}; -use engine::EngineContainer; +use engine::engine_container::MevaContainer; use miette::{IntoDiagnostic, Result, WrapErr, miette}; use crate::commands::MevaCommand; -/// Represents the top-level CLI application for Meva DVCS. +/// The main entry point for the Meva CLI application. /// -/// This struct manages command registration, building the CLI parser, -/// and dispatching command execution based on user input. -pub struct MevaCli -where - T: EngineContainer, -{ - /// Registered commands available in the CLI, stored as a map from command name to command object. - commands: HashMap<&'static str, Box>>, +/// This struct orchestrates the entire command-line interface lifecycle. +pub struct MevaCli { + /// Registry of available commands, mapped from command name to the command instance. + commands: HashMap<&'static str, Box>>, - /// Dependency injection container. - container: T, + /// Dependency injection container providing access to core engine services. + container: MevaContainer, } -impl MevaCli { - /// Creates a new instance of the Meva CLI application with no commands registered. - pub fn new(container: T) -> Self { +impl MevaCli { + /// Creates a new `MevaCli` instance with the provided dependency container. + /// + /// The instance starts with no registered commands. Use [`add_command`](Self::add_command) to populate it. + /// + /// # Arguments + /// * `container`: The initialized `MevaContainer` holding shared resources/services. + pub fn new(container: MevaContainer) -> Self { Self { commands: HashMap::new(), container, } } - /// Adds a new command to the CLI application. + /// Registers a command with the CLI. /// - /// Commands must implement the `MevaCommand` trait. - /// The command is stored in a map, with the command's name as the key. + /// The command is stored in an internal registry keyed by its name (as returned by `command.name()`). /// /// # Arguments - /// * `command`: The boxed command to register. - pub fn add_command(&mut self, command: Box>) { + /// * `command`: A boxed instance implementing the `MevaCommand` trait. + pub fn add_command(&mut self, command: Box>) { self.commands.insert(command.name(), command); } - /// Builds the complete CLI parser using `clap::Command`. + /// Constructs the `clap` argument parser configuration. /// - /// Registers all added commands as subcommands, - /// and configures top-level metadata like name, version, and help behavior. + /// This method iterates over all registered commands, adding them as subcommands + /// to the main application parser. It also configures global settings such as + /// the application name, version, and description. /// /// # Returns - /// - A fully configured `clap::Command` ready to parse CLI arguments. + /// A fully configured [`clap::Command`] builder ready for argument parsing. fn build_cli(&self) -> Command { let mut cli = Command::new("meva") .about("Meva distributed version control system") @@ -62,15 +63,14 @@ impl MevaCli { cli } - /// Runs the CLI application. - /// - /// This method builds the CLI parser, parses the command-line arguments, - /// and dispatches execution to the matched command. - pub fn run(&self) -> Result<()> { + /// Executes the CLI application. + pub async fn run(&self) -> Result<()> { let matches = match self.build_cli().try_get_matches() { Ok(m) => m, Err(err) => { return match err.kind() { + // DisplayHelp and DisplayVersion are not "errors" in the traditional sense, + // so we print the info and exit gracefully. ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => { err.print().into_diagnostic()?; Ok(()) @@ -85,6 +85,7 @@ impl MevaCli { { return cmd .execute(sub_matches, &self.container) + .await .wrap_err_with(|| format!("Error running `{name}` command")); } diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 5b18c8d..5c5d340 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -12,7 +12,7 @@ thiserror.workspace = true serde.workspace = true serde_json.workspace = true regex.workspace = true -toml = "0.9.2" +toml.workspace = true toml_edit = "0.23.2" dirs.workspace = true globset.workspace = true @@ -29,6 +29,12 @@ tree-ds = "0.2.0" similar.workspace = true owo-colors.workspace = true path-absolutize.workspace = true +russh.workspace = true +russh-keys.workspace = true +cryptovec.workspace = true +async-trait.workspace = true +tokio.workspace = true +url.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/engine/src/config/config_loader.rs b/engine/src/config/config_loader.rs index 3aa67c7..312f54d 100644 --- a/engine/src/config/config_loader.rs +++ b/engine/src/config/config_loader.rs @@ -1,4 +1,4 @@ -use std::{io::Write, path::Path, str::FromStr}; +use std::{fmt::Debug, io::Write, path::Path, str::FromStr}; use shared::fs::create_file_with_dirs; @@ -8,7 +8,7 @@ use crate::{ errors::{ConfigError, EngineError, EngineResult}, }; -pub trait ConfigLoader: Send + Sync { +pub trait ConfigLoader: Send + Sync + Debug { /// Retrieve a configuration value by key, searching through each /// configured location until found or falling back to default. /// @@ -42,6 +42,7 @@ pub trait ConfigLoader: Send + Sync { /// Responsible for loading configuration values from multiple sources /// in a defined search order (e.g., local, global). +#[derive(Debug)] pub struct MevaConfigLoader { /// Ordered list of locations to search for configuration files. locations: Vec, @@ -170,6 +171,7 @@ impl ConfigLoader for MevaConfigLoader { "# [user]\n", "# name = \"Your Name\"\n", "# email = \"your.email@example.com\"\n", + "# signing_key = \"path/to/your/signing/key\"\n", "\n", "# [editor]\n", "# default = \"vim\"\n", diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 4b3c4b4..784030e 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -7,9 +7,10 @@ use crate::commit_builder::MevaCommitBuilder; use crate::diff_builder::MevaDiffBuilder; use crate::errors::EngineResult; use crate::handlers::{ - add::AddHandler, commit, commit::CommitHandler, config::ConfigHandler, diff::DiffHandler, - init::InitHandler, log::LogHandler, ls_files::LsFilesHandler, ls_tree::LsTreeHandler, - plugins::PluginsHandler, restore::RestoreHandler, show::ShowHandler, status::StatusHandler, + add::AddHandler, clone::CloneHandler, commit, commit::CommitHandler, config::ConfigHandler, + diff::DiffHandler, init::InitHandler, log::LogHandler, ls_files::LsFilesHandler, + ls_tree::LsTreeHandler, plugins::PluginsHandler, restore::RestoreHandler, show::ShowHandler, + status::StatusHandler, }; use crate::index::{MevaIndex, MevaWorkingDir}; use crate::object_storage::{MevaDryRunObjectStorage, MevaObjectStorage}; @@ -61,6 +62,9 @@ pub trait EngineContainer { /// Return the handler responsible for restore command fn restore_handler(&self) -> EngineResult; + + /// Return the handler responsible for clone command + fn clone_handler(&self) -> EngineResult; } /// Concrete implementation of `EngineContainer` for Meva. @@ -77,6 +81,11 @@ impl MevaContainer { Ok(Arc::new(MevaRepositoryLayout::from_env()?)) } + /// Returns the Meva-specific repository layout. + fn repository_layout_discover(&self) -> EngineResult> { + Ok(Arc::new(MevaRepositoryLayout::discover()?)) + } + /// Builds a [`PluginsEngine`] instance used by Meva. fn plugins_engine(&self) -> EngineResult> { Ok(Arc::new(MevaPluginsEngine { @@ -99,9 +108,10 @@ impl EngineContainer for MevaContainer { } fn init_handler(&self) -> EngineResult { - let layout = Arc::new(MevaRepositoryLayout::from_env()?); + let layout = self.repository_layout_env()?; let config_loader = Arc::new(MevaConfigLoader::default()); - let repository = Arc::new(MevaRepository::new(layout, config_loader)); + let object_storage = Arc::new(MevaObjectStorage::new(layout.clone())); + let repository = Arc::new(MevaRepository::new(layout, config_loader, object_storage)); let handler = InitHandler { repository }; Ok(handler) } @@ -112,14 +122,14 @@ impl EngineContainer for MevaContainer { fn plugins_handler(&self) -> EngineResult { let plugins_repository = self.plugins_engine()?; - let repository_layout = Arc::new(MevaRepositoryLayout::from_env()?); + let repository_layout = self.repository_layout_env()?; let handler = PluginsHandler::new(plugins_repository, repository_layout); Ok(handler) } fn add_handler(&self) -> EngineResult { - let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let repo_layout = self.repository_layout_discover()?; let working_dir = Arc::new(MevaWorkingDir::with_ignore_services(repo_layout.clone())); let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); let index = Arc::new(RwLock::new(MevaIndex::from_disk( @@ -133,7 +143,7 @@ impl EngineContainer for MevaContainer { } fn ls_files_handler(&self) -> EngineResult { - let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let repo_layout = self.repository_layout_discover()?; let working_dir = Arc::new(MevaWorkingDir::with_ignore_services(repo_layout.clone())); let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); let index = Arc::new(MevaIndex::from_disk(working_dir, object_storage, None)?); @@ -143,7 +153,7 @@ impl EngineContainer for MevaContainer { } fn ls_tree_handler(&self) -> EngineResult { - let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let repo_layout = self.repository_layout_discover()?; let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); let refs_manager = Arc::new(MevaRefManager::new(repo_layout)); let revision_resolver = Arc::new(MevaRevisionResolver::new( @@ -157,7 +167,7 @@ impl EngineContainer for MevaContainer { } fn status_handler(&self) -> EngineResult { - let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let repo_layout = self.repository_layout_discover()?; let working_dir = Arc::new(MevaWorkingDir::with_ignore_services(repo_layout.clone())); let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); let index = Arc::new(MevaIndex::from_disk( @@ -188,7 +198,7 @@ impl EngineContainer for MevaContainer { } fn commit_handler(&self, request: &commit::Request) -> EngineResult { - let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let repo_layout = self.repository_layout_discover()?; let refs_manager = Arc::new(MevaRefManager::new(repo_layout.clone())); let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); let revision_resolver = Arc::new(MevaRevisionResolver::new( @@ -218,7 +228,7 @@ impl EngineContainer for MevaContainer { )); let commit_builder = match request.dry_run_arg { true => { - let dry_run_object_storage = Arc::new(MevaDryRunObjectStorage::new()); + let dry_run_object_storage = Arc::new(MevaDryRunObjectStorage); Arc::new(MevaCommitBuilder::new(dry_run_object_storage, index_abs)) } false => Arc::new(MevaCommitBuilder::new(object_storage, index_abs)), @@ -237,7 +247,7 @@ impl EngineContainer for MevaContainer { } fn log_handler(&self) -> EngineResult { - let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let repo_layout = self.repository_layout_discover()?; let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); let refs_manager = Arc::new(MevaRefManager::new(repo_layout.clone())); let revision_resolver = Arc::new(MevaRevisionResolver::new( @@ -263,7 +273,7 @@ impl EngineContainer for MevaContainer { } fn diff_handler(&self) -> EngineResult { - let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let repo_layout = self.repository_layout_discover()?; let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); let refs_manager = Arc::new(MevaRefManager::new(repo_layout.clone())); let revision_resolver = Arc::new(MevaRevisionResolver::new( @@ -288,7 +298,7 @@ impl EngineContainer for MevaContainer { } fn show_handler(&self) -> EngineResult { - let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let repo_layout = self.repository_layout_discover()?; let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); let refs_manager = Arc::new(MevaRefManager::new(repo_layout.clone())); let revision_resolver = Arc::new(MevaRevisionResolver::new( @@ -313,7 +323,7 @@ impl EngineContainer for MevaContainer { } fn restore_handler(&self) -> EngineResult { - let repo_layout = Arc::new(MevaRepositoryLayout::discover()?); + let repo_layout = self.repository_layout_discover()?; let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); let working_dir = Arc::new(MevaWorkingDir::with_ignore_services(repo_layout.clone())); let index = Arc::new(RwLock::new(MevaIndex::from_disk( @@ -337,4 +347,11 @@ impl EngineContainer for MevaContainer { Ok(handler) } + + fn clone_handler(&self) -> EngineResult { + let repository_layout = self.repository_layout_env()?; + let config_loader = Arc::new(MevaConfigLoader::default()); + let handler = CloneHandler::new(repository_layout, config_loader); + Ok(handler) + } } diff --git a/engine/src/errors.rs b/engine/src/errors.rs index e1f90d7..1920101 100644 --- a/engine/src/errors.rs +++ b/engine/src/errors.rs @@ -1,20 +1,24 @@ +pub mod clone_error; pub mod commit_error; pub mod config_error; pub mod engine_error; pub mod ignore_error; pub mod index_error; pub mod init_error; +pub mod network_error; pub mod path_error; pub mod repository_error; pub mod revision_error; pub mod tree_error; pub mod unpack_error; +pub use clone_error::CloneError; pub use commit_error::CommitError; pub use config_error::ConfigError; pub use ignore_error::IgnoreError; pub use index_error::IndexError; pub use init_error::InitError; +pub use network_error::NetworkError; pub use path_error::PathError; pub use repository_error::RepositoryError; pub use revision_error::RevisionError; diff --git a/engine/src/errors/clone_error.rs b/engine/src/errors/clone_error.rs new file mode 100644 index 0000000..f45a834 --- /dev/null +++ b/engine/src/errors/clone_error.rs @@ -0,0 +1,14 @@ +use std::path::PathBuf; + +use thiserror::Error; + +/// Represents errors that can occur during the repository cloning process. +#[derive(Error, Debug)] +pub enum CloneError { + /// Indicates that the target directory for cloning is invalid. + #[error("Destination path '{path}' already exists and is not an empty directory.")] + InvalidCloneDir { + /// The path of the directory that caused the error. + path: PathBuf, + }, +} diff --git a/engine/src/errors/engine_error.rs b/engine/src/errors/engine_error.rs index 7566adb..17af6c5 100644 --- a/engine/src/errors/engine_error.rs +++ b/engine/src/errors/engine_error.rs @@ -5,8 +5,8 @@ use std::{io, string::FromUtf8Error}; use thiserror::Error; use crate::errors::{ - CommitError, ConfigError, IgnoreError, IndexError, InitError, PathError, RepositoryError, - RevisionError, TreeError, UnpackError, + CloneError, CommitError, ConfigError, IgnoreError, IndexError, InitError, NetworkError, + PathError, RepositoryError, RevisionError, TreeError, UnpackError, }; /// A convenient result type alias for engine-related operations. @@ -27,6 +27,13 @@ pub enum EngineError { #[error(transparent)] Init(#[from] InitError), + /// An error occurred during the cloning process. + /// + /// Typically wraps validation errors regarding the clone destination or + /// failures to initialize the new repository context. + #[error(transparent)] + Clone(#[from] CloneError), + /// Configuration-layer parsing, lookup, or validation error. #[error(transparent)] Config(#[from] ConfigError), @@ -118,6 +125,12 @@ pub enum EngineError { #[error(transparent)] RevisionResolver(#[from] RevisionError), + /// Errors related to network operations and remote communication. + /// + /// This includes failures in protocol handshakes, data transfer, or URL parsing. + #[error(transparent)] + Network(#[from] NetworkError), + /// A catch-all variant for any unknown or unexpected engine error. /// Accepts a descriptive string message. #[error("Unknown engine error: {0}")] diff --git a/engine/src/errors/network_error.rs b/engine/src/errors/network_error.rs new file mode 100644 index 0000000..cdc8787 --- /dev/null +++ b/engine/src/errors/network_error.rs @@ -0,0 +1,98 @@ +use std::{num::ParseIntError, str::Utf8Error}; + +use thiserror::Error; + +use crate::network::PackfileError; + +/// Represents errors that can occur during network operations and remote interactions. +/// +/// This enum covers the entire stack of networking issues, from low-level +/// SSH connection failures and authentication problems to protocol-specific +/// data transfer errors (packfiles, sidebands). +#[derive(Error, Debug)] +pub enum NetworkError { + /// Wraps errors from the underlying SSH client library ([`russh`]). + /// + /// Includes connection timeouts, handshake failures, and transport layer issues. + #[error(transparent)] + Ssh(#[from] russh::Error), + + /// Wraps errors related to SSH key loading and parsing ([`russh_keys`]). + /// + /// Occurs when reading identity files or processing agent keys. + #[error(transparent)] + Keys(#[from] russh_keys::Error), + + /// Wraps errors related to URL parsing. + #[error(transparent)] + Url(#[from] url::ParseError), + + /// Wraps UTF-8 conversion errors. + /// + /// Occurs when output received from the remote server cannot be decoded as valid text. + #[error(transparent)] + Utf8(#[from] Utf8Error), + + /// Wraps error which may occur during parsing of an integer. + #[error(transparent)] + ParseInt(#[from] ParseIntError), + + /// Wraps errors related to packfile handling. + /// + /// Occurs during the generation or parsing of the data stream used + /// to transfer objects between repositories. + #[error(transparent)] + Packfile(#[from] PackfileError), + + /// Indicates that the command executed on the remote server exited with a failure status. + #[error("Remote command failed with status {status}")] + RemoteCommand { + /// The exit code returned by the remote process. + status: u32, + }, + + /// Indicates that authentication with the remote host failed. + /// + /// This could be due to rejected keys, invalid credentials, or exhausted auth methods. + #[error("Authentication failed")] + Authentication, + + /// Validation error: The provided SSH URL is missing a username. + #[error("Missing username in SSH URL")] + MissingUsername, + + /// Validation error: The provided SSH URL is missing a host address. + #[error("Missing host in SSH URL")] + MissingHost, + + /// Validation error: The provided SSH URL does not specify a repository path. + #[error("Missing repository path in SSH URL")] + MissingRepositoryPath, + + /// Received data on an unknown sideband channel. + /// + /// Meva's protocol uses specific byte identifiers (bands) to multiplex data, + /// progress information, and errors. This error implies a protocol mismatch. + #[error("Unknown channel band '{channel_band}'")] + UnknownChannel { + /// The byte identifier of the unknown channel. + channel_band: u8, + }, + + /// Represents an explicit error message returned by the remote Meva instance. + /// + /// This differs from `RemoteCommand` in that the server successfully communicated + /// a specific application-level error message before closing. + #[error("Remote error: {message}")] + RemoteError { + /// The error message sent by the remote server. + message: String, + }, + + /// Represents an explicit error message returned when the server is unreachable. + #[error("Connection timed out. The server is unreachable.")] + ConnectionTimeout, + + #[error("Connection closed by server before protocol completed.")] + ConnectionClosedPrematurely, +} diff --git a/engine/src/handlers.rs b/engine/src/handlers.rs index 136aafa..5873a94 100644 --- a/engine/src/handlers.rs +++ b/engine/src/handlers.rs @@ -1,4 +1,5 @@ pub mod add; +pub mod clone; pub mod commit; pub mod config; pub mod diff; diff --git a/engine/src/handlers/clone.rs b/engine/src/handlers/clone.rs new file mode 100644 index 0000000..13f46b5 --- /dev/null +++ b/engine/src/handlers/clone.rs @@ -0,0 +1,5 @@ +pub mod handlers; +pub mod operations; + +pub use handlers::CloneHandler; +pub use operations::*; diff --git a/engine/src/handlers/clone/handlers.rs b/engine/src/handlers/clone/handlers.rs new file mode 100644 index 0000000..f27fc64 --- /dev/null +++ b/engine/src/handlers/clone/handlers.rs @@ -0,0 +1,136 @@ +use std::{ + fs, + path::{Path, PathBuf}, + sync::Arc, +}; + +use async_trait::async_trait; +use tempfile::TempDir; + +use crate::{ + ConfigLoader, MevaConfigLoader, MevaRepository, RepositoryLayout, + errors::{CloneError, EngineError, EngineResult, NetworkError}, + network::{PackfileCodec, SshConnectionParams, SshService}, + object_storage::MevaObjectStorage, + objects::MevaObject, + ref_manager::RefEntry, + repositories::{meva_repository::Repository, meva_repository_layout::MevaRepositoryLayout}, +}; + +use super::{CloneOperations, Request, Response}; + +#[derive(Debug)] +pub struct CloneHandler { + ssh_service: SshService, + repository_layout: Arc, + config_loader: Arc, +} + +impl CloneHandler { + pub fn new( + repository_layout: Arc, + config_loader: Arc, + ) -> Self { + Self { + repository_layout, + config_loader, + ssh_service: SshService, + } + } + + pub async fn handle_clone(&self, request: Request) -> EngineResult { + self.clone(request).await + } + + fn get_user_signing_key(&self) -> EngineResult { + Ok(PathBuf::from( + self.config_loader.get("user.signing_key", None)?, + )) + } + + fn resolve_clone_path( + &self, + request: &Request, + connection_params: &SshConnectionParams, + ) -> EngineResult { + match &request.directory { + Some(dir) => Ok(self.repository_layout.working_dir().join(dir)), + None => { + let path_from_url = PathBuf::from(&connection_params.repository_name); + let dir = path_from_url + .file_stem() + .ok_or_else(|| NetworkError::MissingRepositoryPath)?; + Ok(self.repository_layout.working_dir().join(dir)) + } + } + } + + async fn temp_clone( + &self, + temp_path: &Path, + objects: &[(MevaObject, Vec)], + refs: &[RefEntry], + ) -> EngineResult { + let repository_layout = Arc::new(MevaRepositoryLayout::new(temp_path.to_path_buf())?); + let object_storage = Arc::new(MevaObjectStorage::new(repository_layout.clone())); + let repository = MevaRepository::new( + repository_layout.clone(), + Arc::new(MevaConfigLoader::default()), + object_storage, + ); + + repository.clone(objects, refs) + } +} + +#[async_trait] +impl CloneOperations for CloneHandler { + async fn clone(&self, request: Request) -> EngineResult { + let mut connection_params = SshConnectionParams::try_from(&request.url)?; + + let path = self.resolve_clone_path(&request, &connection_params)?; + if path.exists() && path.read_dir()?.next().is_some() { + return Err(CloneError::InvalidCloneDir { path }.into()); + } + + let client_key = self.get_user_signing_key()?; + connection_params.client_key_path = client_key; + connection_params.server_key_path = request.server_key; + + let mut ssh_session = self.ssh_service.connect(&connection_params).await?; + + let packfile_result = ssh_session + .run_upload_pack(&connection_params.repository_name, Vec::new()) + .await?; + + let refs = packfile_result.refs; + let packfile = packfile_result.packfile_data; + + println!("Decoding objects..."); + + let objects = PackfileCodec::default().decode_packfile(&packfile)?; + + println!("Successfully decoded {} objects.", objects.len()); + + let parent_dir = &self.repository_layout.working_dir(); + let temp_dir = TempDir::new_in(parent_dir).map_err(EngineError::Io)?; + let temp_path = temp_dir.path().to_path_buf(); + + let clone_result = self.temp_clone(&temp_path, &objects, &refs).await; + + match clone_result { + Ok(_) => { + fs::rename(&temp_path, &path)?; + if !request.quiet { + println!("Successfully cloned into {path:?}"); + } + Ok(Response {}) + } + Err(e) => { + eprintln!("Clone failed, cleaning up temporary directory..."); + fs::remove_dir_all(&temp_path)?; + Err(e) + } + } + } +} diff --git a/engine/src/handlers/clone/operations.rs b/engine/src/handlers/clone/operations.rs new file mode 100644 index 0000000..e1e0e3b --- /dev/null +++ b/engine/src/handlers/clone/operations.rs @@ -0,0 +1,21 @@ +use std::path::PathBuf; + +use async_trait::async_trait; +use url::Url; + +use crate::errors::EngineResult; + +pub struct Request { + pub url: Url, + pub directory: Option, + pub origin: Option, + pub server_key: PathBuf, + pub quiet: bool, +} + +pub struct Response {} + +#[async_trait] +pub trait CloneOperations { + async fn clone(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/handlers/init/handlers.rs b/engine/src/handlers/init/handlers.rs index ae41a5e..3de8bf4 100644 --- a/engine/src/handlers/init/handlers.rs +++ b/engine/src/handlers/init/handlers.rs @@ -37,7 +37,7 @@ impl InitOperations for InitHandler { self.repository .layout() .set_working_dir(request.working_dir); - let repository_dir = self.repository.init(&request.initial_branch)?; + let repository_dir = self.repository.init(Some(&request.initial_branch))?; let response = Response { repository_dir }; diff --git a/engine/src/hasher.rs b/engine/src/hasher.rs index 47c87f4..ea3ce6a 100644 --- a/engine/src/hasher.rs +++ b/engine/src/hasher.rs @@ -5,8 +5,19 @@ pub use meva_hasher::MevaHasher; /// Collection of utility functions for computing cryptographic hashes /// used in the Meva repository. /// -/// Currently, this trait provides a single function for computing **SHA-1** -/// hashes of in-memory data. +/// This trait abstracts the hashing logic to ensure consistent usage of +/// algorithms (primarily SHA-1) throughout the engine and allows for +/// thread-safe execution. pub trait Hasher: Send + Sync { + /// Computes the SHA-1 hash of the provided data and returns it as a hexadecimal string. + /// + /// # Arguments + /// * `data`: The input byte slice to be hashed. fn sha1(&self, data: &[u8]) -> String; + + /// Computes the SHA-1 hash of the provided data and returns the raw byte sequence. + /// + /// # Arguments + /// * `data`: The input byte slice to be hashed. + fn sha1_raw(&self, data: &[u8]) -> Vec; } diff --git a/engine/src/hasher/meva_hasher.rs b/engine/src/hasher/meva_hasher.rs index 8048ce9..598f78d 100644 --- a/engine/src/hasher/meva_hasher.rs +++ b/engine/src/hasher/meva_hasher.rs @@ -17,4 +17,10 @@ impl Hasher for MevaHasher { let result = hasher.finalize(); hex::encode(result) } + + fn sha1_raw(&self, data: &[u8]) -> Vec { + let mut hasher = Sha1::new(); + hasher.update(data); + hasher.finalize().to_vec() + } } diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 72112d5..71d3a13 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,5 +1,4 @@ mod commit_builder; -mod object_storage; mod serialize_deserialize; mod traversal; @@ -12,13 +11,15 @@ pub mod handlers; pub mod hasher; pub mod ignore; pub mod index; +pub mod network; +pub mod object_storage; pub mod objects; pub mod plugins_interceptor; pub mod ref_manager; pub mod repositories; pub mod revision_parsing; -use errors::{EngineError, EngineResult, InitError}; +use errors::{EngineResult, InitError}; use object_storage::ObjectStorage; use ref_manager::RefManager; diff --git a/engine/src/network.rs b/engine/src/network.rs new file mode 100644 index 0000000..aca7c46 --- /dev/null +++ b/engine/src/network.rs @@ -0,0 +1,15 @@ +mod client_handler; +mod common; +mod packfiles; +mod protocols; +mod ssh_connection_params; +mod ssh_service; +mod ssh_session; + +use client_handler::ClientHandler; +use ssh_session::SshSession; + +pub use common::{ChannelBand, PktLine, SessionExtension, create_channel_band, create_pkt_line}; +pub use packfiles::{PackfileCodec, PackfileError}; +pub use ssh_connection_params::SshConnectionParams; +pub use ssh_service::SshService; diff --git a/engine/src/network/client_handler.rs b/engine/src/network/client_handler.rs new file mode 100644 index 0000000..0eb6790 --- /dev/null +++ b/engine/src/network/client_handler.rs @@ -0,0 +1,54 @@ +use async_trait::async_trait; +use russh::client; +use russh_keys::key; + +use crate::errors::NetworkError; + +/// Handles the SSH client connection lifecycle and events. +/// +/// This handler is primarily responsible for verifying the identity of the remote +/// server during the SSH handshake to prevent Man-in-the-Middle (MitM) attacks. +pub struct ClientHandler { + /// The expected public key of the server. + server_public_key: key::PublicKey, +} + +impl ClientHandler { + /// Creates a new instance of the `ClientHandler`. + /// + /// # Arguments + /// * `server_key`: The public key expected from the server. + pub fn new(server_key: key::PublicKey) -> Self { + Self { + server_public_key: server_key, + } + } +} + +#[async_trait] +impl client::Handler for ClientHandler { + type Error = NetworkError; + + /// Verifies the server's public key during the handshake. + /// + /// # Arguments + /// * `server_public_key`: The public key presented by the server. + /// + /// # Returns + /// * `Ok(true)` if the keys match, allowing the connection to proceed. + /// * `Ok(false)` if the keys do not match, aborting the connection. + async fn check_server_key( + &mut self, + server_public_key: &key::PublicKey, + ) -> Result { + println!("Server presented public key: {}", server_public_key.name()); + + if server_public_key == &self.server_public_key { + println!("Server key is valid (matches the expected one)."); + Ok(true) + } else { + eprintln!("Server key is INVALID!"); + Ok(false) + } + } +} diff --git a/engine/src/network/common.rs b/engine/src/network/common.rs new file mode 100644 index 0000000..8021146 --- /dev/null +++ b/engine/src/network/common.rs @@ -0,0 +1,7 @@ +mod channel_band; +mod pkt_line; +mod session_extension; + +pub use channel_band::{ChannelBand, create_channel_band}; +pub use pkt_line::{PktLine, create_pkt_line}; +pub use session_extension::SessionExtension; diff --git a/engine/src/network/common/channel_band.rs b/engine/src/network/common/channel_band.rs new file mode 100644 index 0000000..9b06876 --- /dev/null +++ b/engine/src/network/common/channel_band.rs @@ -0,0 +1,70 @@ +use crate::errors::{EngineResult, NetworkError}; + +/// Represents the different types of data channels (sidebands) used in the Meva network protocol. +/// +/// Multiplexing these channels allows the server to send binary data, textual progress, +/// and error messages over a single connection stream without mixing them up. +#[derive(Debug, PartialEq, Eq)] +pub enum ChannelBand { + /// Contains the binary packfile stream (repository objects). + Packfile, + /// Contains textual progress information meant for user display. + Progress, + /// Contains error messages from the remote server. + Error, +} + +impl ChannelBand { + /// Returns the numeric protocol identifier for this channel band. + pub fn get_channel_value(&self) -> u8 { + match self { + Self::Packfile => 1, + Self::Progress => 2, + Self::Error => 3, + } + } +} + +impl TryFrom for ChannelBand { + type Error = NetworkError; + + /// Attempts to convert a raw byte identifier into a [`ChannelBand`]. + /// + /// # Errors + /// Returns [`NetworkError::UnknownChannel`] if the value does not correspond + /// to a known band (1, 2, or 3). + fn try_from(value: u8) -> Result { + match value { + 1 => Ok(ChannelBand::Packfile), + 2 => Ok(ChannelBand::Progress), + 3 => Ok(ChannelBand::Error), + _ => Err(NetworkError::UnknownChannel { + channel_band: value, + }), + } + } +} + +/// Encapsulates data into a sideband packet following the pkt-line format. +/// +/// The resulting packet has the structure: `[LENGTH (4 hex bytes)][BAND (1 byte)][DATA]`. +/// The length field indicates the total size of the packet (including the 4 bytes of the length itself). +/// +/// # Arguments +/// * `band`: The target channel band (Packfile, Progress, or Error). +/// * `data`: The raw payload to wrap in the packet. +/// +/// # Returns +/// * `EngineResult>`: The formatted byte vector ready for network transmission. +pub fn create_channel_band(band: &ChannelBand, data: &[u8]) -> EngineResult> { + let payload_len = data.len() + 1; + let total_len = payload_len + 4; + let header = format!("{total_len:04x}"); + + let mut packet = Vec::with_capacity(total_len); + packet.extend_from_slice(header.as_bytes()); + packet.push(band.get_channel_value()); + packet.extend_from_slice(data); + + Ok(packet) +} diff --git a/engine/src/network/common/pkt_line.rs b/engine/src/network/common/pkt_line.rs new file mode 100644 index 0000000..a8ce782 --- /dev/null +++ b/engine/src/network/common/pkt_line.rs @@ -0,0 +1,38 @@ +/// Represents a parsed packet-line (pkt-line) from the network stream. +/// +/// The pkt-line format is a length-prefixed framing protocol, +/// which allows sending binary data, text commands, and control signals +/// over a single stream. +#[derive(Debug, PartialEq, Eq)] +pub enum PktLine { + /// A standard data packet containing the payload bytes. + /// + /// The first 4 bytes (length prefix) have been stripped, leaving only the content. + Payload(Vec), + + /// A flush packet (represented by "0000"). + /// + /// This signals the end of a list of references or a specific section + /// of the communication protocol. + Flush, + + /// Indicates that the buffer did not contain enough data to parse a full packet. + /// + /// The caller should read more data from the stream and try again. + Incomplete, +} + +/// Encodes a string into the pkt-line format. +/// +/// Calculates the total length of the packet (content length + 4 bytes for the header), +/// formats it as a 4-digit hexadecimal string, and prepends it to the data. +/// +/// # Arguments +/// * `data`: The string content to be encoded. +/// +/// # Returns +/// * `String`: The formatted pkt-line (e.g., input "exit" becomes "0008exit"). +pub fn create_pkt_line(data: &str) -> String { + let len = data.len() + 4; + format!("{len:04x}{data}") +} diff --git a/engine/src/network/common/session_extension.rs b/engine/src/network/common/session_extension.rs new file mode 100644 index 0000000..fa3df52 --- /dev/null +++ b/engine/src/network/common/session_extension.rs @@ -0,0 +1,96 @@ +use russh::{ + ChannelId, CryptoVec, client::Session as ClientSession, server::Session as ServerSession, +}; + +use crate::{ + errors::EngineResult, + network::{ChannelBand, create_channel_band}, +}; + +/// Extends [`russh`] sessions with Meva-specific network protocol capabilities. +/// +/// This trait provides helper methods to standardize sending formatted data +/// (pkt-lines) and multiplexed sideband packets (channel bands) across both +/// client and server SSH sessions. +pub trait SessionExtension { + /// Sends a "flush" packet ("0000") to the specified channel. + /// + /// A flush packet is a special signal in the pkt-line protocol indicating + /// the end of a list of references or the completion of a negotiation phase. + fn send_flush(&mut self, channel: ChannelId); + + /// Sends a raw string message to the specified channel. + /// + /// This is typically used to send pre-formatted pkt-line strings. + /// The data is converted to bytes and sent directly without additional framing. + /// + /// # Arguments + /// * `channel`: The ID of the SSH channel to send data to. + /// * `line`: The string containing pre-formatted pkt-line to send. + fn send_pkt_line(&mut self, channel: ChannelId, line: &str); + + /// Encapsulates data into a sideband packet and sends it. + /// + /// This method wraps the raw data with the appropriate header for the + /// specified `channel_band` (Packfile, Progress, or Error) before sending. + /// + /// # Arguments + /// * `channel`: The ID of the SSH channel. + /// * `channel_band`: The target sideband (determines the band ID prefix). + /// * `line`: The raw payload data to be wrapped. + /// + /// # Returns + /// * `EngineResult<()>`: Success if the packet was created and queued, or an error if framing failed. + fn send_channel_band( + &mut self, + channel: ChannelId, + channel_band: &ChannelBand, + line: &[u8], + ) -> EngineResult<()>; +} + +impl SessionExtension for ClientSession { + fn send_flush(&mut self, channel: ChannelId) { + self.data(channel, CryptoVec::from_slice(b"0000")); + } + + fn send_pkt_line(&mut self, channel: ChannelId, line: &str) { + self.data(channel, CryptoVec::from_slice(line.as_bytes())); + } + + fn send_channel_band( + &mut self, + channel: ChannelId, + channel_band: &ChannelBand, + line: &[u8], + ) -> EngineResult<()> { + self.data( + channel, + CryptoVec::from_slice(&create_channel_band(channel_band, line)?), + ); + Ok(()) + } +} + +impl SessionExtension for ServerSession { + fn send_flush(&mut self, channel: ChannelId) { + self.data(channel, CryptoVec::from_slice(b"0000")); + } + + fn send_pkt_line(&mut self, channel: ChannelId, line: &str) { + self.data(channel, CryptoVec::from_slice(line.as_bytes())); + } + + fn send_channel_band( + &mut self, + channel: ChannelId, + channel_band: &ChannelBand, + line: &[u8], + ) -> EngineResult<()> { + self.data( + channel, + CryptoVec::from_slice(&create_channel_band(channel_band, line)?), + ); + Ok(()) + } +} diff --git a/engine/src/network/packfiles.rs b/engine/src/network/packfiles.rs new file mode 100644 index 0000000..aeb2c09 --- /dev/null +++ b/engine/src/network/packfiles.rs @@ -0,0 +1,5 @@ +mod packfile_codec; +mod packfile_error; + +pub use packfile_codec::PackfileCodec; +pub use packfile_error::PackfileError; diff --git a/engine/src/network/packfiles/packfile_codec.rs b/engine/src/network/packfiles/packfile_codec.rs new file mode 100644 index 0000000..49560b6 --- /dev/null +++ b/engine/src/network/packfiles/packfile_codec.rs @@ -0,0 +1,142 @@ +use std::{ + io::{Cursor, Read}, + sync::Arc, +}; + +use crate::{ + Hasher, + errors::{EngineResult, NetworkError}, + hasher::MevaHasher, + network::PackfileError, + objects::MevaObject, + serialize_deserialize::BinaryCompress, +}; + +/// Handles the encoding and decoding of Meva packfiles. +/// +/// A packfile is a binary format used to transfer multiple objects efficiently +/// over the network. It includes a header, a sequence of compressed objects, +/// and a trailing checksum for integrity verification. +/// +/// # Format Specification +/// +/// 1. **Header**: 4 bytes (`b"PACK"`) +/// 2. **Object Count**: 4 bytes (u32, Big-Endian) +/// 3. **Objects**: Repeated `count` times: +/// - **Length**: 8 bytes (u64, Big-Endian) representing the size of compressed data. +/// - **Data**: The compressed object bytes. +/// 4. **Checksum**: 20 bytes (SHA-1) computed over the entire preceding data. +pub struct PackfileCodec { + hasher: Arc, +} + +impl Default for PackfileCodec { + fn default() -> Self { + Self { + hasher: Arc::new(MevaHasher), + } + } +} + +impl PackfileCodec { + /// Creates a new instance of [`PackfileCodec`] with a custom hasher. + pub fn new(hasher: Arc) -> Self { + Self { hasher } + } + + /// Serializes a list of objects into a binary packfile stream. + /// + /// This method constructs the packfile by writing the header, compressing and + /// writing each object sequentially, and finally appending a checksum of the + /// entire payload. + /// + /// # Arguments + /// * `objects`: A reference to a vector of `MevaObject`s to be packed. + /// + /// # Returns + /// * `EngineResult>`: The complete binary buffer ready for network transmission. + pub fn encode_packfile(&self, objects: &Vec) -> EngineResult> { + let mut buffer = Vec::new(); + buffer.extend_from_slice(b"PACK"); + buffer.extend_from_slice(&(objects.len() as u32).to_be_bytes()); + + for object in objects { + let compressed_bytes = object.to_compressed_bytes()?; + buffer.extend_from_slice(&(compressed_bytes.len() as u64).to_be_bytes()); + buffer.extend_from_slice(&compressed_bytes); + } + + let checksum = self.hasher.sha1_raw(&buffer); + buffer.extend_from_slice(&checksum); + + Ok(buffer) + } + + /// Parses a binary packfile buffer into a list of objects. + /// + /// This method validates the integrity of the packfile by verifying the + /// trailing checksum and the file signature before attempting to deserialize + /// the objects. + /// + /// # Arguments + /// * `buffer`: The raw byte array received from the network. + /// + /// # Returns + /// * `EngineResult)>>`: A vector containing tuples of: + /// - The deserialized `MevaObject`. + /// - The original compressed bytes of that object (useful for direct storage). + /// + /// # Errors + /// Returns `NetworkError::Packfile` if: + /// - The buffer is too short (smaller than header + checksum). + /// - The checksum verification fails (data corruption). + /// - The magic signature `PACK` is missing. + pub fn decode_packfile(&self, buffer: &[u8]) -> EngineResult)>> { + if buffer.len() < 28 { + // header (4) + count (4) + checksum (20) = 28 bytes minimum + return Err(NetworkError::Packfile(PackfileError::InvalidObject { + message: format!("Packfile with {} bytes is too short", buffer.len()), + }) + .into()); + } + + // Separate the content from the trailing checksum (last 20 bytes) + let (buffer, checksum) = buffer.split_at(buffer.len() - 20); + + let calculated_checksum = self.hasher.sha1_raw(buffer); + + if calculated_checksum.as_slice() != checksum { + return Err(NetworkError::Packfile(PackfileError::Checksum { + expected: hex::encode(checksum), + received: hex::encode(calculated_checksum), + }) + .into()); + } + + let mut reader = Cursor::new(buffer); + + let mut header = [0u8; 8]; + reader.read_exact(&mut header)?; + + if &header[0..4] != b"PACK" { + return Err(NetworkError::Packfile(PackfileError::MissingPackSignature).into()); + } + + let object_count = u32::from_be_bytes(header[4..8].try_into().unwrap()); + + let mut objects = Vec::with_capacity(object_count as usize); + + for _ in 0..object_count { + let mut len_buf = [0u8; 8]; + reader.read_exact(&mut len_buf)?; + let compressed_len = u64::from_be_bytes(len_buf) as usize; + + let mut compressed_data = vec![0u8; compressed_len]; + reader.read_exact(&mut compressed_data)?; + + let meva_object = MevaObject::from_compressed_bytes(&compressed_data)?; + objects.push((meva_object, compressed_data)); + } + Ok(objects) + } +} diff --git a/engine/src/network/packfiles/packfile_error.rs b/engine/src/network/packfiles/packfile_error.rs new file mode 100644 index 0000000..88c678a --- /dev/null +++ b/engine/src/network/packfiles/packfile_error.rs @@ -0,0 +1,46 @@ +use thiserror::Error; + +/// Represents errors specific to the encoding, decoding, and validation of Meva packfiles. +/// +/// Packfiles are binary streams used to transfer objects efficiently. These errors +/// cover structural validation, data integrity (checksums), and deserialization issues. +#[derive(Error, Debug)] +pub enum PackfileError { + /// Indicates a failure to parse the header of an individual object within the packfile. + /// + /// This usually implies corruption or an unexpected format in the length or type + /// fields preceding the compressed object data. + #[error("Invalid object header: {message}")] + InvalidObjectHeader { + /// Description of the parsing failure. + message: String, + }, + + /// Indicates that an object within the packfile could not be deserialized or is structurally invalid. + /// + /// This occurs after the header is parsed, but the object content itself is malformed. + #[error("Invalid object: {message}")] + InvalidObject { + /// Description of the invalid object structure. + message: String, + }, + + /// Data integrity verification failed. + /// + /// The SHA-1 checksum calculated from the received payload does not match + /// the checksum appended to the very end of the packfile stream. + #[error("Invalid checksum: expected '{expected}', received '{received}'")] + Checksum { + /// The checksum value found at the end of the file (the "correct" one). + expected: String, + /// The checksum actually computed from the data content. + received: String, + }, + + /// The packfile does not start with the required magic signature bytes (`b"PACK"`). + /// + /// This suggests the data stream is not a valid packfile, is corrupted at the start, + /// or belongs to a different protocol version. + #[error("Missing 'PACK' signature")] + MissingPackSignature, +} diff --git a/engine/src/network/protocols.rs b/engine/src/network/protocols.rs new file mode 100644 index 0000000..4f7d025 --- /dev/null +++ b/engine/src/network/protocols.rs @@ -0,0 +1,3 @@ +mod upload_pack; + +pub use upload_pack::{UploadPackProtocol, UploadPackResult}; diff --git a/engine/src/network/protocols/upload_pack.rs b/engine/src/network/protocols/upload_pack.rs new file mode 100644 index 0000000..5a983c9 --- /dev/null +++ b/engine/src/network/protocols/upload_pack.rs @@ -0,0 +1,252 @@ +mod upload_pack_result; +mod upload_pack_state; + +pub use upload_pack_result::UploadPackResult; +pub use upload_pack_state::UploadPackState; + +use std::str::from_utf8; + +use russh::{Channel, client::Msg}; + +use crate::{ + errors::{EngineResult, NetworkError}, + network::{PktLine, common::ChannelBand, create_pkt_line}, + ref_manager::RefEntry, +}; + +/// Implements the client-side logic for the `meva-upload-pack` protocol. +/// +/// This struct acts as a state machine that processes incoming raw data stream +/// from the SSH channel, parses `pkt-line` formatted messages, and manages the +/// transition between protocol phases (Discovery -> Negotiation -> Packfile). +#[derive(Debug, Default)] +pub struct UploadPackProtocol { + /// The current state of the protocol state machine. + state: UploadPackState, + + /// Internal buffer to hold incoming bytes until a full `pkt-line` can be parsed. + buffer: Vec, + + /// List of commit hashes the client wants to fetch (target commits). + wants: Vec, + + /// List of commit hashes the client already possesses (for delta compression). + haves: Vec, + + /// The list of references discovered from the remote server. + pub refs: Vec, + + /// The accumulated binary data of the packfile received from the server. + pub packfile_data: Vec, +} + +impl UploadPackProtocol { + /// Creates a new protocol handler initialized with a list of known commits ("haves"). + /// + /// This is typically used during a `fetch` operation where the local repository + /// already contains some history, allowing for efficient delta transfer. + pub fn with_haves(haves: Vec) -> Self { + Self { + haves, + ..Default::default() + } + } + + /// Processes a chunk of raw data received from the network channel. + /// + /// This method appends data to the internal buffer and attempts to parse and handle + /// as many complete `pkt-lines` as possible. It advances the internal state machine. + /// + /// # Arguments + /// * `data`: Raw byte slice received from the SSH stream. + /// * `channel`: The active SSH channel (used to send responses like "wants" or "done"). + /// + /// # Returns + /// * `Ok(true)`: If the protocol has completed successfully. + /// * `Ok(false)`: If the protocol is still in progress (needs more data). + /// * `Err(...)`: If a network or protocol error occurs. + pub async fn process_data( + &mut self, + data: &[u8], + channel: &mut Channel, + ) -> EngineResult { + self.buffer.extend_from_slice(data); + + loop { + let packet = self.parse_next_pkt_line()?; + + match packet { + PktLine::Incomplete => { + // Not enough data for a full packet, wait for next chunk. + break; + } + PktLine::Payload(payload) => { + self.handle_line(payload).await?; + } + PktLine::Flush => { + self.handle_flush(channel).await?; + } + } + + if matches!(self.state, UploadPackState::Complete) { + return Ok(true); + } + } + Ok(false) + } + + /// Attempts to parse the next `pkt-line` from the internal buffer. + /// + /// A `pkt-line` consists of a 4-byte hex length prefix followed by the payload. + /// - If length is "0000", it returns [`PktLine::Flush`]. + /// - If buffer is shorter than the specified length, returns [`PktLine::Incomplete`]. + fn parse_next_pkt_line(&mut self) -> EngineResult { + if self.buffer.len() < 4 { + return Ok(PktLine::Incomplete); + } + + let len_str = from_utf8(&self.buffer[0..4]).map_err(NetworkError::from)?; + + let len = usize::from_str_radix(len_str, 16).map_err(NetworkError::from)?; + + if len == 0 { + self.buffer.drain(0..4); + return Ok(PktLine::Flush); + } + + if self.buffer.len() < len { + return Ok(PktLine::Incomplete); + } + + let line_data = self.buffer[4..len].to_vec(); + self.buffer.drain(0..len); + + Ok(PktLine::Payload(line_data)) + } + + /// Handles a parsed payload packet based on the current protocol state. + async fn handle_line(&mut self, payload: Vec) -> EngineResult<()> { + match self.state { + UploadPackState::Discovery => { + let line_str = from_utf8(&payload).map_err(NetworkError::from)?; + println!("Received line: {line_str}"); + } + UploadPackState::ReceivingRefs => { + let line_str = from_utf8(&payload).map_err(NetworkError::from)?; + println!("Received line: {line_str}"); + + if !line_str.starts_with('#') + && let Some(sha) = line_str.split_whitespace().next() + { + let ref_name = line_str.split_whitespace().nth(1).unwrap_or("").to_string(); + println!("Received ref: {sha} ({ref_name})"); + self.refs.push(RefEntry::new(&ref_name, sha)); + } + } + UploadPackState::Negotiation => { + let line_str = from_utf8(&payload).map_err(NetworkError::from)?; + println!("Received line: {line_str}"); + + if line_str.starts_with("NAK") { + println!("Received NAK. Waiting for packfile..."); + self.state = UploadPackState::Packfile; + } else if line_str.starts_with("ACK") { + println!("Received ACK. Waiting for packfile..."); + self.state = UploadPackState::Packfile; + } else { + eprintln!("Unexpected line in Negotiation state: {line_str}"); + } + } + UploadPackState::Packfile => { + if payload.is_empty() { + return Ok(()); + } + + // First byte is the channel band ID + let channel_band = payload[0]; + let data = &payload[1..]; + + match channel_band.try_into()? { + ChannelBand::Packfile => { + println!("Received packfile data ({} bytes)", data.len()); + self.packfile_data.extend_from_slice(data); + } + ChannelBand::Progress => { + let progress_msg = from_utf8(data).unwrap_or("[progress error]").trim(); + eprintln!("Remote: {progress_msg}"); + } + ChannelBand::Error => { + let error_msg = from_utf8(data).unwrap_or("[remote error]").trim(); + eprintln!("Remote error: {error_msg}"); + return Err(NetworkError::RemoteError { + message: error_msg.to_string(), + } + .into()); + } + } + } + UploadPackState::Complete => {} + } + Ok(()) + } + + /// Handles a "flush" packet (0000), which signals a transition or end of a list. + async fn handle_flush(&mut self, channel: &mut Channel) -> EngineResult<()> { + println!("Received flush-packet (0000)"); + + match self.state { + UploadPackState::Discovery => { + println!("Finished discovery. Waiting for references..."); + self.state = UploadPackState::ReceivingRefs; + } + UploadPackState::ReceivingRefs => { + println!("Finished references. Sending 'want'..."); + + self.wants = self + .refs + .iter() + .map(|ref_entry| ref_entry.commit_hash.clone()) + .collect(); + + for sha in &self.wants { + let want_line = create_pkt_line(&format!("want {sha}\n")); + channel + .data(want_line.as_bytes().as_ref()) + .await + .map_err(NetworkError::from)?; + print!("Sending: {want_line}"); + } + + for sha in &self.haves { + let have_line = create_pkt_line(&format!("have {sha}\n")); + channel + .data(have_line.as_bytes().as_ref()) + .await + .map_err(NetworkError::from)?; + println!("Sending: {have_line}"); + } + + channel + .data(b"0000".as_ref()) + .await + .map_err(NetworkError::from)?; + + let done_line = create_pkt_line("done\n"); + channel + .data(done_line.as_bytes().as_ref()) + .await + .map_err(NetworkError::from)?; + println!("Sending 'done'"); + + self.state = UploadPackState::Negotiation; + } + UploadPackState::Negotiation => {} + UploadPackState::Packfile => { + println!("Finished transferring packfile."); + self.state = UploadPackState::Complete; + } + UploadPackState::Complete => {} + } + Ok(()) + } +} diff --git a/engine/src/network/protocols/upload_pack/upload_pack_result.rs b/engine/src/network/protocols/upload_pack/upload_pack_result.rs new file mode 100644 index 0000000..862911d --- /dev/null +++ b/engine/src/network/protocols/upload_pack/upload_pack_result.rs @@ -0,0 +1,21 @@ +use crate::ref_manager::RefEntry; + +/// Represents the result of an `upload-pack` operation executed on the server. +/// +/// This struct encapsulates the data required by the client to synchronize its +/// repository with the remote during a `clone` or `fetch` operation. It combines +/// the actual object data with the reference state of the remote. +#[derive(Debug)] +pub struct UploadPackResult { + /// The binary content of the generated packfile. + /// + /// This vector contains the serialized and compressed objects (commits, trees, blobs) + /// that the client requested or needs to complete its history. + pub packfile_data: Vec, + + /// The list of references (branches, tags) tracked by the remote repository. + /// + /// The client uses this information to update its remote tracking branches + /// (e.g., `refs/remotes/origin/*`) to match the server's state. + pub refs: Vec, +} diff --git a/engine/src/network/protocols/upload_pack/upload_pack_state.rs b/engine/src/network/protocols/upload_pack/upload_pack_state.rs new file mode 100644 index 0000000..7cf301c --- /dev/null +++ b/engine/src/network/protocols/upload_pack/upload_pack_state.rs @@ -0,0 +1,29 @@ +/// Represents the lifecycle states of the `upload-pack` protocol execution. +/// +/// This state machine tracks the server-side progress during a fetch or clone operation, +/// transitioning from the initial handshake to the final data transfer. +#[derive(Debug, Default, PartialEq, Eq)] +pub enum UploadPackState { + /// The initial state where the server advertises its capabilities and available references (refs) + /// to the connecting client. + #[default] + Discovery, + + /// The state where the server waits for the client to specify which references + /// it wants to fetch ("wants") and optionally what it already has ("haves"). + ReceivingRefs, + + /// The phase of determining the common ancestry between the client and server. + /// + /// In this state, the engine calculates the minimal set of objects required + /// to update the client, ensuring no redundant data is sent. + Negotiation, + + /// The state of generating and streaming the binary packfile containing + /// the requested objects and the checksum. + Packfile, + + /// Indicates that the operation has completed successfully and the connection + /// can be closed. + Complete, +} diff --git a/engine/src/network/ssh_connection_params.rs b/engine/src/network/ssh_connection_params.rs new file mode 100644 index 0000000..512f2bd --- /dev/null +++ b/engine/src/network/ssh_connection_params.rs @@ -0,0 +1,77 @@ +use std::path::PathBuf; + +use url::Url; + +use crate::errors::NetworkError; + +/// Encapsulates the configuration parameters required to establish an SSH connection. +/// +/// This struct holds the parsed connection details derived from a repository URL, +/// combined with local configuration paths for authentication keys. +pub struct SshConnectionParams { + /// The destination address in the format `hostname:port`. + pub host_address: String, + + /// The username used for authentication. + pub user: String, + + /// The local filesystem path to the user's private SSH key. + pub client_key_path: PathBuf, + + /// The local filesystem path to the server's public key (or known_hosts file). + pub server_key_path: PathBuf, + + /// The path of the repository on the remote server . + pub repository_name: String, +} + +impl TryFrom<&Url> for SshConnectionParams { + type Error = NetworkError; + + /// Parses a [`url::Url`] into basic SSH connection parameters. + /// + /// This conversion extracts the username, host, port, and repository path. + /// + /// # Behavior + /// - **Port**: Defaults to `22` if not specified in the URL. + /// - **Keys**: `client_key_path` and `server_key_path` are initialized to `PathBuf::default()` + /// (empty), as the URL does not contain information about local key locations. + /// + /// # Errors + /// Returns a [`NetworkError`] if: + /// - The username is missing). + /// - The host address is missing. + /// - The repository path is empty. + fn try_from(value: &Url) -> Result { + let user = value.username(); + if user.is_empty() { + return Err(NetworkError::MissingUsername); + } + let user = user.to_string(); + + let host = value + .host_str() + .ok_or(NetworkError::MissingHost)? + .to_string(); + + let port = value.port().unwrap_or(22); + + let host_addr = format!("{host}:{port}"); + + let repo_name_on_server = value.path().trim_start_matches('/').to_string(); + if repo_name_on_server.is_empty() { + return Err(NetworkError::MissingRepositoryPath); + } + + let params = SshConnectionParams { + host_address: host_addr, + user, + // Keys must be resolved later + client_key_path: PathBuf::default(), + server_key_path: PathBuf::default(), + repository_name: repo_name_on_server, + }; + + Ok(params) + } +} diff --git a/engine/src/network/ssh_service.rs b/engine/src/network/ssh_service.rs new file mode 100644 index 0000000..960851a --- /dev/null +++ b/engine/src/network/ssh_service.rs @@ -0,0 +1,84 @@ +use std::{sync::Arc, time::Duration}; + +use russh::client::{self, Config}; +use russh_keys::{load_public_key, load_secret_key}; +use tokio::net::TcpStream; + +use crate::errors::{EngineResult, NetworkError}; + +use super::{ClientHandler, SshConnectionParams, SshSession}; + +/// Provides functionality to establish and authenticate SSH connections. +/// +/// This service handles the low-level details of TCP connection setup, +/// cryptographic key loading, SSH handshake, and authentication negotiation. +#[derive(Debug, Default)] +pub struct SshService; + +impl SshService { + /// Establishes a secure, authenticated SSH session with a remote host. + /// + /// # Arguments + /// * `params`: Configuration details including host address, username, and file paths for keys. + /// + /// # Returns + /// * `EngineResult`: A wrapped, ready-to-use SSH session on success. + /// + /// # Errors + /// Returns an error (converted to `NetworkError`) if: + /// * The private or public key files cannot be read or parsed. + /// * The TCP connection to the host fails. + /// * The SSH handshake fails (e.g., server key mismatch). + /// * Authentication is rejected by the server. + pub async fn connect(&self, params: &SshConnectionParams) -> EngineResult { + println!( + "Connecting to {} as user '{}'...", + ¶ms.host_address, ¶ms.user + ); + let client_keypair = + Arc::new(load_secret_key(¶ms.client_key_path, None).map_err(NetworkError::from)?); + + let server_pub_key = + load_public_key(¶ms.server_key_path).map_err(NetworkError::from)?; + + let config = Arc::new(self.build_config()); + + let handler = ClientHandler::new(server_pub_key); + + let socket = match tokio::time::timeout( + Duration::from_secs(5), + TcpStream::connect(¶ms.host_address), + ) + .await + { + Ok(result) => result?, + Err(_) => return Err(NetworkError::ConnectionTimeout.into()), + }; + + socket.set_nodelay(true)?; + + let mut session = client::connect_stream(config, socket, handler).await?; + println!("SSH session established."); + + let auth_success = session + .authenticate_publickey(¶ms.user, client_keypair) + .await + .map_err(NetworkError::from)?; + + if !auth_success { + return Err(NetworkError::Authentication.into()); + } + + println!("Public key authentication succeeded!"); + Ok(SshSession::new(session)) + } + + fn build_config(&self) -> Config { + Config { + keepalive_interval: Some(Duration::from_secs(10)), + keepalive_max: 3, + inactivity_timeout: Some(Duration::from_secs(60)), + ..Default::default() + } + } +} diff --git a/engine/src/network/ssh_session.rs b/engine/src/network/ssh_session.rs new file mode 100644 index 0000000..45e44f3 --- /dev/null +++ b/engine/src/network/ssh_session.rs @@ -0,0 +1,112 @@ +use std::mem; + +use russh::{ + Channel, ChannelMsg, + client::{Handle, Msg}, +}; + +use crate::errors::{EngineResult, NetworkError}; + +use super::{ + ClientHandler, + protocols::{UploadPackProtocol, UploadPackResult}, +}; + +/// Wrapper around an authenticated SSH client session. +/// +/// This struct provides high-level methods to execute Meva-specific commands +/// (like `upload-pack` for fetching and `receive-pack` for pushing) over an +/// established, secure SSH connection. +pub struct SshSession { + /// The underlying [`russh`] client handle used to open channels and send data. + session: Handle, +} + +impl SshSession { + /// Creates a new `SshSession` instance from an authenticated [`russh`] handle. + pub fn new(session: Handle) -> Self { + Self { session } + } + + /// Executes the `upload-pack` command on the remote server (Client-side Fetch/Clone). + /// + /// This initiates the process of downloading objects from the remote repository. + /// The method opens a dedicated SSH channel, executes the command, and then + /// acts as a bridge between the raw network stream and the [`UploadPackProtocol`] + /// state machine to handle negotiation and data transfer. + /// + /// # Arguments + /// * `repository_name`: The path/name of the repository on the remote server. + /// * `haves`: A list of commit hashes that the local repository already possesses. + /// + /// # Returns + /// * `EngineResult`: Contains the downloaded packfile binary data + /// and the list of references (branches/tags) discovered on the remote. + /// + /// # Errors + /// Returns a [`NetworkError`] if: + /// * The SSH channel cannot be opened. + /// * The remote command fails (exits with a non-zero status). + /// * The connection is closed by the server before the protocol negotiation + /// and transfer are fully completed (`ConnectionClosedPrematurely`). + pub async fn run_upload_pack( + &mut self, + repository_name: &str, + haves: Vec, + ) -> EngineResult { + let command = format!("upload-pack {repository_name}"); + let mut channel: Channel = self + .session + .channel_open_session() + .await + .map_err(NetworkError::from)?; + channel + .exec(true, command.as_str()) + .await + .map_err(NetworkError::from)?; + + let mut protocol = UploadPackProtocol::with_haves(haves); + + let mut protocol_completed = false; + + while let Some(msg) = channel.wait().await { + match msg { + ChannelMsg::Data { data } => { + let is_complete = protocol.process_data(&data, &mut channel).await?; + + if is_complete { + println!("Protocol finished successfully."); + protocol_completed = true; + } + } + ChannelMsg::ExitStatus { exit_status } => { + println!("Command finished with status: {exit_status}"); + if exit_status != 0 { + return Err(NetworkError::RemoteCommand { + status: exit_status, + } + .into()); + } + } + ChannelMsg::Eof => { + println!("Server finished sending data (EOF)."); + } + _ => {} + } + } + + if !protocol_completed { + return Err(NetworkError::ConnectionClosedPrematurely.into()); + } + + Ok(UploadPackResult { + packfile_data: mem::take(&mut protocol.packfile_data), + refs: protocol.refs, + }) + } + + pub async fn run_receive_pack(&mut self, _repository_name: &str) -> EngineResult<()> { + // TODO: push + todo!() + } +} diff --git a/engine/src/object_storage.rs b/engine/src/object_storage.rs index a771f40..dbadc43 100644 --- a/engine/src/object_storage.rs +++ b/engine/src/object_storage.rs @@ -1,6 +1,7 @@ pub mod meva_dry_run_object_storage; pub mod meva_object_storage; +use std::collections::HashSet; use std::sync::Arc; use crate::RepositoryLayout; @@ -40,5 +41,33 @@ pub trait ObjectStorage: Send + Sync { /// Retrieves and deserializes a [`MevaObject`] from storage by its hash. fn get_object(&self, hash: &str) -> EngineResult; + /// Returns a reference to the repository layout strategy used by this storage. fn layout(&self) -> &Arc; + + /// Bulk imports objects extracted from a packfile into the storage. + /// + /// This method is typically used during `clone` or `fetch` operations to efficiently + /// write multiple objects received from a remote server. + /// + /// # Arguments + /// * `objects_with_data`: A slice of tuples, where each tuple contains: + /// - The deserialized [`MevaObject`] (used for logic/validation). + /// - The raw compressed byte vector (optimized for direct writing to disk without re-compression). + fn add_objects_from_packfile( + &self, + objects_with_data: &[(MevaObject, Vec)], + ) -> EngineResult<()>; + + /// Computes the set of repository objects that are reachable from a set of + /// desired object hashes (`wants`), excluding objects already present in the + /// client's local state (`haves`). + /// + /// # Returns + /// A vector of [`MevaObject`] instances that form the minimal set of objects + /// reachable from `wants` but not included in `haves`. + fn collect_reachable_objects<'a>( + &self, + wants: &'a HashSet, + haves: &'a HashSet, + ) -> EngineResult>; } diff --git a/engine/src/object_storage/meva_dry_run_object_storage.rs b/engine/src/object_storage/meva_dry_run_object_storage.rs index 8f8e80e..fdc5ae5 100644 --- a/engine/src/object_storage/meva_dry_run_object_storage.rs +++ b/engine/src/object_storage/meva_dry_run_object_storage.rs @@ -19,9 +19,9 @@ use crate::{ObjectStorage, RepositoryLayout}; /// without needing a physical repository. pub struct MevaDryRunObjectStorage; -impl MevaDryRunObjectStorage { +impl Default for MevaDryRunObjectStorage { /// Creates a new dry-run object storage instance. - pub fn new() -> Self { + fn default() -> Self { Self {} } } @@ -56,4 +56,19 @@ impl ObjectStorage for MevaDryRunObjectStorage { fn layout(&self) -> &Arc { unimplemented!() } + + fn add_objects_from_packfile( + &self, + _objects_with_data: &[(MevaObject, Vec)], + ) -> EngineResult<()> { + unimplemented!() + } + + fn collect_reachable_objects<'a>( + &self, + _wants: &'a std::collections::HashSet, + _haves: &'a std::collections::HashSet, + ) -> EngineResult> { + unimplemented!() + } } diff --git a/engine/src/object_storage/meva_object_storage.rs b/engine/src/object_storage/meva_object_storage.rs index 0e8f90c..0c79f03 100644 --- a/engine/src/object_storage/meva_object_storage.rs +++ b/engine/src/object_storage/meva_object_storage.rs @@ -1,12 +1,17 @@ use crate::ObjectStorage; use crate::RepositoryLayout; use crate::errors::{EngineResult, PathError}; -use crate::objects::{MevaBlob, MevaCommit, MevaObject, MevaObjectDecodeContext, MevaTree}; +use crate::objects::{ + MevaBlob, MevaCommit, MevaObject, MevaObjectDecodeContext, MevaObjectType, MevaTree, +}; use crate::serialize_deserialize::BinaryCompress; -use std::fs::{File, OpenOptions}; -use std::io::{Read, Write}; -use std::path::PathBuf; -use std::sync::Arc; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::{ + fs::{self, File, OpenOptions}, + io::{ErrorKind, Read, Write}, + path::PathBuf, + sync::Arc, +}; /// Provides low-level object storage for the Meva repository. /// @@ -61,7 +66,7 @@ impl MevaObjectStorage { path: object_path.clone(), })?; if !parent.exists() { - std::fs::create_dir_all(parent)?; + fs::create_dir_all(parent)?; } match OpenOptions::new() @@ -73,7 +78,7 @@ impl MevaObjectStorage { let compressed_blob = object.to_compressed_bytes()?; blob_file.write_all(&compressed_blob)?; } - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + Err(e) if e.kind() == ErrorKind::AlreadyExists => { // another thread or process already wrote this blob // (or a different file with identical content produced the same hash) // nothing to do here, since the hash guarantees the data is identical @@ -99,33 +104,21 @@ impl MevaObjectStorage { } impl ObjectStorage for MevaObjectStorage { - /// Converts a [`MevaBlob`] into a [`MevaObject`] and stores it on disk. - /// - /// Returns the SHA-1 hash of the stored object. fn add_blob(&self, blob: MevaBlob) -> EngineResult { let object = MevaObject::try_from(blob)?; self.add_object(object) } - /// Converts a [`MevaTree`] into a [`MevaObject`] and stores it on disk. - /// - /// Returns the SHA-1 hash of the stored object. fn add_tree(&self, tree: MevaTree) -> EngineResult { let object = MevaObject::try_from(tree)?; self.add_object(object) } - /// Converts a [`MevaCommit`] into a [`MevaObject`] and stores it on disk. - /// - /// Returns the SHA-1 hash of the stored object. fn add_commit(&self, commit: MevaCommit) -> EngineResult { let object = MevaObject::try_from(commit)?; self.add_object(object) } - /// Loads a [`MevaObject`] from disk using its SHA-1 hash. - /// - /// Reads, decompresses, and deserializes the object into memory. fn get_object(&self, hash: &str) -> EngineResult { let path = self.object_path(hash); let mut file = File::open(path)?; @@ -143,4 +136,91 @@ impl ObjectStorage for MevaObjectStorage { fn layout(&self) -> &Arc { &self.repo_layout } + + fn add_objects_from_packfile( + &self, + objects_with_data: &[(MevaObject, Vec)], + ) -> EngineResult<()> { + for (object, compressed_data) in objects_with_data { + let hash = object.sha1(); + let object_path = self.object_path(hash); + + let parent = object_path.parent().ok_or(PathError::NoParent { + path: object_path.clone(), + })?; + + fs::create_dir_all(parent)?; + + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&object_path) + { + Ok(mut blob_file) => { + blob_file.write_all(compressed_data)?; + } + Err(e) if e.kind() == ErrorKind::AlreadyExists => { + // another thread or process already wrote this blob + // (or a different file with identical content produced the same hash) + // nothing to do here, since the hash guarantees the data is identical + } + Err(e) => return Err(e.into()), + } + } + Ok(()) + } + + fn collect_reachable_objects<'a>( + &self, + wants: &'a HashSet, + haves: &'a HashSet, + ) -> EngineResult> { + let mut queue: VecDeque = wants.iter().cloned().collect(); + let mut objects_to_pack: HashMap = HashMap::new(); + + while let Some(current_sha) = queue.pop_front() { + if objects_to_pack.contains_key(¤t_sha) { + continue; + } + + let object = match self.get_object(¤t_sha) { + Ok(obj) => obj, + _ => { + // TODO: narazie pomijamy + continue; + } + }; + + let obj_type = object.object_type(); + + if *obj_type == MevaObjectType::Commit && haves.contains(¤t_sha) { + continue; + } + + match obj_type { + MevaObjectType::Commit => { + let commit = MevaCommit::try_from(object.clone())?; + + queue.push_back(commit.tree); + + for parent_sha in commit.parents { + queue.push_back(parent_sha); + } + } + MevaObjectType::Tree => { + let tree = MevaTree::try_from(object.clone())?; + for entry in tree.tree_entries { + queue.push_back(entry.object_hash); + } + } + MevaObjectType::Blob => {} + } + + objects_to_pack.insert(object.sha1().clone(), object); + } + + let objects_to_pack = objects_to_pack.into_values().collect(); + + Ok(objects_to_pack) + } } diff --git a/engine/src/objects/meva_object.rs b/engine/src/objects/meva_object.rs index 1544e74..c719234 100644 --- a/engine/src/objects/meva_object.rs +++ b/engine/src/objects/meva_object.rs @@ -9,10 +9,8 @@ use bincode::{ error::{DecodeError, EncodeError}, }; use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder}; -use std::{ - io::{Read, Write}, - rc::Rc, -}; +use std::io::{Read, Write}; +use std::sync::Arc; /// Represents a stored object in the Meva repository. /// @@ -42,6 +40,7 @@ use std::{ /// - empty `data` /// - default SHA-1 hash (calculated from empty data) /// - a standard `MevaHasher` wrapped in `Rc` +#[derive(Clone)] pub struct MevaObject { /// The logical type of this object (e.g., blob, tree, commit). object_type: MevaObjectType, @@ -51,7 +50,7 @@ pub struct MevaObject { sha1: String, - hasher: Rc, + hasher: Arc, } impl MevaObject { @@ -134,7 +133,7 @@ impl Default for MevaObject { object_type: MevaObjectType::default(), data: Vec::default(), sha1: String::default(), - hasher: Rc::new(MevaHasher), + hasher: Arc::new(MevaHasher), } } } diff --git a/engine/src/objects/meva_object_decode_context.rs b/engine/src/objects/meva_object_decode_context.rs index f2b7028..874ded1 100644 --- a/engine/src/objects/meva_object_decode_context.rs +++ b/engine/src/objects/meva_object_decode_context.rs @@ -1,5 +1,5 @@ use crate::Hasher; -use std::rc::Rc; +use std::sync::Arc; /// Provides decoding context for reconstructing [`MevaObject`] instances. /// @@ -12,7 +12,7 @@ use std::rc::Rc; /// of [`bincode::Decode`]. pub struct MevaObjectDecodeContext { /// Optional shared hasher instance used for computing object hashes. - pub hasher: Option>, + pub hasher: Option>, /// Optional precomputed SHA-1 hash of the object. pub known_sha1: Option, diff --git a/engine/src/objects/meva_object_type.rs b/engine/src/objects/meva_object_type.rs index 5e5159d..b6a4e84 100644 --- a/engine/src/objects/meva_object_type.rs +++ b/engine/src/objects/meva_object_type.rs @@ -1,5 +1,7 @@ use bincode::{Decode, Encode}; +use crate::{errors::NetworkError, network::PackfileError}; + /// Represents the logical type of object stored in the Meva repository. /// /// A `MevaObjectType` identifies what kind of content a [`MevaObject`] @@ -13,7 +15,7 @@ use bincode::{Decode, Encode}; /// The enum implements [`bincode::Encode`] and [`bincode::Decode`], ensuring /// a stable binary format for hashing and storage. This stability is important /// for consistent object hashes and reliable storage on disk. -#[derive(Encode, Decode, Default, Debug, Clone)] +#[derive(Encode, Decode, Default, Debug, Clone, PartialEq, Eq)] pub enum MevaObjectType { /// A raw data blob (e.g., file contents). #[default] @@ -26,3 +28,39 @@ pub enum MevaObjectType { /// and references to parent commits. Commit, } + +impl From<&MevaObjectType> for usize { + /// Converts the object type to its corresponding protocol integer value. + /// + /// Used primarily when writing headers for objects in a packfile. + fn from(value: &MevaObjectType) -> Self { + match value { + MevaObjectType::Commit => 1, + MevaObjectType::Tree => 2, + MevaObjectType::Blob => 3, + } + } +} + +impl TryFrom for MevaObjectType { + type Error = NetworkError; + + /// Attempts to convert a raw protocol integer value back into a [`MevaObjectType`]. + /// + /// Used during packfile parsing to determine the type of the incoming object. + /// + /// # Errors + /// Returns [`NetworkError::Packfile`] (wrapping [`PackfileError::InvalidObjectHeader`]) if the + /// provided integer does not correspond to a known object type (1, 2, or 3). + fn try_from(value: usize) -> Result { + match value { + 1 => Ok(Self::Commit), + 2 => Ok(Self::Tree), + 3 => Ok(Self::Blob), + _ => Err(PackfileError::InvalidObjectHeader { + message: format!("Unknown object type '{value}'"), + } + .into()), + } + } +} diff --git a/engine/src/ref_manager.rs b/engine/src/ref_manager.rs index f08831b..a185343 100644 --- a/engine/src/ref_manager.rs +++ b/engine/src/ref_manager.rs @@ -56,6 +56,15 @@ pub trait RefManager: Send + Sync { /// [`RefEntry`], if it exists. fn read_ref(&self, name: &str) -> EngineResult>; + /// Retrieves a list of all local branch references (typically stored in `refs/heads/`). + /// + /// This method iterates over the available local references and returns them + /// as a collection of [`RefEntry`] objects. + /// + /// # Returns + /// * `Ok(Vec)`: A list of all found local branches. + fn iter_refs_heads(&self) -> EngineResult>; + /// Updates or creates a named reference with the provided [`RefEntry`] value. fn update_ref(&self, entry: RefEntry) -> EngineResult<()>; } diff --git a/engine/src/ref_manager/meva_ref_manager.rs b/engine/src/ref_manager/meva_ref_manager.rs index 1f2a324..a5b7634 100644 --- a/engine/src/ref_manager/meva_ref_manager.rs +++ b/engine/src/ref_manager/meva_ref_manager.rs @@ -1,12 +1,16 @@ +use walkdir::WalkDir; + use crate::RepositoryLayout; use crate::errors::{EngineResult, PathError}; use crate::ref_manager::{Head, HeadMode, RefEntry, RefManager}; use crate::serialize_deserialize::MevaEncode; -use std::fs; -use std::fs::File; -use std::io::{Read, Write}; -use std::path::Path; + use std::sync::Arc; +use std::{ + fs::{self, File}, + io::{Read, Write}, + path::Path, +}; /// Manages repository references (`HEAD`, branches, and tags) for the Meva repository. /// @@ -46,25 +50,16 @@ impl MevaRefManager { } impl RefManager for MevaRefManager { - /// Reads the repository’s `HEAD` file and deserializes it into a [`Head`] structure. fn read_head(&self) -> EngineResult { let content = Self::read_file(&self.repo_layout.head_file())?; Head::from_json(&content) } - /// Resolves the current `HEAD` reference to the commit hash it points to. - /// - /// # Returns - /// - /// - `Ok(Some(hash))` if `HEAD` points to a commit. - /// - `Ok(None)` if `HEAD` is missing or empty. - /// - `Err` if reading `HEAD` or the target reference fails. fn resolve_head(&self) -> EngineResult> { let head = self.read_head()?; self.resolve_commit_hash(&head) } - /// Resolves a given [`Head`] object to the specific commit hash it points to. fn resolve_commit_hash(&self, head: &Head) -> EngineResult> { let hash = match head.mode { HeadMode::Direct => Some(head.target.clone()), @@ -73,9 +68,6 @@ impl RefManager for MevaRefManager { Ok(hash) } - /// Updates the `HEAD` file on disk and ensures the target reference file exists. - /// - /// If the `HEAD` is symbolic, a new reference file is created if missing. fn update_head(&self, head: Head) -> EngineResult<()> { self.write_file(&self.repo_layout.head_file(), &head.to_json()?)?; @@ -95,9 +87,6 @@ impl RefManager for MevaRefManager { Ok(()) } - /// Reads a reference by name (e.g., `"refs/heads/master"`) and returns it as a [`RefEntry`]. - /// - /// Returns `Ok(None)` if the reference file is empty. fn read_ref(&self, name: &str) -> EngineResult> { let path = self.repo_layout.repository_dir().join(name); let content = Self::read_file(&path)?; @@ -110,9 +99,31 @@ impl RefManager for MevaRefManager { Ok(Some(entry)) } - /// Updates or creates a named reference file with the provided [`RefEntry`]. - /// - /// Parent directories are created automatically if they do not exist. + fn iter_refs_heads(&self) -> EngineResult> { + Ok(WalkDir::new(self.repo_layout.heads_refs_dir()) + .into_iter() + .filter_map(|e| { + let entry = match e { + Ok(entry) if entry.file_type().is_file() => entry, + _ => { + return None; + } + }; + + let path = entry.path(); + + let content = match Self::read_file(path) { + Ok(content) if !content.is_empty() => content, + _ => { + return None; + } + }; + + RefEntry::from_json(&content).ok() + }) + .collect()) + } + fn update_ref(&self, entry: RefEntry) -> EngineResult<()> { let path = self.repo_layout.repository_dir().join(&entry.name); let parent = path diff --git a/engine/src/ref_manager/ref_entry.rs b/engine/src/ref_manager/ref_entry.rs index 35a04bf..4246a44 100644 --- a/engine/src/ref_manager/ref_entry.rs +++ b/engine/src/ref_manager/ref_entry.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; /// /// These entries are typically stored and managed by the [`MevaRefManager`], /// which handles reading and updating branch references in the repository. -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct RefEntry { /// The full name of the reference (e.g., `refs/heads/master`). pub name: String, diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index dba749f..8bfec4d 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -7,13 +7,15 @@ use std::{ use tempfile::TempDir; -use crate::repositories::RepositoryLayout; -use crate::serialize_deserialize::MevaEncode; -use crate::{EngineError, EngineResult, InitError}; +use crate::{EngineResult, InitError}; use crate::{ config::config_loader::ConfigLoader, ref_manager::{Head, HeadMode}, }; +use crate::{ + object_storage::ObjectStorage, objects::MevaObject, serialize_deserialize::MevaEncode, +}; +use crate::{ref_manager::RefEntry, repositories::RepositoryLayout}; use shared::fs::create_file_with_dirs; pub trait Repository: Send + Sync { @@ -22,8 +24,18 @@ pub trait Repository: Send + Sync { /// Creates required directories and files under `.meva/`. /// Returns an error if the repository is already initialized /// or the working directory is invalid. - fn init(&self, initial_branch: &str) -> EngineResult; + fn init(&self, initial_branch: Option<&str>) -> EngineResult; + + /// Reconstructs a repository state from a list of objects and references. + /// + /// This method is typically called after receiving a packfile from a remote server. + /// It initializes the storage, writes all received objects, and sets up the + /// local references to match the remote state. + fn clone(&self, objects: &[(MevaObject, Vec)], refs: &[RefEntry]) -> EngineResult; + /// Returns the layout strategy used by this repository. + /// + /// The layout determines the physical locations of files. fn layout(&self) -> &Arc; } @@ -32,93 +44,177 @@ pub trait Repository: Send + Sync { /// Provides functionality to initialize repository layout and manage /// directory structure and configuration files. pub struct MevaRepository { + /// Strategy for resolving file paths within the repository. pub layout: Arc, + + /// Component responsible for reading and writing configuration files. pub config_loader: Arc, + + /// Backend storage for repository objects (blobs, trees, commits). + pub object_storage: Arc, } impl MevaRepository { /// Creates a new `MevaRepository` for the given working directory. - pub fn new(layout: Arc, config_loader: Arc) -> Self { + pub fn new( + layout: Arc, + config_loader: Arc, + object_storage: Arc, + ) -> Self { Self { layout, config_loader, + object_storage, } } - /// Creates the internal repository directories. - fn create_dirs_at>(&self, root: P) -> EngineResult<()> { - fs::create_dir_all(root.as_ref().join(self.layout.objects_dir_rel()))?; - fs::create_dir_all(root.as_ref().join(self.layout.refs_dir_rel()))?; - fs::create_dir_all(root.as_ref().join(self.layout.heads_refs_dir_rel()))?; - fs::create_dir_all(root.as_ref().join(self.layout.logs_dir_rel()))?; - fs::create_dir_all(root.as_ref().join(self.layout.heads_logs_dir_rel()))?; + /// Verifies that the repository does not already exist to prevent overwriting. + fn check_if_exists(&self) -> EngineResult<()> { + let repo_dir = self.layout.repository_dir(); + if repo_dir.exists() { + return Err(InitError::AlreadyInitialized { + path: repo_dir.to_string_lossy().into(), + } + .into()); + } Ok(()) } - /// Creates the internal repository files. - fn create_files_at>(&self, root: P, initial_branch: &str) -> EngineResult<()> { - let ref_path = root - .as_ref() - .join(self.layout.heads_refs_dir_rel()) - .join(initial_branch); - let log_path = root - .as_ref() - .join(self.layout.heads_logs_dir_rel()) - .join(initial_branch); - - create_file_with_dirs(ref_path)?; - create_file_with_dirs(log_path)?; + /// Creates the physical directory hierarchy for a new repository. + fn initialize_structure(&self, root: &Path) -> EngineResult<()> { + let dirs = [ + self.layout.objects_dir_rel(), + self.layout.refs_dir_rel(), + self.layout.heads_refs_dir_rel(), + self.layout.logs_dir_rel(), + self.layout.heads_logs_dir_rel(), + ]; + + for dir in dirs { + fs::create_dir_all(root.join(dir))?; + } - let config_path = root.as_ref().join(self.layout.config_file_rel()); + let config_path = root.join(self.layout.config_file_rel()); self.config_loader.create_local_config(&config_path)?; - let head_path = root.as_ref().join(self.layout.head_file_rel()); + Ok(()) + } + + /// Configures the `HEAD` file to point to the initial branch. + fn setup_head(&self, root: &Path, branch_name: &str) -> EngineResult<()> { + let head_path = root.join(self.layout.head_file_rel()); let mut head_file = fs::File::create(&head_path)?; + + let target_ref = format!( + "{}/{}/{}", + self.layout.refs_dir_name(), + self.layout.heads_dir_name(), + branch_name + ); + let head = Head { mode: HeadMode::Symbolic, - target: format!( - "{}/{}/{}", - self.layout.refs_dir_name(), - self.layout.heads_dir_name(), - initial_branch - ), + target: target_ref, }; write!(head_file, "{}", head.to_json()?)?; - let head_log_path = root.as_ref().join(self.layout.head_logs_file_rel()); + let head_log_path = root.join(self.layout.head_logs_file_rel()); fs::File::create(head_log_path)?; Ok(()) } + + /// Creates a specific branch reference file. + /// + /// If `commit_hash` is provided, the branch file will contain that hash (used in clone). + /// If `None`, an empty file is created (used in empty init). + fn create_branch_ref( + &self, + root: &Path, + branch_name: &str, + commit_hash: Option<&str>, + ) -> EngineResult<()> { + let ref_path = root + .join(self.layout.heads_refs_dir_rel()) + .join(branch_name); + + create_file_with_dirs(&ref_path)?; + + if let Some(hash) = commit_hash { + let full_ref_name = format!( + "{}/{}/{}", + self.layout.refs_dir_name(), + self.layout.heads_dir_name(), + branch_name + ); + + let entry = RefEntry::new(&full_ref_name, hash); + fs::write(&ref_path, entry.to_json()?)?; + } else { + fs::File::create(&ref_path)?; + } + + let log_path = root + .join(self.layout.heads_logs_dir_rel()) + .join(branch_name); + create_file_with_dirs(&log_path)?; + + Ok(()) + } } impl Repository for MevaRepository { - fn init(&self, initial_branch: &str) -> EngineResult { + /// Initializes a new repository. + /// + /// # Atomicity + /// To ensure the repository is not left in a corrupted state if initialization fails, + /// this method performs all file operations in a **temporary directory** first. + fn init(&self, initial_branch: Option<&str>) -> EngineResult { + self.check_if_exists()?; + + let branch_name = initial_branch.unwrap_or("master"); self.config_loader.create_global_config()?; - let repository_dir = self.layout.repository_dir(); + let tmp_parent = self.layout.working_dir(); + let tmp_dir = TempDir::new_in(tmp_parent)?; - if repository_dir.exists() { - return Err(InitError::AlreadyInitialized { - path: repository_dir.to_string_lossy().into(), - } - .into()); - } + let tmp_working_dir = tmp_dir.path(); + + self.initialize_structure(tmp_working_dir)?; + self.create_branch_ref(tmp_working_dir, branch_name, None)?; + self.setup_head(tmp_working_dir, branch_name)?; + + let tmp_repo_dir = tmp_working_dir.join(self.layout.repository_dir_name()); + let final_repo_path = self.layout.repository_dir(); + fs::rename(&tmp_repo_dir, &final_repo_path)?; + + Ok(final_repo_path) + } + + /// Clones a repository from provided data. + fn clone(&self, objects: &[(MevaObject, Vec)], refs: &[RefEntry]) -> EngineResult { + self.check_if_exists()?; - let tmp_parent = &self.layout.working_dir(); - let tmp_dir = TempDir::new_in(tmp_parent).map_err(EngineError::Io)?; + let working_dir = self.layout.working_dir(); - let tmp_repo = tmp_dir.path().join(self.layout.repository_dir_name()); + self.initialize_structure(&working_dir)?; + self.object_storage.add_objects_from_packfile(objects)?; + let initial_ref = refs + .iter() + .find(|r| r.name.ends_with("/master") || r.name.ends_with("/main")) + .or_else(|| refs.first()) + .unwrap(); - self.create_dirs_at(&tmp_dir)?; - self.create_files_at(&tmp_dir, initial_branch)?; + let branch_name = initial_ref.name.rsplit('/').next().unwrap_or("master"); - let final_repo = self.layout.repository_dir(); + self.create_branch_ref(&working_dir, branch_name, Some(&initial_ref.commit_hash))?; + self.setup_head(&working_dir, branch_name)?; - fs::rename(&tmp_repo, &final_repo).map_err(EngineError::Io)?; + // TODO: clone reszty referencji + // TODO: checkout (przywrócenie working dir) - Ok(final_repo) + Ok(self.layout.repository_dir()) } fn layout(&self) -> &Arc { @@ -129,6 +225,7 @@ impl Repository for MevaRepository { #[cfg(test)] mod tests { use crate::MevaConfigLoader; + use crate::object_storage::MevaObjectStorage; use crate::repositories::meva_repository_layout::MevaRepositoryLayout; use super::*; @@ -151,7 +248,8 @@ mod tests { let layout = Arc::new(MevaRepositoryLayout::new(path.to_path_buf()).expect("should not fail")); let config_loader = Arc::new(MevaConfigLoader::default()); - MevaRepository::new(layout, config_loader) + let object_storage = Arc::new(MevaObjectStorage::new(layout.clone())); + MevaRepository::new(layout, config_loader, object_storage) } #[rstest] @@ -159,7 +257,7 @@ mod tests { let tmp = TempDir::new().expect("failed to create TempDir"); let repo = get_repo(tmp.path()); - let result = repo.init("master"); + let result = repo.init(None); assert!(result.is_ok()); let repo_dir = tmp.path().join(".meva"); @@ -201,10 +299,10 @@ mod tests { let repo = get_repo(tmp.path()); // First init succeeds - assert!(repo.init("dev").is_ok()); + assert!(repo.init(Some("dev")).is_ok()); // Second init should fail - let result = repo.init("dev"); + let result = repo.init(Some("dev")); assert!(result.is_err()); if let Err(err) = result { diff --git a/engine/src/repositories/meva_repository_layout.rs b/engine/src/repositories/meva_repository_layout.rs index 82cda5b..470ef6d 100644 --- a/engine/src/repositories/meva_repository_layout.rs +++ b/engine/src/repositories/meva_repository_layout.rs @@ -4,6 +4,7 @@ use shared::{PathToString, UpwardSearch}; use std::sync::RwLock; use std::{env, path::PathBuf}; +#[derive(Debug)] pub struct MevaRepositoryLayout { pub working_dir: RwLock, } diff --git a/engine/src/repositories/repository_layout.rs b/engine/src/repositories/repository_layout.rs index 5d923a8..a9e7059 100644 --- a/engine/src/repositories/repository_layout.rs +++ b/engine/src/repositories/repository_layout.rs @@ -1,3 +1,4 @@ +use std::fmt::Debug; use std::path::PathBuf; /// Defines the directory layout of a Meva repository. @@ -5,7 +6,7 @@ use std::path::PathBuf; /// Provides methods to compute paths to internal repository components /// i.e. absolute and relative to a working directory. #[allow(dead_code)] -pub trait RepositoryLayout: Send + Sync { +pub trait RepositoryLayout: Send + Sync + Debug { /// Name of the root repository directory. fn repository_dir_name(&self) -> &str; @@ -167,6 +168,7 @@ mod tests { use rstest::rstest; use std::{path::PathBuf, sync::RwLock}; + #[derive(Debug)] struct TestRepoLayout { dir: RwLock, } diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..0ff9bf8 --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "server" +version = "0.1.0" +edition = "2024" +authors.workspace = true + +[dependencies] +engine = { path = "../engine" } +russh.workspace = true +russh-keys.workspace = true +cryptovec.workspace = true +tokio.workspace = true +async-trait.workspace = true +toml.workspace = true +serde.workspace = true +thiserror.workspace = true +log = "0.4" +anyhow = "1.0" +flexi_logger = "0.31.7" +serde_plain = "1.0.2" + +[dev-dependencies] +rstest.workspace = true +mockall.workspace = true +pretty_assertions.workspace = true diff --git a/server/src/auth.rs b/server/src/auth.rs new file mode 100644 index 0000000..dadcc52 --- /dev/null +++ b/server/src/auth.rs @@ -0,0 +1,210 @@ +mod auth_result; +mod repository_access; + +pub use auth_result::AuthResult; +pub use repository_access::RepositoryAccess; + +use std::{collections::HashMap, fs, path::Path}; + +use log::{error, info, warn}; +use russh_keys::{ + key::{self}, + parse_public_key_base64, +}; + +use crate::errors::ServerResult; + +/// Maps a username to their specific access rights (read/write). +pub type UserRepositoryAccess = HashMap; + +/// Represents the global access policy configuration. +/// +/// It maps a repository name to a list of users and their respective permissions. +pub type AccessPolicy = HashMap; + +/// Retrieves the specific access rights for a user on a given repository. +/// +/// This function reads the access policy file (TOML format), parses it, and +/// looks up the permissions associated with the `{repository}/{user}` pair. +/// +/// # Arguments +/// * `access_policy_path`: Path to the TOML file containing access rules. +/// * `repository`: The name of the repository being accessed. +/// * `user`: The identifier of the user requesting access. +/// +/// # Returns +/// * `Ok(Some(RepositoryAccess))`: If the policy exists for the user/repo. +/// * `Ok(None)`: If no specific policy is found. +/// * `Err`: If the policy file cannot be read or parsed. +pub fn get_repository_access>( + access_policy_path: P, + repository: &str, + user: &str, +) -> ServerResult> { + info!("Resolving repository access to {repository} for {user}..."); + + let content = fs::read_to_string(access_policy_path)?; + let policy: AccessPolicy = toml::from_str(&content)?; + + let result = policy + .get(repository) + .and_then(|repo_map| repo_map.get(user)) + .copied(); + + if result.is_some() { + info!("Access policy for user {user} in repository {repository} found"); + } else { + warn!("No access policy for user {user} in repository {repository}"); + } + + Ok(result) +} + +/// Verifies if a user has sufficient privileges to perform an action on a repository. +/// +/// # Arguments +/// * `access_policy_path`: Path to the access policy file. +/// * `repository_name`: The target repository. +/// * `user`: The user identifier. +/// * `requires_write`: `true` if the operation modifies the repository (e.g., push), +/// `false` for read-only operations (e.g., fetch/clone). +/// +/// # Returns +/// * `true`: Access is granted. +/// * `false`: Access is denied (or no policy found). +pub fn check_repository_access>( + access_policy_path: P, + repository_name: &str, + user: &str, + requires_write: bool, +) -> ServerResult { + let maybe_access = get_repository_access(access_policy_path, repository_name, user)?; + + let allowed = maybe_access.is_some_and(|ra| { + if requires_write { + // Write access implies read access is also available/checked + ra.read && ra.write + } else { + ra.read + } + }); + + if allowed { + info!( + "Access to repository {repository_name} for {user} granted (requires_write={requires_write})" + ); + } else { + warn!( + "Access to repository {repository_name} for {user} denied (requires_write={requires_write})" + ); + } + + Ok(allowed) +} + +/// Scans an authorized_keys-style file to identify a user by their public key. +/// +/// The expected file format for each line is: +/// `[algorithm] [base64_key] [user_identifier]` +/// +/// Lines starting with `#` or empty lines are ignored. +/// +/// # Arguments +/// * `authorized_keys_path`: Path to the file containing public keys. +/// * `public_key`: The public key presented by the client during the handshake. +/// +/// # Returns +/// * `Ok(Some(String))`: The user identifier associated with the matching key. +/// * `Ok(None)`: If no matching key is found. +async fn find_user_by_public_key>( + authorized_keys_path: P, + public_key: &key::PublicKey, +) -> ServerResult> { + let contents = tokio::fs::read_to_string(authorized_keys_path).await?; + + for (lineno, line) in contents.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + // [type] [key-base64] [user-identifier] + let mut parts = trimmed.split_whitespace(); + let _key_type = parts.next(); + let key_b64 = match parts.next() { + Some(key) => key, + None => { + warn!( + "Ignoring invalid line in authorized keys (line {}): too few parts", + lineno + 1 + ); + continue; + } + }; + let identifier = match parts.next() { + Some(id) => id, + None => { + warn!( + "Ignoring invalid line in authorized keys (line {}): missing indentifier", + lineno + 1 + ); + continue; + } + }; + + match parse_public_key_base64(key_b64) { + Ok(key_from_file) => { + if key_from_file == *public_key { + info!( + "Found matching key in authorized_keys (line {}) for user {identifier}", + lineno + 1 + ); + return Ok(Some(identifier.to_string())); + } + } + Err(e) => { + error!( + "An error occurred while parsing key in authorized_keys (line {}): {e}", + lineno + 1 + ); + } + } + } + + Ok(None) +} + +/// Authenticates an incoming SSH connection attempt using public key authentication. +/// +/// This function checks if the presented public key exists in the `authorized_keys` file. +/// If a match is found, the connection is accepted and mapped to the corresponding user. +/// +/// # Returns +/// * `AuthResult::Accepted`: Authentication successful, contains the user identifier. +/// * `AuthResult::Rejected`: Key not found or file error. +pub async fn challenge_public_key>( + authorized_keys_path: P, + public_key: &key::PublicKey, +) -> ServerResult { + info!( + "Client is trying to authenticate using key: {}", + public_key.name() + ); + + match find_user_by_public_key(authorized_keys_path, public_key).await { + Ok(Some(user_from_file)) => { + info!("Matching user: '{user_from_file}'"); + Ok(AuthResult::Accepted { + user: user_from_file, + }) + } + Ok(None) => { + info!("Authentication with public key failed (missing matching key)"); + Ok(AuthResult::Rejected) + } + Err(e) => { + error!("Failed to read authorized_keys or encountered another error: {e}"); + Ok(AuthResult::Rejected) + } + } +} diff --git a/server/src/auth/auth_result.rs b/server/src/auth/auth_result.rs new file mode 100644 index 0000000..a2b336a --- /dev/null +++ b/server/src/auth/auth_result.rs @@ -0,0 +1,13 @@ +/// Represents the outcome of an authentication attempt on the server. +#[derive(Debug)] +pub enum AuthResult { + /// Authentication succeeded. + Accepted { + /// The unique identifier of the user (e.g., username) derived from + /// the credentials provided (e.g., public key). + user: String, + }, + + /// Authentication failed or was denied. + Rejected, +} diff --git a/server/src/auth/repository_access.rs b/server/src/auth/repository_access.rs new file mode 100644 index 0000000..317fc1d --- /dev/null +++ b/server/src/auth/repository_access.rs @@ -0,0 +1,13 @@ +use serde::Deserialize; + +/// Defines the scope of permissions granted to a user for a specific repository. +#[derive(Debug, Copy, Clone, Default, Deserialize)] +pub struct RepositoryAccess { + /// Grants permission to read data from the repository. + #[serde(default)] + pub read: bool, + + /// Grants permission to write data to the repository. + #[serde(default)] + pub write: bool, +} diff --git a/server/src/config.rs b/server/src/config.rs new file mode 100644 index 0000000..f1e38df --- /dev/null +++ b/server/src/config.rs @@ -0,0 +1,26 @@ +mod access_config; +mod logging_config; +mod server_config; + +use access_config::AccessConfig; +use server_config::ServerConfig; + +pub use logging_config::LoggingConfig; + +use serde::Deserialize; + +/// Represents the top-level configuration for the Meva server application. +#[derive(Debug, Deserialize)] +pub struct Config { + /// Core server settings, including network binding (IP/port), host identity, + /// and repository storage locations. + pub server: ServerConfig, + + /// Security configuration defining where authentication data (keys) and + /// authorization rules (policies) are located. + pub access: AccessConfig, + + /// Observability settings controlling log verbosity, output destinations, + /// and file rotation strategies. + pub logging: LoggingConfig, +} diff --git a/server/src/config/access_config.rs b/server/src/config/access_config.rs new file mode 100644 index 0000000..4e31b44 --- /dev/null +++ b/server/src/config/access_config.rs @@ -0,0 +1,11 @@ +use serde::Deserialize; + +/// Configuration paths for the server's authentication and authorization mechanisms. +#[derive(Debug, Deserialize)] +pub struct AccessConfig { + /// The filesystem path to the file containing trusted SSH public keys. + pub authorized_keys: String, + + /// The filesystem path to the file defining access policies. + pub policy_file: String, +} diff --git a/server/src/config/logging_config.rs b/server/src/config/logging_config.rs new file mode 100644 index 0000000..a78aa17 --- /dev/null +++ b/server/src/config/logging_config.rs @@ -0,0 +1,25 @@ +use serde::Deserialize; + +/// Configuration settings for the application's logging system. +#[derive(Debug, Deserialize)] +pub struct LoggingConfig { + /// The minimum severity level of messages to capture. + /// + /// Common values include `"trace"`, `"debug"`, `"info"`, `"warn"`, and `"error"`. + /// Messages below this threshold will be filtered out. + pub level: String, + + /// The filesystem path to the primary log file. + pub log_file: String, + + /// Controls the verbosity of the log entry format. + pub detailed: bool, + + /// The maximum size (in bytes) the log file is allowed to reach before rotation occurs. + /// + /// If `None`, file rotation based on size is disabled. + pub rotate_size: Option, + + /// The maximum number of rotated log archives to retain. + pub keep_logs: Option, +} diff --git a/server/src/config/server_config.rs b/server/src/config/server_config.rs new file mode 100644 index 0000000..b7d707b --- /dev/null +++ b/server/src/config/server_config.rs @@ -0,0 +1,24 @@ +use serde::Deserialize; + +/// General network and storage configuration for the Meva server instance. +#[derive(Debug, Deserialize)] +pub struct ServerConfig { + /// The TCP port number the server will listen on (e.g., 2222). + pub port: u16, + + /// The IP address or interface to bind to (e.g., "0.0.0.0" for all interfaces). + pub address: String, + + /// The filesystem path to the server's private SSH host key. + pub host_key: String, + + /// The root directory on the server's filesystem where all repositories are stored. + pub repositories_root: String, +} + +impl ServerConfig { + /// Returns the formatted socket address string in `address:port` format. + pub fn bind_address(&self) -> String { + format!("{}:{}", self.address, self.port) + } +} diff --git a/server/src/enums.rs b/server/src/enums.rs new file mode 100644 index 0000000..4c61543 --- /dev/null +++ b/server/src/enums.rs @@ -0,0 +1,13 @@ +mod active_protocol; +mod channel_state; +mod protocol_command; +mod receive_pack_state; +mod upload_pack_command; +mod upload_pack_state; + +pub use active_protocol::ActiveProtocol; +pub use channel_state::ChannelState; +pub use protocol_command::ProtocolCommand; +pub use receive_pack_state::ReceivePackState; +pub use upload_pack_command::UploadPackCommand; +pub use upload_pack_state::UploadPackState; diff --git a/server/src/enums/active_protocol.rs b/server/src/enums/active_protocol.rs new file mode 100644 index 0000000..35bff13 --- /dev/null +++ b/server/src/enums/active_protocol.rs @@ -0,0 +1,18 @@ +use super::{ReceivePackState, UploadPackState}; + +/// Tracks the specific protocol currently active on the SSH session. +#[derive(Debug, Clone)] +pub enum ActiveProtocol { + /// No protocol command has been received yet, or the session is idle. + None, + + /// The session is executing the `upload-pack` protocol (server-side fetch/clone). + /// + /// Wraps the [`UploadPackState`] machine which manages negotiation and packfile generation. + UploadPack(UploadPackState), + + /// The session is executing the `receive-pack` protocol (server-side push). + /// + /// Wraps the [`ReceivePackState`] machine which manages reference updates and object unpacking. + ReceivePack(ReceivePackState), +} diff --git a/server/src/enums/channel_state.rs b/server/src/enums/channel_state.rs new file mode 100644 index 0000000..36d1d8f --- /dev/null +++ b/server/src/enums/channel_state.rs @@ -0,0 +1,27 @@ +use super::ActiveProtocol; + +/// Maintains the context and state for a specific SSH channel execution. +#[derive(Debug)] +pub struct ChannelState { + /// The identifier of the authenticated user associated with this channel. + pub user: String, + + /// The current protocol being executed + pub protocol: ActiveProtocol, + + /// Internal buffer for accumulating incoming raw bytes. + pub buffer: Vec, +} + +impl ChannelState { + /// Creates a new channel state for the specified authenticated user. + /// + /// The channel starts with no active protocol and an empty data buffer. + pub fn new(user: String) -> Self { + Self { + user, + protocol: ActiveProtocol::None, + buffer: Vec::new(), + } + } +} diff --git a/server/src/enums/protocol_command.rs b/server/src/enums/protocol_command.rs new file mode 100644 index 0000000..8d513d9 --- /dev/null +++ b/server/src/enums/protocol_command.rs @@ -0,0 +1,26 @@ +use std::str::FromStr; + +use serde::Deserialize; + +use crate::errors::ServerError; + +/// Represents the supported high-level commands that can be +/// executed over the SSH transport. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProtocolCommand { + /// The command used to fetch data from the server (Clone/Fetch). + UploadPack, + + /// The command used to push data to the server (Push). + ReceivePack, +} + +impl FromStr for ProtocolCommand { + type Err = ServerError; + + /// Parses a string into a [`ProtocolCommand`]. + fn from_str(s: &str) -> Result { + Ok(serde_plain::from_str(s)?) + } +} diff --git a/server/src/enums/receive_pack_state.rs b/server/src/enums/receive_pack_state.rs new file mode 100644 index 0000000..f90779f --- /dev/null +++ b/server/src/enums/receive_pack_state.rs @@ -0,0 +1,14 @@ +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct ReceivePackState { + pub repository_path: PathBuf, +} + +impl ReceivePackState { + pub fn new(repository_path: impl Into) -> Self { + Self { + repository_path: repository_path.into(), + } + } +} diff --git a/server/src/enums/upload_pack_command.rs b/server/src/enums/upload_pack_command.rs new file mode 100644 index 0000000..d38d3c6 --- /dev/null +++ b/server/src/enums/upload_pack_command.rs @@ -0,0 +1,42 @@ +use crate::errors::UploadPackError; + +/// Represents the specific instructions sent by the client during the negotiation phase +/// of the `upload-pack` protocol (fetch/clone). +#[derive(Debug, Clone)] +pub enum UploadPackCommand<'a> { + /// The client requests the server to send the objects reachable from this commit hash. + Want(&'a str), + + /// The client advertises a commit hash it already possesses. + Have(&'a str), + + /// Signals the end of the negotiation phase. + Done, +} + +impl<'a> TryFrom<&'a str> for UploadPackCommand<'a> { + type Error = UploadPackError; + + /// Parses a raw protocol line into a structured command. + /// + /// This method identifies the command type by stripping standard prefixes + /// ("want ", "have ") and extracting the associated commit hash. + /// + /// # Examples + /// * `"want 5f2c..."` -> `UploadPackCommand::Want("5f2c...")` + /// * `"have 3a1b..."` -> `UploadPackCommand::Have("3a1b...")` + /// * `"done"` -> `UploadPackCommand::Done` + fn try_from(value: &'a str) -> Result { + if let Some(sha) = value.strip_prefix("want ") { + let sha = sha.split_whitespace().next().unwrap_or(sha); + Ok(UploadPackCommand::Want(sha)) + } else if let Some(sha) = value.strip_prefix("have ") { + let sha = sha.split_whitespace().next().unwrap_or(sha); + Ok(UploadPackCommand::Have(sha)) + } else if value == "done" { + Ok(UploadPackCommand::Done) + } else { + Err(UploadPackError::InvalidCommand(value.to_string())) + } + } +} diff --git a/server/src/enums/upload_pack_state.rs b/server/src/enums/upload_pack_state.rs new file mode 100644 index 0000000..cb172e5 --- /dev/null +++ b/server/src/enums/upload_pack_state.rs @@ -0,0 +1,28 @@ +use std::{collections::HashSet, path::PathBuf}; + +/// Tracks the internal state of the `upload-pack` negotiation phase on the server. +#[derive(Debug, Clone)] +pub struct UploadPackState { + /// The physical filesystem path to the repository being accessed. + pub repository_path: PathBuf, + + /// A set of commit hashes the client wishes to download. + pub wants: HashSet, + + /// A set of commit hashes the client advertises as already having. + pub haves: HashSet, +} + +impl UploadPackState { + /// Creates a new, empty negotiation state for the specified repository. + /// + /// # Arguments + /// * `repository_path`: The path to the repository on the server's disk. + pub fn new(repository_path: impl Into) -> Self { + Self { + repository_path: repository_path.into(), + wants: HashSet::new(), + haves: HashSet::new(), + } + } +} diff --git a/server/src/errors.rs b/server/src/errors.rs new file mode 100644 index 0000000..f1086ac --- /dev/null +++ b/server/src/errors.rs @@ -0,0 +1,5 @@ +mod server_error; +mod upload_pack_error; + +pub use server_error::{Result as ServerResult, ServerError}; +pub use upload_pack_error::UploadPackError; diff --git a/server/src/errors/server_error.rs b/server/src/errors/server_error.rs new file mode 100644 index 0000000..21d62bd --- /dev/null +++ b/server/src/errors/server_error.rs @@ -0,0 +1,33 @@ +use std::io; + +use flexi_logger::FlexiLoggerError; +use thiserror::Error; + +use super::UploadPackError; + +/// A convenient result type alias for server-related operations. +pub type Result = std::result::Result; + +/// Represents all possible errors that can occur during the lifecycle of the Meva server. +#[derive(Error, Debug)] +pub enum ServerError { + /// Wraps standard Input/Output errors. + #[error(transparent)] + Io(#[from] io::Error), + + /// Errors specific to the `upload-pack` protocol negotiation. + #[error(transparent)] + UploadPack(#[from] UploadPackError), + + /// Errors originating from the logging subsystem initialization. + #[error(transparent)] + Logger(#[from] FlexiLoggerError), + + /// Errors occurred while parsing TOML configuration files. + #[error(transparent)] + Toml(#[from] toml::de::Error), + + /// Errors during simple string deserialization. + #[error(transparent)] + Serde(#[from] serde_plain::Error), +} diff --git a/server/src/errors/upload_pack_error.rs b/server/src/errors/upload_pack_error.rs new file mode 100644 index 0000000..ad47b2e --- /dev/null +++ b/server/src/errors/upload_pack_error.rs @@ -0,0 +1,12 @@ +use thiserror::Error; + +/// Represents errors specific to the `upload-pack` protocol execution. +#[derive(Error, Debug)] +pub enum UploadPackError { + /// Indicates that a protocol command line was malformed or unrecognized. + #[error("Invalid upload-pack command: {0}")] + InvalidCommand( + /// The raw string content of the command that caused the error. + String, + ), +} diff --git a/server/src/lib.rs b/server/src/lib.rs new file mode 100644 index 0000000..fce91cf --- /dev/null +++ b/server/src/lib.rs @@ -0,0 +1,15 @@ +mod auth; +mod config; +mod enums; +mod errors; +mod logging; +mod validation; + +pub use auth::{ + AccessPolicy, AuthResult, RepositoryAccess, UserRepositoryAccess, challenge_public_key, + check_repository_access, +}; +pub use config::{Config, LoggingConfig}; +pub use enums::*; +pub use logging::init_logging; +pub use validation::validate_repository_name; diff --git a/server/src/logging.rs b/server/src/logging.rs new file mode 100644 index 0000000..0948071 --- /dev/null +++ b/server/src/logging.rs @@ -0,0 +1,92 @@ +use std::path::Path; + +use flexi_logger::{Cleanup, Criterion, Duplicate, FileSpec, Logger, Naming, style}; + +use crate::{config::LoggingConfig, errors::ServerResult}; + +/// Initializes the global logging system for the server application. +/// +/// # Arguments +/// +/// * `logging_config`: The configuration object defining levels, paths, and rotation policies. +/// +/// # Returns +/// +/// * `ServerResult<()>`: Ok if the logger was successfully initialized, Err otherwise. +pub fn init_logging(logging_config: &LoggingConfig) -> ServerResult<()> { + let log_path = Path::new(&logging_config.log_file); + let (dir, base) = match (log_path.parent(), log_path.file_name()) { + (Some(parent), Some(name)) if parent != Path::new("") => { + (parent.to_path_buf(), name.to_string_lossy().to_string()) + } + _ => ( + std::env::current_dir()?, + log_path.to_string_lossy().to_string(), + ), + }; + + let file_spec = FileSpec::default().directory(dir).basename(&base); + + let mut logger = Logger::try_with_str(logging_config.level.as_str())? + .log_to_file(file_spec) + .duplicate_to_stdout(Duplicate::All); + + if let Some(size) = logging_config.rotate_size { + let keep = logging_config.keep_logs.unwrap_or(7); + logger = logger.rotate( + Criterion::Size(size), + Naming::Numbers, + Cleanup::KeepLogFiles(keep), + ); + } + + if logging_config.detailed { + // File format: Plain text, full details. + logger = logger.format_for_files(|writer, now, record| { + write!( + writer, + "[{}] [{}] [{}:{}] {}", + now.now().format("%Y-%m-%d %H:%M:%S"), + record.level(), + record.file().unwrap_or("?"), + record.line().unwrap_or(0), + &record.args() + ) + }); + // Stdout format: Colored output, full details. + logger = logger.format_for_stdout(|writer, now, record| { + write!( + writer, + "[{}] [{}] [{}:{}] {}", + now.now().format("%Y-%m-%d %H:%M:%S"), + style(record.level()).paint(record.level().to_string()), + record.file().unwrap_or("?"), + record.line().unwrap_or(0), + &record.args() + ) + }); + } else { + // Simple mode: Just timestamp, level, and message. + logger = logger.format_for_files(|writer, now, record| { + write!( + writer, + "[{}] [{}] {}", + now.now().format("%Y-%m-%d %H:%M:%S"), + record.level(), + &record.args() + ) + }); + logger = logger.format_for_stdout(|writer, now, record| { + write!( + writer, + "[{}] [{}] {}", + now.now().format("%Y-%m-%d %H:%M:%S"), + style(record.level()).paint(record.level().to_string()), + &record.args() + ) + }); + } + + logger.start()?; + Ok(()) +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..bcd9bab --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,67 @@ +mod server_handler; + +use server_handler::ServerHandler; + +use ::server::{Config, init_logging}; + +use anyhow::Result; +use log::{error, info}; +use russh::server::Config as SSHConfig; +use tokio::net::TcpListener; + +use std::{env, fs, sync::Arc}; + +#[tokio::main] +async fn main() -> Result<()> { + let args: Vec = env::args().collect(); + let meva_server_config = args.get(1).unwrap_or_else(|| { + eprintln!("Usage: {} ", args[0]); + std::process::exit(1); + }); + + let content = fs::read_to_string(meva_server_config)?; + let config: Config = toml::from_str(&content)?; + + init_logging(&config.logging)?; + + fs::create_dir_all(&config.server.repositories_root)?; + + let host_key = russh_keys::load_secret_key(&config.server.host_key, None)?; + info!("Successfully loaded the server host key."); + + let mut ssh_config = SSHConfig::default(); + ssh_config.keys.push(host_key); + let ssh_config = Arc::new(ssh_config); + + let handler = ServerHandler::new( + &config.access.authorized_keys, + &config.access.policy_file, + &config.server.repositories_root, + ); + + let bind_address = config.server.bind_address(); + let listener = TcpListener::bind(&bind_address).await?; + info!("Server listening on {bind_address}..."); + + loop { + let (socket, address) = match listener.accept().await { + Ok(pair) => pair, + Err(e) => { + error!("Failed to accept incoming connection: {e}"); + continue; + } + }; + + info!("Accepted connection from: {address}"); + + let ssh_config = ssh_config.clone(); + let handler = handler.clone(); + + tokio::spawn(async move { + match russh::server::run_stream(ssh_config, socket, handler).await { + Ok(_) => info!("Connection closed successfully"), + Err(e) => error!("Connection ended with an error: {e}"), + } + }); + } +} diff --git a/server/src/server_handler.rs b/server/src/server_handler.rs new file mode 100644 index 0000000..f8a6790 --- /dev/null +++ b/server/src/server_handler.rs @@ -0,0 +1,471 @@ +use anyhow::Result; +use log::{error, info, warn}; +use russh::{ + Channel, + keys::key, + server::{self, Auth, Handler, Session}, + *, +}; + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + str::FromStr, + sync::Arc, +}; + +use ::server::{ + ActiveProtocol, AuthResult, ChannelState, ProtocolCommand, ReceivePackState, UploadPackCommand, + UploadPackState, challenge_public_key, check_repository_access, validate_repository_name, +}; +use engine::{ + network::{ChannelBand, PackfileCodec, SessionExtension, create_pkt_line}, + object_storage::{MevaObjectStorage, ObjectStorage}, + ref_manager::{MevaRefManager, RefManager}, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; + +// Stream data in chunks to avoid choking the connection +const CHUNK_SIZE: usize = 65_000; + +/// The main SSH event handler for the Meva server. +/// +/// This struct manages the lifecycle of SSH connections, including authentication +/// session management, command execution and protocol handling. +pub struct ServerHandler { + /// Path to the file containing trusted public keys (authorized_keys). + authorized_keys_path: String, + /// Path to the TOML file defining repository access policies (read/write). + access_policy_path: String, + /// The root directory where all repositories are stored on the server. + repositories_root: PathBuf, + /// Tracks the state of open channels (mapped by ChannelId). + sessions: HashMap, + /// The identifier of the currently authenticated user (if any). + user: Option, +} + +impl ServerHandler { + /// Creates a new instance of the [`ServerHandler`] with the provided configuration. + pub fn new(auth_keys: &str, policy_file: &str, repos_root: &str) -> Self { + Self { + authorized_keys_path: auth_keys.to_string(), + access_policy_path: policy_file.to_string(), + repositories_root: PathBuf::from(repos_root), + sessions: HashMap::new(), + user: None, + } + } + + /// Initiates the `upload-pack` protocol (Discovery Phase). + /// + /// It sends the initial advertisement of references (branches, tags) and capabilities + /// to the client. + async fn handle_upload_pack_start( + &mut self, + session: &mut Session, + channel: ChannelId, + repository_root: &Path, + ) -> Result<()> { + let line = create_pkt_line("# service=meva-upload-pack"); + session.send_pkt_line(channel, &line); + session.send_flush(channel); + + let repository_layout = Arc::new(MevaRepositoryLayout::new(repository_root.to_path_buf())?); + let ref_manager = MevaRefManager::new(repository_layout); + + for entry in ref_manager.iter_refs_heads()? { + let entry_line = create_pkt_line(&format!("{} {}", entry.commit_hash, entry.name)); + session.send_pkt_line(channel, &entry_line); + } + + session.send_flush(channel); + info!("(UploadPack) List of references sent. Waiting for 'want'/'have'..."); + Ok(()) + } + + async fn handle_receive_pack_start( + &mut self, + _session: &mut Session, + _channel: ChannelId, + _repo_path: &Path, + ) -> Result<()> { + // TODO: push + todo!() + } + + /// Processes incoming data chunks during the `upload-pack` negotiation phase. + /// + /// This method acts as a state machine parser for the client's requests. + /// It handles the `want` (commits client needs) and `have` (commits client owns) + /// lists to determine the common ancestry and the minimal set of objects to send. + fn process_upload_pack_data( + &mut self, + session: &mut Session, + channel: ChannelId, + ) -> Result<()> { + let state = self.sessions.get_mut(&channel).unwrap(); + let upload_state = match &mut state.protocol { + ActiveProtocol::UploadPack(state) => state, + _ => return Ok(()), // should not happen (already checked in 'data' function) + }; + loop { + // Need at least 4 bytes for length prefix + if state.buffer.len() < 4 { + break; + } + + let len_str = std::str::from_utf8(&state.buffer[0..4]).unwrap_or("????"); + let len = match usize::from_str_radix(len_str, 16) { + Ok(l) => l, + Err(e) => { + error!("Could not parse pkt-line length: {e}"); + state.buffer.clear(); + break; + } + }; + + if len == 0 { + info!("(UploadPack) Received flush-packet (0000)"); + state.buffer.drain(0..4); + continue; + } + + if state.buffer.len() < len { + break; // Wait for more data + } + + let line_data = &state.buffer[4..len]; + let line_str = std::str::from_utf8(line_data) + .unwrap_or("[utf8 error]") + .trim(); + + let command = match UploadPackCommand::try_from(line_str) { + Ok(command) => command, + Err(e) => return Err(e.into()), + }; + + match command { + UploadPackCommand::Want(sha) => { + info!("(UploadPack) Received 'want': {sha}"); + upload_state.wants.insert(sha.to_string()); + } + UploadPackCommand::Have(sha) => { + info!("(UploadPack) Received 'have': {sha}"); + upload_state.haves.insert(sha.to_string()); + } + UploadPackCommand::Done => { + info!("(UploadPack) Received 'done'. Negotiation finished."); + state.buffer.drain(0..len); + return self.handle_packfile_generation(session, channel); + } + } + + state.buffer.drain(0..len); + } + + Ok(()) + } + + fn process_receive_pack_data( + &mut self, + _session: &mut Session, + _channel: ChannelId, + ) -> Result<()> { + // TODO: push + todo!() + } + + /// Generates and streams the packfile to the client. + /// + /// This is the final phase of the `upload-pack` protocol. + fn handle_packfile_generation( + &mut self, + session: &mut Session, + channel: ChannelId, + ) -> Result<()> { + info!("(UploadPack) Generating packfile..."); + + let state = self.sessions.get(&channel).unwrap(); + let upload_state = match &state.protocol { + ActiveProtocol::UploadPack(state) => state, + _ => return Ok(()), + }; + + info!("(UploadPack) Client 'wants': {:?}", upload_state.wants); + info!("(UploadPack) Client 'haves': {:?}", upload_state.haves); + + if upload_state.haves.is_empty() { + let nak = create_pkt_line("NAK"); + session.send_pkt_line(channel, &nak); + } + + let repository_layout = Arc::new(MevaRepositoryLayout::new( + upload_state.repository_path.clone(), + )?); + let object_storage = MevaObjectStorage::new(repository_layout); + + let objects = + object_storage.collect_reachable_objects(&upload_state.wants, &upload_state.haves)?; + let objects_count = objects.len(); + + info!("(UploadPack) Collected {objects_count} objects to pack."); + + session.send_channel_band( + channel, + &ChannelBand::Progress, + format!("Enumerating objects: {objects_count}, done.").as_bytes(), + )?; + + let packfile_codec = PackfileCodec::default(); + let packfile = packfile_codec.encode_packfile(&objects)?; + + session.send_channel_band( + channel, + &ChannelBand::Progress, + format!("Compressing objects: {objects_count}, done.").as_bytes(), + )?; + + info!( + "(UploadPack) Sending packfile ({} bytes)...", + packfile.len() + ); + + let mut chunk_counter = 0; + for chunk in packfile.chunks(CHUNK_SIZE) { + session.send_channel_band(channel, &ChannelBand::Packfile, chunk)?; + chunk_counter += 1; + } + + session.send_channel_band( + channel, + &ChannelBand::Progress, + format!("Total {chunk_counter} chunks sent.").as_bytes(), + )?; + + session.send_flush(channel); + + info!("(UploadPack) Packfile sent. Closing channel."); + session.exit_status_request(channel, 0); + session.eof(channel); + session.close(channel); + + Ok(()) + } +} + +#[async_trait::async_trait] +impl Handler for ServerHandler { + type Error = anyhow::Error; + + /// Handles SSH public key authentication. + /// + /// Verifies the provided `public_key` against the configured `authorized_keys_path`. + /// If a match is found, the user identity is stored in the handler state. + async fn auth_publickey( + &mut self, + _user: &str, + public_key: &key::PublicKey, + ) -> Result { + match challenge_public_key(&self.authorized_keys_path, public_key).await? { + AuthResult::Accepted { user } => { + self.user = Some(user); + Ok(Auth::Accept) + } + AuthResult::Rejected => Ok(Auth::Reject { + proceed_with_methods: None, + }), + } + } + + /// Called when the client requests to open a new channel. + /// + /// Initializes the `ChannelState` for the new channel, associating it with + /// the authenticated user. + async fn channel_open_session( + &mut self, + channel: Channel, + _session: &mut Session, + ) -> Result { + info!("Attempting to open a session on channel {:?}", channel.id()); + if let Some(user) = &self.user { + let state = ChannelState::new(user.clone()); + self.sessions.insert(channel.id(), state); + info!( + "Opened a session on channel {:?} for user '{}'", + channel.id(), + user + ); + Ok(true) + } else { + // should not happen at this stage + error!( + "Failed to open a session on channel {:?} (no user in session)", + channel.id() + ); + Ok(false) + } + } + + /// Handles SSH `exec` requests (command execution). + /// + /// This is the entry point for Meva commands. It parses the command, + /// validates repository existence and user permissions, and transitions the channel + /// to the appropriate protocol state. + /// + /// # Command Format + /// Expects: ` ` + async fn exec_request( + &mut self, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) -> Result<(), Self::Error> { + let command_str = std::str::from_utf8(data)?; + + let mut reject = |msg: &str| -> Result<(), Self::Error> { + error!("Executing command on channel {channel:?}: {msg}"); + session.data( + channel, + CryptoVec::from_slice(format!("Error: {msg}\n").as_bytes()), + ); + session.exit_status_request(channel, 1); + session.eof(channel); + session.close(channel); + Ok(()) + }; + + let session_state = match self.sessions.get_mut(&channel) { + Some(state) => state, + None => return reject("Unable to locate user session."), + }; + let user = &session_state.user; + + info!("User '{user}' on channel {channel:?} wants to execute the command: '{command_str}'"); + + let parts: Vec<&str> = command_str.split_whitespace().collect(); + if parts.len() != 2 { + return reject("Invalid command format. Expected: "); + } + + let protocol_command = parts[0]; + let repository_path = parts[1]; + + let repository_name = match validate_repository_name(repository_path) { + Some(name) => name, + None => return reject(&format!("Invalid repository path '{repository_path}'")), + }; + + let protocol_command = match ProtocolCommand::from_str(protocol_command) { + Ok(command) => command, + Err(_) => return reject(&format!("Unknown protocol command '{protocol_command}'")), + }; + + let requires_write = matches!(protocol_command, ProtocolCommand::ReceivePack); + + match check_repository_access( + &self.access_policy_path, + repository_name, + user, + requires_write, + ) { + Err(e) => reject(&format!( + "Internal server error while checking permissions: {e}", + )), + Ok(false) => { + reject("Access denied. You do not have permission to access this repository.") + } + Ok(true) => { + info!("Authorization successful for '{user}' to '{repository_name}'",); + + let mut repository_full_path = self.repositories_root.clone(); + repository_full_path.push(repository_name); + + info!( + "Validating physical path: {}", + repository_full_path.to_string_lossy() + ); + + if !repository_full_path.exists() { + return reject("Access denied. The repository does not exist on the server."); + } + if !repository_full_path.is_dir() { + return reject("Access denied. The repository path is not a directory."); + } + + info!("Path validation successful."); + + match protocol_command { + ProtocolCommand::UploadPack => { + let upload_state = UploadPackState::new(&repository_full_path); + session_state.protocol = ActiveProtocol::UploadPack(upload_state); + self.handle_upload_pack_start(session, channel, &repository_full_path) + .await? + } + ProtocolCommand::ReceivePack => { + session_state.protocol = ActiveProtocol::ReceivePack( + ReceivePackState::new(&repository_full_path), + ); + self.handle_receive_pack_start(session, channel, &repository_full_path) + .await? + } + } + + Ok(()) + } + } + } + + /// Handles incoming raw data on a channel. + /// + /// Routes the data to the specific protocol handler (`process_upload_pack_data` + /// or `process_receive_pack_data`) based on the active state of the channel. + async fn data( + &mut self, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) -> Result<(), Self::Error> { + let protocol_to_run; + + { + let state = match self.sessions.get_mut(&channel) { + Some(state) => state, + None => { + error!("Received data on an unknown channel {channel:?}"); + return Ok(()); + } + }; + + state.buffer.extend_from_slice(data); + + protocol_to_run = state.protocol.clone(); + } + + match protocol_to_run { + ActiveProtocol::UploadPack { .. } => { + self.process_upload_pack_data(session, channel)?; + } + ActiveProtocol::ReceivePack { .. } => { + self.process_receive_pack_data(session, channel)?; + } + ActiveProtocol::None => { + warn!("Received data on channel {channel:?}, which has no active protocol."); + } + } + + Ok(()) + } +} + +impl Clone for ServerHandler { + fn clone(&self) -> Self { + ServerHandler { + authorized_keys_path: self.authorized_keys_path.clone(), + access_policy_path: self.access_policy_path.clone(), + repositories_root: self.repositories_root.clone(), + sessions: HashMap::new(), + user: None, + } + } +} diff --git a/server/src/validation.rs b/server/src/validation.rs new file mode 100644 index 0000000..2b709a7 --- /dev/null +++ b/server/src/validation.rs @@ -0,0 +1,3 @@ +mod repository_name; + +pub use repository_name::validate_repository_name; diff --git a/server/src/validation/repository_name.rs b/server/src/validation/repository_name.rs new file mode 100644 index 0000000..92e38aa --- /dev/null +++ b/server/src/validation/repository_name.rs @@ -0,0 +1,39 @@ +/// Validates and sanitizes a repository name to ensure filesystem safety. +/// +/// # Security +/// +/// This validation is critical for security. By rejecting `..` and internal separators, +/// it prevents malicious clients from accessing files outside the designated repository root +/// (e.g., `../../etc/passwd`). +/// +/// # Arguments +/// * `name`: The raw repository identifier provided by the user or client. +/// +/// # Returns +/// * `Some(&str)`: The sanitized, valid name slice. +/// * `None`: If the name violates any validation rule. +pub fn validate_repository_name(name: &str) -> Option<&str> { + let name = name.trim_start_matches(['/', '\\']); + + if name.is_empty() + // Prevent hidden files or current directory references + || name.starts_with('.') + // Prevent Windows issues with trailing dots + || name.ends_with('.') + // Prevent directory traversal + || name.contains("..") + // Enforce single directory component (no subdirectories allowed here) + || name.contains('/') + || name.contains('\\') + // Enforce filesystem limits + || name.len() > 255 + // Enforce strict character whitelist + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') + { + return None; + } + + Some(name) +} From c11d9ea34a6bd9c70606d38b8bf9cf4e7fc5a9ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:02:37 +0100 Subject: [PATCH 22/42] Command `remote` (#31) --- Cargo.lock | 12 + Cargo.toml | 1 + cli/src/commands/fetch.rs | 42 ++- cli/src/commands/remote.rs | 21 +- cli/src/commands/remote/subcommands/add.rs | 46 ++- .../commands/remote/subcommands/get_url.rs | 57 +-- cli/src/commands/remote/subcommands/remove.rs | 25 +- cli/src/commands/remote/subcommands/rename.rs | 32 +- .../commands/remote/subcommands/set_url.rs | 60 ++-- cli/src/commands/remote/subcommands/show.rs | 27 +- engine/Cargo.toml | 3 + engine/src/config/config_document.rs | 175 +++++++++ .../src/config/config_document_operations.rs | 37 ++ engine/src/config/config_loader.rs | 2 +- engine/src/engine_container.rs | 44 ++- engine/src/errors/config_error.rs | 7 + engine/src/handlers.rs | 2 + engine/src/handlers/clone/operations.rs | 4 +- engine/src/handlers/fetch.rs | 5 + engine/src/handlers/fetch/handlers.rs | 182 ++++++++++ engine/src/handlers/fetch/operations.rs | 21 ++ engine/src/handlers/remote.rs | 7 + engine/src/handlers/remote/handlers.rs | 334 ++++++++++++++++++ engine/src/handlers/remote/models.rs | 104 ++++++ engine/src/handlers/remote/operations.rs | 135 +++++++ engine/src/network.rs | 7 +- engine/src/network/common.rs | 2 + engine/src/network/common/remote_entry.rs | 190 ++++++++++ engine/src/network/protocols/upload_pack.rs | 27 +- .../upload_pack/upload_pack_result.rs | 24 ++ engine/src/network/remotes.rs | 73 ++++ .../network/remotes/meva_remotes_manager.rs | 125 +++++++ engine/src/network/ssh_session.rs | 61 +++- engine/src/ref_manager.rs | 11 +- engine/src/ref_manager/meva_ref_manager.rs | 59 ++-- engine/src/ref_manager/ref_entry.rs | 2 +- engine/src/repositories/meva_repository.rs | 2 + engine/src/repositories/repository_layout.rs | 30 ++ server/src/server_handler.rs | 18 +- shared/src/extensions/path_to_string.rs | 2 +- 40 files changed, 1902 insertions(+), 116 deletions(-) create mode 100644 engine/src/handlers/fetch.rs create mode 100644 engine/src/handlers/fetch/handlers.rs create mode 100644 engine/src/handlers/fetch/operations.rs create mode 100644 engine/src/handlers/remote.rs create mode 100644 engine/src/handlers/remote/handlers.rs create mode 100644 engine/src/handlers/remote/models.rs create mode 100644 engine/src/handlers/remote/operations.rs create mode 100644 engine/src/network/common/remote_entry.rs create mode 100644 engine/src/network/remotes.rs create mode 100644 engine/src/network/remotes/meva_remotes_manager.rs diff --git a/Cargo.lock b/Cargo.lock index 58f2550..f6b0e64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -776,6 +776,7 @@ dependencies = [ "flate2", "globset", "hex", + "itertools", "mockall", "owo-colors", "path-absolutize", @@ -791,6 +792,8 @@ dependencies = [ "sha1 0.11.0-rc.2", "shared", "similar", + "strum", + "strum_macros", "tempfile", "thiserror 2.0.16", "tokio", @@ -1279,6 +1282,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" diff --git a/Cargo.toml b/Cargo.toml index cf5c8c0..a8f9e94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,3 +35,4 @@ russh = "0.44.0" russh-keys = "0.44.0" cryptovec = "0.7.0" url = "2.5.7" +itertools = "0.14.0" diff --git a/cli/src/commands/fetch.rs b/cli/src/commands/fetch.rs index bb246ac..1a531cf 100644 --- a/cli/src/commands/fetch.rs +++ b/cli/src/commands/fetch.rs @@ -1,11 +1,16 @@ use async_trait::async_trait; use clap::{Arg, ArgAction, ArgMatches, Command}; -use engine::engine_container::MevaContainer; +use engine::{EngineContainer, engine_container::MevaContainer, handlers::fetch::Request}; -use miette::Result; +use miette::{IntoDiagnostic, Result}; use crate::{commands::MevaCommand, extensions::WithVerbose}; +/// Represents the `fetch` command in the CLI. +/// +/// This command is responsible for downloading objects and references from a remote +/// repository. It serves as the entry point for the fetch logic, handling argument +/// parsing and delegating the execution to the appropriate engine handler. pub struct FetchCommand; impl FetchCommand { @@ -14,10 +19,13 @@ impl FetchCommand { Self {} } + /// The name of the argument used to specify the prune flag. const ARG_PRUNE: &'static str = "prune"; - const ARG_REPOSITORY: &'static str = "repository"; + /// The name of the argument used to specify the remote origin. + const ARG_ORIGIN: &'static str = "origin"; + /// The name of the argument used to specify a specific branch. const ARG_BRANCH: &'static str = "branch"; } @@ -25,24 +33,33 @@ impl FetchCommand { impl MevaCommand for FetchCommand { type Container = MevaContainer; + /// Returns the name of the command as invoked from the command line. fn name(&self) -> &'static str { "fetch" } + /// Returns a brief description of the command's purpose, displayed in help messages. fn about(&self) -> &'static str { "Download objects and refs from a remote repository" } + /// Returns the current version of the command. fn version(&self) -> &'static str { "1.0.0" } /// Builds the CLI argument parser for the `fetch` command using `clap`. + /// + /// This method defines the accepted arguments: + /// - `[ORIGIN]`: The remote repository to fetch from (defaults to "origin"). + /// - `[BRANCH]`: An optional specific branch to fetch. + /// - `--prune` (`-p`): A flag to remove remote-tracking branches that no longer exist on the remote. + /// - `--verbose`: Inherited from `WithVerbose` extension. fn build_command(&self) -> Command { self.build_base_command() .arg( - Arg::new(Self::ARG_REPOSITORY) - .value_name("REPOSITORY") + Arg::new(Self::ARG_ORIGIN) + .value_name("ORIGIN") .index(1) .required(false) .default_value("origin") @@ -53,7 +70,6 @@ impl MevaCommand for FetchCommand { .value_name("BRANCH") .index(2) .required(false) - .default_value("master") .help("Branch to fetch"), ) .arg( @@ -66,8 +82,18 @@ impl MevaCommand for FetchCommand { .with_verbose_arg("Enable verbose output") } - /// Executes the `fetch` command. - async fn execute(&self, _matches: &ArgMatches, _container: &Self::Container) -> Result<()> { + /// Executes the `fetch` command logic. + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { + let request = Request { + origin: matches.get_one::(Self::ARG_ORIGIN).unwrap().clone(), + branch: matches.get_one::(Self::ARG_BRANCH).cloned(), + prune: matches.get_flag(Self::ARG_PRUNE), + verbose: matches.get_flag(Command::ARG_VERBOSE), + }; + + let handler = container.fetch_handler().into_diagnostic()?; + let _response = handler.handle_fetch(request).await.into_diagnostic()?; + Ok(()) } } diff --git a/cli/src/commands/remote.rs b/cli/src/commands/remote.rs index 6666636..f7f9699 100644 --- a/cli/src/commands/remote.rs +++ b/cli/src/commands/remote.rs @@ -4,9 +4,11 @@ use subcommands::*; use async_trait::async_trait; use clap::{ArgMatches, Command}; -use miette::Result; +use miette::{IntoDiagnostic, Result}; -use engine::engine_container::MevaContainer; +use engine::{ + EngineContainer, engine_container::MevaContainer, handlers::remote::RemoteOperations, +}; use crate::{ commands::{MevaCommand, execute_multiple}, @@ -83,8 +85,19 @@ impl MevaCommand for RemoteCommand { // If no subcommand was invoked, fall back to listing remotes if matches.subcommand_name().is_none() { let verbose = matches.get_flag(Command::ARG_VERBOSE); - println!("{verbose}"); - // TODO: implement remotes listing logic here... + let handler = container.remote_handler().into_diagnostic()?; + let response = handler.list().into_diagnostic()?; + + let mut remotes_vec: Vec<_> = response.remotes.iter().collect(); + remotes_vec.sort_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b)); + + for (name, entry) in remotes_vec { + if verbose { + println!("{}", entry.display_verbose(name)); + } else { + println!("{name}"); + } + } } Ok(handled) diff --git a/cli/src/commands/remote/subcommands/add.rs b/cli/src/commands/remote/subcommands/add.rs index ffbfcad..e98193b 100644 --- a/cli/src/commands/remote/subcommands/add.rs +++ b/cli/src/commands/remote/subcommands/add.rs @@ -1,6 +1,13 @@ +use std::path::PathBuf; + use async_trait::async_trait; -use clap::{Arg, ArgAction, ArgMatches, Command}; -use engine::engine_container::MevaContainer; +use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::remote::{AddRequest, RemoteOperations}, +}; +use miette::IntoDiagnostic; use url::Url; use crate::{commands::MevaCommand, extensions::WithName}; @@ -23,6 +30,9 @@ impl RemoteAddCommand { /// Argument name for the remote URL. const ARG_URL: &'static str = "url"; + + /// Argument name for specifying the server public key. + const ARG_SERVER_KEY: &'static str = "server-key"; } #[async_trait] @@ -53,6 +63,15 @@ impl MevaCommand for RemoteAddCommand { .value_parser(clap::value_parser!(Url)) .help("Repository URL (e.g. ssh://user@host:port/repository)"), ) + .arg( + Arg::new(Self::ARG_SERVER_KEY) + .value_name("SERVER_KEY") + .index(3) + .required(true) + .value_hint(ValueHint::FilePath) + .value_parser(clap::value_parser!(PathBuf)) + .help("Path to server's public key"), + ) .arg( Arg::new(Self::ARG_FETCH) .short('f') @@ -68,10 +87,27 @@ impl MevaCommand for RemoteAddCommand { /// and the fetch flag is set, it initiates a fetch operation from the new remote. async fn execute( &self, - _matches: &ArgMatches, - _container: &Self::Container, + matches: &ArgMatches, + container: &Self::Container, ) -> miette::Result<()> { - todo!(); + let request = AddRequest { + name: matches + .get_one::(Command::ARG_NAME) + .unwrap() + .to_string(), + url: matches.get_one::(Self::ARG_URL).unwrap().clone(), + pub_key: matches + .get_one::(Self::ARG_SERVER_KEY) + .unwrap() + .clone(), + fetch: matches.get_flag(Self::ARG_FETCH), + }; + + let handler = container.remote_handler().into_diagnostic()?; + + let _response = handler.add(request).into_diagnostic()?; + + Ok(()) } } diff --git a/cli/src/commands/remote/subcommands/get_url.rs b/cli/src/commands/remote/subcommands/get_url.rs index 098a38e..e845b21 100644 --- a/cli/src/commands/remote/subcommands/get_url.rs +++ b/cli/src/commands/remote/subcommands/get_url.rs @@ -1,6 +1,13 @@ use async_trait::async_trait; -use clap::{Arg, ArgMatches, Command, ValueEnum, value_parser}; -use engine::engine_container::MevaContainer; +use clap::{Arg, ArgMatches, Command, builder::PossibleValuesParser}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::remote::{GetUrlRequest, RemoteOperations}, + network::RemoteDirection, +}; +use miette::IntoDiagnostic; +use strum::VariantNames; use crate::{commands::MevaCommand, extensions::WithName}; @@ -17,8 +24,8 @@ impl RemoteGetUrlCommand { Self } - /// Argument name for the mode selection (fetch/push/all). - const ARG_MODE: &'static str = "mode"; + /// Argument name for the direction selection (fetch/push). + const ARG_DIRECTION: &'static str = "direction"; } #[async_trait] @@ -42,12 +49,12 @@ impl MevaCommand for RemoteGetUrlCommand { self.build_base_command() .with_name_arg("Name for the remote") .arg( - Arg::new(Self::ARG_MODE) - .long(Self::ARG_MODE) - .short('m') - .value_name("MODE") - .value_parser(value_parser!(UrlMode)) + Arg::new(Self::ARG_DIRECTION) + .long(Self::ARG_DIRECTION) + .short('d') + .value_name("DIRECTION") .default_value("fetch") + .value_parser(PossibleValuesParser::new(RemoteDirection::VARIANTS)) .help("Select which URL(s) to show"), ) } @@ -57,10 +64,24 @@ impl MevaCommand for RemoteGetUrlCommand { /// Looks up the specified remote in the configuration and prints the requested URL. async fn execute( &self, - _matches: &ArgMatches, - _container: &Self::Container, + matches: &ArgMatches, + container: &Self::Container, ) -> miette::Result<()> { - todo!(); + let direction_str = matches.get_one::(Self::ARG_DIRECTION).unwrap(); + let request = GetUrlRequest { + name: matches + .get_one::(Command::ARG_NAME) + .unwrap() + .clone(), + direction: direction_str.parse::().unwrap(), + }; + + let handler = container.remote_handler().into_diagnostic()?; + let response = handler.get_url(request).into_diagnostic()?; + + println!("{}", response.url); + + Ok(()) } } @@ -78,15 +99,3 @@ mod tests { assert_eq!(cmd.version(), "1.0.0"); } } - -/// Specifies which URL type to retrieve for a remote repository. -#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, ValueEnum)] -pub enum UrlMode { - /// Retrieve the URL used for fetching data (default). - #[default] - Fetch, - /// Retrieve the URL used for pushing data. - Push, - /// Retrieve all URLs associated with the remote. - All, -} diff --git a/cli/src/commands/remote/subcommands/remove.rs b/cli/src/commands/remote/subcommands/remove.rs index 7029940..1ae4950 100644 --- a/cli/src/commands/remote/subcommands/remove.rs +++ b/cli/src/commands/remote/subcommands/remove.rs @@ -1,6 +1,11 @@ use async_trait::async_trait; use clap::{ArgMatches, Command}; -use engine::engine_container::MevaContainer; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::remote::{RemoteOperations, RemoveRequest}, +}; +use miette::IntoDiagnostic; use crate::{commands::MevaCommand, extensions::WithName}; @@ -45,10 +50,22 @@ impl MevaCommand for RemoteRemoveCommand { /// Deletes the configuration for the specified remote name. async fn execute( &self, - _matches: &ArgMatches, - _container: &Self::Container, + matches: &ArgMatches, + container: &Self::Container, ) -> miette::Result<()> { - todo!(); + let request = RemoveRequest { + name: matches + .get_one::(Command::ARG_NAME) + .unwrap() + .clone(), + }; + + let handler = container.remote_handler().into_diagnostic()?; + let response = handler.remove(request).into_diagnostic()?; + + println!("{}", response.removed_value); + + Ok(()) } } diff --git a/cli/src/commands/remote/subcommands/rename.rs b/cli/src/commands/remote/subcommands/rename.rs index ff2e78d..644495d 100644 --- a/cli/src/commands/remote/subcommands/rename.rs +++ b/cli/src/commands/remote/subcommands/rename.rs @@ -1,6 +1,11 @@ use async_trait::async_trait; use clap::{Arg, ArgMatches, Command}; -use engine::engine_container::MevaContainer; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::remote::{RemoteOperations, RenameRequest}, +}; +use miette::IntoDiagnostic; use crate::commands::MevaCommand; @@ -66,10 +71,29 @@ impl MevaCommand for RemoteRenameCommand { /// then updates the repository configuration to reflect the name change. async fn execute( &self, - _matches: &ArgMatches, - _container: &Self::Container, + matches: &ArgMatches, + container: &Self::Container, ) -> miette::Result<()> { - todo!(); + let request = RenameRequest { + old_name: matches + .get_one::(Self::ARG_OLD_NAME) + .unwrap() + .clone(), + new_name: matches + .get_one::(Self::ARG_NEW_NAME) + .unwrap() + .clone(), + }; + + let handler = container.remote_handler().into_diagnostic()?; + let response = handler.rename(request).into_diagnostic()?; + + println!( + "Remote renamed: {} -> {}", + response.old_name, response.new_name + ); + + Ok(()) } } diff --git a/cli/src/commands/remote/subcommands/set_url.rs b/cli/src/commands/remote/subcommands/set_url.rs index 6cb6293..ae1606a 100644 --- a/cli/src/commands/remote/subcommands/set_url.rs +++ b/cli/src/commands/remote/subcommands/set_url.rs @@ -1,6 +1,13 @@ use async_trait::async_trait; -use clap::{Arg, ArgAction, ArgMatches, Command}; -use engine::engine_container::MevaContainer; +use clap::{Arg, ArgMatches, Command, builder::PossibleValuesParser}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::remote::{RemoteOperations, SetUrlRequest}, + network::RemoteDirection, +}; +use miette::IntoDiagnostic; +use strum::VariantNames; use url::Url; use crate::{commands::MevaCommand, extensions::WithName}; @@ -19,14 +26,11 @@ impl RemoteSetUrlCommand { Self } - /// Argument name for the push flag. - const ARG_PUSH: &'static str = "push"; + /// Argument name for the direction selection (fetch/push). + const ARG_DIRECTION: &'static str = "direction"; /// Argument name for the new URL. const ARG_NEW_URL: &'static str = "new-url"; - - /// Argument name for the old URL (optional filter). - const ARG_OLD_URL: &'static str = "old-url"; } #[async_trait] @@ -58,19 +62,13 @@ impl MevaCommand for RemoteSetUrlCommand { .help("New repository URL (e.g. ssh://user@host:port/repository)"), ) .arg( - Arg::new(Self::ARG_OLD_URL) - .value_name("OLD_URL") - .index(3) - .required(false) - .value_parser(clap::value_parser!(Url)) - .help("Old URL to replace if the remote has multiple URLs"), - ) - .arg( - Arg::new(Self::ARG_PUSH) - .long(Self::ARG_PUSH) - .short('p') - .action(ArgAction::SetTrue) - .help("Modify the push-URL instead of the fetch-URL"), + Arg::new(Self::ARG_DIRECTION) + .long(Self::ARG_DIRECTION) + .short('d') + .value_name("DIRECTION") + .default_value("fetch") + .value_parser(PossibleValuesParser::new(RemoteDirection::VARIANTS)) + .help("Select which URL(s) to show"), ) } @@ -79,10 +77,26 @@ impl MevaCommand for RemoteSetUrlCommand { /// Updates the configuration for the specified remote. async fn execute( &self, - _matches: &ArgMatches, - _container: &Self::Container, + matches: &ArgMatches, + container: &Self::Container, ) -> miette::Result<()> { - todo!(); + let direction_str = matches.get_one::(Self::ARG_DIRECTION).unwrap(); + + let request = SetUrlRequest { + name: matches + .get_one::(Command::ARG_NAME) + .unwrap() + .clone(), + new_url: matches.get_one::(Self::ARG_NEW_URL).unwrap().clone(), + direction: direction_str.parse::().unwrap(), + }; + + let handler = container.remote_handler().into_diagnostic()?; + let _response = handler.set_url(request).into_diagnostic()?; + + println!("Remote URL changed successfully!"); + + Ok(()) } } diff --git a/cli/src/commands/remote/subcommands/show.rs b/cli/src/commands/remote/subcommands/show.rs index 816be87..67e0879 100644 --- a/cli/src/commands/remote/subcommands/show.rs +++ b/cli/src/commands/remote/subcommands/show.rs @@ -1,6 +1,12 @@ use async_trait::async_trait; use clap::{Arg, ArgAction, ArgMatches, Command}; -use engine::engine_container::MevaContainer; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::remote::{RemoteOperations, ShowRequest}, +}; +use miette::IntoDiagnostic; +use owo_colors::OwoColorize; use crate::{commands::MevaCommand, extensions::WithName}; @@ -58,10 +64,23 @@ impl MevaCommand for RemoteShowCommand { /// the remote server to display the most up-to-date branch status. async fn execute( &self, - _matches: &ArgMatches, - _container: &Self::Container, + matches: &ArgMatches, + container: &Self::Container, ) -> miette::Result<()> { - todo!(); + let name = matches.get_one::(Command::ARG_NAME).unwrap(); + + let request = ShowRequest { + name: name.clone(), + no_fetch: matches.get_flag(Self::ARG_NO_FETCH), + }; + + let handler = container.remote_handler().into_diagnostic()?; + let response = handler.show(request).await.into_diagnostic()?; + + println!("* Remote {}", name.bold()); + println!("{response}"); + + Ok(()) } } diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 5c5d340..29c4dd2 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -35,6 +35,9 @@ cryptovec.workspace = true async-trait.workspace = true tokio.workspace = true url.workspace = true +itertools.workspace = true +strum.workspace = true +strum_macros.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/engine/src/config/config_document.rs b/engine/src/config/config_document.rs index 245ba98..29cbff6 100644 --- a/engine/src/config/config_document.rs +++ b/engine/src/config/config_document.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, fs, path::{Path, PathBuf}, }; @@ -147,6 +148,37 @@ impl ConfigDocument { _ => {} // Ignore other item types } } + + /// Converts a TOML [`Item`] into a [`HashMap`] of strings if it represents a table. + /// + /// This method handles both standard tables and inline tables. + /// + /// # Returns + /// `Some(HashMap)` containing the key-value pairs if the item is a table, + /// or `None` if the item is a primitive value or array. + fn item_to_map(item: &Item) -> Option> { + let mut map = HashMap::new(); + + match item { + Item::Table(table) => { + for (k, v) in table.iter() { + if let Some(val_str) = Self::value_to_string(v.as_value()?) { + map.insert(k.to_string(), val_str); + } + } + Some(map) + } + Item::Value(Value::InlineTable(table)) => { + for (k, v) in table.iter() { + if let Some(val_str) = Self::value_to_string(v) { + map.insert(k.to_string(), val_str); + } + } + Some(map) + } + _ => None, + } + } } impl ConfigDocumentOperations for ConfigDocument { @@ -183,6 +215,76 @@ impl ConfigDocumentOperations for ConfigDocument { } } + fn get_table(&self, key_path: &str) -> EngineResult> { + let keys = Self::split_key_path(key_path); + let mut current_item = self.doc.as_item(); + + for key in keys { + current_item = match current_item.get(key) { + Some(item) => item, + None => { + return Err(ConfigError::KeyNotFound { + key: key_path.to_string(), + } + .into()); + } + } + } + + if !current_item.is_table() && !current_item.is_inline_table() { + return Err(ConfigError::InvalidValueType { + type_name: current_item.type_name().to_string(), + } + .into()); + } + + let mut collected_vec = Vec::new(); + + Self::collect_key_values("", current_item, &mut collected_vec); + let result_map: HashMap = collected_vec.into_iter().collect(); + + Ok(result_map) + } + + fn get_subtables( + &self, + key_path: &str, + ) -> EngineResult>> { + let keys = Self::split_key_path(key_path); + let mut current_item = self.doc.as_item(); + + for key in keys { + match current_item.get(key) { + Some(item) => current_item = item, + None => { + return Err(ConfigError::KeyNotFound { + key: key_path.to_string(), + } + .into()); + } + } + } + + let mut result = HashMap::new(); + + if let Some(table) = current_item.as_table() { + for (key, item) in table.iter() { + if let Some(properties) = Self::item_to_map(item) { + result.insert(key.to_string(), properties); + } + } + } else if let Some(inline_table) = current_item.as_inline_table() { + for (key, item) in inline_table.iter() { + let item_wrapper = Item::Value(item.clone()); + if let Some(properties) = Self::item_to_map(&item_wrapper) { + result.insert(key.to_string(), properties); + } + } + } + + Ok(result) + } + fn set(&mut self, key_path: &str, val: &str) -> EngineResult<()> { let keys = Self::split_key_path(key_path); @@ -208,6 +310,33 @@ impl ConfigDocumentOperations for ConfigDocument { Ok(()) } + fn set_table(&mut self, key_path: &str, values: &HashMap) -> EngineResult<()> { + let keys = Self::split_key_path(key_path); + + let mut current_table = self.doc.as_table_mut(); + + for key in keys { + let entry = current_table.entry(key); + + let item = entry.or_insert(Item::Table(Table::new())); + + let item_name = item.type_name().to_string(); + + current_table = item.as_table_mut().ok_or(ConfigError::InvalidValueType { + type_name: item_name, + })?; + + current_table.set_implicit(false); + } + + for (k, v) in values { + let item = Self::parse_item(v)?; + current_table.insert(k, item); + } + + Ok(()) + } + fn unset(&mut self, key_path: &str) -> EngineResult { let keys = Self::split_key_path(key_path); @@ -275,4 +404,50 @@ impl ConfigDocumentOperations for ConfigDocument { Self::collect_key_values("", self.doc.as_item(), &mut result); result } + + fn rename_entry( + &mut self, + parent_path: &str, + old_key: &str, + new_key: &str, + ) -> EngineResult<()> { + let keys = Self::split_key_path(parent_path); + let mut current_item = self.doc.as_item_mut(); + + for key in keys { + if let Some(item) = current_item.get_mut(key) { + current_item = item; + } else { + return Err(ConfigError::KeyNotFound { + key: parent_path.to_string(), + } + .into()); + } + } + + let current_item_type = current_item.type_name().to_string(); + + let parent_table = current_item + .as_table_mut() + .ok_or(ConfigError::InvalidValueType { + type_name: current_item_type, + })?; + + if parent_table.contains_key(new_key) { + return Err(ConfigError::KeyAlreadyExists { + key: format!("{parent_path}.{new_key}"), + } + .into()); + } + + if let Some(item) = parent_table.remove(old_key) { + parent_table.insert(new_key, item); + Ok(()) + } else { + Err(ConfigError::KeyNotFound { + key: format!("{parent_path}.{old_key}"), + } + .into()) + } + } } diff --git a/engine/src/config/config_document_operations.rs b/engine/src/config/config_document_operations.rs index 6fe0566..63479a8 100644 --- a/engine/src/config/config_document_operations.rs +++ b/engine/src/config/config_document_operations.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::errors::EngineResult; /// Defines operations for accessing and modifying configuration values @@ -14,6 +16,22 @@ pub trait ConfigDocumentOperations { /// An `EngineResult` wrapping the found value as `String`, or an error if retrieval fails. fn get(&self, key_path: &str, default: Option<&String>) -> EngineResult; + /// Retrieves all values within a specific table section as a flat `HashMap`. + /// + /// This flattens nested keys relative to the requested `key_path`. + /// For example, if `[remote.origin]` has a key `url`, the result map will contain "url". + fn get_table(&self, key_path: &str) -> EngineResult>; + + /// Retrieves immediate sub-tables of a given key path. + /// + /// # Returns + /// A map where the key is the sub-table name (e.g., "origin") and the value + /// is a map of that sub-table's properties. + fn get_subtables( + &self, + key_path: &str, + ) -> EngineResult>>; + /// Sets or overrides the value at the specified key path. /// /// # Arguments @@ -26,6 +44,12 @@ pub trait ConfigDocumentOperations { /// An `EngineResult<()>` indicating success or containing an error. fn set(&mut self, key_path: &str, val: &str) -> EngineResult<()>; + /// Sets multiple values within a specific table section. + /// + /// This ensures the target table exists and populates it with the provided + /// key-value pairs. + fn set_table(&mut self, key_path: &str, values: &HashMap) -> EngineResult<()>; + /// Removes the value at the specified key path, if it exists. /// /// # Arguments @@ -43,4 +67,17 @@ pub trait ConfigDocumentOperations { /// /// A `Vec` of tuples where each tuple contains the full key path and its corresponding value. fn list(&self) -> Vec<(String, String)>; + + /// Renames an entry within a specific table. + /// + /// # Arguments + /// + /// * `parent_path` - The path to the table containing the key. + /// * `old_key` - The current name of the key. + /// * `new_key` - The new name for the key. + /// + /// # Errors + /// Returns `KeyAlreadyExists` if `new_key` is already present in the table. + fn rename_entry(&mut self, parent_path: &str, old_key: &str, new_key: &str) + -> EngineResult<()>; } diff --git a/engine/src/config/config_loader.rs b/engine/src/config/config_loader.rs index 312f54d..56448a1 100644 --- a/engine/src/config/config_loader.rs +++ b/engine/src/config/config_loader.rs @@ -78,7 +78,7 @@ impl MevaConfigLoader { /// /// # Returns /// - /// The found value or an error wrapped in `EngineResult`. + /// The found value or an error wrapped in [`EngineResult`]. fn try_load_from( &self, location: &ConfigLocation, diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 784030e..fd7663b 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -8,11 +8,12 @@ use crate::diff_builder::MevaDiffBuilder; use crate::errors::EngineResult; use crate::handlers::{ add::AddHandler, clone::CloneHandler, commit, commit::CommitHandler, config::ConfigHandler, - diff::DiffHandler, init::InitHandler, log::LogHandler, ls_files::LsFilesHandler, - ls_tree::LsTreeHandler, plugins::PluginsHandler, restore::RestoreHandler, show::ShowHandler, - status::StatusHandler, + diff::DiffHandler, fetch::FetchHandler, init::InitHandler, log::LogHandler, + ls_files::LsFilesHandler, ls_tree::LsTreeHandler, plugins::PluginsHandler, + remote::RemoteHandler, restore::RestoreHandler, show::ShowHandler, status::StatusHandler, }; use crate::index::{MevaIndex, MevaWorkingDir}; +use crate::network::MevaRemotesManager; use crate::object_storage::{MevaDryRunObjectStorage, MevaObjectStorage}; use crate::plugins_interceptor::PluginsInterceptor; use crate::ref_manager::MevaRefManager; @@ -65,6 +66,12 @@ pub trait EngineContainer { /// Return the handler responsible for clone command fn clone_handler(&self) -> EngineResult; + + /// Return the handler responsible for clone command + fn fetch_handler(&self) -> EngineResult; + + /// Return the handler responsible for remote command + fn remote_handler(&self) -> EngineResult; } /// Concrete implementation of `EngineContainer` for Meva. @@ -354,4 +361,35 @@ impl EngineContainer for MevaContainer { let handler = CloneHandler::new(repository_layout, config_loader); Ok(handler) } + + fn fetch_handler(&self) -> EngineResult { + let layout = self.repository_layout_discover()?; + let object_storage = Arc::new(MevaObjectStorage::new(layout.clone())); + let config_loader = Arc::new(MevaConfigLoader::default()); + let ref_manager = Arc::new(MevaRefManager::new(layout.clone())); + let remotes_manager = Arc::new(MevaRemotesManager); + + let handler = FetchHandler::new( + layout, + object_storage, + config_loader, + ref_manager, + remotes_manager, + ); + Ok(handler) + } + + fn remote_handler(&self) -> EngineResult { + let layout = self.repository_layout_discover()?; + let ref_manager = Arc::new(MevaRefManager::new(layout.clone())); + let remotes_manager = Arc::new(MevaRemotesManager); + let config_loader = Arc::new(MevaConfigLoader::default()); + + Ok(RemoteHandler::new( + remotes_manager, + config_loader, + layout, + ref_manager, + )) + } } diff --git a/engine/src/errors/config_error.rs b/engine/src/errors/config_error.rs index f873fbf..bde347e 100644 --- a/engine/src/errors/config_error.rs +++ b/engine/src/errors/config_error.rs @@ -52,4 +52,11 @@ pub enum ConfigError { /// Indicates that the user's home directory could not be determined. #[error("Home directory not found")] HomeDirNotFound, + + /// Indicates that key already exists in the specified config file. + #[error("Key '{key}' already exists")] + KeyAlreadyExists { + /// The existing key string provided by the caller. + key: String, + }, } diff --git a/engine/src/handlers.rs b/engine/src/handlers.rs index 5873a94..ad07d9f 100644 --- a/engine/src/handlers.rs +++ b/engine/src/handlers.rs @@ -3,11 +3,13 @@ pub mod clone; pub mod commit; pub mod config; pub mod diff; +pub mod fetch; pub mod init; pub mod log; pub mod ls_files; pub mod ls_tree; pub mod plugins; +pub mod remote; pub mod restore; pub mod show; pub mod status; diff --git a/engine/src/handlers/clone/operations.rs b/engine/src/handlers/clone/operations.rs index e1e0e3b..4fe36d4 100644 --- a/engine/src/handlers/clone/operations.rs +++ b/engine/src/handlers/clone/operations.rs @@ -13,7 +13,9 @@ pub struct Request { pub quiet: bool, } -pub struct Response {} +pub struct Response { + // TODO +} #[async_trait] pub trait CloneOperations { diff --git a/engine/src/handlers/fetch.rs b/engine/src/handlers/fetch.rs new file mode 100644 index 0000000..68f0556 --- /dev/null +++ b/engine/src/handlers/fetch.rs @@ -0,0 +1,5 @@ +mod handlers; +mod operations; + +pub use handlers::FetchHandler; +pub use operations::*; diff --git a/engine/src/handlers/fetch/handlers.rs b/engine/src/handlers/fetch/handlers.rs new file mode 100644 index 0000000..940106d --- /dev/null +++ b/engine/src/handlers/fetch/handlers.rs @@ -0,0 +1,182 @@ +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + sync::Arc, +}; + +use async_trait::async_trait; +use itertools::Itertools; +use shared::PathToString; + +use crate::{ + ConfigLoader, RepositoryLayout, + errors::EngineResult, + network::{PackfileCodec, RemoteEntry, RemotesManager, SshConnectionParams, SshService}, + object_storage::ObjectStorage, + ref_manager::{RefEntry, RefManager}, +}; + +use super::{FetchOperations, Request, Response}; + +/// Handles the logic for fetching data from a remote repository. +pub struct FetchHandler { + ssh_service: SshService, + repository_layout: Arc, + object_storage: Arc, + config_loader: Arc, + ref_manager: Arc, + remotes_manager: Arc, +} + +impl FetchHandler { + /// Creates a new instance of the [`FetchHandler`]. + pub fn new( + repository_layout: Arc, + object_storage: Arc, + config_loader: Arc, + ref_manager: Arc, + remotes_manager: Arc, + ) -> Self { + Self { + ssh_service: SshService, + repository_layout, + object_storage, + config_loader, + ref_manager, + remotes_manager, + } + } + + /// Convenience wrapper to execute the fetch operation. + pub async fn handle_fetch(&self, request: Request) -> EngineResult { + self.fetch(request).await + } + + /// Retrieves the configuration details for a specific remote. + fn get_remote_entry(&self, name: &str) -> EngineResult { + self.remotes_manager.get_remote(name) + } + + /// Retrieves the path to the user's SSH signing key from the configuration. + fn get_user_signing_key(&self) -> EngineResult { + Ok(PathBuf::from( + self.config_loader.get("user.signing_key", None)?, + )) + } + + /// Collects existing local references for a specific remote. + /// + /// If a specific `branch` is provided in the request, this attempts to resolve + /// only that branch's local tracking ref. Otherwise, it collects all references + /// under the remote's namespace. + fn collect_refs(&self, origin: &str, branch: &Option) -> EngineResult> { + match branch { + Some(branch) => { + let name = self + .repository_layout + .remotes_refs_dir_rel() + .join(origin) + .join(branch); + Ok(vec![ + self.ref_manager.read_ref(&name.to_utf8_string())?.unwrap(), + ]) // TODO: RefNotFound? + } + None => self.ref_manager.collect_refs_remotes(origin), + } + } + + /// Removes local remote-tracking branches that no longer exist on the server. + /// + /// This is only executed if the `prune` flag is set in the request. + fn prune_remote_refs( + &self, + local_refs: &mut Vec, + server_refs: &[RefEntry], + ) -> EngineResult<()> { + println!("Pruning remote branches..."); + let server_names: HashSet<&str> = server_refs.iter().map(|r| r.name.as_str()).collect(); + let to_prune = local_refs + .iter() + .filter(|local| !server_names.contains(local.name.as_str())) + .cloned() + .collect_vec(); + + // TODO: usunąć branche + for r in &to_prune { + println!("Pruning {}...", r.name); + } + + local_refs.retain(|local| server_names.contains(local.name.as_str())); + Ok(()) + } + + /// Updates local references to match the state of the remote repository. + fn update_remote_refs(&self, refs_to_update: &[RefEntry]) -> EngineResult<()> { + for r in refs_to_update { + println!( + "Updating/creating remote-tracking ref: {} -> {}", + r.name, r.commit_hash + ); + } + Ok(()) + } + + /// Decodes the received packfile data and writes the objects to storage. + fn process_packfile(&self, packfile_data: &[u8]) -> EngineResult<()> { + println!("Decoding objects..."); + + let objects = PackfileCodec::default().decode_packfile(packfile_data)?; + + println!("Successfully decoded {} objects.", objects.len()); + + self.object_storage.add_objects_from_packfile(&objects) + } +} + +#[async_trait] +impl FetchOperations for FetchHandler { + /// Executes the full fetch protocol. + async fn fetch(&self, request: Request) -> EngineResult { + let remote_entry = self.get_remote_entry(&request.origin)?; + let mut connection_params = SshConnectionParams::try_from(&remote_entry.url)?; + + let client_key = self.get_user_signing_key()?; + connection_params.client_key_path = client_key; + connection_params.server_key_path = remote_entry.pub_signing_key; + + let mut ssh_session = self.ssh_service.connect(&connection_params).await?; + + let mut local_refs = self.collect_refs(&request.origin, &request.branch)?; + let haves = local_refs + .iter() + .map(|e| e.commit_hash.clone()) + .unique() + .collect::>(); + + let packfile_result = ssh_session + .run_upload_pack(&connection_params.repository_name, haves) + .await?; + + let server_refs = packfile_result.refs; + + if request.prune { + self.prune_remote_refs(&mut local_refs, &server_refs)?; + } + + let local_map: HashMap<&str, &RefEntry> = + local_refs.iter().map(|e| (e.name.as_str(), e)).collect(); + + let refs_to_update: Vec = server_refs + .into_iter() + .filter(|server| match local_map.get(server.name.as_str()) { + Some(local) => local.commit_hash != server.commit_hash, + None => true, + }) + .collect(); + + self.process_packfile(&packfile_result.packfile_data)?; + self.update_remote_refs(&refs_to_update)?; + + Ok(Response {}) // TODO: można tutaj coś zwrócić? + } +} diff --git a/engine/src/handlers/fetch/operations.rs b/engine/src/handlers/fetch/operations.rs new file mode 100644 index 0000000..2207dd7 --- /dev/null +++ b/engine/src/handlers/fetch/operations.rs @@ -0,0 +1,21 @@ +use async_trait::async_trait; + +use crate::errors::EngineResult; + +#[derive(Debug)] +pub struct Request { + pub origin: String, + pub branch: Option, + pub prune: bool, + pub verbose: bool, +} + +#[derive(Debug)] +pub struct Response { + // TODO +} + +#[async_trait] +pub trait FetchOperations { + async fn fetch(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/handlers/remote.rs b/engine/src/handlers/remote.rs new file mode 100644 index 0000000..56e0def --- /dev/null +++ b/engine/src/handlers/remote.rs @@ -0,0 +1,7 @@ +mod handlers; +mod models; +mod operations; + +pub use handlers::RemoteHandler; +pub use models::*; +pub use operations::*; diff --git a/engine/src/handlers/remote/handlers.rs b/engine/src/handlers/remote/handlers.rs new file mode 100644 index 0000000..6647aae --- /dev/null +++ b/engine/src/handlers/remote/handlers.rs @@ -0,0 +1,334 @@ +use std::{collections::HashMap, path::PathBuf, sync::Arc}; + +use async_trait::async_trait; + +use crate::{ + ConfigLoader, RepositoryLayout, + errors::EngineResult, + network::{RemotesManager, SshConnectionParams, SshService}, + ref_manager::{RefEntry, RefManager}, +}; + +use super::*; + +/// Handles high-level operations for managing remote repositories. +/// +/// This struct acts as a coordinator between the configuration system, +/// the reference manager, and the SSH network service. It executes +/// requests to add, remove, rename, and inspect remote repositories. +pub struct RemoteHandler { + remotes_manager: Arc, + config_loader: Arc, + ref_manager: Arc, + layout: Arc, + ssh_service: SshService, +} + +impl RemoteHandler { + /// Creates a new instance of the [`RemoteHandler`]. + pub fn new( + remotes_manager: Arc, + config_loader: Arc, + layout: Arc, + ref_manager: Arc, + ) -> Self { + Self { + remotes_manager, + config_loader, + layout, + ref_manager, + ssh_service: SshService, + } + } + + /// Retrieves the path to the user's SSH private key from the configuration. + fn get_user_signing_key(&self) -> EngineResult { + Ok(PathBuf::from( + self.config_loader.get("user.signing_key", None)?, + )) + } + + /// Processes raw server references into a usable map and extracts the HEAD hash. + /// + /// # Returns + /// A tuple containing: + /// 1. A `HashMap` mapping short branch names (e.g., "main") to commit hashes. + /// 2. An `Option` containing the commit hash pointed to by the remote HEAD. + fn build_server_map( + &self, + server_refs: &[RefEntry], + refs_heads_prefix: &str, + ) -> (HashMap, Option) { + let mut server_heads_map: HashMap = HashMap::new(); + let mut server_head_hash: Option = None; + + for entry in server_refs { + if entry.name == "HEAD" { + server_head_hash = Some(entry.commit_hash.clone()); + } else if let Some(short_name) = entry.name.strip_prefix(refs_heads_prefix) { + server_heads_map.insert(short_name.to_string(), entry.commit_hash.clone()); + } + } + + (server_heads_map, server_head_hash) + } + + /// Maps local remote-tracking branches to their commit hashes. + /// + /// This filters the local references to find only those belonging to the specified `remote_name` + /// (e.g., `refs/remotes/origin/*`) and strips the prefix to allow direct comparison with server refs. + fn build_local_tracking_map( + &self, + local_remotes: &[RefEntry], + remote_name: &str, + refs_remotes_prefix: &str, + ) -> HashMap { + local_remotes + .iter() + .filter_map(|entry| { + let name = if entry + .name + .starts_with(&format!("{refs_remotes_prefix}{remote_name}/")) + { + entry + .name + .strip_prefix(&format!("{refs_remotes_prefix}{remote_name}/")) + } else { + None + }; + + name.map(|short| (short.to_string(), entry.commit_hash.clone())) + }) + .collect::>() + } + + /// Compares server state with local tracking branches to determine synchronization status. + /// + /// It identifies: + /// - **Tracked**: Exists on both server and locally. + /// - **New**: Exists on server but not locally. + /// - **Stale**: Exists locally but has been deleted from the server. + fn analyze_remote_branches( + &self, + server_heads_map: &HashMap, + local_tracking_map: &HashMap, + ) -> Vec { + let mut remote_branches = Vec::new(); + + for name in server_heads_map.keys() { + let status = if local_tracking_map.contains_key(name) { + RemoteBranchStatus::Tracked + } else { + RemoteBranchStatus::New + }; + remote_branches.push(RemoteBranchInfo { + name: name.clone(), + status, + }); + } + + for name in local_tracking_map.keys() { + if !server_heads_map.contains_key(name) { + remote_branches.push(RemoteBranchInfo { + name: name.clone(), + status: RemoteBranchStatus::Stale, + }); + } + } + + remote_branches.sort_by(|a, b| a.name.cmp(&b.name)); + remote_branches + } + + /// Compares local branch heads against the remote to determine push status. + /// + /// It identifies: + /// - **UpToDate**: Local hash matches remote hash. + /// - **LocalOutOfDate**: Hashes differ (local might be ahead or behind). + /// - **MissingOnRemote**: Local branch does not exist on the remote. + fn analyze_local_branches( + &self, + local_heads: &[RefEntry], + server_heads_map: &HashMap, + refs_heads_prefix: &str, + ) -> Vec { + let mut local_branches = Vec::new(); + + for entry in local_heads { + let short_name = entry + .name + .strip_prefix(refs_heads_prefix) + .unwrap_or(&entry.name); + + let status = match server_heads_map.get(short_name) { + Some(server_hash) => { + if server_hash == &entry.commit_hash { + LocalPushStatus::UpToDate + } else { + LocalPushStatus::LocalOutOfDate + } + } + None => LocalPushStatus::MissingOnRemote, + }; + + local_branches.push(LocalBranchPushInfo { + name: short_name.to_string(), + status, + }); + } + local_branches.sort_by(|a, b| a.name.cmp(&b.name)); + local_branches + } + + /// Orchestrates the analysis of all reference data to produce a complete status report. + fn analyze_remote_status( + &self, + remote_name: &str, + server_refs: &[RefEntry], + local_remotes: &[RefEntry], + local_heads: &[RefEntry], + ) -> ( + Option, + Vec, + Vec, + ) { + let refs_heads_prefix = format!( + "{}/{}/", + self.layout.refs_dir_name(), + self.layout.heads_dir_name() + ) + .to_string(); + + let refs_remotes_prefix = format!( + "{}/{}/", + self.layout.refs_dir_name(), + self.layout.remotes_dir_name() + ) + .to_string(); + + let (server_heads_map, server_head_hash) = + self.build_server_map(server_refs, &refs_heads_prefix); + + let local_tracking_map = + self.build_local_tracking_map(local_remotes, remote_name, &refs_remotes_prefix); + + // Determine the name of the branch HEAD points to (e.g., "main") + let head_branch = server_head_hash.and_then(|hash| { + server_heads_map + .iter() + .find(|(_, h)| **h == hash) + .map(|(name, _)| name.clone()) + }); + + let remote_branches = self.analyze_remote_branches(&server_heads_map, &local_tracking_map); + let local_branches = + self.analyze_local_branches(local_heads, &server_heads_map, &refs_heads_prefix); + + (head_branch, remote_branches, local_branches) + } +} + +#[async_trait] +impl RemoteOperations for RemoteHandler { + /// Adds a new remote repository configuration. + fn add(&self, request: AddRequest) -> EngineResult { + Ok(AddResponse { + remote: self.remotes_manager.add_remote( + &request.name, + request.url, + &request.pub_key, + )?, + }) + } + + /// Removes an existing remote repository configuration. + fn remove(&self, request: RemoveRequest) -> EngineResult { + Ok(RemoveResponse { + removed_value: self.remotes_manager.remove_remote(&request.name)?, + }) + } + + /// Renames a remote repository. + fn rename(&self, request: RenameRequest) -> EngineResult { + if request.old_name == request.new_name { + return Ok(RenameResponse { + old_name: request.old_name, + new_name: request.new_name, + }); + } + + self.remotes_manager + .rename_remote(&request.old_name, &request.new_name)?; + + Ok(RenameResponse { + old_name: request.old_name, + new_name: request.new_name, + }) + } + + /// Retrieves the URL for a specific remote. + fn get_url(&self, request: GetUrlRequest) -> EngineResult { + Ok(GetUrlResponse { + url: self + .remotes_manager + .get_remote_url(&request.name, &request.direction)?, + }) + } + + /// Updates the URL for an existing remote. + fn set_url(&self, request: SetUrlRequest) -> EngineResult<()> { + self.remotes_manager.set_remote_url( + &request.name, + request.new_url.as_ref(), + &request.direction, + ) + } + + /// Lists all configured remote repositories. + fn list(&self) -> EngineResult { + Ok(ListResponse { + remotes: self.remotes_manager.get_remotes()?, + }) + } + + /// Connects to the remote and gathers detailed status information. + /// + /// This method performs a network operation (`ls-remote`) to fetch the current + /// state of references on the server. It then compares these with the local + /// references to determine the status of branches (Tracked, New, Stale, etc.). + async fn show(&self, request: ShowRequest) -> EngineResult { + let remote = self.remotes_manager.get_remote(&request.name)?; + + let mut connection_params = SshConnectionParams::try_from(&remote.url)?; + + let client_key = self.get_user_signing_key()?; + connection_params.client_key_path = client_key; + connection_params.server_key_path = remote.pub_signing_key.clone(); + + let mut ssh_session = self.ssh_service.connect(&connection_params).await?; + + let packfile_result = ssh_session + .run_ls_remotes(&connection_params.repository_name) + .await?; + + // dbg!(server_refs, local_remotes, local_heads); + // TODO: coś takiego musi być w lokalnym configu (ale to chyba merge będzie dodawał??) + // [branch.develop] + // remote = origin + // merge = refs/heads/develop + + let (head_branch, remote_branches, local_branches) = self.analyze_remote_status( + &request.name, + &packfile_result.refs, + &self.ref_manager.collect_refs_remotes(&request.name)?, + &self.ref_manager.collect_refs_heads()?, + ); + + Ok(ShowResponse { + remote, + head_branch, + remote_branches, + local_branches, + }) + } +} diff --git a/engine/src/handlers/remote/models.rs b/engine/src/handlers/remote/models.rs new file mode 100644 index 0000000..fa41659 --- /dev/null +++ b/engine/src/handlers/remote/models.rs @@ -0,0 +1,104 @@ +use std::fmt; + +use owo_colors::OwoColorize; + +/// Represents the state of a branch on the remote server compared to the +/// local remote-tracking references (e.g., `refs/remotes/origin/*`). +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum RemoteBranchStatus { + /// The branch exists on both the remote and locally as a remote-tracking branch. + Tracked, + /// The branch exists on the remote but has not yet been fetched locally. + New, + /// The branch exists locally as a remote-tracking reference but has been deleted from the remote server. + Stale, +} + +/// A view model used to format and display information about a remote branch. +/// +/// This struct encapsulates the branch name and its synchronization status, +/// providing a specific `Display` implementation for CLI output. +#[derive(Debug)] +pub struct RemoteBranchInfo { + /// The name of the branch on the remote (e.g., "main", "feature/login"). + pub name: String, + /// The current synchronization status of the branch. + pub status: RemoteBranchStatus, +} + +impl RemoteBranchInfo { + /// Helper method that returns the display label and a colorizing function + /// based on the branch status. + /// + /// # Returns + /// A tuple containing: + /// 1. A static string description (e.g., "tracked"). + /// 2. A function pointer that applies the appropriate terminal color (Green, Yellow, or Red). + fn get_status_formatting(&self) -> (&'static str, fn(&str) -> String) { + match self.status { + RemoteBranchStatus::Tracked => ("tracked", |s| s.green().to_string()), + RemoteBranchStatus::New => ("new (next fetch will store in remotes/)", |s| { + s.yellow().to_string() + }), + RemoteBranchStatus::Stale => ("stale (use 'remote prune' to remove)", |s| { + s.red().to_string() + }), + } + } +} + +impl fmt::Display for RemoteBranchInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let (status_str, color_func) = self.get_status_formatting(); + write!(f, " {:<30} {}", self.name, color_func(status_str)) + } +} + +/// Represents the synchronization status of a local branch relative to its +/// remote counterpart (upstream). +/// +/// This is used to determine if the local branch is ready to push or needs updates. +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum LocalPushStatus { + /// The local branch commit hash matches the remote branch commit hash. + UpToDate, + /// The local branch commit hash differs from the remote (could be ahead or behind). + LocalOutOfDate, + /// The local branch exists, but there is no corresponding branch on the remote. + MissingOnRemote, +} + +/// A view model used to format and display information about local branch push configuration. +#[derive(Debug)] +pub struct LocalBranchPushInfo { + /// The name of the local branch. + pub name: String, + /// The status of the branch relative to the remote. + pub status: LocalPushStatus, +} + +impl LocalBranchPushInfo { + /// Helper method that returns the display label and color for the push status. + fn get_status_formatting(&self) -> (&'static str, fn(&str) -> String) { + match self.status { + LocalPushStatus::UpToDate => ("(up to date)", |s| s.dimmed().to_string()), + LocalPushStatus::LocalOutOfDate => ("(local out of date)", |s| s.yellow().to_string()), + LocalPushStatus::MissingOnRemote => { + ("(new - missing on remote)", |s| s.blue().to_string()) + } + } + } +} + +impl fmt::Display for LocalBranchPushInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let (status_str, color_func) = self.get_status_formatting(); + write!( + f, + " {:<30} pushes to {:<30} {}", + self.name, + self.name, + color_func(status_str) + ) + } +} diff --git a/engine/src/handlers/remote/operations.rs b/engine/src/handlers/remote/operations.rs new file mode 100644 index 0000000..7603fab --- /dev/null +++ b/engine/src/handlers/remote/operations.rs @@ -0,0 +1,135 @@ +use std::{collections::HashMap, fmt, path::PathBuf}; + +use async_trait::async_trait; +use owo_colors::OwoColorize; +use url::Url; + +use crate::{ + errors::EngineResult, + network::{RemoteDirection, RemoteEntry}, +}; + +use super::*; + +#[derive(Debug)] +pub struct AddRequest { + pub name: String, + pub url: Url, + pub pub_key: PathBuf, + pub fetch: bool, // so far unused +} + +#[derive(Debug)] +pub struct AddResponse { + pub remote: RemoteEntry, +} + +#[derive(Debug)] +pub struct GetUrlRequest { + pub name: String, + pub direction: RemoteDirection, +} + +#[derive(Debug)] +pub struct GetUrlResponse { + pub url: Url, +} + +#[derive(Debug)] +pub struct RemoveRequest { + pub name: String, +} + +#[derive(Debug)] +pub struct RemoveResponse { + pub removed_value: String, +} + +#[derive(Debug)] +pub struct RenameRequest { + pub old_name: String, + pub new_name: String, +} + +#[derive(Debug)] +pub struct RenameResponse { + pub old_name: String, + pub new_name: String, +} + +#[derive(Debug)] +pub struct SetUrlRequest { + pub name: String, + pub new_url: Url, + pub direction: RemoteDirection, +} + +#[derive(Debug)] +pub struct ListResponse { + pub remotes: HashMap, +} + +#[derive(Debug)] +pub struct ShowRequest { + pub name: String, + pub no_fetch: bool, +} + +#[derive(Debug)] +pub struct ShowResponse { + pub remote: RemoteEntry, + pub head_branch: Option, + pub remote_branches: Vec, + pub local_branches: Vec, +} + +impl ShowResponse { + fn fmt_head(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(head) = &self.head_branch { + writeln!(f, " HEAD branch: {}", head.cyan()) + } else { + writeln!(f, " HEAD branch: {}", "(unknown)".dimmed()) + } + } +} + +impl fmt::Display for ShowResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{}", self.remote)?; + + self.fmt_head(f)?; + + if !self.remote_branches.is_empty() { + writeln!(f, " Remote branches:")?; + for branch in &self.remote_branches { + writeln!(f, "{branch}")?; + } + } + + if !self.local_branches.is_empty() { + writeln!(f, " Local refs configured for 'meva push':")?; + for branch in &self.local_branches { + writeln!(f, "{branch}")?; + } + } + + Ok(()) + } +} + +#[async_trait] +pub trait RemoteOperations { + fn add(&self, request: AddRequest) -> EngineResult; + + fn remove(&self, request: RemoveRequest) -> EngineResult; + + fn rename(&self, request: RenameRequest) -> EngineResult; + + fn get_url(&self, request: GetUrlRequest) -> EngineResult; + + fn set_url(&self, request: SetUrlRequest) -> EngineResult<()>; + + fn list(&self) -> EngineResult; + + async fn show(&self, request: ShowRequest) -> EngineResult; +} diff --git a/engine/src/network.rs b/engine/src/network.rs index aca7c46..e29be3d 100644 --- a/engine/src/network.rs +++ b/engine/src/network.rs @@ -2,6 +2,7 @@ mod client_handler; mod common; mod packfiles; mod protocols; +mod remotes; mod ssh_connection_params; mod ssh_service; mod ssh_session; @@ -9,7 +10,11 @@ mod ssh_session; use client_handler::ClientHandler; use ssh_session::SshSession; -pub use common::{ChannelBand, PktLine, SessionExtension, create_channel_band, create_pkt_line}; +pub use common::{ + ChannelBand, PktLine, RemoteDirection, RemoteEntry, SessionExtension, create_channel_band, + create_pkt_line, +}; pub use packfiles::{PackfileCodec, PackfileError}; +pub use remotes::{MevaRemotesManager, RemotesManager}; pub use ssh_connection_params::SshConnectionParams; pub use ssh_service::SshService; diff --git a/engine/src/network/common.rs b/engine/src/network/common.rs index 8021146..0d03341 100644 --- a/engine/src/network/common.rs +++ b/engine/src/network/common.rs @@ -1,7 +1,9 @@ mod channel_band; mod pkt_line; +mod remote_entry; mod session_extension; pub use channel_band::{ChannelBand, create_channel_band}; pub use pkt_line::{PktLine, create_pkt_line}; +pub use remote_entry::{RemoteDirection, RemoteEntry}; pub use session_extension::SessionExtension; diff --git a/engine/src/network/common/remote_entry.rs b/engine/src/network/common/remote_entry.rs new file mode 100644 index 0000000..2a42364 --- /dev/null +++ b/engine/src/network/common/remote_entry.rs @@ -0,0 +1,190 @@ +use std::{ + collections::HashMap, + fmt, + path::{Path, PathBuf}, +}; + +use owo_colors::OwoColorize; +use strum_macros::{Display, EnumString, VariantNames}; +use url::Url; + +use crate::errors::{ConfigError, EngineResult, NetworkError}; + +/// Specifies the direction of the network operation with the remote repository. +#[derive(Debug, Default, Clone, PartialEq, Eq, EnumString, VariantNames, Display)] +#[strum(serialize_all = "kebab-case")] +pub enum RemoteDirection { + /// Reading data from the remote (e.g., fetch, pull). + #[default] + Fetch, + /// Writing data to the remote (e.g., push). + Push, +} + +/// Represents the configuration of a single remote repository. +/// +/// A remote entry always has a primary fetch URL and key. It may optionally +/// have distinct URLs and keys for push operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteEntry { + /// The primary URL used for fetching data. + pub url: Url, + + /// The path to the public signing key used to verify the server during fetch operations. + pub pub_signing_key: PathBuf, + + /// An optional URL used specifically for pushing data. + /// If `None`, the `url` field is used for pushing as well. + pub push_url: Option, + + /// An optional path to the public signing key used for push operations. + /// If `None`, `pub_signing_key` is used. + pub push_pub_signing_key: Option, +} + +impl RemoteEntry { + /// Creates a new `RemoteEntry` with the mandatory fetch configuration. + #[allow(dead_code)] + pub fn new(url: Url, pub_signing_key: impl Into) -> Self { + Self { + url, + pub_signing_key: pub_signing_key.into(), + push_url: None, + push_pub_signing_key: None, + } + } + + /// Fluent builder method to set a specific push URL. + pub fn with_push_url(mut self, url: Url) -> Self { + self.push_url = Some(url); + self + } + + /// Fluent builder method to set a specific push signing key. + pub fn with_push_key(mut self, key_path: impl Into) -> Self { + self.push_pub_signing_key = Some(key_path.into()); + self + } + + /// Constructs a `RemoteEntry` from a key-value map (typically from a config file). + /// + /// # Required Keys + /// * `url`: The fetch URL. + /// * `server_key`: The path to the server's public key. + /// + /// # Optional Keys + /// * `push_url`: The push URL. + /// * `push_server_key`: The path to the server's public key for pushing. + pub fn from_map(map: &HashMap) -> EngineResult { + let url_str = map.get("url").ok_or(ConfigError::KeyNotFound { + key: "url".to_string(), + })?; + let url = Url::parse(url_str).map_err(NetworkError::from)?; + + let key_str = map.get("server_key").ok_or(ConfigError::KeyNotFound { + key: "server_key".to_string(), + })?; + + let pub_signing_key = PathBuf::from(key_str); + + let push_url = if let Some(s) = map.get("push_url") { + Some(Url::parse(s).map_err(NetworkError::from)?) + } else { + None + }; + + let push_pub_signing_key = map.get("push_server_key").map(PathBuf::from); + + Ok(Self { + url, + pub_signing_key, + push_url, + push_pub_signing_key, + }) + } + + /// Retrieves the appropriate URL for the given operation direction. + /// + /// If `direction` is `Push` and a `push_url` is configured, it is returned. + /// Otherwise, the method falls back to the standard `url`. + pub fn url_for(&self, direction: RemoteDirection) -> &Url { + match direction { + RemoteDirection::Fetch => &self.url, + RemoteDirection::Push => self.push_url.as_ref().unwrap_or(&self.url), + } + } + + /// Retrieves the appropriate signing key path for the given operation direction. + /// + /// If `direction` is `Push` and a `push_pub_signing_key` is configured, it is returned. + /// Otherwise, the method falls back to the standard `pub_signing_key`. + pub fn key_for(&self, direction: RemoteDirection) -> &Path { + match direction { + RemoteDirection::Fetch => &self.pub_signing_key, + RemoteDirection::Push => self + .push_pub_signing_key + .as_deref() + .unwrap_or(&self.pub_signing_key), + } + } + + /// Serializes the remote configuration into a key-value map. + /// + /// This is useful for saving the configuration back to a file. + pub fn to_map(&self) -> HashMap { + let mut map = HashMap::new(); + map.insert("url".to_string(), self.url.to_string()); + map.insert( + "server_key".to_string(), + self.pub_signing_key.to_string_lossy().to_string(), + ); + + if let Some(push_url) = &self.push_url { + map.insert("push_url".to_string(), push_url.to_string()); + } + if let Some(push_key) = &self.push_pub_signing_key { + map.insert( + "push_server_key".to_string(), + push_key.to_string_lossy().to_string(), + ); + } + map + } + + /// Formats the remote entry for verbose display. + /// + /// Returns a multi-line string with colored output indicating the fetch and push URLs. + pub fn display_verbose(&self, name: &str) -> String { + let fetch_url = self.url_for(RemoteDirection::Fetch); + let push_url = self.url_for(RemoteDirection::Push); + + format!( + "{} {} {}\n{} {} {}", + name.bold(), + fetch_url.cyan(), + "(fetch)".dimmed(), + name.bold(), + push_url.cyan(), + "(push)".dimmed() + ) + } +} + +impl fmt::Display for RemoteEntry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, " fetch: {}", self.url.cyan())?; + + let push_url = self.url_for(RemoteDirection::Push); + + if let Some(push) = &self.push_url { + write!(f, " push: {} {}", push.cyan(), "(explicit)".dimmed()) + } else { + write!( + f, + " push: {} {}", + push_url.cyan(), + "(same as fetch)".dimmed() + ) + } + } +} diff --git a/engine/src/network/protocols/upload_pack.rs b/engine/src/network/protocols/upload_pack.rs index 5a983c9..31ad5fa 100644 --- a/engine/src/network/protocols/upload_pack.rs +++ b/engine/src/network/protocols/upload_pack.rs @@ -38,6 +38,9 @@ pub struct UploadPackProtocol { /// The accumulated binary data of the packfile received from the server. pub packfile_data: Vec, + + /// Flag indicating wheather the protocol is going to end after the Discover phase. + discovery_only: bool, } impl UploadPackProtocol { @@ -48,6 +51,16 @@ impl UploadPackProtocol { pub fn with_haves(haves: Vec) -> Self { Self { haves, + discovery_only: false, + ..Default::default() + } + } + + /// Creates an instance of the upload-pack protocol + /// used for the 'ls-remotes' variant od the upload-pack. + pub fn discovery_only() -> Self { + Self { + discovery_only: true, ..Default::default() } } @@ -200,8 +213,20 @@ impl UploadPackProtocol { self.state = UploadPackState::ReceivingRefs; } UploadPackState::ReceivingRefs => { - println!("Finished references. Sending 'want'..."); + println!("Finished references."); + + if self.discovery_only { + println!("Discovery only mode. Sending flush and closing."); + channel + .data(b"0000".as_ref()) + .await + .map_err(NetworkError::from)?; + + self.state = UploadPackState::Complete; + return Ok(()); + } + println!("Sending 'want'..."); self.wants = self .refs .iter() diff --git a/engine/src/network/protocols/upload_pack/upload_pack_result.rs b/engine/src/network/protocols/upload_pack/upload_pack_result.rs index 862911d..75c40e2 100644 --- a/engine/src/network/protocols/upload_pack/upload_pack_result.rs +++ b/engine/src/network/protocols/upload_pack/upload_pack_result.rs @@ -19,3 +19,27 @@ pub struct UploadPackResult { /// (e.g., `refs/remotes/origin/*`) to match the server's state. pub refs: Vec, } + +impl UploadPackResult { + /// Creates a new [`UploadPackResult`] with both packfile data and references. + /// + /// This is the standard constructor used for `clone` and `fetch` operations. + pub fn new(packfile_data: Vec, refs: Vec) -> Self { + Self { + packfile_data, + refs, + } + } + + /// Creates a new [`UploadPackResult`] containing only references. + /// + /// Used for operations like `remote show`, where + /// the client only wants to inspect the state of the remote repository + /// without downloading any objects (`packfile_data` is empty). + pub fn refs_only(refs: Vec) -> Self { + Self { + packfile_data: Vec::new(), + refs, + } + } +} diff --git a/engine/src/network/remotes.rs b/engine/src/network/remotes.rs new file mode 100644 index 0000000..2cb1a42 --- /dev/null +++ b/engine/src/network/remotes.rs @@ -0,0 +1,73 @@ +mod meva_remotes_manager; + +pub use meva_remotes_manager::MevaRemotesManager; + +use std::{collections::HashMap, path::Path}; + +use url::Url; + +use crate::{ + errors::EngineResult, + network::{RemoteDirection, RemoteEntry}, +}; + +/// Defines the interface for managing remote repository configurations. +pub trait RemotesManager: Send + Sync { + /// Registers a new remote with the specified configuration. + /// + /// # Arguments + /// + /// * `name` - The alias for the remote (e.g., "origin"). + /// * `url` - The URL of the remote repository. + /// * `pub_signing_key` - Path to the public key used to verify the remote's identity. + /// + /// # Returns + /// The created `RemoteEntry` on success. + fn add_remote(&self, name: &str, url: Url, pub_signing_key: &Path) + -> EngineResult; + + /// Retrieves the configuration for a specific remote by name. + /// + /// # Errors + /// Returns an error if a remote with the given `name` does not exist. + fn get_remote(&self, name: &str) -> EngineResult; + + /// Retrieves the specific URL for a remote based on the operation direction. + /// + /// A remote may have different URLs for fetching (reading) and pushing (writing). + /// + /// # Arguments + /// + /// * `name` - The name of the remote. + /// * `direction` - Whether the URL is needed for a `Fetch` or `Push` operation. + fn get_remote_url(&self, name: &str, direction: &RemoteDirection) -> EngineResult; + + /// Retrieves all configured remotes as a map. + /// + /// # Returns + /// A `HashMap` where the keys are remote names and values are their configurations. + fn get_remotes(&self) -> EngineResult>; + + /// Updates the URL for an existing remote. + /// + /// # Arguments + /// + /// * `name` - The name of the remote to update. + /// * `new_url` - The new URL string (will be parsed into a `Url`). + /// * `direction` - Specifies whether to update the fetch URL, the push URL, or both. + fn set_remote_url( + &self, + name: &str, + new_url: &str, + direction: &RemoteDirection, + ) -> EngineResult<()>; + + /// Unregisters (deletes) a remote configuration. + /// + /// # Returns + /// The name of the removed remote. + fn remove_remote(&self, name: &str) -> EngineResult; + + /// Renames an existing remote. + fn rename_remote(&self, old_name: &str, new_name: &str) -> EngineResult<()>; +} diff --git a/engine/src/network/remotes/meva_remotes_manager.rs b/engine/src/network/remotes/meva_remotes_manager.rs new file mode 100644 index 0000000..4ecb01c --- /dev/null +++ b/engine/src/network/remotes/meva_remotes_manager.rs @@ -0,0 +1,125 @@ +use std::{collections::HashMap, path::Path}; + +use url::Url; + +use crate::{ + ConfigDocument, ConfigLocation, + config::ConfigDocumentOperations, + errors::{EngineResult, NetworkError}, +}; + +use super::{RemoteDirection, RemoteEntry, RemotesManager}; + +/// Concrete implementation of the `RemotesManager` trait. +/// +/// This manager handles the storage and retrieval of remote repository configurations +/// by interacting directly with the local configuration file (typically `.meva/config`). +/// It maps high-level remote operations to low-level TOML document edits. +pub struct MevaRemotesManager; + +impl MevaRemotesManager { + /// Loads the editable configuration document for the local repository. + /// + /// This is the file where remote definitions are persisted. + /// + /// # Returns + /// An [`EngineResult`] containing the loaded [`ConfigDocument`]. + fn get_local_document(&self) -> EngineResult { + ConfigDocument::load(&ConfigLocation::Local.get_default_path()?) + } + + /// Constructs the base TOML table key for a specific remote. + fn get_remote_key(&self, name: &str) -> String { + format!("remotes.{name}") + } + + /// Resolves the specific configuration key for a URL based on the operation direction. + /// + /// This distinguishes between the standard fetch URL and the optional push URL. + /// + /// # Returns + /// * If `direction` is `Fetch`: returns "remotes.{name}.url" + /// * If `direction` is `Push`: returns "remotes.{name}.push_url" + fn get_remote_url_keys(&self, name: &str, direction: &RemoteDirection) -> String { + let table_name = self.get_remote_key(name); + match direction { + RemoteDirection::Fetch => format!("{table_name}.url"), + RemoteDirection::Push => format!("{table_name}.push_url"), + } + } +} + +impl RemotesManager for MevaRemotesManager { + fn add_remote( + &self, + name: &str, + url: Url, + pub_signing_key: &Path, + ) -> EngineResult { + let mut doc = self.get_local_document()?; + let remote_key = self.get_remote_key(name); + + let remote_entry = RemoteEntry::new(url, pub_signing_key); + + doc.set_table(&remote_key, &remote_entry.to_map())?; + doc.save()?; + + Ok(remote_entry) + } + + fn get_remote(&self, name: &str) -> EngineResult { + let remote_key = self.get_remote_key(name); + let doc = self.get_local_document()?; + let table = doc.get_table(&remote_key)?; + RemoteEntry::from_map(&table) + } + + fn get_remote_url(&self, name: &str, direction: &RemoteDirection) -> EngineResult { + let remote_url_key = self.get_remote_url_keys(name, direction); + let doc = self.get_local_document()?; + let url = doc.get(&remote_url_key, None)?; + Ok(Url::parse(&url).map_err(NetworkError::from)?) + } + + fn get_remotes(&self) -> EngineResult> { + let doc = self.get_local_document()?; + let raw_remotes = doc.get_subtables("remotes")?; + + let mut remotes = HashMap::new(); + + for (name, properties) in raw_remotes { + let entry = RemoteEntry::from_map(&properties)?; + remotes.insert(name, entry); + } + + Ok(remotes) + } + + fn set_remote_url( + &self, + name: &str, + new_url: &str, + direction: &RemoteDirection, + ) -> EngineResult<()> { + let remote_url_key = self.get_remote_url_keys(name, direction); + let mut doc = self.get_local_document()?; + doc.set(&remote_url_key, new_url)?; + doc.save() + } + + fn remove_remote(&self, name: &str) -> EngineResult { + let mut doc = self.get_local_document()?; + + let value = doc.unset(&self.get_remote_key(name))?; + doc.save()?; + + Ok(value) + } + + fn rename_remote(&self, old_name: &str, new_name: &str) -> EngineResult<()> { + let mut doc = self.get_local_document()?; + + doc.rename_entry("remotes", old_name, new_name)?; + doc.save() + } +} diff --git a/engine/src/network/ssh_session.rs b/engine/src/network/ssh_session.rs index 45e44f3..b2522d1 100644 --- a/engine/src/network/ssh_session.rs +++ b/engine/src/network/ssh_session.rs @@ -66,7 +66,6 @@ impl SshSession { .map_err(NetworkError::from)?; let mut protocol = UploadPackProtocol::with_haves(haves); - let mut protocol_completed = false; while let Some(msg) = channel.wait().await { @@ -99,14 +98,66 @@ impl SshSession { return Err(NetworkError::ConnectionClosedPrematurely.into()); } - Ok(UploadPackResult { - packfile_data: mem::take(&mut protocol.packfile_data), - refs: protocol.refs, - }) + Ok(UploadPackResult::new( + mem::take(&mut protocol.packfile_data), + protocol.refs, + )) } pub async fn run_receive_pack(&mut self, _repository_name: &str) -> EngineResult<()> { // TODO: push todo!() } + + /// Connects to the remote to list references without downloading objects. + /// + /// It uses the `upload-pack` command but terminates the protocol immediately + /// after the "Discovery" phase, skipping the negotiation and packfile download steps. + /// + /// # Arguments + /// * `repository_name`: The path/name of the repository on the remote server. + /// + /// # Returns + /// * `EngineResult`: Contains only the list of references + /// (`refs`) found on the server. The `packfile_data` field will be empty. + pub async fn run_ls_remotes( + &mut self, + repository_name: &str, + ) -> EngineResult { + let command = format!("upload-pack {repository_name}"); + let mut channel: Channel = self + .session + .channel_open_session() + .await + .map_err(NetworkError::from)?; + + channel + .exec(true, command.as_str()) + .await + .map_err(NetworkError::from)?; + + let mut protocol = UploadPackProtocol::discovery_only(); + let mut protocol_completed = false; + + while let Some(msg) = channel.wait().await { + match msg { + ChannelMsg::Data { data } => { + let is_complete = protocol.process_data(&data, &mut channel).await?; + if is_complete { + protocol_completed = true; + channel.close().await.map_err(NetworkError::from)?; + } + } + ChannelMsg::ExitStatus { .. } => {} + ChannelMsg::Eof => {} + _ => {} + } + } + + if !protocol_completed { + return Err(NetworkError::ConnectionClosedPrematurely.into()); + } + + Ok(UploadPackResult::refs_only(protocol.refs)) + } } diff --git a/engine/src/ref_manager.rs b/engine/src/ref_manager.rs index a185343..80f8c25 100644 --- a/engine/src/ref_manager.rs +++ b/engine/src/ref_manager.rs @@ -63,7 +63,16 @@ pub trait RefManager: Send + Sync { /// /// # Returns /// * `Ok(Vec)`: A list of all found local branches. - fn iter_refs_heads(&self) -> EngineResult>; + fn collect_refs_heads(&self) -> EngineResult>; + + /// Retrieves a list of all local remote branch references (typically stored in `refs/remotes/`). + /// + /// This method iterates over the available local remote references and returns them + /// as a collection of [`RefEntry`] objects. + /// + /// # Returns + /// * `Ok(Vec)`: A list of all found local remote branches. + fn collect_refs_remotes(&self, origin: &str) -> EngineResult>; /// Updates or creates a named reference with the provided [`RefEntry`] value. fn update_ref(&self, entry: RefEntry) -> EngineResult<()>; diff --git a/engine/src/ref_manager/meva_ref_manager.rs b/engine/src/ref_manager/meva_ref_manager.rs index a5b7634..bedb549 100644 --- a/engine/src/ref_manager/meva_ref_manager.rs +++ b/engine/src/ref_manager/meva_ref_manager.rs @@ -5,11 +5,11 @@ use crate::errors::{EngineResult, PathError}; use crate::ref_manager::{Head, HeadMode, RefEntry, RefManager}; use crate::serialize_deserialize::MevaEncode; -use std::sync::Arc; use std::{ fs::{self, File}, io::{Read, Write}, - path::Path, + path::{Path, PathBuf}, + sync::Arc, }; /// Manages repository references (`HEAD`, branches, and tags) for the Meva repository. @@ -47,6 +47,34 @@ impl MevaRefManager { file.write_all(content.as_bytes())?; Ok(()) } + + /// Recursively walks a directory to find and parse valid `RefEntry` objects. + /// + /// Any file that is empty or fails parsing is silently skipped. + fn collect_refs(&self, directory: impl Into) -> EngineResult> { + Ok(WalkDir::new(directory.into()) + .into_iter() + .filter_map(|e| { + let entry = match e { + Ok(entry) if entry.file_type().is_file() => entry, + _ => { + return None; + } + }; + + let path = entry.path(); + + let content = match Self::read_file(path) { + Ok(content) if !content.is_empty() => content, + _ => { + return None; + } + }; + + RefEntry::from_json(&content).ok() + }) + .collect()) + } } impl RefManager for MevaRefManager { @@ -99,29 +127,12 @@ impl RefManager for MevaRefManager { Ok(Some(entry)) } - fn iter_refs_heads(&self) -> EngineResult> { - Ok(WalkDir::new(self.repo_layout.heads_refs_dir()) - .into_iter() - .filter_map(|e| { - let entry = match e { - Ok(entry) if entry.file_type().is_file() => entry, - _ => { - return None; - } - }; - - let path = entry.path(); - - let content = match Self::read_file(path) { - Ok(content) if !content.is_empty() => content, - _ => { - return None; - } - }; + fn collect_refs_heads(&self) -> EngineResult> { + self.collect_refs(self.repo_layout.heads_refs_dir()) + } - RefEntry::from_json(&content).ok() - }) - .collect()) + fn collect_refs_remotes(&self, origin: &str) -> EngineResult> { + self.collect_refs(self.repo_layout.remotes_refs_dir().join(origin)) } fn update_ref(&self, entry: RefEntry) -> EngineResult<()> { diff --git a/engine/src/ref_manager/ref_entry.rs b/engine/src/ref_manager/ref_entry.rs index 4246a44..884d42b 100644 --- a/engine/src/ref_manager/ref_entry.rs +++ b/engine/src/ref_manager/ref_entry.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; /// /// These entries are typically stored and managed by the [`MevaRefManager`], /// which handles reading and updating branch references in the repository. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RefEntry { /// The full name of the reference (e.g., `refs/heads/master`). pub name: String, diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index 8bfec4d..85eee84 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -86,8 +86,10 @@ impl MevaRepository { self.layout.objects_dir_rel(), self.layout.refs_dir_rel(), self.layout.heads_refs_dir_rel(), + self.layout.remotes_refs_dir_rel(), self.layout.logs_dir_rel(), self.layout.heads_logs_dir_rel(), + self.layout.remotes_logs_dir_rel(), ]; for dir in dirs { diff --git a/engine/src/repositories/repository_layout.rs b/engine/src/repositories/repository_layout.rs index a9e7059..536a85d 100644 --- a/engine/src/repositories/repository_layout.rs +++ b/engine/src/repositories/repository_layout.rs @@ -68,6 +68,11 @@ pub trait RepositoryLayout: Send + Sync + Debug { self.refs_dir_rel().join(self.heads_dir_name()) } + /// Path to remotes in refs relative to `working_dir`. + fn remotes_refs_dir_rel(&self) -> PathBuf { + self.refs_dir_rel().join(self.remotes_dir_name()) + } + /// Path to the logs directory relative to `working_dir`. fn logs_dir_rel(&self) -> PathBuf { self.repository_dir_rel().join(self.logs_dir_name()) @@ -78,6 +83,11 @@ pub trait RepositoryLayout: Send + Sync + Debug { self.logs_dir_rel().join(self.heads_dir_name()) } + /// Path to remotes in logs relative to `working_dir`. + fn remotes_logs_dir_rel(&self) -> PathBuf { + self.logs_dir_rel().join(self.remotes_dir_name()) + } + /// Path to plugins directory relative to `working_dir`. fn plugins_dir_rel(&self) -> PathBuf { self.repository_dir_rel().join(self.plugins_dir_name()) @@ -125,6 +135,11 @@ pub trait RepositoryLayout: Send + Sync + Debug { self.working_dir().join(self.heads_refs_dir_rel()) } + /// Absolute path to remotes in refs. + fn remotes_refs_dir(&self) -> PathBuf { + self.working_dir().join(self.remotes_refs_dir_rel()) + } + /// Absolute path to the logs directory. fn logs_dir(&self) -> PathBuf { self.working_dir().join(self.logs_dir_rel()) @@ -135,6 +150,11 @@ pub trait RepositoryLayout: Send + Sync + Debug { self.working_dir().join(self.heads_logs_dir_rel()) } + /// Absolute path to remotes in logs. + fn remotes_logs_dir(&self) -> PathBuf { + self.working_dir().join(self.remotes_logs_dir_rel()) + } + /// Absolute path to plugins directory. fn plugins_dir(&self) -> PathBuf { self.working_dir().join(self.plugins_dir_rel()) @@ -249,11 +269,19 @@ mod tests { layout.heads_refs_dir_rel(), PathBuf::from(".meva/refs/heads") ); + assert_eq!( + layout.remotes_refs_dir_rel(), + PathBuf::from(".meva/refs/remotes") + ); assert_eq!(layout.logs_dir_rel(), PathBuf::from(".meva/logs")); assert_eq!( layout.heads_logs_dir_rel(), PathBuf::from(".meva/logs/heads") ); + assert_eq!( + layout.remotes_logs_dir_rel(), + PathBuf::from(".meva/logs/remotes") + ); assert_eq!(layout.plugins_dir_rel(), PathBuf::from(".meva/plugins")); assert_eq!(layout.head_file_rel(), PathBuf::from(".meva/HEAD")); assert_eq!(layout.config_file_rel(), PathBuf::from(".meva/mevaconfig")); @@ -267,8 +295,10 @@ mod tests { assert_eq!(layout.objects_dir(), base.join(".meva/objects")); assert_eq!(layout.refs_dir(), base.join(".meva/refs")); assert_eq!(layout.heads_refs_dir(), base.join(".meva/refs/heads")); + assert_eq!(layout.remotes_refs_dir(), base.join(".meva/refs/remotes")); assert_eq!(layout.logs_dir(), base.join(".meva/logs")); assert_eq!(layout.heads_logs_dir(), base.join(".meva/logs/heads")); + assert_eq!(layout.remotes_logs_dir(), base.join(".meva/logs/remotes")); assert_eq!(layout.plugins_dir(), base.join(".meva/plugins")); assert_eq!(layout.config_file(), base.join(".meva/mevaconfig")); assert_eq!(layout.head_file(), base.join(".meva/HEAD")); diff --git a/server/src/server_handler.rs b/server/src/server_handler.rs index f8a6790..8dda04a 100644 --- a/server/src/server_handler.rs +++ b/server/src/server_handler.rs @@ -74,7 +74,13 @@ impl ServerHandler { let repository_layout = Arc::new(MevaRepositoryLayout::new(repository_root.to_path_buf())?); let ref_manager = MevaRefManager::new(repository_layout); - for entry in ref_manager.iter_refs_heads()? { + if let Some(head) = ref_manager.resolve_head()? { + info!("(UploadPack) Sending {head} HEAD..."); + let head_pkt_line = create_pkt_line(&format!("{head} HEAD")); + session.send_pkt_line(channel, &head_pkt_line); + } + + for entry in ref_manager.collect_refs_heads()? { let entry_line = create_pkt_line(&format!("{} {}", entry.commit_hash, entry.name)); session.send_pkt_line(channel, &entry_line); } @@ -128,6 +134,16 @@ impl ServerHandler { if len == 0 { info!("(UploadPack) Received flush-packet (0000)"); state.buffer.drain(0..4); + + if upload_state.wants.is_empty() && upload_state.haves.is_empty() { + info!( + "(UploadPack) Client sent flush without wants/haves (ls-remote). Closing channel." + ); + session.exit_status_request(channel, 0); + session.eof(channel); + session.close(channel); + return Ok(()); + } continue; } diff --git a/shared/src/extensions/path_to_string.rs b/shared/src/extensions/path_to_string.rs index 3d31e5f..c94dc11 100644 --- a/shared/src/extensions/path_to_string.rs +++ b/shared/src/extensions/path_to_string.rs @@ -5,7 +5,7 @@ use std::path::Path; /// This provides a convenient way to obtain a `String` representation /// of a path without dealing with `OsStr` or platform-specific encodings. /// -/// Internally, this uses [`PathBuf::to_string_lossy`], which replaces +/// Internally, this uses `PathBuf::to_string_lossy`, which replaces /// any invalid UTF-8 sequences with the Unicode replacement character (`�`). pub trait PathToString { /// Converts this path into an owned UTF-8 string. From a48dac2071ed3be9c74a93ab6765f7ec083635ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Wed, 26 Nov 2025 20:47:52 +0100 Subject: [PATCH 23/42] Feature/branch (#33) --- cli/src/commands.rs | 3 + cli/src/commands/branch.rs | 208 ++++++++++++ cli/src/main.rs | 7 +- engine/src/branch_manager.rs | 127 +++++++- engine/src/branch_manager/branch.rs | 31 ++ engine/src/branch_manager/branch_info.rs | 29 ++ .../src/branch_manager/meva_branch_manager.rs | 296 +++++++++++++++++- .../branch_manager/remove_branches_result.rs | 19 ++ engine/src/engine_container.rs | 33 +- engine/src/errors.rs | 4 + engine/src/errors/branch_error.rs | 34 ++ engine/src/errors/engine_error.rs | 18 +- engine/src/errors/ref_entry_error.rs | 29 ++ engine/src/handlers.rs | 1 + engine/src/handlers/branch.rs | 8 + .../handlers/branch/display_branches_list.rs | 76 +++++ engine/src/handlers/branch/handlers.rs | 124 ++++++++ engine/src/handlers/branch/operations.rs | 81 +++++ engine/src/ref_manager.rs | 45 +++ engine/src/ref_manager/branch_ref_entry.rs | 24 ++ engine/src/ref_manager/meva_ref_manager.rs | 74 ++++- engine/src/ref_manager/ref_entry.rs | 40 ++- engine/src/traversal.rs | 4 +- .../traversal/meva_commit_history_walker.rs | 53 ++++ engine/src/traversal/traits.rs | 37 +++ shared/src/extensions.rs | 2 + shared/src/extensions/remove_empty.rs | 150 +++++++++ shared/src/lib.rs | 1 + 28 files changed, 1513 insertions(+), 45 deletions(-) create mode 100644 cli/src/commands/branch.rs create mode 100644 engine/src/branch_manager/branch.rs create mode 100644 engine/src/branch_manager/branch_info.rs create mode 100644 engine/src/branch_manager/remove_branches_result.rs create mode 100644 engine/src/errors/branch_error.rs create mode 100644 engine/src/errors/ref_entry_error.rs create mode 100644 engine/src/handlers/branch.rs create mode 100644 engine/src/handlers/branch/display_branches_list.rs create mode 100644 engine/src/handlers/branch/handlers.rs create mode 100644 engine/src/handlers/branch/operations.rs create mode 100644 engine/src/ref_manager/branch_ref_entry.rs create mode 100644 engine/src/traversal/meva_commit_history_walker.rs create mode 100644 shared/src/extensions/remove_empty.rs diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 5e15d38..1b857cf 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1,4 +1,5 @@ pub mod add; +pub mod branch; pub mod clone; pub mod commit; pub mod config; @@ -17,10 +18,12 @@ pub mod show; pub mod status; pub use add::AddCommand; +pub use branch::BranchCommand; pub use clone::CloneCommand; pub use commit::CommitCommand; pub use config::ConfigCommand; pub use diff::DiffCommand; +pub use fetch::FetchCommand; pub use ignore::IgnoreCommand; pub use init::InitCommand; pub use log::LogCommand; diff --git a/cli/src/commands/branch.rs b/cli/src/commands/branch.rs new file mode 100644 index 0000000..2d710fa --- /dev/null +++ b/cli/src/commands/branch.rs @@ -0,0 +1,208 @@ +use crate::commands::MevaCommand; +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; +use engine::engine_container::{EngineContainer, MevaContainer}; +use engine::handlers::branch::{ + BranchOperations, CreateRequest, DeleteRequest, ListRequest, RenameRequest, Request, +}; +use engine::revision_parsing::Revision; +use miette::{IntoDiagnostic, Result}; + +/// Implements the `branch` command for Meva DVCS. +/// +/// Allows listing, creating, deleting, and renaming branches within the repository. +/// It handles argument parsing to dispatch the appropriate request (Create, Delete, +/// Rename, or List) to the [`BranchHandler`]. +pub struct BranchCommand; + +impl BranchCommand { + /// Creates a new instance of the [`BranchCommand`]. + pub fn new() -> Self { + Self + } + + /// First positional argument: branch name (creation/deletion) or old branch name (renaming). + const ARG_NAME: &'static str = "branch-name"; + + /// Second positional argument: start point (creation) or new branch name (renaming). + const ARG_TARGET: &'static str = "target"; + + /// Flag to delete a branch. + const ARG_DELETE: &'static str = "delete"; + + /// Flag to force delete a branch (even if unmerged). + const ARG_FORCE_DELETE: &'static str = "force-delete"; + + /// Flag to rename a branch. + const ARG_MOVE: &'static str = "move"; + + /// Flag to show verbose output (sha1 and commit subject). + const ARG_VERBOSE: &'static str = "verbose"; + + /// Flag to explicitly list branches. + const ARG_LIST: &'static str = "list"; + + /// Flag to list both local and remote-tracking branches. + const ARG_ALL: &'static str = "all"; + + /// Flag to list only remote-tracking branches. + const ARG_REMOTES: &'static str = "remotes"; +} + +#[async_trait] +impl MevaCommand for BranchCommand { + type Container = MevaContainer; + + fn name(&self) -> &'static str { + "branch" + } + + fn about(&self) -> &'static str { + "List, create, or delete branches" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `branch` command using `clap`. + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_NAME) + .value_name("NAME") + .help("The name of the branch to create, delete, or rename") + .index(1) + .required(false), + ) + .arg( + Arg::new(Self::ARG_TARGET) + .value_name("START_POINT/NEW_NAME") + .help("The new branch head (creation) or new name (rename)") + .index(2) + .required(false), + ) + .arg( + Arg::new(Self::ARG_DELETE) + .short('d') + .long(Self::ARG_DELETE) + .help("Delete a branch") + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_MOVE) + .conflicts_with(Self::ARG_TARGET) + .requires(Self::ARG_NAME), + ) + .arg( + Arg::new(Self::ARG_FORCE_DELETE) + .short('D') + .help("Force delete a branch") + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_MOVE) + .conflicts_with(Self::ARG_TARGET) + .requires(Self::ARG_NAME), + ) + .arg( + Arg::new(Self::ARG_MOVE) + .short('m') + .long(Self::ARG_MOVE) + .help("Move/rename a branch") + .action(ArgAction::SetTrue) + .requires(Self::ARG_NAME) + .requires(Self::ARG_TARGET), + ) + .arg( + Arg::new(Self::ARG_LIST) + .short('l') + .long(Self::ARG_LIST) + .help("List branches") + .action(ArgAction::SetTrue) + .conflicts_with_all([Self::ARG_DELETE, Self::ARG_FORCE_DELETE, Self::ARG_MOVE]), + ) + .arg( + Arg::new(Self::ARG_ALL) + .short('a') + .long(Self::ARG_ALL) + .help("List both remote-tracking and local branches") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_REMOTES) + .short('r') + .long(Self::ARG_REMOTES) + .help("List the remote-tracking branches") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_VERBOSE) + .short('v') + .long(Self::ARG_VERBOSE) + .help("Show sha1 and commit subject line") + .action(ArgAction::SetTrue), + ) + .group( + ArgGroup::new("delete_mode") + .args([Self::ARG_DELETE, Self::ARG_FORCE_DELETE]) + .multiple(false), + ) + } + + /// Executes the `branch` command. + /// + /// Maps the CLI arguments to a specific `Request` variant (`Delete`, `Rename`, `Create`, or `List`) + /// and delegates execution to the `BranchHandler`. + /// + /// # Returns + /// * `Result<()>`: Indicates success or returns a detailed error if the operation fails. + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { + let branch_name = matches.get_one::(Self::ARG_NAME); + let second_arg = matches.get_one::(Self::ARG_TARGET); + let delete = matches.get_flag(Self::ARG_DELETE); + let force_delete = matches.get_flag(Self::ARG_FORCE_DELETE); + let move_arg = matches.get_flag(Self::ARG_MOVE); + let verbose = matches.get_flag(Self::ARG_VERBOSE); + let all = matches.get_flag(Self::ARG_ALL); + let remotes = matches.get_flag(Self::ARG_REMOTES); + + let request = if delete || force_delete { + let request = DeleteRequest { + branch_name: branch_name.cloned().unwrap(), + force: force_delete, + }; + + Request::Delete(request) + } else if move_arg { + let request = RenameRequest { + old_name: branch_name.cloned().unwrap(), + new_name: second_arg.cloned().unwrap(), + }; + + Request::Rename(request) + } else if let Some(branch_name) = branch_name { + let start_point = match second_arg { + None => Revision::head(Vec::new()), + Some(start_point) => start_point.parse::().into_diagnostic()?, + }; + + let request = CreateRequest { + branch_name: branch_name.clone(), + start_point, + }; + + Request::Create(request) + } else { + let request = ListRequest { + verbose, + local: !remotes, + remotes: remotes || all, + }; + + Request::List(request) + }; + + let handler = container.branch_handler().into_diagnostic()?; + + handler.branch(request).into_diagnostic()?; + + Ok(()) + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index df6f6aa..a7bfa08 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -6,9 +6,9 @@ use miette::Result; use crate::{commands::RemoteCommand, meva_cli::MevaCli}; use commands::{ - AddCommand, CloneCommand, CommitCommand, ConfigCommand, DiffCommand, IgnoreCommand, - InitCommand, LogCommand, LsFilesCommand, LsTreeCommand, MevaCommand, PluginsCommand, - RestoreCommand, ShowCommand, StatusCommand, fetch::FetchCommand, + AddCommand, BranchCommand, CloneCommand, CommitCommand, ConfigCommand, DiffCommand, + FetchCommand, IgnoreCommand, InitCommand, LogCommand, LsFilesCommand, LsTreeCommand, + MevaCommand, PluginsCommand, RestoreCommand, ShowCommand, StatusCommand, }; use engine::engine_container::MevaContainer; @@ -33,6 +33,7 @@ async fn main() -> Result<()> { Box::new(CloneCommand::new()), Box::new(RemoteCommand::new()), Box::new(FetchCommand::new()), + Box::new(BranchCommand::new()), ]; let container = MevaContainer; diff --git a/engine/src/branch_manager.rs b/engine/src/branch_manager.rs index 7ab01a6..33dc14a 100644 --- a/engine/src/branch_manager.rs +++ b/engine/src/branch_manager.rs @@ -1,27 +1,37 @@ +mod branch; +mod branch_info; +mod remove_branches_result; + pub mod meva_branch_manager; use crate::errors::EngineResult; use crate::objects::MevaCommit; +pub use branch::{Branch, BranchType}; +pub use branch_info::BranchInfo; pub use meva_branch_manager::MevaBranchManager; +pub use remove_branches_result::RemoveBranchesResult; /// Defines a common interface for managing commits within a branch. /// -/// Currently, this trait focuses on commit-level operations: -/// - adding new commits, -/// - amending the latest commit, -/// - retrieving the latest commit hash or object. -/// -/// In the future, it can be extended to support full branch operations, -/// such as creating new branches, switching (checkout), merging, or -/// enumerating commits. -/// -/// # Methods -/// -/// - `add_commit` – adds a commit to the branch and returns its SHA-1 hash. -/// - `last_commit_hash` – retrieves the SHA-1 hash of the most recent commit, if any. -/// - `last_commit` – retrieves the most recent commit object, if any. -pub trait BranchManager { +/// This trait handles the lifecycle of branches within the repository, including: +/// - **Commit Operations**: adding, amending, and retrieving commits. +/// - **Branch Management**: creating, deleting, renaming, and listing branches. +/// - **State Inspection**: identifying the current active branch. +pub trait BranchManager: Send + Sync { + /// Retrieves the name of the currently active branch (pointed to by HEAD). + /// + /// # Returns + /// + /// * `Ok(Some(name))` - If HEAD points to a valid branch reference (e.g., "main"). + /// * `Ok(None)` - If HEAD is in a "detached" state (points directly to a commit hash) + /// or if the repository is empty. + /// + /// # Errors + /// + /// Returns an [`EngineResult`] if the HEAD file cannot be read or parsed. + fn current_branch_name(&self) -> EngineResult>; + /// Adds a new commit to the branch. /// /// # Arguments @@ -41,7 +51,7 @@ pub trait BranchManager { /// Amends the latest commit in the branch with a new [`MevaCommit`]. /// /// Returns the SHA-1 hash of the updated commit. - fn amend_last_commit(&self, comit: MevaCommit) -> EngineResult; + fn amend_last_commit(&self, commit: MevaCommit) -> EngineResult; /// Returns the SHA-1 hash of the most recent commit in the branch. /// @@ -67,4 +77,89 @@ pub trait BranchManager { /// Returns an [`EngineResult`] if an error occurs during retrieval /// or object deserialization. fn last_commit(&self) -> EngineResult>; + + /// Creates a new branch based on the provided definition. + /// + /// # Arguments + /// + /// * `branch` - A [`Branch`] struct containing the name and the target commit hash. + /// + /// # Errors + /// + /// Returns an error if a branch with the same name already exists. + fn create_branch(&self, branch: &Branch) -> EngineResult<()>; + + /// Creates multiple branches in a batch operation. + /// + /// Iterates through the provided slice and creates each branch. + /// + /// # Arguments + /// + /// * `branches` - A slice of [`Branch`] definitions to create. + fn create_branches(&self, branches: &[Branch]) -> EngineResult<()>; + + /// Deletes a specific branch. + /// + /// # Arguments + /// + /// * `branch_name` - The name of the branch to delete. + /// * `force` - If `true`, the branch will be deleted even if it contains + /// commits not merged into the current branch. If `false`, such an attempt + /// will return an error. + /// + /// # Errors + /// + /// Returns an error if the branch does not exist or if `force` is false + /// and the branch is not fully merged. + fn remove_branch(&self, branch_name: &str, force: bool) -> EngineResult<()>; + + /// Deletes multiple branches in a batch operation. + /// + /// # Arguments + /// + /// * `branch_names` - A list of branch names to remove. + /// * `force` - Applies the force-delete logic to all specified branches. + /// + /// # Returns + /// + /// Returns a [`RemoveBranchesResult`] which aggregates information about + /// which deletions succeeded and which failed. + fn remove_branches( + &self, + branch_names: &[&str], + force: bool, + ) -> EngineResult; + + /// Renames an existing branch. + /// + /// # Arguments + /// + /// * `old_branch_name` - The current name of the branch. + /// * `new_branch` - The desired new name. + /// + /// # Errors + /// + /// Returns an error if: + /// * `old_branch_name` does not exist. + /// * `new_branch` already exists. + fn rename_branch(&self, old_branch_name: &str, new_branch: &str) -> EngineResult<()>; + + /// Lists branches present in the repository. + /// + /// # Arguments + /// + /// * `local` - Include local branches (`refs/heads/`) in the list. + /// * `remotes` - Include remote-tracking branches (`refs/remotes/`) in the list. + /// * `detailed` - If `true`, fetches commit details (hash and message) for each branch. + /// This involves reading objects from storage and is more expensive than a simple name list. + /// + /// # Returns + /// + /// Returns a vector of [`BranchInfo`] objects representing the filtered list of branches. + fn list_branches( + &self, + local: bool, + remotes: bool, + detailed: bool, + ) -> EngineResult>; } diff --git a/engine/src/branch_manager/branch.rs b/engine/src/branch_manager/branch.rs new file mode 100644 index 0000000..ff93e5d --- /dev/null +++ b/engine/src/branch_manager/branch.rs @@ -0,0 +1,31 @@ +/// Categorizes a branch based on its location and purpose within the repository. +/// +/// This distinction is crucial for determining where the branch reference +/// is stored on the filesystem and how it interacts with commands +#[derive(Debug, Clone, Eq, Hash, PartialEq)] +pub enum BranchType { + /// A standard local branch. + Local, + + /// A remote-tracking branch. + Remote, +} + +/// Represents a branch definition in the Meva system. +/// +/// A branch is essentially a lightweight, named pointer to a specific commit. +/// This structure holds the metadata required to locate, identify, and manipulate +/// that pointer. +#[derive(Debug, Clone)] +pub struct Branch { + /// The human-readable name of the branch (e.g., "main", "feature/login"). + /// + /// Note: This name should not contain the full reference path (like `refs/heads/`). + pub name: String, + + /// The category of the branch (Local or Remote). + pub branch_type: BranchType, + + /// The SHA-1 hash of the commit that this branch currently points to. + pub head_hash: String, +} diff --git a/engine/src/branch_manager/branch_info.rs b/engine/src/branch_manager/branch_info.rs new file mode 100644 index 0000000..9af0541 --- /dev/null +++ b/engine/src/branch_manager/branch_info.rs @@ -0,0 +1,29 @@ +/// Represents information about a single branch returned by a listing operation. +/// +/// This enum allows the system to be efficient by fetching and returning +/// only the necessary data based on the requested verbosity level. +pub enum BranchInfo { + /// Minimal representation containing only the branch name. + /// + /// This variant is returned by default when listing branches without + /// the verbose (`-v`) flag, avoiding the cost of reading commit objects. + NameOnly { + /// The name of the branch (e.g., "main", "feature/login"). + name: String, + }, + + /// Extended representation including the tip commit's details. + /// + /// This variant is returned when the verbose (`-v`) flag is active. + /// It requires resolving the commit hash to a commit object to retrieve the message. + Detailed { + /// The name of the branch. + name: String, + + /// The SHA-1 hash of the commit the branch points to. + commit_hash: String, + + /// The commit message associated with the hash. + commit_message: String, + }, +} diff --git a/engine/src/branch_manager/meva_branch_manager.rs b/engine/src/branch_manager/meva_branch_manager.rs index 84f2b9c..a1779b7 100644 --- a/engine/src/branch_manager/meva_branch_manager.rs +++ b/engine/src/branch_manager/meva_branch_manager.rs @@ -1,36 +1,46 @@ -use crate::errors::{CommitError, EngineResult}; +use crate::branch_manager::{Branch, BranchInfo, BranchType, RemoveBranchesResult}; +use crate::errors::{BranchError, CommitError, EngineResult, RefEntryError}; use crate::objects::MevaCommit; -use crate::ref_manager::{Head, HeadMode, RefEntry}; +use crate::ref_manager::{BranchRefEntry, Head, HeadMode, RefEntry}; +use crate::traversal::CommitHistoryWalker; use crate::{BranchManager, ObjectStorage, RefManager}; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; -/// Manages commits within a Meva branch. -/// /// `MevaBranchManager` is a concrete implementation of [`BranchManager`] -/// that provides commit-level operations for a branch using: -/// - [`MevaObjectStorage`] for persistent object storage, -/// - [`MevaRefManager`] for reading and updating branch references (HEAD). -/// -/// Currently, this manager handles adding commits and querying the latest commit. -/// In the future, it can be extended to support full branch-level operations, -/// such as creating new branches, switching branches (checkout), or merging. +/// This manager handles the complete lifecycle of branches and commits, ensuring +/// data integrity and safety. Its responsibilities include: +/// - **Commit Management**: adding new commits and retrieving the branch tip. +/// - **Branch Lifecycle**: creating, listing, renaming, and deleting branches. +/// - **Safety Checks**: verifying if a branch is fully merged into `HEAD` before deletion +/// to prevent accidental data loss. pub struct MevaBranchManager { /// Provides storage for commit objects. object_storage: Arc, + /// Service for traversing the commit graph (used for merge checks). + history_traversal: Arc, + /// Provides access to branch references (HEAD, etc.). ref_manager: Arc, } impl MevaBranchManager { - /// Creates a new [`MevaBranchManager`] for the given repository layout. + /// Creates a new [`MevaBranchManager`]. /// /// # Arguments /// - /// * `object_storage` – Provides storage for commit objects. - /// * `ref_manager` - Provides access to branch references. - pub fn new(object_storage: Arc, ref_manager: Arc) -> Self { + /// * `repo_layout` - Access to repository paths. + /// * `object_storage` – Storage backend for reading/writing commit objects. + /// * `history_traversal` - Service for walking the commit graph (BFS/DFS). + /// * `ref_manager` - Manager for reading and updating references. + pub fn new( + object_storage: Arc, + history_traversal: Arc, + ref_manager: Arc, + ) -> Self { Self { + history_traversal, object_storage, ref_manager, } @@ -71,9 +81,98 @@ impl MevaBranchManager { Ok(()) } + + /// Checks if a single branch is safe to delete. + /// + /// A branch is considered safe to delete if: + /// 1. **It is NOT the current active branch** (you cannot delete the branch you are currently on). + /// 2. It is a remote-tracking branch. + /// 3. Its tip commit is reachable from the current `HEAD` (fully merged). + /// + /// Returns `false` if the branch is the current active branch. + fn is_safe_to_delete(&self, branch_ref: &BranchRefEntry) -> EngineResult { + if matches!(branch_ref.branch_type, BranchType::Remote) { + return Ok(true); + } + + let head = self.ref_manager.read_head()?; + if head.target == branch_ref.ref_entry.name { + return Ok(false); + } + + let head_hash = match self.ref_manager.resolve_commit_hash(&head)? { + Some(hash) => hash, + None => { + return Err( + BranchError::NoHead(self.current_branch_name()?.unwrap_or("".into())).into(), + ); + } + }; + + let safe_to_delete = self.history_traversal.walk_bfs(head_hash)?; + + Ok(safe_to_delete.contains(&branch_ref.ref_entry.commit_hash.to_string())) + } + + /// Optimized batch check for branch deletion safety. + /// + /// Performs a single BFS traversal starting from `HEAD` to determine which + /// of the provided branches are fully merged. + /// + /// # Arguments + /// + /// * `branch_refs` - A slice of references to check. + /// + /// # Returns + /// + /// A vector of tuples, where each tuple contains the original reference + /// and a boolean indicating whether it is safe to delete. + fn are_safe_to_delete<'a>( + &self, + branch_refs: &[&'a BranchRefEntry], + ) -> EngineResult> { + let head = self.ref_manager.read_head()?; + let head_hash = self.ref_manager.resolve_commit_hash(&head)?; + if head_hash.is_none() { + return Err( + BranchError::NoHead(self.current_branch_name()?.unwrap_or("".into())).into(), + ); + } + let head_hash = head_hash.unwrap(); + + let safe_to_delete: HashSet = self + .history_traversal + .walk_bfs(head_hash.clone())? + .into_iter() + .collect(); + + let mut result = Vec::new(); + for &branch_ref in branch_refs { + if matches!(branch_ref.branch_type, BranchType::Remote) { + result.push((branch_ref, true)); + } else if branch_ref.ref_entry.name == head.target { + result.push((branch_ref, false)); + } else { + let safe = safe_to_delete.contains(&branch_ref.ref_entry.commit_hash.to_string()); + result.push((branch_ref, safe)); + } + } + + Ok(result) + } } impl BranchManager for MevaBranchManager { + fn current_branch_name(&self) -> EngineResult> { + let head = self.ref_manager.read_head()?; + match head.mode { + HeadMode::Symbolic => { + let prefix = self.ref_manager.heads_refs_prefix(); + Ok(head.target.strip_prefix(&prefix).map(String::from)) + } + HeadMode::Direct => Ok(None), + } + } fn add_commit(&self, mut commit: MevaCommit) -> EngineResult { let last_commit_hash = self.last_commit_hash()?; match last_commit_hash { @@ -123,4 +222,171 @@ impl BranchManager for MevaBranchManager { Ok(None) } } + + fn create_branch(&self, branch: &Branch) -> EngineResult<()> { + let ref_entry = RefEntry::new_branch_entry(branch); + self.ref_manager.update_ref(ref_entry) + } + + fn create_branches(&self, branch: &[Branch]) -> EngineResult<()> { + for branch in branch { + self.create_branch(branch)?; + } + Ok(()) + } + + fn remove_branch(&self, branch_name: &str, force: bool) -> EngineResult<()> { + let ref_entry = self.ref_manager.resolve_branch_ref(branch_name)?; + + match ref_entry { + None => Err(BranchError::BranchNotFound(branch_name.to_owned()).into()), + Some(branch_entry) => { + if force || self.is_safe_to_delete(&branch_entry)? { + self.ref_manager.remove_ref(&branch_entry.ref_entry.name)?; + Ok(()) + } else { + Err( + BranchError::NotSafeToDelete(branch_entry.ref_entry.name.to_string()) + .into(), + ) + } + } + } + } + + fn remove_branches( + &self, + branch_names: &[&str], + force: bool, + ) -> EngineResult { + let mut result = RemoveBranchesResult { + deleted: Vec::new(), + failed: Vec::new(), + }; + + let mut branch_entries: HashMap = HashMap::new(); + for &branch_name in branch_names { + let ref_entry = self.ref_manager.resolve_branch_ref(branch_name)?; + + match ref_entry { + None => result.failed.push(( + branch_name.to_string(), + BranchError::BranchNotFound(branch_name.to_owned()).into(), + )), + Some(branch_entry) => { + branch_entries.insert(branch_entry, branch_name); + } + } + } + + let safe_to_delete = match force { + false => self.are_safe_to_delete(&branch_entries.keys().collect::>())?, + true => branch_entries.keys().map(|k| (k, true)).collect(), + }; + + for (branch_entry, safe) in safe_to_delete { + let branch_name = *branch_entries.get(branch_entry).unwrap(); + if safe { + match self.ref_manager.remove_ref(&branch_entry.ref_entry.name) { + Ok(_) => { + result.deleted.push(branch_name.to_owned()); + } + Err(e) => { + result.failed.push((branch_name.to_owned(), e)); + } + } + } else { + result.failed.push(( + branch_name.to_owned(), + BranchError::NotSafeToDelete(branch_name.to_owned()).into(), + )); + } + } + + Ok(result) + } + + fn rename_branch(&self, old_branch_name: &str, new_branch_name: &str) -> EngineResult<()> { + let old_branch_ref = self.ref_manager.resolve_branch_ref(old_branch_name)?; + let new_branch_ref = self.ref_manager.resolve_branch_ref(new_branch_name)?; + + if old_branch_ref.is_none() { + return Err(BranchError::BranchNotFound(old_branch_name.to_owned()).into()); + } else if new_branch_ref.is_some() { + return Err(BranchError::BranchAlreadyExists(new_branch_name.to_owned()).into()); + } + + let old_branch_ref = old_branch_ref.unwrap(); + + let new_branch = Branch { + name: new_branch_name.to_string(), + branch_type: old_branch_ref.branch_type, + head_hash: old_branch_ref.ref_entry.commit_hash, + }; + let new_branch_ref = RefEntry::new_branch_entry(&new_branch); + + self.ref_manager.update_ref(new_branch_ref)?; + self.ref_manager + .remove_ref(&old_branch_ref.ref_entry.name)?; + + Ok(()) + } + + fn list_branches( + &self, + local: bool, + remotes: bool, + detailed: bool, + ) -> EngineResult> { + let local_branches = match local { + true => self.ref_manager.collect_refs_heads()?, + false => Vec::new(), + }; + + let remote_branches = match remotes { + true => self.ref_manager.collect_refs_remotes("")?, + false => Vec::new(), + }; + + let local_prefix = self.ref_manager.heads_refs_prefix(); + let remote_prefix = self.ref_manager.remotes_refs_prefix(); + + let process_entry = |ref_entry: RefEntry, prefix: &str| -> EngineResult { + let branch_name = ref_entry + .name + .strip_prefix(prefix) + .ok_or_else(|| RefEntryError::RefPrefixMismatch { + name: ref_entry.name.clone(), + prefix: prefix.to_owned(), + })? + .to_owned(); + + if detailed { + let commit_object = self.object_storage.get_object(&ref_entry.commit_hash)?; + + let commit = MevaCommit::try_from(commit_object)?; + + Ok(BranchInfo::Detailed { + name: branch_name, + commit_message: commit.message, + commit_hash: ref_entry.commit_hash, + }) + } else { + Ok(BranchInfo::NameOnly { name: branch_name }) + } + }; + + let local_iter = local_branches + .into_iter() + .map(|entry| process_entry(entry, &local_prefix)); + + let remote_iter = remote_branches + .into_iter() + .map(|entry| process_entry(entry, &remote_prefix)); + + let branches_info: Vec = + local_iter.chain(remote_iter).collect::>()?; + + Ok(branches_info) + } } diff --git a/engine/src/branch_manager/remove_branches_result.rs b/engine/src/branch_manager/remove_branches_result.rs new file mode 100644 index 0000000..550f7bb --- /dev/null +++ b/engine/src/branch_manager/remove_branches_result.rs @@ -0,0 +1,19 @@ +use crate::errors::EngineError; + +/// Aggregates the results of a batch branch deletion operation. +/// +/// This structure is used when deleting multiple branches at once. It allows +/// the operation to proceed even if some individual deletions fail, providing +/// a complete report of successes and failures afterward. +#[derive(Debug)] +pub struct RemoveBranchesResult { + /// A list of names of the branches that were successfully removed. + pub deleted: Vec, + + /// A list of branches that failed to be deleted, paired with the specific error. + /// + /// Each entry contains: + /// 1. The name of the branch. + /// 2. The [`EngineError`] explaining why the deletion failed (e.g., "branch not found", "branch not safe to delete"). + pub failed: Vec<(String, EngineError)>, +} diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index fd7663b..ad4d63f 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -7,9 +7,9 @@ use crate::commit_builder::MevaCommitBuilder; use crate::diff_builder::MevaDiffBuilder; use crate::errors::EngineResult; use crate::handlers::{ - add::AddHandler, clone::CloneHandler, commit, commit::CommitHandler, config::ConfigHandler, - diff::DiffHandler, fetch::FetchHandler, init::InitHandler, log::LogHandler, - ls_files::LsFilesHandler, ls_tree::LsTreeHandler, plugins::PluginsHandler, + add::AddHandler, branch::BranchHandler, clone::CloneHandler, commit, commit::CommitHandler, + config::ConfigHandler, diff::DiffHandler, fetch::FetchHandler, init::InitHandler, + log::LogHandler, ls_files::LsFilesHandler, ls_tree::LsTreeHandler, plugins::PluginsHandler, remote::RemoteHandler, restore::RestoreHandler, show::ShowHandler, status::StatusHandler, }; use crate::index::{MevaIndex, MevaWorkingDir}; @@ -19,7 +19,7 @@ use crate::plugins_interceptor::PluginsInterceptor; use crate::ref_manager::MevaRefManager; use crate::repositories::meva_repository_layout::MevaRepositoryLayout; use crate::revision_parsing::MevaRevisionResolver; -use crate::traversal::MevaCommitTreeWalker; +use crate::traversal::{MevaCommitHistoryWalker, MevaCommitTreeWalker}; use crate::{MevaConfigLoader, MevaRepository, RepositoryLayout}; /// Dependency injection container for the engine. @@ -72,6 +72,9 @@ pub trait EngineContainer { /// Return the handler responsible for remote command fn remote_handler(&self) -> EngineResult; + + /// Returns the handler responsible for branch management. + fn branch_handler(&self) -> EngineResult; } /// Concrete implementation of `EngineContainer` for Meva. @@ -229,8 +232,10 @@ impl EngineContainer for MevaContainer { working_dir, index_rel.clone(), )?); + let commit_history_walker = Arc::new(MevaCommitHistoryWalker::new(object_storage.clone())); let branch_manager = Arc::new(MevaBranchManager::new( object_storage.clone(), + commit_history_walker, refs_manager.clone(), )); let commit_builder = match request.dry_run_arg { @@ -392,4 +397,24 @@ impl EngineContainer for MevaContainer { ref_manager, )) } + + fn branch_handler(&self) -> EngineResult { + let repo_layout = self.repository_layout_discover()?; + let refs_manager = Arc::new(MevaRefManager::new(repo_layout.clone())); + let object_storage = Arc::new(MevaObjectStorage::new(repo_layout)); + let revision_resolver = Arc::new(MevaRevisionResolver::new( + object_storage.clone(), + refs_manager.clone(), + )); + let commit_history_walker = Arc::new(MevaCommitHistoryWalker::new(object_storage.clone())); + let branch_manager = Arc::new(MevaBranchManager::new( + object_storage, + commit_history_walker, + refs_manager, + )); + + let handler = BranchHandler::new(branch_manager, revision_resolver); + + Ok(handler) + } } diff --git a/engine/src/errors.rs b/engine/src/errors.rs index 1920101..96fc608 100644 --- a/engine/src/errors.rs +++ b/engine/src/errors.rs @@ -1,3 +1,4 @@ +pub mod branch_error; pub mod clone_error; pub mod commit_error; pub mod config_error; @@ -7,11 +8,13 @@ pub mod index_error; pub mod init_error; pub mod network_error; pub mod path_error; +pub mod ref_entry_error; pub mod repository_error; pub mod revision_error; pub mod tree_error; pub mod unpack_error; +pub use branch_error::BranchError; pub use clone_error::CloneError; pub use commit_error::CommitError; pub use config_error::ConfigError; @@ -20,6 +23,7 @@ pub use index_error::IndexError; pub use init_error::InitError; pub use network_error::NetworkError; pub use path_error::PathError; +pub use ref_entry_error::RefEntryError; pub use repository_error::RepositoryError; pub use revision_error::RevisionError; pub use tree_error::{NodeError, TreeError}; diff --git a/engine/src/errors/branch_error.rs b/engine/src/errors/branch_error.rs new file mode 100644 index 0000000..c316512 --- /dev/null +++ b/engine/src/errors/branch_error.rs @@ -0,0 +1,34 @@ +use thiserror::Error; + +/// Represents specific errors that can occur during branch management operations. +#[derive(Error, Debug)] +pub enum BranchError { + /// Occurs when attempting to operate on a branch that cannot be found. + /// + /// This typically happens during deletion, renaming, or checkout if the specified + /// branch name does not correspond to any reference in `refs/heads/` or `refs/remotes/`. + #[error("Branch '{0}' does not exist")] + BranchNotFound(String), + + /// Occurs when attempting to create or rename a branch to a name that is already taken. + /// + /// Branch names must be unique. To resolve this, the user must either choose + /// a different name or delete the existing branch first. + #[error("Branch '{0}' already exists")] + BranchAlreadyExists(String), + + /// Occurs when the current branch reference (`HEAD`) cannot be resolved to a commit hash. + /// + /// This usually indicates that the repository is empty (no commits yet) or the + /// `HEAD` reference is pointing to a non-existent path. + #[error("Branch '{0}' has no head")] + NoHead(String), + + /// Occurs when attempting to delete a branch that contains commits not reachable + /// from the current `HEAD`. + /// + /// This is a safety mechanism to prevent accidental loss of work. The operation + /// can be retried with the `force` flag (`-D`) to bypass this check. + #[error("Branch '{0} is not safe to delete")] + NotSafeToDelete(String), +} diff --git a/engine/src/errors/engine_error.rs b/engine/src/errors/engine_error.rs index 17af6c5..62007d1 100644 --- a/engine/src/errors/engine_error.rs +++ b/engine/src/errors/engine_error.rs @@ -5,8 +5,8 @@ use std::{io, string::FromUtf8Error}; use thiserror::Error; use crate::errors::{ - CloneError, CommitError, ConfigError, IgnoreError, IndexError, InitError, NetworkError, - PathError, RepositoryError, RevisionError, TreeError, UnpackError, + BranchError, CloneError, CommitError, ConfigError, IgnoreError, IndexError, InitError, + NetworkError, PathError, RefEntryError, RepositoryError, RevisionError, TreeError, UnpackError, }; /// A convenient result type alias for engine-related operations. @@ -131,6 +131,20 @@ pub enum EngineError { #[error(transparent)] Network(#[from] NetworkError), + /// Errors specific to high-level branch management operations. + /// + /// Includes failures in creating, deleting, renaming, or listing branches + /// (e.g., name collisions, merge safety violations, missing HEAD). + #[error(transparent)] + Branch(#[from] BranchError), + + /// Errors related to the parsing and validation of reference entries. + /// + /// Typically occurs when reference names are malformed or do not match + /// expected directory prefixes (e.g., checking if a ref is local or remote). + #[error(transparent)] + RefEntry(#[from] RefEntryError), + /// A catch-all variant for any unknown or unexpected engine error. /// Accepts a descriptive string message. #[error("Unknown engine error: {0}")] diff --git a/engine/src/errors/ref_entry_error.rs b/engine/src/errors/ref_entry_error.rs new file mode 100644 index 0000000..4de507f --- /dev/null +++ b/engine/src/errors/ref_entry_error.rs @@ -0,0 +1,29 @@ +use thiserror::Error; + +/// Represents errors related to parsing, validating, or manipulating reference entries. +/// +/// References (refs) in Meva are typically file paths (e.g., `refs/heads/main`) pointing +/// to commit hashes. These errors ensure that the system processes only correctly +/// formatted and correctly located references to maintain repository integrity. +#[derive(Error, Debug)] +pub enum RefEntryError { + /// Occurs when a reference string does not conform to the expected structure. + /// + /// This might happen if a reference name is empty, contains illegal filesystem + /// characters, or fails internal parsing rules. + #[error("Invalid reference format: {0}")] + InvalidRefFormat(String), + + /// Occurs when a reference path does not start with the required directory prefix. + /// + /// This error is typically raised during branch listing or categorization, + /// when the system expects a reference to belong to a specific namespace + /// (e.g., `refs/heads/` for local branches) but encounters a mismatch. + #[error("Reference '{name}' does not start with expected prefix '{prefix}'")] + RefPrefixMismatch { + /// The actual reference name encountered. + name: String, + /// The expected prefix (e.g., "refs/heads/"). + prefix: String, + }, +} diff --git a/engine/src/handlers.rs b/engine/src/handlers.rs index ad07d9f..ff34bcb 100644 --- a/engine/src/handlers.rs +++ b/engine/src/handlers.rs @@ -1,4 +1,5 @@ pub mod add; +pub mod branch; pub mod clone; pub mod commit; pub mod config; diff --git a/engine/src/handlers/branch.rs b/engine/src/handlers/branch.rs new file mode 100644 index 0000000..7723d6c --- /dev/null +++ b/engine/src/handlers/branch.rs @@ -0,0 +1,8 @@ +mod display_branches_list; +mod handlers; +mod operations; + +use display_branches_list::DisplayBranchesList; + +pub use handlers::BranchHandler; +pub use operations::*; diff --git a/engine/src/handlers/branch/display_branches_list.rs b/engine/src/handlers/branch/display_branches_list.rs new file mode 100644 index 0000000..21d3d18 --- /dev/null +++ b/engine/src/handlers/branch/display_branches_list.rs @@ -0,0 +1,76 @@ +use crate::branch_manager::BranchInfo; +use owo_colors::OwoColorize; +use std::fmt::{Display, Formatter, Result}; + +/// A presentation wrapper for the list of branches. +/// +/// This struct implements [`std::fmt::Display`] to render the branch list +/// in a user-friendly, colored format. +/// +/// # Formatting Rules +/// +/// * **Current Branch**: Prefixed with `*` and highlighted in **green**. +/// * **Remote Branches**: Names starting with `remotes/` are highlighted in **red**. +/// * **Alignment**: In detailed mode, commit hashes and messages are vertically +/// aligned based on the longest branch name. +/// * **Commit Details**: Hashes are shortened to 7 characters and dimmed. +pub struct DisplayBranchesList { + /// The name of the currently active branch (HEAD). + /// Used to determine which branch gets the `*` marker and green highlight. + pub current_branch: Option, + + /// The vector of branch information to display. + pub branches_list: Vec, +} + +impl Display for DisplayBranchesList { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let list = &self.branches_list; + + let max_width = list + .iter() + .map(|info| match info { + BranchInfo::NameOnly { name } => name.len(), + BranchInfo::Detailed { name, .. } => name.len(), + }) + .max() + .unwrap_or(0); + + for branch in list { + let (name, details) = match branch { + BranchInfo::NameOnly { name } => (name, None), + BranchInfo::Detailed { + name, + commit_hash, + commit_message, + } => (name, Some((commit_hash, commit_message))), + }; + + let is_current = self.current_branch.as_deref() == Some(name); + let is_remote = name.starts_with("remotes/"); + + let marker = if is_current { "*" } else { " " }; + + if is_current { + write!(f, "{} {}", marker, name.green())?; + } else if is_remote { + write!(f, "{} {}", marker, name.red())?; + } else { + write!(f, "{marker} {name}")?; + } + + if let Some((hash, message)) = details { + let padding_len = max_width.saturating_sub(name.len()); + let padding = " ".repeat(padding_len + 2); + + let short_hash = hash.get(0..7).unwrap_or(hash); + + writeln!(f, "{}{} {}", padding, short_hash.dimmed(), message)?; + } else { + writeln!(f)?; + } + } + + Ok(()) + } +} diff --git a/engine/src/handlers/branch/handlers.rs b/engine/src/handlers/branch/handlers.rs new file mode 100644 index 0000000..18069ff --- /dev/null +++ b/engine/src/handlers/branch/handlers.rs @@ -0,0 +1,124 @@ +use crate::branch_manager::{Branch, BranchType}; +use crate::errors::EngineResult; +use crate::handlers::branch::{ + BranchOperations, CreateRequest, DeleteRequest, DisplayBranchesList, ListRequest, + RenameRequest, Request, +}; +use crate::{BranchManager, RevisionResolver}; +use std::sync::Arc; + +/// Handles the `meva branch` command execution flow. +/// +/// The `BranchHandler` serves as the application layer for branch operations, +/// bridging the gap between CLI requests and the core domain logic. +/// It is responsible for: +/// - Resolving abstract revision names (e.g., "HEAD", tags) into concrete commit hashes via [`RevisionResolver`], +/// - Constructing domain objects (like [`Branch`]) from request data, +/// - Dispatching specific operations (create, delete, rename, list) to the [`BranchManager`], +/// - Formatting and displaying the branch list to the user using [`DisplayBranchesList`]. +/// +/// The `branch_manager` field encapsulates the core logic for filesystem and reference updates, +/// while `revision_resolver` ensures that user-provided start points are valid. +pub struct BranchHandler { + branch_manager: Arc, + revision_resolver: Arc, +} + +impl BranchHandler { + /// Creates a new [`BranchHandler`] and wires together dependencies required + /// for branch management. + /// + /// # Arguments + /// + /// * `branch_manager` — The core service handling branch lifecycle and integrity checks. + /// * `revision_resolver` — Service used to resolve start points (strings) to commit hashes. + pub fn new( + branch_manager: Arc, + revision_resolver: Arc, + ) -> Self { + Self { + branch_manager, + revision_resolver, + } + } + + /// Handles the creation of a new branch. + /// + /// This method performs the necessary preparation before calling the manager: + /// 1. Resolves the `start_point` from the request (e.g., "HEAD", "feature-x") + /// into a valid SHA-1 commit hash. + /// 2. Constructs a [`Branch`] domain object with the target hash. + /// 3. Delegates the creation to [`BranchManager::create_branch`]. + /// + /// # Errors + /// + /// Returns an [`EngineError`] if the start point cannot be resolved + /// or if a branch with the same name already exists. + fn handler_create(&self, request: CreateRequest) -> EngineResult<()> { + let branch_head = self.revision_resolver.resolve_hash(&request.start_point)?; + + let new_branch = Branch { + name: request.branch_name, + branch_type: BranchType::Local, + head_hash: branch_head, + }; + + self.branch_manager.create_branch(&new_branch) + } + + /// Handles the deletion of a branch. + /// + /// Delegates the operation directly to the manager, passing the `force` flag + /// which determines whether safety checks (merge status) should be bypassed. + fn handler_delete(&self, request: DeleteRequest) -> EngineResult<()> { + self.branch_manager + .remove_branch(&request.branch_name, request.force) + } + + /// Handles the renaming of an existing branch. + /// + /// Delegates the operation to the manager to update the reference file names + /// and maintain the `HEAD` consistency if the renamed branch is currently checked out. + fn handle_rename(&self, request: RenameRequest) -> EngineResult<()> { + self.branch_manager + .rename_branch(&request.old_name, &request.new_name) + } + + /// Retrieves and displays the list of branches. + /// + /// This method handles the UI aspect of the list command: + /// 1. Fetches the current active branch name. + /// 2. Retrieves the raw list of branches via [`BranchManager::list_branches`]. + /// 3. Wraps the data in a [`DisplayBranchesList`] for formatted output. + /// 4. Prints the result to standard output. + /// + /// # Errors + /// + /// Returns an [`EngineError`] if reading the references or object storage fails. + fn handle_list(&self, request: ListRequest) -> EngineResult<()> { + let current_branch = self.branch_manager.current_branch_name()?; + let branches_list = + self.branch_manager + .list_branches(request.local, request.remotes, request.verbose)?; + + let display_list = DisplayBranchesList { + current_branch, + branches_list, + }; + + println!("{display_list}"); + + Ok(()) + } +} + +impl BranchOperations for BranchHandler { + fn branch(&self, request: Request) -> EngineResult<()> { + match request { + Request::Create(request) => self.handler_create(request), + Request::Delete(request) => self.handler_delete(request), + Request::Rename(request) => self.handle_rename(request), + Request::List(request) => self.handle_list(request), + } + } +} diff --git a/engine/src/handlers/branch/operations.rs b/engine/src/handlers/branch/operations.rs new file mode 100644 index 0000000..da980eb --- /dev/null +++ b/engine/src/handlers/branch/operations.rs @@ -0,0 +1,81 @@ +use crate::errors::EngineResult; +use crate::revision_parsing::Revision; + +/// Represents a unified request type for all branch-related operations. +/// +/// This enum acts as a container that encapsulates specific parameters +/// for one of the four supported branch actions: creating, deleting, +/// renaming, or listing. It allows the command handler to accept a single +/// argument type +pub enum Request { + /// Request to create a new branch. + Create(CreateRequest), + + /// Request to delete an existing branch. + Delete(DeleteRequest), + + /// Request to rename a branch. + Rename(RenameRequest), + + /// Request to list branches. + List(ListRequest), +} + +/// Parameters required to create a new branch. +pub struct CreateRequest { + /// The name of the new branch to be created. + pub branch_name: String, + + /// The revision (commit hash or reference) from which the new branch will start. + /// + /// This determines where the new pointer will be placed in the commit graph. + pub start_point: Revision, +} + +/// Parameters required to delete a branch. +pub struct DeleteRequest { + /// The name of the branch to be deleted. + pub branch_name: String, + + /// Determines whether to bypass safety checks. + /// + /// If `false`, the system should prevent deletion if the branch contains + /// commits that have not been merged into the current HEAD. + /// If `true` (`-D`), the branch will be deleted regardless of its merge status. + pub force: bool, +} + +/// Parameters required to rename a branch. +pub struct RenameRequest { + /// The current name of the branch. + pub old_name: String, + + /// The new desired name for the branch. + pub new_name: String, +} + +/// Configuration options for listing branches. +pub struct ListRequest { + /// If `true` (`-v`), provides detailed output including the SHA-1 hash + /// and the subject line of the tip commit for each branch. + pub verbose: bool, + + /// If `true`, includes local branches (`refs/heads/`) in the output. + pub local: bool, + + /// If `true` (`-r` or `-a`), includes remote-tracking branches (`refs/remotes/`) in the output. + pub remotes: bool, +} + +/// Defines the high-level interface for handling branch command logic. +/// +/// This trait is typically implemented by a specific command handler +/// (e.g., `BranchHandler`) that coordinates the interaction between +/// the CLI request and the core repository logic (`BranchManager`). +pub trait BranchOperations { + /// Executes a branch operation based on the provided request variant. + /// + /// This method dispatches the specific logic for creation, deletion, + /// renaming, or listing. + fn branch(&self, request: Request) -> EngineResult<()>; +} diff --git a/engine/src/ref_manager.rs b/engine/src/ref_manager.rs index 80f8c25..77d83b0 100644 --- a/engine/src/ref_manager.rs +++ b/engine/src/ref_manager.rs @@ -1,3 +1,5 @@ +mod branch_ref_entry; + pub mod head; pub mod head_mode; pub mod meva_ref_manager; @@ -5,6 +7,7 @@ pub mod ref_entry; use crate::errors::EngineResult; +pub use branch_ref_entry::BranchRefEntry; pub use head::Head; pub use head_mode::HeadMode; pub use meva_ref_manager::MevaRefManager; @@ -74,6 +77,48 @@ pub trait RefManager: Send + Sync { /// * `Ok(Vec)`: A list of all found local remote branches. fn collect_refs_remotes(&self, origin: &str) -> EngineResult>; + /// Returns the standard directory prefix for local branch references. + /// + /// This string typically resolves to `"refs/heads/"`. It is useful for: + /// - Filtering references to find only local branches. + /// - Stripping the namespace to obtain a short branch name (e.g., transforming + /// `refs/heads/main` into `main`). + fn heads_refs_prefix(&self) -> String; + + /// Returns the standard directory prefix for remote-tracking branch references. + /// + /// This string typically resolves to `"refs/remotes/"`. It is used to + /// identify references that track the state of remote repositories and distinguish + /// them from local development branches. + fn remotes_refs_prefix(&self) -> String; + + /// Attempts to find a branch reference by its short name and categorizes it. + /// + /// This helper method looks up the reference in standard locations to create + /// a typed [`BranchRefEntry`]. + /// + /// # Arguments + /// + /// * `branch` - The name of the branch to resolve (e.g., "master"). + /// + /// # Returns + /// + /// * `Ok(Some(BranchRefEntry))` - If the branch exists locally or remotely. + /// * `Ok(None)` - If the branch could not be found. + fn resolve_branch_ref(&self, branch: &str) -> EngineResult>; + /// Updates or creates a named reference with the provided [`RefEntry`] value. fn update_ref(&self, entry: RefEntry) -> EngineResult<()>; + + /// Removes a named reference from the repository. + /// + /// # Arguments + /// + /// * `name` - The full name of the reference to remove (e.g., "refs/heads/feature"). + /// + /// # Returns + /// + /// * `Ok(Some(RefEntry))` - The value of the reference just before it was deleted. + /// * `Ok(None)` - If the reference did not exist. + fn remove_ref(&self, name: &str) -> EngineResult>; } diff --git a/engine/src/ref_manager/branch_ref_entry.rs b/engine/src/ref_manager/branch_ref_entry.rs new file mode 100644 index 0000000..9089e86 --- /dev/null +++ b/engine/src/ref_manager/branch_ref_entry.rs @@ -0,0 +1,24 @@ +use crate::branch_manager::BranchType; +use crate::ref_manager::RefEntry; + +/// Represents a fully resolved branch reference paired with its classification. +/// +/// While [`RefEntry`] provides the raw data (path and commit hash), `BranchRefEntry` +/// adds semantic context by explicitly categorizing the reference as either +/// [`BranchType::Local`] or [`BranchType::Remote`]. +/// +/// This structure is primarily used during branch enumeration and safety checks, +/// allowing the system to apply different logic based on the branch type without +/// repeatedly parsing the reference path string. +#[derive(Debug, Clone, Eq, Hash, PartialEq)] +pub struct BranchRefEntry { + /// The underlying reference data containing the full reference name (e.g., `refs/heads/develop`) + /// and the commit hash it points to. + pub ref_entry: RefEntry, + + /// The category of the branch. + /// + /// Determines how the system interacts with this reference (e.g., local branches + /// require merge checks before deletion, whereas remote-tracking branches do not). + pub branch_type: BranchType, +} diff --git a/engine/src/ref_manager/meva_ref_manager.rs b/engine/src/ref_manager/meva_ref_manager.rs index bedb549..0178cd3 100644 --- a/engine/src/ref_manager/meva_ref_manager.rs +++ b/engine/src/ref_manager/meva_ref_manager.rs @@ -1,10 +1,12 @@ use walkdir::WalkDir; use crate::RepositoryLayout; -use crate::errors::{EngineResult, PathError}; -use crate::ref_manager::{Head, HeadMode, RefEntry, RefManager}; +use crate::errors::{EngineError, EngineResult, PathError}; +use crate::ref_manager::{BranchRefEntry, Head, HeadMode, RefEntry, RefManager}; use crate::serialize_deserialize::MevaEncode; +use crate::branch_manager::BranchType; +use shared::remove_empty_parents; use std::{ fs::{self, File}, io::{Read, Write}, @@ -43,7 +45,11 @@ impl MevaRefManager { /// Writes string content to a file, replacing its previous contents. fn write_file(&self, path: &Path, content: &str) -> EngineResult<()> { - let mut file = File::options().write(true).truncate(true).open(path)?; + let mut file = File::options() + .create(true) + .write(true) + .truncate(true) + .open(path)?; file.write_all(content.as_bytes())?; Ok(()) } @@ -135,8 +141,57 @@ impl RefManager for MevaRefManager { self.collect_refs(self.repo_layout.remotes_refs_dir().join(origin)) } + fn heads_refs_prefix(&self) -> String { + format!( + "{}/{}/", + self.repo_layout.refs_dir_name(), + self.repo_layout.heads_dir_name() + ) + } + + fn remotes_refs_prefix(&self) -> String { + format!( + "{}/{}/", + self.repo_layout.refs_dir_name(), + self.repo_layout.remotes_dir_name() + ) + } + + fn resolve_branch_ref(&self, branch: &str) -> EngineResult> { + let local = format!("{}{branch}", self.heads_refs_prefix(),); + let remote = format!("{}{branch}", self.remotes_refs_prefix(),); + + match self.read_ref(&local) { + Ok(Some(entry)) => { + return Ok(Some(BranchRefEntry { + ref_entry: entry, + branch_type: BranchType::Local, + })); + } + Ok(None) => {} + Err(e) if matches ! ( & e, EngineError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) => + {} + Err(e) => return Err(e), + } + + match self.read_ref(&remote) { + Ok(Some(entry)) => { + return Ok(Some(BranchRefEntry { + ref_entry: entry, + branch_type: BranchType::Remote, + })); + } + Ok(None) => {} + Err(e) if matches ! ( & e, EngineError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) => + {} + Err(e) => return Err(e), + } + + Ok(None) + } fn update_ref(&self, entry: RefEntry) -> EngineResult<()> { let path = self.repo_layout.repository_dir().join(&entry.name); + let parent = path .parent() .ok_or(PathError::NoParent { path: path.clone() })?; @@ -147,4 +202,17 @@ impl RefManager for MevaRefManager { Ok(()) } + + fn remove_ref(&self, name: &str) -> EngineResult> { + let entry = self.read_ref(name)?; + + let path = self.repo_layout.repository_dir().join(name); + + if entry.is_some() { + fs::remove_file(&path)?; + remove_empty_parents(&path, &self.repo_layout.refs_dir())?; + } + + Ok(entry) + } } diff --git a/engine/src/ref_manager/ref_entry.rs b/engine/src/ref_manager/ref_entry.rs index 884d42b..5441749 100644 --- a/engine/src/ref_manager/ref_entry.rs +++ b/engine/src/ref_manager/ref_entry.rs @@ -1,3 +1,4 @@ +use crate::branch_manager::{Branch, BranchType}; use crate::serialize_deserialize::MevaEncode; use serde::{Deserialize, Serialize}; @@ -9,7 +10,7 @@ use serde::{Deserialize, Serialize}; /// /// These entries are typically stored and managed by the [`MevaRefManager`], /// which handles reading and updating branch references in the repository. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Eq, Hash, PartialEq)] pub struct RefEntry { /// The full name of the reference (e.g., `refs/heads/master`). pub name: String, @@ -31,6 +32,43 @@ impl RefEntry { commit_hash: contents.trim().to_string(), } } + + /// Constructs a `RefEntry` based on a higher-level [`Branch`] definition. + /// + /// This method automatically determines the correct reference prefix based on the + /// [`BranchType`]: + /// - **Local**: Prefixes with `refs/heads/`. + /// - **Remote**: Prefixes with `refs/remotes/`. + /// + /// # Arguments + /// + /// * `branch` - The branch object containing the name and target hash. + pub fn new_branch_entry(branch: &Branch) -> Self { + match branch.branch_type { + BranchType::Local => { + Self::new_local_branch_entry(&branch.name, branch.head_hash.clone()) + } + BranchType::Remote => { + Self::new_remote_branch_entry(&branch.name, branch.head_hash.clone()) + } + } + } + + /// Helper to create a local branch entry (refs/heads/). + fn new_local_branch_entry(branch_name: &str, commit_hash: String) -> Self { + Self { + name: format!("refs/heads/{branch_name}"), + commit_hash, + } + } + + /// Helper to create a remote branch entry (refs/remotes/). + fn new_remote_branch_entry(branch_name: &str, commit_hash: String) -> Self { + Self { + name: format!("refs/remotes/{branch_name}"), + commit_hash, + } + } } impl MevaEncode for RefEntry {} diff --git a/engine/src/traversal.rs b/engine/src/traversal.rs index 5f94350..336b205 100644 --- a/engine/src/traversal.rs +++ b/engine/src/traversal.rs @@ -1,5 +1,7 @@ +mod meva_commit_history_walker; mod meva_commit_tree_walker; mod traits; +pub use meva_commit_history_walker::MevaCommitHistoryWalker; pub use meva_commit_tree_walker::MevaCommitTreeWalker; -pub use traits::CommitTreeWalker; +pub use traits::{CommitHistoryWalker, CommitTreeWalker}; diff --git a/engine/src/traversal/meva_commit_history_walker.rs b/engine/src/traversal/meva_commit_history_walker.rs new file mode 100644 index 0000000..8e0daa5 --- /dev/null +++ b/engine/src/traversal/meva_commit_history_walker.rs @@ -0,0 +1,53 @@ +use crate::errors::EngineResult; +use crate::object_storage::ObjectStorage; +use crate::objects::MevaCommit; +use crate::traversal::CommitHistoryWalker; +use std::{collections::VecDeque, sync::Arc}; + +/// Concrete implementation of [`CommitHistoryWalker`] for the Meva repository. +/// +/// This component is responsible for traversing the Directed Acyclic Graph (DAG) +/// of commits. It retrieves commit objects from the provided [`ObjectStorage`] +/// to discover parent relationships. +pub struct MevaCommitHistoryWalker { + /// Access to persistent storage to read commit objects by hash. + object_storage: Arc, +} + +impl MevaCommitHistoryWalker { + /// Creates a new instance of [`MevaCommitHistoryWalker`]. + /// + /// # Arguments + /// + /// * `object_storage` - The storage backend used to resolve commit hashes + /// into actual [`MevaCommit`] structures containing parent info. + pub fn new(object_storage: Arc) -> Self { + Self { object_storage } + } +} + +impl CommitHistoryWalker for MevaCommitHistoryWalker { + fn walk_linear(&self) -> EngineResult> { + //TODO: move log handler logic here + todo!() + } + + fn walk_bfs(&self, root_commit_hash: String) -> EngineResult> { + let mut ancestors = vec![]; + let mut queue = VecDeque::new(); + queue.push_back(root_commit_hash); + + while let Some(hash) = queue.pop_front() { + let object = self.object_storage.get_object(&hash)?; + let commit = MevaCommit::try_from(object)?; + + for parent in commit.parents { + queue.push_back(parent); + } + + ancestors.push(hash); + } + + Ok(ancestors) + } +} diff --git a/engine/src/traversal/traits.rs b/engine/src/traversal/traits.rs index 4172e5b..68a691f 100644 --- a/engine/src/traversal/traits.rs +++ b/engine/src/traversal/traits.rs @@ -37,3 +37,40 @@ pub trait CommitTreeWalker { path_filter: Option<&[PathBuf]>, ) -> EngineResult>; } + +/// Defines the interface for traversing the commit history graph. +/// +/// The commit history in Meva is a Directed Acyclic Graph (DAG). This trait +/// abstracts the algorithms used to walk through this graph, allowing for different +/// traversal strategies (e.g., linear logs vs. full reachability checks) regardless +/// of the underlying storage backend. +/// +/// Requires `Send` and `Sync` to ensure the walker can be shared across threads +/// in an asynchronous environment. +pub trait CommitHistoryWalker: Send + Sync { + /// Traverses the history linearly, following only the first parent of each commit. + /// + /// This strategy effectively "flattens" the history, ignoring side branches + /// that were merged in. + /// + /// # Returns + /// + /// A list of commit hashes representing the "main line" of the history. + fn walk_linear(&self) -> EngineResult>; + + /// Traverses the entire ancestry graph using Breadth-First Search (BFS). + /// + /// This method visits **every** commit reachable from the `root_commit_hash`, + /// including commits on merged side branches. + /// + /// # Use Cases + /// + /// * **Merge Checks**: Determining if a specific commit (e.g., a branch tip) + /// is an ancestor of the root (e.g., HEAD). + /// * **Garbage Collection**: Identifying all live objects in the repository. + /// + /// # Arguments + /// + /// * `root_commit_hash` - The starting point of the traversal (typically HEAD). + fn walk_bfs(&self, root_commit_hash: String) -> EngineResult>; +} diff --git a/shared/src/extensions.rs b/shared/src/extensions.rs index 126219f..ebd4f38 100644 --- a/shared/src/extensions.rs +++ b/shared/src/extensions.rs @@ -3,6 +3,7 @@ pub mod fs; pub mod is_within; pub mod open_in_editor; pub mod path_to_string; +pub mod remove_empty; pub mod strip_base; pub mod upward_search; @@ -11,5 +12,6 @@ pub use fs::create_file_with_dirs; pub use is_within::IsWithin; pub use open_in_editor::OpenInEditor; pub use path_to_string::PathToString; +pub use remove_empty::remove_empty_parents; pub use strip_base::StripBase; pub use upward_search::UpwardSearch; diff --git a/shared/src/extensions/remove_empty.rs b/shared/src/extensions/remove_empty.rs new file mode 100644 index 0000000..94638ec --- /dev/null +++ b/shared/src/extensions/remove_empty.rs @@ -0,0 +1,150 @@ +use std::path::Path; +use std::{fs, io}; + +/// Recursively removes empty parent directories starting from the given path upwards. +/// +/// This utility function is used to clean up the directory tree after removing a file. +/// It attempts to delete the parent directory of `path`, and if successful (meaning +/// the directory became empty), it continues to the parent's parent, and so on. +/// +/// # Behavior +/// +/// The process stops if: +/// * The `repo_root` directory is reached (this directory is never deleted). +/// * A directory is not empty (`DirectoryNotEmpty` error). +/// * An unexpected I/O error occurs (e.g., permission denied). +/// +/// If a directory does not exist (`NotFound`), the function assumes it was already +/// removed and proceeds to check its parent. +/// +/// # Arguments +/// +/// * `path` - The path to the file or directory that was just removed. The cleanup +/// starts from `path.parent()`. +/// +/// * `repo_root` - The root directory of the repository, acting as a boundary +/// to prevent deleting the project root. +/// +/// # Returns +/// +/// Returns `Ok(())` if the cleanup finished successfully or stopped naturally. +/// Returns `Err` if an unexpected I/O error occurred. +pub fn remove_empty_parents(path: &Path, repo_root: &Path) -> io::Result<()> { + let mut current = path.to_path_buf(); + + while let Some(parent) = current.parent() { + if parent == repo_root { + break; + } + + match fs::remove_dir(parent) { + Ok(_) => { + current = parent.to_path_buf(); + } + Err(e) if e.kind() == io::ErrorKind::DirectoryNotEmpty => break, + Err(e) if e.kind() == io::ErrorKind::NotFound => { + current = parent.to_path_buf(); + } + Err(e) => return Err(e), + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_removes_nested_empty_directories() { + let temp = tempdir().unwrap(); + let root = temp.path(); + + let dir_path = root.join("a/b/c"); + fs::create_dir_all(&dir_path).unwrap(); + + let deleted_file_path = dir_path.join("test_file.txt"); + + remove_empty_parents(&deleted_file_path, root).unwrap(); + + assert!( + !root.join("a").exists(), + "Directory 'a' should have been removed" + ); + assert!(root.exists(), "Root directory should still exist"); + } + + #[test] + fn test_stops_at_non_empty_directory() { + let temp = tempdir().unwrap(); + let root = temp.path(); + + let dir_path = root.join("a/b/c"); + fs::create_dir_all(&dir_path).unwrap(); + + let keeper_file = root.join("a/keeper.txt"); + fs::write(&keeper_file, "I protect directory a").unwrap(); + + let deleted_file_path = dir_path.join("temp.txt"); + + remove_empty_parents(&deleted_file_path, root).unwrap(); + + assert!( + !root.join("a/b").exists(), + "Directory 'b' should be removed" + ); + + assert!( + root.join("a").exists(), + "Directory 'a' should NOT be removed" + ); + assert!(keeper_file.exists(), "Keeper file should still exist"); + } + + #[test] + fn test_does_not_remove_repo_root() { + let temp = tempdir().unwrap(); + let root = temp.path(); + + let dir_a = root.join("a"); + fs::create_dir(&dir_a).unwrap(); + + let deleted_file_path = dir_a.join("file.txt"); + + remove_empty_parents(&deleted_file_path, root).unwrap(); + + assert!(!dir_a.exists(), "Directory 'a' should be removed"); + assert!(root.exists(), "Repo root must never be removed"); + } + + #[test] + fn test_handles_non_existent_directories_gracefully() { + let temp = tempdir().unwrap(); + let root = temp.path(); + + let phantom_path = root.join("ghost/path/file.txt"); + + let result = remove_empty_parents(&phantom_path, root); + + assert!( + result.is_ok(), + "Function should succeed even if dirs are missing" + ); + assert!(root.exists()); + } + + #[test] + fn test_handles_file_in_root() { + let temp = tempdir().unwrap(); + let root = temp.path(); + + let deleted_file_path = root.join("file.txt"); + + remove_empty_parents(&deleted_file_path, root).unwrap(); + + assert!(root.exists()); + } +} diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 165f93a..8d796be 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -4,5 +4,6 @@ pub mod extensions; pub use extensions::{ CumulativePaths, IsWithin, OpenInEditor, PathToString, StripBase, UpwardSearch, fs, + remove_empty_parents, }; pub use pretty_field::PrettyField; From c3ec0de542777094be9aaf60eb7abb2470544ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Sat, 29 Nov 2025 17:19:34 +0100 Subject: [PATCH 24/42] Command `fetch` (#34) --- engine/src/engine_container.rs | 14 +- engine/src/handlers/fetch/handlers.rs | 225 ++++++++++++------ engine/src/handlers/remote/handlers.rs | 20 +- engine/src/handlers/restore/handlers.rs | 1 - engine/src/network.rs | 2 +- engine/src/network/protocols/upload_pack.rs | 4 +- engine/src/object_storage.rs | 3 + .../meva_dry_run_object_storage.rs | 4 + .../src/object_storage/meva_object_storage.rs | 5 + engine/src/ref_manager.rs | 6 + engine/src/ref_manager/meva_ref_manager.rs | 18 ++ server/src/server_handler.rs | 23 +- 12 files changed, 223 insertions(+), 102 deletions(-) diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index ad4d63f..9a82d4c 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -371,29 +371,23 @@ impl EngineContainer for MevaContainer { let layout = self.repository_layout_discover()?; let object_storage = Arc::new(MevaObjectStorage::new(layout.clone())); let config_loader = Arc::new(MevaConfigLoader::default()); - let ref_manager = Arc::new(MevaRefManager::new(layout.clone())); + let ref_manager = Arc::new(MevaRefManager::new(layout)); let remotes_manager = Arc::new(MevaRemotesManager); - let handler = FetchHandler::new( - layout, - object_storage, - config_loader, - ref_manager, - remotes_manager, - ); + let handler = + FetchHandler::new(object_storage, config_loader, ref_manager, remotes_manager); Ok(handler) } fn remote_handler(&self) -> EngineResult { let layout = self.repository_layout_discover()?; - let ref_manager = Arc::new(MevaRefManager::new(layout.clone())); + let ref_manager = Arc::new(MevaRefManager::new(layout)); let remotes_manager = Arc::new(MevaRemotesManager); let config_loader = Arc::new(MevaConfigLoader::default()); Ok(RemoteHandler::new( remotes_manager, config_loader, - layout, ref_manager, )) } diff --git a/engine/src/handlers/fetch/handlers.rs b/engine/src/handlers/fetch/handlers.rs index 940106d..880b095 100644 --- a/engine/src/handlers/fetch/handlers.rs +++ b/engine/src/handlers/fetch/handlers.rs @@ -6,12 +6,13 @@ use std::{ use async_trait::async_trait; use itertools::Itertools; -use shared::PathToString; use crate::{ - ConfigLoader, RepositoryLayout, + ConfigLoader, errors::EngineResult, - network::{PackfileCodec, RemoteEntry, RemotesManager, SshConnectionParams, SshService}, + network::{ + PackfileCodec, RemoteEntry, RemotesManager, SshConnectionParams, SshService, SshSession, + }, object_storage::ObjectStorage, ref_manager::{RefEntry, RefManager}, }; @@ -21,7 +22,6 @@ use super::{FetchOperations, Request, Response}; /// Handles the logic for fetching data from a remote repository. pub struct FetchHandler { ssh_service: SshService, - repository_layout: Arc, object_storage: Arc, config_loader: Arc, ref_manager: Arc, @@ -31,7 +31,6 @@ pub struct FetchHandler { impl FetchHandler { /// Creates a new instance of the [`FetchHandler`]. pub fn new( - repository_layout: Arc, object_storage: Arc, config_loader: Arc, ref_manager: Arc, @@ -39,7 +38,6 @@ impl FetchHandler { ) -> Self { Self { ssh_service: SshService, - repository_layout, object_storage, config_loader, ref_manager, @@ -53,6 +51,12 @@ impl FetchHandler { } /// Retrieves the configuration details for a specific remote. + /// + /// This method looks up the remote configuration (URL, server key, etc.) + /// in the local repository configuration by its name (e.g., "origin"). + /// + /// # Arguments + /// * `name` - The name of the remote to retrieve. fn get_remote_entry(&self, name: &str) -> EngineResult { self.remotes_manager.get_remote(name) } @@ -64,64 +68,82 @@ impl FetchHandler { )) } - /// Collects existing local references for a specific remote. - /// - /// If a specific `branch` is provided in the request, this attempts to resolve - /// only that branch's local tracking ref. Otherwise, it collects all references - /// under the remote's namespace. - fn collect_refs(&self, origin: &str, branch: &Option) -> EngineResult> { - match branch { - Some(branch) => { - let name = self - .repository_layout - .remotes_refs_dir_rel() - .join(origin) - .join(branch); - Ok(vec![ - self.ref_manager.read_ref(&name.to_utf8_string())?.unwrap(), - ]) // TODO: RefNotFound? - } - None => self.ref_manager.collect_refs_remotes(origin), - } - } - /// Removes local remote-tracking branches that no longer exist on the server. /// - /// This is only executed if the `prune` flag is set in the request. + /// This process is known as "pruning". It ensures that the local view of the + /// remote (e.g., `refs/remotes/origin/*`) accurately reflects the server's state, + /// deleting stale references. + /// + /// This method maps server-side reference names (e.g., `refs/heads/main`) to their + /// local tracking equivalents (e.g., `refs/remotes/origin/main`) to determine + /// which local branches are now obsolete. + /// + /// # Arguments + /// * `remote_name` - The name of the remote being pruned (e.g., "origin"). + /// * `local_refs` - A mutable vector of local references. Stale references will be removed from this list. + /// * `server_refs` - The list of references currently existing on the server. fn prune_remote_refs( &self, + remote_name: &str, local_refs: &mut Vec, server_refs: &[RefEntry], ) -> EngineResult<()> { println!("Pruning remote branches..."); - let server_names: HashSet<&str> = server_refs.iter().map(|r| r.name.as_str()).collect(); - let to_prune = local_refs + let expected_local_names: HashSet = server_refs .iter() - .filter(|local| !server_names.contains(local.name.as_str())) - .cloned() - .collect_vec(); + .map(|server_ref| { + self.ref_manager + .map_head_to_remote_ref(&server_ref.name, remote_name) + }) + .collect(); + + let (kept, to_prune): (Vec<_>, Vec<_>) = local_refs + .drain(..) + .partition(|local| expected_local_names.contains(&local.name)); - // TODO: usunąć branche - for r in &to_prune { - println!("Pruning {}...", r.name); + for branch in to_prune { + self.ref_manager.remove_ref(&branch.name)?; } - local_refs.retain(|local| server_names.contains(local.name.as_str())); + *local_refs = kept; + Ok(()) } - /// Updates local references to match the state of the remote repository. - fn update_remote_refs(&self, refs_to_update: &[RefEntry]) -> EngineResult<()> { - for r in refs_to_update { - println!( - "Updating/creating remote-tracking ref: {} -> {}", - r.name, r.commit_hash - ); + /// Updates local remote-tracking references to match the state of the remote repository. + /// + /// This method iterates over the provided list of references (which are known to + /// be new or updated) and writes them to the local reference store. + /// + /// The reference names are automatically mapped from the server's namespace + /// (e.g., `refs/heads/main`) to the local remote-tracking namespace + /// (e.g., `refs/remotes/origin/main`). + /// + /// # Arguments + /// * `remote_name` - The name of the remote (e.g., "origin"). + /// * `remote_refs` - The list of server references to update locally. + fn update_remote_refs(&self, remote_name: &str, remote_refs: &[RefEntry]) -> EngineResult<()> { + for remote_ref in remote_refs { + let tracking_name = self + .ref_manager + .map_head_to_remote_ref(&remote_ref.name, remote_name); + + println!("Updating {} -> {}", tracking_name, remote_ref.commit_hash); + + let new_entry = RefEntry::new(&tracking_name, &remote_ref.commit_hash); + self.ref_manager.update_ref(new_entry)?; } Ok(()) } /// Decodes the received packfile data and writes the objects to storage. + /// + /// This method takes the raw binary packfile data received from the server, + /// decodes it into individual Meva objects (commits, trees, blobs), and + /// persists them into the local object database. + /// + /// # Arguments + /// * `packfile_data` - The raw bytes of the packfile. fn process_packfile(&self, packfile_data: &[u8]) -> EngineResult<()> { println!("Decoding objects..."); @@ -131,12 +153,18 @@ impl FetchHandler { self.object_storage.add_objects_from_packfile(&objects) } -} -#[async_trait] -impl FetchOperations for FetchHandler { - /// Executes the full fetch protocol. - async fn fetch(&self, request: Request) -> EngineResult { + /// Establishes an SSH connection to the remote server based on the request. + /// + /// This helper method resolves the remote URL and key paths, creates the + /// connection parameters, and initiates the SSH session. + /// + /// # Returns + /// A tuple containing the active [`SshSession`] and the [`SshConnectionParams`] used. + async fn connect_ssh( + &self, + request: &Request, + ) -> EngineResult<(SshSession, SshConnectionParams)> { let remote_entry = self.get_remote_entry(&request.origin)?; let mut connection_params = SshConnectionParams::try_from(&remote_entry.url)?; @@ -144,11 +172,73 @@ impl FetchOperations for FetchHandler { connection_params.client_key_path = client_key; connection_params.server_key_path = remote_entry.pub_signing_key; - let mut ssh_session = self.ssh_service.connect(&connection_params).await?; + Ok(( + self.ssh_service.connect(&connection_params).await?, + connection_params, + )) + } - let mut local_refs = self.collect_refs(&request.origin, &request.branch)?; - let haves = local_refs + /// Identifies which references from the server need to be updated or created locally. + /// + /// This method performs a comparison between the server's state and the local + /// remote-tracking branches. It filters the server references based on the fetch + /// request (e.g., specific branch vs. all branches) and checks for hash divergences. + fn find_refs_to_update( + &self, + request: &Request, + server_remote_refs: &[RefEntry], + local_remote_refs: &[RefEntry], + ) -> EngineResult> { + let heads_refs_prefix = self.ref_manager.heads_refs_prefix(); + + let target_branch_name = request + .branch + .as_ref() + .map(|b| format!("{heads_refs_prefix}{b}")); + + let local_map: HashMap<&str, &str> = local_remote_refs .iter() + .map(|e| (e.name.as_str(), e.commit_hash.as_str())) + .collect(); + + let refs_to_update = server_remote_refs + .iter() + .filter(|server_ref| { + if let Some(target) = &target_branch_name + && &server_ref.name != target + { + return false; + } else if !server_ref.name.starts_with(&heads_refs_prefix) { + return false; + } + + let tracking_name = self + .ref_manager + .map_head_to_remote_ref(&server_ref.name, &request.origin); + + match local_map.get(tracking_name.as_str()) { + Some(&local_hash) => local_hash != server_ref.commit_hash, + None => true, + } + }) + .cloned() + .collect(); + + Ok(refs_to_update) + } +} + +#[async_trait] +impl FetchOperations for FetchHandler { + /// Executes the full fetch protocol. + async fn fetch(&self, request: Request) -> EngineResult { + let (mut ssh_session, connection_params) = self.connect_ssh(&request).await?; + let mut local_remote_refs = self.ref_manager.collect_refs_remotes(&request.origin)?; + let local_heads = self.ref_manager.collect_refs_heads()?; + + let haves = local_remote_refs + .iter() + .chain(local_heads.iter()) .map(|e| e.commit_hash.clone()) .unique() .collect::>(); @@ -157,26 +247,27 @@ impl FetchOperations for FetchHandler { .run_upload_pack(&connection_params.repository_name, haves) .await?; - let server_refs = packfile_result.refs; + let heads_refs_prefix = self.ref_manager.heads_refs_prefix(); + let server_remote_refs = packfile_result + .refs + .into_iter() + .filter(|r| r.name.starts_with(&heads_refs_prefix)) + .collect::>(); if request.prune { - self.prune_remote_refs(&mut local_refs, &server_refs)?; + self.prune_remote_refs(&request.origin, &mut local_remote_refs, &server_remote_refs)?; } - let local_map: HashMap<&str, &RefEntry> = - local_refs.iter().map(|e| (e.name.as_str(), e)).collect(); + let refs_to_update = + self.find_refs_to_update(&request, &server_remote_refs, &local_remote_refs)?; - let refs_to_update: Vec = server_refs - .into_iter() - .filter(|server| match local_map.get(server.name.as_str()) { - Some(local) => local.commit_hash != server.commit_hash, - None => true, - }) - .collect(); - - self.process_packfile(&packfile_result.packfile_data)?; - self.update_remote_refs(&refs_to_update)?; + if refs_to_update.is_empty() { + println!("Already up to date."); + } else { + self.process_packfile(&packfile_result.packfile_data)?; + self.update_remote_refs(&request.origin, &refs_to_update)?; + } - Ok(Response {}) // TODO: można tutaj coś zwrócić? + Ok(Response {}) } } diff --git a/engine/src/handlers/remote/handlers.rs b/engine/src/handlers/remote/handlers.rs index 6647aae..a0c0af2 100644 --- a/engine/src/handlers/remote/handlers.rs +++ b/engine/src/handlers/remote/handlers.rs @@ -3,7 +3,7 @@ use std::{collections::HashMap, path::PathBuf, sync::Arc}; use async_trait::async_trait; use crate::{ - ConfigLoader, RepositoryLayout, + ConfigLoader, errors::EngineResult, network::{RemotesManager, SshConnectionParams, SshService}, ref_manager::{RefEntry, RefManager}, @@ -20,7 +20,6 @@ pub struct RemoteHandler { remotes_manager: Arc, config_loader: Arc, ref_manager: Arc, - layout: Arc, ssh_service: SshService, } @@ -29,13 +28,11 @@ impl RemoteHandler { pub fn new( remotes_manager: Arc, config_loader: Arc, - layout: Arc, ref_manager: Arc, ) -> Self { Self { remotes_manager, config_loader, - layout, ref_manager, ssh_service: SshService, } @@ -192,19 +189,8 @@ impl RemoteHandler { Vec, Vec, ) { - let refs_heads_prefix = format!( - "{}/{}/", - self.layout.refs_dir_name(), - self.layout.heads_dir_name() - ) - .to_string(); - - let refs_remotes_prefix = format!( - "{}/{}/", - self.layout.refs_dir_name(), - self.layout.remotes_dir_name() - ) - .to_string(); + let refs_heads_prefix = self.ref_manager.heads_refs_prefix(); + let refs_remotes_prefix = self.ref_manager.remotes_refs_prefix(); let (server_heads_map, server_head_hash) = self.build_server_map(server_refs, &refs_heads_prefix); diff --git a/engine/src/handlers/restore/handlers.rs b/engine/src/handlers/restore/handlers.rs index 3c4c0d2..9c974ce 100644 --- a/engine/src/handlers/restore/handlers.rs +++ b/engine/src/handlers/restore/handlers.rs @@ -160,7 +160,6 @@ impl RestoreHandler { .collect(); workdir_files.extend(collected_files); } - dbg!(&workdir_files); workdir_files } diff --git a/engine/src/network.rs b/engine/src/network.rs index e29be3d..85a2d42 100644 --- a/engine/src/network.rs +++ b/engine/src/network.rs @@ -8,7 +8,6 @@ mod ssh_service; mod ssh_session; use client_handler::ClientHandler; -use ssh_session::SshSession; pub use common::{ ChannelBand, PktLine, RemoteDirection, RemoteEntry, SessionExtension, create_channel_band, @@ -18,3 +17,4 @@ pub use packfiles::{PackfileCodec, PackfileError}; pub use remotes::{MevaRemotesManager, RemotesManager}; pub use ssh_connection_params::SshConnectionParams; pub use ssh_service::SshService; +pub use ssh_session::SshSession; diff --git a/engine/src/network/protocols/upload_pack.rs b/engine/src/network/protocols/upload_pack.rs index 31ad5fa..4be246a 100644 --- a/engine/src/network/protocols/upload_pack.rs +++ b/engine/src/network/protocols/upload_pack.rs @@ -1,6 +1,7 @@ mod upload_pack_result; mod upload_pack_state; +use itertools::Itertools; pub use upload_pack_result::UploadPackResult; pub use upload_pack_state::UploadPackState; @@ -231,6 +232,7 @@ impl UploadPackProtocol { .refs .iter() .map(|ref_entry| ref_entry.commit_hash.clone()) + .unique() .collect(); for sha in &self.wants { @@ -248,7 +250,7 @@ impl UploadPackProtocol { .data(have_line.as_bytes().as_ref()) .await .map_err(NetworkError::from)?; - println!("Sending: {have_line}"); + print!("Sending: {have_line}"); } channel diff --git a/engine/src/object_storage.rs b/engine/src/object_storage.rs index dbadc43..a6959a7 100644 --- a/engine/src/object_storage.rs +++ b/engine/src/object_storage.rs @@ -41,6 +41,9 @@ pub trait ObjectStorage: Send + Sync { /// Retrieves and deserializes a [`MevaObject`] from storage by its hash. fn get_object(&self, hash: &str) -> EngineResult; + /// Checks if an object with the given hash exists in storage. + fn object_exists(&self, hash: &str) -> EngineResult; + /// Returns a reference to the repository layout strategy used by this storage. fn layout(&self) -> &Arc; diff --git a/engine/src/object_storage/meva_dry_run_object_storage.rs b/engine/src/object_storage/meva_dry_run_object_storage.rs index fdc5ae5..47ce6e1 100644 --- a/engine/src/object_storage/meva_dry_run_object_storage.rs +++ b/engine/src/object_storage/meva_dry_run_object_storage.rs @@ -71,4 +71,8 @@ impl ObjectStorage for MevaDryRunObjectStorage { ) -> EngineResult> { unimplemented!() } + + fn object_exists(&self, _hash: &str) -> EngineResult { + unimplemented!() + } } diff --git a/engine/src/object_storage/meva_object_storage.rs b/engine/src/object_storage/meva_object_storage.rs index 0c79f03..9c447f6 100644 --- a/engine/src/object_storage/meva_object_storage.rs +++ b/engine/src/object_storage/meva_object_storage.rs @@ -223,4 +223,9 @@ impl ObjectStorage for MevaObjectStorage { Ok(objects_to_pack) } + + fn object_exists(&self, hash: &str) -> EngineResult { + let path = self.object_path(hash); + Ok(path.try_exists()?) + } } diff --git a/engine/src/ref_manager.rs b/engine/src/ref_manager.rs index 77d83b0..093c760 100644 --- a/engine/src/ref_manager.rs +++ b/engine/src/ref_manager.rs @@ -92,6 +92,12 @@ pub trait RefManager: Send + Sync { /// them from local development branches. fn remotes_refs_prefix(&self) -> String; + /// Maps a local head reference name to its corresponding remote-tracking reference name. + fn map_head_to_remote_ref(&self, head_ref: &str, remote_name: &str) -> String; + + /// Maps a remote-tracking reference name to its corresponding local head reference name. + fn map_remote_to_head_ref(&self, remote_ref: &str, remote_name: &str) -> String; + /// Attempts to find a branch reference by its short name and categorizes it. /// /// This helper method looks up the reference in standard locations to create diff --git a/engine/src/ref_manager/meva_ref_manager.rs b/engine/src/ref_manager/meva_ref_manager.rs index 0178cd3..2c3fcee 100644 --- a/engine/src/ref_manager/meva_ref_manager.rs +++ b/engine/src/ref_manager/meva_ref_manager.rs @@ -157,6 +157,24 @@ impl RefManager for MevaRefManager { ) } + fn map_head_to_remote_ref(&self, head_ref: &str, remote_name: &str) -> String { + let heads_prefix = self.heads_refs_prefix(); + let remotes_prefix = self.remotes_refs_prefix(); + let short_name = head_ref.trim_start_matches(&heads_prefix); + + format!("{remotes_prefix}{remote_name}/{short_name}") + } + + fn map_remote_to_head_ref(&self, remote_ref: &str, remote_name: &str) -> String { + let heads_prefix = self.heads_refs_prefix(); + let remotes_prefix = self.remotes_refs_prefix(); + + let specific_remote_base = format!("{remotes_prefix}{remote_name}"); + let short_name = remote_ref.trim_start_matches(&specific_remote_base); + + format!("{heads_prefix}{short_name}") + } + fn resolve_branch_ref(&self, branch: &str) -> EngineResult> { let local = format!("{}{branch}", self.heads_refs_prefix(),); let remote = format!("{}{branch}", self.remotes_refs_prefix(),); diff --git a/server/src/server_handler.rs b/server/src/server_handler.rs index 8dda04a..a759328 100644 --- a/server/src/server_handler.rs +++ b/server/src/server_handler.rs @@ -211,16 +211,29 @@ impl ServerHandler { info!("(UploadPack) Client 'wants': {:?}", upload_state.wants); info!("(UploadPack) Client 'haves': {:?}", upload_state.haves); - if upload_state.haves.is_empty() { - let nak = create_pkt_line("NAK"); - session.send_pkt_line(channel, &nak); - } - let repository_layout = Arc::new(MevaRepositoryLayout::new( upload_state.repository_path.clone(), )?); let object_storage = MevaObjectStorage::new(repository_layout); + let mut common_ancestor_found = false; + + for have in &upload_state.haves { + if object_storage.object_exists(have)? { + let ack = create_pkt_line("ACK"); + session.send_pkt_line(channel, &ack); + info!("(UploadPack) Found common ancestor: {have}. Sending ACK."); + common_ancestor_found = true; + break; + } + } + + if !common_ancestor_found { + let nak = create_pkt_line("NAK"); + session.send_pkt_line(channel, &nak); + info!("(UploadPack) No common ancestor found. Sending NAK."); + } + let objects = object_storage.collect_reachable_objects(&upload_state.wants, &upload_state.haves)?; let objects_count = objects.len(); From e17d17ef0d11bb12e6c91f237dcc7d1ca3c387de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:52:21 +0100 Subject: [PATCH 25/42] Command `clone` improvements (#35) --- cli/src/commands/clone.rs | 7 +- .../src/branch_manager/meva_branch_manager.rs | 6 +- engine/src/engine_container.rs | 8 +- engine/src/errors/network_error.rs | 15 +- engine/src/handlers/clone/handlers.rs | 72 ++++-- engine/src/handlers/clone/operations.rs | 21 +- engine/src/handlers/fetch/handlers.rs | 2 +- engine/src/ref_manager.rs | 2 +- engine/src/ref_manager/meva_ref_manager.rs | 2 +- engine/src/ref_manager/ref_entry.rs | 10 + engine/src/repositories/meva_repository.rs | 216 ++++++++++++++---- 11 files changed, 279 insertions(+), 82 deletions(-) diff --git a/cli/src/commands/clone.rs b/cli/src/commands/clone.rs index c4b0d92..1da87ee 100644 --- a/cli/src/commands/clone.rs +++ b/cli/src/commands/clone.rs @@ -106,7 +106,7 @@ impl MevaCommand for CloneCommand { .unwrap() .clone(), directory: matches.get_one::(Self::ARG_DIRECTORY).cloned(), - origin: matches.get_one::(Self::ARG_ORIGIN).cloned(), + origin: matches.get_one::(Self::ARG_ORIGIN).unwrap().clone(), server_key: matches .get_one::(Self::ARG_SERVER_KEY) .unwrap() @@ -115,7 +115,10 @@ impl MevaCommand for CloneCommand { }; let handler = container.clone_handler().into_diagnostic()?; - let _response = handler.handle_clone(request).await.into_diagnostic()?; + let response = handler.handle_clone(request).await.into_diagnostic()?; + + print!("{response}"); + Ok(()) } } diff --git a/engine/src/branch_manager/meva_branch_manager.rs b/engine/src/branch_manager/meva_branch_manager.rs index a1779b7..d1f4dfc 100644 --- a/engine/src/branch_manager/meva_branch_manager.rs +++ b/engine/src/branch_manager/meva_branch_manager.rs @@ -75,7 +75,7 @@ impl MevaBranchManager { name: head.target.clone(), commit_hash: hash.to_string(), }; - self.ref_manager.update_ref(ref_entry)?; + self.ref_manager.update_ref(&ref_entry)?; } } @@ -225,7 +225,7 @@ impl BranchManager for MevaBranchManager { fn create_branch(&self, branch: &Branch) -> EngineResult<()> { let ref_entry = RefEntry::new_branch_entry(branch); - self.ref_manager.update_ref(ref_entry) + self.ref_manager.update_ref(&ref_entry) } fn create_branches(&self, branch: &[Branch]) -> EngineResult<()> { @@ -325,7 +325,7 @@ impl BranchManager for MevaBranchManager { }; let new_branch_ref = RefEntry::new_branch_entry(&new_branch); - self.ref_manager.update_ref(new_branch_ref)?; + self.ref_manager.update_ref(&new_branch_ref)?; self.ref_manager .remove_ref(&old_branch_ref.ref_entry.name)?; diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 9a82d4c..9ed56e5 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -121,7 +121,13 @@ impl EngineContainer for MevaContainer { let layout = self.repository_layout_env()?; let config_loader = Arc::new(MevaConfigLoader::default()); let object_storage = Arc::new(MevaObjectStorage::new(layout.clone())); - let repository = Arc::new(MevaRepository::new(layout, config_loader, object_storage)); + let ref_manager = Arc::new(MevaRefManager::new(layout.clone())); + let repository = Arc::new(MevaRepository::new( + layout, + config_loader, + object_storage, + ref_manager, + )); let handler = InitHandler { repository }; Ok(handler) } diff --git a/engine/src/errors/network_error.rs b/engine/src/errors/network_error.rs index cdc8787..2735781 100644 --- a/engine/src/errors/network_error.rs +++ b/engine/src/errors/network_error.rs @@ -89,10 +89,23 @@ pub enum NetworkError { message: String, }, - /// Represents an explicit error message returned when the server is unreachable. + /// Represents a failure to establish a connection within the allotted time. + /// + /// This typically indicates network issues or that the server is down/unreachable. #[error("Connection timed out. The server is unreachable.")] ConnectionTimeout, + /// Indicates that the network stream ended unexpectedly. + /// + /// Occurs when the server closes the connection before the protocol negotiation + /// or data transfer has completed (e.g., partial packfile download). #[error("Connection closed by server before protocol completed.")] ConnectionClosedPrematurely, + + /// Indicates that the remote repository does not have a valid HEAD reference. + /// + /// This usually means the remote repository is empty or corrupt, preventing + /// operations like `clone` from determining the default branch. + #[error("HEAD not found in remote repository.")] + RemoteHeadNotFound, } diff --git a/engine/src/handlers/clone/handlers.rs b/engine/src/handlers/clone/handlers.rs index f27fc64..c1bd622 100644 --- a/engine/src/handlers/clone/handlers.rs +++ b/engine/src/handlers/clone/handlers.rs @@ -10,10 +10,10 @@ use tempfile::TempDir; use crate::{ ConfigLoader, MevaConfigLoader, MevaRepository, RepositoryLayout, errors::{CloneError, EngineError, EngineResult, NetworkError}, - network::{PackfileCodec, SshConnectionParams, SshService}, + network::{PackfileCodec, SshConnectionParams, SshService, SshSession}, object_storage::MevaObjectStorage, objects::MevaObject, - ref_manager::RefEntry, + ref_manager::{MevaRefManager, RefEntry}, repositories::{meva_repository::Repository, meva_repository_layout::MevaRepositoryLayout}, }; @@ -27,6 +27,7 @@ pub struct CloneHandler { } impl CloneHandler { + /// Creates a new instance of [`CloneHandler`]. pub fn new( repository_layout: Arc, config_loader: Arc, @@ -38,16 +39,26 @@ impl CloneHandler { } } + /// Handles an incoming clone request. + /// + /// This is the entry point for the handler, delegating to the internal logic. pub async fn handle_clone(&self, request: Request) -> EngineResult { self.clone(request).await } + /// Retrieves the user's signing key path from the configuration. + /// + /// Returns a `PathBuf` derived from the `user.signing_key` config value. fn get_user_signing_key(&self) -> EngineResult { Ok(PathBuf::from( self.config_loader.get("user.signing_key", None)?, )) } + /// Resolves the local destination path for the cloned repository. + /// + /// If a directory is specified in the request, it is used. Otherwise, the + /// directory name is derived from the repository name in the URL. fn resolve_clone_path( &self, request: &Request, @@ -65,39 +76,57 @@ impl CloneHandler { } } + /// Establishes an SSH connection and prepares the environment for cloning. + async fn connect_ssh( + &self, + request: &Request, + ) -> EngineResult<(SshSession, SshConnectionParams, PathBuf)> { + let mut connection_params = SshConnectionParams::try_from(&request.url)?; + + let path = self.resolve_clone_path(request, &connection_params)?; + if path.exists() && path.read_dir()?.next().is_some() { + return Err(CloneError::InvalidCloneDir { path }.into()); + } + + let client_key = self.get_user_signing_key()?; + connection_params.client_key_path = client_key; + connection_params.server_key_path = request.server_key.clone(); + + let ssh_session = self.ssh_service.connect(&connection_params).await?; + + Ok((ssh_session, connection_params, path)) + } + + /// Performs the repository initialization and population within a temporary directory. + /// + /// This creates the necessary repository structure (layout, object storage, ref manager) + /// and populates it with the downloaded objects and references. async fn temp_clone( &self, temp_path: &Path, objects: &[(MevaObject, Vec)], refs: &[RefEntry], - ) -> EngineResult { + remote_name: &str, + ) -> EngineResult> { let repository_layout = Arc::new(MevaRepositoryLayout::new(temp_path.to_path_buf())?); let object_storage = Arc::new(MevaObjectStorage::new(repository_layout.clone())); + let ref_manager = Arc::new(MevaRefManager::new(repository_layout.clone())); let repository = MevaRepository::new( repository_layout.clone(), Arc::new(MevaConfigLoader::default()), object_storage, + ref_manager, ); - repository.clone(objects, refs) + repository.clone(objects, refs, remote_name) } } #[async_trait] impl CloneOperations for CloneHandler { + /// Orchestrates the complete cloning process. async fn clone(&self, request: Request) -> EngineResult { - let mut connection_params = SshConnectionParams::try_from(&request.url)?; - - let path = self.resolve_clone_path(&request, &connection_params)?; - if path.exists() && path.read_dir()?.next().is_some() { - return Err(CloneError::InvalidCloneDir { path }.into()); - } - - let client_key = self.get_user_signing_key()?; - connection_params.client_key_path = client_key; - connection_params.server_key_path = request.server_key; - - let mut ssh_session = self.ssh_service.connect(&connection_params).await?; + let (mut ssh_session, connection_params, path) = self.connect_ssh(&request).await?; let packfile_result = ssh_session .run_upload_pack(&connection_params.repository_name, Vec::new()) @@ -116,15 +145,14 @@ impl CloneOperations for CloneHandler { let temp_dir = TempDir::new_in(parent_dir).map_err(EngineError::Io)?; let temp_path = temp_dir.path().to_path_buf(); - let clone_result = self.temp_clone(&temp_path, &objects, &refs).await; + let clone_result = self + .temp_clone(&temp_path, &objects, &refs, &request.origin) + .await; match clone_result { - Ok(_) => { + Ok(ref_entries) => { fs::rename(&temp_path, &path)?; - if !request.quiet { - println!("Successfully cloned into {path:?}"); - } - Ok(Response {}) + Ok(Response { path, ref_entries }) } Err(e) => { eprintln!("Clone failed, cleaning up temporary directory..."); diff --git a/engine/src/handlers/clone/operations.rs b/engine/src/handlers/clone/operations.rs index 4fe36d4..a2032c5 100644 --- a/engine/src/handlers/clone/operations.rs +++ b/engine/src/handlers/clone/operations.rs @@ -1,20 +1,33 @@ -use std::path::PathBuf; +use std::{fmt::Display, path::PathBuf}; use async_trait::async_trait; +use shared::PathToString; use url::Url; -use crate::errors::EngineResult; +use crate::{errors::EngineResult, ref_manager::RefEntry}; pub struct Request { pub url: Url, pub directory: Option, - pub origin: Option, + pub origin: String, pub server_key: PathBuf, pub quiet: bool, } pub struct Response { - // TODO + pub path: PathBuf, + pub ref_entries: Vec, +} + +impl Display for Response { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Cloned repository into: {}", self.path.to_utf8_string())?; + writeln!(f, "References:")?; + for entry in &self.ref_entries { + writeln!(f, " {entry}")?; + } + Ok(()) + } } #[async_trait] diff --git a/engine/src/handlers/fetch/handlers.rs b/engine/src/handlers/fetch/handlers.rs index 880b095..8130689 100644 --- a/engine/src/handlers/fetch/handlers.rs +++ b/engine/src/handlers/fetch/handlers.rs @@ -131,7 +131,7 @@ impl FetchHandler { println!("Updating {} -> {}", tracking_name, remote_ref.commit_hash); let new_entry = RefEntry::new(&tracking_name, &remote_ref.commit_hash); - self.ref_manager.update_ref(new_entry)?; + self.ref_manager.update_ref(&new_entry)?; } Ok(()) } diff --git a/engine/src/ref_manager.rs b/engine/src/ref_manager.rs index 093c760..c2e45e2 100644 --- a/engine/src/ref_manager.rs +++ b/engine/src/ref_manager.rs @@ -114,7 +114,7 @@ pub trait RefManager: Send + Sync { fn resolve_branch_ref(&self, branch: &str) -> EngineResult>; /// Updates or creates a named reference with the provided [`RefEntry`] value. - fn update_ref(&self, entry: RefEntry) -> EngineResult<()>; + fn update_ref(&self, entry: &RefEntry) -> EngineResult<()>; /// Removes a named reference from the repository. /// diff --git a/engine/src/ref_manager/meva_ref_manager.rs b/engine/src/ref_manager/meva_ref_manager.rs index 2c3fcee..1d7bbe3 100644 --- a/engine/src/ref_manager/meva_ref_manager.rs +++ b/engine/src/ref_manager/meva_ref_manager.rs @@ -207,7 +207,7 @@ impl RefManager for MevaRefManager { Ok(None) } - fn update_ref(&self, entry: RefEntry) -> EngineResult<()> { + fn update_ref(&self, entry: &RefEntry) -> EngineResult<()> { let path = self.repo_layout.repository_dir().join(&entry.name); let parent = path diff --git a/engine/src/ref_manager/ref_entry.rs b/engine/src/ref_manager/ref_entry.rs index 5441749..928a3bb 100644 --- a/engine/src/ref_manager/ref_entry.rs +++ b/engine/src/ref_manager/ref_entry.rs @@ -1,5 +1,8 @@ +use std::fmt::Display; + use crate::branch_manager::{Branch, BranchType}; use crate::serialize_deserialize::MevaEncode; +use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; /// Represents a branch reference entry within the Meva repository. @@ -72,3 +75,10 @@ impl RefEntry { } impl MevaEncode for RefEntry {} + +impl Display for RefEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let short_hash = self.commit_hash.chars().take(7).collect::(); + write!(f, "{} ({})", self.name.green(), short_hash.dimmed()) + } +} diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index 85eee84..9131837 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -7,7 +7,7 @@ use std::{ use tempfile::TempDir; -use crate::{EngineResult, InitError}; +use crate::{EngineResult, InitError, errors::NetworkError, ref_manager::RefManager}; use crate::{ config::config_loader::ConfigLoader, ref_manager::{Head, HeadMode}, @@ -31,7 +31,12 @@ pub trait Repository: Send + Sync { /// This method is typically called after receiving a packfile from a remote server. /// It initializes the storage, writes all received objects, and sets up the /// local references to match the remote state. - fn clone(&self, objects: &[(MevaObject, Vec)], refs: &[RefEntry]) -> EngineResult; + fn clone( + &self, + objects: &[(MevaObject, Vec)], + refs: &[RefEntry], + remote_name: &str, + ) -> EngineResult>; /// Returns the layout strategy used by this repository. /// @@ -52,6 +57,9 @@ pub struct MevaRepository { /// Backend storage for repository objects (blobs, trees, commits). pub object_storage: Arc, + + /// Manager for branch and reference handling within the repository. + pub ref_manager: Arc, } impl MevaRepository { @@ -60,11 +68,13 @@ impl MevaRepository { layout: Arc, config_loader: Arc, object_storage: Arc, + ref_manager: Arc, ) -> Self { Self { layout, config_loader, object_storage, + ref_manager, } } @@ -107,12 +117,8 @@ impl MevaRepository { let head_path = root.join(self.layout.head_file_rel()); let mut head_file = fs::File::create(&head_path)?; - let target_ref = format!( - "{}/{}/{}", - self.layout.refs_dir_name(), - self.layout.heads_dir_name(), - branch_name - ); + let heads_refs_prefix = self.ref_manager.heads_refs_prefix(); + let target_ref = format!("{heads_refs_prefix}{branch_name}"); let head = Head { mode: HeadMode::Symbolic, @@ -136,33 +142,129 @@ impl MevaRepository { root: &Path, branch_name: &str, commit_hash: Option<&str>, - ) -> EngineResult<()> { - let ref_path = root - .join(self.layout.heads_refs_dir_rel()) - .join(branch_name); - - create_file_with_dirs(&ref_path)?; - - if let Some(hash) = commit_hash { - let full_ref_name = format!( - "{}/{}/{}", - self.layout.refs_dir_name(), - self.layout.heads_dir_name(), - branch_name - ); + create_head: bool, + remote_name: Option<&str>, + ) -> EngineResult> { + let heads_refs_prefix = self.ref_manager.heads_refs_prefix(); + let branch_name = branch_name.trim_start_matches(&heads_refs_prefix); + let commit_hash = commit_hash.unwrap_or(""); + + let mut ref_entries = Vec::new(); + + if create_head { + let full_ref_name = format!("{heads_refs_prefix}{branch_name}"); + let entry = RefEntry::new(&full_ref_name, commit_hash); - let entry = RefEntry::new(&full_ref_name, hash); + let ref_path = root + .join(self.layout.heads_refs_dir_rel()) + .join(branch_name); + + if let Some(parent) = ref_path.parent() { + fs::create_dir_all(parent)?; + } fs::write(&ref_path, entry.to_json()?)?; - } else { - fs::File::create(&ref_path)?; + + let log_path = root + .join(self.layout.heads_logs_dir_rel()) + .join(branch_name); + create_file_with_dirs(&log_path)?; + + ref_entries.push(entry); } - let log_path = root - .join(self.layout.heads_logs_dir_rel()) - .join(branch_name); - create_file_with_dirs(&log_path)?; + if let Some(remote) = remote_name { + let remotes_refs_prefix = self.ref_manager.remotes_refs_prefix(); + let full_remote_ref_name = format!("{remotes_refs_prefix}{remote}/{branch_name}"); + let entry = RefEntry::new(&full_remote_ref_name, commit_hash); - Ok(()) + let ref_path = root + .join(self.layout.remotes_refs_dir_rel()) + .join(remote) + .join(branch_name); + + if let Some(parent) = ref_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&ref_path, entry.to_json()?)?; + + ref_entries.push(entry); + } + + Ok(ref_entries) + } + + /// Determines which branch should be checked out after a clone. + /// + /// It attempts to resolve the remote `HEAD` reference to a branch name. + /// If an exact match isn't found, it defaults to standard names like `master` or `main`. + fn determine_initial_branch<'a>( + &self, + refs: &'a [RefEntry], + heads_refs_prefix: &str, + ) -> EngineResult<(&'a str, &'a str)> { + let head_hash = refs + .iter() + .find(|r| r.name == self.layout.head_file_name()) + .map(|r| r.commit_hash.as_str()) + .ok_or_else(|| NetworkError::RemoteHeadNotFound)?; + let candidates: Vec<&str> = refs + .iter() + .filter(|r| r.commit_hash == head_hash && r.name.starts_with(heads_refs_prefix)) + .map(|r| r.name.as_str()) + .collect(); + let initial_branch = candidates + .iter() + .find(|name| name.ends_with("/master")) + .cloned() + .or_else(|| { + candidates + .iter() + .find(|name| name.ends_with("/main")) + .cloned() + }) + .or_else(|| candidates.first().cloned()) + .ok_or_else(|| NetworkError::RemoteError { + message: "Remote HEAD points to a commit not reachable by any branch".into(), + })?; + + Ok((initial_branch, head_hash)) + } + + /// Iterates through the references received during clone and creates local files for them. + /// + /// This handles creating both the local branch for the initial checkout and + /// the remote tracking branches for all other references. + fn create_local_and_remote_refs( + &self, + working_dir: &Path, + refs: &[RefEntry], + heads_prefix: &str, + initial_branch: &str, + remote_name: &str, + ) -> EngineResult> { + let mut created_refs = Vec::new(); + + for ref_entry in refs { + if ref_entry.name == self.layout.head_file_name() + || !ref_entry.name.starts_with(heads_prefix) + { + continue; + } + + let create_local_head = ref_entry.name == initial_branch; + + let refs = self.create_branch_ref( + working_dir, + &ref_entry.name, + Some(&ref_entry.commit_hash), + create_local_head, + Some(remote_name), + )?; + + created_refs.extend(refs); + } + + Ok(created_refs) } } @@ -184,7 +286,7 @@ impl Repository for MevaRepository { let tmp_working_dir = tmp_dir.path(); self.initialize_structure(tmp_working_dir)?; - self.create_branch_ref(tmp_working_dir, branch_name, None)?; + self.create_branch_ref(tmp_working_dir, branch_name, None, true, None)?; self.setup_head(tmp_working_dir, branch_name)?; let tmp_repo_dir = tmp_working_dir.join(self.layout.repository_dir_name()); @@ -195,28 +297,48 @@ impl Repository for MevaRepository { } /// Clones a repository from provided data. - fn clone(&self, objects: &[(MevaObject, Vec)], refs: &[RefEntry]) -> EngineResult { + /// + /// This process involves: + /// 1. Creating the repository structure. + /// 2. Populating the object database (blobs/commits) from the packfile. + /// 3. Determining the correct HEAD branch. + /// 4. Creating references (local and remote). + /// 5. Setting up HEAD. + fn clone( + &self, + objects: &[(MevaObject, Vec)], + refs: &[RefEntry], + remote_name: &str, + ) -> EngineResult> { self.check_if_exists()?; let working_dir = self.layout.working_dir(); - self.initialize_structure(&working_dir)?; - self.object_storage.add_objects_from_packfile(objects)?; - let initial_ref = refs - .iter() - .find(|r| r.name.ends_with("/master") || r.name.ends_with("/main")) - .or_else(|| refs.first()) - .unwrap(); - let branch_name = initial_ref.name.rsplit('/').next().unwrap_or("master"); + if !objects.is_empty() { + self.object_storage.add_objects_from_packfile(objects)?; + } + + let heads_refs_prefix = self.ref_manager.heads_refs_prefix(); + + let (initial_branch_name, _) = self.determine_initial_branch(refs, &heads_refs_prefix)?; + + let ref_entries = self.create_local_and_remote_refs( + &working_dir, + refs, + &heads_refs_prefix, + initial_branch_name, + remote_name, + )?; - self.create_branch_ref(&working_dir, branch_name, Some(&initial_ref.commit_hash))?; - self.setup_head(&working_dir, branch_name)?; + self.setup_head( + &working_dir, + initial_branch_name.trim_start_matches(&heads_refs_prefix), + )?; - // TODO: clone reszty referencji - // TODO: checkout (przywrócenie working dir) + // TODO: checkout (restore working dir) - Ok(self.layout.repository_dir()) + Ok(ref_entries) } fn layout(&self) -> &Arc { @@ -228,6 +350,7 @@ impl Repository for MevaRepository { mod tests { use crate::MevaConfigLoader; use crate::object_storage::MevaObjectStorage; + use crate::ref_manager::MevaRefManager; use crate::repositories::meva_repository_layout::MevaRepositoryLayout; use super::*; @@ -251,7 +374,8 @@ mod tests { Arc::new(MevaRepositoryLayout::new(path.to_path_buf()).expect("should not fail")); let config_loader = Arc::new(MevaConfigLoader::default()); let object_storage = Arc::new(MevaObjectStorage::new(layout.clone())); - MevaRepository::new(layout, config_loader, object_storage) + let ref_manager = Arc::new(MevaRefManager::new(layout.clone())); + MevaRepository::new(layout, config_loader, object_storage, ref_manager) } #[rstest] From 77bfb39d018bd75907137ecb5754f80445af4c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Wed, 17 Dec 2025 18:16:17 +0100 Subject: [PATCH 26/42] Issues #27, #17 and #16 (#36) --- Cargo.lock | 2 + cli/src/commands/config/subcommands/list.rs | 10 ++- cli/src/commands/config/subcommands/set.rs | 8 ++- cli/src/commands/plugins/subcommands/edit.rs | 5 +- cli/src/commands/plugins/subcommands/info.rs | 5 +- cli/src/commands/plugins/subcommands/list.rs | 5 +- .../commands/plugins/subcommands/register.rs | 70 ++++++------------- .../plugins/subcommands/unregister.rs | 9 ++- cli/src/extensions/command/with_scope.rs | 6 +- .../plugins/examples/bash/infinite_counter.sh | 34 +++++++++ docs/plugins/examples/bash/nonzero_exit.sh | 32 +++++++++ docs/plugins/examples/bash/print_arg.sh | 40 +++++++++++ docs/plugins/examples/bash/write_error.sh | 61 ++++++++++++++++ engine/src/handlers/plugins/handlers.rs | 13 +++- engine/src/handlers/plugins/operations.rs | 20 ++++-- engine/src/plugins_interceptor.rs | 2 +- plugins/Cargo.toml | 1 + plugins/src/enums/command_type.rs | 25 +++++++ plugins/src/enums/invocation_payload.rs | 13 +--- plugins/src/lib.rs | 2 +- plugins/src/models.rs | 3 +- plugins/src/models/payloads/add_payload.rs | 20 +++++- plugins/src/models/plugin_configuration.rs | 44 +++++++----- plugins/src/plugins_discovery.rs | 3 +- plugins/src/plugins_engine.rs | 2 +- shared/Cargo.toml | 1 + shared/src/pretty_field.rs | 7 +- 27 files changed, 334 insertions(+), 109 deletions(-) create mode 100644 docs/plugins/examples/bash/infinite_counter.sh create mode 100644 docs/plugins/examples/bash/nonzero_exit.sh create mode 100644 docs/plugins/examples/bash/print_arg.sh create mode 100644 docs/plugins/examples/bash/write_error.sh diff --git a/Cargo.lock b/Cargo.lock index f6b0e64..627a58b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1741,6 +1741,7 @@ version = "0.1.0" dependencies = [ "chrono", "mockall", + "owo-colors", "pretty_assertions", "rstest", "serde", @@ -2384,6 +2385,7 @@ version = "0.1.0" dependencies = [ "editor-command", "mockall", + "owo-colors", "path-absolutize", "pretty_assertions", "rstest", diff --git a/cli/src/commands/config/subcommands/list.rs b/cli/src/commands/config/subcommands/list.rs index 3351105..6f571e4 100644 --- a/cli/src/commands/config/subcommands/list.rs +++ b/cli/src/commands/config/subcommands/list.rs @@ -4,6 +4,7 @@ use engine::EngineContainer; use engine::engine_container::MevaContainer; use engine::handlers::config::ListRequest; use miette::IntoDiagnostic; +use owo_colors::OwoColorize; use crate::commands::MevaCommand; use crate::extensions::{LocationSelection, WithLocations}; @@ -63,7 +64,10 @@ impl MevaCommand for ConfigListCommand { .into_diagnostic()?; if response.key_values.is_empty() { - println!("Config file has no key-value pairs to display.") + println!( + "{}", + "Config file has no key-value pairs to display.".yellow() + ); } let max_key_len = response @@ -74,7 +78,9 @@ impl MevaCommand for ConfigListCommand { .unwrap_or(0); for (key, value) in response.key_values { - println!("{key:max_key_len$} = {value}"); + let padded_key = format!("{key: Command { self.build_base_command() + .with_command_and_plugin_arg("Command type (kebab-case)", "Plugin name") .arg( Arg::new(Self::ARG_PATH) .value_name("PATH") - .index(1) + .index(3) .required(true) .value_parser(clap::value_parser!(PathBuf)) .value_hint(ValueHint::FilePath) .help("Path to the script to register"), ) .with_file_arg("Relative path to the file") - .arg( - Arg::new(Self::ARG_SCOPE) - .short('s') - .long(Self::ARG_SCOPE) - .value_parser(PossibleValuesParser::new(ScopeType::VARIANTS)) - .help("Scope of the plugin"), - ) - .arg( - Arg::new(Self::ARG_NAME) - .short('n') - .long(Self::ARG_NAME) - .value_name("NAME") - .required(true) - .help("Plugin name"), - ) + .with_scope_arg("Scope of the plugin") .arg( Arg::new(Self::ARG_DESCRIPTION) .short('d') @@ -91,21 +77,12 @@ impl MevaCommand for PluginsRegisterCommand { .value_name("DESCRIPTION") .help("Plugin description"), ) - .arg( - Arg::new(Self::ARG_COMMAND) - .short('c') - .long(Self::ARG_COMMAND) - .value_name("COMMAND") - .required(true) - .value_parser(PossibleValuesParser::new(CommandType::VARIANTS)) - .help("Command type (kebab-case)"), - ) .arg( Arg::new(Self::ARG_EVENT) .short('e') .long(Self::ARG_EVENT) .value_name("EVENT") - .required(true) + .default_value("post-execute") .value_parser(PossibleValuesParser::new(EventType::VARIANTS)) .help("Event type (kebab-case)"), ) @@ -114,7 +91,7 @@ impl MevaCommand for PluginsRegisterCommand { .short('o') .long(Self::ARG_ORDER) .value_name("ORDER") - .required(true) + .default_value("1") .value_parser(clap::value_parser!(u32)) .help("Execution order (integer)"), ) @@ -124,7 +101,7 @@ impl MevaCommand for PluginsRegisterCommand { .long(Self::ARG_TIMEOUT) .value_name("TIMEOUT") .value_parser(clap::value_parser!(u64)) - .help("Execution timeout in miliseconds (integer)"), + .help("Execution timeout in milliseconds (integer)"), ) .arg( Arg::new(Self::ARG_DISABLED) @@ -148,22 +125,19 @@ impl MevaCommand for PluginsRegisterCommand { matches: &ArgMatches, container: &Self::Container, ) -> miette::Result<()> { - let command_str = matches.get_one::(Self::ARG_COMMAND).unwrap(); + let command_str = matches.get_one::(Command::ARG_COMMAND).unwrap(); let event_str = matches.get_one::(Self::ARG_EVENT).unwrap(); - let default_scope = ScopeType::Local.to_string(); - let scope_str = matches - .get_one::(Self::ARG_SCOPE) - .unwrap_or(&default_scope); + let scope_str = matches.get_one::(Command::ARG_SCOPE).unwrap(); let request = RegisterRequest { path: matches.get_one::(Self::ARG_PATH).unwrap().clone(), scope: scope_str.parse::().unwrap(), - name: matches.get_one::(Self::ARG_NAME).unwrap().clone(), - description: matches.get_one::(Self::ARG_DESCRIPTION).cloned(), - file: matches - .get_one::(Command::ARG_FILE) + name: matches + .get_one::(Command::ARG_PLUGIN) .unwrap() .clone(), + description: matches.get_one::(Self::ARG_DESCRIPTION).cloned(), + file: matches.get_one::(Command::ARG_FILE).cloned(), command: command_str.parse::().unwrap(), event: event_str.parse::().unwrap(), order: *matches.get_one::(Self::ARG_ORDER).unwrap(), @@ -176,14 +150,16 @@ impl MevaCommand for PluginsRegisterCommand { let response = handler.register(request).into_diagnostic()?; - println!("Plugin registered successfully!"); + println!("{}", "Plugin registered successfully!".green().bold()); + println!( "Source code copied to: {}", - response.plugin_source_file.display() + response.plugin_source_file.to_utf8_string().cyan() ); + println!( "Metadata saved at: {}", - response.plugins_metadata_file.display() + response.plugins_metadata_file.to_utf8_string().cyan() ); Ok(()) diff --git a/cli/src/commands/plugins/subcommands/unregister.rs b/cli/src/commands/plugins/subcommands/unregister.rs index 66fdff9..4ebaa0a 100644 --- a/cli/src/commands/plugins/subcommands/unregister.rs +++ b/cli/src/commands/plugins/subcommands/unregister.rs @@ -6,6 +6,7 @@ use engine::{ handlers::plugins::{PluginsOperations, UnregisterRequest}, }; use miette::IntoDiagnostic; +use owo_colors::OwoColorize; use plugins::{CommandType, ScopeType}; use crate::{ @@ -71,14 +72,16 @@ impl MevaCommand for PluginsUnregisterCommand { let response = handler.unregister(request).into_diagnostic()?; - println!("Plugin unregistered successfully!"); + println!("{}", "Plugin unregistered successfully!".green().bold()); + println!( "Removed entry from configuration file: {}", - response.plugins_metadata_file.display() + response.plugins_metadata_file.display().cyan() ); + println!( "Deleted source code file: {}", - response.plugin_source_file.display() + response.plugin_source_file.display().cyan() ); Ok(()) diff --git a/cli/src/extensions/command/with_scope.rs b/cli/src/extensions/command/with_scope.rs index 450fea3..097e384 100644 --- a/cli/src/extensions/command/with_scope.rs +++ b/cli/src/extensions/command/with_scope.rs @@ -31,6 +31,7 @@ impl WithScope for Command { Arg::new(Self::ARG_SCOPE) .short('s') .long(Self::ARG_SCOPE) + .default_value("local") // Uses the variants of `ScopeType` as allowed values .value_parser(PossibleValuesParser::new(ScopeType::VARIANTS)) .help(scope_help), @@ -81,6 +82,9 @@ mod tests { #[rstest] fn test_no_scope_provided(cmd: Command) { let matches = cmd.try_get_matches_from(vec!["cmd"]).unwrap(); - assert!(matches.get_one::("scope").is_none()); + assert_eq!( + matches.get_one::("scope"), + Some(&"local".to_string()) + ); } } diff --git a/docs/plugins/examples/bash/infinite_counter.sh b/docs/plugins/examples/bash/infinite_counter.sh new file mode 100644 index 0000000..77a5381 --- /dev/null +++ b/docs/plugins/examples/bash/infinite_counter.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +# Description: +# Example of Bash plugin which demonstrates: +# - printing messages to standard output (stdout) every 100 ms, +# - timeout behaviour. +# +# Invocation: +# chmod +x infinite_counter.sh +# ./infinite_counter.sh +# +# Expected behavior: +# - stdout: 0, 1, 2, 3, ... (printed continuously), +# - exit status: plugin will be terminated by the plugin runner when timeout is reached. +# +# Example configuration entry (JSON): +# { +# "name": "infinite_counter", +# "description": "Demonstrates plugin runner timeout behavior", +# "file": "infinite_counter.sh", +# "event": "pre-execute", +# "order": 1, +# "timeout": 500, +# "enabled": true, +# "interpreter": "bash" +# } + +i=0 + +while true; do + echo "$i" + ((i++)) + sleep 0.1 +done \ No newline at end of file diff --git a/docs/plugins/examples/bash/nonzero_exit.sh b/docs/plugins/examples/bash/nonzero_exit.sh new file mode 100644 index 0000000..133e23c --- /dev/null +++ b/docs/plugins/examples/bash/nonzero_exit.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +# Description: +# Example of Bash plugin which demonstrates: +# - printing a message to standard output (stdout), +# - printing a message to standard error (stderr), +# - exiting with a non-zero status code (3). +# +# Invocation: +# chmod +x nonzero_exit.sh +# ./nonzero_exit.sh +# +# Expected behavior: +# - stdout: "This is a message to stdout", +# - stderr: "This is a message to stderr", +# - exit status: 3. +# +# Example configuration entry (JSON): +# { +# "name": "nonzero_exit", +# "description": "Demonstrates exiting with a non-zero status code", +# "file": "nonzero_exit.sh", +# "event": "pre-execute", +# "order": 1, +# "timeout": null, +# "enabled": true, +# "interpreter": "bash" +# } + +echo "This is a message to stdout" +echo "This is a message to stderr" >&2 +exit 3 \ No newline at end of file diff --git a/docs/plugins/examples/bash/print_arg.sh b/docs/plugins/examples/bash/print_arg.sh new file mode 100644 index 0000000..884e47d --- /dev/null +++ b/docs/plugins/examples/bash/print_arg.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Description: +# Example Bash plugin which demonstrates: +# - reading a file specified as the first argument in the console, +# - printing its content to standard output (stdout), +# - handling file-not-found or read errors by printing to standard error (stderr). +# +# Invocation: +# chmod +x print_arg.sh +# ./print_arg.sh +# +# Expected behavior: +# - stdout: content of the specified file, +# - stderr: error messages if the file does not exist or cannot be read, +# - exit status: 0 on success, 1 on error. +# +# Example configuration entry (JSON): +# { +# "name": "print_arg", +# "description": null, +# "file": "print_arg.sh", +# "event": "pre-execute", +# "order": 1, +# "timeout": null, +# "enabled": true, +# "interpreter": "bash" +# } + +filename="$1" + +if [ ! -f "$filename" ]; then + echo "Error: File '$filename' not found." >&2 + exit 1 +fi + +cat "$filename" || { + echo "Error reading file '$filename'" >&2 + exit 1 +} \ No newline at end of file diff --git a/docs/plugins/examples/bash/write_error.sh b/docs/plugins/examples/bash/write_error.sh new file mode 100644 index 0000000..201cc38 --- /dev/null +++ b/docs/plugins/examples/bash/write_error.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +# Description: +# Example Bash plugin that writes a business logic error +# to the "error" field of a JSON file representing plugin execution results using 'jq'. +# +# Dependencies: +# Requires 'jq' to be installed (sudo apt install jq / brew install jq). +# +# Invocation: +# chmod +x write_error.sh +# ./write_error.sh +# +# Expected behavior: +# - The "error" field in the JSON file will be updated. +# - exit status: 1. +# +# Example configuration entry (JSON): +# { +# "name": "write_error", +# "description": null, +# "file": "write_error.sh", +# "event": "pre-execute", +# "order": 1, +# "timeout": null, +# "enabled": true, +# "interpreter": "bash" +# } + +filename="$1" + +if [ -z "$filename" ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +if [ ! -f "$filename" ]; then + echo "Error: File '$filename' not found." >&2 + exit 1 +fi + +if ! command -v jq &> /dev/null; then + echo "Error: 'jq' is not installed. Please install it to manipulate JSON files in Bash." >&2 + exit 1 +fi + +error_payload='{ + "code": "BUSINESS_LOGIC_ERROR", + "message": "A business rule was violated during plugin execution", + "details": "Optional additional context or stack trace" +}' + +if jq --argjson err "$error_payload" '.error = $err' "$filename" > "${filename}.tmp"; then + mv "${filename}.tmp" "$filename" + echo "Updated error in $filename" + exit 1 +else + echo "Error parsing or writing JSON." >&2 + rm -f "${filename}.tmp" + exit 1 +fi \ No newline at end of file diff --git a/engine/src/handlers/plugins/handlers.rs b/engine/src/handlers/plugins/handlers.rs index 0f62324..4c6abbf 100644 --- a/engine/src/handlers/plugins/handlers.rs +++ b/engine/src/handlers/plugins/handlers.rs @@ -1,6 +1,6 @@ use std::{env, path::PathBuf, sync::Arc}; -use plugins::{PluginConfiguration, PluginsRepository, ScopeType}; +use plugins::{PluginConfiguration, PluginError, PluginsRepository, RegisterError, ScopeType}; use shared::UpwardSearch; use super::{ @@ -50,10 +50,19 @@ impl PluginsOperations for PluginsHandler { fn register(&self, request: RegisterRequest) -> EngineResult { let plugins_dir = self.get_plugins_dir(&request.scope)?; + let resolved_file = request + .file + .or_else(|| request.path.file_name().map(PathBuf::from)) + .ok_or_else(|| { + PluginError::Register(RegisterError::InvalidFilePath { + path: request.path.clone(), + }) + })?; + let plugin_configuration = PluginConfiguration { name: request.name, description: request.description, - file: request.file, + file: resolved_file, event: request.event, order: request.order, timeout: request.timeout, diff --git a/engine/src/handlers/plugins/operations.rs b/engine/src/handlers/plugins/operations.rs index 5abfcfd..5951d9f 100644 --- a/engine/src/handlers/plugins/operations.rs +++ b/engine/src/handlers/plugins/operations.rs @@ -3,6 +3,7 @@ use std::{ path::PathBuf, }; +use owo_colors::OwoColorize; use plugins::{CommandType, EventType, PluginConfiguration, ScopeType}; use crate::errors::EngineResult; @@ -12,7 +13,7 @@ pub struct RegisterRequest { pub scope: ScopeType, pub name: String, pub description: Option, - pub file: PathBuf, + pub file: Option, pub command: CommandType, pub event: EventType, pub order: u32, @@ -52,13 +53,19 @@ pub struct ListResponse { impl Display for ListResponse { fn fmt(&self, f: &mut Formatter<'_>) -> Result { if self.plugins.is_empty() { - writeln!(f, "No plugins found.")?; + writeln!(f, "{}", "No plugins found.".yellow())?; return Ok(()); } for (i, (config, path)) in self.plugins.iter().enumerate() { - writeln!(f, "[{}] Source file: {}", i + 1, path.display())?; - writeln!(f, "Configuration:")?; + writeln!( + f, + "{} Source file: {}", + format!("[{}]", i + 1).green().bold(), + path.display().cyan() + )?; + + writeln!(f, "{}", "Configuration:".bold())?; let conf_str = format!("{config}"); for line in conf_str.lines() { @@ -86,8 +93,9 @@ pub struct InfoResponse { impl Display for InfoResponse { fn fmt(&self, f: &mut Formatter<'_>) -> Result { - writeln!(f, "Source file: {}", self.source_file.display())?; - writeln!(f, "Configuration:")?; + writeln!(f, "Source file: {}", self.source_file.display().cyan())?; + + writeln!(f, "{}", "Configuration:".bold())?; let conf_str = format!("{}", self.configuration); for line in conf_str.lines() { diff --git a/engine/src/plugins_interceptor.rs b/engine/src/plugins_interceptor.rs index c49df79..9186108 100644 --- a/engine/src/plugins_interceptor.rs +++ b/engine/src/plugins_interceptor.rs @@ -133,7 +133,7 @@ impl PluginsInterceptor { let mut runner = PluginsRunner::new( invocation_file.clone(), - plugins_dir.join(command.to_string()).clone(), + plugins_dir.join(command.to_path()).clone(), logger, ); diff --git a/plugins/Cargo.toml b/plugins/Cargo.toml index 126b783..1c9c9f6 100644 --- a/plugins/Cargo.toml +++ b/plugins/Cargo.toml @@ -13,6 +13,7 @@ chrono.workspace = true strum.workspace = true strum_macros.workspace = true wait-timeout = "0.1.5" +owo-colors.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/plugins/src/enums/command_type.rs b/plugins/src/enums/command_type.rs index 4fd7a2d..1e29ed0 100644 --- a/plugins/src/enums/command_type.rs +++ b/plugins/src/enums/command_type.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use serde::{Deserialize, Serialize}; use strum_macros::{Display, EnumString, VariantNames}; @@ -16,3 +18,26 @@ pub enum CommandType { Add, Commit, } + +impl CommandType { + pub fn to_path(&self) -> PathBuf { + self.to_string().split('-').collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_path_construction_logic() { + let cmd_init = CommandType::Init; + assert_eq!(cmd_init.to_path(), PathBuf::from("init")); + + let cmd_config = CommandType::ConfigGet; + let expected = PathBuf::from("config").join("get"); + + assert_eq!(cmd_config.to_path(), expected); + } +} diff --git a/plugins/src/enums/invocation_payload.rs b/plugins/src/enums/invocation_payload.rs index e2df3d3..83cd492 100644 --- a/plugins/src/enums/invocation_payload.rs +++ b/plugins/src/enums/invocation_payload.rs @@ -1,13 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::{ - AddPostPayload, AddPrePayload, CommitPostPayload, CommitPrePayload, InitPostPayload, - InitPrePayload, - models::{ - ConfigGetPostPayload, ConfigGetPrePayload, ConfigListPostPayload, ConfigListPrePayload, - ConfigSetPostPayload, ConfigSetPrePayload, ConfigUnsetPostPayload, ConfigUnsetPrePayload, - }, -}; +use crate::models::payloads::*; /// Represents the data passed to plugins /// before a command is executed. @@ -16,7 +9,7 @@ use crate::{ /// that is about to run. This allows plugins to inspect or /// modify data prior to execution. #[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(untagged)] +#[serde(tag = "type", rename_all = "kebab-case")] pub enum InvocationPrePayload { /// Context for the `init` command. Init(InitPrePayload), @@ -46,7 +39,7 @@ pub enum InvocationPrePayload { /// Each variant contains a payload specific to the command /// that has just completed. #[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(untagged)] +#[serde(tag = "type", rename_all = "kebab-case")] pub enum InvocationPostPayload { /// Context for the `init` command. Init(InitPostPayload), diff --git a/plugins/src/lib.rs b/plugins/src/lib.rs index 2769161..4971a74 100644 --- a/plugins/src/lib.rs +++ b/plugins/src/lib.rs @@ -9,7 +9,7 @@ mod plugins_runner; pub mod models; pub use enums::{CommandType, EventType, InvocationPostPayload, InvocationPrePayload, ScopeType}; -pub use errors::PluginError; +pub use errors::{PluginError, RegisterError}; pub use layout::{MevaPluginsLayout, PluginsLayout}; pub use models::*; pub use plugins_discovery::MevaPluginsDiscovery; diff --git a/plugins/src/models.rs b/plugins/src/models.rs index de34346..9ccbee5 100644 --- a/plugins/src/models.rs +++ b/plugins/src/models.rs @@ -1,9 +1,10 @@ mod invocation; -mod payloads; mod plugin_configuration; mod plugin_entry; mod plugins_configuration; +pub mod payloads; + pub use invocation::{InvocationContext, InvocationInput, InvocationLogger, InvocationOutput}; pub use payloads::*; pub use plugin_configuration::PluginConfiguration; diff --git a/plugins/src/models/payloads/add_payload.rs b/plugins/src/models/payloads/add_payload.rs index 012da06..26dd4cb 100644 --- a/plugins/src/models/payloads/add_payload.rs +++ b/plugins/src/models/payloads/add_payload.rs @@ -1,21 +1,37 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; -/// Payload provided to plugins **before** the `meva add` command execution +/// Payload provided to plugins **before** the `meva add` command execution. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct AddPrePayload { + /// Indicates whether the `--all` flag was passed. pub all_flag: bool, + + /// Indicates whether the `--force` flag was passed. pub force_flag: bool, + + /// Indicates whether the `--update` flag was passed. pub update_flag: bool, + + /// Indicates whether the `--dry-run` flag was passed. pub dry_run_flag: bool, + + /// Indicates whether the `--verbose` flag was passed. pub verbose_flag: bool, + + /// The optional path argument provided to the command. pub path_arg: Option, } -/// Payload provided to plugins **after** the `meva add` command execution +/// Payload provided to plugins **after** the `meva add` command execution. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct AddPostPayload { + /// A list of file paths that were successfully added or staged. pub added: Vec, + + /// A list of file paths that were detected as modified and updated. pub modified: Vec, + + /// A list of file paths that were removed or detected as deleted during the operation. pub removed: Vec, } diff --git a/plugins/src/models/plugin_configuration.rs b/plugins/src/models/plugin_configuration.rs index 2a63a04..23d8c47 100644 --- a/plugins/src/models/plugin_configuration.rs +++ b/plugins/src/models/plugin_configuration.rs @@ -3,8 +3,9 @@ use std::{ path::PathBuf, }; +use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; -use shared::PrettyField; +use shared::{PathToString, PrettyField}; use crate::enums::EventType; @@ -50,30 +51,41 @@ impl Display for PluginConfiguration { let max_len = labels.iter().map(|s| s.len()).max().unwrap_or(0); let indent = 2; - writeln!(f, "Plugin: {}", self.name)?; + writeln!(f, "Plugin: {}", self.name.green().bold())?; + if let Some(desc) = &self.description { f.field(indent, labels[0], desc, max_len)?; } - f.field(indent, labels[1], self.file.display(), max_len)?; - f.field(indent, labels[2], format!("{:?}", self.event), max_len)?; - f.field(indent, labels[3], self.order, max_len)?; - f.field( - indent, - labels[4], - self.timeout - .map(|t| format!("{t} ms")) - .unwrap_or_else(|| "-".to_string()), - max_len, - )?; + f.field( indent, - labels[5], - if self.enabled { "yes" } else { "no" }, + labels[1], + self.file.to_utf8_string().cyan(), max_len, )?; + + f.field(indent, labels[2], self.event.to_string(), max_len)?; + + f.field(indent, labels[3], self.order.to_string(), max_len)?; + + let timeout_display = self + .timeout + .map(|t| format!("{t} ms")) + .unwrap_or_else(|| "-".dimmed().to_string()); + + f.field(indent, labels[4], timeout_display, max_len)?; + + let enabled_display = if self.enabled { + "yes".to_string() + } else { + "no".red().to_string() + }; + f.field(indent, labels[5], enabled_display, max_len)?; + if let Some(interp) = &self.interpreter { - f.field(indent, labels[6], interp, max_len)?; + f.field(indent, labels[6], interp.magenta(), max_len)?; } + Ok(()) } } diff --git a/plugins/src/plugins_discovery.rs b/plugins/src/plugins_discovery.rs index 07f7664..da0bf7f 100644 --- a/plugins/src/plugins_discovery.rs +++ b/plugins/src/plugins_discovery.rs @@ -84,8 +84,9 @@ impl PluginsDiscovery for MevaPluginsDiscovery { } } + /// Returns the directory path for a specific command, creating it if necessary. fn get_command_dir(&self, command: &CommandType, plugins_dir: &Path) -> PluginResult { - let command_str = command.to_string(); + let command_str = command.to_path(); let command_dir = plugins_dir.join(command_str); fs::create_dir_all(&command_dir).map_err(PluginError::Io)?; diff --git a/plugins/src/plugins_engine.rs b/plugins/src/plugins_engine.rs index 85b4f21..8976061 100644 --- a/plugins/src/plugins_engine.rs +++ b/plugins/src/plugins_engine.rs @@ -81,7 +81,7 @@ impl PluginsEngine for MevaPluginsEngine { let formatted_timestamp = self.formatted_timestamp(timestamp); let invocation_dir = plugins_dir .join(self.discovery.layout().invocations_dir_name()) - .join(command.to_string()) + .join(command.to_path()) .join(formatted_timestamp); fs::create_dir_all(&invocation_dir)?; diff --git a/shared/Cargo.toml b/shared/Cargo.toml index d5d8d00..76eaab3 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -9,6 +9,7 @@ tempfile.workspace = true up_finder.workspace = true editor-command.workspace = true path-absolutize.workspace = true +owo-colors.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/shared/src/pretty_field.rs b/shared/src/pretty_field.rs index 807b28e..574be11 100644 --- a/shared/src/pretty_field.rs +++ b/shared/src/pretty_field.rs @@ -1,12 +1,15 @@ use std::fmt::{Display, Formatter, Result}; +use owo_colors::OwoColorize; + pub trait PrettyField { fn field(&mut self, indent: usize, label: &str, value: T, width: usize) -> Result; } impl<'a> PrettyField for Formatter<'a> { fn field(&mut self, indent: usize, label: &str, value: T, width: usize) -> Result { - let padding = " ".repeat(indent); - writeln!(self, "{padding}{label:width$} : {value}") + let padding_str = " ".repeat(indent); + let padded_label = format!("{label: Date: Thu, 18 Dec 2025 16:02:53 +0100 Subject: [PATCH 27/42] First commit panic (#40) --- engine/src/repositories/meva_repository.rs | 19 ++++++++++++++----- server/src/server_handler.rs | 5 +---- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index 9131837..9afd38d 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -147,13 +147,12 @@ impl MevaRepository { ) -> EngineResult> { let heads_refs_prefix = self.ref_manager.heads_refs_prefix(); let branch_name = branch_name.trim_start_matches(&heads_refs_prefix); - let commit_hash = commit_hash.unwrap_or(""); let mut ref_entries = Vec::new(); if create_head { let full_ref_name = format!("{heads_refs_prefix}{branch_name}"); - let entry = RefEntry::new(&full_ref_name, commit_hash); + let entry = RefEntry::new(&full_ref_name, commit_hash.unwrap_or("")); let ref_path = root .join(self.layout.heads_refs_dir_rel()) @@ -162,7 +161,12 @@ impl MevaRepository { if let Some(parent) = ref_path.parent() { fs::create_dir_all(parent)?; } - fs::write(&ref_path, entry.to_json()?)?; + + if commit_hash.is_some() { + fs::write(&ref_path, entry.to_json()?)?; + } else { + fs::File::create(&ref_path)?; + } let log_path = root .join(self.layout.heads_logs_dir_rel()) @@ -175,7 +179,7 @@ impl MevaRepository { if let Some(remote) = remote_name { let remotes_refs_prefix = self.ref_manager.remotes_refs_prefix(); let full_remote_ref_name = format!("{remotes_refs_prefix}{remote}/{branch_name}"); - let entry = RefEntry::new(&full_remote_ref_name, commit_hash); + let entry = RefEntry::new(&full_remote_ref_name, commit_hash.unwrap_or("")); let ref_path = root .join(self.layout.remotes_refs_dir_rel()) @@ -185,7 +189,12 @@ impl MevaRepository { if let Some(parent) = ref_path.parent() { fs::create_dir_all(parent)?; } - fs::write(&ref_path, entry.to_json()?)?; + + if commit_hash.is_some() { + fs::write(&ref_path, entry.to_json()?)?; + } else { + fs::File::create(&ref_path)?; + } ref_entries.push(entry); } diff --git a/server/src/server_handler.rs b/server/src/server_handler.rs index a759328..4055b00 100644 --- a/server/src/server_handler.rs +++ b/server/src/server_handler.rs @@ -156,10 +156,7 @@ impl ServerHandler { .unwrap_or("[utf8 error]") .trim(); - let command = match UploadPackCommand::try_from(line_str) { - Ok(command) => command, - Err(e) => return Err(e.into()), - }; + let command = UploadPackCommand::try_from(line_str)?; match command { UploadPackCommand::Want(sha) => { From c1ae22c62d263dfa3b77e7e6d801b9215f4d06b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Thu, 25 Dec 2025 17:54:15 +0100 Subject: [PATCH 28/42] GUI Template (#37) --- Cargo.lock | 5551 ++++++++++++++--- cli/src/commands/branch.rs | 10 +- cli/src/commands/commit.rs | 20 +- cli/src/commands/log.rs | 5 + engine/src/branch_manager/branch.rs | 4 +- engine/src/branch_manager/branch_info.rs | 29 + .../src/branch_manager/meva_branch_manager.rs | 16 +- engine/src/config/config_location.rs | 6 +- engine/src/diff_builder.rs | 110 +- engine/src/diff_builder/meva_diff_builder.rs | 105 - engine/src/diff_builder/models.rs | 1 + engine/src/diff_builder/models/change_kind.rs | 11 + .../diff_builder/models/file_change_kind.rs | 22 + engine/src/diff_builder/models/hunk.rs | 11 + engine/src/engine_container.rs | 8 +- engine/src/handlers/add/operations.rs | 12 +- engine/src/handlers/branch.rs | 5 +- ..._branches_list.rs => branch_collection.rs} | 58 +- engine/src/handlers/branch/handlers.rs | 28 +- engine/src/handlers/branch/operations.rs | 20 +- engine/src/handlers/commit/handlers.rs | 28 +- engine/src/handlers/commit/operations.rs | 20 +- engine/src/handlers/config/operations.rs | 68 + engine/src/handlers/diff/operations.rs | 45 + engine/src/handlers/init/operations.rs | 1 + engine/src/handlers/log/handlers.rs | 5 +- engine/src/handlers/log/operations.rs | 3 + engine/src/handlers/status.rs | 1 + engine/src/handlers/status/handlers.rs | 4 +- engine/src/handlers/status/models.rs | 1 + .../src/handlers/status/models/status_kind.rs | 11 + engine/src/handlers/status/operations.rs | 10 +- gui/Cargo.toml | 10 + gui/assets/feather.png | Bin 0 -> 2331 bytes gui/src/events.rs | 107 + gui/src/events/worker_event.rs | 55 + gui/src/events/worker_result.rs | 92 + gui/src/icons.rs | 16 + gui/src/lib.rs | 3 + gui/src/main.rs | 383 +- gui/src/ui.rs | 5 + gui/src/ui/components.rs | 12 + gui/src/ui/components/branch_status.rs | 142 + gui/src/ui/components/buttons.rs | 15 + .../ui/components/buttons/commit_button.rs | 29 + gui/src/ui/components/buttons/icon_button.rs | 64 + .../components/buttons/navigation_button.rs | 61 + .../buttons/open_repository_button.rs | 138 + .../buttons/refresh_status_button.rs | 115 + .../ui/components/buttons/settings_button.rs | 53 + gui/src/ui/components/buttons/theme_button.rs | 66 + gui/src/ui/components/dialogs.rs | 11 + .../dialogs/commit_changes_dialog.rs | 198 + .../dialogs/create_repository_dialog.rs | 275 + gui/src/ui/components/dialogs/error_dialog.rs | 72 + .../ui/components/dialogs/loading_dialog.rs | 67 + .../dialogs/switch_branch_dialog.rs | 327 + gui/src/ui/components/file_changes.rs | 515 ++ gui/src/ui/components/remote_actions.rs | 181 + gui/src/ui/views.rs | 23 + gui/src/ui/views/dashboard.rs | 118 + gui/src/ui/views/diff.rs | 342 + gui/src/ui/views/log.rs | 337 + gui/src/ui/views/settings.rs | 264 + gui/src/ui/views/settings/set_config_form.rs | 182 + gui/src/ui/views/settings/settings_tab.rs | 31 + shared/src/extensions.rs | 2 + shared/src/extensions/change_directory.rs | 27 + shared/src/lib.rs | 4 +- 69 files changed, 9553 insertions(+), 1018 deletions(-) rename engine/src/handlers/branch/{display_branches_list.rs => branch_collection.rs} (52%) create mode 100644 gui/assets/feather.png create mode 100644 gui/src/events.rs create mode 100644 gui/src/events/worker_event.rs create mode 100644 gui/src/events/worker_result.rs create mode 100644 gui/src/icons.rs create mode 100644 gui/src/lib.rs create mode 100644 gui/src/ui.rs create mode 100644 gui/src/ui/components.rs create mode 100644 gui/src/ui/components/branch_status.rs create mode 100644 gui/src/ui/components/buttons.rs create mode 100644 gui/src/ui/components/buttons/commit_button.rs create mode 100644 gui/src/ui/components/buttons/icon_button.rs create mode 100644 gui/src/ui/components/buttons/navigation_button.rs create mode 100644 gui/src/ui/components/buttons/open_repository_button.rs create mode 100644 gui/src/ui/components/buttons/refresh_status_button.rs create mode 100644 gui/src/ui/components/buttons/settings_button.rs create mode 100644 gui/src/ui/components/buttons/theme_button.rs create mode 100644 gui/src/ui/components/dialogs.rs create mode 100644 gui/src/ui/components/dialogs/commit_changes_dialog.rs create mode 100644 gui/src/ui/components/dialogs/create_repository_dialog.rs create mode 100644 gui/src/ui/components/dialogs/error_dialog.rs create mode 100644 gui/src/ui/components/dialogs/loading_dialog.rs create mode 100644 gui/src/ui/components/dialogs/switch_branch_dialog.rs create mode 100644 gui/src/ui/components/file_changes.rs create mode 100644 gui/src/ui/components/remote_actions.rs create mode 100644 gui/src/ui/views.rs create mode 100644 gui/src/ui/views/dashboard.rs create mode 100644 gui/src/ui/views/diff.rs create mode 100644 gui/src/ui/views/log.rs create mode 100644 gui/src/ui/views/settings.rs create mode 100644 gui/src/ui/views/settings/set_config_form.rs create mode 100644 gui/src/ui/views/settings/settings_tab.rs create mode 100644 shared/src/extensions/change_directory.rs diff --git a/Cargo.lock b/Cargo.lock index 627a58b..0367e50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,112 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" + +[[package]] +name = "accesskit_atspi_common" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "890d241cf51fc784f0ac5ac34dfc847421f8d39da6c7c91a0fcc987db62a8267" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "serde", + "thiserror 1.0.69", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db81010a6895d8707f9072e6ce98070579b43b717193d2614014abd5cb17dd43" +dependencies = [ + "accesskit", + "hashbrown 0.15.5", +] + +[[package]] +name = "accesskit_macos" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0089e5c0ac0ca281e13ea374773898d9354cc28d15af9f0f7394d44a495b575" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301e55b39cfc15d9c48943ce5f572204a551646700d0e8efa424585f94fec528" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d63dd5041e49c363d83f5419a896ecb074d309c414036f616dc0b04faca971" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "static_assertions", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cfabe59d0eaca7412bfb1f70198dd31e3b0496fee7e15b066f9c36a1a140a0" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + [[package]] name = "addr2line" version = "0.24.2" @@ -52,6 +158,19 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.3", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -61,6 +180,51 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "377e4c0ba83e4431b10df45c1d4666f178ea9c552cac93e60c3a88bf32785923" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "android-activity" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +dependencies = [ + "android-properties", + "bitflags 2.9.4", + "cc", + "cesu8", + "jni", + "jni-sys", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -127,10 +291,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] -name = "async-trait" -version = "0.1.89" +name = "arbitrary" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", @@ -138,1385 +328,3328 @@ dependencies = [ ] [[package]] -name = "autocfg" -version = "1.5.0" +name = "arrayref" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] -name = "backtrace" -version = "0.3.75" +name = "arrayvec" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "backtrace-ext" +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "as-slice" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" dependencies = [ - "backtrace", + "stable_deref_trait", ] [[package]] -name = "base16ct" -version = "0.2.0" +name = "ash" +version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] [[package]] -name = "base64ct" -version = "1.8.0" +name = "ashpd" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] [[package]] -name = "bcrypt-pbkdf" -version = "0.10.0" +name = "async-broadcast" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "blowfish", - "pbkdf2 0.12.2", - "sha2", + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", ] [[package]] -name = "bincode" -version = "2.0.1" +name = "async-channel" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ - "bincode_derive", - "serde", - "unty", + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", ] [[package]] -name = "bincode_derive" -version = "2.0.1" +name = "async-executor" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" dependencies = [ - "virtue", + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", ] [[package]] -name = "bitflags" -version = "2.9.4" +name = "async-fs" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] [[package]] -name = "block-buffer" -version = "0.10.4" +name = "async-io" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ - "generic-array", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.2", + "slab", + "windows-sys 0.61.2", ] [[package]] -name = "block-buffer" -version = "0.11.0-rc.5" +name = "async-lock" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9ef36a6fcdb072aa548f3da057640ec10859eb4e91ddf526ee648d50c76a949" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" dependencies = [ - "hybrid-array", + "event-listener", + "event-listener-strategy", + "pin-project-lite", ] [[package]] -name = "block-padding" -version = "0.3.3" +name = "async-net" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" dependencies = [ - "generic-array", + "async-io", + "blocking", + "futures-lite", ] [[package]] -name = "blowfish" -version = "0.9.1" +name = "async-process" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ - "byteorder", - "cipher", + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.2", ] [[package]] -name = "bstr" -version = "1.12.0" +name = "async-recursion" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ - "memchr", - "serde", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "bumpalo" -version = "3.19.0" +name = "async-signal" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.2", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] [[package]] -name = "byteorder" -version = "1.5.0" +name = "async-task" +version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] -name = "bytes" -version = "1.10.1" +name = "async-trait" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "cbc" -version = "0.1.2" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "cc" -version = "1.2.37" +name = "atspi" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" +checksum = "c83247582e7508838caf5f316c00791eee0e15c0bf743e6880585b867e16815c" dependencies = [ - "find-msvc-tools", - "shlex", + "atspi-common", + "atspi-connection", + "atspi-proxies", ] [[package]] -name = "cfg-if" -version = "1.0.3" +name = "atspi-common" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "33dfc05e7cdf90988a197803bf24f5788f94f7c94a69efa95683e8ffe76cfdfb" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] [[package]] -name = "chacha20" -version = "0.9.1" +name = "atspi-connection" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +checksum = "4193d51303d8332304056ae0004714256b46b6635a5c556109b319c0d3784938" dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", + "atspi-common", + "atspi-proxies", + "futures-lite", + "zbus", ] [[package]] -name = "chardetng" -version = "0.1.17" +name = "atspi-proxies" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" +checksum = "d2eebcb9e7e76f26d0bcfd6f0295e1cd1e6f33bedbc5698a971db8dc43d7751c" dependencies = [ - "cfg-if", - "encoding_rs", - "memchr", + "atspi-common", + "serde", + "zbus", ] [[package]] -name = "chrono" -version = "0.4.42" +name = "autocfg" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link 0.2.0", -] +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "cipher" -version = "0.4.4" +name = "av-scenechange" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" dependencies = [ - "crypto-common 0.1.6", - "inout", + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.17", + "v_frame", + "y4m", ] [[package]] -name = "clap" -version = "4.5.47" +name = "av1-grain" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" dependencies = [ - "clap_builder", - "clap_derive", + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", ] [[package]] -name = "clap_builder" -version = "4.5.47" +name = "avif-serialize" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", + "arrayvec", ] [[package]] -name = "clap_derive" -version = "4.5.47" +name = "backtrace" +version = "0.3.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", ] [[package]] -name = "clap_lex" -version = "0.7.5" +name = "backtrace-ext" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" - -[[package]] -name = "cli" -version = "0.1.0" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" dependencies = [ - "async-trait", - "clap", - "dateparser", - "engine", - "globset", - "miette", - "mockall", - "owo-colors", - "plugins", - "pretty_assertions", - "regex", - "rstest", - "shared", - "strum", - "strum_macros", - "thiserror 2.0.16", - "tokio", - "url", + "backtrace", ] [[package]] -name = "colorchoice" -version = "1.0.4" +name = "base16ct" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] -name = "const-oid" -version = "0.9.6" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "const-oid" -version = "0.10.1" +name = "base64ct" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dabb6555f92fb9ee4140454eb5dcd14c7960e1225c6d1a6cc361f032947713e" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "bcrypt-pbkdf" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2 0.12.2", + "sha2", +] [[package]] -name = "cpufeatures" -version = "0.2.17" +name = "bincode" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" dependencies = [ - "libc", + "bincode_derive", + "serde", + "unty", ] [[package]] -name = "crc32fast" -version = "1.5.0" +name = "bincode_derive" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" dependencies = [ - "cfg-if", + "virtue", ] [[package]] -name = "crossbeam-deque" -version = "0.8.6" +name = "bit-set" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", + "bit-vec", ] [[package]] -name = "crossbeam-epoch" -version = "0.9.18" +name = "bit-vec" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] -name = "crossbeam-utils" -version = "0.8.21" +name = "bit_field" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] -name = "crypto-bigint" -version = "0.5.5" +name = "bitflags" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" dependencies = [ - "generic-array", - "rand_core", - "subtle", - "zeroize", + "core2", ] [[package]] -name = "crypto-common" +name = "block" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array", - "rand_core", - "typenum", ] [[package]] -name = "crypto-common" -version = "0.2.0-rc.4" +name = "block-buffer" +version = "0.11.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8235645834fbc6832939736ce2f2d08192652269e11010a6240f61b908a1c6" +checksum = "e9ef36a6fcdb072aa548f3da057640ec10859eb4e91ddf526ee648d50c76a949" dependencies = [ "hybrid-array", ] [[package]] -name = "cryptovec" -version = "0.7.1" +name = "block-padding" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae2b4855ffe5a3fe35d5aa2d91b719fbcae83f3b90b97c4dac9d797282e3a7d7" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "libc", - "winapi", + "generic-array", ] [[package]] -name = "ctr" -version = "0.9.2" +name = "block2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "cipher", + "objc2 0.5.2", ] [[package]] -name = "curve25519-dalek" -version = "4.1.3" +name = "block2" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", + "objc2 0.6.3", ] [[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" +name = "blocking" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "proc-macro2", - "quote", - "syn", + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", ] [[package]] -name = "data-encoding" -version = "2.9.0" +name = "blowfish" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] [[package]] -name = "dateparser" -version = "0.2.1" +name = "bstr" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ef451feee09ae5ecd8a02e738bd9adee9266b8fa9b44e22d3ce968d8694238" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" dependencies = [ - "anyhow", - "chrono", - "lazy_static", - "regex", + "memchr", + "serde", ] [[package]] -name = "der" -version = "0.7.10" +name = "built" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid 0.9.6", - "pem-rfc7468", - "zeroize", -] +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" [[package]] -name = "diff" -version = "0.1.13" +name = "bumpalo" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] -name = "digest" -version = "0.10.7" +name = "bytemuck" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" dependencies = [ - "block-buffer 0.10.4", - "const-oid 0.9.6", - "crypto-common 0.1.6", - "subtle", + "bytemuck_derive", ] [[package]] -name = "digest" -version = "0.11.0-rc.2" +name = "bytemuck_derive" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6749b668519cd7149ee3d11286a442a8a8bdc3a9d529605f579777bfccc5a4bc" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ - "block-buffer 0.11.0-rc.5", - "const-oid 0.10.1", - "crypto-common 0.2.0-rc.4", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "dirs" -version = "5.0.1" +name = "byteorder" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "dirs-sys 0.4.1", + "bitflags 2.9.4", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", ] [[package]] -name = "dirs" -version = "6.0.0" +name = "calloop" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" dependencies = [ - "dirs-sys 0.5.0", + "bitflags 2.9.4", + "polling", + "rustix 1.1.2", + "slab", + "tracing", ] [[package]] -name = "dirs-sys" +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ - "libc", - "option-ext", - "redox_users 0.4.6", - "windows-sys 0.48.0", + "calloop 0.14.3", + "rustix 1.1.2", + "wayland-backend", + "wayland-client", ] [[package]] -name = "dirs-sys" -version = "0.5.0" +name = "cbc" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "libc", - "option-ext", - "redox_users 0.5.2", - "windows-sys 0.61.0", + "cipher", ] [[package]] -name = "displaydoc" -version = "0.2.5" +name = "cc" +version = "1.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" dependencies = [ - "proc-macro2", - "quote", - "syn", + "find-msvc-tools", + "jobserver", + "libc", + "shlex", ] [[package]] -name = "downcast" -version = "0.11.0" +name = "cesu8" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] -name = "ecdsa" -version = "0.16.9" +name = "cfg-if" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" dependencies = [ - "der", - "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "signature", - "spki", + "libc", ] [[package]] -name = "ed25519" -version = "2.2.3" +name = "chacha20" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ - "pkcs8", - "signature", + "cfg-if", + "cipher", + "cpufeatures", ] [[package]] -name = "ed25519-dalek" -version = "2.2.0" +name = "chardetng" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" dependencies = [ - "curve25519-dalek", - "ed25519", - "rand_core", + "cfg-if", + "encoding_rs", + "memchr", +] + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", "serde", - "sha2", - "subtle", - "zeroize", + "wasm-bindgen", + "windows-link 0.2.1", ] [[package]] -name = "editor-command" -version = "1.0.0" +name = "cipher" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee98e369cc618396b828ba0231d5bc39857ee1d8e9dcb1c1adef49a21bb5c427" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "shell-words", + "crypto-common 0.1.6", + "inout", ] [[package]] -name = "either" -version = "1.15.0" +name = "clap" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" +dependencies = [ + "clap_builder", + "clap_derive", +] [[package]] -name = "elliptic-curve" -version = "0.13.8" +name = "clap_builder" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" dependencies = [ - "base16ct", - "crypto-bigint", - "digest 0.10.7", - "ff", - "generic-array", - "group", - "hkdf", - "pem-rfc7468", - "pkcs8", - "rand_core", - "sec1", - "subtle", - "zeroize", + "anstream", + "anstyle", + "clap_lex", + "strsim", ] [[package]] -name = "encoding_rs" -version = "0.8.35" +name = "clap_derive" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ - "cfg-if", + "heck", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "engine" +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "cli" version = "0.1.0" dependencies = [ "async-trait", - "bincode", - "chardetng", - "chrono", - "cryptovec", - "dirs 6.0.0", - "encoding_rs", - "flate2", + "clap", + "dateparser", + "engine", "globset", - "hex", - "itertools", + "miette", "mockall", "owo-colors", - "path-absolutize", "plugins", "pretty_assertions", - "rayon", "regex", "rstest", - "russh", - "russh-keys", - "serde", - "serde_json", - "sha1 0.11.0-rc.2", "shared", - "similar", "strum", "strum_macros", - "tempfile", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", - "toml", - "toml_edit", - "tree-ds", "url", - "walkdir", ] [[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" +name = "clipboard-win" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" dependencies = [ - "libc", - "windows-sys 0.61.0", + "error-code", ] [[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "ff" -version = "0.13.1" +name = "codespan-reporting" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ - "rand_core", - "subtle", + "serde", + "termcolor", + "unicode-width 0.2.1", ] [[package]] -name = "fiat-crypto" -version = "0.2.9" +name = "color_quant" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] -name = "find-msvc-tools" -version = "0.1.1" +name = "colorchoice" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] -name = "flate2" -version = "1.1.2" +name = "combine" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ - "crc32fast", - "miniz_oxide", + "bytes", + "memchr", ] [[package]] -name = "flexi_logger" -version = "0.31.7" +name = "concurrent-queue" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e5335674a3a259527f97e9176a3767dcc9b220b8e29d643daeb2d6c72caf8b" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ - "chrono", + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dabb6555f92fb9ee4140454eb5dcd14c7960e1225c6d1a6cc361f032947713e" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8235645834fbc6832939736ce2f2d08192652269e11010a6240f61b908a1c6" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cryptovec" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae2b4855ffe5a3fe35d5aa2d91b719fbcae83f3b90b97c4dac9d797282e3a7d7" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "dateparser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ef451feee09ae5ecd8a02e738bd9adee9266b8fa9b44e22d3ce968d8694238" +dependencies = [ + "anyhow", + "chrono", + "lazy_static", + "regex", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6749b668519cd7149ee3d11286a442a8a8bdc3a9d529605f579777bfccc5a4bc" +dependencies = [ + "block-buffer 0.11.0-rc.5", + "const-oid 0.10.1", + "crypto-common 0.2.0-rc.4", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.4", + "block2 0.6.2", + "libc", + "objc2 0.6.3", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ecolor" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "editor-command" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee98e369cc618396b828ba0231d5bc39857ee1d8e9dcb1c1adef49a21bb5c427" +dependencies = [ + "shell-words", +] + +[[package]] +name = "eframe" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "parking_lot", + "percent-encoding", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.9.4", + "emath", + "epaint", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", +] + +[[package]] +name = "egui-phosphor" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b2838620355debaeb7fa3cc331a1d8ab77e6387d40e65686fa468c229dbf63" +dependencies = [ + "egui", +] + +[[package]] +name = "egui-wgpu" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.17", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_extras" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01d34e845f01c62e3fded726961092e70417d66570c499b9817ab24674ca4ed" +dependencies = [ + "ahash", + "egui", + "ehttp", + "enum-map", + "image", + "log", + "mime_guess2", + "profiling", + "resvg", +] + +[[package]] +name = "egui_glow" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "memoffset", + "profiling", + "wasm-bindgen", + "web-sys", + "winit", +] + +[[package]] +name = "ehttp" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04499d3c719edecfad5c9b46031726c8540905d73be6d7e4f9788c4a298da908" +dependencies = [ + "document-features", + "js-sys", + "ureq", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "emath" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "engine" +version = "0.1.0" +dependencies = [ + "async-trait", + "bincode", + "chardetng", + "chrono", + "cryptovec", + "dirs 6.0.0", + "encoding_rs", + "flate2", + "globset", + "hex", + "itertools", + "mockall", + "owo-colors", + "path-absolutize", + "plugins", + "pretty_assertions", + "rayon", + "regex", + "rstest", + "russh", + "russh-keys", + "serde", + "serde_json", + "sha1 0.11.0-rc.2", + "shared", + "similar", + "strum", + "strum_macros", + "tempfile", + "thiserror 2.0.17", + "tokio", + "toml", + "toml_edit", + "tree-ds", + "url", + "walkdir", +] + +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "epaint" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot", + "profiling", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "euclid" +version = "0.22.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" + +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flexi_logger" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e5335674a3a259527f97e9176a3767dcc9b220b8e29d643daeb2d6c72caf8b" +dependencies = [ + "chrono", + "log", + "nu-ansi-term", + "regex", + "thiserror 2.0.17", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.2", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.9.4", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.9.4", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.9.4", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.9.4", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.9.4", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "gui" +version = "0.1.0" +dependencies = [ + "chrono", + "eframe", + "egui", + "egui-phosphor", + "egui_extras", + "engine", + "image", + "mockall", + "plugins", + "pretty_assertions", + "rfd", + "rstest", + "shared", + "strum", + "strum_macros", + "tokio", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hybrid-array" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7116c472cf19838450b1d421b4e842569f52b519d640aee9ace1ebcf5b21051" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", "log", - "nu-ansi-term", - "regex", - "thiserror 2.0.16", + "wasm-bindgen", + "windows-core 0.62.0", ] [[package]] -name = "form_urlencoded" -version = "1.2.2" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "percent-encoding", + "cc", ] [[package]] -name = "fragile" -version = "2.0.1" +name = "icu_collections" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] [[package]] -name = "futures" -version = "0.3.31" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[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 = "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 = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.0", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core 0.5.0", + "zune-jpeg 0.5.6", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + +[[package]] +name = "indexmap" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92119844f513ffa41556430369ab02c295a3578af21cf945caa3e9e0c2481ac3" +dependencies = [ + "equivalent", + "hashbrown 0.15.5", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", + "arbitrary", + "cc", ] [[package]] -name = "futures-channel" -version = "0.3.31" +name = "libloading" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ - "futures-core", - "futures-sink", + "cfg-if", + "windows-link 0.2.1", ] [[package]] -name = "futures-core" -version = "0.3.31" +name = "libm" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] -name = "futures-executor" -version = "0.3.31" +name = "libredox" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "futures-core", - "futures-task", - "futures-util", + "bitflags 2.9.4", + "libc", + "redox_syscall 0.5.18", ] [[package]] -name = "futures-io" -version = "0.3.31" +name = "linux-raw-sys" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] -name = "futures-macro" -version = "0.3.31" +name = "linux-raw-sys" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] -name = "futures-sink" -version = "0.3.31" +name = "litemap" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] -name = "futures-task" -version = "0.3.31" +name = "litrs" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] -name = "futures-timer" -version = "3.0.3" +name = "lock_api" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] [[package]] -name = "futures-util" -version = "0.3.31" +name = "log" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] -name = "generic-array" -version = "0.14.9" +name = "loop9" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" dependencies = [ - "typenum", - "version_check", - "zeroize", + "imgref", ] [[package]] -name = "getrandom" -version = "0.2.16" +name = "malloc_buf" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" dependencies = [ - "cfg-if", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", ] [[package]] -name = "getrandom" -version = "0.3.3" +name = "maybe-rayon" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" dependencies = [ "cfg-if", - "libc", - "r-efi", - "wasi 0.14.7+wasi-0.2.4", + "rayon", ] [[package]] -name = "ghash" -version = "0.5.1" +name = "md5" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] -name = "gimli" -version = "0.31.1" +name = "memchr" +version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] -name = "glob" -version = "0.3.3" +name = "memmap2" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] [[package]] -name = "globset" -version = "0.4.16" +name = "memoffset" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", + "autocfg", ] [[package]] -name = "group" -version = "0.13.0" +name = "metal" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" dependencies = [ - "ff", - "rand_core", - "subtle", + "bitflags 2.9.4", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", ] [[package]] -name = "gui" -version = "0.1.0" +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" dependencies = [ - "engine", - "mockall", - "plugins", - "pretty_assertions", - "rstest", - "shared", + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", ] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "miette-derive" +version = "7.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "heck" -version = "0.5.0" +name = "mime" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "hex" -version = "0.4.3" +name = "mime_guess2" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca" +dependencies = [ + "mime", + "phf", + "phf_shared", + "unicase", +] [[package]] -name = "hex-literal" -version = "0.4.1" +name = "miniz_oxide" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] [[package]] -name = "hkdf" -version = "0.12.4" +name = "mio" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ - "hmac", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", ] [[package]] -name = "hmac" -version = "0.12.1" +name = "mockall" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" dependencies = [ - "digest 0.10.7", + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", ] [[package]] -name = "hybrid-array" -version = "0.4.1" +name = "mockall_derive" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7116c472cf19838450b1d421b4e842569f52b519d640aee9ace1ebcf5b21051" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" dependencies = [ - "typenum", + "cfg-if", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "iana-time-zone" -version = "0.1.64" +name = "moxcms" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "80986bbbcf925ebd3be54c26613d861255284584501595cf418320c078945608" dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", + "num-traits", + "pxfm", ] [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "naga" +version = "27.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" dependencies = [ - "cc", + "arrayvec", + "bit-set", + "bitflags 2.9.4", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.17", + "unicode-ident", ] [[package]] -name = "icu_collections" -version = "2.1.1" +name = "ndk" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", + "bitflags 2.9.4", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", ] [[package]] -name = "icu_locale_core" -version = "2.1.1" +name = "ndk-context" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", + "jni-sys", ] [[package]] -name = "icu_normalizer" -version = "2.1.1" +name = "new_debug_unreachable" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", + "bitflags 2.9.4", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", ] [[package]] -name = "icu_normalizer_data" -version = "2.1.1" +name = "nohash-hasher" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" [[package]] -name = "icu_properties" -version = "2.1.1" +name = "nom" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", + "memchr", ] [[package]] -name = "icu_properties_data" -version = "2.1.1" +name = "noop_proc_macro" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] -name = "icu_provider" -version = "2.1.1" +name = "nu-ansi-term" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", + "windows-sys 0.61.2", ] [[package]] -name = "idna" -version = "1.1.0" +name = "num-bigint" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", + "num-integer", + "num-traits", + "rand 0.8.5", ] [[package]] -name = "idna_adapter" -version = "1.2.1" +name = "num-bigint-dig" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "82c79c15c05d4bf82b6f5ef163104cc81a760d8e874d38ac50ab67c8877b647b" dependencies = [ - "icu_normalizer", - "icu_properties", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", ] [[package]] -name = "indexmap" -version = "2.11.3" +name = "num-derive" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92119844f513ffa41556430369ab02c295a3578af21cf945caa3e9e0c2481ac3" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ - "equivalent", - "hashbrown", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "inout" -version = "0.1.4" +name = "num-integer" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ - "block-padding", - "generic-array", + "num-traits", ] [[package]] -name = "is_ci" -version = "1.2.0" +name = "num-iter" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] [[package]] -name = "is_terminal_polyfill" -version = "1.70.1" +name = "num-rational" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] [[package]] -name = "itertools" -version = "0.14.0" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "either", + "autocfg", + "libm", ] [[package]] -name = "itoa" -version = "1.0.15" +name = "num_enum" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] [[package]] -name = "js-sys" -version = "0.3.80" +name = "num_enum_derive" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "once_cell", - "wasm-bindgen", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "objc" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" dependencies = [ - "spin 0.9.8", + "malloc_buf", ] [[package]] -name = "libc" -version = "0.2.175" +name = "objc-sys" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" [[package]] -name = "libm" -version = "0.2.15" +name = "objc2" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] [[package]] -name = "libredox" -version = "0.1.10" +name = "objc2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" dependencies = [ - "bitflags", - "libc", + "objc2-encode", ] [[package]] -name = "linux-raw-sys" -version = "0.11.0" +name = "objc2-app-kit" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.9.4", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] [[package]] -name = "litemap" -version = "0.8.1" +name = "objc2-app-kit" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.9.4", + "block2 0.6.2", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] [[package]] -name = "lock_api" -version = "0.4.14" +name = "objc2-cloud-kit" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "scopeguard", + "bitflags 2.9.4", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] -name = "log" -version = "0.4.28" +name = "objc2-contacts" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] [[package]] -name = "md5" -version = "0.7.0" +name = "objc2-core-data" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.9.4", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] [[package]] -name = "memchr" -version = "2.7.5" +name = "objc2-core-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.4", + "dispatch2", + "objc2 0.6.3", +] [[package]] -name = "miette" -version = "7.6.0" +name = "objc2-core-graphics" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "backtrace", - "backtrace-ext", - "cfg-if", - "miette-derive", - "owo-colors", - "supports-color", - "supports-hyperlinks", - "supports-unicode", - "terminal_size", - "textwrap", - "unicode-width 0.1.14", + "bitflags 2.9.4", + "dispatch2", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-io-surface", ] [[package]] -name = "miette-derive" -version = "7.6.0" +name = "objc2-core-image" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "proc-macro2", - "quote", - "syn", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", ] [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "objc2-core-location" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "adler2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", ] [[package]] -name = "mio" -version = "1.1.0" +name = "objc2-encode" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ + "bitflags 2.9.4", + "block2 0.5.1", + "dispatch", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.61.0", + "objc2 0.5.2", ] [[package]] -name = "mockall" -version = "0.13.1" +name = "objc2-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "cfg-if", - "downcast", - "fragile", - "mockall_derive", - "predicates", - "predicates-tree", + "bitflags 2.9.4", + "objc2 0.6.3", + "objc2-core-foundation", ] [[package]] -name = "mockall_derive" -version = "0.13.1" +name = "objc2-io-surface" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "cfg-if", - "proc-macro2", - "quote", - "syn", + "bitflags 2.9.4", + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", ] [[package]] -name = "nu-ansi-term" -version = "0.50.3" +name = "objc2-metal" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "windows-sys 0.61.0", + "bitflags 2.9.4", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] -name = "num-bigint" -version = "0.4.6" +name = "objc2-quartz-core" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "num-integer", - "num-traits", - "rand", + "bitflags 2.9.4", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", ] [[package]] -name = "num-bigint-dig" -version = "0.8.5" +name = "objc2-symbols" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82c79c15c05d4bf82b6f5ef163104cc81a760d8e874d38ac50ab67c8877b647b" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand", - "smallvec", - "zeroize", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] -name = "num-integer" -version = "0.1.46" +name = "objc2-ui-kit" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "num-traits", + "bitflags 2.9.4", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", ] [[package]] -name = "num-iter" -version = "0.1.45" +name = "objc2-uniform-type-identifiers" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "autocfg", - "num-integer", - "num-traits", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "objc2-user-notifications" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "autocfg", - "libm", + "bitflags 2.9.4", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] @@ -1552,6 +3685,43 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "orbclient" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "247ad146e19b9437f8604c21f8652423595cf710ad108af40e77d3ae6e96b827" +dependencies = [ + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "owo-colors" version = "4.2.3" @@ -1592,10 +3762,16 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "rand_core", + "rand_core 0.6.4", "sha2", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1614,9 +3790,9 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -1626,10 +3802,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "path-absolutize" version = "3.1.1" @@ -1685,6 +3873,76 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", + "unicase", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", + "unicase", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1697,6 +3955,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -1731,10 +4000,16 @@ checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der", "pkcs5", - "rand_core", + "rand_core 0.6.4", "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "plugins" version = "0.1.0" @@ -1749,10 +4024,56 @@ dependencies = [ "shared", "strum", "strum_macros", - "thiserror 2.0.16", + "thiserror 2.0.17", "wait-timeout", ] +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.9.4", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "poly1305" version = "0.8.0" @@ -1776,6 +4097,21 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1820,6 +4156,12 @@ dependencies = [ "termtree", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -1857,6 +4199,68 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.40" @@ -1879,28 +4283,119 @@ 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]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "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]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +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.3", +] + +[[package]] +name = "range-alloc" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.17", + "v_frame", + "wasm-bindgen", ] [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "ravif" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" dependencies = [ - "ppv-lite86", - "rand_core", + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", ] [[package]] -name = "rand_core" -version = "0.6.4" +name = "raw-window-handle" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.16", -] +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] name = "rayon" @@ -1922,13 +4417,22 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.9.4", ] [[package]] @@ -1950,7 +4454,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -1988,6 +4492,26 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -1998,6 +4522,59 @@ dependencies = [ "subtle", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rsa" version = "0.9.8" @@ -2011,7 +4588,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sha2", "signature", "spki", @@ -2058,7 +4635,7 @@ dependencies = [ "aes", "aes-gcm", "async-trait", - "bitflags", + "bitflags 2.9.4", "byteorder", "cbc", "chacha20", @@ -2078,8 +4655,8 @@ dependencies = [ "p384", "p521", "poly1305", - "rand", - "rand_core", + "rand 0.8.5", + "rand_core 0.6.4", "russh-cryptovec", "russh-keys", "sha1 0.10.6", @@ -2134,8 +4711,8 @@ dependencies = [ "pkcs1", "pkcs5", "pkcs8", - "rand", - "rand_core", + "rand 0.8.5", + "rand_core 0.6.4", "rsa", "russh-cryptovec", "sec1", @@ -2158,6 +4735,12 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -2173,17 +4756,65 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.9.4", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags", + "bitflags 2.9.4", "errno", "libc", - "linux-raw-sys", - "windows-sys 0.61.0", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -2216,6 +4847,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -2233,6 +4870,19 @@ dependencies = [ "sha2", ] +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + [[package]] name = "sec1" version = "0.7.3" @@ -2315,6 +4965,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_spanned" version = "1.0.1" @@ -2341,7 +5002,7 @@ dependencies = [ "russh-keys", "serde", "serde_plain", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "toml", ] @@ -2421,7 +5082,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest 0.10.7", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", ] [[package]] @@ -2430,18 +5106,114 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.9.4", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.9.4", + "calloop 0.14.3", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.2", + "thiserror 2.0.17", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + [[package]] name = "socket2" version = "0.6.1" @@ -2467,6 +5239,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.9.4", +] + [[package]] name = "spki" version = "0.7.3" @@ -2517,7 +5298,7 @@ dependencies = [ "p256", "p384", "p521", - "rand_core", + "rand_core 0.6.4", "rsa", "sec1", "sha2", @@ -2534,6 +5315,21 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2585,6 +5381,16 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo", + "siphasher", +] + [[package]] name = "syn" version = "2.0.106" @@ -2616,8 +5422,17 @@ dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix", - "windows-sys 0.61.0", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", ] [[package]] @@ -2626,7 +5441,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix", + "rustix 1.1.2", "windows-sys 0.60.2", ] @@ -2657,11 +5472,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.16", + "thiserror-impl 2.0.17", ] [[package]] @@ -2677,15 +5492,55 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2710,7 +5565,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -2787,6 +5642,38 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" +[[package]] +name = "tracing" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +dependencies = [ + "once_cell", +] + [[package]] name = "tree-ds" version = "0.2.0" @@ -2797,7 +5684,22 @@ dependencies = [ "sequential_gen", "serde", "spin 0.10.0", - "thiserror 2.0.16", + "thiserror 2.0.17", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.1", ] [[package]] @@ -2826,6 +5728,23 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -2838,6 +5757,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.1.14" @@ -2860,6 +5785,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "unty" version = "0.0.4" @@ -2872,10 +5803,26 @@ version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be33efd9986755f20533a6d724f20c4f62f680de544999f47ae04cffc1d996ae" dependencies = [ - "rustc-hash", + "rustc-hash 2.1.1", "typed-builder", ] +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.7" @@ -2888,6 +5835,34 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "xmlwriter", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2907,7 +5882,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom 0.3.3", + "js-sys", "serde", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", ] [[package]] @@ -2992,6 +5980,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.103" @@ -3024,6 +6025,352 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wayland-backend" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.2", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +dependencies = [ + "bitflags 2.9.4", + "rustix 1.1.2", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.9.4", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" +dependencies = [ + "rustix 1.1.2", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +dependencies = [ + "bitflags 2.9.4", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.9.4", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfe33d551eb8bffd03ff067a8b44bb963919157841a99957151299a6307d19c" +dependencies = [ + "bitflags 2.9.4", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" +dependencies = [ + "bitflags 2.9.4", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" +dependencies = [ + "bitflags 2.9.4", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +dependencies = [ + "proc-macro2", + "quick-xml 0.37.5", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" +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 = "webbrowser" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" +dependencies = [ + "core-foundation 0.10.1", + "jni", + "log", + "ndk-context", + "objc2 0.6.3", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.4", +] + +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.9.4", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.9.4", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.17", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.9.4", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.17", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.9.4", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.17", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3046,7 +6393,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -3055,24 +6402,115 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.0", + "windows-interface 0.59.1", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.2.0", - "windows-result", - "windows-strings", + "windows-implement 0.60.0", + "windows-interface 0.59.1", + "windows-link 0.2.1", + "windows-result 0.4.0", + "windows-strings 0.5.0", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "windows-implement" -version = "0.60.0" +name = "windows-interface" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", @@ -3098,9 +6536,37 @@ checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] [[package]] name = "windows-result" @@ -3108,7 +6574,26 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -3117,7 +6602,16 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", ] [[package]] @@ -3129,6 +6623,24 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -3140,11 +6652,26 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.61.0" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ - "windows-link 0.2.0", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", ] [[package]] @@ -3195,6 +6722,21 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3213,6 +6755,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -3231,6 +6779,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -3261,6 +6815,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -3279,6 +6839,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -3297,6 +6863,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -3315,6 +6887,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -3333,6 +6911,58 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winit" +version = "0.30.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.9.4", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + [[package]] name = "winnow" version = "0.7.13" @@ -3354,6 +6984,81 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.2", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.9.4", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yansi" version = "1.0.1" @@ -3383,6 +7088,104 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix", + "ordered-stream", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589e9a02bfafb9754bb2340a9e3b38f389772684c63d9637e76b1870377bec29" +dependencies = [ + "quick-xml 0.36.2", + "serde", + "static_assertions", + "zbus_names", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.27" @@ -3462,3 +7265,83 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f520eebad972262a1dde0ec455bce4f8b298b1e5154513de58c114c4c54303e8" +dependencies = [ + "zune-core 0.5.0", +] + +[[package]] +name = "zvariant" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow", +] diff --git a/cli/src/commands/branch.rs b/cli/src/commands/branch.rs index 2d710fa..bdd840d 100644 --- a/cli/src/commands/branch.rs +++ b/cli/src/commands/branch.rs @@ -163,6 +163,8 @@ impl MevaCommand for BranchCommand { let all = matches.get_flag(Self::ARG_ALL); let remotes = matches.get_flag(Self::ARG_REMOTES); + let mut is_list = false; + let request = if delete || force_delete { let request = DeleteRequest { branch_name: branch_name.cloned().unwrap(), @@ -196,12 +198,18 @@ impl MevaCommand for BranchCommand { remotes: remotes || all, }; + is_list = true; + Request::List(request) }; let handler = container.branch_handler().into_diagnostic()?; - handler.branch(request).into_diagnostic()?; + let response = handler.branch(request).into_diagnostic()?; + + if is_list && response.branches.is_some() { + println!("{}", response.branches.unwrap()); + } Ok(()) } diff --git a/cli/src/commands/commit.rs b/cli/src/commands/commit.rs index 17cbb67..38d7f31 100644 --- a/cli/src/commands/commit.rs +++ b/cli/src/commands/commit.rs @@ -197,11 +197,11 @@ impl MevaCommand for CommitCommand { matches: &ArgMatches, container: &Self::Container, ) -> miette::Result<()> { - let message_arg = matches.get_one::(Self::ARG_MESSAGE).unwrap(); - let author_arg = matches.get_one::(Self::ARG_AUTHOR); + let message = matches.get_one::(Self::ARG_MESSAGE).unwrap(); + let author = matches.get_one::(Self::ARG_AUTHOR); let all_arg = matches.get_flag(Self::ARG_ALL); - let dry_run_arg = matches.get_flag(Self::ARG_DRY_RUN); - let amend_arg = matches.get_flag(Self::ARG_AMEND); + let dry_run = matches.get_flag(Self::ARG_DRY_RUN); + let amend = matches.get_flag(Self::ARG_AMEND); let interceptor = container.plugins_interceptor().into_diagnostic()?; @@ -221,15 +221,13 @@ impl MevaCommand for CommitCommand { } let commit_request = Request { - dry_run_arg, - amend_arg, - message_arg: message_arg.clone(), - author_arg: author_arg.cloned(), + dry_run, + amend, + message: message.clone(), + author: author.cloned(), }; - let commit_handler = container - .commit_handler(&commit_request) - .into_diagnostic()?; + let commit_handler = container.commit_handler(dry_run).into_diagnostic()?; let response = match commit_handler.handle_commit(commit_request, &interceptor) { Ok(val) => val, diff --git a/cli/src/commands/log.rs b/cli/src/commands/log.rs index 0bb7539..faa81ee 100644 --- a/cli/src/commands/log.rs +++ b/cli/src/commands/log.rs @@ -59,6 +59,11 @@ impl LogCommand { /// Depending on the `oneline` flag, this method prints either /// a condensed or detailed view of each commit. fn display_response(&self, response: Response, oneline: bool) { + if response.entries.is_empty() { + println!("{}", "No commits found.".yellow()); + return; + } + for entry in response.entries { match oneline { false => println!("{}", entry.snapshot), diff --git a/engine/src/branch_manager/branch.rs b/engine/src/branch_manager/branch.rs index ff93e5d..1e583cf 100644 --- a/engine/src/branch_manager/branch.rs +++ b/engine/src/branch_manager/branch.rs @@ -1,8 +1,10 @@ +use strum_macros::Display; + /// Categorizes a branch based on its location and purpose within the repository. /// /// This distinction is crucial for determining where the branch reference /// is stored on the filesystem and how it interacts with commands -#[derive(Debug, Clone, Eq, Hash, PartialEq)] +#[derive(Debug, Clone, Eq, Hash, PartialEq, Display)] pub enum BranchType { /// A standard local branch. Local, diff --git a/engine/src/branch_manager/branch_info.rs b/engine/src/branch_manager/branch_info.rs index 9af0541..4908393 100644 --- a/engine/src/branch_manager/branch_info.rs +++ b/engine/src/branch_manager/branch_info.rs @@ -1,7 +1,10 @@ +use super::BranchType; + /// Represents information about a single branch returned by a listing operation. /// /// This enum allows the system to be efficient by fetching and returning /// only the necessary data based on the requested verbosity level. +#[derive(Debug)] pub enum BranchInfo { /// Minimal representation containing only the branch name. /// @@ -10,6 +13,9 @@ pub enum BranchInfo { NameOnly { /// The name of the branch (e.g., "main", "feature/login"). name: String, + + /// The type of the branch. + branch_type: BranchType, }, /// Extended representation including the tip commit's details. @@ -25,5 +31,28 @@ pub enum BranchInfo { /// The commit message associated with the hash. commit_message: String, + + /// The type of the branch. + branch_type: BranchType, }, } + +impl BranchInfo { + /// Retrieves the name of the branch. + pub fn get_name(&self) -> String { + let name = match self { + Self::NameOnly { name, .. } => name, + Self::Detailed { name, .. } => name, + }; + name.clone() + } + + /// Retrieves the type of the branch (e.g., Local or Remote). + pub fn get_type(&self) -> BranchType { + let branch_type = match self { + Self::NameOnly { branch_type, .. } => branch_type, + Self::Detailed { branch_type, .. } => branch_type, + }; + branch_type.clone() + } +} diff --git a/engine/src/branch_manager/meva_branch_manager.rs b/engine/src/branch_manager/meva_branch_manager.rs index d1f4dfc..d670175 100644 --- a/engine/src/branch_manager/meva_branch_manager.rs +++ b/engine/src/branch_manager/meva_branch_manager.rs @@ -351,7 +351,10 @@ impl BranchManager for MevaBranchManager { let local_prefix = self.ref_manager.heads_refs_prefix(); let remote_prefix = self.ref_manager.remotes_refs_prefix(); - let process_entry = |ref_entry: RefEntry, prefix: &str| -> EngineResult { + let process_entry = |ref_entry: RefEntry, + prefix: &str, + branch_type: BranchType| + -> EngineResult { let branch_name = ref_entry .name .strip_prefix(prefix) @@ -363,26 +366,29 @@ impl BranchManager for MevaBranchManager { if detailed { let commit_object = self.object_storage.get_object(&ref_entry.commit_hash)?; - let commit = MevaCommit::try_from(commit_object)?; Ok(BranchInfo::Detailed { name: branch_name, commit_message: commit.message, commit_hash: ref_entry.commit_hash, + branch_type, }) } else { - Ok(BranchInfo::NameOnly { name: branch_name }) + Ok(BranchInfo::NameOnly { + name: branch_name, + branch_type, + }) } }; let local_iter = local_branches .into_iter() - .map(|entry| process_entry(entry, &local_prefix)); + .map(|entry| process_entry(entry, &local_prefix, BranchType::Local)); let remote_iter = remote_branches .into_iter() - .map(|entry| process_entry(entry, &remote_prefix)); + .map(|entry| process_entry(entry, &remote_prefix, BranchType::Remote)); let branches_info: Vec = local_iter.chain(remote_iter).collect::>()?; diff --git a/engine/src/config/config_location.rs b/engine/src/config/config_location.rs index b2267a7..050fd8e 100644 --- a/engine/src/config/config_location.rs +++ b/engine/src/config/config_location.rs @@ -1,6 +1,7 @@ use std::path::{Path, PathBuf}; use shared::UpwardSearch; +use strum_macros::{Display, EnumIter}; use crate::{ RepositoryLayout, @@ -9,9 +10,10 @@ use crate::{ }; /// Defines where to load configuration from: global, local repository, or a specific file. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq, EnumIter, Display)] pub enum ConfigLocation { /// User-level config file in the OS config directory + #[default] Global, /// Repository-specific config file, searched upward from current working directory @@ -33,7 +35,6 @@ impl ConfigLocation { /// Returns `Global` if the path matches the OS config directory, `Local` if it matches /// a repository-specific config, or `File` for any other path. pub fn from_path(path: &Path) -> EngineResult { - // TODO: MevaRepositoryLayout should not be directly used here let layout = MevaRepositoryLayout::from_env()?; let path = path.canonicalize()?; @@ -59,7 +60,6 @@ impl ConfigLocation { /// /// A `PathBuf` pointing to the config file, or an error if resolution fails. pub fn get_default_path(&self) -> EngineResult { - // TODO: MevaRepositoryLayout should not be directly used here let layout = MevaRepositoryLayout::from_env()?; match self { ConfigLocation::Global => { diff --git a/engine/src/diff_builder.rs b/engine/src/diff_builder.rs index b2ca4f1..bf86216 100644 --- a/engine/src/diff_builder.rs +++ b/engine/src/diff_builder.rs @@ -8,8 +8,8 @@ use crate::objects::{MevaCommit, ObjectEntry}; use crate::revision_parsing::Revision; pub use meva_diff_builder::MevaDiffBuilder; pub use models::{ - ChangeKind, DiffMode, DiffStat, FileChange, FileChangeKind, Hunk, HunkLine, HunkLineType, - RevisionRange, RevisionSide, + ChangeKind, DiffMode, DiffStat, FileChange, FileChangeKind, FileDiffStat, Hunk, HunkLine, + HunkLineType, RevisionRange, RevisionSide, }; use std::collections::HashMap; use std::path::PathBuf; @@ -24,6 +24,18 @@ use std::path::PathBuf; /// list of [`FileChange`] structures representing semantic differences between /// snapshots. pub trait DiffBuilder: Send + Sync { + /// Computes the difference between two repository snapshots (commits). + /// + /// # Arguments + /// + /// * `from` - The starting revision for the diff. + /// * `to` - The ending revision for the diff. + /// * `mode` - The level of detail for the diff ([`DiffMode`]). + /// * `paths` - An optional slice of paths to limit the diff to. If `None`, all paths are considered. + /// + /// # Returns + /// + /// A [`EngineResult`] containing a vector of [`FileChange`] objects representing the differences. fn diff_snapshots( &self, from: &Revision, @@ -32,6 +44,18 @@ pub trait DiffBuilder: Send + Sync { paths: Option<&[PathBuf]>, ) -> EngineResult>; + /// Computes the difference between a commit and its first parent. + /// If the commit has no parents (an initial commit), it shows all files as added. + /// + /// # Arguments + /// + /// * `child` - The revision of the commit to analyze. + /// * `mode` - The level of detail for the diff. + /// * `include_changes` - If `false`, skips the diff calculation and returns an empty vector of changes. + /// + /// # Returns + /// + /// A tuple containing the resolved child commit, its hash, and a vector of file changes. fn diff_snapshot_with_parent( &self, child: &Revision, @@ -39,6 +63,17 @@ pub trait DiffBuilder: Send + Sync { include_changes: bool, ) -> EngineResult<(MevaCommit, String, Vec)>; + /// Computes the difference between a repository snapshot and the working directory. + /// + /// # Arguments + /// + /// * `from` - The revision to compare against the working directory. + /// * `mode` - The level of detail for the diff ([`DiffMode`]). + /// * `paths` - An optional slice of paths to limit the diff to. + /// + /// # Returns + /// + /// A [`EngineResult`] containing a vector of [`FileChange`] objects. fn diff_snapshot_working_dir( &self, from: &Revision, @@ -46,6 +81,17 @@ pub trait DiffBuilder: Send + Sync { paths: Option<&[PathBuf]>, ) -> EngineResult>; + /// Computes the difference between a repository snapshot and the index (staging area). + /// + /// # Arguments + /// + /// * `from` - The revision to compare against the index. + /// * `mode` - The level of detail for the diff ([`DiffMode`]). + /// * `paths` - An optional slice of paths to limit the diff to. + /// + /// # Returns + /// + /// A [`EngineResult`] containing a vector of [`FileChange`] objects. fn diff_snapshot_index( &self, from: &Revision, @@ -53,6 +99,24 @@ pub trait DiffBuilder: Send + Sync { paths: Option<&[PathBuf]>, ) -> EngineResult>; + /// Computes the difference between the index (staging area) and the working directory. + /// + /// This method detects changes that have not been staged yet (e.g., modifications, + /// deletions, and untracked files which are considered 'added' relative to the index). + /// + /// # Arguments + /// + /// * `mode` - The level of detail for the diff ([`DiffMode`]). + /// * `paths` - An optional slice of paths to limit the diff to. If `None`, the entire + /// working directory is compared. + /// * `index_map` - An optional, pre-computed map of index entries. If `None`, this + /// method will generate its own map from the loaded index. + /// * `working_map` - An optional, pre-computed map of working directory entries. + /// If `None`, this method will scan the working directory to create one. + /// + /// # Returns + /// + /// A [`EngineResult`] containing a vector of [`FileChange`] objects. fn diff_index_working_dir( &self, mode: &DiffMode, @@ -61,6 +125,20 @@ pub trait DiffBuilder: Send + Sync { working_map: Option>, ) -> EngineResult>; + /// Computes the difference between two tree-like structures represented by object entry maps. + /// + /// This is a core diffing utility that takes two flattened representations of repository trees + /// and produces a vector of file changes. + /// + /// # Arguments + /// + /// * `from_objects` - A map of file paths to object entries representing the "old" state. + /// * `to_objects` - A map of file paths to object entries representing the "new" state. + /// * `mode` - The level of detail for the diff. + /// + /// # Returns + /// + /// A [`EngineResult`] containing a vector of [`FileChange`] objects. fn diff_tree_vs_tree( &self, from_objects: &HashMap, @@ -68,6 +146,19 @@ pub trait DiffBuilder: Send + Sync { mode: &DiffMode, ) -> EngineResult>; + /// Compares two maps of object entries to determine added, deleted, and modified files. + /// + /// # Arguments + /// + /// * `from_objects` - The map representing the "old" state. + /// * `to_objects` - The map representing the "new" state. + /// + /// # Returns + /// + /// A tuple containing three vectors: + /// - `added`: [`ObjectEntry`]s present in `to_objects` but not `from_objects`. + /// - `deleted`: [`ObjectEntry`]s present in `from_objects` but not `to_objects`. + /// - `modified`: Tuples of (`from_entry`, `to_entry`) for entries present in both but with different hashes. fn diff_object_maps<'b>( &self, from_objects: &'b HashMap, @@ -78,25 +169,38 @@ pub trait DiffBuilder: Send + Sync { Vec<(&'b ObjectEntry, &'b ObjectEntry)>, ); + /// Converts a list of added [`ObjectEntry`]s into a vector of [`FileChange`]s. fn added_to_file_changes( &self, added: Vec<&ObjectEntry>, mode: &DiffMode, ) -> EngineResult>; + /// Converts a list of deleted [`ObjectEntry`]s into a vector of [`FileChange`]s. fn deleted_to_file_changes( &self, deleted: Vec<&ObjectEntry>, mode: &DiffMode, ) -> EngineResult>; + /// Converts a list of modified [`ObjectEntry`] pairs into a vector of [`FileChange`]s. fn modified_to_file_changes( &self, modified: Vec<(&ObjectEntry, &ObjectEntry)>, mode: &DiffMode, ) -> EngineResult>; - //TODO: Move this commit tree walker? + /// Recursively traverses a tree object and collects all file entries into a map. + /// + /// # Arguments + /// + /// * `path` - The current directory path being traversed. + /// * `tree_hash` - The hash of the tree object to process. + /// * `paths` - An optional filter to only include specified paths. + /// + /// # Returns + /// + /// A [`EngineResult`] with a [`HashMap`] mapping file paths to their [`ObjectEntry`] data. fn collect_object_entries( &self, path: PathBuf, diff --git a/engine/src/diff_builder/meva_diff_builder.rs b/engine/src/diff_builder/meva_diff_builder.rs index 7c9a964..c19ca72 100644 --- a/engine/src/diff_builder/meva_diff_builder.rs +++ b/engine/src/diff_builder/meva_diff_builder.rs @@ -321,18 +321,6 @@ impl MevaDiffBuilder { } impl DiffBuilder for MevaDiffBuilder { - /// Computes the difference between two repository snapshots (commits). - /// - /// # Arguments - /// - /// * `from` - The starting revision for the diff. - /// * `to` - The ending revision for the diff. - /// * `mode` - The level of detail for the diff ([`DiffMode`]). - /// * `paths` - An optional slice of paths to limit the diff to. If `None`, all paths are considered. - /// - /// # Returns - /// - /// A [`EngineResult`] containing a vector of [`FileChange`] objects representing the differences. fn diff_snapshots( &self, from: &Revision, @@ -349,18 +337,6 @@ impl DiffBuilder for MevaDiffBuilder { self.diff_tree_vs_tree(&from_objects, &to_objects, mode) } - /// Computes the difference between a commit and its first parent. - /// If the commit has no parents (an initial commit), it shows all files as added. - /// - /// # Arguments - /// - /// * `child` - The revision of the commit to analyze. - /// * `mode` - The level of detail for the diff. - /// * `include_changes` - If `false`, skips the diff calculation and returns an empty vector of changes. - /// - /// # Returns - /// - /// A tuple containing the resolved child commit, its hash, and a vector of file changes. fn diff_snapshot_with_parent( &self, child: &Revision, @@ -391,17 +367,6 @@ impl DiffBuilder for MevaDiffBuilder { Ok((child_commit, child_hash, files)) } - /// Computes the difference between a repository snapshot and the working directory. - /// - /// # Arguments - /// - /// * `from` - The revision to compare against the working directory. - /// * `mode` - The level of detail for the diff ([`DiffMode`]). - /// * `paths` - An optional slice of paths to limit the diff to. - /// - /// # Returns - /// - /// A [`EngineResult`] containing a vector of [`FileChange`] objects. fn diff_snapshot_working_dir( &self, from: &Revision, @@ -515,17 +480,6 @@ impl DiffBuilder for MevaDiffBuilder { Ok(self.merge_and_sort(added, deleted, modified)) } - /// Computes the difference between a repository snapshot and the index (staging area). - /// - /// # Arguments - /// - /// * `from` - The revision to compare against the index. - /// * `mode` - The level of detail for the diff ([`DiffMode`]). - /// * `paths` - An optional slice of paths to limit the diff to. - /// - /// # Returns - /// - /// A [`EngineResult`] containing a vector of [`FileChange`] objects. fn diff_snapshot_index( &self, from: &Revision, @@ -540,24 +494,6 @@ impl DiffBuilder for MevaDiffBuilder { self.diff_tree_vs_tree(&from_objects, &to_objects, mode) } - /// Computes the difference between the index (staging area) and the working directory. - /// - /// This method detects changes that have not been staged yet (e.g., modifications, - /// deletions, and untracked files which are considered 'added' relative to the index). - /// - /// # Arguments - /// - /// * `mode` - The level of detail for the diff ([`DiffMode`]). - /// * `paths` - An optional slice of paths to limit the diff to. If `None`, the entire - /// working directory is compared. - /// * `index_map` - An optional, pre-computed map of index entries. If `None`, this - /// method will generate its own map from the loaded index. - /// * `working_map` - An optional, pre-computed map of working directory entries. - /// If `None`, this method will scan the working directory to create one. - /// - /// # Returns - /// - /// A [`EngineResult`] containing a vector of [`FileChange`] objects. fn diff_index_working_dir( &self, mode: &DiffMode, @@ -689,20 +625,6 @@ impl DiffBuilder for MevaDiffBuilder { Ok(all) } - /// Computes the difference between two tree-like structures represented by object entry maps. - /// - /// This is a core diffing utility that takes two flattened representations of repository trees - /// and produces a vector of file changes. - /// - /// # Arguments - /// - /// * `from_objects` - A map of file paths to object entries representing the "old" state. - /// * `to_objects` - A map of file paths to object entries representing the "new" state. - /// * `mode` - The level of detail for the diff. - /// - /// # Returns - /// - /// A [`EngineResult`] containing a vector of [`FileChange`] objects. fn diff_tree_vs_tree( &self, from_objects: &HashMap, @@ -718,19 +640,6 @@ impl DiffBuilder for MevaDiffBuilder { Ok(self.merge_and_sort(added_changes, deleted_changes, modified_changes)) } - /// Compares two maps of object entries to determine added, deleted, and modified files. - /// - /// # Arguments - /// - /// * `from_objects` - The map representing the "old" state. - /// * `to_objects` - The map representing the "new" state. - /// - /// # Returns - /// - /// A tuple containing three vectors: - /// - `added`: [`ObjectEntry`]s present in `to_objects` but not `from_objects`. - /// - `deleted`: [`ObjectEntry`]s present in `from_objects` but not `to_objects`. - /// - `modified`: Tuples of (`from_entry`, `to_entry`) for entries present in both but with different hashes. fn diff_object_maps<'b>( &self, from_objects: &'b HashMap, @@ -766,7 +675,6 @@ impl DiffBuilder for MevaDiffBuilder { (added, deleted, modified) } - /// Converts a list of added [`ObjectEntry`]s into a vector of [`FileChange`]s. fn added_to_file_changes( &self, added: Vec<&ObjectEntry>, @@ -795,7 +703,6 @@ impl DiffBuilder for MevaDiffBuilder { } } - /// Converts a list of deleted [`ObjectEntry`]s into a vector of [`FileChange`]s. fn deleted_to_file_changes( &self, deleted: Vec<&ObjectEntry>, @@ -824,7 +731,6 @@ impl DiffBuilder for MevaDiffBuilder { } } - /// Converts a list of modified [`ObjectEntry`] pairs into a vector of [`FileChange`]s. fn modified_to_file_changes( &self, modified: Vec<(&ObjectEntry, &ObjectEntry)>, @@ -866,17 +772,6 @@ impl DiffBuilder for MevaDiffBuilder { } } - /// Recursively traverses a tree object and collects all file entries into a map. - /// - /// # Arguments - /// - /// * `path` - The current directory path being traversed. - /// * `tree_hash` - The hash of the tree object to process. - /// * `paths` - An optional filter to only include specified paths. - /// - /// # Returns - /// - /// A [`EngineResult`] with a [`HashMap`] mapping file paths to their [`ObjectEntry`] data. fn collect_object_entries( &self, path: PathBuf, diff --git a/engine/src/diff_builder/models.rs b/engine/src/diff_builder/models.rs index bbe2040..68273ec 100644 --- a/engine/src/diff_builder/models.rs +++ b/engine/src/diff_builder/models.rs @@ -13,6 +13,7 @@ pub use diff_mode::DiffMode; pub use diff_stat::DiffStat; pub use file_change::FileChange; pub use file_change_kind::FileChangeKind; +pub use file_diff_stat::FileDiffStat; pub use hunk::{Hunk, HunkLine, HunkLineType}; pub use revision_range::RevisionRange; pub use revision_side::RevisionSide; diff --git a/engine/src/diff_builder/models/change_kind.rs b/engine/src/diff_builder/models/change_kind.rs index f6f343c..a6cd22f 100644 --- a/engine/src/diff_builder/models/change_kind.rs +++ b/engine/src/diff_builder/models/change_kind.rs @@ -100,3 +100,14 @@ impl From<&FileChangeKind> for ChangeKind { } } } + +impl From for char { + fn from(val: ChangeKind) -> Self { + match val { + ChangeKind::Added => 'A', + ChangeKind::Deleted => 'D', + ChangeKind::Modified => 'M', + ChangeKind::Unmerged => 'U', + } + } +} diff --git a/engine/src/diff_builder/models/file_change_kind.rs b/engine/src/diff_builder/models/file_change_kind.rs index 36f8d0c..f941b7e 100644 --- a/engine/src/diff_builder/models/file_change_kind.rs +++ b/engine/src/diff_builder/models/file_change_kind.rs @@ -102,4 +102,26 @@ impl FileChangeKind { deletions: 0, } } + + /// Retrieves the line change statistics for the file. + /// + /// This helper method standardizes the access to insertion and deletion counts + /// across different change variants. + /// + /// # Returns + /// + /// A tuple `(usize, usize)` containing: + /// * The number of **insertions** (lines added). + /// * The number of **deletions** (lines removed). + pub fn get_file_stats(&self) -> (usize, usize) { + match self { + Self::Added { insertions, .. } => (*insertions, 0), + Self::Deleted { deletions, .. } => (0, *deletions), + Self::Modified { + insertions, + deletions, + .. + } => (*insertions, *deletions), + } + } } diff --git a/engine/src/diff_builder/models/hunk.rs b/engine/src/diff_builder/models/hunk.rs index 07a3a9f..7c9c63b 100644 --- a/engine/src/diff_builder/models/hunk.rs +++ b/engine/src/diff_builder/models/hunk.rs @@ -84,6 +84,17 @@ pub enum HunkLineType { Deletion, } +impl HunkLineType { + /// Returns the symbol associated with hunk type. + pub fn get_symbol(&self) -> char { + match self { + HunkLineType::Context => ' ', + HunkLineType::Addition => '+', + HunkLineType::Deletion => '-', + } + } +} + impl Display for HunkLineType { /// Formats the line type as its diff marker with color. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 9ed56e5..213f9f3 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -7,7 +7,7 @@ use crate::commit_builder::MevaCommitBuilder; use crate::diff_builder::MevaDiffBuilder; use crate::errors::EngineResult; use crate::handlers::{ - add::AddHandler, branch::BranchHandler, clone::CloneHandler, commit, commit::CommitHandler, + add::AddHandler, branch::BranchHandler, clone::CloneHandler, commit::CommitHandler, config::ConfigHandler, diff::DiffHandler, fetch::FetchHandler, init::InitHandler, log::LogHandler, ls_files::LsFilesHandler, ls_tree::LsTreeHandler, plugins::PluginsHandler, remote::RemoteHandler, restore::RestoreHandler, show::ShowHandler, status::StatusHandler, @@ -50,7 +50,7 @@ pub trait EngineContainer { fn status_handler(&self) -> EngineResult; /// Return the handler responsible for commit creation - fn commit_handler(&self, request: &commit::Request) -> EngineResult; + fn commit_handler(&self, dry_run: bool) -> EngineResult; /// Return the handler responsible for log command fn log_handler(&self) -> EngineResult; @@ -213,7 +213,7 @@ impl EngineContainer for MevaContainer { Ok(handler) } - fn commit_handler(&self, request: &commit::Request) -> EngineResult { + fn commit_handler(&self, dry_run: bool) -> EngineResult { let repo_layout = self.repository_layout_discover()?; let refs_manager = Arc::new(MevaRefManager::new(repo_layout.clone())); let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); @@ -244,7 +244,7 @@ impl EngineContainer for MevaContainer { commit_history_walker, refs_manager.clone(), )); - let commit_builder = match request.dry_run_arg { + let commit_builder = match dry_run { true => { let dry_run_object_storage = Arc::new(MevaDryRunObjectStorage); Arc::new(MevaCommitBuilder::new(dry_run_object_storage, index_abs)) diff --git a/engine/src/handlers/add/operations.rs b/engine/src/handlers/add/operations.rs index d491dea..7eab475 100644 --- a/engine/src/handlers/add/operations.rs +++ b/engine/src/handlers/add/operations.rs @@ -1,7 +1,8 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::errors::EngineResult; +#[derive(Debug, Clone, Default)] pub struct AddRequest { pub all_flag: bool, pub force_flag: bool, @@ -11,6 +12,15 @@ pub struct AddRequest { pub path_arg: Option, } +impl AddRequest { + pub fn with_path(path: &Path) -> Self { + AddRequest { + path_arg: Some(path.to_path_buf()), + ..Default::default() + } + } +} + pub struct AddResponse { pub added: Vec, pub modified: Vec, diff --git a/engine/src/handlers/branch.rs b/engine/src/handlers/branch.rs index 7723d6c..9d2d49c 100644 --- a/engine/src/handlers/branch.rs +++ b/engine/src/handlers/branch.rs @@ -1,8 +1,7 @@ -mod display_branches_list; +mod branch_collection; mod handlers; mod operations; -use display_branches_list::DisplayBranchesList; - +pub use branch_collection::BranchCollection; pub use handlers::BranchHandler; pub use operations::*; diff --git a/engine/src/handlers/branch/display_branches_list.rs b/engine/src/handlers/branch/branch_collection.rs similarity index 52% rename from engine/src/handlers/branch/display_branches_list.rs rename to engine/src/handlers/branch/branch_collection.rs index 21d3d18..fe489d7 100644 --- a/engine/src/handlers/branch/display_branches_list.rs +++ b/engine/src/handlers/branch/branch_collection.rs @@ -1,4 +1,4 @@ -use crate::branch_manager::BranchInfo; +use crate::branch_manager::{BranchInfo, BranchType}; use owo_colors::OwoColorize; use std::fmt::{Display, Formatter, Result}; @@ -14,7 +14,8 @@ use std::fmt::{Display, Formatter, Result}; /// * **Alignment**: In detailed mode, commit hashes and messages are vertically /// aligned based on the longest branch name. /// * **Commit Details**: Hashes are shortened to 7 characters and dimmed. -pub struct DisplayBranchesList { +#[derive(Debug)] +pub struct BranchCollection { /// The name of the currently active branch (HEAD). /// Used to determine which branch gets the `*` marker and green highlight. pub current_branch: Option, @@ -23,14 +24,54 @@ pub struct DisplayBranchesList { pub branches_list: Vec, } -impl Display for DisplayBranchesList { +impl BranchCollection { + /// Checks if the collection contains at least one local branch. + pub fn has_local_branches(&self) -> bool { + self.branches_list + .iter() + .any(|info| self.is_branch_type(info, BranchType::Local)) + } + + /// Checks if the collection contains at least one remote branch. + pub fn has_remote_branches(&self) -> bool { + self.branches_list + .iter() + .any(|info| self.is_branch_type(info, BranchType::Remote)) + } + + /// Returns an iterator yielding only local branches. + pub fn local_branches(&self) -> impl Iterator { + self.branches_list + .iter() + .filter(|info| self.is_branch_type(info, BranchType::Local)) + } + + /// Returns an iterator yielding only remote branches. + pub fn remote_branches(&self) -> impl Iterator { + self.branches_list + .iter() + .filter(|info| self.is_branch_type(info, BranchType::Remote)) + } + + /// Helper method to check if a specific [`BranchInfo`] matches a target [`BranchType`]. + /// + /// This abstracts away the destructuring of the [`BranchInfo`] enum variants. + fn is_branch_type(&self, info: &BranchInfo, target: BranchType) -> bool { + match info { + BranchInfo::NameOnly { branch_type, .. } => *branch_type == target, + BranchInfo::Detailed { branch_type, .. } => *branch_type == target, + } + } +} + +impl Display for BranchCollection { fn fmt(&self, f: &mut Formatter<'_>) -> Result { let list = &self.branches_list; let max_width = list .iter() .map(|info| match info { - BranchInfo::NameOnly { name } => name.len(), + BranchInfo::NameOnly { name, .. } => name.len(), BranchInfo::Detailed { name, .. } => name.len(), }) .max() @@ -38,11 +79,12 @@ impl Display for DisplayBranchesList { for branch in list { let (name, details) = match branch { - BranchInfo::NameOnly { name } => (name, None), + BranchInfo::NameOnly { name, .. } => (name, None), BranchInfo::Detailed { name, commit_hash, commit_message, + .. } => (name, Some((commit_hash, commit_message))), }; @@ -52,9 +94,9 @@ impl Display for DisplayBranchesList { let marker = if is_current { "*" } else { " " }; if is_current { - write!(f, "{} {}", marker, name.green())?; + write!(f, "{marker} {}", name.green())?; } else if is_remote { - write!(f, "{} {}", marker, name.red())?; + write!(f, "{marker} {}", name.red())?; } else { write!(f, "{marker} {name}")?; } @@ -65,7 +107,7 @@ impl Display for DisplayBranchesList { let short_hash = hash.get(0..7).unwrap_or(hash); - writeln!(f, "{}{} {}", padding, short_hash.dimmed(), message)?; + writeln!(f, "{padding}{} {message}", short_hash.dimmed())?; } else { writeln!(f)?; } diff --git a/engine/src/handlers/branch/handlers.rs b/engine/src/handlers/branch/handlers.rs index 18069ff..d4e5e9a 100644 --- a/engine/src/handlers/branch/handlers.rs +++ b/engine/src/handlers/branch/handlers.rs @@ -1,8 +1,8 @@ use crate::branch_manager::{Branch, BranchType}; use crate::errors::EngineResult; use crate::handlers::branch::{ - BranchOperations, CreateRequest, DeleteRequest, DisplayBranchesList, ListRequest, - RenameRequest, Request, + BranchCollection, BranchOperations, CreateRequest, DeleteRequest, ListRequest, RenameRequest, + Request, Response, }; use crate::{BranchManager, RevisionResolver}; use std::sync::Arc; @@ -95,30 +95,34 @@ impl BranchHandler { /// # Errors /// /// Returns an [`EngineError`] if reading the references or object storage fails. - fn handle_list(&self, request: ListRequest) -> EngineResult<()> { + fn handle_list(&self, request: ListRequest) -> EngineResult { let current_branch = self.branch_manager.current_branch_name()?; let branches_list = self.branch_manager .list_branches(request.local, request.remotes, request.verbose)?; - let display_list = DisplayBranchesList { + let display_list = BranchCollection { current_branch, branches_list, }; - println!("{display_list}"); - - Ok(()) + Ok(display_list) } } impl BranchOperations for BranchHandler { - fn branch(&self, request: Request) -> EngineResult<()> { + fn branch(&self, request: Request) -> EngineResult { match request { - Request::Create(request) => self.handler_create(request), - Request::Delete(request) => self.handler_delete(request), - Request::Rename(request) => self.handle_rename(request), - Request::List(request) => self.handle_list(request), + Request::Create(request) => self.handler_create(request)?, + Request::Delete(request) => self.handler_delete(request)?, + Request::Rename(request) => self.handle_rename(request)?, + Request::List(request) => { + return Ok(Response { + branches: Some(self.handle_list(request)?), + }); + } } + + Ok(Response::default()) } } diff --git a/engine/src/handlers/branch/operations.rs b/engine/src/handlers/branch/operations.rs index da980eb..72e47c9 100644 --- a/engine/src/handlers/branch/operations.rs +++ b/engine/src/handlers/branch/operations.rs @@ -1,6 +1,8 @@ use crate::errors::EngineResult; use crate::revision_parsing::Revision; +use super::branch_collection::BranchCollection; + /// Represents a unified request type for all branch-related operations. /// /// This enum acts as a container that encapsulates specific parameters @@ -55,6 +57,7 @@ pub struct RenameRequest { } /// Configuration options for listing branches. +#[derive(Debug, Default)] pub struct ListRequest { /// If `true` (`-v`), provides detailed output including the SHA-1 hash /// and the subject line of the tip commit for each branch. @@ -67,6 +70,21 @@ pub struct ListRequest { pub remotes: bool, } +impl ListRequest { + pub fn local_only(verbose: bool) -> Self { + Self { + local: true, + verbose, + ..Default::default() + } + } +} + +#[derive(Debug, Default)] +pub struct Response { + pub branches: Option, +} + /// Defines the high-level interface for handling branch command logic. /// /// This trait is typically implemented by a specific command handler @@ -77,5 +95,5 @@ pub trait BranchOperations { /// /// This method dispatches the specific logic for creation, deletion, /// renaming, or listing. - fn branch(&self, request: Request) -> EngineResult<()>; + fn branch(&self, request: Request) -> EngineResult; } diff --git a/engine/src/handlers/commit/handlers.rs b/engine/src/handlers/commit/handlers.rs index f24053c..431e5dd 100644 --- a/engine/src/handlers/commit/handlers.rs +++ b/engine/src/handlers/commit/handlers.rs @@ -109,7 +109,7 @@ impl CommitHandler { match head_hash { None => Ok(!self.index.get_entries().is_empty()), Some(hash) => { - if request.amend_arg { + if request.amend { return Ok(true); } let revision = Revision::hash(&hash, Vec::new()); @@ -186,7 +186,7 @@ impl CommitHandler { impl CommitOperations for CommitHandler { fn commit(&self, request: Request) -> EngineResult { - let author = match request.author_arg.clone() { + let author = match request.author.clone() { Some(a) => a, None => Person { name: self.get_username()?, @@ -196,11 +196,11 @@ impl CommitOperations for CommitHandler { let commit = self .commit_builder - .build_commit(request.message_arg.clone(), author)?; + .build_commit(request.message.clone(), author)?; - let commit_hash = if request.dry_run_arg { + let commit_hash = if request.dry_run { None - } else if request.amend_arg { + } else if request.amend { Some(self.branch_manager.amend_last_commit(commit.clone())?) } else { Some(self.branch_manager.add_commit(commit.clone())?) @@ -218,13 +218,13 @@ impl CommitOperations for CommitHandler { impl PluginsInvocationMapper for CommitHandler { fn request_to_payload(&self, req: &Request) -> EngineResult { Ok(InvocationPrePayload::Commit(CommitPrePayload { - message: req.message_arg.clone(), + message: req.message.clone(), author: CommitAuthor { - name: req.author_arg.clone().map(|a| a.name).unwrap_or_default(), - email: req.author_arg.clone().map(|a| a.email).unwrap_or_default(), + name: req.author.clone().map(|a| a.name).unwrap_or_default(), + email: req.author.clone().map(|a| a.email).unwrap_or_default(), }, - dry_run: req.dry_run_arg, - amend: req.amend_arg, + dry_run: req.dry_run, + amend: req.amend, })) } @@ -293,13 +293,13 @@ impl PluginsInvocationMapper for CommitHandler { fn input_to_request(&self, input: &InvocationInput) -> EngineResult { if let Some(InvocationPrePayload::Commit(pre)) = &input.pre_payload { Ok(Request { - message_arg: pre.message.clone(), - author_arg: Some(Person { + message: pre.message.clone(), + author: Some(Person { name: pre.author.name.clone(), email: pre.author.name.clone(), }), - dry_run_arg: pre.dry_run, - amend_arg: pre.amend, + dry_run: pre.dry_run, + amend: pre.amend, }) } else { Err(EngineError::Plugins(PluginError::PrePayload { diff --git a/engine/src/handlers/commit/operations.rs b/engine/src/handlers/commit/operations.rs index 3ad3e61..a8ec3ee 100644 --- a/engine/src/handlers/commit/operations.rs +++ b/engine/src/handlers/commit/operations.rs @@ -6,19 +6,29 @@ use crate::objects::{MevaCommit, Person}; /// /// This struct holds input data passed to the commit process, /// including the commit message, author information, and execution flags. -#[derive(Debug)] +#[derive(Debug, Default)] pub struct Request { /// Commit message provided by the user. - pub message_arg: String, + pub message: String, /// Optional author information (name and email). - pub author_arg: Option, + pub author: Option, /// If `true`, the commit is simulated without actually modifying the repository. - pub dry_run_arg: bool, + pub dry_run: bool, /// If `true`, the last commit is amended instead of creating a new one. - pub amend_arg: bool, + pub amend: bool, +} + +impl Request { + pub fn new(message: String, amend: bool) -> Self { + Self { + message, + amend, + ..Default::default() + } + } } /// Represents the result of executing the `meva commit` command. diff --git a/engine/src/handlers/config/operations.rs b/engine/src/handlers/config/operations.rs index 3d97b2a..58a830a 100644 --- a/engine/src/handlers/config/operations.rs +++ b/engine/src/handlers/config/operations.rs @@ -1,50 +1,118 @@ +use std::collections::BTreeMap; + use crate::{ConfigLocation, errors::EngineResult}; +/// Represents a request to retrieve a specific configuration value. pub struct GetRequest { + /// The scope/location where the configuration should be searched (e.g., Global, Local). pub location: ConfigLocation, + /// The configuration key to look up (e.g., "user.name"). pub key: String, + /// An optional default value to return if the key is not found. pub default: Option, } +/// Represents the result of a successful configuration retrieval. pub struct GetResponse { + /// The key that was requested. pub key: String, + /// The value associated with the key. pub value: String, } +/// Represents a request to set or update a configuration value. pub struct SetRequest { + /// The scope/location where the configuration should be saved. pub location: ConfigLocation, + /// The key to set (e.g., "user.email"). pub key: String, + /// The value to assign to the key. pub value: String, } +/// Represents the confirmation of a successful set operation. pub struct SetResponse { + /// The key that was set. pub key: String, + /// The value that was assigned. pub value: String, } +/// Represents a request to remove a specific configuration entry. pub struct UnsetRequest { + /// The scope/location from which the key should be removed. pub location: ConfigLocation, + /// The key to remove. pub key: String, } +/// Represents the result of a successful unset operation. pub struct UnsetResponse { + /// The value of the key that was just removed (if it existed). pub value: String, } +/// Represents a request to list all configuration entries in a specific location. +#[derive(Debug)] pub struct ListRequest { + /// The scope/location to list configurations from. pub location: ConfigLocation, } +/// Contains the list of configuration entries retrieved from a location. +#[derive(Debug)] pub struct ListResponse { + /// A vector of key-value pairs (e.g., `vec![("user.name", "John")]`). pub key_values: Vec<(String, String)>, } +impl ListResponse { + /// Organizes configuration keys into groups based on their prefix. + /// + /// This method expects keys to be in the format `section.key` (e.g., `user.name`). + /// It splits keys by the first dot separator. + /// + /// # Returns + /// + /// A `BTreeMap` where: + /// * **Key:** The prefix/section name (e.g., "user"). If no dot is present, "general" is used. + /// * **Value:** A list of tuples containing the suffix (key name) and the value. + /// + /// Using `BTreeMap` ensures that the sections are sorted alphabetically, which is ideal for UI rendering. + pub fn group_by_prefix(&self) -> BTreeMap<&str, Vec<(&str, &str)>> { + let mut groups: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new(); + + for (full_key, value) in &self.key_values { + let (prefix, suffix) = match full_key.split_once('.') { + Some((p, s)) => (p, s), + // Fallback for keys without a section separator + None => ("general", full_key.as_str()), + }; + + groups + .entry(prefix) + .or_default() + .push((suffix, value.as_str())); + } + + groups + } +} + +/// Defines the core operations available for configuration management. +/// +/// This trait abstracts the logic for reading, writing, and listing configuration values +/// regardless of the underlying storage mechanism (file-based, memory, etc.). pub trait ConfigOperations { + /// Retrieves a single configuration value. fn get(&self, request: GetRequest) -> EngineResult; + /// Sets or updates a single configuration value. fn set(&self, request: SetRequest) -> EngineResult; + /// Removes a single configuration value. fn unset(&self, request: UnsetRequest) -> EngineResult; + /// Lists all configuration values for a given location. fn list(&self, request: ListRequest) -> EngineResult; } diff --git a/engine/src/handlers/diff/operations.rs b/engine/src/handlers/diff/operations.rs index ade7267..ba87605 100644 --- a/engine/src/handlers/diff/operations.rs +++ b/engine/src/handlers/diff/operations.rs @@ -3,14 +3,40 @@ use crate::errors::EngineResult; use crate::revision_parsing::Revision; use std::path::PathBuf; +/// Represents a request to calculate the difference between two states in the repository. +/// +/// This structure encapsulates all necessary parameters to perform a diff, including +/// the range of revisions to compare, the output mode (e.g., stats or full diff), +/// and an optional filter for specific file paths. #[derive(Debug)] pub struct Request { + /// Determines the level of detail for the diff output (e.g., `Stat`, `NameOnly`, `Patch`). pub mode: DiffMode, + /// Specifies the source and target revisions to compare (e.g., `HEAD` vs `WorkingTree`). pub range: RevisionRange, + /// Optional list of paths to filter the diff. If `None`, all changed files are included. pub paths: Option>, } impl Request { + /// Creates a new `Request` by resolving the provided revision arguments into a [`RevisionRange`]. + /// + /// This helper handles common diff scenarios: + /// 1. **Two revisions (`from` & `to`):** Compares two specific points in history. + /// 2. **One revision (`from` only):** Compares the given revision against the working tree (or index if `cached` is true). + /// 3. **No revisions:** Performs a default diff (usually Index vs. Working Tree). + /// + /// # Arguments + /// + /// * `from` - The source revision (e.g., "HEAD", "e4a1b2"). + /// * `to` - The target revision. If `None`, usually implies the Working Tree or Index. + /// * `cached` - If `true`, compares against the Index (Staging Area) instead of the Working Tree (relevant when `to` is `None`). + /// * `mode` - The desired output format. + /// * `paths` - Specific files to limit the operation to. + /// + /// # Panics + /// + /// Panics if `to` is specified without `from`, as this is an invalid state for a diff range. pub fn new( from: Option, to: Option, @@ -39,11 +65,30 @@ impl Request { } } +/// Represents the result of a diff operation. +/// +/// Contains the calculated differences, which may include high-level statistics +/// and/or detailed file changes, depending on the requested `DiffMode`. +#[derive(Debug)] pub struct Response { + /// Optional summary statistics (insertions, deletions, files changed). pub stat: Option, + /// Optional list of detailed changes per file. pub files: Option>, } +/// Defines the core logic for performing diff operations. +/// +/// This trait abstracts the mechanism of calculating differences between repository states. pub trait DiffOperations { + /// Calculates the diff based on the provided request parameters. + /// + /// # Arguments + /// + /// * `request` - configuration object specifying what to compare and how. + /// + /// # Returns + /// + /// Returns a `Response` containing the diff results or an error if the operation fails. fn diff(&self, request: Request) -> EngineResult; } diff --git a/engine/src/handlers/init/operations.rs b/engine/src/handlers/init/operations.rs index 3f8bafe..49751bd 100644 --- a/engine/src/handlers/init/operations.rs +++ b/engine/src/handlers/init/operations.rs @@ -7,6 +7,7 @@ pub struct Request { pub initial_branch: String, } +#[derive(Debug, Clone)] pub struct Response { pub repository_dir: PathBuf, } diff --git a/engine/src/handlers/log/handlers.rs b/engine/src/handlers/log/handlers.rs index cb28362..eccc0b4 100644 --- a/engine/src/handlers/log/handlers.rs +++ b/engine/src/handlers/log/handlers.rs @@ -108,7 +108,10 @@ impl LogOperations for LogHandler { .revision .unwrap_or_else(|| Revision::head(Vec::new())); - let commit_object = self.revision_resolver.resolve_object(&revision)?; + let commit_object = match self.revision_resolver.resolve_object(&revision) { + Ok(commit_object) => commit_object, + Err(_) => return Ok(result), + }; let mut current_commit_hash = Some(commit_object.sha1().clone()); let mut current_commit = Some(MevaCommit::try_from(commit_object)?); let mut next_commit_hash = match current_commit.as_ref() { diff --git a/engine/src/handlers/log/operations.rs b/engine/src/handlers/log/operations.rs index 231d3fc..724492d 100644 --- a/engine/src/handlers/log/operations.rs +++ b/engine/src/handlers/log/operations.rs @@ -6,6 +6,7 @@ use chrono::{DateTime, Utc}; use regex::Regex; /// Represents a single commit entry in the log output. +#[derive(Debug)] pub struct LogEntry { /// Snapshot of the commit, including hash, message, author, and timestamp. pub snapshot: Snapshot, @@ -18,6 +19,7 @@ pub struct LogEntry { /// /// Contains all filtering, formatting, and traversal options /// used to control how commit history is retrieved and displayed. +#[derive(Debug, Default)] pub struct Request { /// Show each commit in a condensed single-line format. pub oneline: bool, @@ -53,6 +55,7 @@ pub struct Request { /// Represents the response from the `log` command handler. /// /// Contains the list of commit entries retrieved based on the request. +#[derive(Debug, Default)] pub struct Response { /// The retrieved commit entries. pub entries: Vec, diff --git a/engine/src/handlers/status.rs b/engine/src/handlers/status.rs index 9f86a02..60353b8 100644 --- a/engine/src/handlers/status.rs +++ b/engine/src/handlers/status.rs @@ -3,4 +3,5 @@ mod models; mod operations; pub use handlers::StatusHandler; +pub use models::{BranchInfo, StatusEntry, StatusKind}; pub use operations::*; diff --git a/engine/src/handlers/status/handlers.rs b/engine/src/handlers/status/handlers.rs index 85affec..814505d 100644 --- a/engine/src/handlers/status/handlers.rs +++ b/engine/src/handlers/status/handlers.rs @@ -302,12 +302,12 @@ impl StatusHandler { .collect(); let head_objects = match head_hash { - Some(head_hash) => { + Some(head_hash) if !head_hash.is_empty() => { let head_commit = MevaCommit::try_from(self.object_storage.get_object(head_hash)?)?; self.diff_builder .collect_object_entries(PathBuf::new(), &head_commit.tree, None)? } - None => HashMap::::new(), + _ => HashMap::::new(), }; let changes = self.diff_builder.diff_tree_vs_tree( diff --git a/engine/src/handlers/status/models.rs b/engine/src/handlers/status/models.rs index e98eee8..520a2ad 100644 --- a/engine/src/handlers/status/models.rs +++ b/engine/src/handlers/status/models.rs @@ -6,3 +6,4 @@ mod status_kind; pub use branch_info::BranchInfo; pub use merge_entry::MergeEntry; pub use status_entry::StatusEntry; +pub use status_kind::StatusKind; diff --git a/engine/src/handlers/status/models/status_kind.rs b/engine/src/handlers/status/models/status_kind.rs index 726dba3..8cb1976 100644 --- a/engine/src/handlers/status/models/status_kind.rs +++ b/engine/src/handlers/status/models/status_kind.rs @@ -29,3 +29,14 @@ pub enum StatusKind { theirs: ChangeKind, }, } + +impl From for char { + fn from(val: StatusKind) -> Self { + match val { + StatusKind::Unstaged(kind) | StatusKind::Staged(kind) => kind.into(), + StatusKind::Unmerged { .. } => '!', + StatusKind::Ignored => '#', + StatusKind::Untracked => '?', + } + } +} diff --git a/engine/src/handlers/status/operations.rs b/engine/src/handlers/status/operations.rs index d973b20..bfe5142 100644 --- a/engine/src/handlers/status/operations.rs +++ b/engine/src/handlers/status/operations.rs @@ -42,13 +42,21 @@ impl Request { short_format: short, } } + + pub fn with_branch() -> Self { + Self { + show_branch: true, + show_ignored: false, + short_format: false, + } + } } /// Contains the categorized results of a status operation. /// /// This struct is populated by the `status` operation and contains all the /// data necessary to render a complete status report. -#[derive(Debug, Default)] +#[derive(Debug, Clone, Default)] pub struct Response { /// Information about the current branch and HEAD state. pub branch: Option, diff --git a/gui/Cargo.toml b/gui/Cargo.toml index ef803a4..4b1fac0 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -5,9 +5,19 @@ edition = "2024" authors.workspace = true [dependencies] +egui_extras = { version = "0.33.3", features = ["all_loaders"] } +egui-phosphor = { version = "0.11.0", features = ["fill"] } shared = { path = "../shared" } engine = { path = "../engine" } plugins = { path = "../plugins" } +eframe = "0.33.3" +egui = "0.33.3" +image = { version = "0.25.9", features = ["png"] } +rfd = "0.16.0" +tokio.workspace = true +strum.workspace = true +strum_macros.workspace = true +chrono.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/gui/assets/feather.png b/gui/assets/feather.png new file mode 100644 index 0000000000000000000000000000000000000000..80e115206154dff759800ceb173e1a0a4bcb8985 GIT binary patch literal 2331 zcmV+$3FP*PP)6IdY%LmXX@`mmOsGIb0-;1CBS2u7kjuGzB=P-xJ^NkLa~Dd8?bul$>?cL>_r2fy zzTfYC-~ay|0{B0NXu1U?g8)GQM};{mOjINlK)zfk$dw5NhRPW^nlhtS)9VVfA85Jt zn}@;xDiokOQtZ)eRIose3g>At(QJ)aWCE7OM>#VLT9{j21h`ZT7TX9|2!AU8OrHU< zP#`N)NY<9fu{8#s6gaFETCC829$s`!eD=UJc+-$Hpy>RMM8+eJ(o#z3HvBHBiFhip#-cbhS(U-JjX>r)c_ z0||LXod$BWYG7Fwmd&dKnqlFq<{RK4f@$^B9V8Er{+==e*(zMSr|!oGmR6NL$CqO= zX;Ln4bjjQbz(m4$YHV${07Wxt^+U4UV0R#TVzfLZ29{NoF8}?e=RVUY=Vx%gFyK{*7l>Z285HR_zra$heQ;y44PJXnZh6 zqA|E~+XAzS@;}8b3q&w+06SyM`QtXr*pOH;&k{y#}LpY30(sYP~kE#WT`Ugj$ehT-!)u&p=DsCF&QUq1I0Poicj9& z(5O@4hIIIZ)fS)sT@DwRmM#J;N>*@oeYd@l?FDCZb-g&>i{p zrsiC&dS*KOpu+)yK^ydpcwz4HYEUQ@@p_JBf%N%+bbBF25b<(iaLm51|NZLmo4ub* z)YpUt6jHJ5)9oA2m**Q6q$8fsJVBcsto{&GEnNgssU)#PmIEpjf>6&v{fB>ez_fTD?Wd@AhyqJGPv#;XT1c_*cT zO;0U-JDo{SX8JR94RiktoCL%yF>qSp8cco^JYcW-q2NkgHW0=9Cm9+z#Ij z(oX<|CgrYfB3B^Y#I(YGB_m{X-jB6CK>?~Za%TnUm0tDjo=WnMjZvV^43;Qw^ zpVGh*Q|T%Mfs{u-iX_3`V*wgPA`4l%i?AUL}`G z5CjqXf?@w;#PdihaNYM7y>2QrCfzD9F$z2#oxlJBdQ&B!DFT*DfT7VmiN z>E+udp?4gT8qg|nW3p#N{~$0C60*w60Fxxn^QmyIt>g2Ze?Re7#xEv7(io6SFh!#J z(1;UAmkZRTGXcZpK!hXq>pj+sQZc46s1?R+wZbUDMA~F)WNT-C{g(HStYg@u%kb0~ zNOYEp1_BW19{_o-5hNOQyiO^T=FTrKthv(FU;lA^%hBd`OTEqQv*u~ld3u$iz^GN_ z88pfQeh($ZFv;$JoZ23x=-5<Fkd0>vg6|5M;Q@c3OwO zUh~_(J`ssgQ}*RYku-=M9=~<;H^0ZB0s&B$mSilR$H6mA`|pQ%xYvLH0r-AJ!D@b3 z21Yzx5TEx!2-)m_=NbW%%QNoaaDB_}V@+*Gr%>}4j0xg=jWTb?sz*;qM8X$u4-8#r zX&QW%UO;giu zbO;~v`n&<bW_3eINgHs?udLj8Vl;p;L9w3`tOtVI&yo%*LhVU@-JcG#ZTG!T;AV zFNmU9h9p9y&l3o_T|{u$;r91;+egf87Hf;e?(9o)pEczeGlf_V;D*E9xUfLKh8NGW zZ1~`bhWDBWoo5Gq!Ja71+}E!;8H}43PgdmD?pQhhc>Gon`If+OT*Mh7`w24Uj!<-n z9|e}#Soq_ zY47y}I(xmu?Fhxt(kE002ovPDHLkV1huh Ba>oDw literal 0 HcmV?d00001 diff --git a/gui/src/events.rs b/gui/src/events.rs new file mode 100644 index 0000000..5642c97 --- /dev/null +++ b/gui/src/events.rs @@ -0,0 +1,107 @@ +mod worker_event; +mod worker_result; + +pub use worker_event::{EventError, WorkerEvent}; +pub use worker_result::WorkerResult; + +use std::{ + sync::mpsc::{Receiver, Sender, channel}, + thread, +}; + +/// Manages asynchronous background tasks to prevent blocking the UI thread. +/// +/// `AsyncWorker` acts as a bridge between the immediate mode UI (egui) and long-running +/// operations (such as file I/O or network requests). It handles the thread spawning, +/// progress reporting, and result retrieval. +#[derive(Default)] +pub struct AsyncWorker { + /// Indicates whether a background task is currently executing. + /// + /// Use this flag to conditionally render loading spinners or disable interaction + /// in the UI while work is being done. + pub is_active: bool, + + /// The current status message reported by the active background task. + pub loading_message: String, + + /// The receiver end of the channel used to communicate with the background thread. + /// + /// This is `None` when the worker is idle, and `Some` when a task is running. + rx: Option>, +} + +impl AsyncWorker { + /// Spawns a new background thread to execute the given task. + /// + /// This method sets up the communication channel, updates the worker state to active, + /// and starts the thread. It guarantees that `ctx.request_repaint()` is called + /// when the task finishes to ensure the UI updates immediately. + /// + /// # Arguments + /// + /// * `ctx` - The `egui::Context` clone. It is required to wake up the UI thread + /// when the worker sends an event or finishes. + /// * `task` - A closure that defines the work to be done. It receives a [`Sender`] + /// to send [`WorkerEvent`]s (Progress, Success, or Error) back to the main thread. + pub fn spawn(&mut self, ctx: egui::Context, task: F) + where + F: FnOnce(Sender) + Send + 'static, + { + let (tx, rx) = channel(); + self.rx = Some(rx); + self.is_active = true; + self.loading_message = "Starting...".to_string(); + + thread::spawn(move || { + task(tx); + // Ensure the UI refreshes to process the final result or progress update. + ctx.request_repaint(); + }); + } + + /// Polls the worker for updates from the background thread. + /// + /// This method should be called once per frame in the main application loop. + /// It processes all pending events in the channel queue to ensure the UI is + /// up-to-date. + /// + /// # Returns + /// + /// * `Some(WorkerResult)` - If the task completed successfully or failed with an error. + /// * `None` - If the task is still running, or if no task is active. + pub fn poll(&mut self) -> Option { + if !self.is_active { + return None; + } + + let mut result_to_return = None; + let mut should_close = false; + + if let Some(rx) = &self.rx { + while let Ok(event) = rx.try_recv() { + match event { + WorkerEvent::Progress(msg) => { + self.loading_message = msg; + } + WorkerEvent::Success(data) => { + should_close = true; + result_to_return = Some(data); + } + WorkerEvent::Error(err) => { + should_close = true; + result_to_return = Some(WorkerResult::Error(err)); + } + } + } + } + + // Cleanup if the task finished + if should_close { + self.is_active = false; + self.rx = None; + } + + result_to_return + } +} diff --git a/gui/src/events/worker_event.rs b/gui/src/events/worker_event.rs new file mode 100644 index 0000000..cff645f --- /dev/null +++ b/gui/src/events/worker_event.rs @@ -0,0 +1,55 @@ +use super::WorkerResult; + +/// Represents the stream of events sent from a background worker thread to the main UI thread. +/// +/// This enum allows the worker to report intermediate progress, signal successful completion +/// with data, or report a failure. +pub enum WorkerEvent { + /// Indicates that the operation is ongoing. + /// + /// The string payload usually contains a user-friendly status message. + Progress(String), + + /// Indicates that the operation completed successfully. + /// + /// Contains the final [`WorkerResult`] produced by the background task. + Success(WorkerResult), + + /// Indicates that the operation failed. + /// + /// Contains structured error details suitable for display in a UI dialog. + Error(EventError), +} + +/// A structured error type designed specifically for UI presentation. +/// +/// Unlike standard errors, this struct separates the error into a hierarchy +/// (title, subtitle, message) to allow for rich, readable error dialogs. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct EventError { + /// The main heading of the error. + pub title: String, + + /// A brief context description. + pub subtitle: String, + + /// The detailed technical description or raw error message. + pub message: String, +} + +impl EventError { + /// Creates a new [`EventError`] instance from string slices. + /// + /// # Arguments + /// + /// * `title` - The bold header text. + /// * `subtitle` - The context text. + /// * `message` - The detailed error body. + pub fn new(title: String, subtitle: String, message: String) -> Self { + Self { + title, + subtitle, + message, + } + } +} diff --git a/gui/src/events/worker_result.rs b/gui/src/events/worker_result.rs new file mode 100644 index 0000000..866de04 --- /dev/null +++ b/gui/src/events/worker_result.rs @@ -0,0 +1,92 @@ +use std::path::PathBuf; + +use engine::{ + ConfigLocation, + handlers::{ + branch::Response as BranchResponse, + config::{ListResponse as ConfigListResponse, SetResponse as ConfigSetResponse}, + diff::Response as DiffResponse, + init::Response as InitResponse, + log::LogEntry, + status::Response as StatusResponse, + }, +}; + +use crate::events::EventError; + +/// Represents the final outcome of a background operation performed by the `AsyncWorker`. +/// +/// This enum is carried by the `WorkerEvent::Success` variant and allows the main thread +/// to update the application state based on the specific type of task that completed. +pub enum WorkerResult { + /// The operation completed successfully, but produced no specific data payload (void). + None, + + /// The operation failed. + /// + /// Contains the error details to be displayed to the user via an error dialog. + /// Note: While `WorkerEvent::Error` also exists, this variant allows returning + /// errors that were caught and wrapped during the result processing phase. + Error(EventError), + + /// Successfully initialized a new repository. + /// + /// Returns: + /// * `init`: Details about the created repository (e.g., path). + /// * `status`: The immediate status of the new repository. + RepositoryCreated { + init: InitResponse, + status: StatusResponse, + }, + + /// Successfully opened an existing repository or re-loaded the current one. + /// + /// This is used during application startup or when switching branches. + /// Returns: + /// * `branch`: A list of local and remote branches. + /// * `status`: The current status of the working directory. + RepositoryOpened { + branch: BranchResponse, + status: StatusResponse, + }, + + /// Successfully refreshed the repository status. + /// + /// Used when polling for file changes or after staging/unstaging files. + /// Returns only the `status` containing modified/staged files and HEAD info. + StatusRefreshed { status: StatusResponse }, + + /// Successfully calculated the diff for a specific file. + /// + /// Returns: + /// * `diff`: The structured difference data containing hunks, additions, and deletions. + DiffOpened { diff: DiffResponse, path: PathBuf }, + + /// Successfully retrieved the full list of configuration entries. + /// + /// Used to populate the settings view tables. + /// Returns: + /// * `location`: The scope from which the config was loaded (Global/Local). + /// * `config`: The list of key-value pairs grouped by prefix. + ConfigLoaded { + location: ConfigLocation, + config: ConfigListResponse, + }, + + /// Successfully updated or inserted a single configuration entry. + /// + /// Used to update the local UI state without reloading the entire configuration list. + /// Returns: + /// * `location`: The scope where the change occurred. + /// * `config`: The specific key and value that were set. + ConfigUpdated { + location: ConfigLocation, + config: ConfigSetResponse, + }, + + /// Successfully retrieved the commit history (log). + /// + /// Returns: + /// * `entries`: A chronological list of commits, including snapshots and diff statistics. + LogLoaded { entries: Vec }, +} diff --git a/gui/src/icons.rs b/gui/src/icons.rs new file mode 100644 index 0000000..d80a7fa --- /dev/null +++ b/gui/src/icons.rs @@ -0,0 +1,16 @@ +pub fn load_icon() -> egui::IconData { + let (icon_rgba, icon_width, icon_height) = { + let icon = include_bytes!("../assets/feather.png"); + let image = image::load_from_memory(icon).expect("Icon asset not found!"); + let rgba = image.into_rgba8(); + let (width, height) = rgba.dimensions(); + let rgba = rgba.into_raw(); + (rgba, width, height) + }; + + egui::IconData { + rgba: icon_rgba, + width: icon_width, + height: icon_height, + } +} diff --git a/gui/src/lib.rs b/gui/src/lib.rs new file mode 100644 index 0000000..58ff936 --- /dev/null +++ b/gui/src/lib.rs @@ -0,0 +1,3 @@ +pub mod events; +pub mod icons; +pub mod ui; diff --git a/gui/src/main.rs b/gui/src/main.rs index e7a11a9..1aa9d0e 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -1,3 +1,384 @@ +use std::{path::PathBuf, sync::Arc}; + +use eframe::egui; +use egui::{RichText, Ui}; +use egui_phosphor::regular as icons; + +use engine::{ + engine_container::MevaContainer, + handlers::{branch::BranchCollection, status::Response}, +}; +use gui::{ + events::{AsyncWorker, EventError, WorkerResult}, + icons::load_icon, + ui::{ + BranchStatusComponent, CommitButton, CommitChangesDialog, CommitChangesForm, + CreateRepositoryDialog, CreateRepositoryForm, DashboardView, DiffView, ErrorDialog, + FileChangesComponent, FileChangesState, LoadingDialog, LogView, NavigationButton, + OpenRepositoryButton, RefreshStatusButton, RemoteActionsComponent, SettingsButton, + SettingsView, SwitchBranchDialog, SwitchBranchForm, ThemeButton, View, + }, +}; +use strum_macros::Display; + +#[derive(Debug, Default, PartialEq, Eq, Display)] +enum GuiView { + #[default] + Dashboard, + Settings, + Diff, + History, +} + +struct MevaGui { + current_view: GuiView, + diff_view: DiffView, + settings_view: SettingsView, + log_view: LogView, + + create_repo_form: CreateRepositoryForm, + commit_form: CommitChangesForm, + file_changes_state: FileChangesState, + switch_branch_form: SwitchBranchForm, + + container: Arc, + async_worker: AsyncWorker, + + event_error: Option, + + repository_path: Option, + repository_status: Option, + repository_branches: Option, +} + +impl MevaGui { + fn new(cc: &eframe::CreationContext) -> Self { + let mut fonts = egui::FontDefinitions::default(); + egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Fill); + cc.egui_ctx.set_fonts(fonts); + Self { + current_view: GuiView::default(), + diff_view: DiffView::default(), + settings_view: SettingsView::default(), + log_view: LogView::default(), + create_repo_form: CreateRepositoryForm::default(), + commit_form: CommitChangesForm::default(), + file_changes_state: FileChangesState::default(), + switch_branch_form: SwitchBranchForm::default(), + container: Arc::new(MevaContainer), + async_worker: AsyncWorker::default(), + event_error: None, + repository_path: None, + repository_status: None, + repository_branches: None, + } + } + + fn render_loading_modal(&self, ctx: &egui::Context) { + LoadingDialog { + active: self.async_worker.is_active, + message: &self.async_worker.loading_message, + } + .show(ctx); + } + + fn render_menu_bar(&mut self, ctx: &egui::Context, ui: &mut Ui) { + egui::MenuBar::new().ui(ui, |ui| { + ui.menu_button("File", |ui| { + OpenRepositoryButton::new(&mut self.async_worker, &self.container).show(ui, ctx); + + if ui + .button(format!("{} Create new", icons::FOLDER_PLUS)) + .clicked() + { + ui.close_kind(egui::UiKind::Menu); + self.create_repo_form.is_open = true; + self.create_repo_form.branch_name = "main".to_string(); + } + if ui.button(format!("{} Close", icons::X)).clicked() { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + }); + } + + fn render_top_panel(&mut self, ctx: &egui::Context) { + egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { + ui.horizontal(|ui| { + self.render_menu_bar(ctx, ui); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(12.0); + let is_dark = ctx.style().visuals.dark_mode; + if ThemeButton::default().show(ui, is_dark) { + ctx.set_visuals(match is_dark { + true => egui::Visuals::light(), + false => egui::Visuals::dark(), + }); + } + }); + }); + }); + } + + fn render_bottom_panel(&mut self, ctx: &egui::Context) { + egui::TopBottomPanel::bottom("bottom_panel").show(ctx, |ui| { + ui.horizontal(|ui| { + if SettingsButton::default().show(ui) { + self.current_view = GuiView::Settings; + } + + ui.separator(); + + if let Some(status) = &self.repository_status + && let Some(branch_info) = &status.branch + { + BranchStatusComponent::new(branch_info, &mut self.switch_branch_form.is_open) + .show(ui); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new("v0.1.0").weak()) + .on_hover_text("Current version"); + + ui.separator(); + + if let Some(status) = &self.repository_status + && let Some(branch) = &status.branch + { + RemoteActionsComponent::new( + &mut self.async_worker, + &self.container, + ctx, + branch, + ) + .show(ui); + } + }); + }); + }); + } + + fn render_navigation(&mut self, ui: &mut Ui) { + ui.heading(RichText::new("Navigation").heading()); + ui.add_space(5.0); + ui.vertical(|ui| { + let is_selected = matches!(self.current_view, GuiView::Dashboard); + + if NavigationButton::new(is_selected, icons::HOUSE, &GuiView::Dashboard.to_string()) + .show(ui) + .clicked() + { + self.current_view = GuiView::Dashboard; + } + }); + + if self.diff_view.has_open_tabs() { + ui.vertical(|ui| { + let is_selected = matches!(self.current_view, GuiView::Diff); + + if NavigationButton::new(is_selected, icons::FILE_CODE, &GuiView::Diff.to_string()) + .show(ui) + .clicked() + { + self.current_view = GuiView::Diff; + } + }); + } + + if self.repository_status.is_some() { + ui.vertical(|ui| { + let is_selected = matches!(self.current_view, GuiView::History); + + if NavigationButton::new( + is_selected, + icons::CLOCK_COUNTER_CLOCKWISE, + &GuiView::History.to_string(), + ) + .show(ui) + .clicked() + { + self.current_view = GuiView::History; + } + }); + } + } + + fn render_left_panel(&mut self, ctx: &egui::Context) { + egui::SidePanel::left("left_panel") + .resizable(true) + .default_width(200.0) + .min_width(250.0) + .show(ctx, |ui| { + ui.add_space(10.0); + + self.render_navigation(ui); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + if let Some(status) = &self.repository_status { + ui.horizontal(|ui| { + RefreshStatusButton::new(&mut self.async_worker, &self.container) + .show(ui, ctx); + ui.with_layout( + egui::Layout::top_down_justified(egui::Align::Center), + |ui| { + if CommitButton.show( + ui, + self.repository_status.as_ref().is_some_and(|status| { + status + .staged + .as_ref() + .is_some_and(|staged| !staged.is_empty()) + }), + ) { + self.commit_form.is_open = true; + } + }, + ); + }); + + ui.add_space(10.0); + + egui::ScrollArea::vertical() + .id_salt("changes_scroll_area") + .auto_shrink([false, false]) + .show(ui, |ui| { + FileChangesComponent::new(&mut self.async_worker, &self.container, ctx) + .show(ui, status, &mut self.file_changes_state); + }); + } else { + ui.vertical_centered(|ui| { + ui.add_space(20.0); + ui.label(egui::RichText::new("No repository open").weak()); + }); + } + }); + } + + fn render_central_panel(&mut self, ctx: &egui::Context) { + egui::CentralPanel::default().show(ctx, |ui| match self.current_view { + GuiView::Dashboard => { + DashboardView {}.show(ui); + } + GuiView::Settings => { + self.settings_view + .show(ui, &mut self.async_worker, &self.container, ctx); + } + GuiView::Diff => { + let should_close = self.diff_view.show_tabs(ui); + + if should_close { + self.current_view = GuiView::Dashboard; + } else { + ui.separator(); + self.diff_view.show_diff_content(ui); + } + } + GuiView::History => { + self.log_view + .show(ui, &mut self.async_worker, &self.container, ctx); + } + }); + } +} + +impl eframe::App for MevaGui { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + if let Some(result) = self.async_worker.poll() { + match result { + WorkerResult::Error(event_error) => { + self.event_error = Some(event_error); + } + WorkerResult::None => { + println!("Operation finished (void)."); + } + WorkerResult::RepositoryCreated { init, status } => { + self.repository_path = Some(init.repository_dir); + self.repository_status = Some(status); + + self.current_view = GuiView::Dashboard; + } + WorkerResult::StatusRefreshed { status } => { + self.repository_status = Some(status); + } + WorkerResult::RepositoryOpened { branch, status } => { + self.repository_status = Some(status); + self.repository_branches = branch.branches; + } + WorkerResult::DiffOpened { path, diff } => { + self.diff_view.open(path, diff); + self.current_view = GuiView::Diff; + } + WorkerResult::ConfigLoaded { .. } | WorkerResult::ConfigUpdated { .. } => { + self.settings_view.handle_worker_result(result); + } + WorkerResult::LogLoaded { .. } => { + self.log_view.handle_worker_result(result); + } + } + } + + self.render_top_panel(ctx); + self.render_bottom_panel(ctx); + self.render_left_panel(ctx); + self.render_central_panel(ctx); + + CreateRepositoryDialog::new( + &mut self.create_repo_form, + &mut self.async_worker, + &self.container, + ) + .show(ctx); + + CommitChangesDialog::new( + &mut self.commit_form, + &mut self.async_worker, + &self.container, + ctx, + ) + .show(); + + SwitchBranchDialog::new( + &mut self.switch_branch_form, + &self.repository_branches, + &mut self.async_worker, + &self.container, + ctx, + ) + .show(); + + self.render_loading_modal(ctx); + + if let Some(event_error) = &self.event_error { + let dialog = ErrorDialog { error: event_error }; + if dialog.show(ctx) { + self.event_error = None; + } + } + } +} + fn main() { - println!("Hello, world!"); + let icon = load_icon(); + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([800.0, 600.0]) + .with_icon(icon), + centered: true, + ..Default::default() + }; + + eframe::run_native( + "MEVA GUI", + options, + Box::new(|context| { + context.egui_ctx.set_theme(egui::Theme::Dark); + Ok(Box::new(MevaGui::new(context))) + }), + ) + .unwrap(); } diff --git a/gui/src/ui.rs b/gui/src/ui.rs new file mode 100644 index 0000000..5dc45a8 --- /dev/null +++ b/gui/src/ui.rs @@ -0,0 +1,5 @@ +mod components; +mod views; + +pub use components::*; +pub use views::*; diff --git a/gui/src/ui/components.rs b/gui/src/ui/components.rs new file mode 100644 index 0000000..6c4b9c0 --- /dev/null +++ b/gui/src/ui/components.rs @@ -0,0 +1,12 @@ +mod branch_status; +mod buttons; +mod dialogs; +mod file_changes; +mod remote_actions; + +pub use buttons::*; +pub use dialogs::*; + +pub use branch_status::BranchStatusComponent; +pub use file_changes::{FileChangesComponent, FileChangesState}; +pub use remote_actions::RemoteActionsComponent; diff --git a/gui/src/ui/components/branch_status.rs b/gui/src/ui/components/branch_status.rs new file mode 100644 index 0000000..0bfc1b9 --- /dev/null +++ b/gui/src/ui/components/branch_status.rs @@ -0,0 +1,142 @@ +use egui::{RichText, Ui}; +use egui_phosphor::regular as icons; + +use engine::handlers::status::BranchInfo; + +/// A UI component responsible for rendering the current repository branch status. +/// +/// This component visualizes: +/// - The current HEAD (branch name or commit hash if detached). +/// - The relationship with the upstream branch (ahead/behind counts). +/// - An indication if the repository is empty (no commits). +/// +/// # Interaction +/// Clicking the branch name button triggers the opening of the "Switch Branch" dialog +/// by modifying the provided `dialog_open_flag`. +pub struct BranchStatusComponent<'a> { + /// Read-only reference to the branch information (model data). + info: &'a BranchInfo, + + /// Mutable reference to a boolean flag used to signal that the + /// switch branch dialog should be opened. + dialog_open_flag: &'a mut bool, +} + +impl<'a> BranchStatusComponent<'a> { + /// Creates a new [`BranchStatusComponent`]. + /// + /// # Arguments + /// + /// * `info` - Detailed information about the current branch status. + /// * `dialog_open_flag` - A mutable reference to the state variable that controls + /// the visibility of the switch/create branch modal. + pub fn new(info: &'a BranchInfo, dialog_open_flag: &'a mut bool) -> Self { + Self { + info, + dialog_open_flag, + } + } + + /// Renders the component into the provided UI builder. + pub fn show(&mut self, ui: &mut Ui) { + if !self.info.has_commits { + ui.label(egui::RichText::new(format!("{} No commits", icons::GIT_COMMIT)).weak()) + .on_hover_text( + "This branch is empty (orphan) or the repository is just initialized.", + ); + return; + } + + self.render_head_status(ui); + + if self.info.upstream.is_some() { + ui.separator(); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + + self.render_sync_arrow( + ui, + icons::ARROW_DOWN, + self.info.behind, + "commits behind upstream", + ); + + self.render_sync_arrow( + ui, + icons::ARROW_UP, + self.info.ahead, + "commits ahead of upstream", + ); + }); + } else { + ui.separator(); + ui.label(RichText::new(icons::CLOUD_SLASH).size(14.0).weak()) + .on_hover_text("Local branch only (no upstream)"); + } + } + + /// Renders the clickable button displaying the current branch name or commit hash. + /// + /// If the button is clicked, `self.dialog_open_flag` is set to `true`. + fn render_head_status(&mut self, ui: &mut Ui) { + let icon = if self.info.is_detached { + icons::GIT_COMMIT + } else { + icons::GIT_BRANCH + }; + + let branch_name = self.info.head.as_deref().unwrap_or("???"); + + let btn = egui::Button::new(RichText::new(format!("{icon} {branch_name}")).strong()); + + if ui + .add(btn) + .on_hover_ui(|ui| { + if self.info.is_detached { + ui.horizontal(|ui| { + ui.label("HEAD is detached at commit:"); + ui.label(RichText::new(branch_name).strong()); + }); + } else { + ui.horizontal(|ui| { + ui.label("Current branch:"); + ui.label(RichText::new(branch_name).strong()); + }); + } + + if let Some(upstream) = &self.info.upstream { + ui.add_space(5.0); + ui.horizontal(|ui| { + ui.label("Tracking:"); + ui.label(RichText::new(upstream).strong()); + }); + } + }) + .clicked() + { + *self.dialog_open_flag = true; + } + } + + /// Helper to render a sync indicator arrow with a count. + /// + /// If `count` is 0, the indicator is rendered with a weak (dimmed) color + /// to reduce visual noise. + fn render_sync_arrow(&self, ui: &mut Ui, icon: &str, count: usize, tooltip_suffix: &str) { + let is_active = count > 0; + + let color = match is_active { + true => ui.visuals().text_color(), + false => ui.visuals().weak_text_color(), + }; + + let mut text = RichText::new(format!("{icon} {count}")).color(color); + + if is_active { + text = text.strong(); + } + + ui.label(text) + .on_hover_text(format!("{count} {tooltip_suffix}")); + } +} diff --git a/gui/src/ui/components/buttons.rs b/gui/src/ui/components/buttons.rs new file mode 100644 index 0000000..ea83471 --- /dev/null +++ b/gui/src/ui/components/buttons.rs @@ -0,0 +1,15 @@ +mod commit_button; +mod icon_button; +mod navigation_button; +mod open_repository_button; +mod refresh_status_button; +mod settings_button; +mod theme_button; + +pub use commit_button::CommitButton; +pub use icon_button::IconButton; +pub use navigation_button::NavigationButton; +pub use open_repository_button::OpenRepositoryButton; +pub use refresh_status_button::RefreshStatusButton; +pub use settings_button::SettingsButton; +pub use theme_button::ThemeButton; diff --git a/gui/src/ui/components/buttons/commit_button.rs b/gui/src/ui/components/buttons/commit_button.rs new file mode 100644 index 0000000..2119a22 --- /dev/null +++ b/gui/src/ui/components/buttons/commit_button.rs @@ -0,0 +1,29 @@ +use egui::Ui; +use egui_phosphor::regular as icons; + +/// A stateless UI component representing the primary "Commit" button. +#[derive(Default)] +pub struct CommitButton; + +impl CommitButton { + /// Renders the commit button into the provided UI. + /// + /// # Arguments + /// + /// * `ui` - The UI region where the button will be drawn. + /// * `enabled` - If `true`, the button is interactive. If `false`, it is rendered + /// in a disabled state (grayed out) and cannot be clicked (e.g., when no files are staged). + /// + /// # Returns + /// + /// `true` if the button was clicked in this frame, `false` otherwise. + pub fn show(self, ui: &mut Ui, enabled: bool) -> bool { + ui.add_enabled( + enabled, + egui::Button::new( + egui::RichText::new(format!("{} Commit", icons::GIT_COMMIT)).strong(), + ), + ) + .clicked() + } +} diff --git a/gui/src/ui/components/buttons/icon_button.rs b/gui/src/ui/components/buttons/icon_button.rs new file mode 100644 index 0000000..8ec967c --- /dev/null +++ b/gui/src/ui/components/buttons/icon_button.rs @@ -0,0 +1,64 @@ +use egui::{RichText, Ui, Vec2}; + +/// A helper struct for creating frameless, icon-only buttons with tooltips. +/// +/// This component implements the builder pattern, allowing for easy configuration +/// of the icon size and hit-box dimensions before rendering. It is typically used +/// for toolbars or action rows where standard framed buttons would look too heavy. +pub struct IconButton<'a> { + icon: &'a str, + tooltip: &'a str, + icon_size: f32, + min_size: Vec2, +} + +impl<'a> IconButton<'a> { + /// Creates a new [`IconButton`] with default styling. + /// + /// # Arguments + /// + /// * `icon` - The string representing the icon (usually a glyph from a font like Phosphor or FontAwesome). + /// * `tooltip` - The text to display when the user hovers over the button. + pub fn new(icon: &'a str, tooltip: &'a str) -> Self { + Self { + icon, + tooltip, + icon_size: 16.0, + min_size: Vec2::new(18.0, 18.0), + } + } + + /// Sets the font size of the icon text. + /// + /// The default size is 16.0. + pub fn icon_size(mut self, size: f32) -> Self { + self.icon_size = size; + self + } + + /// Sets the minimum size of the button's clickable area. + /// + /// This is useful for ensuring small icons still have a large enough hit-box + /// for usability. The default is 18x18. + pub fn min_size(mut self, size: Vec2) -> Self { + self.min_size = size; + self + } + + /// Renders the button into the provided UI. + /// + /// This method creates an `egui::Button` with `frame(false)` (transparent background). + /// + /// # Returns + /// + /// `true` if the button was clicked, `false` otherwise. + pub fn show(self, ui: &mut Ui) -> bool { + ui.add( + egui::Button::new(RichText::new(self.icon).size(self.icon_size)) + .frame(false) + .min_size(self.min_size), + ) + .on_hover_text(self.tooltip) + .clicked() + } +} diff --git a/gui/src/ui/components/buttons/navigation_button.rs b/gui/src/ui/components/buttons/navigation_button.rs new file mode 100644 index 0000000..cc8826b --- /dev/null +++ b/gui/src/ui/components/buttons/navigation_button.rs @@ -0,0 +1,61 @@ +use egui::{Align2, FontId, Response, Sense, Ui, Vec2}; + +/// A custom navigation button used in the side panel. +/// It renders an icon and a label with a custom background when selected or hovered. +pub struct NavigationButton<'a> { + selected: bool, + icon: &'a str, + label: &'a str, +} + +impl<'a> NavigationButton<'a> { + pub fn new(selected: bool, icon: &'a str, label: &'a str) -> Self { + Self { + selected, + icon, + label, + } + } + + pub fn show(self, ui: &mut Ui) -> Response { + let height = 20.0; + let max_button_width = 140.0; + + let width = ui.available_width().min(max_button_width); + let size = Vec2::new(width, height); + + let (rect, response) = ui.allocate_exact_size(size, Sense::click()); + + if self.selected || response.hovered() { + let visuals = ui.visuals(); + let bg_color = if self.selected { + visuals.selection.bg_fill.linear_multiply(0.4) + } else { + visuals.widgets.hovered.bg_fill + }; + + let rounding = ui.style().visuals.widgets.noninteractive.corner_radius; + ui.painter().rect_filled(rect, rounding, bg_color); + } + + let text_color = if self.selected { + ui.visuals().strong_text_color() + } else { + ui.visuals().text_color() + }; + + let font_id = FontId::proportional(14.0); + + let text_pos = rect.left_center() + Vec2::new(10.0, 0.0); + + ui.painter().text( + text_pos, + Align2::LEFT_CENTER, + format!("{} {}", self.icon, self.label), + font_id, + text_color, + ); + + response.on_hover_cursor(egui::CursorIcon::PointingHand) + } +} diff --git a/gui/src/ui/components/buttons/open_repository_button.rs b/gui/src/ui/components/buttons/open_repository_button.rs new file mode 100644 index 0000000..aa1f10b --- /dev/null +++ b/gui/src/ui/components/buttons/open_repository_button.rs @@ -0,0 +1,138 @@ +use std::{path::PathBuf, sync::Arc, thread}; + +use egui_phosphor::regular as icons; + +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::{ + branch::{BranchOperations, ListRequest, Request as BranchRequest}, + status::Request as StatusRequest, + }, +}; +use shared::change_directory; + +use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; + +/// A UI component responsible for opening an existing repository. +/// +/// This struct renders a button that, when clicked, opens a native system file dialog. +/// Upon selecting a folder, it spawns a background task to validate the repository, +/// change the working directory, and load the initial state (status and branches). +pub struct OpenRepositoryButton<'a> { + /// Reference to the worker used to offload loading tasks to a background thread. + worker: &'a mut AsyncWorker, + /// Reference to the dependency container for accessing engine handlers. + container: &'a Arc, +} + +impl<'a> OpenRepositoryButton<'a> { + /// Creates a new [`OpenRepositoryButton`]. + /// + /// # Arguments + /// + /// * `worker` - The async worker for spawning background tasks. + /// * `container` - The application service container. + pub fn new(worker: &'a mut AsyncWorker, container: &'a Arc) -> Self { + Self { worker, container } + } + + /// Renders the button and handles interaction. + /// + /// If the button is clicked: + /// 1. Closes the parent menu (if applicable). + /// 2. Opens a native folder picker dialog. + /// 3. If a path is selected, initiates the background loading process. + pub fn show(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + if ui + .button(format!("{} Open repository...", icons::FOLDER_OPEN)) + .clicked() + { + ui.close_kind(egui::UiKind::Menu); + + if let Some(path) = rfd::FileDialog::new() + .set_title("Open Existing Repository") + .set_directory(".") + .pick_folder() + { + self.start_open_process(ctx, path); + } + } + } + + /// Orchestrates the asynchronous loading process. + /// + /// This method spawns a thread via [`AsyncWorker`]. It sets up a reporting closure + /// to send progress updates back to the main thread and handles the final success/error + /// result. + fn start_open_process(&mut self, ctx: &egui::Context, path: PathBuf) { + let container = self.container.clone(); + let ctx_clone = ctx.clone(); + + self.worker.spawn(ctx.clone(), move |tx| { + let mut report = |msg: String| { + let _ = tx.send(WorkerEvent::Progress(msg)); + ctx_clone.request_repaint(); + }; + + match Self::execute_open_logic(container, path, &mut report) { + Ok(result) => { + let _ = tx.send(WorkerEvent::Success(result)); + } + Err(err) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Open Error".to_string(), + "Could not load repository".to_string(), + err, + ))); + } + } + ctx_clone.request_repaint(); + }); + } + + /// Performs the actual logic of opening the repository. + /// + /// This function runs in a background thread. It performs the following steps: + /// 1. Changes the process current directory to the target path. + /// 2. Fetches the repository status (files, HEAD). + /// 3. Lists available local and remote branches. + /// + /// # Returns + /// + /// * `Ok(WorkerResult::RepositoryOpened)` containing the loaded data on success. + /// * `Err(String)` containing the error message on failure. + fn execute_open_logic( + container: Arc, + path: PathBuf, + report: &mut dyn FnMut(String), + ) -> Result { + report("Opening repository...".to_string()); + // Artificial delay for UX (to let the user see the loading state) + thread::sleep(std::time::Duration::from_millis(500)); + + change_directory(&path).map_err(|e| e.to_string())?; + + report("Reading repository status...".into()); + thread::sleep(std::time::Duration::from_millis(500)); + + let status_handler = container.status_handler().map_err(|e| e.to_string())?; + let status = status_handler + .handle_status(StatusRequest::with_branch()) + .map_err(|e| e.to_string())?; + + report("Loading branches...".into()); + thread::sleep(std::time::Duration::from_millis(500)); + + let branch_handler = container.branch_handler().map_err(|e| e.to_string())?; + let branch = branch_handler + .branch(BranchRequest::List(ListRequest { + local: true, + remotes: true, + verbose: false, + })) + .map_err(|e| e.to_string())?; + + Ok(WorkerResult::RepositoryOpened { branch, status }) + } +} diff --git a/gui/src/ui/components/buttons/refresh_status_button.rs b/gui/src/ui/components/buttons/refresh_status_button.rs new file mode 100644 index 0000000..e192fde --- /dev/null +++ b/gui/src/ui/components/buttons/refresh_status_button.rs @@ -0,0 +1,115 @@ +use std::{sync::Arc, thread}; + +use egui_phosphor::regular as icons; + +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::{ + branch::{BranchOperations, ListRequest, Request as BranchRequest}, + status::Request as StatusRequest, + }, +}; + +use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; + +/// A UI component responsible for manually refreshing the repository status. +/// +/// This button is useful when external changes occur (e.g., files changed by another editor) +/// and the user wants to force the application to re-scan the working directory +/// and branch state immediately. +pub struct RefreshStatusButton<'a> { + /// Reference to the worker used to offload the refresh task to a background thread. + worker: &'a mut AsyncWorker, + /// Reference to the dependency container for accessing engine handlers. + container: &'a Arc, +} + +impl<'a> RefreshStatusButton<'a> { + /// Creates a new [`RefreshStatusButton`]. + /// + /// # Arguments + /// + /// * `worker` - The async worker for spawning background tasks. + /// * `container` - The application service container. + pub fn new(worker: &'a mut AsyncWorker, container: &'a Arc) -> Self { + Self { worker, container } + } + + /// Renders the button and handles interaction. + /// + /// It displays a "clockwise arrows" icon. When clicked, it initiates + /// the background refresh process. + pub fn show(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + if ui + .button(icons::ARROWS_CLOCKWISE) + .on_hover_text("Refresh status") + .clicked() + { + self.start_refresh_process(ctx); + } + } + + /// Orchestrates the asynchronous refresh process. + /// + /// Spawns a background task via [`AsyncWorker`] that re-fetches the repository status + /// and branch list, then sends the result back to the main thread to update the UI. + fn start_refresh_process(&mut self, ctx: &egui::Context) { + let container = self.container.clone(); + let ctx_clone = ctx.clone(); + + self.worker.spawn(ctx.clone(), move |tx| { + let mut report = |msg: String| { + let _ = tx.send(WorkerEvent::Progress(msg)); + ctx_clone.request_repaint(); + }; + + match Self::execute_refresh_logic(container, &mut report) { + Ok(result) => { + let _ = tx.send(WorkerEvent::Success(result)); + } + Err(err) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Refresh Error".to_string(), + "Could not refresh repository status".to_string(), + err, + ))); + } + } + ctx_clone.request_repaint(); + }); + } + + /// Performs the actual logic of refreshing the repository state. + /// + /// This function runs in a background thread. It: + /// 1. Fetches the current file status (staged/unstaged changes). + /// 2. Reloads the list of branches. + /// + /// # Returns + /// + /// * `Ok(WorkerResult::RepositoryOpened)` containing the fresh status and branch list. + /// * `Err(String)` containing the error message on failure. + fn execute_refresh_logic( + container: Arc, + report: &mut dyn FnMut(String), + ) -> Result { + report("Refreshing status...".to_string()); + // Artificial delay to give visual feedback that work is happening + thread::sleep(std::time::Duration::from_millis(500)); + + let status_handler = container.status_handler().map_err(|e| e.to_string())?; + let status = status_handler + .handle_status(StatusRequest::with_branch()) + .map_err(|e| e.to_string())?; + + report("Loading branches...".into()); + + let branch_handler = container.branch_handler().map_err(|e| e.to_string())?; + let branch = branch_handler + .branch(BranchRequest::List(ListRequest::local_only(false))) + .map_err(|e| e.to_string())?; + + Ok(WorkerResult::RepositoryOpened { branch, status }) + } +} diff --git a/gui/src/ui/components/buttons/settings_button.rs b/gui/src/ui/components/buttons/settings_button.rs new file mode 100644 index 0000000..f4c17a3 --- /dev/null +++ b/gui/src/ui/components/buttons/settings_button.rs @@ -0,0 +1,53 @@ +use egui::{Ui, Vec2}; +use egui_phosphor::regular as icons; + +use super::IconButton; + +/// A specialized button component for opening the application settings. +/// +/// This is a convenience wrapper around [`IconButton`] pre-configured with the +/// "Gear" icon and a "Change settings" tooltip. It supports the same fluent +/// builder pattern for customizing size. +pub struct SettingsButton { + icon_size: f32, + min_size: Vec2, +} + +impl Default for SettingsButton { + fn default() -> Self { + Self { + icon_size: 16.0, + min_size: Vec2::new(18.0, 18.0), + } + } +} + +impl SettingsButton { + /// Sets the font size of the gear icon. + /// + /// The default is 16.0. + pub fn icon_size(mut self, size: f32) -> Self { + self.icon_size = size; + self + } + + /// Sets the minimum clickable area of the button. + /// + /// The default is 18x18. + pub fn min_size(mut self, size: Vec2) -> Self { + self.min_size = size; + self + } + + /// Renders the settings button into the provided UI. + /// + /// # Returns + /// + /// `true` if the button was clicked, `false` otherwise. + pub fn show(self, ui: &mut Ui) -> bool { + IconButton::new(icons::GEAR, "Change settings") + .icon_size(self.icon_size) + .min_size(self.min_size) + .show(ui) + } +} diff --git a/gui/src/ui/components/buttons/theme_button.rs b/gui/src/ui/components/buttons/theme_button.rs new file mode 100644 index 0000000..42bf668 --- /dev/null +++ b/gui/src/ui/components/buttons/theme_button.rs @@ -0,0 +1,66 @@ +use egui::{Ui, Vec2}; +use egui_phosphor::regular as icons; + +use super::IconButton; + +/// A specialized button component for toggling the application's color theme. +/// +/// This component automatically switches its icon and tooltip based on the current +/// theme state (Light/Dark). +/// +/// - Displays a **Sun** icon in Dark mode (to switch to Light). +/// - Displays a **Moon** icon in Light mode (to switch to Dark). +pub struct ThemeButton { + icon_size: f32, + min_size: egui::Vec2, +} + +impl Default for ThemeButton { + fn default() -> Self { + Self { + icon_size: 18.0, + min_size: Vec2::new(24.0, 24.0), + } + } +} + +impl ThemeButton { + /// Sets the font size of the toggle icon. + /// + /// The default is 18.0. + pub fn icon_size(mut self, size: f32) -> Self { + self.icon_size = size; + self + } + + /// Sets the minimum clickable area of the button. + /// + /// The default is 24x24. + pub fn min_size(mut self, size: Vec2) -> Self { + self.min_size = size; + self + } + + /// Renders the theme toggle button into the provided UI. + /// + /// # Arguments + /// + /// * `ui` - The UI region where the button will be drawn. + /// * `is_dark` - A boolean indicating if the current theme is dark (`true`) or light (`false`). + /// + /// # Returns + /// + /// `true` if the button was clicked, `false` otherwise. + pub fn show(self, ui: &mut Ui, is_dark: bool) -> bool { + let (icon, tooltip) = if is_dark { + (icons::SUN, "Switch to light mode") + } else { + (icons::MOON, "Switch to dark mode") + }; + + IconButton::new(icon, tooltip) + .icon_size(self.icon_size) + .min_size(self.min_size) + .show(ui) + } +} diff --git a/gui/src/ui/components/dialogs.rs b/gui/src/ui/components/dialogs.rs new file mode 100644 index 0000000..efc8f17 --- /dev/null +++ b/gui/src/ui/components/dialogs.rs @@ -0,0 +1,11 @@ +mod commit_changes_dialog; +mod create_repository_dialog; +mod error_dialog; +mod loading_dialog; +mod switch_branch_dialog; + +pub use commit_changes_dialog::{CommitChangesDialog, CommitChangesForm}; +pub use create_repository_dialog::{CreateRepositoryDialog, CreateRepositoryForm}; +pub use error_dialog::ErrorDialog; +pub use loading_dialog::LoadingDialog; +pub use switch_branch_dialog::{SwitchBranchDialog, SwitchBranchForm}; diff --git a/gui/src/ui/components/dialogs/commit_changes_dialog.rs b/gui/src/ui/components/dialogs/commit_changes_dialog.rs new file mode 100644 index 0000000..edbea02 --- /dev/null +++ b/gui/src/ui/components/dialogs/commit_changes_dialog.rs @@ -0,0 +1,198 @@ +use std::{sync::Arc, thread}; + +use egui::{Context, Ui}; +use egui_phosphor::regular as icons; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::{commit::Request as CommitRequest, status::Request as StatusRequest}, +}; + +use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; + +/// Holds the transient state of the commit dialog. +/// +/// This struct persists across frames in the main application state to ensure +/// the user's input (message, flags) is preserved even if the window is closed and reopened, +/// or until the commit is successfully processed. +#[derive(Default)] +pub struct CommitChangesForm { + /// Controls the visibility of the dialog window. + pub is_open: bool, + + /// The content of the commit message input buffer. + pub message: String, + + /// If true, the new commit will replace the tip of the current branch. + pub amend: bool, +} + +impl CommitChangesForm { + /// Clears the form inputs, resetting them to their default state. + /// + /// This is typically called immediately after a successful commit to prepare + /// the form for the next operation. + pub fn reset(&mut self) { + self.message.clear(); + self.amend = false; + } +} + +/// A modal dialog for composing and submitting git commits. +/// +/// This component renders a window containing a multi-line text area for the commit message +/// and options for commit flags (e.g., amend). It delegates the actual execution +/// to the `AsyncWorker` to prevent freezing the UI. +pub struct CommitChangesDialog<'a> { + /// Mutable reference to the form state. + form: &'a mut CommitChangesForm, + /// Worker for spawning background tasks. + worker: &'a mut AsyncWorker, + /// Dependency container for accessing the commit engine logic. + container: &'a Arc, + /// Egui context used to request repaints from background threads. + ctx: &'a Context, +} + +impl<'a> CommitChangesDialog<'a> { + /// Creates a new [`CommitChangesDialog`]. + pub fn new( + form: &'a mut CommitChangesForm, + worker: &'a mut AsyncWorker, + container: &'a Arc, + ctx: &'a Context, + ) -> Self { + Self { + form, + worker, + container, + ctx, + } + } + + /// Renders the dialog window if `form.is_open` is true. + /// + /// The window is fixed-size and non-collapsible. It contains: + /// - A text area for the commit message. + /// - A checkbox for the "Amend" option. + /// - "Commit" and "Cancel" buttons. + pub fn show(&mut self) { + if !self.form.is_open { + return; + } + + let mut keep_window_open = true; + + egui::Window::new(format!("{} Commit Changes", icons::GIT_COMMIT)) + .collapsible(false) + .resizable(false) + .default_width(300.0) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .open(&mut keep_window_open) + .show(self.ctx, |ui| self.show_commit_form(ui)); + + if !keep_window_open { + self.form.is_open = false; + } + } + + /// Renders the commit form. + fn show_commit_form(&mut self, ui: &mut Ui) { + ui.add_space(10.0); + ui.label("Commit message:"); + ui.add_space(5.0); + ui.add( + egui::TextEdit::multiline(&mut self.form.message) + .hint_text("Enter your commit message here...") + .desired_rows(5) + .desired_width(f32::INFINITY), + ); + + ui.add_space(10.0); + + ui.checkbox(&mut self.form.amend, "Amend last commit") + .on_hover_text("Combine these changes with the previous commit"); + + ui.add_space(5.0); + ui.separator(); + ui.add_space(5.0); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + let can_commit = !self.form.message.trim().is_empty(); + + self.show_commit_button(ui, can_commit); + + if ui.button("Cancel").clicked() { + self.form.is_open = false; + } + }); + } + + /// Helper to render the primary commit button. + fn show_commit_button(&mut self, ui: &mut Ui, can_commit: bool) { + if ui + .add_enabled(can_commit, egui::Button::new("Commit")) + .clicked() + { + self.handle_commit(); + } + } + + /// Initiates the commit process. + fn handle_commit(&mut self) { + self.form.is_open = false; + + let message = self.form.message.clone(); + let amend = self.form.amend; + + let ctx_clone = self.ctx.clone(); + let container = self.container.clone(); + + self.form.reset(); + + self.worker.spawn(self.ctx.clone(), move |tx| { + let report = |msg: &str| { + let _ = tx.send(WorkerEvent::Progress(msg.to_string())); + ctx_clone.request_repaint(); + }; + + report("Committing changes..."); + thread::sleep(std::time::Duration::from_millis(500)); + + let run_task = || -> Result { + let request = CommitRequest::new(message, amend); + + let commit_handler = container.commit_handler(false).map_err(|e| e.to_string())?; + let interceptor = container.plugins_interceptor().map_err(|e| e.to_string())?; + + let _ = commit_handler + .handle_commit(request, &interceptor) + .map_err(|e| e.to_string())?; + + let status_handler = container.status_handler().map_err(|e| e.to_string())?; + report("Fetching status..."); + thread::sleep(std::time::Duration::from_millis(500)); + + let status = status_handler + .handle_status(StatusRequest::with_branch()) + .map_err(|e| e.to_string())?; + + Ok(WorkerResult::StatusRefreshed { status }) + }; + + match run_task() { + Ok(success) => { + let _ = tx.send(WorkerEvent::Success(success)); + } + Err(err_msg) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Commit Failed".to_string(), + "Could not commit changes".to_string(), + err_msg, + ))); + } + } + ctx_clone.request_repaint(); + }); + } +} diff --git a/gui/src/ui/components/dialogs/create_repository_dialog.rs b/gui/src/ui/components/dialogs/create_repository_dialog.rs new file mode 100644 index 0000000..b4d7f97 --- /dev/null +++ b/gui/src/ui/components/dialogs/create_repository_dialog.rs @@ -0,0 +1,275 @@ +use std::{path::PathBuf, sync::Arc, thread}; + +use egui::{Align, Context, Layout, Ui}; +use egui_phosphor::regular::{self as icons}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::{init::Request as InitRequest, status::Request as StatusRequest}, +}; +use shared::change_directory; + +use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; + +/// Holds the transient state of the "Create Repository" dialog. +/// +/// This struct persists in the main application state to ensure the user's +/// selection (path, branch name) is preserved while the dialog is open. +pub struct CreateRepositoryForm { + /// Controls the visibility of the dialog window. + pub is_open: bool, + + /// The name of the initial branch to create (defaults to "main"). + pub branch_name: String, + + /// The file system path where the new repository will be initialized. + pub selected_path: Option, +} + +impl Default for CreateRepositoryForm { + /// Creates a new form state with default values ("main" branch, no path). + fn default() -> Self { + Self { + is_open: false, + branch_name: "main".to_string(), + selected_path: None, + } + } +} + +impl CreateRepositoryForm { + /// Resets the form inputs to their default state. + /// + /// This is called after a successful repository creation or when the + /// dialog is cancelled/closed. + pub fn reset(&mut self) { + self.branch_name = "main".to_string(); + self.selected_path = None; + } +} + +/// A modal dialog for initializing a new repository. +/// +/// This component renders a window allowing the user to: +/// 1. Select a target directory on the file system. +/// 2. Specify the initial branch name. +/// 3. Trigger the initialization process asynchronously. +pub struct CreateRepositoryDialog<'a> { + /// Mutable reference to the form state. + form: &'a mut CreateRepositoryForm, + /// Worker for spawning background initialization tasks. + worker: &'a mut AsyncWorker, + /// Dependency container for accessing the initialization engine. + container: &'a Arc, +} + +impl<'a> CreateRepositoryDialog<'a> { + /// Creates a new [`CreateRepositoryDialog`]. + pub fn new( + form: &'a mut CreateRepositoryForm, + worker: &'a mut AsyncWorker, + container: &'a Arc, + ) -> Self { + Self { + form, + worker, + container, + } + } + + /// Renders the dialog window if `form.is_open` is true. + /// + /// The window is fixed-size and modal-like. It includes validation logic + /// to disable the "Create" button until a valid path is selected. + pub fn show(&mut self, ctx: &Context) { + if !self.form.is_open { + return; + } + + let mut keep_window_open = true; + + egui::Window::new("Create New Repository") + .collapsible(false) + .resizable(false) + .default_width(300.0) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .open(&mut keep_window_open) + .show(ctx, |ui| self.show_create_repository_form(ctx, ui)); + + if !keep_window_open { + self.form.is_open = false; + } + } + + /// Renders the create repository form. + fn show_create_repository_form(&mut self, ctx: &Context, ui: &mut Ui) { + ui.add_space(10.0); + ui.label("Default branch name:"); + ui.add_space(5.0); + ui.text_edit_singleline(&mut self.form.branch_name); + + ui.add_space(10.0); + ui.label("Location:"); + ui.add_space(5.0); + + let display_text = self.get_directory_display_text(); + + ui.monospace(display_text); + ui.add_space(5.0); + + self.show_browse_button(ui); + + ui.add_space(5.0); + ui.separator(); + ui.add_space(5.0); + + ui.with_layout(Layout::right_to_left(Align::Min), |ui| { + let is_ready = self.form.selected_path.is_some() && !self.form.branch_name.is_empty(); + + self.show_create_repository_button(ctx, ui, is_ready); + + if ui.button("Cancel").clicked() { + self.form.is_open = false; + } + }); + } + + /// Renders the button that opens the native OS folder picker. + fn show_browse_button(&mut self, ui: &mut Ui) { + if ui + .button(format!("{} Browse", icons::FOLDER_OPEN)) + .clicked() + && let Some(path) = rfd::FileDialog::new().pick_folder() + { + self.form.selected_path = Some(path); + } + } + + /// Renders the primary action button. + fn show_create_repository_button(&mut self, ctx: &Context, ui: &mut Ui, is_ready: bool) { + if ui + .add_enabled(is_ready, egui::Button::new("Create Repository")) + .clicked() + { + self.handle_create_click(ctx); + } + } + + /// Formats the selected path for display in the UI. + /// + /// Truncates the path from the left (adding "...") if it exceeds a certain length, + /// ensuring the UI doesn't break for deeply nested paths. + fn get_directory_display_text(&self) -> String { + let path_text = self + .form + .selected_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "No directory selected...".to_string()); + + let max_len = 25; + if path_text.chars().count() > max_len { + let truncated: String = path_text + .chars() + .rev() + .take(max_len - 3) + .collect::() + .chars() + .rev() + .collect(); + format!("...{truncated}") + } else { + path_text + } + } + + /// Initiates the repository creation process. + /// + /// This spawns a background task via [`AsyncWorker`] to avoid freezing the UI + /// during file system operations. + fn handle_create_click(&mut self, ctx: &Context) { + self.form.is_open = false; + + let path = self.form.selected_path.clone().unwrap(); + let branch = self.form.branch_name.clone(); + + let ctx_clone = ctx.clone(); + let container = self.container.clone(); + + self.worker.spawn(ctx.clone(), move |tx| { + let report = |msg: &str| { + let _ = tx.send(WorkerEvent::Progress(msg.to_string())); + ctx_clone.request_repaint(); + }; + + report("Initializing directory structure..."); + thread::sleep(std::time::Duration::from_millis(500)); + + match Self::execute_creation_task(container, path, branch, report) { + Ok(success) => { + let _ = tx.send(WorkerEvent::Success(success)); + } + Err(err_msg) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Error".to_string(), + "Initialization failed".to_string(), + err_msg, + ))); + } + } + ctx_clone.request_repaint(); + }); + } + + /// Executes the actual logic for creating the repository. + /// + /// This runs in the background thread. It: + /// 1. Calls the `init` handler to create the `.git` structure. + /// 2. Changes the process working directory to the new repo. + /// 3. Fetches the initial status. + fn execute_creation_task( + container: Arc, + path: PathBuf, + branch: String, + report: impl Fn(&str), + ) -> Result { + report("Initializing directory structure..."); + thread::sleep(std::time::Duration::from_millis(500)); + + let init_handler = container.init_handler().map_err(|e| e.to_string())?; + let interceptor = container.plugins_interceptor().map_err(|e| e.to_string())?; + + let init_response = init_handler + .handle_init( + InitRequest { + working_dir: path, + initial_branch: branch.clone(), + }, + &interceptor, + ) + .map_err(|e| e.to_string())?; + + let repository_parent = init_response + .repository_dir + .parent() + .ok_or_else(|| "Failed to access the required directory".to_string())?; + + change_directory(repository_parent).map_err(|e| e.to_string())?; + + report(&format!("Creating branch '{}'...", &branch)); + thread::sleep(std::time::Duration::from_millis(500)); + + let status_handler = container.status_handler().map_err(|e| e.to_string())?; + report("Fetching status..."); + thread::sleep(std::time::Duration::from_millis(500)); + + let status_response = status_handler + .handle_status(StatusRequest::with_branch()) + .map_err(|e| e.to_string())?; + + Ok(WorkerResult::RepositoryCreated { + init: init_response, + status: status_response, + }) + } +} diff --git a/gui/src/ui/components/dialogs/error_dialog.rs b/gui/src/ui/components/dialogs/error_dialog.rs new file mode 100644 index 0000000..313b7c1 --- /dev/null +++ b/gui/src/ui/components/dialogs/error_dialog.rs @@ -0,0 +1,72 @@ +use egui::Context; +use egui_phosphor::regular as icons; + +use crate::events::EventError; + +/// A modal dialog for displaying critical error messages to the user. +/// +/// This component is typically triggered when an asynchronous background task +/// fails (e.g., `WorkerResult::Error`). It renders a centered window with: +/// - A warning icon. +/// - A high-level subtitle (context). +/// - The detailed error message (technical details) styled in red. +pub struct ErrorDialog<'a> { + /// The structured error data to display. + pub error: &'a EventError, +} + +impl<'a> ErrorDialog<'a> { + /// Renders the error dialog into the provided context. + /// + /// # Behavior + /// + /// The dialog is anchored to the center of the screen, is non-resizable, and + /// non-collapsible. It grabs focus to ensure the user acknowledges the error. + /// + /// # Returns + /// + /// `true` if the dialog should be closed (i.e., the user clicked "Close" or the 'X' button). + /// The caller is responsible for clearing the error state when this returns `true`. + pub fn show(&self, ctx: &Context) -> bool { + let mut is_open = true; + let mut close_button_clicked = false; + + egui::Window::new(&self.error.title) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .collapsible(false) + .resizable(false) + .open(&mut is_open) + .show(ctx, |ui| { + ui.set_width(300.0); + + ui.horizontal(|ui| { + ui.add_space(10.0); + ui.label( + egui::RichText::new(icons::WARNING) + .size(24.0) + .color(egui::Color32::RED), + ); + ui.add_space(10.0); + ui.label(egui::RichText::new(&self.error.subtitle).size(14.0)); + }); + + ui.separator(); + ui.add_space(5.0); + + ui.label( + egui::RichText::new(&self.error.message) + .color(ctx.style().visuals.error_fg_color), + ); + + ui.add_space(20.0); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + if ui.button("Close").clicked() { + close_button_clicked = true; + } + }); + }); + + !is_open || close_button_clicked + } +} diff --git a/gui/src/ui/components/dialogs/loading_dialog.rs b/gui/src/ui/components/dialogs/loading_dialog.rs new file mode 100644 index 0000000..b21555e --- /dev/null +++ b/gui/src/ui/components/dialogs/loading_dialog.rs @@ -0,0 +1,67 @@ +use egui::{Align2, Color32, Context, CornerRadius, Order}; + +/// A modal overlay component used to indicate background activity. +/// +/// When active, this dialog renders a semi-transparent black overlay across the entire +/// application window to block user interaction with the underlying UI. On top of this +/// overlay, it displays a centered window containing a loading spinner and a status message. +pub struct LoadingDialog<'a> { + /// Controls whether the dialog is currently visible. + /// + /// If `false`, the `show()` method returns immediately without rendering anything. + pub active: bool, + + /// The text message displayed below the spinner. + pub message: &'a str, +} + +impl<'a> LoadingDialog<'a> { + /// Renders the loading overlay and spinner into the provided context. + /// + /// # Rendering Layers + /// + /// 1. **Overlay Layer**: An `egui::Area` with `Order::Foreground` is drawn first. + /// It covers the entire screen with a dimmed rectangle and consumes all mouse + /// clicks to prevent them from reaching widgets below. + /// 2. **Dialog Layer**: An `egui::Window` with `Order::Tooltip` (highest priority) + /// is drawn on top of the overlay, ensuring the spinner remains visible and clear. + pub fn show(&self, ctx: &Context) { + if !self.active { + return; + } + + egui::Area::new("loading_overlay".into()) + .interactable(true) + .fixed_pos(egui::pos2(0.0, 0.0)) + .order(Order::Foreground) // Render above normal windows + .show(ctx, |ui| { + let screen_rect = ctx.input(|i| i.content_rect()); + + ui.painter().rect_filled( + screen_rect, + CornerRadius::ZERO, + Color32::from_black_alpha(200), + ); + + // Allocate a rectangle covering the whole screen that senses clicks. + ui.allocate_rect(screen_rect, egui::Sense::click()); + }); + + egui::Window::new("loading") + .title_bar(false) + .collapsible(false) + .resizable(false) + .anchor(Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .order(Order::Tooltip) // Render on top of the overlay + .show(ctx, |ui| { + ui.set_width(250.0); + ui.vertical_centered(|ui| { + ui.add_space(20.0); + ui.add(egui::Spinner::new().size(32.0)); + ui.add_space(15.0); + ui.label(egui::RichText::new(self.message).strong().size(16.0)); + ui.add_space(20.0); + }); + }); + } +} diff --git a/gui/src/ui/components/dialogs/switch_branch_dialog.rs b/gui/src/ui/components/dialogs/switch_branch_dialog.rs new file mode 100644 index 0000000..8b839f0 --- /dev/null +++ b/gui/src/ui/components/dialogs/switch_branch_dialog.rs @@ -0,0 +1,327 @@ +use egui::{Align, Context, Layout, RichText, TextEdit, Ui}; +use egui_phosphor::regular as icons; +use std::sync::Arc; + +use crate::events::{AsyncWorker, WorkerEvent, WorkerResult}; +use engine::{ + branch_manager::{BranchInfo, BranchType}, + engine_container::MevaContainer, + handlers::branch::BranchCollection, +}; + +/// Defines the currently selected category of branches to display. +#[derive(Default, PartialEq, Eq, Clone, Copy)] +pub enum BranchTab { + /// Show branches that exist locally on the machine. + #[default] + Local, + /// Show branches that track remote repositories (e.g., origin/main). + Remote, +} + +/// Holds the transient state of the "Switch/Create Branch" dialog. +/// +/// This struct persists in the main application state, allowing the dialog +/// to retain user input (filter text, new branch name) even if closed and reopened. +#[derive(Default)] +pub struct SwitchBranchForm { + /// Controls the visibility of the dialog window. + pub is_open: bool, + + /// The text entered into the search bar to filter the branch list. + pub filter_query: String, + + /// The name entered for a potential new branch. + pub new_branch_name: String, + + /// The optional start point (commit hash or tag) for the new branch. + pub start_point: String, + + /// The currently active tab (Local vs Remote). + pub current_tab: BranchTab, +} + +impl SwitchBranchForm { + /// Resets all form fields to their default state. + /// + /// Useful when opening the dialog fresh or after a successful operation. + pub fn reset(&mut self) { + self.filter_query.clear(); + self.new_branch_name.clear(); + self.start_point.clear(); + self.current_tab = BranchTab::Local; + } +} + +/// A complex modal dialog that combines branch listing, filtering, switching, +/// and creation into a single unified interface. +/// +/// It delegates the actual list of branches to `BranchCollection` and uses +/// [`AsyncWorker`] to perform heavy Git operations (checkout/create) without freezing the UI. +pub struct SwitchBranchDialog<'a> { + /// Mutable reference to the form state. + form: &'a mut SwitchBranchForm, + /// Read-only reference to the loaded branches (if any). + branches: &'a Option, + /// Worker for spawning background tasks. + worker: &'a mut AsyncWorker, + /// Dependency container (unused here but kept for future expansion). + _container: &'a Arc, + /// Egui context for repainting. + ctx: &'a Context, +} + +impl<'a> SwitchBranchDialog<'a> { + /// Creates a new [`SwitchBranchDialog`]. + pub fn new( + form: &'a mut SwitchBranchForm, + branches: &'a Option, + worker: &'a mut AsyncWorker, + container: &'a Arc, + ctx: &'a Context, + ) -> Self { + Self { + form, + branches, + worker, + _container: container, + ctx, + } + } + + /// Renders the dialog window if `form.is_open` is true. + /// + /// The layout consists of: + /// 1. A search/filter bar. + /// 2. Tabs to toggle between Local and Remote branches. + /// 3. A scrollable list of branches. + /// 4. A form at the bottom to create a new branch. + pub fn show(&mut self) { + if !self.form.is_open { + return; + } + + let mut keep_window_open = true; + + egui::Window::new(format!("{} Switch / Create Branch", icons::GIT_BRANCH)) + .collapsible(false) + .resizable(false) + .default_width(300.0) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .open(&mut keep_window_open) + .show(self.ctx, |ui| { + ui.add_space(10.0); + ui.label(RichText::new("Switch between branches").size(14.0)); + ui.add_space(5.0); + + self.show_filter_branches(ui); + + ui.add_space(10.0); + + self.show_local_remote_tabs(ui); + + ui.add_space(5.0); + + self.show_branches_list(ui); + + ui.add_space(10.0); + self.show_create_branch_form(ui); + }); + + if !keep_window_open { + self.form.is_open = false; + } + } + + /// Renders the tab switcher (Local / Remote) using clickable labels. + fn show_local_remote_tabs(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.columns(2, |columns| { + if columns[0] + .selectable_label(self.form.current_tab == BranchTab::Local, "Local") + .clicked() + { + self.form.current_tab = BranchTab::Local; + } + + if columns[1] + .selectable_label(self.form.current_tab == BranchTab::Remote, "Remote") + .clicked() + { + self.form.current_tab = BranchTab::Remote; + } + }); + }); + } + + /// Renders the search input field. + fn show_filter_branches(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label(icons::MAGNIFYING_GLASS); + ui.add( + TextEdit::singleline(&mut self.form.filter_query) + .hint_text("Filter branches...") + .desired_width(f32::INFINITY), + ); + }); + } + + /// Renders the scrollable container for the list of branches. + fn show_branches_list(&mut self, ui: &mut Ui) { + egui::Frame::group(ui.style()) + .inner_margin(5.0) + .show(ui, |ui| { + egui::ScrollArea::vertical() + .max_height(150.0) + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.set_min_height(150.0); + + if let Some(collection) = self.branches { + self.show_active_tab(ui, collection); + } else { + ui.vertical_centered(|ui| { + ui.add_space(80.0); + ui.label("No branches loaded."); + }); + } + }); + }); + } + + /// Renders the actual list items based on the currently selected tab. + fn show_active_tab(&mut self, ui: &mut Ui, collection: &BranchCollection) { + match self.form.current_tab { + BranchTab::Local => { + if !collection.has_local_branches() { + ui.vertical_centered(|ui| { + ui.add_space(20.0); + ui.weak("No local branches found."); + }); + } else { + for branch in collection.local_branches() { + self.show_branch_item(ui, branch); + } + } + } + BranchTab::Remote => { + if !collection.has_remote_branches() { + ui.vertical_centered(|ui| { + ui.add_space(20.0); + ui.weak("No remote branches found."); + }); + } else { + for branch in collection.remote_branches() { + self.show_branch_item(ui, branch); + } + } + } + } + } + + /// Renders the "Create new branch" form section at the bottom of the dialog. + fn show_create_branch_form(&mut self, ui: &mut Ui) { + ui.label(RichText::new("Create new branch").size(14.0)); + ui.add_space(5.0); + + egui::Grid::new("create_branch_grid") + .num_columns(2) + .spacing([10.0, 8.0]) + .show(ui, |ui| { + ui.label("Name:"); + ui.add( + egui::TextEdit::singleline(&mut self.form.new_branch_name) + .desired_width(f32::INFINITY), + ); + ui.end_row(); + + ui.label("Start point:"); + ui.add( + egui::TextEdit::singleline(&mut self.form.start_point) + .hint_text("Optional hash") + .desired_width(f32::INFINITY), + ) + .on_hover_text("Leave empty to start from HEAD"); + ui.end_row(); + }); + + ui.add_space(5.0); + ui.separator(); + ui.add_space(5.0); + + ui.with_layout(Layout::right_to_left(Align::Min), |ui| { + let enabled = !self.form.new_branch_name.trim().is_empty(); + self.show_create_and_switch_button(ui, enabled); + }); + } + + /// Renders the button that triggers creation of a new branch. + fn show_create_and_switch_button(&mut self, ui: &mut Ui, enabled: bool) { + if ui + .add_enabled(enabled, egui::Button::new("Create & Switch")) + .clicked() + { + self.handle_create_branch(); + } + } + + /// Renders a single branch item in the list. + /// + /// Applies the search filter to hide items that don't match. + fn show_branch_item(&mut self, ui: &mut Ui, info: &BranchInfo) { + let query = self.form.filter_query.to_lowercase(); + let name = info.get_name(); + + if !query.is_empty() && !name.to_lowercase().contains(&query) { + return; + } + + let (icon, color) = match info.get_type() { + BranchType::Local => (icons::GIT_BRANCH, ui.visuals().text_color()), + BranchType::Remote => (icons::CLOUD, ui.visuals().weak_text_color()), + }; + + let button = egui::Button::new(egui::RichText::new(format!("{icon} {name}")).color(color)) + .frame(false); + + if ui.add(button).clicked() { + self.handle_checkout(name.to_string()); + } + } + + /// Spawns a background task to checkout the selected branch. + fn handle_checkout(&mut self, branch_name: String) { + self.form.is_open = false; + let ctx = self.ctx.clone(); + + self.worker.spawn(ctx.clone(), move |tx| { + let _ = tx.send(WorkerEvent::Progress(format!( + "Checking out '{branch_name}'...", + ))); + std::thread::sleep(std::time::Duration::from_millis(500)); + // TODO: Integrate actual engine checkout logic here + + let _ = tx.send(WorkerEvent::Success(WorkerResult::None)); + ctx.request_repaint(); + }); + } + + /// Spawns a background task to create and checkout a new branch. + fn handle_create_branch(&mut self) { + self.form.is_open = false; + let name = self.form.new_branch_name.clone(); + + let ctx = self.ctx.clone(); + + self.worker.spawn(ctx.clone(), move |tx| { + let _ = tx.send(WorkerEvent::Progress(format!( + "Creating branch '{name}'..." + ))); + std::thread::sleep(std::time::Duration::from_millis(500)); + // TODO: Integrate actual engine branch creation logic here + let _ = tx.send(WorkerEvent::Success(WorkerResult::None)); + ctx.request_repaint(); + }); + } +} diff --git a/gui/src/ui/components/file_changes.rs b/gui/src/ui/components/file_changes.rs new file mode 100644 index 0000000..172347f --- /dev/null +++ b/gui/src/ui/components/file_changes.rs @@ -0,0 +1,515 @@ +use std::{ + path::{Path, PathBuf}, + sync::Arc, + thread, +}; + +use eframe::egui; +use egui::{Color32, RichText, Ui, collapsing_header::CollapsingState}; +use egui_phosphor::regular as icons; + +use engine::{ + EngineContainer, + diff_builder::{ChangeKind, DiffMode}, + engine_container::MevaContainer, + handlers::{ + add::AddRequest, + diff::Request as DiffRequest, + restore::Request as RestoreRequest, + status::{Request as StatusRequest, Response as StatusResponse, StatusEntry, StatusKind}, + }, + revision_parsing::Revision, +}; +use shared::PathToString; + +use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; + +/// Persists the UI state of the file changes view across frames. +/// +/// This struct tracks which sections (Staged, Unstaged, etc.) are currently +/// expanded or collapsed by the user. +#[derive(Debug, Clone)] +pub struct FileChangesState { + pub staged_open: bool, + pub unstaged_open: bool, + pub untracked_open: bool, + pub unmerged_open: bool, +} + +impl Default for FileChangesState { + fn default() -> Self { + Self { + staged_open: true, + unstaged_open: true, + untracked_open: false, + unmerged_open: true, + } + } +} + +/// A complex UI component responsible for listing modified files and providing action buttons. +/// +/// This component renders the "Working Tree" view, dividing files into four categories: +/// 1. **Staged Changes**: Ready to be committed. +/// 2. **Changes**: Modified but not yet staged. +/// 3. **Untracked**: New files not yet added to git. +/// 4. **Unmerged**: Files with merge conflicts. +/// +/// It handles user interactions such as staging, unstaging, and discarding changes +/// by spawning asynchronous background tasks. +pub struct FileChangesComponent<'a> { + worker: &'a mut AsyncWorker, + container: &'a Arc, + ctx: &'a egui::Context, +} + +impl<'a> FileChangesComponent<'a> { + /// Creates a new [`FileChangesComponent]`. + pub fn new( + worker: &'a mut AsyncWorker, + container: &'a Arc, + ctx: &'a egui::Context, + ) -> Self { + Self { + worker, + container, + ctx, + } + } + + /// Renders the complete file status view. + /// + /// This method iterates through the `StatusResponse` data and renders collapsible + /// sections for each category of file changes. + /// + /// # Arguments + /// + /// * `ui` - The Egui UI builder. + /// * `status` - The current repository status data from the engine. + /// * `state` - Mutable reference to the UI state (expanded/collapsed headers). + pub fn show( + &mut self, + ui: &mut Ui, + status: &engine::handlers::status::Response, + state: &mut FileChangesState, + ) { + self.render_section( + ui, + &status.staged, + "Staged Changes", + &mut state.staged_open, + true, + ); + self.render_section( + ui, + &status.unstaged, + "Changes", + &mut state.unstaged_open, + false, + ); + self.render_section( + ui, + &status.untracked, + "Untracked", + &mut state.untracked_open, + false, + ); + self.render_section( + ui, + &status.unmerged, + "Unmerged", + &mut state.unmerged_open, + false, + ); + } + + /// Helper to render a generic collapsible section of files. + /// + /// Only renders the section if the list of entries is not empty. + fn render_section( + &mut self, + ui: &mut Ui, + entries: &Option>, + title: &str, + is_open: &mut bool, + is_staged_section: bool, + ) { + if let Some(list) = entries + && !list.is_empty() + { + let id = ui.make_persistent_id(title); + + let mut state = CollapsingState::load_with_default_open(ui.ctx(), id, *is_open); + + state.set_open(*is_open); + + let (toggle_response, _, _) = state + .show_header(ui, |ui| { + ui.label(format!("{title} ({})", list.len())); + }) + .body(|ui| { + for entry in list { + self.render_file_row(ui, entry, is_staged_section); + } + }); + + if toggle_response.clicked() { + *is_open = !*is_open; + } + + ui.add_space(5.0); + } + } + + /// Renders a single row representing a file in the status list. + /// + /// Displays: + /// * The status icon/letter (colored based on change type). + /// * The file path (truncated if necessary). + /// * Action buttons (Stage/Unstage, Context Menu). + fn render_file_row(&mut self, ui: &mut Ui, entry: &StatusEntry, is_section_staged: bool) { + let (letter, color) = self.get_status_style(&entry.kind); + let full_path = entry.path.to_string_lossy().to_string(); + let short_path = Self::smart_truncate_path(&entry.path, 30); + + egui::Frame::NONE + .inner_margin(4.0) + .corner_radius(2.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + + ui.label( + RichText::new(format!("{letter} ")) + .color(color) + .strong() + .monospace(), + ); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(2.0); + + let style = ui.style_mut(); + let original_button_frame = style.visuals.button_frame; + style.visuals.button_frame = false; + + self.show_more_actions_menu(ui, entry, is_section_staged); + + ui.style_mut().visuals.button_frame = original_button_frame; + ui.add_space(4.0); + + self.show_primary_action_button(ui, entry, is_section_staged); + self.show_file_path(ui, &short_path, &full_path); + }); + }) + }); + } + + /// Renders the file path button. + /// + /// Shows a truncated path to save space, but displays the full path in a tooltip on hover. + fn show_file_path(&mut self, ui: &mut Ui, short_path: &str, full_path: &str) { + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + let label_btn = ui.add( + egui::Button::new(RichText::new(short_path).size(13.0)) + .frame(false) + .truncate() + .sense(egui::Sense::hover()), + ); + label_btn.on_hover_text(full_path); + }); + } + + /// Renders the primary action button for a file (Stage `+` or Unstage `-`). + fn show_primary_action_button( + &mut self, + ui: &mut Ui, + entry: &StatusEntry, + is_section_staged: bool, + ) { + let (icon, tooltip) = if is_section_staged { + (icons::MINUS_CIRCLE, "Unstage changes") + } else { + (icons::PLUS_CIRCLE, "Stage changes") + }; + + if ui + .add(egui::Button::new(RichText::new(icon).size(14.0)).frame(false)) + .on_hover_text(tooltip) + .clicked() + { + if is_section_staged { + self.handle_unstage(&entry.path); + } else { + self.handle_stage(&entry.path); + } + } + } + + /// Renders the "three dots" menu button containing secondary actions. + fn show_more_actions_menu( + &mut self, + ui: &mut Ui, + entry: &StatusEntry, + is_section_staged: bool, + ) { + ui.menu_button( + RichText::new(icons::DOTS_THREE_OUTLINE_VERTICAL).size(14.0), + |ui| { + ui.style_mut().visuals.button_frame = true; + ui.set_min_width(120.0); + + self.show_diff_menu_item(ui, entry, is_section_staged); + + // Discard is only available for unstaged changes + if matches!(entry.kind, StatusKind::Unstaged(_)) { + ui.separator(); + self.show_discard_menu_item(ui, entry); + } + }, + ); + } + + /// Renders the "Discard Changes" menu item with visual warning (red color). + fn show_discard_menu_item(&mut self, ui: &mut Ui, entry: &StatusEntry) { + if ui + .button( + RichText::new(format!( + "{} Discard Changes", + icons::ARROW_COUNTER_CLOCKWISE + )) + .color(ui.visuals().error_fg_color), + ) + .clicked() + { + ui.close_kind(egui::UiKind::Menu); + self.handle_discard(&entry.path); + } + } + + /// Renders the "Show Diff" menu item. + fn show_diff_menu_item(&mut self, ui: &mut Ui, entry: &StatusEntry, is_section_staged: bool) { + if ui + .button(format!("{} Show Diff", icons::FILE_CODE)) + .clicked() + { + ui.close_kind(egui::UiKind::Menu); + self.handle_show_diff(&entry.path, is_section_staged); + } + } + + /// Triggers the background task to stage a file. + fn handle_stage(&mut self, path: &Path) { + self.spawn_file_action(path, "Staging file...", |c, p| { + let add_handler = c.add_handler().map_err(|e| e.to_string())?; + let interceptor = c.plugins_interceptor().map_err(|e| e.to_string())?; + add_handler + .handle_add(AddRequest::with_path(&p), &interceptor) + .map_err(|e| e.to_string())?; + thread::sleep(std::time::Duration::from_millis(500)); + Ok(WorkerResult::None) + }); + } + + /// Triggers the background task to unstage a file. + fn handle_unstage(&mut self, path: &Path) { + self.spawn_file_action(path, "Unstaging file...", |c, p| { + let restore_handler = c.restore_handler().map_err(|e| e.to_string())?; + let request = RestoreRequest { + staged: true, + worktree: false, + source: Revision::head(vec![]), + paths: vec![p], + }; + restore_handler + .handle_restore(request) + .map_err(|e| e.to_string())?; + thread::sleep(std::time::Duration::from_millis(500)); + Ok(WorkerResult::None) + }); + } + + /// Triggers the background task to discard changes to a file (`git restore`). + /// + /// **Warning**: This operation is destructive and cannot be undone via Git. + fn handle_discard(&mut self, path: &Path) { + self.spawn_file_action(path, "Discarding changes...", |c, p| { + let restore_handler = c.restore_handler().map_err(|e| e.to_string())?; + let request = RestoreRequest { + staged: false, + worktree: true, + source: Revision::head(vec![]), + paths: vec![p], + }; + restore_handler + .handle_restore(request) + .map_err(|e| e.to_string())?; + thread::sleep(std::time::Duration::from_millis(500)); + Ok(WorkerResult::None) + }); + } + + /// Spawns a background task to calculate the diff for the selected file. + /// + /// This method sends a `WorkerEvent::Progress` immediately, performs the diff + /// calculation in a separate thread, and returns a `WorkerResult::DiffOpened` + /// upon success. + fn handle_show_diff(&mut self, path: &Path, is_staged: bool) { + let path_buf = path.to_path_buf(); + let ctx = self.ctx.clone(); + let container = self.container.clone(); + + self.worker.spawn(ctx.clone(), move |tx| { + let _ = tx.send(WorkerEvent::Progress("Calculating diff...".to_string())); + + let run_task = || -> Result { + let diff_handler = container.diff_handler().map_err(|e| e.to_string())?; + + let paths = Some(vec![path_buf.clone()]); + + let diff_request = if is_staged { + DiffRequest::new( + Some(Revision::head(vec![])), + None, + true, + DiffMode::Full, + paths, + ) + } else { + DiffRequest::new(None, None, false, DiffMode::Full, paths) + }; + + let diff_response = diff_handler + .handle_diff(diff_request) + .map_err(|e| e.to_string())?; + + Ok(WorkerResult::DiffOpened { + diff: diff_response, + path: path_buf.clone(), + }) + }; + + match run_task() { + Ok(result) => { + let _ = tx.send(WorkerEvent::Success(result)); + } + Err(err_msg) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Diff Error".to_string(), + format!("Could not calculate diff for {}", path_buf.to_utf8_string()), + err_msg, + ))); + } + } + + ctx.request_repaint(); + }); + } + + /// Generic helper to spawn a background thread for a single file operation. + /// + /// This method: + /// 1. Sends a `Progress` event to the UI. + /// 2. Executes the provided `action` closure. + /// 3. Automatically triggers a status refresh after the action completes. + /// 4. Sends a `Success` (with fresh status) or `Error` event back to the UI. + fn spawn_file_action(&mut self, path: &Path, progress_msg: &str, action: F) + where + F: FnOnce(Arc, PathBuf) -> Result + Send + 'static, + { + let ctx = self.ctx.clone(); + let container = self.container.clone(); + let path_buf = path.to_path_buf(); + let msg = progress_msg.to_string(); + + self.worker.spawn(ctx.clone(), move |tx| { + let _ = tx.send(WorkerEvent::Progress(msg)); + + let task_result: Result = (|| { + action(container.clone(), path_buf)?; + + let new_status = { + let handler = container.status_handler().map_err(|e| e.to_string())?; + handler + .handle_status(StatusRequest::with_branch()) + .map_err(|e| e.to_string())? + }; + + Ok(new_status) + })(); + + match task_result { + Ok(status_response) => { + let _ = tx.send(WorkerEvent::Success(WorkerResult::StatusRefreshed { + status: status_response, + })); + } + Err(err_msg) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Error".to_string(), + "Action failed".to_string(), + err_msg, + ))); + } + } + ctx.request_repaint(); + }); + } + + /// Smartly truncates a file path to fit within a specific character limit. + /// + /// Prioritizes the filename (the end of the path) and creates an ellipsis + /// in the middle if necessary, rather than cutting off the end. + fn smart_truncate_path(path: &Path, max_chars: usize) -> String { + let full_str = path.to_string_lossy(); + let char_count = full_str.chars().count(); + + if char_count <= max_chars { + return full_str.to_string(); + } + + let file_name = path + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_default(); + + let file_name_chars = file_name.chars().count(); + + if file_name_chars >= max_chars.saturating_sub(3) { + let skip_n = file_name_chars.saturating_sub(max_chars.saturating_sub(3)); + let truncated: String = file_name.chars().skip(skip_n).collect(); + return format!("...{truncated}"); + } + + let keep_n = max_chars.saturating_sub(3); + let skip_n = char_count.saturating_sub(keep_n); + let truncated: String = full_str.chars().skip(skip_n).collect(); + + format!("...{truncated}") + } + + /// Maps a `ChangeKind` (Added, Modified, Deleted) to a visual representation. + fn get_change_style(&self, kind: ChangeKind) -> (char, Color32) { + let color = match kind { + ChangeKind::Added => Color32::GREEN, + ChangeKind::Modified => Color32::from_rgb(226, 192, 141), + ChangeKind::Deleted => Color32::RED, + ChangeKind::Unmerged => Color32::GOLD, + }; + (kind.into(), color) + } + + /// Maps a `StatusKind` to a character and color for the UI list. + fn get_status_style(&self, kind: &StatusKind) -> (char, Color32) { + let color = match kind { + StatusKind::Staged(change) | StatusKind::Unstaged(change) => { + self.get_change_style(*change).1 + } + StatusKind::Untracked => Color32::GRAY, + StatusKind::Ignored => Color32::DARK_GRAY, + StatusKind::Unmerged { .. } => Color32::from_rgb(255, 69, 0), + }; + + ((*kind).into(), color) + } +} diff --git a/gui/src/ui/components/remote_actions.rs b/gui/src/ui/components/remote_actions.rs new file mode 100644 index 0000000..898fc25 --- /dev/null +++ b/gui/src/ui/components/remote_actions.rs @@ -0,0 +1,181 @@ +use std::{ + sync::Arc, + thread, + time::{Duration, Instant}, +}; + +use egui::Ui; +use egui_phosphor::regular as icons; + +use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::{fetch::Request as FetchRequest, status::BranchInfo}, +}; + +use super::IconButton; + +/// A UI component representing a toolbar of remote operations (Sync, Push, Pull, Fetch). +/// +/// This component conditionally renders itself only if the current branch has a configured +/// upstream remote. It serves as the primary interface for synchronizing local changes +/// with the remote repository. +pub struct RemoteActionsComponent<'a> { + worker: &'a mut AsyncWorker, + container: &'a Arc, + ctx: &'a egui::Context, + /// Information about the current branch, used to determine upstream status. + branch: &'a BranchInfo, +} + +impl<'a> RemoteActionsComponent<'a> { + /// Creates a new [`RemoteActionsComponent`]. + pub fn new( + worker: &'a mut AsyncWorker, + container: &'a Arc, + ctx: &'a egui::Context, + branch: &'a BranchInfo, + ) -> Self { + Self { + worker, + container, + ctx, + branch, + } + } + + /// Renders the toolbar buttons into the provided UI. + /// + /// # Visibility Logic + /// + /// This method returns early (rendering nothing) if: + /// - The current branch has no upstream (local-only branch). + /// - The HEAD is detached (no branch to push/pull). + pub fn show(&mut self, ui: &mut Ui) { + if self.branch.is_detached { + return; + } + + let Some(upstream) = &self.branch.upstream else { + return; + }; + + let branch = self.branch.head.clone().unwrap_or_default(); + + if IconButton::new(icons::ARROWS_CLOCKWISE, "Sync (Pull & Push)").show(ui) { + self.spawn_action("Syncing repository...", "Sync failed", |_container| { + thread::sleep(std::time::Duration::from_millis(800)); + Ok(()) + }); + } + + if IconButton::new(icons::ARROW_UP, "Push to upstream").show(ui) { + self.spawn_action("Pushing changes...", "Push failed", |_container| { + thread::sleep(std::time::Duration::from_millis(800)); + Ok(()) + }); + } + + if IconButton::new(icons::ARROW_DOWN, "Pull from upstream").show(ui) { + self.spawn_action("Pulling changes...", "Pull failed", |_container| { + thread::sleep(std::time::Duration::from_millis(800)); + Ok(()) + }); + } + + if IconButton::new(icons::DOWNLOAD_SIMPLE, "Fetch from upstream").show(ui) { + self.handle_fetch_click(upstream.clone(), branch.clone()); + } + + ui.separator(); + } + + /// Triggers the background fetch operation. + /// + /// Since the fetch handler uses `async/await`, this method creates a temporary + /// Tokio runtime inside the worker thread to execute the future. + fn handle_fetch_click(&mut self, upstream: String, branch: String) { + self.spawn_action("Fetching origin...", "Fetch failed", move |container| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("Failed to create runtime: {e}"))?; + + rt.block_on(async { + let fetch_handler = container.fetch_handler().map_err(|e| e.to_string())?; + + let request = FetchRequest { + origin: upstream, + branch: Some(branch), + prune: false, + verbose: false, + }; + + let _ = fetch_handler + .handle_fetch(request) + .await + .map_err(|e| e.to_string())?; + + Ok::<(), String>(()) + })?; + + Ok(()) + }); + } + + /// Generic helper to spawn a background thread for a remote operation. + /// + /// Handles: + /// - Sending the initial "Progress" event. + /// - executing the closure. + /// - Enforcing a minimum execution time (to prevent UI flickering). + /// - Sending "Success" or "Error" events. + fn spawn_action(&mut self, progress_msg: &str, error_title: &str, action: F) + where + F: FnOnce(&Arc) -> Result<(), String> + Send + 'static, + { + let ctx_clone = self.ctx.clone(); + let container = self.container.clone(); + + let progress_msg = progress_msg.to_string(); + let error_title = error_title.to_string(); + + self.worker.spawn(self.ctx.clone(), move |tx| { + let start_time = Instant::now(); + + let report = |msg: &str| { + let _ = tx.send(WorkerEvent::Progress(msg.to_string())); + ctx_clone.request_repaint(); + }; + + report(&progress_msg); + + let run_task = || -> Result { + action(&container)?; + + let elapsed = start_time.elapsed(); + let min_duration = Duration::from_millis(800); + if elapsed < min_duration { + thread::sleep(min_duration - elapsed); + } + + Ok(WorkerResult::None) + }; + + match run_task() { + Ok(success) => { + let _ = tx.send(WorkerEvent::Success(success)); + } + Err(err_msg) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Remote Error".to_string(), + error_title, + err_msg, + ))); + } + } + ctx_clone.request_repaint(); + }); + } +} diff --git a/gui/src/ui/views.rs b/gui/src/ui/views.rs new file mode 100644 index 0000000..c69a5d6 --- /dev/null +++ b/gui/src/ui/views.rs @@ -0,0 +1,23 @@ +mod dashboard; +mod diff; +mod log; +mod settings; + +pub use dashboard::DashboardView; +pub use diff::DiffView; +pub use log::LogView; +pub use settings::SettingsView; + +/// A common interface for all major application views (screens). +/// +/// Implementing this trait allows the main application loop to switch between +/// different screens (e.g., Dashboard, Settings) generically, +/// without knowing the implementation details of each screen. +pub trait View { + /// Renders the view's content into the provided UI region. + /// + /// # Arguments + /// + /// * `ui` - The `egui::Ui` context where the view should draw its widgets. + fn show(&mut self, ui: &mut egui::Ui); +} diff --git a/gui/src/ui/views/dashboard.rs b/gui/src/ui/views/dashboard.rs new file mode 100644 index 0000000..9627876 --- /dev/null +++ b/gui/src/ui/views/dashboard.rs @@ -0,0 +1,118 @@ +use egui::Ui; +use egui_phosphor::regular as icons; + +use super::View; + +/// The landing page of the application. +/// +/// This view is displayed when no specific repository is open or as the default +/// startup screen. It provides context about the project (Engineering Thesis), +/// lists the authors/supervisors, and guides the user to the sidebar for actions. +pub struct DashboardView; + +impl DashboardView { + /// Renders the main application title ("MEVA") and the tagline. + fn render_header(&self, ui: &mut Ui) { + ui.heading( + egui::RichText::new(format!("{} MEVA", icons::FEATHER)) + .size(32.0) + .strong(), + ); + + ui.add_space(10.0); + + ui.label( + egui::RichText::new("A Distributed Version Control System powered by Rust").size(16.0), + ); + } + + /// Renders the academic context of the project. + /// + /// Displays the university, faculty, and thesis details. + fn render_thesis_info(&self, ui: &mut Ui) { + ui.label("This software was developed as an"); + ui.label( + egui::RichText::new("Engineering Thesis") + .strong() + .size(18.0), + ); + + ui.add_space(5.0); + + ui.label("at the"); + ui.label( + egui::RichText::new("Warsaw University of Technology") + .strong() + .size(20.0), + ); + + ui.add_space(5.0); + ui.label("Faculty of Mathematics and Information Science"); + ui.label(egui::RichText::new("Academic Year 2025/2026")); + } + + /// Renders the credits section. + /// + /// Includes: + /// - Authors with clickable hyperlinks to their GitHub profiles. + /// - Supervisors associated with the thesis. + fn render_authors(&self, ui: &mut Ui) { + ui.group(|ui| { + ui.set_width(400.0); + ui.vertical_centered(|ui| { + ui.add_space(5.0); + ui.label(egui::RichText::new(format!("{} Authors", icons::USERS)).strong()); + ui.add_space(5.0); + ui.hyperlink_to( + format!("{} Adam Grącikowski", icons::GITHUB_LOGO), + "https://github.com/adamgracikowski", + ); + ui.hyperlink_to( + format!("{} Mikołaj Karbowski", icons::GITHUB_LOGO), + "https://github.com/mikolajkarbowski", + ); + ui.add_space(10.0); + ui.separator(); + ui.add_space(5.0); + ui.label(egui::RichText::new(format!("{} Supervisors", icons::USERS)).strong()); + ui.add_space(5.0); + ui.label("mgr inż. Tomasz Herman"); + ui.label("dr inż. Krzysztof Kaczmarski"); + ui.add_space(5.0); + }); + }); + } + + /// Renders a footer message guiding the user to the next step. + fn render_footer(&self, ui: &mut Ui) { + ui.label( + egui::RichText::new("Select an action from the sidebar to start.") + .color(ui.visuals().weak_text_color()), + ); + } +} + +impl View for DashboardView { + /// Composes the dashboard by rendering all sub-sections in a centered vertical layout. + fn show(&mut self, ui: &mut egui::Ui) { + ui.vertical_centered(|ui| { + ui.add_space(20.0); + + self.render_header(ui); + + ui.add_space(20.0); + ui.separator(); + ui.add_space(20.0); + + self.render_thesis_info(ui); + + ui.add_space(30.0); + + self.render_authors(ui); + + ui.add_space(40.0); + + self.render_footer(ui); + }); + } +} diff --git a/gui/src/ui/views/diff.rs b/gui/src/ui/views/diff.rs new file mode 100644 index 0000000..ce8df27 --- /dev/null +++ b/gui/src/ui/views/diff.rs @@ -0,0 +1,342 @@ +use super::View; +use egui::{Button, Color32, RichText, ScrollArea, Ui}; +use egui_phosphor::regular as icons; +use engine::{ + diff_builder::{FileChange, FileChangeKind, Hunk, HunkLineType}, + handlers::diff::Response as DiffResponse, +}; +use shared::PathToString; +use std::path::PathBuf; + +/// Represents a single open tab in the diff view. +/// +/// Stores the file path and the associated diff data necessary to render the changes. +pub struct DiffTab { + path: PathBuf, + diff: DiffResponse, +} + +/// The main component for displaying file differences. +/// +/// Manages a list of open tabs ([`DiffTab`]) and tracks the currently selected tab. +/// It provides functionality to open new diffs, switch between them, and close tabs. +#[derive(Default)] +pub struct DiffView { + /// List of currently open file diffs. + tabs: Vec, + /// Index of the currently active tab. `None` if no tabs are open. + selected_index: Option, +} + +impl DiffView { + /// Opens a diff for a specific file path. + /// + /// If a tab for the given `path` already exists, it updates the diff data + /// and switches focus to that tab. Otherwise, it creates a new tab and selects it. + pub fn open(&mut self, path: PathBuf, diff: DiffResponse) { + if let Some(index) = self.tabs.iter().position(|t| t.path == path) { + self.selected_index = Some(index); + self.tabs[index].diff = diff; + } else { + self.tabs.push(DiffTab { path, diff }); + self.selected_index = Some(self.tabs.len() - 1); + } + } + + /// Checks if there are any currently open tabs. + pub fn has_open_tabs(&self) -> bool { + !self.tabs.is_empty() + } + + /// Renders the tab bar navigation. + /// + /// Handles drawing tab buttons, visual selection states, and closing tabs via the 'X' button. + /// + /// # Returns + /// + /// Returns `true` if the tab list is empty (e.g., after closing the last tab), + /// indicating that the parent view might want to switch context. + pub fn show_tabs(&mut self, ui: &mut Ui) -> bool { + let mut tab_to_close = None; + + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + + for (index, tab) in self.tabs.iter().enumerate() { + let is_selected = Some(index) == self.selected_index; + let file_name = tab.path.to_utf8_string(); + + let bg_color = if is_selected { + ui.visuals().selection.bg_fill + } else { + ui.visuals().faint_bg_color + }; + + let fg_color = if is_selected { + ui.visuals().selection.stroke.color + } else { + ui.visuals().text_color() + }; + + egui::Frame::default() + .fill(bg_color) + .corner_radius(4.0) + .inner_margin(2.0) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().widgets.noninteractive.bg_stroke.color, + )) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Tab Label (Selects the tab) + if ui + .add( + Button::new(RichText::new(&file_name).color(fg_color)) + .frame(false), + ) + .clicked() + { + self.selected_index = Some(index); + } + + if ui + .add( + Button::new( + RichText::new(icons::X_CIRCLE).size(12.0).color(fg_color), + ) + .frame(false), + ) + .clicked() + { + tab_to_close = Some(index); + } + }); + }); + } + }); + + // Handle tab closing logic outside the loop to avoid mutation during iteration + if let Some(index_to_remove) = tab_to_close { + self.tabs.remove(index_to_remove); + + if self.tabs.is_empty() { + self.selected_index = None; + return true; + } else if Some(index_to_remove) == self.selected_index { + self.selected_index = Some(index_to_remove.saturating_sub(1)); + } else if let Some(current) = self.selected_index + && index_to_remove < current + { + self.selected_index = Some(current - 1); + } + } + + self.tabs.is_empty() + } + + /// Renders the main content area for the currently selected tab. + /// + /// Creates a scrollable area and delegates rendering to `show_file_diff`. + pub fn show_diff_content(&self, ui: &mut Ui) { + if let Some(index) = self.selected_index + && let Some(tab) = self.tabs.get(index) + { + ScrollArea::both() + .id_salt("diff_scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.add_space(10.0); + + if let Some(files) = &tab.diff.files { + if let Some(file) = files.first() { + self.show_file_diff(ui, file); + } else { + ui.label("No file changes found in response."); + } + } else { + ui.label("No file data available."); + } + }); + } + } + + /// Renders the diff details for a specific file. + /// + /// Shows the file header and either a grid of hunks (chunks of changes) + /// or the raw unified diff text if parsed hunks are unavailable. + fn show_file_diff(&self, ui: &mut Ui, file: &FileChange) { + self.show_file_header(ui, file); + + ui.add_space(5.0); + + if let Some(hunks) = &file.hunks { + egui::Grid::new("diff_grid") + .striped(false) + .num_columns(4) // Columns: Old Line #, New Line #, Marker (+/-), Content + .spacing([10.0, 0.0]) + .min_col_width(20.0) + .show(ui, |ui| { + for hunk in hunks { + self.show_hunk(ui, hunk); + } + }); + } else if file.unified_diff.is_some() { + ui.monospace(file.unified_diff.as_ref().unwrap()); + } else { + ui.weak("No content changes (maybe binary file or empty change)."); + } + } + + /// Displays the file header with metadata. + /// + /// Shows the file icon (colored by change type), the file path, and statistics + /// (insertions/deletions count). + fn show_file_header(&self, ui: &mut Ui, file: &FileChange) { + ui.horizontal(|ui| { + let (icon, color, path) = match &file.kind { + FileChangeKind::Added { new_path, .. } => { + (icons::FILE_PLUS, Color32::GREEN, new_path.to_utf8_string()) + } + FileChangeKind::Deleted { old_path, .. } => { + (icons::FILE_MINUS, Color32::RED, old_path.to_utf8_string()) + } + FileChangeKind::Modified { new_path, .. } => ( + icons::FILE, + Color32::from_rgb(226, 192, 141), + new_path.to_utf8_string(), + ), + }; + + ui.label(RichText::new(icon).color(color)); + ui.label(RichText::new(path)); + + let (insertions, deletions) = file.kind.get_file_stats(); + if insertions > 0 { + ui.label(RichText::new(format!("+{insertions}")).color(Color32::GREEN)); + } + if deletions > 0 { + ui.label(RichText::new(format!("-{deletions}")).color(Color32::RED)); + } + }); + } + + /// Renders a single diff hunk (a block of contiguous changes). + /// + /// This includes: + /// 1. The Hunk Header (e.g., `@@ -1,5 +1,5 @@`). + /// 2. The individual lines of code with line numbers and background colors based on change type (add/del). + fn show_hunk(&self, ui: &mut Ui, hunk: &Hunk) { + ui.end_row(); + + ui.label(""); + ui.label(""); + ui.label(""); + + let header_text = format!( + "@@ -{},{} +{},{} @@", + hunk.old_start, hunk.old_lines, hunk.new_start, hunk.new_lines + ); + + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.label( + RichText::new(header_text) + .color(ui.visuals().weak_text_color()) + .monospace(), + ); + }); + ui.end_row(); + + let mut current_old_line = hunk.old_start; + let mut current_new_line = hunk.new_start; + + let font_id = egui::TextStyle::Monospace.resolve(ui.style()); + let row_height = ui.text_style_height(&egui::TextStyle::Body); + + for line in &hunk.lines { + let bg_color = match line.line_type { + HunkLineType::Addition => Color32::from_rgba_premultiplied(0, 50, 0, 50), + HunkLineType::Deletion => Color32::from_rgba_premultiplied(50, 0, 0, 50), + HunkLineType::Context => Color32::TRANSPARENT, + }; + + let text_color = match line.line_type { + HunkLineType::Addition => Color32::from_rgb(150, 255, 150), + HunkLineType::Deletion => Color32::from_rgb(255, 150, 150), + HunkLineType::Context => ui.visuals().text_color(), + }; + + let marker = line.line_type.get_symbol(); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.set_min_height(row_height); + if line.line_type != HunkLineType::Addition { + ui.label( + RichText::new(current_old_line.to_string()) + .color(ui.visuals().weak_text_color()) + .font(font_id.clone()), + ); + current_old_line += 1; + } + }); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.set_min_height(row_height); + if line.line_type != HunkLineType::Deletion { + ui.label( + RichText::new(current_new_line.to_string()) + .color(ui.visuals().weak_text_color()) + .font(font_id.clone()), + ); + current_new_line += 1; + } + }); + + ui.with_layout( + egui::Layout::centered_and_justified(egui::Direction::LeftToRight), + |ui| { + ui.set_min_height(row_height); + ui.label( + RichText::new(marker) + .color(text_color) + .font(font_id.clone()), + ); + }, + ); + + let available_width = ui.available_width(); + + let (rect, _) = ui.allocate_exact_size( + egui::vec2(available_width, row_height), + egui::Sense::hover(), + ); + + if bg_color != Color32::TRANSPARENT { + ui.painter().rect_filled(rect, 0.0, bg_color); + } + + let text_pos = egui::pos2( + rect.min.x + 5.0, + rect.min.y + (row_height / 2.0) + - (ui.text_style_height(&egui::TextStyle::Body) / 2.0), + ); + + ui.painter().text( + text_pos, + egui::Align2::LEFT_TOP, + &line.content, + font_id.clone(), + text_color, + ); + + ui.end_row(); + } + } +} + +impl View for DiffView { + fn show(&mut self, ui: &mut Ui) { + self.show_tabs(ui); + ui.separator(); + self.show_diff_content(ui); + } +} diff --git a/gui/src/ui/views/log.rs b/gui/src/ui/views/log.rs new file mode 100644 index 0000000..8b665c9 --- /dev/null +++ b/gui/src/ui/views/log.rs @@ -0,0 +1,337 @@ +use std::{collections::HashSet, sync::Arc, thread}; + +use chrono::{DateTime, Local, Utc}; +use egui::{Color32, Margin, RichText, Stroke, Ui, epaint::CornerRadiusF32}; +use egui_extras::{Column, TableBuilder}; +use egui_phosphor::regular as icons; +use engine::{ + EngineContainer, + diff_builder::{DiffStat, FileDiffStat}, + engine_container::MevaContainer, + handlers::log::{LogEntry, Request}, + objects::Person, +}; + +use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; +use shared::PathToString; + +/// A UI component responsible for displaying the repository's commit history. +/// +/// It renders a list of commits as expandable cards, showing metadata (author, date, hash) +/// and detailed statistics about changed files. +#[derive(Debug, Default)] +pub struct LogView { + /// The list of commit entries retrieved from the engine. + entries: Vec, + /// Tracks whether the initial data fetch has been triggered to prevent infinite loops in `show()`. + init_load_triggered: bool, + /// A set of commit hashes corresponding to the cards currently expanded by the user. + expanded_commits: HashSet, +} + +impl LogView { + /// Renders the main commit history view. + pub fn show( + &mut self, + ui: &mut Ui, + worker: &mut AsyncWorker, + container: &Arc, + ctx: &egui::Context, + ) { + if !self.init_load_triggered { + self.spawn_fetch_log(worker, container, ctx); + self.init_load_triggered = true; + } + + ui.heading("Commit History"); + ui.add_space(5.0); + + self.show_refresh_button(ui, worker, container, ctx); + ui.add_space(10.0); + + if self.entries.is_empty() { + self.show_when_no_commits(ui); + return; + } + + egui::ScrollArea::vertical().show(ui, |ui| { + for entry in &self.entries { + Self::show_commit_card(ui, entry, &mut self.expanded_commits); + ui.add_space(8.0); + } + }); + } + + /// Updates the view state based on results received from the background worker. + /// + /// Should be called from the main event loop when a [`WorkerResult`] is received. + pub fn handle_worker_result(&mut self, result: WorkerResult) { + if let WorkerResult::LogLoaded { entries } = result { + self.entries = entries; + } + } + + /// Spawns a background task to fetch the commit history from the engine. + /// + /// Sends a [`WorkerEvent::Progress`] before starting and a [`WorkerResult::LogLoaded`] + /// upon successful completion. + fn spawn_fetch_log( + &self, + worker: &mut AsyncWorker, + container: &Arc, + ctx: &egui::Context, + ) { + let ctx = ctx.clone(); + let container = container.clone(); + + worker.spawn(ctx.clone(), move |tx| { + let _ = tx.send(WorkerEvent::Progress("Loading commit history...".into())); + thread::sleep(std::time::Duration::from_millis(500)); + + let task = || -> Result { + let handler = container.log_handler().map_err(|e| e.to_string())?; + + let request = Request { + stat: true, + ..Default::default() + }; + let response = handler.handle_log(request).map_err(|e| e.to_string())?; + + Ok(WorkerResult::LogLoaded { + entries: response.entries, + }) + }; + + match task() { + Ok(result) => { + let _ = tx.send(WorkerEvent::Success(result)); + } + Err(e) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Log Error".into(), + "Failed to load commit history".into(), + e, + ))); + } + } + ctx.request_repaint(); + }); + } + + /// Renders the refresh button that manually triggers a log fetch. + fn show_refresh_button( + &mut self, + ui: &mut Ui, + worker: &mut AsyncWorker, + container: &Arc, + ctx: &egui::Context, + ) { + if ui + .button(format!("{} Refresh", icons::ARROWS_CLOCKWISE)) + .clicked() + { + self.spawn_fetch_log(worker, container, ctx); + } + } + + /// Renders a placeholder view when there are no commits to display. + fn show_when_no_commits(&self, ui: &mut Ui) { + ui.centered_and_justified(|ui| { + ui.vertical_centered(|ui| { + ui.label( + RichText::new(icons::GIT_BRANCH) + .size(48.0) + .color(Color32::from_gray(90)), + ); + + ui.add_space(10.0); + + ui.label( + RichText::new("No commits yet") + .heading() + .strong() + .color(Color32::GRAY), + ); + + ui.label( + RichText::new("Make your first commit to see the history here.") + .color(Color32::GRAY), + ); + }); + }); + } + + /// Renders a single commit entry as a card. + /// + /// The card shows summary info (hash, message, author) and can be expanded + /// to show full details and file statistics. + fn show_commit_card(ui: &mut Ui, entry: &LogEntry, expanded_commits: &mut HashSet) { + let hash = &entry.snapshot.hash; + let is_expanded = expanded_commits.contains(hash); + + let frame = egui::Frame::NONE + .fill(ui.visuals().faint_bg_color) + .corner_radius(CornerRadiusF32::same(4.0)) + .stroke(Stroke::new( + 1.0, + ui.visuals().widgets.noninteractive.bg_stroke.color, + )) + .inner_margin(Margin::same(10)); + + frame.show(ui, |ui| { + // Force the card to take full available width, ensuring proper layout even when collapsed. + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + let icon = if is_expanded { + icons::CARET_DOWN + } else { + icons::CARET_RIGHT + }; + if ui + .add(egui::Button::new(RichText::new(icon).size(14.0)).frame(false)) + .clicked() + { + if is_expanded { + expanded_commits.remove(hash); + } else { + expanded_commits.insert(hash.clone()); + } + } + + ui.vertical(|ui| { + ui.horizontal(|ui| { + let summary = entry + .snapshot + .message + .lines() + .next() + .unwrap_or("No message"); + ui.monospace(RichText::new(&hash[0..7]).color(Color32::GRAY)); + ui.add_space(5.0); + ui.add( + egui::Label::new(RichText::new(summary).strong().size(15.0)).truncate(), + ); + }); + ui.add_space(5.0); + ui.horizontal(|ui| { + Self::show_author(ui, &entry.snapshot.author); + ui.label(RichText::new("|").color(Color32::DARK_GRAY)); + ui.label( + RichText::new(Self::format_date(entry.snapshot.timestamp)) + .size(12.0) + .color(Color32::GRAY), + ); + + if let Some(stats) = &entry.stats { + ui.add_space(10.0); + Self::show_summary_stats(ui, stats); + } + }); + }); + }); + + if is_expanded { + ui.add_space(10.0); + ui.separator(); + ui.add_space(5.0); + + if entry.snapshot.message.lines().count() > 1 { + ui.label(RichText::new(&entry.snapshot.message).size(12.0)); + ui.add_space(10.0); + } + + if let Some(stats) = &entry.stats + && !stats.file_stats.is_empty() + { + ui.label(RichText::new("Changed Files:").strong().size(14.0)); + ui.add_space(5.0); + Self::show_file_stats_table(ui, &stats.file_stats, &entry.snapshot.hash); + } + } + }); + } + + /// Helper to render author information (name and email) with consistent styling. + fn show_author(ui: &mut Ui, author: &Person) { + ui.label( + RichText::new(&author.name) + .size(12.0) + .color(Color32::LIGHT_GRAY), + ); + ui.label(RichText::new("|").color(Color32::DARK_GRAY)); + ui.label(RichText::new(&author.email).size(12.0).color(Color32::GRAY)); + } + + /// Helper to render the high-level diff statistics (files changed, insertions, deletions). + fn show_summary_stats(ui: &mut Ui, stats: &DiffStat) { + let files_label = if stats.files_changed > 1 { + "files" + } else { + "file" + }; + ui.label(format!("{} {files_label}", stats.files_changed)); + if stats.insertions > 0 { + ui.label(RichText::new(format!("+{}", stats.insertions)).color(Color32::GREEN)); + } + if stats.deletions > 0 { + ui.label(RichText::new(format!("-{}", stats.deletions)).color(Color32::RED)); + } + } + + /// Renders a table detailing changes per file for a specific commit. + fn show_file_stats_table(ui: &mut Ui, file_stats: &[FileDiffStat], hash: &str) { + TableBuilder::new(ui) + .id_salt(hash) + .striped(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto().at_least(200.0)) + .column(Column::exact(60.0)) + .column(Column::exact(60.0)) + .header(18.0, |mut header| { + header.col(|ui| { + ui.strong("File"); + }); + header.col(|ui| { + ui.strong("Insertions"); + }); + header.col(|ui| { + ui.strong("Deletions"); + }); + }) + .body(|mut body| { + for file in file_stats { + body.row(18.0, |mut row| { + row.col(|ui| { + ui.label(format!("{} {}", icons::FILE, file.path.to_utf8_string())); + }); + row.col(|ui| { + if file.insertions > 0 { + ui.label( + RichText::new(format!("+{}", file.insertions)) + .color(Color32::GREEN), + ); + } else { + ui.label(RichText::new("-").color(Color32::GRAY)); + } + }); + row.col(|ui| { + if file.deletions > 0 { + ui.label( + RichText::new(format!("-{}", file.deletions)) + .color(Color32::RED), + ); + } else { + ui.label(RichText::new("-").color(Color32::GRAY)); + } + }); + }); + } + }); + } + + /// Formats a UTC timestamp into a readable local time string. + fn format_date(dt: DateTime) -> String { + let local_dt: DateTime = DateTime::from(dt); + local_dt.format("%Y-%m-%d %H:%M").to_string() + } +} diff --git a/gui/src/ui/views/settings.rs b/gui/src/ui/views/settings.rs new file mode 100644 index 0000000..8549c91 --- /dev/null +++ b/gui/src/ui/views/settings.rs @@ -0,0 +1,264 @@ +mod set_config_form; +mod settings_tab; + +use set_config_form::SetConfigForm; +use settings_tab::SettingsTab; + +use std::{sync::Arc, thread}; + +use egui::Ui; + +use egui_extras::{Column, TableBuilder}; +use engine::{ + ConfigLocation, EngineContainer, + engine_container::MevaContainer, + handlers::config::{ListRequest as ConfigListRequest, ListResponse as ConfigListResponse}, +}; + +use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; + +/// The main view component for managing application settings. +/// +/// It allows viewing and modifying configuration values for both: +/// * **Global scope**: Settings applied to the user's environment. +/// * **Local scope**: Settings specific to the current repository. +/// +/// It manages the UI tabs, data fetching, and integrates the `SetConfigForm` for edits. +#[derive(Debug, Default)] +pub struct SettingsView { + /// The currently active tab (Global or Local). + active_tab: SettingsTab, + /// Cached configuration data for the global scope. + global_config: Option, + /// Cached configuration data for the local scope. + local_config: Option, + /// Tracks whether the initial data fetch has been triggered to prevent infinite loops. + init_load_triggered: bool, + + /// Sub-component responsible for the "Add/Update Config" form. + set_config_form: SetConfigForm, +} + +impl SettingsView { + /// Renders the settings interface. + pub fn show( + &mut self, + ui: &mut Ui, + worker: &mut AsyncWorker, + container: &Arc, + ctx: &egui::Context, + ) { + if !self.init_load_triggered { + self.spawn_fetch_config(worker, container, ctx, ConfigLocation::Global); + self.init_load_triggered = true; + } + + ui.heading("Settings"); + ui.add_space(10.0); + + self.set_config_form.show(ui, worker, container, ctx); + + ui.add_space(10.0); + + self.show_configuration_tabs(ui, worker, container, ctx); + + ui.separator(); + + self.show_configuration_content(ui); + } + + /// Updates the view state based on results received from the background worker. + /// + /// Handles two types of events: + /// * `ConfigLoaded`: Replaces the entire configuration list for a scope. + /// * `ConfigUpdated`: Optimistically updates or inserts a single key-value pair + /// to reflect changes immediately without reloading the whole list. + pub fn handle_worker_result(&mut self, result: WorkerResult) { + match result { + WorkerResult::ConfigLoaded { location, config } => match location { + ConfigLocation::Global => self.global_config = Some(config), + ConfigLocation::Local => self.local_config = Some(config), + _ => unreachable!(), + }, + WorkerResult::ConfigUpdated { location, config } => { + let target = match location { + ConfigLocation::Global => &mut self.global_config, + ConfigLocation::Local => &mut self.local_config, + _ => unreachable!(), + }; + + if let Some(target) = target { + if let Some(existing_entry) = + target.key_values.iter_mut().find(|(k, _)| *k == config.key) + { + existing_entry.1 = config.value; + } else { + target.key_values.push((config.key, config.value)); + target.key_values.sort_by(|a, b| a.0.cmp(&b.0)); + } + } else { + // Config wasn't loaded yet, but we set a value. Initialize it. + *target = Some(ConfigListResponse { + key_values: vec![(config.key, config.value)], + }); + } + } + + _ => {} + } + } + + /// Spawns a background task to fetch the configuration list for a specific location. + fn spawn_fetch_config( + &self, + worker: &mut AsyncWorker, + container: &Arc, + ctx: &egui::Context, + location: ConfigLocation, + ) { + let ctx = ctx.clone(); + let container = container.clone(); + let location = location.clone(); + + worker.spawn(ctx.clone(), move |tx| { + let _ = tx.send(WorkerEvent::Progress(format!( + "Loading {location} config...", + ))); + thread::sleep(std::time::Duration::from_millis(500)); + + let task = || -> Result { + let handler = container.config_handler().map_err(|e| e.to_string())?; + let interceptor = container.plugins_interceptor().map_err(|e| e.to_string())?; + let config_request = ConfigListRequest { + location: location.clone(), + }; + let config = handler + .handle_list(config_request, &interceptor) + .map_err(|e| e.to_string())?; + + Ok(WorkerResult::ConfigLoaded { location, config }) + }; + + match task() { + Ok(result) => { + let _ = tx.send(WorkerEvent::Success(result)); + } + Err(err_msg) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Config Load Error".into(), + "Failed to load configuration".into(), + err_msg, + ))); + } + } + + ctx.request_repaint(); + }); + } + + /// Renders the navigation tabs (Global / Local) to switch between configuration scopes. + fn show_configuration_tabs( + &mut self, + ui: &mut Ui, + worker: &mut AsyncWorker, + container: &Arc, + ctx: &egui::Context, + ) { + ui.horizontal(|ui| { + if ui + .selectable_value( + &mut self.active_tab, + SettingsTab::Global, + SettingsTab::Global.to_string(), + ) + .clicked() + { + self.spawn_fetch_config(worker, container, ctx, ConfigLocation::Global); + } + if ui + .selectable_value( + &mut self.active_tab, + SettingsTab::Local, + SettingsTab::Local.to_string(), + ) + .clicked() + { + self.spawn_fetch_config(worker, container, ctx, ConfigLocation::Local); + } + }); + } + + /// Renders the content of the currently selected tab. + /// + /// Handles empty states (e.g., no repository opened for Local scope) and delegates + /// table rendering if data is available. + fn show_configuration_content(&self, ui: &mut Ui) { + egui::ScrollArea::vertical().show(ui, |ui| match self.active_tab { + SettingsTab::Global => { + if let Some(global_cfg) = &self.global_config { + self.show_config_table(ui, global_cfg); + } else { + ui.vertical_centered(|ui| { + ui.add_space(50.0); + ui.label("Global configuration file not found."); + ui.label("Create a new global configuration file to view its contents."); + }); + } + } + SettingsTab::Local => { + if let Some(local_cfg) = &self.local_config { + self.show_config_table(ui, local_cfg); + } else { + ui.vertical_centered(|ui| { + ui.add_space(50.0); + ui.label("No repository opened."); + ui.label("Open a repository to view local configuration."); + }); + } + } + }); + } + + /// Renders the configuration list as a grouped table. + /// + /// Groups keys by their prefix (e.g., `user.name` and `user.email` go into the `user` group). + fn show_config_table(&self, ui: &mut Ui, data: &ConfigListResponse) { + if data.key_values.is_empty() { + ui.label("Configuration file is empty."); + return; + } + + let groups = data.group_by_prefix(); + + for (group_name, items) in groups { + ui.push_id(group_name, |ui| { + ui.collapsing(egui::RichText::new(group_name).monospace(), |ui| { + self.show_group_table(ui, &items); + }); + }); + ui.add_space(5.0); + } + } + + /// Renders the rows for a specific configuration group (section). + fn show_group_table(&self, ui: &mut Ui, items: &[(&str, &str)]) { + ui.add_space(5.0); + TableBuilder::new(ui) + .striped(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto().at_least(100.0)) + .column(Column::remainder().clip(true)) + .body(|mut body| { + for (key, value) in items { + body.row(18.0, |mut row| { + row.col(|ui| { + ui.monospace(*key); + }); + row.col(|ui| { + ui.monospace(*value); + }); + }); + } + }); + } +} diff --git a/gui/src/ui/views/settings/set_config_form.rs b/gui/src/ui/views/settings/set_config_form.rs new file mode 100644 index 0000000..cce86d8 --- /dev/null +++ b/gui/src/ui/views/settings/set_config_form.rs @@ -0,0 +1,182 @@ +use std::{sync::Arc, thread}; + +use egui::{Color32, Layout, RichText, Ui}; +use egui_phosphor::regular as icons; +use engine::{ + ConfigLocation, EngineContainer, engine_container::MevaContainer, + handlers::config::SetRequest as ConfigSetRequest, +}; +use strum::IntoEnumIterator; + +use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; + +use super::SettingsTab; + +/// A sub-component of the Settings view responsible for the "Add / Update Entry" form. +/// +/// This struct manages the temporary state of the input fields (key, value, location) +/// and handles the UI rendering and validation logic before submitting a change request. +#[derive(Debug, Default)] +pub struct SetConfigForm { + /// The current input value for the configuration key (e.g., "user.name"). + pub new_key: String, + /// The current input value for the configuration value (e.g., "John Doe"). + pub new_value: String, + /// The selected scope where the setting should be applied (Global vs Local). + pub target_location: SettingsTab, +} + +impl SetConfigForm { + /// Renders the configuration form UI. + /// + /// Consists of: + /// * A drop-down to select the target location (Global/Local). + /// * Text inputs for the Key and Value. + /// * A "Save" button that triggers the background update task. + /// + /// The "Save" button is visually disabled if validation fails, displaying + /// a tooltip explaining the error on hover. + pub fn show( + &mut self, + ui: &mut Ui, + worker: &mut AsyncWorker, + container: &Arc, + ctx: &egui::Context, + ) { + ui.push_id("set_form_area", |ui| { + ui.label(RichText::new(format!("{} Add / Update Entry", icons::GEAR)).size(14.0)); + ui.add_space(10.0); + + egui::Grid::new("set_configuration_grid") + .num_columns(2) + .spacing([10.0, 8.0]) + .striped(false) + .show(ui, |ui| { + ui.label("Target:"); + egui::ComboBox::from_id_salt("scope_combo") + .selected_text(format!("{}", self.target_location)) + .show_ui(ui, |ui| { + for scope in SettingsTab::iter() { + ui.selectable_value( + &mut self.target_location, + scope, + scope.to_string(), + ); + } + }); + ui.end_row(); + + ui.label("Key:"); + ui.add(egui::TextEdit::singleline(&mut self.new_key).hint_text("user.name")); + ui.end_row(); + + ui.label("Value:"); + ui.add(egui::TextEdit::singleline(&mut self.new_value).hint_text("John Doe")); + ui.end_row(); + }); + + ui.add_space(10.0); + + ui.with_layout(Layout::left_to_right(egui::Align::TOP), |ui| { + let is_form_valid = self.is_form_valid(); + let save_button = ui.add_enabled( + is_form_valid, + egui::Button::new(format!("{} Save", icons::FLOPPY_DISK)), + ); + + if save_button.clicked() { + self.spawn_set_config( + worker, + container, + ctx, + self.new_key.clone(), + self.new_value.clone(), + ConfigLocation::from(self.target_location), + ); + + self.clear(); + } + + save_button.on_disabled_hover_ui(|ui| { + if let Some(validation_message) = self.get_validation_message() { + ui.label(RichText::new(validation_message).color(Color32::RED)); + } + }); + }); + }); + } + + /// Spawns a background thread to persist the configuration change. + /// + /// This prevents blocking the UI thread during file I/O operations. + /// It sends a `ConfigUpdated` event on success, which allows the parent view + /// to update its list optimistically. + fn spawn_set_config( + &self, + worker: &mut AsyncWorker, + container: &Arc, + ctx: &egui::Context, + key: String, + value: String, + location: ConfigLocation, + ) { + let ctx = ctx.clone(); + let container = container.clone(); + let location = location.clone(); + + worker.spawn(ctx.clone(), move |tx| { + let _ = tx.send(WorkerEvent::Progress("Saving configuration...".to_string())); + thread::sleep(std::time::Duration::from_millis(500)); + + let task = || -> Result { + let handler = container.config_handler().map_err(|e| e.to_string())?; + let interceptor = container.plugins_interceptor().map_err(|e| e.to_string())?; + + let set_request = ConfigSetRequest { + key: key.clone(), + value: value.clone(), + location: location.clone(), + }; + let config = handler + .handle_set(set_request, &interceptor) + .map_err(|e| e.to_string())?; + + Ok(WorkerResult::ConfigUpdated { location, config }) + }; + + match task() { + Ok(response) => { + let _ = tx.send(WorkerEvent::Success(response)); + } + Err(err_msg) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Config Save Error".into(), + format!("Failed to set {key} = {value}"), + err_msg, + ))); + } + } + ctx.request_repaint(); + }); + } + + /// Checks basic validation rules (fields cannot be empty). + fn is_form_valid(&self) -> bool { + !self.new_key.is_empty() && !self.new_value.is_empty() + } + + /// Returns a user-friendly error message if the form is invalid. + fn get_validation_message(&self) -> Option<&'static str> { + match self.is_form_valid() { + true => None, + false if self.new_key.is_empty() => Some("'Key' cannot be empty!"), + _ => Some("'Value' cannot be empty!"), + } + } + + /// Resets the form fields to their default state after a successful save. + fn clear(&mut self) { + self.new_key.clear(); + self.new_value.clear(); + } +} diff --git a/gui/src/ui/views/settings/settings_tab.rs b/gui/src/ui/views/settings/settings_tab.rs new file mode 100644 index 0000000..a6b06b2 --- /dev/null +++ b/gui/src/ui/views/settings/settings_tab.rs @@ -0,0 +1,31 @@ +use engine::ConfigLocation; +use strum_macros::{Display, EnumIter}; + +/// Represents the different configuration scopes available in the Settings view. +/// +/// This enum is used to switch the UI context between viewing/editing global user settings +/// (applied to all projects) and local repository settings (specific to the current project). +#[derive(Debug, Default, PartialEq, Clone, Copy, EnumIter, Display)] +pub enum SettingsTab { + /// Global configuration scope. + /// + /// Represents settings stored in the user's home directory. + /// These settings apply to all repositories unless overridden by a local config. + #[default] + Global, + + /// Local configuration scope. + /// + /// Represents settings stored within the specific repository. + /// These settings take precedence over global configuration. + Local, +} + +impl From for ConfigLocation { + fn from(tab: SettingsTab) -> Self { + match tab { + SettingsTab::Global => Self::Global, + SettingsTab::Local => Self::Local, + } + } +} diff --git a/shared/src/extensions.rs b/shared/src/extensions.rs index ebd4f38..bb837eb 100644 --- a/shared/src/extensions.rs +++ b/shared/src/extensions.rs @@ -1,3 +1,4 @@ +pub mod change_directory; pub mod cumulative_paths; pub mod fs; pub mod is_within; @@ -7,6 +8,7 @@ pub mod remove_empty; pub mod strip_base; pub mod upward_search; +pub use change_directory::change_directory; pub use cumulative_paths::CumulativePaths; pub use fs::create_file_with_dirs; pub use is_within::IsWithin; diff --git a/shared/src/extensions/change_directory.rs b/shared/src/extensions/change_directory.rs new file mode 100644 index 0000000..acc7b26 --- /dev/null +++ b/shared/src/extensions/change_directory.rs @@ -0,0 +1,27 @@ +use std::{ + env, io, + path::{Path, PathBuf}, +}; + +/// Changes the current working directory of the entire process. +/// +/// This function acts as a wrapper around [`std::env::set_current_dir`], ensuring +/// the change is verified by returning the new current directory upon success. +/// +/// # Warning +/// Changing the working directory is a process-global operation. It affects **all threads** +/// simultaneously. +/// +/// # Arguments +/// +/// * `target_path` - The directory path to switch into. Can be absolute or relative. +/// +/// # Returns +/// +/// * `Ok(PathBuf)` - The new, absolute current working directory. +/// * `Err(io::Error)` - If the path does not exist, is not a directory, or the user lacks permissions. +pub fn change_directory>(target_path: P) -> io::Result { + let path = target_path.as_ref(); + env::set_current_dir(path)?; + env::current_dir() +} diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 8d796be..5169298 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -3,7 +3,7 @@ mod pretty_field; pub mod extensions; pub use extensions::{ - CumulativePaths, IsWithin, OpenInEditor, PathToString, StripBase, UpwardSearch, fs, - remove_empty_parents, + CumulativePaths, IsWithin, OpenInEditor, PathToString, StripBase, UpwardSearch, + change_directory, fs, remove_empty_parents, }; pub use pretty_field::PrettyField; From 50f7aaa89926f53cff869039c84f351e5ddef8a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Tue, 30 Dec 2025 09:39:55 +0100 Subject: [PATCH 29/42] Fix `restore` (#47) --- engine/src/handlers/restore/handlers.rs | 13 ++++++++++--- engine/src/index/working_dir.rs | 2 +- engine/src/traversal/meva_commit_tree_walker.rs | 17 +++++++++++------ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/engine/src/handlers/restore/handlers.rs b/engine/src/handlers/restore/handlers.rs index 9c974ce..d4f199c 100644 --- a/engine/src/handlers/restore/handlers.rs +++ b/engine/src/handlers/restore/handlers.rs @@ -4,6 +4,7 @@ use crate::index::{FileMode, index_entry::IndexEntry, stage::Stage}; use crate::objects::{MevaBlob, MevaObject, ObjectEntry}; use crate::{CommitTreeWalker, Index, ObjectStorage, RevisionResolver, WorkingDir}; use chrono::Utc; +use path_absolutize::Absolutize; use shared::{PathToString, StripBase}; use std::collections::{HashMap, HashSet}; use std::fs; @@ -135,6 +136,8 @@ impl RestoreHandler { let index_entries_to_remove = Self::build_index_entries_to_remove(&index_entries_map, &source_files_map); + drop(index_read_guard); + let mut index_write_guard = self.index.write().unwrap(); index_write_guard.insert_entries(index_entries_to_add); index_write_guard.remove_entries(index_entries_to_remove); @@ -152,10 +155,14 @@ impl RestoreHandler { .working_dir .collect_files(path, false) .into_iter() - .map(|(entry_path, _)| { + .filter_map(|(entry_path, _)| { entry_path - .strip_base(&self.working_dir.layout().working_dir()) - .to_path_buf() + .absolutize() + .map(|path| { + path.strip_base(&self.working_dir.layout().working_dir()) + .to_path_buf() + }) + .ok() }) .collect(); workdir_files.extend(collected_files); diff --git a/engine/src/index/working_dir.rs b/engine/src/index/working_dir.rs index 359ca56..a704b60 100644 --- a/engine/src/index/working_dir.rs +++ b/engine/src/index/working_dir.rs @@ -65,7 +65,7 @@ pub trait WorkingDir: Send + Sync { /// # Returns /// /// A vector of tuples of type `(PathBuf, bool)`: - /// - `PathBuf`: the absolute file path. + /// - `PathBuf`: file path. /// - `bool`: whether the file is ignored (`true` if ignored). fn collect_files(&self, path: &Path, include_ignored: bool) -> Vec<(PathBuf, bool)>; diff --git a/engine/src/traversal/meva_commit_tree_walker.rs b/engine/src/traversal/meva_commit_tree_walker.rs index e0d618c..48c071c 100644 --- a/engine/src/traversal/meva_commit_tree_walker.rs +++ b/engine/src/traversal/meva_commit_tree_walker.rs @@ -2,8 +2,11 @@ use super::CommitTreeWalker; use crate::errors::EngineResult; use crate::object_storage::ObjectStorage; use crate::objects::{MevaBlob, MevaCommit, MevaTree, ObjectEntry, TreeEntry, TreeEntryType}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; +use shared::IsWithin; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; /// A concrete implementation of [`CommitTreeWalker`] for traversing commits /// stored in a local Meva object database. @@ -36,9 +39,9 @@ impl MevaCommitTreeWalker { /// - The current entry path **starts with** or **is a prefix of** any of the filters. fn should_traverse(&self, entry_path: &Path, path_filter: Option<&[PathBuf]>) -> bool { match path_filter { - Some(filters) => filters - .iter() - .any(|f| f.starts_with(entry_path) || entry_path.starts_with(f)), + Some(filters) => filters.iter().any(|f| { + f.is_within(entry_path).unwrap_or(false) || entry_path.is_within(f).unwrap_or(false) + }), None => true, } } @@ -49,7 +52,9 @@ impl MevaCommitTreeWalker { /// should be **included** in the traversal result. fn matches_entry(&self, entry_path: &Path, path_filter: Option<&[PathBuf]>) -> bool { match path_filter { - Some(filters) => filters.iter().any(|f| entry_path.starts_with(f)), + Some(filters) => filters + .iter() + .any(|f| entry_path.is_within(f).unwrap_or(false)), None => true, } } From 02f32f7805b42f4d9014ab19e8898e0304c5cd1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Tue, 30 Dec 2025 10:05:12 +0100 Subject: [PATCH 30/42] GUI Improvements (#43) --- Cargo.lock | 1 + engine/src/diff_builder/models/diff_stat.rs | 10 +- .../src/diff_builder/models/file_diff_stat.rs | 15 +- engine/src/engine_container.rs | 1 + gui/Cargo.toml | 5 + gui/src/events/worker_result.rs | 18 + gui/src/lib.rs | 4 + gui/src/main.rs | 387 +----------- gui/src/meva_gui.rs | 339 +++++++++++ gui/src/ui/components.rs | 2 + gui/src/ui/components/dialogs.rs | 4 + .../dialogs/clone_repository_dialog.rs | 306 ++++++++++ .../dialogs/create_repository_dialog.rs | 35 +- .../dialogs/register_plugin_dialog.rs | 325 ++++++++++ gui/src/ui/components/navigation.rs | 62 ++ gui/src/ui/views.rs | 26 + gui/src/ui/views/plugins.rs | 574 ++++++++++++++++++ plugins/src/enums/command_type.rs | 15 +- plugins/src/enums/event_type.rs | 15 +- plugins/src/enums/scope_type.rs | 15 +- server/Cargo.toml | 4 + shared/src/extensions/path_to_string.rs | 20 + 22 files changed, 1765 insertions(+), 418 deletions(-) create mode 100644 gui/src/meva_gui.rs create mode 100644 gui/src/ui/components/dialogs/clone_repository_dialog.rs create mode 100644 gui/src/ui/components/dialogs/register_plugin_dialog.rs create mode 100644 gui/src/ui/components/navigation.rs create mode 100644 gui/src/ui/views/plugins.rs diff --git a/Cargo.lock b/Cargo.lock index 0367e50..babbdb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2501,6 +2501,7 @@ dependencies = [ "strum", "strum_macros", "tokio", + "url", ] [[package]] diff --git a/engine/src/diff_builder/models/diff_stat.rs b/engine/src/diff_builder/models/diff_stat.rs index 7ece81b..09ce632 100644 --- a/engine/src/diff_builder/models/diff_stat.rs +++ b/engine/src/diff_builder/models/diff_stat.rs @@ -3,6 +3,7 @@ use std::fmt::Display; use owo_colors::OwoColorize; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use serde::{Deserialize, Serialize}; +use shared::PathToString; use crate::diff_builder::FileChange; @@ -70,8 +71,15 @@ impl DiffStat { impl Display for DiffStat { /// Formats the diff statistics in a human-readable summary form. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let max_path_len = self + .file_stats + .iter() + .map(|s| s.path.to_utf8_string().len()) + .max() + .unwrap_or(0); + for file_stat in &self.file_stats { - writeln!(f, "{file_stat}")?; + writeln!(f, "{}", file_stat.fmt(max_path_len))?; } writeln!(f, "{}", self.summary_string()) diff --git a/engine/src/diff_builder/models/file_diff_stat.rs b/engine/src/diff_builder/models/file_diff_stat.rs index 198c400..e41d42b 100644 --- a/engine/src/diff_builder/models/file_diff_stat.rs +++ b/engine/src/diff_builder/models/file_diff_stat.rs @@ -1,7 +1,8 @@ -use std::{fmt::Display, path::PathBuf}; +use std::path::PathBuf; use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; +use shared::PathToString; /// Represents the line change statistics for a single file in a diff. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -68,11 +69,9 @@ impl FileDiffStat { (ins_len, del_len) } -} -impl Display for FileDiffStat { /// Formats the file diff stat with colored insertions/deletions bar. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + pub fn fmt(&self, path_width: usize) -> String { let changes = self.total_changes(); let (ins_len, del_len) = self.bar_components(MAX_BAR); @@ -82,11 +81,9 @@ impl Display for FileDiffStat { let del_bar = del.red(); let bar = format!("{ins_bar}{del_bar}"); - write!( - f, - " {} | {changes:>3} {bar:3} {bar}", + self.path.to_utf8_string(), ) } } diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 213f9f3..17d1872 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -78,6 +78,7 @@ pub trait EngineContainer { } /// Concrete implementation of `EngineContainer` for Meva. +#[derive(Debug, Default)] pub struct MevaContainer; impl MevaContainer { diff --git a/gui/Cargo.toml b/gui/Cargo.toml index 4b1fac0..710424b 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2024" authors.workspace = true +[[bin]] +name = "meva-gui" +path = "src/main.rs" + [dependencies] egui_extras = { version = "0.33.3", features = ["all_loaders"] } egui-phosphor = { version = "0.11.0", features = ["fill"] } @@ -18,6 +22,7 @@ tokio.workspace = true strum.workspace = true strum_macros.workspace = true chrono.workspace = true +url.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/gui/src/events/worker_result.rs b/gui/src/events/worker_result.rs index 866de04..56047ad 100644 --- a/gui/src/events/worker_result.rs +++ b/gui/src/events/worker_result.rs @@ -11,6 +11,7 @@ use engine::{ status::Response as StatusResponse, }, }; +use plugins::PluginConfiguration; use crate::events::EventError; @@ -89,4 +90,21 @@ pub enum WorkerResult { /// Returns: /// * `entries`: A chronological list of commits, including snapshots and diff statistics. LogLoaded { entries: Vec }, + + /// Successfully retrieved the list of registered plugins. + /// + /// Used to populate the Plugins view with cards displaying configuration and status. + /// Returns: + /// * `plugins`: A list of pairs containing the plugin configuration and its file path. + PluginsLoaded { + plugins: Vec<(PluginConfiguration, PathBuf)>, + }, + + /// Successfully modified a plugin's state. + /// + /// Typically returned after toggling a plugin's active status (Enable/Disable). + /// Returns: + /// * `name`: The unique identifier of the plugin that was modified. + /// * `enabled`: The new active status of the plugin. + PluginEdited { name: String, enabled: bool }, } diff --git a/gui/src/lib.rs b/gui/src/lib.rs index 58ff936..f265ae8 100644 --- a/gui/src/lib.rs +++ b/gui/src/lib.rs @@ -1,3 +1,7 @@ +mod meva_gui; + pub mod events; pub mod icons; pub mod ui; + +pub use meva_gui::MevaGui; diff --git a/gui/src/main.rs b/gui/src/main.rs index 1aa9d0e..d70c927 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -1,380 +1,10 @@ -use std::{path::PathBuf, sync::Arc}; - use eframe::egui; -use egui::{RichText, Ui}; -use egui_phosphor::regular as icons; - -use engine::{ - engine_container::MevaContainer, - handlers::{branch::BranchCollection, status::Response}, -}; -use gui::{ - events::{AsyncWorker, EventError, WorkerResult}, - icons::load_icon, - ui::{ - BranchStatusComponent, CommitButton, CommitChangesDialog, CommitChangesForm, - CreateRepositoryDialog, CreateRepositoryForm, DashboardView, DiffView, ErrorDialog, - FileChangesComponent, FileChangesState, LoadingDialog, LogView, NavigationButton, - OpenRepositoryButton, RefreshStatusButton, RemoteActionsComponent, SettingsButton, - SettingsView, SwitchBranchDialog, SwitchBranchForm, ThemeButton, View, - }, -}; -use strum_macros::Display; - -#[derive(Debug, Default, PartialEq, Eq, Display)] -enum GuiView { - #[default] - Dashboard, - Settings, - Diff, - History, -} - -struct MevaGui { - current_view: GuiView, - diff_view: DiffView, - settings_view: SettingsView, - log_view: LogView, - - create_repo_form: CreateRepositoryForm, - commit_form: CommitChangesForm, - file_changes_state: FileChangesState, - switch_branch_form: SwitchBranchForm, - - container: Arc, - async_worker: AsyncWorker, - - event_error: Option, - - repository_path: Option, - repository_status: Option, - repository_branches: Option, -} - -impl MevaGui { - fn new(cc: &eframe::CreationContext) -> Self { - let mut fonts = egui::FontDefinitions::default(); - egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Fill); - cc.egui_ctx.set_fonts(fonts); - Self { - current_view: GuiView::default(), - diff_view: DiffView::default(), - settings_view: SettingsView::default(), - log_view: LogView::default(), - create_repo_form: CreateRepositoryForm::default(), - commit_form: CommitChangesForm::default(), - file_changes_state: FileChangesState::default(), - switch_branch_form: SwitchBranchForm::default(), - container: Arc::new(MevaContainer), - async_worker: AsyncWorker::default(), - event_error: None, - repository_path: None, - repository_status: None, - repository_branches: None, - } - } - - fn render_loading_modal(&self, ctx: &egui::Context) { - LoadingDialog { - active: self.async_worker.is_active, - message: &self.async_worker.loading_message, - } - .show(ctx); - } - - fn render_menu_bar(&mut self, ctx: &egui::Context, ui: &mut Ui) { - egui::MenuBar::new().ui(ui, |ui| { - ui.menu_button("File", |ui| { - OpenRepositoryButton::new(&mut self.async_worker, &self.container).show(ui, ctx); - - if ui - .button(format!("{} Create new", icons::FOLDER_PLUS)) - .clicked() - { - ui.close_kind(egui::UiKind::Menu); - self.create_repo_form.is_open = true; - self.create_repo_form.branch_name = "main".to_string(); - } - if ui.button(format!("{} Close", icons::X)).clicked() { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - }); - }); - } - - fn render_top_panel(&mut self, ctx: &egui::Context) { - egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { - ui.horizontal(|ui| { - self.render_menu_bar(ctx, ui); - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add_space(12.0); - let is_dark = ctx.style().visuals.dark_mode; - if ThemeButton::default().show(ui, is_dark) { - ctx.set_visuals(match is_dark { - true => egui::Visuals::light(), - false => egui::Visuals::dark(), - }); - } - }); - }); - }); - } - - fn render_bottom_panel(&mut self, ctx: &egui::Context) { - egui::TopBottomPanel::bottom("bottom_panel").show(ctx, |ui| { - ui.horizontal(|ui| { - if SettingsButton::default().show(ui) { - self.current_view = GuiView::Settings; - } - - ui.separator(); - - if let Some(status) = &self.repository_status - && let Some(branch_info) = &status.branch - { - BranchStatusComponent::new(branch_info, &mut self.switch_branch_form.is_open) - .show(ui); - } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(egui::RichText::new("v0.1.0").weak()) - .on_hover_text("Current version"); - - ui.separator(); - - if let Some(status) = &self.repository_status - && let Some(branch) = &status.branch - { - RemoteActionsComponent::new( - &mut self.async_worker, - &self.container, - ctx, - branch, - ) - .show(ui); - } - }); - }); - }); - } - - fn render_navigation(&mut self, ui: &mut Ui) { - ui.heading(RichText::new("Navigation").heading()); - ui.add_space(5.0); - ui.vertical(|ui| { - let is_selected = matches!(self.current_view, GuiView::Dashboard); - - if NavigationButton::new(is_selected, icons::HOUSE, &GuiView::Dashboard.to_string()) - .show(ui) - .clicked() - { - self.current_view = GuiView::Dashboard; - } - }); - - if self.diff_view.has_open_tabs() { - ui.vertical(|ui| { - let is_selected = matches!(self.current_view, GuiView::Diff); - - if NavigationButton::new(is_selected, icons::FILE_CODE, &GuiView::Diff.to_string()) - .show(ui) - .clicked() - { - self.current_view = GuiView::Diff; - } - }); - } - - if self.repository_status.is_some() { - ui.vertical(|ui| { - let is_selected = matches!(self.current_view, GuiView::History); - - if NavigationButton::new( - is_selected, - icons::CLOCK_COUNTER_CLOCKWISE, - &GuiView::History.to_string(), - ) - .show(ui) - .clicked() - { - self.current_view = GuiView::History; - } - }); - } - } - - fn render_left_panel(&mut self, ctx: &egui::Context) { - egui::SidePanel::left("left_panel") - .resizable(true) - .default_width(200.0) - .min_width(250.0) - .show(ctx, |ui| { - ui.add_space(10.0); - - self.render_navigation(ui); - - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - - if let Some(status) = &self.repository_status { - ui.horizontal(|ui| { - RefreshStatusButton::new(&mut self.async_worker, &self.container) - .show(ui, ctx); - ui.with_layout( - egui::Layout::top_down_justified(egui::Align::Center), - |ui| { - if CommitButton.show( - ui, - self.repository_status.as_ref().is_some_and(|status| { - status - .staged - .as_ref() - .is_some_and(|staged| !staged.is_empty()) - }), - ) { - self.commit_form.is_open = true; - } - }, - ); - }); - - ui.add_space(10.0); - - egui::ScrollArea::vertical() - .id_salt("changes_scroll_area") - .auto_shrink([false, false]) - .show(ui, |ui| { - FileChangesComponent::new(&mut self.async_worker, &self.container, ctx) - .show(ui, status, &mut self.file_changes_state); - }); - } else { - ui.vertical_centered(|ui| { - ui.add_space(20.0); - ui.label(egui::RichText::new("No repository open").weak()); - }); - } - }); - } - - fn render_central_panel(&mut self, ctx: &egui::Context) { - egui::CentralPanel::default().show(ctx, |ui| match self.current_view { - GuiView::Dashboard => { - DashboardView {}.show(ui); - } - GuiView::Settings => { - self.settings_view - .show(ui, &mut self.async_worker, &self.container, ctx); - } - GuiView::Diff => { - let should_close = self.diff_view.show_tabs(ui); - - if should_close { - self.current_view = GuiView::Dashboard; - } else { - ui.separator(); - self.diff_view.show_diff_content(ui); - } - } - GuiView::History => { - self.log_view - .show(ui, &mut self.async_worker, &self.container, ctx); - } - }); - } -} - -impl eframe::App for MevaGui { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - if let Some(result) = self.async_worker.poll() { - match result { - WorkerResult::Error(event_error) => { - self.event_error = Some(event_error); - } - WorkerResult::None => { - println!("Operation finished (void)."); - } - WorkerResult::RepositoryCreated { init, status } => { - self.repository_path = Some(init.repository_dir); - self.repository_status = Some(status); - - self.current_view = GuiView::Dashboard; - } - WorkerResult::StatusRefreshed { status } => { - self.repository_status = Some(status); - } - WorkerResult::RepositoryOpened { branch, status } => { - self.repository_status = Some(status); - self.repository_branches = branch.branches; - } - WorkerResult::DiffOpened { path, diff } => { - self.diff_view.open(path, diff); - self.current_view = GuiView::Diff; - } - WorkerResult::ConfigLoaded { .. } | WorkerResult::ConfigUpdated { .. } => { - self.settings_view.handle_worker_result(result); - } - WorkerResult::LogLoaded { .. } => { - self.log_view.handle_worker_result(result); - } - } - } - - self.render_top_panel(ctx); - self.render_bottom_panel(ctx); - self.render_left_panel(ctx); - self.render_central_panel(ctx); - - CreateRepositoryDialog::new( - &mut self.create_repo_form, - &mut self.async_worker, - &self.container, - ) - .show(ctx); - - CommitChangesDialog::new( - &mut self.commit_form, - &mut self.async_worker, - &self.container, - ctx, - ) - .show(); - - SwitchBranchDialog::new( - &mut self.switch_branch_form, - &self.repository_branches, - &mut self.async_worker, - &self.container, - ctx, - ) - .show(); - - self.render_loading_modal(ctx); - - if let Some(event_error) = &self.event_error { - let dialog = ErrorDialog { error: event_error }; - if dialog.show(ctx) { - self.event_error = None; - } - } - } -} +use gui::{MevaGui, icons::load_icon}; fn main() { - let icon = load_icon(); - - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_inner_size([800.0, 600.0]) - .with_icon(icon), - centered: true, - ..Default::default() - }; - eframe::run_native( "MEVA GUI", - options, + configure_frame_options(), Box::new(|context| { context.egui_ctx.set_theme(egui::Theme::Dark); Ok(Box::new(MevaGui::new(context))) @@ -382,3 +12,16 @@ fn main() { ) .unwrap(); } + +/// Configures the native window options for the application. +fn configure_frame_options() -> eframe::NativeOptions { + let icon = load_icon(); + + eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([800.0, 600.0]) + .with_icon(icon), + centered: true, + ..Default::default() + } +} diff --git a/gui/src/meva_gui.rs b/gui/src/meva_gui.rs new file mode 100644 index 0000000..2818172 --- /dev/null +++ b/gui/src/meva_gui.rs @@ -0,0 +1,339 @@ +use std::{path::PathBuf, sync::Arc}; + +use eframe::egui; +use egui::Ui; +use egui_phosphor::regular as icons; + +use crate::{events::*, ui::*}; +use engine::{ + engine_container::MevaContainer, + handlers::{branch::BranchCollection, status::Response}, +}; + +/// The main application state structure. +#[derive(Default)] +pub struct MevaGui { + current_view: GuiView, + diff_view: DiffView, + settings_view: SettingsView, + log_view: LogView, + plugins_view: PluginsView, + + create_repo_form: CreateRepositoryForm, + clone_repo_form: CloneRepositoryForm, + commit_form: CommitChangesForm, + file_changes_state: FileChangesState, + switch_branch_form: SwitchBranchForm, + + container: Arc, + async_worker: AsyncWorker, + event_error: Option, + + repository_path: Option, + repository_status: Option, + repository_branches: Option, +} + +impl eframe::App for MevaGui { + fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) { + self.handle_worker_results(); + self.show(ctx); + } +} + +impl MevaGui { + /// Creates a new instance of the application. + pub fn new(cc: &eframe::CreationContext) -> Self { + let mut fonts = egui::FontDefinitions::default(); + egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Fill); + cc.egui_ctx.set_fonts(fonts); + + Self { + plugins_view: PluginsView::new(), + clone_repo_form: CloneRepositoryForm::new(), + container: Arc::new(MevaContainer), + ..Default::default() + } + } + + /// Renders the entire application layout. + fn show(&mut self, ctx: &egui::Context) { + self.show_top_panel(ctx); + self.show_bottom_panel(ctx); + self.show_left_panel(ctx); + self.show_central_panel(ctx); + self.show_dialogs(ctx); + } + + /// Polls the async worker for completed tasks and updates the state accordingly. + fn handle_worker_results(&mut self) { + if let Some(result) = self.async_worker.poll() { + match result { + WorkerResult::Error(event_error) => { + self.event_error = Some(event_error); + } + WorkerResult::None => { + println!("Operation finished (void)."); + } + WorkerResult::RepositoryCreated { init, status } => { + self.repository_path = Some(init.repository_dir); + self.repository_status = Some(status); + + self.current_view = GuiView::Dashboard; + } + WorkerResult::StatusRefreshed { status } => { + self.repository_status = Some(status); + } + WorkerResult::RepositoryOpened { branch, status } => { + self.repository_status = Some(status); + self.repository_branches = branch.branches; + } + WorkerResult::DiffOpened { path, diff } => { + self.diff_view.open(path, diff); + self.current_view = GuiView::Diff; + } + WorkerResult::ConfigLoaded { .. } | WorkerResult::ConfigUpdated { .. } => { + self.settings_view.handle_worker_result(result); + } + WorkerResult::LogLoaded { .. } => { + self.log_view.handle_worker_result(result); + } + WorkerResult::PluginsLoaded { .. } | WorkerResult::PluginEdited { .. } => { + self.plugins_view.handle_worker_result(result); + } + } + } + } + + /// Renders the top menu bar (File, Edit, etc.). + fn show_menu_bar(&mut self, ctx: &egui::Context, ui: &mut Ui) { + egui::MenuBar::new().ui(ui, |ui| { + ui.menu_button("File", |ui| { + OpenRepositoryButton::new(&mut self.async_worker, &self.container).show(ui, ctx); + + if ui + .button(format!("{} Create new", icons::FOLDER_PLUS)) + .clicked() + { + ui.close_kind(egui::UiKind::Menu); + self.create_repo_form.is_open = true; + self.create_repo_form.branch_name = "main".to_string(); + } + + if ui.button(format!("{} Clone...", icons::DOWNLOAD)).clicked() { + ui.close_kind(egui::UiKind::Menu); + self.clone_repo_form.is_open = true; + self.clone_repo_form.reset(); + } + + ui.separator(); + + if ui.button(format!("{} Close", icons::X)).clicked() { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + }); + } + + /// Renders the top panel containing the menu bar and theme toggle. + fn show_top_panel(&mut self, ctx: &egui::Context) { + egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { + ui.horizontal(|ui| { + self.show_menu_bar(ctx, ui); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(12.0); + let is_dark = ctx.style().visuals.dark_mode; + if ThemeButton::default().show(ui, is_dark) { + ctx.set_visuals(match is_dark { + true => egui::Visuals::light(), + false => egui::Visuals::dark(), + }); + } + }); + }); + }); + } + + /// Renders the bottom status bar. + fn show_bottom_panel(&mut self, ctx: &egui::Context) { + egui::TopBottomPanel::bottom("bottom_panel").show(ctx, |ui| { + ui.horizontal(|ui| { + if SettingsButton::default().show(ui) { + self.current_view = GuiView::Settings; + } + + ui.separator(); + + if let Some(status) = &self.repository_status + && let Some(branch_info) = &status.branch + { + BranchStatusComponent::new(branch_info, &mut self.switch_branch_form.is_open) + .show(ui); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new("v0.1.0").weak()) + .on_hover_text("Current version"); + + ui.separator(); + + if let Some(status) = &self.repository_status + && let Some(branch) = &status.branch + { + RemoteActionsComponent::new( + &mut self.async_worker, + &self.container, + ctx, + branch, + ) + .show(ui); + } + }); + }); + }); + } + + /// Helper to render the sidebar navigation. + fn show_navigation(&mut self, ui: &mut Ui) { + NavigationComponent::new( + &mut self.current_view, + self.diff_view.has_open_tabs(), + self.repository_status.is_some(), + ) + .show(ui); + } + + /// Renders the left sidebar containing navigation and working tree actions. + fn show_left_panel(&mut self, ctx: &egui::Context) { + egui::SidePanel::left("left_panel") + .resizable(true) + .default_width(200.0) + .min_width(250.0) + .show(ctx, |ui| { + ui.add_space(10.0); + + self.show_navigation(ui); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + if let Some(status) = &self.repository_status { + ui.horizontal(|ui| { + RefreshStatusButton::new(&mut self.async_worker, &self.container) + .show(ui, ctx); + ui.with_layout( + egui::Layout::top_down_justified(egui::Align::Center), + |ui| { + if CommitButton.show( + ui, + self.repository_status.as_ref().is_some_and(|status| { + status + .staged + .as_ref() + .is_some_and(|staged| !staged.is_empty()) + }), + ) { + self.commit_form.is_open = true; + } + }, + ); + }); + + ui.add_space(10.0); + + egui::ScrollArea::vertical() + .id_salt("changes_scroll_area") + .auto_shrink([false, false]) + .show(ui, |ui| { + FileChangesComponent::new(&mut self.async_worker, &self.container, ctx) + .show(ui, status, &mut self.file_changes_state); + }); + } else { + ui.vertical_centered(|ui| { + ui.add_space(20.0); + ui.label(egui::RichText::new("No repository open").weak()); + }); + } + }); + } + + /// Renders the main central content area based on the `current_view`. + fn show_central_panel(&mut self, ctx: &egui::Context) { + egui::CentralPanel::default().show(ctx, |ui| match self.current_view { + GuiView::Dashboard => { + DashboardView {}.show(ui); + } + GuiView::Settings => { + self.settings_view + .show(ui, &mut self.async_worker, &self.container, ctx); + } + GuiView::Diff => { + let should_close = self.diff_view.show_tabs(ui); + + if should_close { + self.current_view = GuiView::Dashboard; + } else { + ui.separator(); + self.diff_view.show_diff_content(ui); + } + } + GuiView::History => { + self.log_view + .show(ui, &mut self.async_worker, &self.container, ctx); + } + GuiView::Plugins => { + self.plugins_view + .show(ui, &mut self.async_worker, &self.container, ctx); + } + }); + } + + /// Renders any active modal dialogs. + fn show_dialogs(&mut self, ctx: &egui::Context) { + CreateRepositoryDialog::new( + &mut self.create_repo_form, + &mut self.async_worker, + &self.container, + ) + .show(ctx); + + CloneRepositoryDialog::new( + &mut self.clone_repo_form, + &mut self.async_worker, + &self.container, + ) + .show(ctx); + + CommitChangesDialog::new( + &mut self.commit_form, + &mut self.async_worker, + &self.container, + ctx, + ) + .show(); + + SwitchBranchDialog::new( + &mut self.switch_branch_form, + &self.repository_branches, + &mut self.async_worker, + &self.container, + ctx, + ) + .show(); + + LoadingDialog { + active: self.async_worker.is_active, + message: &self.async_worker.loading_message, + } + .show(ctx); + + if let Some(event_error) = &self.event_error { + let dialog = ErrorDialog { error: event_error }; + if dialog.show(ctx) { + self.event_error = None; + } + } + } +} diff --git a/gui/src/ui/components.rs b/gui/src/ui/components.rs index 6c4b9c0..e831a9b 100644 --- a/gui/src/ui/components.rs +++ b/gui/src/ui/components.rs @@ -2,6 +2,7 @@ mod branch_status; mod buttons; mod dialogs; mod file_changes; +mod navigation; mod remote_actions; pub use buttons::*; @@ -9,4 +10,5 @@ pub use dialogs::*; pub use branch_status::BranchStatusComponent; pub use file_changes::{FileChangesComponent, FileChangesState}; +pub use navigation::NavigationComponent; pub use remote_actions::RemoteActionsComponent; diff --git a/gui/src/ui/components/dialogs.rs b/gui/src/ui/components/dialogs.rs index efc8f17..37b25f7 100644 --- a/gui/src/ui/components/dialogs.rs +++ b/gui/src/ui/components/dialogs.rs @@ -1,11 +1,15 @@ +mod clone_repository_dialog; mod commit_changes_dialog; mod create_repository_dialog; mod error_dialog; mod loading_dialog; +mod register_plugin_dialog; mod switch_branch_dialog; +pub use clone_repository_dialog::{CloneRepositoryDialog, CloneRepositoryForm}; pub use commit_changes_dialog::{CommitChangesDialog, CommitChangesForm}; pub use create_repository_dialog::{CreateRepositoryDialog, CreateRepositoryForm}; pub use error_dialog::ErrorDialog; pub use loading_dialog::LoadingDialog; +pub use register_plugin_dialog::{RegisterPluginDialog, RegisterPluginForm}; pub use switch_branch_dialog::{SwitchBranchDialog, SwitchBranchForm}; diff --git a/gui/src/ui/components/dialogs/clone_repository_dialog.rs b/gui/src/ui/components/dialogs/clone_repository_dialog.rs new file mode 100644 index 0000000..36cd9ab --- /dev/null +++ b/gui/src/ui/components/dialogs/clone_repository_dialog.rs @@ -0,0 +1,306 @@ +use std::{path::PathBuf, sync::Arc, thread}; + +use egui::{Align, Context, Layout, Ui}; +use egui_phosphor::regular::{self as icons}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::{ + branch::{BranchOperations, ListRequest, Request as BranchRequest}, + clone::Request as CloneRequest, + status::Request as StatusRequest, + }, +}; +use shared::{PathToString, change_directory}; +use url::Url; + +use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; + +/// Holds the transient state of the "Clone Repository" dialog. +/// +/// This struct persists in the main application state to ensure the user's +/// inputs (URL, paths, origin) are preserved while the dialog is open. +#[derive(Debug, Default)] +pub struct CloneRepositoryForm { + /// Controls the visibility of the dialog window. + pub is_open: bool, + + /// The remote address/URL of the repository to clone. + pub repository_url: String, + + /// The local directory where the repository will be cloned into. + pub target_directory: Option, + + /// The name of the remote (defaults to "origin"). + pub origin_name: String, + + /// Path to the server's public key file (required by CLI). + pub server_key_path: Option, +} + +impl CloneRepositoryForm { + /// Creates a new form state with default values. + pub fn new() -> Self { + Self { + origin_name: "origin".to_string(), + ..Default::default() + } + } + + /// Resets the form inputs to their default state. + pub fn reset(&mut self) { + self.repository_url.clear(); + self.target_directory = None; + self.origin_name = "origin".to_string(); + self.server_key_path = None; + } + + /// Validates the current form inputs, returning an error message if invalid. + pub fn get_validation_message(&self) -> Option { + if self.repository_url.trim().is_empty() { + Some("Repository URL cannot be empty.".to_string()) + } else if self.target_directory.is_none() { + Some("Please select a destination directory.".to_string()) + } else if self.server_key_path.is_none() { + Some("Please select a server public key file.".to_string()) + } else if self.origin_name.trim().is_empty() { + Some("Origin name cannot be empty.".to_string()) + } else { + None + } + } +} + +/// A modal dialog for cloning an existing repository. +pub struct CloneRepositoryDialog<'a> { + /// Mutable reference to the form state. + form: &'a mut CloneRepositoryForm, + /// Worker for spawning background tasks. + worker: &'a mut AsyncWorker, + /// Dependency container for accessing the engine. + container: &'a Arc, +} + +impl<'a> CloneRepositoryDialog<'a> { + /// Creates a new [`CloneRepositoryDialog`]. + pub fn new( + form: &'a mut CloneRepositoryForm, + worker: &'a mut AsyncWorker, + container: &'a Arc, + ) -> Self { + Self { + form, + worker, + container, + } + } + + /// Renders the dialog window if `form.is_open` is true. + pub fn show(&mut self, ctx: &egui::Context) { + if !self.form.is_open { + return; + } + + let mut keep_window_open = true; + + egui::Window::new("Clone Repository") + .collapsible(false) + .resizable(false) + .default_width(300.0) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .open(&mut keep_window_open) + .show(ctx, |ui| self.show_clone_form(ctx, ui)); + + if !keep_window_open { + self.form.is_open = false; + } + } + + /// Renders the clone repository form content. + fn show_clone_form(&mut self, ctx: &egui::Context, ui: &mut Ui) { + ui.add_space(10.0); + ui.label("Repository address (URL):"); + ui.add_space(5.0); + ui.text_edit_singleline(&mut self.form.repository_url); + + ui.add_space(10.0); + ui.label("Origin name:"); + ui.add_space(5.0); + ui.text_edit_singleline(&mut self.form.origin_name); + + ui.add_space(10.0); + ui.label("Destination directory:"); + ui.add_space(5.0); + + let display_text = match &self.form.target_directory { + Some(path) => path.to_gui_string(), + None => "No directory selected...".to_string(), + }; + + ui.monospace(display_text); + ui.add_space(5.0); + + self.show_directory_browse_button(ui); + + ui.add_space(10.0); + + ui.label("Server public key:"); + ui.add_space(5.0); + + let display_text = match &self.form.server_key_path { + Some(path) => path.to_gui_string(), + None => "No key file selected...".to_string(), + }; + + ui.monospace(display_text); + ui.add_space(5.0); + + self.show_key_browse_button(ui); + + ui.add_space(5.0); + ui.separator(); + ui.add_space(5.0); + + ui.with_layout(Layout::right_to_left(Align::Min), |ui| { + self.show_clone_button(ctx, ui); + + if ui.button("Cancel").clicked() { + self.form.is_open = false; + } + }); + } + + /// Renders the button to pick the destination folder. + fn show_directory_browse_button(&mut self, ui: &mut Ui) { + if ui + .button(format!("{} Browse Folder", icons::FOLDER_OPEN)) + .clicked() + && let Some(path) = rfd::FileDialog::new().pick_folder() + { + self.form.target_directory = Some(path); + } + } + + /// Renders the button to pick the server key file. + fn show_key_browse_button(&mut self, ui: &mut Ui) { + if ui + .button(format!("{} Browse Key File", icons::KEY)) + .clicked() + && let Some(path) = rfd::FileDialog::new().pick_file() + { + self.form.server_key_path = Some(path); + } + } + + /// Renders the primary "Clone" action button. + fn show_clone_button(&mut self, ctx: &Context, ui: &mut Ui) { + let validation_message = self.form.get_validation_message(); + let clone_button = ui.add_enabled(validation_message.is_none(), egui::Button::new("Clone")); + + if clone_button.clicked() { + self.handle_clone_click(ctx); + } + + clone_button.on_disabled_hover_ui(|ui| { + if let Some(validation_message) = validation_message { + ui.label(egui::RichText::new(validation_message).color(egui::Color32::RED)); + } + }); + } + + /// Initiates the clone process in the background. + fn handle_clone_click(&mut self, ctx: &Context) { + self.form.is_open = false; + + let url = self.form.repository_url.clone(); + let target_dir = self.form.target_directory.clone().unwrap(); + let origin = self.form.origin_name.clone(); + let server_key = self.form.server_key_path.clone().unwrap(); + + let ctx_clone = ctx.clone(); + let container = self.container.clone(); + + self.worker.spawn(ctx.clone(), move |tx| { + let report = |msg: &str| { + let _ = tx.send(WorkerEvent::Progress(msg.to_string())); + ctx_clone.request_repaint(); + }; + + report("Cloning repository..."); + thread::sleep(std::time::Duration::from_millis(500)); + + match Self::execute_clone_task(container, url, target_dir, origin, server_key, report) { + Ok(response) => { + let _ = tx.send(WorkerEvent::Success(response)); + } + Err(err_msg) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Clone Failed".to_string(), + "Could not clone repository".to_string(), + err_msg, + ))); + } + } + ctx_clone.request_repaint(); + }); + } + + /// Executes the backend logic for cloning. + fn execute_clone_task( + container: Arc, + url: String, + target_dir: PathBuf, + origin: String, + server_key: PathBuf, + report: impl Fn(&str), + ) -> Result { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("Failed to create runtime: {e}"))?; + + report("Opening repository..."); + thread::sleep(std::time::Duration::from_millis(500)); + + rt.block_on(async { + let clone_handler = container.clone_handler().map_err(|e| e.to_string())?; + + let request = CloneRequest { + url: Url::parse(&url).map_err(|e| e.to_string())?, + directory: Some(target_dir.clone()), + origin, + server_key, + quiet: true, + }; + + clone_handler + .handle_clone(request) + .await + .map_err(|e| e.to_string()) + })?; + + change_directory(&target_dir).map_err(|e| e.to_string())?; + + report("Reading repository status..."); + thread::sleep(std::time::Duration::from_millis(500)); + + let status_handler = container.status_handler().map_err(|e| e.to_string())?; + let status = status_handler + .handle_status(StatusRequest::with_branch()) + .map_err(|e| e.to_string())?; + + report("Loading branches..."); + thread::sleep(std::time::Duration::from_millis(500)); + + let branch_handler = container.branch_handler().map_err(|e| e.to_string())?; + let branch = branch_handler + .branch(BranchRequest::List(ListRequest { + local: true, + remotes: true, + verbose: false, + })) + .map_err(|e| e.to_string())?; + + Ok(WorkerResult::RepositoryOpened { branch, status }) + } +} diff --git a/gui/src/ui/components/dialogs/create_repository_dialog.rs b/gui/src/ui/components/dialogs/create_repository_dialog.rs index b4d7f97..fdaf1c5 100644 --- a/gui/src/ui/components/dialogs/create_repository_dialog.rs +++ b/gui/src/ui/components/dialogs/create_repository_dialog.rs @@ -7,7 +7,7 @@ use engine::{ engine_container::MevaContainer, handlers::{init::Request as InitRequest, status::Request as StatusRequest}, }; -use shared::change_directory; +use shared::{PathToString, change_directory}; use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; @@ -112,7 +112,10 @@ impl<'a> CreateRepositoryDialog<'a> { ui.label("Location:"); ui.add_space(5.0); - let display_text = self.get_directory_display_text(); + let display_text = match self.form.selected_path.as_ref() { + Some(path) => path.to_gui_string(), + None => "No directory selected...".to_string(), + }; ui.monospace(display_text); ui.add_space(5.0); @@ -155,34 +158,6 @@ impl<'a> CreateRepositoryDialog<'a> { } } - /// Formats the selected path for display in the UI. - /// - /// Truncates the path from the left (adding "...") if it exceeds a certain length, - /// ensuring the UI doesn't break for deeply nested paths. - fn get_directory_display_text(&self) -> String { - let path_text = self - .form - .selected_path - .as_ref() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| "No directory selected...".to_string()); - - let max_len = 25; - if path_text.chars().count() > max_len { - let truncated: String = path_text - .chars() - .rev() - .take(max_len - 3) - .collect::() - .chars() - .rev() - .collect(); - format!("...{truncated}") - } else { - path_text - } - } - /// Initiates the repository creation process. /// /// This spawns a background task via [`AsyncWorker`] to avoid freezing the UI diff --git a/gui/src/ui/components/dialogs/register_plugin_dialog.rs b/gui/src/ui/components/dialogs/register_plugin_dialog.rs new file mode 100644 index 0000000..5ca3bac --- /dev/null +++ b/gui/src/ui/components/dialogs/register_plugin_dialog.rs @@ -0,0 +1,325 @@ +use std::thread; +use std::{path::PathBuf, sync::Arc}; + +use egui::{Color32, Context, RichText, Ui}; +use egui_phosphor::regular as icons; +use strum::IntoEnumIterator; + +use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::plugins::{PluginsOperations, RegisterRequest}, +}; +use plugins::{CommandType, EventType, ScopeType}; + +/// Maintains the transient state of the "Register Plugin" form fields. +#[derive(Debug, Default)] +pub struct RegisterPluginForm { + pub is_open: bool, + pub name: String, + pub description: String, + pub path: String, + pub file_path: String, + pub scope: ScopeType, + pub command: CommandType, + pub event: EventType, + pub order: String, + pub timeout: String, + pub interpreter: String, + pub enabled: bool, +} + +impl RegisterPluginForm { + /// Creates a new form state with sensible default values. + pub fn new() -> Self { + Self { + order: "0".to_string(), + enabled: true, + ..Default::default() + } + } + + /// Resets the form fields to their default state and sets the visibility flag to `true`. + pub fn open(&mut self) { + self.reset(); + self.is_open = true; + } + + /// Clears all input fields, reverting them to `Default`. + pub fn reset(&mut self) { + *self = Self::new(); + } + + /// Validates the current form inputs. + /// + /// Checks for required fields and proper numeric parsing. + /// + /// # Returns + /// * `Some(&str)` containing the error message if validation fails. + /// * `None` if all fields are valid. + pub fn get_validation_error(&self) -> Option<&'static str> { + if self.name.trim().is_empty() { + return Some("Name is required"); + } + + if self.path.trim().is_empty() { + return Some("Path is required"); + } + + if self.order.parse::().is_err() { + return Some("Order must be a valid non-negative integer"); + } + + if !self.timeout.trim().is_empty() && self.timeout.parse::().is_err() { + return Some("Timeout must be a valid positive integer"); + } + + None + } + + pub fn to_register_request(&self) -> RegisterRequest { + RegisterRequest { + name: self.name.clone(), + path: PathBuf::from(&self.path), + scope: self.scope.clone(), + command: self.command.clone(), + event: self.event.clone(), + enabled: self.enabled, + description: (!self.description.trim().is_empty()).then_some(self.description.clone()), + file: (!self.file_path.trim().is_empty()).then_some(PathBuf::from(&self.file_path)), + interpreter: (!self.interpreter.trim().is_empty()).then_some(self.interpreter.clone()), + order: self.order.parse::().unwrap_or(0), + timeout: self.timeout.trim().parse::().ok(), + } + } +} + +/// A modal dialog component for registering new external plugins. +pub struct RegisterPluginDialog<'a> { + form: &'a mut RegisterPluginForm, + worker: &'a mut AsyncWorker, + container: &'a Arc, + ctx: &'a Context, +} + +impl<'a> RegisterPluginDialog<'a> { + /// Creates a new instance of the dialog. + pub fn new( + form: &'a mut RegisterPluginForm, + worker: &'a mut AsyncWorker, + container: &'a Arc, + ctx: &'a Context, + ) -> Self { + Self { + form, + worker, + container, + ctx, + } + } + + /// Renders the dialog window if `form.is_open` is true. + pub fn show(&mut self) { + if !self.form.is_open { + return; + } + + let mut keep_open = true; + + egui::Window::new(format!("{} Register Plugin", icons::PLUG)) + .collapsible(false) + .resizable(false) + .default_width(400.0) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .open(&mut keep_open) + .show(self.ctx, |ui| { + self.show_form_content(ui); + }); + + if !keep_open { + self.form.is_open = false; + } + } + + /// Collects form data, transforms it into a `RegisterRequest`, and spawns a background task. + fn handle_register(&mut self) { + self.form.is_open = false; + let request = self.form.to_register_request(); + let ctx_clone = self.ctx.clone(); + let container = self.container.clone(); + + self.worker.spawn(self.ctx.clone(), move |tx| { + let _ = tx.send(WorkerEvent::Progress("Registering plugin...".into())); + thread::sleep(std::time::Duration::from_millis(500)); + + let task = || -> Result { + let handler = container.plugins_handler().map_err(|e| e.to_string())?; + let _ = handler.register(request).map_err(|e| e.to_string())?; + let _ = tx.send(WorkerEvent::Progress("Refreshing list...".into())); + Ok(WorkerResult::None) + }; + + match task() { + Ok(response) => { + let _ = tx.send(WorkerEvent::Success(response)); + } + Err(err_msg) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Registration Failed".into(), + "Could not register plugin".into(), + err_msg, + ))); + } + } + ctx_clone.request_repaint(); + }); + } + + /// Renders the scrollable content area containing all input fields. + fn show_form_content(&mut self, ui: &mut Ui) { + egui::ScrollArea::vertical() + .max_height(500.0) + .show(ui, |ui| { + ui.add_space(10.0); + + egui::Grid::new("register_plugin_grid") + .num_columns(2) + .spacing([20.0, 10.0]) + .striped(false) + .show(ui, |ui| { + ui.label("Command:*").on_hover_text( + "The command type this plugin will hook into", + ); + egui::ComboBox::from_id_salt("reg_cmd") + .selected_text(self.form.command.to_string()) + .show_ui(ui, |ui| { + for cmd in CommandType::iter() { + ui.selectable_value( + &mut self.form.command, + cmd.clone(), + cmd.to_string(), + ); + } + }); + ui.end_row(); + + ui.label("Name:*") + .on_hover_text("Unique identifier for the plugin configuration"); + ui.add( + egui::TextEdit::singleline(&mut self.form.name) + .hint_text("e.g. script.py"), + ); + ui.end_row(); + + ui.label("Path:*") + .on_hover_text("Absolute path to the plugin source file"); + ui.add( + egui::TextEdit::singleline(&mut self.form.path) + .hint_text("/absolute/path/to/script.py"), + ); + ui.end_row(); + + ui.label("File:").on_hover_text( + "Relative path to script file inside plugins directory (e.g. script.py)", + ); + ui.add( + egui::TextEdit::singleline(&mut self.form.file_path) + .hint_text("script.py"), + ); + ui.end_row(); + + ui.label("Scope:").on_hover_text("Determines if the plugin applies globally or only to the current repository"); + egui::ComboBox::from_id_salt("reg_scope") + .selected_text(self.form.scope.to_string()) + .show_ui(ui, |ui| { + for scope in ScopeType::iter() { + ui.selectable_value( + &mut self.form.scope, + scope.clone(), + scope.to_string(), + ); + } + }); + ui.end_row(); + + ui.label("Description:").on_hover_text("Optional human-readable description of what the plugin does"); + ui.add( + egui::TextEdit::multiline(&mut self.form.description) + .hint_text("Describe the plugin behavior...") + .desired_rows(3), + ); + ui.end_row(); + + ui.label("Event:").on_hover_text("When the plugin should execute: before (Pre) or after (Post) the command"); + egui::ComboBox::from_id_salt("reg_evt") + .selected_text(self.form.event.to_string()) + .show_ui(ui, |ui| { + for evt in EventType::iter() { + ui.selectable_value( + &mut self.form.event, + evt.clone(), + evt.to_string(), + ); + } + }); + ui.end_row(); + + ui.label("Order:").on_hover_text("Execution priority. Lower numbers run first"); + ui.add( + egui::TextEdit::singleline(&mut self.form.order) + .hint_text("0"), + ); + ui.end_row(); + + ui.label("Timeout (ms):").on_hover_text("Maximum allowed execution time in milliseconds before the plugin is killed"); + ui.add( + egui::TextEdit::singleline(&mut self.form.timeout) + .hint_text("e.g. 5000"), + ); + ui.end_row(); + + ui.label("Status:").on_hover_text("Controls whether the plugin is currently active"); + ui.checkbox(&mut self.form.enabled, "Enabled"); + ui.end_row(); + + ui.label("Interpreter:").on_hover_text("The binary used to run the script (e.g., 'python3', 'node', 'bash')"); + ui.add( + egui::TextEdit::singleline(&mut self.form.interpreter) + .hint_text("python"), + ); + ui.end_row(); + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + self.show_form_actions(ui); + }); + } + + /// Renders the action buttons (Register, Cancel) at the bottom of the form. + fn show_form_actions(&mut self, ui: &mut Ui) { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + let validation_error = self.form.get_validation_error(); + + let register_button = + ui.add_enabled(validation_error.is_none(), egui::Button::new("Register")); + + if register_button.clicked() { + self.handle_register(); + } + + register_button.on_disabled_hover_ui(|ui| { + if let Some(validation_message) = validation_error { + ui.label(RichText::new(validation_message).color(Color32::RED)); + } + }); + + if ui.button("Cancel").clicked() { + self.form.is_open = false; + } + }); + } +} diff --git a/gui/src/ui/components/navigation.rs b/gui/src/ui/components/navigation.rs new file mode 100644 index 0000000..a6a2f59 --- /dev/null +++ b/gui/src/ui/components/navigation.rs @@ -0,0 +1,62 @@ +use egui::Ui; +use egui_phosphor::regular as icons; + +use crate::ui::{GuiView, components::buttons::NavigationButton}; + +pub struct NavigationComponent<'a> { + current_view: &'a mut GuiView, + has_diff_tabs: bool, + is_repository_open: bool, +} + +impl<'a> NavigationComponent<'a> { + /// Creates a new navigation component. + /// + /// # Arguments + /// * `current_view` - Mutable reference to the current view state (to update it on click). + /// * `has_diff_tabs` - Condition to show the Diff button. + /// * `is_repository_open` - Condition to show History/Repo buttons. + pub fn new( + current_view: &'a mut GuiView, + has_diff_tabs: bool, + is_repository_open: bool, + ) -> Self { + Self { + current_view, + has_diff_tabs, + is_repository_open, + } + } + + /// Renders the navigation UI component. + pub fn show(&mut self, ui: &mut Ui) { + ui.heading("Navigation"); + ui.add_space(5.0); + + self.nav_item(ui, GuiView::Dashboard, icons::HOUSE); + + if self.has_diff_tabs { + self.nav_item(ui, GuiView::Diff, icons::FILE_CODE); + } + + if self.is_repository_open { + self.nav_item(ui, GuiView::History, icons::CLOCK_COUNTER_CLOCKWISE); + } + + self.nav_item(ui, GuiView::Plugins, icons::PLUG); + } + + /// Renders a single navigation item. + fn nav_item(&mut self, ui: &mut Ui, view: GuiView, icon: &str) { + ui.vertical(|ui| { + let is_selected = *self.current_view == view; + + if NavigationButton::new(is_selected, icon, &view.to_string()) + .show(ui) + .clicked() + { + *self.current_view = view; + } + }); + } +} diff --git a/gui/src/ui/views.rs b/gui/src/ui/views.rs index c69a5d6..a643189 100644 --- a/gui/src/ui/views.rs +++ b/gui/src/ui/views.rs @@ -1,12 +1,15 @@ mod dashboard; mod diff; mod log; +mod plugins; mod settings; pub use dashboard::DashboardView; pub use diff::DiffView; pub use log::LogView; +pub use plugins::PluginsView; pub use settings::SettingsView; +use strum_macros::Display; /// A common interface for all major application views (screens). /// @@ -21,3 +24,26 @@ pub trait View { /// * `ui` - The `egui::Ui` context where the view should draw its widgets. fn show(&mut self, ui: &mut egui::Ui); } + +/// Represents the currently active view or tab in the application's main interface. +#[derive(Debug, Default, PartialEq, Eq, Display)] +pub enum GuiView { + /// The main landing view. + #[default] + Dashboard, + + /// The screen for managing global and local + /// configuration files. + Settings, + + /// A dedicated view for inspecting diffs. + Diff, + + /// The commit log view, showing the chronological history of changes, + /// authors, and commit messages. + History, + + /// Interface for listing, searching, registering, and configuring + /// external plugins. + Plugins, +} diff --git a/gui/src/ui/views/plugins.rs b/gui/src/ui/views/plugins.rs new file mode 100644 index 0000000..aa4cde1 --- /dev/null +++ b/gui/src/ui/views/plugins.rs @@ -0,0 +1,574 @@ +use std::path::Path; +use std::sync::Arc; +use std::{path::PathBuf, thread}; + +use egui::{Color32, Layout, Margin, RichText, Stroke, Ui}; +use egui_phosphor::regular as icons; + +use engine::{ConfigLoader, MevaConfigLoader}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::plugins::{ + EditRequest as PluginsEditRequest, ListRequest as PluginsListRequest, PluginsOperations, + }, +}; +use plugins::{CommandType, EventType, PluginConfiguration, ScopeType}; +use shared::OpenInEditor; +use strum::IntoEnumIterator; + +use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; +use crate::ui::{RegisterPluginDialog, RegisterPluginForm}; + +/// Maintains the state of the filters applied to the plugin list. +#[derive(Debug, Default, Clone, PartialEq)] +struct FilterState { + command: CommandType, + event: EventType, + scope: ScopeType, + enabled: bool, +} + +impl FilterState { + /// Creates a new [`FilterState`] with default values. + pub fn new() -> Self { + Self { + enabled: true, + ..Default::default() + } + } +} + +struct PluginEditParams { + name: String, + command: CommandType, + scope: ScopeType, + path: PathBuf, + enabled: Option, +} + +/// The main view component for listing, filtering, and managing external plugins. +/// +/// This struct holds the list of loaded plugins, the current filter state, +/// and the state of the registration modal form. +#[derive(Debug, Default)] +pub struct PluginsView { + /// The cached list of plugins loaded from the backend. + plugins: Vec<(PluginConfiguration, PathBuf)>, + /// Current filter criteria selected by the user. + filters: FilterState, + /// Flag to ensure the initial data fetch happens only once upon view creation. + init_load_triggered: bool, + /// State for the "Register New Plugin" modal form. + register_form: RegisterPluginForm, +} + +impl PluginsView { + /// Creates a new instance of the Plugins view. + pub fn new() -> Self { + Self { + register_form: RegisterPluginForm::new(), + filters: FilterState::new(), + ..Default::default() + } + } + + /// Renders the Plugins view. + /// + /// This method is responsible for: + /// 1. Rendering the registration dialog (if open). + /// 2. Triggering the initial fetch of plugins. + /// 3. Rendering the filter bar and the list of plugin cards. + pub fn show( + &mut self, + ui: &mut Ui, + worker: &mut AsyncWorker, + container: &Arc, + ctx: &egui::Context, + ) { + RegisterPluginDialog::new(&mut self.register_form, worker, container, ctx).show(); + + if !self.init_load_triggered { + self.spawn_fetch_plugins(worker, container, ctx); + self.init_load_triggered = true; + } + + ui.heading("Registered Plugins"); + ui.add_space(10.0); + + if ui.button(format!("{} Register New", icons::PLUG)).clicked() { + self.register_form.open(); + } + + ui.add_space(10.0); + + self.show_filters(ui, worker, container, ctx); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + if self.plugins.is_empty() { + self.show_when_no_plugins(ui); + } else { + egui::ScrollArea::vertical().show(ui, |ui| { + for (config, path) in &self.plugins { + self.show_plugin_card(ui, worker, container, ctx, config, path); + ui.add_space(8.0); + } + }); + } + } + + /// Updates the view state based on events received from the background worker. + /// + /// Handles: + /// - `PluginsLoaded`: Replaces the current list with new data. + /// - `PluginEdited`: Updates a single plugin's state locally to reflect changes immediately. + pub fn handle_worker_result(&mut self, result: WorkerResult) { + match result { + WorkerResult::PluginsLoaded { plugins } => { + self.plugins = plugins; + } + + WorkerResult::PluginEdited { name, enabled } => { + if let Some(plugin) = self.plugins.iter_mut().find(|p| p.0.name == name) { + plugin.0.enabled = enabled; + } + } + _ => {} + } + } + + /// Spawns a background task to fetch the list of plugins based on current filters. + fn spawn_fetch_plugins( + &self, + worker: &mut AsyncWorker, + container: &Arc, + ctx: &egui::Context, + ) { + let ctx = ctx.clone(); + let container = container.clone(); + + let filters = self.filters.clone(); + + worker.spawn(ctx.clone(), move |tx| { + let _ = tx.send(WorkerEvent::Progress("Searching plugins...".into())); + thread::sleep(std::time::Duration::from_millis(500)); + + let task = || -> Result { + let handler = container.plugins_handler().map_err(|e| e.to_string())?; + + let request = PluginsListRequest { + command: filters.command, + event: filters.event, + scope: filters.scope, + include_enabled: filters.enabled, + include_disabled: !filters.enabled, + }; + + let response = handler.list(request).map_err(|e| e.to_string())?; + + Ok(WorkerResult::PluginsLoaded { + plugins: response.plugins, + }) + }; + + match task() { + Ok(response) => { + let _ = tx.send(WorkerEvent::Success(response)); + } + Err(err_msg) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Plugin Error".into(), + "Failed to load plugins".into(), + err_msg, + ))); + } + } + ctx.request_repaint(); + }); + } + + /// Spawns a background task to edit a plugin or open its source file. + /// + /// # Behavior + /// * If `enabled` is `Some(bool)`, it toggles the plugin's status via the backend engine. + /// * If `enabled` is `None`, it attempts to open the plugin's source file in the system editor. + fn spawn_edit_plugin( + &self, + worker: &mut AsyncWorker, + container: &Arc, + ctx: &egui::Context, + params: PluginEditParams, + ) { + let ctx = ctx.clone(); + let container = container.clone(); + + worker.spawn(ctx.clone(), move |tx| { + let _ = tx.send(WorkerEvent::Progress("Updating plugin...".into())); + thread::sleep(std::time::Duration::from_millis(500)); + + let task = || -> Result { + let handler = container.plugins_handler().map_err(|e| e.to_string())?; + + let request = PluginsEditRequest { + command: params.command, + name: params.name.clone(), + scope: params.scope, + enabled: params.enabled, + }; + + if let Some(enabled) = params.enabled { + let _ = handler.edit(request).map_err(|e| e.to_string())?; + Ok(WorkerResult::PluginEdited { + name: params.name, + enabled, + }) + } else { + let loader = MevaConfigLoader::default(); + let override_cmd = loader.get("core.editor", None).ok(); + params + .path + .open_in_editor(override_cmd) + .map_err(|e| e.to_string())?; + Ok(WorkerResult::None) + } + }; + + match task() { + Ok(response) => { + let _ = tx.send(WorkerEvent::Success(response)); + } + Err(err_msg) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Plugin Edit Error".into(), + "Failed to update plugin state".into(), + err_msg, + ))); + } + } + ctx.request_repaint(); + }); + } + + /// Renders a placeholder UI when the plugin list is empty. + fn show_when_no_plugins(&mut self, ui: &mut Ui) { + ui.vertical_centered(|ui| { + ui.add_space(ui.available_height() * 0.25); + ui.label( + RichText::new(icons::PLUGS) + .size(64.0) + .color(Color32::from_gray(90)), + ); + + ui.add_space(15.0); + ui.label( + RichText::new("No plugins found") + .heading() + .strong() + .color(Color32::GRAY), + ); + ui.add_space(5.0); + ui.label( + RichText::new("Register a new plugin configuration to see it listed here.") + .color(ui.visuals().weak_text_color()), + ); + }); + } + + /// Renders the collapsible filter section. + fn show_filters( + &mut self, + ui: &mut Ui, + worker: &mut AsyncWorker, + container: &Arc, + ctx: &egui::Context, + ) { + const COMBO_WIDTH: f32 = 160.0; + const LABEL_WIDTH: f32 = 70.0; + + ui.group(|ui| { + let is_narrow = ui.available_width() < 510.0; + let columns = if is_narrow { 1 } else { 2 }; + + ui.label(RichText::new(format!("{} Filter Plugins", icons::FUNNEL)).strong()); + ui.add_space(10.0); + + egui::Grid::new("plugin_filters_grid") + .num_columns(columns) + .spacing([40.0, 10.0]) + .striped(false) + .show(ui, |ui| { + let render_row = + |ui: &mut Ui, + label: &str, + id: &str, + current_val: String, + content: Box| { + let row_height = ui.style().spacing.interact_size.y; + + ui.with_layout( + egui::Layout::left_to_right(egui::Align::Min) + .with_main_align(egui::Align::Min), + |ui| { + ui.add_sized( + [LABEL_WIDTH, row_height], + egui::Label::new(label).wrap().halign(egui::Align::LEFT), + ); + + egui::ComboBox::from_id_salt(id) + .selected_text(current_val) + .width(COMBO_WIDTH) + .show_ui(ui, content); + }, + ); + }; + + render_row( + ui, + "Command:", + "filter_cmd", + self.filters.command.to_string(), + Box::new(|ui| { + for cmd in CommandType::iter() { + ui.selectable_value( + &mut self.filters.command, + cmd.clone(), + cmd.to_string(), + ); + } + }), + ); + + if is_narrow { + ui.end_row(); + } + + render_row( + ui, + "Event:", + "filter_evt", + self.filters.event.to_string(), + Box::new(|ui| { + for evt in EventType::iter() { + ui.selectable_value( + &mut self.filters.event, + evt.clone(), + evt.to_string(), + ); + } + }), + ); + + ui.end_row(); + + render_row( + ui, + "Scope:", + "filter_scope", + self.filters.scope.to_string(), + Box::new(|ui| { + for scope in ScopeType::iter() { + ui.selectable_value( + &mut self.filters.scope, + scope.clone(), + scope.to_string(), + ); + } + }), + ); + + if is_narrow { + ui.end_row(); + } + + let enabled = "enabled"; + let disabled = "disabled"; + + render_row( + ui, + "Status:", + "filter_status", + if self.filters.enabled { + enabled + } else { + disabled + } + .to_string(), + Box::new(|ui| { + ui.selectable_value(&mut self.filters.enabled, true, enabled); + ui.selectable_value(&mut self.filters.enabled, false, disabled); + }), + ); + + ui.end_row(); + }); + + ui.add_space(10.0); + + self.show_search_button(ui, worker, container, ctx); + }); + } + + /// Renders the search/refresh button that triggers a plugin list reload. + fn show_search_button( + &mut self, + ui: &mut Ui, + worker: &mut AsyncWorker, + container: &Arc, + ctx: &egui::Context, + ) { + ui.with_layout(Layout::right_to_left(egui::Align::TOP), |ui| { + if ui + .button(format!("{} Search", icons::MAGNIFYING_GLASS)) + .clicked() + { + self.spawn_fetch_plugins(worker, container, ctx); + } + }); + } + + /// Renders a single plugin card with details and action buttons. + fn show_plugin_card( + &self, + ui: &mut Ui, + worker: &mut AsyncWorker, + container: &Arc, + ctx: &egui::Context, + config: &PluginConfiguration, + path: &Path, + ) { + let (state_color, state_icon) = match config.enabled { + true => (Color32::from_rgb(100, 200, 100), icons::PLUGS_CONNECTED), + false => (Color32::from_rgb(255, 165, 0), icons::PLUGS), + }; + + let border_color = ui.visuals().widgets.noninteractive.bg_stroke.color; + + let frame = egui::Frame::NONE + .fill(ui.visuals().faint_bg_color) + .corner_radius(4.0) + .stroke(Stroke::new(1.0, border_color)) + .inner_margin(Margin::same(10)); + + frame.show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(state_icon).size(18.0).color(state_color)); + ui.label(RichText::new(&config.name).strong().size(15.0)); + ui.label(RichText::new("|").color(Color32::DARK_GRAY)); + + ui.label( + RichText::new(self.filters.scope.to_string()) + .size(12.0) + .color(Color32::GRAY), + ); + ui.label(RichText::new("|").color(Color32::DARK_GRAY)); + + let event_color = match config.event { + EventType::PreExecute => Color32::from_rgb(255, 165, 0), + EventType::PostExecute => Color32::from_rgb(100, 149, 237), + }; + ui.label( + RichText::new(format!("{}", config.event)) + .color(event_color) + .strong(), + ); + + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + let (button_text, button_icon, button_color) = if config.enabled { + ("Disable", icons::POWER, ui.visuals().error_fg_color) + } else { + ("Enable", icons::POWER, Color32::GREEN) + }; + + if ui + .add( + egui::Button::new( + RichText::new(format!("{button_icon} {button_text}")) + .color(button_color), + ) + .small(), + ) + .clicked() + { + self.spawn_edit_plugin( + worker, + container, + ctx, + PluginEditParams { + name: config.name.clone(), + command: self.filters.command.clone(), + scope: self.filters.scope.clone(), + path: path.to_path_buf(), + enabled: Some(!config.enabled), + }, + ); + } + + if ui + .add(egui::Button::new(format!("{} Edit", icons::PENCIL_SIMPLE)).small()) + .clicked() + { + self.spawn_edit_plugin( + worker, + container, + ctx, + PluginEditParams { + name: config.name.clone(), + command: self.filters.command.clone(), + scope: self.filters.scope.clone(), + path: path.to_path_buf(), + enabled: None, + }, + ); + } + + ui.add_space(10.0); + }); + }); + + if let Some(description) = &config.description { + ui.add_space(5.0); + ui.label(RichText::new(description).weak()); + } + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 20.0; + + let render_prop = |ui: &mut Ui, label: &str, value: &str| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + ui.label(RichText::new(format!("{label}:")).size(12.0).weak()); + ui.label( + RichText::new(value) + .size(12.0) + .monospace() + .color(ui.visuals().text_color()), + ); + }); + }; + + render_prop(ui, "Order", &config.order.to_string()); + + ui.label(RichText::new("·").weak().strong()); + + let interp_text = config.interpreter.as_deref().unwrap_or("default"); + render_prop(ui, "Interpreter", interp_text); + + ui.label(RichText::new("·").weak().strong()); + + let timeout_text = if let Some(timeout) = config.timeout { + format!("{timeout} ms") + } else { + "None".to_string() + }; + render_prop(ui, "Timeout", &timeout_text); + }); + }); + } +} diff --git a/plugins/src/enums/command_type.rs b/plugins/src/enums/command_type.rs index 1e29ed0..049286f 100644 --- a/plugins/src/enums/command_type.rs +++ b/plugins/src/enums/command_type.rs @@ -1,15 +1,26 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; -use strum_macros::{Display, EnumString, VariantNames}; +use strum_macros::{Display, EnumIter, EnumString, VariantNames}; /// Defines the command types that plugins can be registered to. #[derive( - Debug, Deserialize, Serialize, Clone, EnumString, VariantNames, Display, Eq, PartialEq, + Debug, + Default, + Deserialize, + Serialize, + Clone, + EnumString, + VariantNames, + Display, + Eq, + PartialEq, + EnumIter, )] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum CommandType { + #[default] Init, ConfigGet, ConfigSet, diff --git a/plugins/src/enums/event_type.rs b/plugins/src/enums/event_type.rs index 201b3ae..56ec386 100644 --- a/plugins/src/enums/event_type.rs +++ b/plugins/src/enums/event_type.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use strum_macros::{Display, EnumString, VariantNames}; +use strum_macros::{Display, EnumIter, EnumString, VariantNames}; /// Represents the types of events that plugins can be registered to. /// @@ -13,7 +13,17 @@ use strum_macros::{Display, EnumString, VariantNames}; /// - Triggered after a command has been executed, /// - Useful for cleanup, logging, or post-processing. #[derive( - Debug, Deserialize, Serialize, Clone, PartialEq, Eq, EnumString, VariantNames, Display, + Debug, + Default, + Deserialize, + Serialize, + Clone, + PartialEq, + Eq, + EnumString, + VariantNames, + EnumIter, + Display, )] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] @@ -22,5 +32,6 @@ pub enum EventType { PreExecute, /// Event fired after successful command execution. + #[default] PostExecute, } diff --git a/plugins/src/enums/scope_type.rs b/plugins/src/enums/scope_type.rs index b1a336c..5d6c720 100644 --- a/plugins/src/enums/scope_type.rs +++ b/plugins/src/enums/scope_type.rs @@ -1,9 +1,19 @@ use serde::{Deserialize, Serialize}; -use strum_macros::{Display, EnumString, VariantNames}; +use strum_macros::{Display, EnumIter, EnumString, VariantNames}; /// Defines the scope of plugins. #[derive( - Debug, Deserialize, Serialize, Clone, PartialEq, Eq, EnumString, VariantNames, Display, + Debug, + Default, + Deserialize, + Serialize, + Clone, + PartialEq, + Eq, + EnumString, + VariantNames, + EnumIter, + Display, )] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] @@ -12,5 +22,6 @@ pub enum ScopeType { Local, /// A plugin that is shared across all repositories. + #[default] Global, } diff --git a/server/Cargo.toml b/server/Cargo.toml index 0ff9bf8..a9db54c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2024" authors.workspace = true +[[bin]] +name = "meva-server" +path = "src/main.rs" + [dependencies] engine = { path = "../engine" } russh.workspace = true diff --git a/shared/src/extensions/path_to_string.rs b/shared/src/extensions/path_to_string.rs index c94dc11..83629b4 100644 --- a/shared/src/extensions/path_to_string.rs +++ b/shared/src/extensions/path_to_string.rs @@ -13,6 +13,12 @@ pub trait PathToString { /// Any non-UTF-8 sequences will be replaced with the Unicode /// replacement character (`�`). fn to_utf8_string(&self) -> String; + + /// Converts this path into a GUI-friendly string. + /// + /// If the path exceeds a certain length, it will be truncated from the left, + /// adding "..." to ensure it fits well within UI constraints. + fn to_gui_string(&self) -> String; } impl PathToString for T @@ -22,4 +28,18 @@ where fn to_utf8_string(&self) -> String { self.as_ref().to_string_lossy().into_owned() } + + fn to_gui_string(&self) -> String { + let path_text = self.to_utf8_string(); + + let max_len = 25; + let char_count = path_text.chars().count(); + if char_count > max_len { + let keep_len = max_len - 3; + let truncated: String = path_text.chars().skip(char_count - keep_len).collect(); + format!("...{truncated}") + } else { + path_text + } + } } From bd2eba33575c0984c38e5a57da524ba0589ef1b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Tue, 30 Dec 2025 11:53:16 +0100 Subject: [PATCH 31/42] Missing global config, issue #25 (#49) --- cli/src/commands/add.rs | 6 +- cli/src/commands/branch.rs | 6 +- cli/src/commands/clone.rs | 6 +- cli/src/commands/commit.rs | 6 +- cli/src/commands/config.rs | 11 +-- cli/src/commands/config/subcommands.rs | 2 + cli/src/commands/config/subcommands/create.rs | 70 +++++++++++++++++++ cli/src/commands/config/subcommands/edit.rs | 11 +-- cli/src/commands/config/subcommands/get.rs | 9 +-- cli/src/commands/config/subcommands/list.rs | 11 +-- cli/src/commands/config/subcommands/set.rs | 9 +-- cli/src/commands/config/subcommands/unset.rs | 11 +-- cli/src/commands/diff.rs | 8 +-- cli/src/commands/fetch.rs | 8 +-- cli/src/commands/ignore.rs | 10 +-- cli/src/commands/ignore/subcommands/add.rs | 11 +-- cli/src/commands/ignore/subcommands/check.rs | 9 +-- cli/src/commands/ignore/subcommands/edit.rs | 11 +-- cli/src/commands/ignore/subcommands/remove.rs | 11 +-- cli/src/commands/init.rs | 14 ++-- cli/src/commands/log.rs | 6 +- cli/src/commands/ls_files.rs | 10 +-- cli/src/commands/ls_tree.rs | 6 +- cli/src/commands/plugins.rs | 10 +-- cli/src/commands/plugins/subcommands/edit.rs | 8 +-- cli/src/commands/plugins/subcommands/info.rs | 10 +-- cli/src/commands/plugins/subcommands/list.rs | 8 +-- .../commands/plugins/subcommands/register.rs | 8 +-- .../plugins/subcommands/unregister.rs | 10 +-- cli/src/commands/remote.rs | 11 +-- cli/src/commands/remote/subcommands/add.rs | 9 +-- .../commands/remote/subcommands/get_url.rs | 9 +-- cli/src/commands/remote/subcommands/remove.rs | 11 +-- cli/src/commands/remote/subcommands/rename.rs | 9 +-- .../commands/remote/subcommands/set_url.rs | 9 +-- cli/src/commands/remote/subcommands/show.rs | 9 +-- cli/src/commands/restore.rs | 6 +- cli/src/commands/show.rs | 10 +-- cli/src/commands/status.rs | 10 +-- cli/src/main.rs | 34 ++++----- engine/src/config/config_loader.rs | 7 +- engine/src/engine_container.rs | 3 +- engine/src/handlers/config/handlers.rs | 28 ++++++-- engine/src/handlers/config/operations.rs | 11 ++- engine/src/repositories/meva_repository.rs | 2 +- 45 files changed, 202 insertions(+), 292 deletions(-) create mode 100644 cli/src/commands/config/subcommands/create.rs diff --git a/cli/src/commands/add.rs b/cli/src/commands/add.rs index 9fedeaf..45f0cb3 100644 --- a/cli/src/commands/add.rs +++ b/cli/src/commands/add.rs @@ -22,14 +22,10 @@ use engine::handlers::add::AddRequest; /// - **`--update, -u`**: Add modified and deleted files only. Conflicts with `--all`. /// - **`--dry-run, -n`**: Show which files *would* be added, without actually staging them. /// - **`--verbose, -v`**: Increase verbosity of output. +#[derive(Default)] pub struct AddCommand; impl AddCommand { - /// Creates a new instance of the `AddCommand`. - pub fn new() -> Self { - Self - } - /// Path argument key. const ARG_PATH: &'static str = "path"; diff --git a/cli/src/commands/branch.rs b/cli/src/commands/branch.rs index bdd840d..640cce5 100644 --- a/cli/src/commands/branch.rs +++ b/cli/src/commands/branch.rs @@ -13,14 +13,10 @@ use miette::{IntoDiagnostic, Result}; /// Allows listing, creating, deleting, and renaming branches within the repository. /// It handles argument parsing to dispatch the appropriate request (Create, Delete, /// Rename, or List) to the [`BranchHandler`]. +#[derive(Default)] pub struct BranchCommand; impl BranchCommand { - /// Creates a new instance of the [`BranchCommand`]. - pub fn new() -> Self { - Self - } - /// First positional argument: branch name (creation/deletion) or old branch name (renaming). const ARG_NAME: &'static str = "branch-name"; diff --git a/cli/src/commands/clone.rs b/cli/src/commands/clone.rs index 1da87ee..7991bfd 100644 --- a/cli/src/commands/clone.rs +++ b/cli/src/commands/clone.rs @@ -11,14 +11,10 @@ use url::Url; /// Clones a remote repository into a new directory, creates a /// tracking connection to the remote repository (origin), and checks out /// the default branch. +#[derive(Default)] pub struct CloneCommand; impl CloneCommand { - /// Creates a new instance of the [`CloneCommand`]. - pub fn new() -> Self { - Self {} - } - /// Argument name for specifying the remote repository URL. const ARG_REPOSITORY: &'static str = "repository"; diff --git a/cli/src/commands/commit.rs b/cli/src/commands/commit.rs index 38d7f31..eaa1069 100644 --- a/cli/src/commands/commit.rs +++ b/cli/src/commands/commit.rs @@ -15,14 +15,10 @@ use owo_colors::OwoColorize; /// along with a user-supplied message and optional metadata (author, date, etc.). /// Supports automatically staging changes, amending the last commit, or performing a dry run /// to preview the commit without persisting it. +#[derive(Default)] pub struct CommitCommand; impl CommitCommand { - /// Creates a new instance of the `CommitCommand`. - pub fn new() -> Self { - Self - } - /// Argument key for specifying the commit message. /// Corresponds to the `-m, --message ` option. const ARG_MESSAGE: &'static str = "message"; diff --git a/cli/src/commands/config.rs b/cli/src/commands/config.rs index b6c2021..63da940 100644 --- a/cli/src/commands/config.rs +++ b/cli/src/commands/config.rs @@ -12,15 +12,9 @@ use subcommands::*; /// /// Serves as a namespace for configuration-related subcommands: /// list, get, set, unset, and edit settings at global/local/file scopes. +#[derive(Default)] pub struct ConfigCommand; -impl ConfigCommand { - /// Creates a new instance of the `ConfigCommand`. - pub fn new() -> Self { - Self - } -} - #[async_trait] impl MevaCommand for ConfigCommand { type Container = MevaContainer; @@ -47,6 +41,7 @@ impl MevaCommand for ConfigCommand { Box::new(ConfigSetCommand), Box::new(ConfigUnsetCommand), Box::new(ConfigEditCommand), + Box::new(ConfigCreateCommand), ] } @@ -63,7 +58,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = ConfigCommand::new(); + let cmd = ConfigCommand; assert_eq!(cmd.name(), "config"); assert_eq!( cmd.about(), diff --git a/cli/src/commands/config/subcommands.rs b/cli/src/commands/config/subcommands.rs index 998cd35..18411ee 100644 --- a/cli/src/commands/config/subcommands.rs +++ b/cli/src/commands/config/subcommands.rs @@ -1,9 +1,11 @@ +pub mod create; pub mod edit; pub mod get; pub mod list; pub mod set; pub mod unset; +pub use create::ConfigCreateCommand; pub use edit::ConfigEditCommand; pub use get::ConfigGetCommand; pub use list::ConfigListCommand; diff --git a/cli/src/commands/config/subcommands/create.rs b/cli/src/commands/config/subcommands/create.rs new file mode 100644 index 0000000..ffe397a --- /dev/null +++ b/cli/src/commands/config/subcommands/create.rs @@ -0,0 +1,70 @@ +use std::path::PathBuf; + +use async_trait::async_trait; +use clap::{ArgMatches, Command}; +use engine::EngineContainer; +use engine::engine_container::MevaContainer; +use engine::handlers::config::CreateRequest; +use miette::IntoDiagnostic; + +use crate::commands::MevaCommand; +use crate::extensions::WithFile; + +/// Implements the `create` subcommand for Meva configuration management. +/// +/// Creates a new configuration file at the specified path or the default global location. +#[derive(Default)] +pub struct ConfigCreateCommand; + +#[async_trait] +impl MevaCommand for ConfigCreateCommand { + type Container = MevaContainer; + + fn name(&self) -> &'static str { + "create" + } + + fn about(&self) -> &'static str { + "Create a new configuration file" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .with_file_arg("Path to the configuration file to create") + } + + async fn execute( + &self, + matches: &ArgMatches, + container: &Self::Container, + ) -> miette::Result<()> { + let file = matches.get_one::(Command::ARG_FILE); + + let request = CreateRequest { + file: file.cloned(), + }; + + let config_handler = container.config_handler().into_diagnostic()?; + config_handler.handle_create(request).into_diagnostic()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = ConfigCreateCommand; + assert_eq!(cmd.name(), "create"); + assert_eq!(cmd.about(), "Create a new configuration file"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/config/subcommands/edit.rs b/cli/src/commands/config/subcommands/edit.rs index 72ee690..d8f00da 100644 --- a/cli/src/commands/config/subcommands/edit.rs +++ b/cli/src/commands/config/subcommands/edit.rs @@ -12,16 +12,9 @@ use crate::extensions::{LocationSelection, WithLocations}; /// /// Opens the chosen configuration file in the user's preferred editor, /// respecting any `core.editor` override in config or falling back to OS defaults. +#[derive(Default)] pub struct ConfigEditCommand; -impl ConfigEditCommand { - /// Creates a new instance of the `ConfigGetCommand`. - #[allow(dead_code)] - pub fn new() -> Self { - Self - } -} - #[async_trait] impl MevaCommand for ConfigEditCommand { type Container = MevaContainer; @@ -75,7 +68,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = ConfigEditCommand::new(); + let cmd = ConfigEditCommand; assert_eq!(cmd.name(), "edit"); assert_eq!(cmd.about(), "Open the config file in your default editor"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/config/subcommands/get.rs b/cli/src/commands/config/subcommands/get.rs index d3e0b9d..1ab693e 100644 --- a/cli/src/commands/config/subcommands/get.rs +++ b/cli/src/commands/config/subcommands/get.rs @@ -8,6 +8,7 @@ use miette::IntoDiagnostic; use crate::commands::MevaCommand; use crate::extensions::{LocationSelection, WithLocations}; +#[derive(Default)] pub struct ConfigGetCommand; /// Implements the `get` subcommand for Meva configuration management. @@ -15,12 +16,6 @@ pub struct ConfigGetCommand; /// Retrieves the value of a specified TOML key from the chosen /// configuration scope, with an optional default fallback if the key is missing. impl ConfigGetCommand { - /// Creates a new instance of the `ConfigGetCommand`. - #[allow(dead_code)] - pub fn new() -> Self { - Self - } - const ARG_KEY: &'static str = "key"; const ARG_DEFAULT: &'static str = "default"; @@ -101,7 +96,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = ConfigGetCommand::new(); + let cmd = ConfigGetCommand; assert_eq!(cmd.name(), "get"); assert_eq!(cmd.about(), "Get the value for a given configuration key"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/config/subcommands/list.rs b/cli/src/commands/config/subcommands/list.rs index 6f571e4..717ad0e 100644 --- a/cli/src/commands/config/subcommands/list.rs +++ b/cli/src/commands/config/subcommands/list.rs @@ -13,16 +13,9 @@ use crate::extensions::{LocationSelection, WithLocations}; /// /// Retrieves and displays all key/value pairs from the selected /// configuration scope. +#[derive(Default)] pub struct ConfigListCommand; -impl ConfigListCommand { - /// Creates a new instance of the `ConfigListCommand`. - #[allow(dead_code)] - pub fn new() -> Self { - Self - } -} - #[async_trait] impl MevaCommand for ConfigListCommand { type Container = MevaContainer; @@ -95,7 +88,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = ConfigListCommand::new(); + let cmd = ConfigListCommand; assert_eq!(cmd.name(), "list"); assert_eq!(cmd.about(), "Print all available configuration entries"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/config/subcommands/set.rs b/cli/src/commands/config/subcommands/set.rs index 09eda3e..21ee442 100644 --- a/cli/src/commands/config/subcommands/set.rs +++ b/cli/src/commands/config/subcommands/set.rs @@ -13,15 +13,10 @@ use crate::extensions::{LocationSelection, WithKey, WithLocations}; /// /// Adds or updates a specified TOML key in the chosen configuration scope /// with a provided value. +#[derive(Default)] pub struct ConfigSetCommand; impl ConfigSetCommand { - /// Creates a new instance of the `ConfigSetCommand`. - #[allow(dead_code)] - pub fn new() -> Self { - Self - } - const ARG_VALUE: &'static str = "value"; } @@ -99,7 +94,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = ConfigSetCommand::new(); + let cmd = ConfigSetCommand; assert_eq!(cmd.name(), "set"); assert_eq!(cmd.about(), "Set the value for a given configuration key"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/config/subcommands/unset.rs b/cli/src/commands/config/subcommands/unset.rs index 1669166..76aa1bc 100644 --- a/cli/src/commands/config/subcommands/unset.rs +++ b/cli/src/commands/config/subcommands/unset.rs @@ -11,16 +11,9 @@ use crate::extensions::{LocationSelection, WithKey, WithLocations}; /// Implements the `unset` subcommand for Meva configuration management. /// /// Removes a specified TOML key from the chosen configuration scope. +#[derive(Default)] pub struct ConfigUnsetCommand; -impl ConfigUnsetCommand { - /// Creates a new instance of the `ConfigUnsetCommand`. - #[allow(dead_code)] - pub fn new() -> Self { - Self - } -} - #[async_trait] impl MevaCommand for ConfigUnsetCommand { type Container = MevaContainer; @@ -81,7 +74,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = ConfigUnsetCommand::new(); + let cmd = ConfigUnsetCommand; assert_eq!(cmd.name(), "unset"); assert_eq!(cmd.about(), "Remove a configuration entry"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/diff.rs b/cli/src/commands/diff.rs index 4b43501..91288fa 100644 --- a/cli/src/commands/diff.rs +++ b/cli/src/commands/diff.rs @@ -18,14 +18,10 @@ use crate::commands::MevaCommand; /// Implements the `diff` command for Meva DVCS. /// /// Shows changes between commits, the index, and the working tree. +#[derive(Default)] pub struct DiffCommand; impl DiffCommand { - /// Creates a new instance of the `DiffCommand`. - pub fn new() -> Self { - Self {} - } - /// Argument name for the `--cached` flag. /// When set, the command compares the index to the specified commit (default: HEAD). const ARG_CACHED: &'static str = "cached"; @@ -185,7 +181,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = DiffCommand::new(); + let cmd = DiffCommand; assert_eq!(cmd.name(), "diff"); assert_eq!( cmd.about(), diff --git a/cli/src/commands/fetch.rs b/cli/src/commands/fetch.rs index 1a531cf..f7f7bbc 100644 --- a/cli/src/commands/fetch.rs +++ b/cli/src/commands/fetch.rs @@ -11,14 +11,10 @@ use crate::{commands::MevaCommand, extensions::WithVerbose}; /// This command is responsible for downloading objects and references from a remote /// repository. It serves as the entry point for the fetch logic, handling argument /// parsing and delegating the execution to the appropriate engine handler. +#[derive(Default)] pub struct FetchCommand; impl FetchCommand { - /// Creates a new instance of the [`FetchCommand`]. - pub fn new() -> Self { - Self {} - } - /// The name of the argument used to specify the prune flag. const ARG_PRUNE: &'static str = "prune"; @@ -106,7 +102,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = FetchCommand::new(); + let cmd = FetchCommand; assert_eq!(cmd.name(), "fetch"); assert_eq!( cmd.about(), diff --git a/cli/src/commands/ignore.rs b/cli/src/commands/ignore.rs index de78b4a..0554c37 100644 --- a/cli/src/commands/ignore.rs +++ b/cli/src/commands/ignore.rs @@ -12,15 +12,9 @@ use subcommands::*; /// /// Serves as a namespace for subcommands managing ignore patterns: /// add, remove, check, and edit ignore rules for files and directories. +#[derive(Default)] pub struct IgnoreCommand; -impl IgnoreCommand { - /// Creates a new instance of the `IgnoreCommand`. - pub fn new() -> Self { - Self - } -} - #[async_trait] impl MevaCommand for IgnoreCommand { type Container = MevaContainer; @@ -62,7 +56,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = IgnoreCommand::new(); + let cmd = IgnoreCommand; assert_eq!(cmd.name(), "ignore"); assert_eq!( cmd.about(), diff --git a/cli/src/commands/ignore/subcommands/add.rs b/cli/src/commands/ignore/subcommands/add.rs index eee0132..e327891 100644 --- a/cli/src/commands/ignore/subcommands/add.rs +++ b/cli/src/commands/ignore/subcommands/add.rs @@ -18,16 +18,9 @@ use crate::{ /// /// Adds the specified pattern to the chosen ignore file, /// ensuring the ignore rule is appended without duplicates or formatting errors. +#[derive(Default)] pub struct IgnoreAddCommand; -impl IgnoreAddCommand { - /// Creates a new instance of the `IgnoreAddCommand`. - #[allow(dead_code)] - pub fn new() -> Self { - Self - } -} - #[async_trait] impl MevaCommand for IgnoreAddCommand { type Container = MevaContainer; @@ -81,7 +74,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = IgnoreAddCommand::new(); + let cmd = IgnoreAddCommand; assert_eq!(cmd.name(), "add"); assert_eq!(cmd.about(), "Appends a pattern to the ignore file"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/ignore/subcommands/check.rs b/cli/src/commands/ignore/subcommands/check.rs index e36ad51..542b544 100644 --- a/cli/src/commands/ignore/subcommands/check.rs +++ b/cli/src/commands/ignore/subcommands/check.rs @@ -14,15 +14,10 @@ use crate::{commands::MevaCommand, extensions::WithFile}; /// /// Checks whether the specified path is ignored according to the rules of the chosen ignore file, /// optionally providing an explanation of which patterns matched. +#[derive(Default)] pub struct IgnoreCheckCommand; impl IgnoreCheckCommand { - /// Creates a new instance of the `IgnoreCheckCommand`. - #[allow(dead_code)] - pub fn new() -> Self { - Self - } - const ARG_PATH: &'static str = "path"; const ARG_EXPLAIN: &'static str = "explain"; @@ -123,7 +118,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = IgnoreCheckCommand::new(); + let cmd = IgnoreCheckCommand; assert_eq!(cmd.name(), "check"); assert_eq!( cmd.about(), diff --git a/cli/src/commands/ignore/subcommands/edit.rs b/cli/src/commands/ignore/subcommands/edit.rs index 33207e9..350c896 100644 --- a/cli/src/commands/ignore/subcommands/edit.rs +++ b/cli/src/commands/ignore/subcommands/edit.rs @@ -15,16 +15,9 @@ use crate::{commands::MevaCommand, extensions::WithFile}; /// /// Opens the chosen configuration file in the user's preferred editor, /// respecting any `core.editor` override in config or falling back to OS defaults. +#[derive(Default)] pub struct IgnoreEditCommand; -impl IgnoreEditCommand { - /// Creates a new instance of the `IgnoreEditCommand`. - #[allow(dead_code)] - pub fn new() -> Self { - Self - } -} - #[async_trait] impl MevaCommand for IgnoreEditCommand { type Container = MevaContainer; @@ -78,7 +71,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = IgnoreEditCommand::new(); + let cmd = IgnoreEditCommand; assert_eq!(cmd.name(), "edit"); assert_eq!(cmd.about(), "Open the ignore file in your default editor"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/ignore/subcommands/remove.rs b/cli/src/commands/ignore/subcommands/remove.rs index 562d583..f0b8d40 100644 --- a/cli/src/commands/ignore/subcommands/remove.rs +++ b/cli/src/commands/ignore/subcommands/remove.rs @@ -18,16 +18,9 @@ use crate::{ /// /// Removes the specified pattern from the chosen ignore file, /// reporting how many lines were deleted or if the pattern was not present. +#[derive(Default)] pub struct IgnoreRemoveCommand; -impl IgnoreRemoveCommand { - /// Creates a new instance of the `IgnoreRemoveCommand`. - #[allow(dead_code)] - pub fn new() -> Self { - Self - } -} - #[async_trait] impl MevaCommand for IgnoreRemoveCommand { type Container = MevaContainer; @@ -93,7 +86,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = IgnoreRemoveCommand::new(); + let cmd = IgnoreRemoveCommand; assert_eq!(cmd.name(), "remove"); assert_eq!(cmd.about(), "Remove a pattern from the ignore file"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/init.rs b/cli/src/commands/init.rs index b50194b..53ad4cb 100644 --- a/cli/src/commands/init.rs +++ b/cli/src/commands/init.rs @@ -11,14 +11,10 @@ use crate::commands::MevaCommand; /// /// Initializes a new repository at a specified path, /// optionally setting the initial branch name. -pub struct InitCommand {} +#[derive(Default)] +pub struct InitCommand; impl InitCommand { - /// Creates a new instance of the `InitCommand`. - pub fn new() -> Self { - Self {} - } - /// Argument name for specifying the initial branch. const ARG_INITIAL_BRANCH: &'static str = "initial-branch"; @@ -115,13 +111,13 @@ mod tests { use std::path::PathBuf; fn get_matches_from(args: &[&str]) -> ArgMatches { - let cmd = InitCommand::new(); + let cmd = InitCommand; cmd.build_command().try_get_matches_from(args).unwrap() } #[rstest] fn test_command_name_about_version() { - let cmd = InitCommand::new(); + let cmd = InitCommand; assert_eq!(cmd.name(), "init"); assert_eq!(cmd.about(), "Create an empty Meva repository"); assert_eq!(cmd.version(), "1.0.0"); @@ -129,7 +125,7 @@ mod tests { #[rstest] fn test_command_builds_with_expected_args() { - let cmd = InitCommand::new(); + let cmd = InitCommand; let clap_cmd = cmd.build_command(); // Check command name diff --git a/cli/src/commands/log.rs b/cli/src/commands/log.rs index faa81ee..9f471ee 100644 --- a/cli/src/commands/log.rs +++ b/cli/src/commands/log.rs @@ -16,14 +16,10 @@ use regex::Regex; /// messages, and optional change statistics. /// Supports flexible output formatting and filtering options, such as compact one-line view, /// commit range limits, time-based filters, and pattern matching on commit messages. +#[derive(Default)] pub struct LogCommand; impl LogCommand { - /// Creates a new instance of the `LogCommand`. - pub fn new() -> Self { - Self {} - } - /// Flag for displaying commits in a condensed, one-line format. const ARG_ONELINE: &'static str = "oneline"; diff --git a/cli/src/commands/ls_files.rs b/cli/src/commands/ls_files.rs index a4aff6b..4548cf0 100644 --- a/cli/src/commands/ls_files.rs +++ b/cli/src/commands/ls_files.rs @@ -12,14 +12,10 @@ use crate::commands::MevaCommand; /// Implements the `ls-files` command for Meva DVCS. /// /// Displays information about files in the index and working directory. -pub struct LsFilesCommand {} +#[derive(Default)] +pub struct LsFilesCommand; impl LsFilesCommand { - /// Creates a new instance of the `LsFilesCommand`. - pub fn new() -> Self { - Self {} - } - /// Argument name for `--cached` flag. /// When provided, shows only files that are tracked (present in the index). const ARG_CACHED: &'static str = "cached"; @@ -157,7 +153,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = LsFilesCommand::new(); + let cmd = LsFilesCommand; assert_eq!(cmd.name(), "ls-files"); assert_eq!( cmd.about(), diff --git a/cli/src/commands/ls_tree.rs b/cli/src/commands/ls_tree.rs index 33a52b7..ab7cc05 100644 --- a/cli/src/commands/ls_tree.rs +++ b/cli/src/commands/ls_tree.rs @@ -19,14 +19,10 @@ use std::path::PathBuf; /// - **name-only view (`--name-only`)** – prints only filenames and directories. /// /// Can be used to inspect historical snapshots, branches, or arbitrary commit hashes. +#[derive(Default)] pub struct LsTreeCommand; impl LsTreeCommand { - /// Creates a new instance of the `LsTreeCommand`. - pub fn new() -> Self { - Self {} - } - /// Argument key for specifying the commit, tag, or branch to inspect. /// Corresponds to the `` argument (e.g., `HEAD`, branch name, or hash). const ARG_SNAPSHOT_ID: &'static str = "snapshot_id"; diff --git a/cli/src/commands/plugins.rs b/cli/src/commands/plugins.rs index 6266217..f6f3561 100644 --- a/cli/src/commands/plugins.rs +++ b/cli/src/commands/plugins.rs @@ -13,15 +13,9 @@ use subcommands::*; /// The `plugins` command serves as a namespace for all plugin-related operations /// such as listing, editing, registering, unregistering, or retrieving information /// about plugins. +#[derive(Default)] pub struct PluginsCommand; -impl PluginsCommand { - /// Creates a new instance of the `PluginsCommand`. - pub fn new() -> Self { - Self - } -} - #[async_trait] impl MevaCommand for PluginsCommand { type Container = MevaContainer; @@ -64,7 +58,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = PluginsCommand::new(); + let cmd = PluginsCommand; assert_eq!(cmd.name(), "plugins"); assert_eq!(cmd.about(), "Manage plugins"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/plugins/subcommands/edit.rs b/cli/src/commands/plugins/subcommands/edit.rs index 47d89d2..7818f3e 100644 --- a/cli/src/commands/plugins/subcommands/edit.rs +++ b/cli/src/commands/plugins/subcommands/edit.rs @@ -14,14 +14,10 @@ use crate::{ extensions::{WithCommandPlugin, WithScope}, }; +#[derive(Default)] pub struct PluginsEditCommand; impl PluginsEditCommand { - #[allow(dead_code)] - pub fn new() -> Self { - Self - } - const ARG_ENABLE: &'static str = "enable"; const ARG_DISABLE: &'static str = "disable"; @@ -119,7 +115,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = PluginsEditCommand::new(); + let cmd = PluginsEditCommand; assert_eq!(cmd.name(), "edit"); assert_eq!( cmd.about(), diff --git a/cli/src/commands/plugins/subcommands/info.rs b/cli/src/commands/plugins/subcommands/info.rs index ae52440..4720af8 100644 --- a/cli/src/commands/plugins/subcommands/info.rs +++ b/cli/src/commands/plugins/subcommands/info.rs @@ -13,15 +13,9 @@ use crate::{ extensions::{WithCommandPlugin, WithScope}, }; +#[derive(Default)] pub struct PluginsInfoCommand; -impl PluginsInfoCommand { - #[allow(dead_code)] - pub fn new() -> Self { - Self - } -} - #[async_trait] impl MevaCommand for PluginsInfoCommand { type Container = MevaContainer; @@ -82,7 +76,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = PluginsInfoCommand::new(); + let cmd = PluginsInfoCommand; assert_eq!(cmd.name(), "info"); assert_eq!( cmd.about(), diff --git a/cli/src/commands/plugins/subcommands/list.rs b/cli/src/commands/plugins/subcommands/list.rs index 6cb2648..b7b0fa3 100644 --- a/cli/src/commands/plugins/subcommands/list.rs +++ b/cli/src/commands/plugins/subcommands/list.rs @@ -10,14 +10,10 @@ use plugins::{CommandType, EventType, ScopeType}; use crate::{commands::MevaCommand, extensions::WithScope}; +#[derive(Default)] pub struct PluginsListCommand; impl PluginsListCommand { - #[allow(dead_code)] - pub fn new() -> Self { - Self - } - const ARG_COMMAND: &'static str = "command"; const ARG_EVENT: &'static str = "event"; @@ -120,7 +116,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = PluginsListCommand::new(); + let cmd = PluginsListCommand; assert_eq!(cmd.name(), "list"); assert_eq!(cmd.about(), "List registered plugins with optional filters"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/plugins/subcommands/register.rs b/cli/src/commands/plugins/subcommands/register.rs index 357a01c..6142831 100644 --- a/cli/src/commands/plugins/subcommands/register.rs +++ b/cli/src/commands/plugins/subcommands/register.rs @@ -17,14 +17,10 @@ use crate::{ extensions::{WithCommandPlugin, WithFile, WithScope}, }; +#[derive(Default)] pub struct PluginsRegisterCommand; impl PluginsRegisterCommand { - #[allow(dead_code)] - pub fn new() -> Self { - Self - } - const ARG_PATH: &'static str = "path"; const ARG_DESCRIPTION: &'static str = "description"; @@ -174,7 +170,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = PluginsRegisterCommand::new(); + let cmd = PluginsRegisterCommand; assert_eq!(cmd.name(), "register"); assert_eq!(cmd.about(), "Register a new plugin"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/plugins/subcommands/unregister.rs b/cli/src/commands/plugins/subcommands/unregister.rs index 4ebaa0a..82e0594 100644 --- a/cli/src/commands/plugins/subcommands/unregister.rs +++ b/cli/src/commands/plugins/subcommands/unregister.rs @@ -14,15 +14,9 @@ use crate::{ extensions::{WithCommandPlugin, WithScope}, }; +#[derive(Default)] pub struct PluginsUnregisterCommand; -impl PluginsUnregisterCommand { - #[allow(dead_code)] - pub fn new() -> Self { - Self - } -} - #[async_trait] impl MevaCommand for PluginsUnregisterCommand { type Container = MevaContainer; @@ -96,7 +90,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = PluginsUnregisterCommand::new(); + let cmd = PluginsUnregisterCommand; assert_eq!(cmd.name(), "unregister"); assert_eq!(cmd.about(), "Unregister a previously registered plugin"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/remote.rs b/cli/src/commands/remote.rs index f7f9699..2ed0f79 100644 --- a/cli/src/commands/remote.rs +++ b/cli/src/commands/remote.rs @@ -20,16 +20,9 @@ use crate::{ /// This command manages the set of tracked repositories ("remotes"). /// It acts as a parent command for operations like adding, removing, or renaming remotes. /// If no subcommand is provided, it defaults to listing the configured remotes. +#[derive(Default)] pub struct RemoteCommand; -impl RemoteCommand { - /// Creates a new instance of the [`RemoteCommand`]. - #[allow(dead_code)] - pub fn new() -> Self { - Self - } -} - #[async_trait] impl MevaCommand for RemoteCommand { type Container = MevaContainer; @@ -112,7 +105,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = RemoteCommand::new(); + let cmd = RemoteCommand; assert_eq!(cmd.name(), "remote"); assert_eq!(cmd.about(), "Manage remote repositories"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/remote/subcommands/add.rs b/cli/src/commands/remote/subcommands/add.rs index e98193b..1a4d5c3 100644 --- a/cli/src/commands/remote/subcommands/add.rs +++ b/cli/src/commands/remote/subcommands/add.rs @@ -16,15 +16,10 @@ use crate::{commands::MevaCommand, extensions::WithName}; /// /// This command registers a new remote repository with a specific name and URL /// in the local repository configuration. It optionally performs an immediate fetch. +#[derive(Default)] pub struct RemoteAddCommand; impl RemoteAddCommand { - /// Creates a new instance of the [`RemoteAddCommand`]. - #[allow(dead_code)] - pub fn new() -> Self { - Self - } - /// Argument name for the fetch flag. const ARG_FETCH: &'static str = "fetch"; @@ -119,7 +114,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = RemoteAddCommand::new(); + let cmd = RemoteAddCommand; assert_eq!(cmd.name(), "add"); assert_eq!(cmd.about(), "Add a new remote repository"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/remote/subcommands/get_url.rs b/cli/src/commands/remote/subcommands/get_url.rs index e845b21..0c570ce 100644 --- a/cli/src/commands/remote/subcommands/get_url.rs +++ b/cli/src/commands/remote/subcommands/get_url.rs @@ -15,15 +15,10 @@ use crate::{commands::MevaCommand, extensions::WithName}; /// /// This command retrieves and displays the URL associated with a tracked remote. /// It allows filtering based on whether the URL is used for fetching or pushing. +#[derive(Default)] pub struct RemoteGetUrlCommand; impl RemoteGetUrlCommand { - /// Creates a new instance of the [`RemoteGetUrlCommand`]. - #[allow(dead_code)] - pub fn new() -> Self { - Self - } - /// Argument name for the direction selection (fetch/push). const ARG_DIRECTION: &'static str = "direction"; } @@ -93,7 +88,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = RemoteGetUrlCommand::new(); + let cmd = RemoteGetUrlCommand; assert_eq!(cmd.name(), "get-url"); assert_eq!(cmd.about(), "Show the URL of a remote"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/remote/subcommands/remove.rs b/cli/src/commands/remote/subcommands/remove.rs index 1ae4950..61c0e23 100644 --- a/cli/src/commands/remote/subcommands/remove.rs +++ b/cli/src/commands/remote/subcommands/remove.rs @@ -13,16 +13,9 @@ use crate::{commands::MevaCommand, extensions::WithName}; /// /// This command removes a remote repository configuration from the local repository. /// Once removed, the repository will no longer track changes from that specific remote. +#[derive(Default)] pub struct RemoteRemoveCommand; -impl RemoteRemoveCommand { - /// Creates a new instance of the [`RemoteRemoveCommand`]. - #[allow(dead_code)] - pub fn new() -> Self { - Self - } -} - #[async_trait] impl MevaCommand for RemoteRemoveCommand { type Container = MevaContainer; @@ -77,7 +70,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = RemoteRemoveCommand::new(); + let cmd = RemoteRemoveCommand; assert_eq!(cmd.name(), "remove"); assert_eq!(cmd.about(), "Remove an existing remote"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/remote/subcommands/rename.rs b/cli/src/commands/remote/subcommands/rename.rs index 644495d..08b5aec 100644 --- a/cli/src/commands/remote/subcommands/rename.rs +++ b/cli/src/commands/remote/subcommands/rename.rs @@ -14,15 +14,10 @@ use crate::commands::MevaCommand; /// This command updates the identifier of an existing remote repository. /// It changes the name used to reference the remote in other commands /// (like fetch or push) without altering the remote URL itself. +#[derive(Default)] pub struct RemoteRenameCommand; impl RemoteRenameCommand { - /// Creates a new instance of the [`RemoteRenameCommand`]. - #[allow(dead_code)] - pub fn new() -> Self { - Self - } - /// Argument name for the new name of the remote. const ARG_NEW_NAME: &'static str = "new-name"; @@ -105,7 +100,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = RemoteRenameCommand::new(); + let cmd = RemoteRenameCommand; assert_eq!(cmd.name(), "rename"); assert_eq!(cmd.about(), "Rename a remote"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/remote/subcommands/set_url.rs b/cli/src/commands/remote/subcommands/set_url.rs index ae1606a..361c958 100644 --- a/cli/src/commands/remote/subcommands/set_url.rs +++ b/cli/src/commands/remote/subcommands/set_url.rs @@ -17,15 +17,10 @@ use crate::{commands::MevaCommand, extensions::WithName}; /// This command updates the URL for an existing remote repository. /// It supports changing either the fetch URL (default) or the push URL, /// and can selectively replace a specific URL if the remote has multiple. +#[derive(Default)] pub struct RemoteSetUrlCommand; impl RemoteSetUrlCommand { - /// Creates a new instance of the [`RemoteSetUrlCommand`]. - #[allow(dead_code)] - pub fn new() -> Self { - Self - } - /// Argument name for the direction selection (fetch/push). const ARG_DIRECTION: &'static str = "direction"; @@ -108,7 +103,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = RemoteSetUrlCommand::new(); + let cmd = RemoteSetUrlCommand; assert_eq!(cmd.name(), "set-url"); assert_eq!(cmd.about(), "Set the URL of a remote"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/remote/subcommands/show.rs b/cli/src/commands/remote/subcommands/show.rs index 67e0879..cc7e8cc 100644 --- a/cli/src/commands/remote/subcommands/show.rs +++ b/cli/src/commands/remote/subcommands/show.rs @@ -15,15 +15,10 @@ use crate::{commands::MevaCommand, extensions::WithName}; /// This command displays detailed information about a specific remote repository, /// such as its URLs and the status of tracked branches. It can optionally retrieve /// live data from the remote or rely solely on local configuration. +#[derive(Default)] pub struct RemoteShowCommand; impl RemoteShowCommand { - /// Creates a new instance of the [`RemoteShowCommand`]. - #[allow(dead_code)] - pub fn new() -> Self { - Self - } - /// Argument name for the no-fetch flag. const ARG_NO_FETCH: &'static str = "no-fetch"; } @@ -92,7 +87,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = RemoteShowCommand::new(); + let cmd = RemoteShowCommand; assert_eq!(cmd.name(), "show"); assert_eq!(cmd.about(), "Show the URL of a remote"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/restore.rs b/cli/src/commands/restore.rs index 0ddffd7..bec2131 100644 --- a/cli/src/commands/restore.rs +++ b/cli/src/commands/restore.rs @@ -19,14 +19,10 @@ use std::path::PathBuf; /// Defaults to `HEAD` if not provided. /// - **``**: Optional list of file or directory paths to restore. /// If omitted, the entire repository is restored. +#[derive(Default)] pub struct RestoreCommand; impl RestoreCommand { - /// Creates a new instance of the [RestoreCommand]. - pub fn new() -> Self { - Self - } - /// `--staged` flag key. const ARG_STAGED: &'static str = "staged"; diff --git a/cli/src/commands/show.rs b/cli/src/commands/show.rs index 8674cd2..ed9f1da 100644 --- a/cli/src/commands/show.rs +++ b/cli/src/commands/show.rs @@ -14,14 +14,10 @@ use crate::commands::MevaCommand; /// Implements the `show` command for Meva DVCS. /// /// Displays information about a specific snapshot (commit) in the repository. -pub struct ShowCommand {} +#[derive(Default)] +pub struct ShowCommand; impl ShowCommand { - /// Creates a new instance of the `ShowCommand`. - pub fn new() -> Self { - Self {} - } - /// Argument name for specifying the snapshot identifier to display. const ARG_SNAPSHOT_ID: &'static str = "snapshot-id"; @@ -223,7 +219,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = ShowCommand::new(); + let cmd = ShowCommand; assert_eq!(cmd.name(), "show"); assert_eq!(cmd.about(), "Show information about a snapshot"); assert_eq!(cmd.version(), "1.0.0"); diff --git a/cli/src/commands/status.rs b/cli/src/commands/status.rs index b9ebf8c..3ee3a43 100644 --- a/cli/src/commands/status.rs +++ b/cli/src/commands/status.rs @@ -10,14 +10,10 @@ use miette::{IntoDiagnostic, Result}; /// /// Displays the current working tree status, including tracked, untracked, /// and ignored files. Supports short output format and branch information. -pub struct StatusCommand {} +#[derive(Default)] +pub struct StatusCommand; impl StatusCommand { - /// Creates a new instance of the `StatusCommand`. - pub fn new() -> Self { - Self {} - } - /// Argument name for enabling short format (`-s` / `--short`). const ARG_SHORT: &'static str = "short"; @@ -123,7 +119,7 @@ mod tests { #[rstest] fn test_command_name_about_version() { - let cmd = StatusCommand::new(); + let cmd = StatusCommand; assert_eq!(cmd.name(), "status"); assert_eq!( cmd.about(), diff --git a/cli/src/main.rs b/cli/src/main.rs index a7bfa08..528e3bb 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -17,23 +17,23 @@ async fn main() -> Result<()> { miette::set_panic_hook(); let commands: Vec>> = vec![ - Box::new(InitCommand::new()), - Box::new(ConfigCommand::new()), - Box::new(IgnoreCommand::new()), - Box::new(PluginsCommand::new()), - Box::new(AddCommand::new()), - Box::new(LsFilesCommand::new()), - Box::new(ShowCommand::new()), - Box::new(StatusCommand::new()), - Box::new(CommitCommand::new()), - Box::new(DiffCommand::new()), - Box::new(LsTreeCommand::new()), - Box::new(LogCommand::new()), - Box::new(RestoreCommand::new()), - Box::new(CloneCommand::new()), - Box::new(RemoteCommand::new()), - Box::new(FetchCommand::new()), - Box::new(BranchCommand::new()), + Box::new(InitCommand), + Box::new(ConfigCommand), + Box::new(IgnoreCommand), + Box::new(PluginsCommand), + Box::new(AddCommand), + Box::new(LsFilesCommand), + Box::new(ShowCommand), + Box::new(StatusCommand), + Box::new(CommitCommand), + Box::new(DiffCommand), + Box::new(LsTreeCommand), + Box::new(LogCommand), + Box::new(RestoreCommand), + Box::new(CloneCommand), + Box::new(RemoteCommand), + Box::new(FetchCommand), + Box::new(BranchCommand), ]; let container = MevaContainer; diff --git a/engine/src/config/config_loader.rs b/engine/src/config/config_loader.rs index 56448a1..24f009d 100644 --- a/engine/src/config/config_loader.rs +++ b/engine/src/config/config_loader.rs @@ -31,7 +31,7 @@ pub trait ConfigLoader: Send + Sync + Debug { /// Create a new global configuration file with default settings. /// If the file already exists, it will not overwrite it. - fn create_global_config(&self) -> EngineResult<()>; + fn create_global_config(&self, path: Option<&Path>) -> EngineResult<()>; /// Returns the default content for a local configuration file. fn get_default_local_config(&self) -> &str; @@ -138,8 +138,9 @@ impl ConfigLoader for MevaConfigLoader { Ok(()) } - fn create_global_config(&self) -> EngineResult<()> { - let path = ConfigLocation::Global.get_default_path()?; + fn create_global_config(&self, path: Option<&Path>) -> EngineResult<()> { + let global_path = ConfigLocation::Global.get_default_path()?; + let path = path.unwrap_or(&global_path); let (mut config_file, created) = create_file_with_dirs(path)?; if created { diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 17d1872..9ae31ea 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -134,7 +134,8 @@ impl EngineContainer for MevaContainer { } fn config_handler(&self) -> EngineResult { - Ok(ConfigHandler) + let config_loader = Arc::new(MevaConfigLoader::default()); + Ok(ConfigHandler::new(config_loader)) } fn plugins_handler(&self) -> EngineResult { diff --git a/engine/src/handlers/config/handlers.rs b/engine/src/handlers/config/handlers.rs index 0bac8f7..e162313 100644 --- a/engine/src/handlers/config/handlers.rs +++ b/engine/src/handlers/config/handlers.rs @@ -1,20 +1,26 @@ +use std::sync::Arc; + use plugins::{CommandType, InvocationPostPayload, InvocationPrePayload, PluginError, models::*}; -use super::{ - ConfigOperations, GetRequest, GetResponse, ListRequest, ListResponse, SetRequest, SetResponse, - UnsetRequest, UnsetResponse, -}; +use super::*; use crate::{ - ConfigLocation, + ConfigLoader, ConfigLocation, config::{ConfigDocument, ConfigDocumentOperations}, errors::{EngineError, EngineResult}, + handlers::config::CreateRequest, plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}, }; -pub struct ConfigHandler; +pub struct ConfigHandler { + config_loader: Arc, +} impl ConfigHandler { + pub fn new(config_loader: Arc) -> Self { + Self { config_loader } + } + pub fn handle_get( &self, request: GetRequest, @@ -54,6 +60,10 @@ impl ConfigHandler { self.list(req) }) } + + pub fn handle_create(&self, request: CreateRequest) -> EngineResult { + self.create(request) + } } impl ConfigOperations for ConfigHandler { @@ -102,6 +112,12 @@ impl ConfigOperations for ConfigHandler { Ok(response) } + + fn create(&self, request: CreateRequest) -> EngineResult { + self.config_loader + .create_global_config(request.file.as_deref())?; + Ok(CreateResponse) + } } impl PluginsInvocationMapper for ConfigHandler { diff --git a/engine/src/handlers/config/operations.rs b/engine/src/handlers/config/operations.rs index 58a830a..4e51f1b 100644 --- a/engine/src/handlers/config/operations.rs +++ b/engine/src/handlers/config/operations.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::{collections::BTreeMap, path::PathBuf}; use crate::{ConfigLocation, errors::EngineResult}; @@ -66,6 +66,13 @@ pub struct ListResponse { pub key_values: Vec<(String, String)>, } +#[derive(Debug)] +pub struct CreateRequest { + pub file: Option, +} + +pub struct CreateResponse; + impl ListResponse { /// Organizes configuration keys into groups based on their prefix. /// @@ -115,4 +122,6 @@ pub trait ConfigOperations { /// Lists all configuration values for a given location. fn list(&self, request: ListRequest) -> EngineResult; + + fn create(&self, request: CreateRequest) -> EngineResult; } diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index 9afd38d..37e2829 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -287,7 +287,7 @@ impl Repository for MevaRepository { self.check_if_exists()?; let branch_name = initial_branch.unwrap_or("master"); - self.config_loader.create_global_config()?; + self.config_loader.create_global_config(None)?; let tmp_parent = self.layout.working_dir(); let tmp_dir = TempDir::new_in(tmp_parent)?; From decfc0dc7dce1cd9d1a0b0797132fd25397fea09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Sun, 4 Jan 2026 19:30:36 +0100 Subject: [PATCH 32/42] GUI for `merge` (#52) --- cli/src/commands/config/subcommands/edit.rs | 4 +- cli/src/commands/ignore/subcommands/edit.rs | 4 +- cli/src/commands/plugins/subcommands/edit.rs | 2 +- engine/src/handlers/status/handlers.rs | 1 + gui/src/main.rs | 2 +- gui/src/meva_gui.rs | 14 +- gui/src/ui/components/buttons.rs | 2 + .../components/buttons/more_actions_button.rs | 170 ++++++++++++++++++ .../buttons/refresh_status_button.rs | 1 + gui/src/ui/components/file_changes.rs | 98 ++++++++-- gui/src/ui/views/plugins.rs | 2 +- 11 files changed, 277 insertions(+), 23 deletions(-) create mode 100644 gui/src/ui/components/buttons/more_actions_button.rs diff --git a/cli/src/commands/config/subcommands/edit.rs b/cli/src/commands/config/subcommands/edit.rs index d8f00da..cacbbae 100644 --- a/cli/src/commands/config/subcommands/edit.rs +++ b/cli/src/commands/config/subcommands/edit.rs @@ -11,7 +11,7 @@ use crate::extensions::{LocationSelection, WithLocations}; /// Implements the `edit` subcommand for Meva configuration management. /// /// Opens the chosen configuration file in the user's preferred editor, -/// respecting any `core.editor` override in config or falling back to OS defaults. +/// respecting any `editor.default` override in config or falling back to OS defaults. #[derive(Default)] pub struct ConfigEditCommand; @@ -45,7 +45,7 @@ impl MevaCommand for ConfigEditCommand { _container: &Self::Container, ) -> miette::Result<()> { let loader = MevaConfigLoader::default(); - let override_cmd = loader.get("core.editor", None).ok(); + let override_cmd = loader.get("editor.default", None).ok(); let location = matches .get_config_location() .get_default_path() diff --git a/cli/src/commands/ignore/subcommands/edit.rs b/cli/src/commands/ignore/subcommands/edit.rs index 350c896..579bbaa 100644 --- a/cli/src/commands/ignore/subcommands/edit.rs +++ b/cli/src/commands/ignore/subcommands/edit.rs @@ -14,7 +14,7 @@ use crate::{commands::MevaCommand, extensions::WithFile}; /// Implements the `edit` subcommand for Meva ignored files management. /// /// Opens the chosen configuration file in the user's preferred editor, -/// respecting any `core.editor` override in config or falling back to OS defaults. +/// respecting any `editor.default` override in config or falling back to OS defaults. #[derive(Default)] pub struct IgnoreEditCommand; @@ -50,7 +50,7 @@ impl MevaCommand for IgnoreEditCommand { let ignore_service = IgnoreService::new(layout.ignore_file_name()); let loader = MevaConfigLoader::default(); - let override_cmd = loader.get("core.editor", None).ok(); + let override_cmd = loader.get("editor.default", None).ok(); let ignore_file = match file { Some(p) => p.to_path_buf(), diff --git a/cli/src/commands/plugins/subcommands/edit.rs b/cli/src/commands/plugins/subcommands/edit.rs index 7818f3e..c99fcfd 100644 --- a/cli/src/commands/plugins/subcommands/edit.rs +++ b/cli/src/commands/plugins/subcommands/edit.rs @@ -94,7 +94,7 @@ impl MevaCommand for PluginsEditCommand { if enabled.is_none() { let loader = MevaConfigLoader::default(); - let override_cmd = loader.get("core.editor", None).ok(); + let override_cmd = loader.get("editor.default", None).ok(); response .source_file .open_in_editor(override_cmd) diff --git a/engine/src/handlers/status/handlers.rs b/engine/src/handlers/status/handlers.rs index 814505d..cec2e20 100644 --- a/engine/src/handlers/status/handlers.rs +++ b/engine/src/handlers/status/handlers.rs @@ -160,6 +160,7 @@ impl StatusHandler { ahead: 0, behind: 0, }, + // TODO: Fetch real upstream/ahead/behind info HeadMode::Symbolic => BranchInfo { head: head.extract_branch_name(), is_detached: false, diff --git a/gui/src/main.rs b/gui/src/main.rs index d70c927..b336f42 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -19,7 +19,7 @@ fn configure_frame_options() -> eframe::NativeOptions { eframe::NativeOptions { viewport: egui::ViewportBuilder::default() - .with_inner_size([800.0, 600.0]) + .with_inner_size([1200.0, 800.0]) .with_icon(icon), centered: true, ..Default::default() diff --git a/gui/src/meva_gui.rs b/gui/src/meva_gui.rs index 2818172..5fd92ca 100644 --- a/gui/src/meva_gui.rs +++ b/gui/src/meva_gui.rs @@ -221,8 +221,18 @@ impl MevaGui { if let Some(status) = &self.repository_status { ui.horizontal(|ui| { - RefreshStatusButton::new(&mut self.async_worker, &self.container) - .show(ui, ctx); + let has_unmerged = status + .unmerged + .as_ref() + .map(|v| !v.is_empty()) + .unwrap_or(false); + + MoreActionsButton::new( + &mut self.async_worker, + &self.container, + has_unmerged, + ) + .show(ui, ctx); ui.with_layout( egui::Layout::top_down_justified(egui::Align::Center), |ui| { diff --git a/gui/src/ui/components/buttons.rs b/gui/src/ui/components/buttons.rs index ea83471..684afbb 100644 --- a/gui/src/ui/components/buttons.rs +++ b/gui/src/ui/components/buttons.rs @@ -1,5 +1,6 @@ mod commit_button; mod icon_button; +mod more_actions_button; mod navigation_button; mod open_repository_button; mod refresh_status_button; @@ -8,6 +9,7 @@ mod theme_button; pub use commit_button::CommitButton; pub use icon_button::IconButton; +pub use more_actions_button::MoreActionsButton; pub use navigation_button::NavigationButton; pub use open_repository_button::OpenRepositoryButton; pub use refresh_status_button::RefreshStatusButton; diff --git a/gui/src/ui/components/buttons/more_actions_button.rs b/gui/src/ui/components/buttons/more_actions_button.rs new file mode 100644 index 0000000..a66b2c4 --- /dev/null +++ b/gui/src/ui/components/buttons/more_actions_button.rs @@ -0,0 +1,170 @@ +use std::{sync::Arc, thread}; + +use egui::{RichText, Ui}; +use egui_phosphor::regular as icons; + +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::{ + // merge::Request as MergeRequest, + branch::{BranchOperations, ListRequest, Request as BranchRequest}, + status::Request as StatusRequest, + }, +}; + +use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; + +/// A UI component providing a dropdown menu for secondary repository actions. +/// +/// Contains operations like: +/// - **Abort Merge**: Available only during a merge conflict. +/// - **Refresh Status**: Forces a re-scan of the working directory. +pub struct MoreActionsButton<'a> { + worker: &'a mut AsyncWorker, + container: &'a Arc, + has_unmerged_changes: bool, +} + +impl<'a> MoreActionsButton<'a> { + /// Creates a new [`MoreActionsButton`]. + pub fn new( + worker: &'a mut AsyncWorker, + container: &'a Arc, + has_unmerged_changes: bool, + ) -> Self { + Self { + worker, + container, + has_unmerged_changes, + } + } + + /// Renders the button (three vertical dots) and the dropdown menu. + pub fn show(&mut self, ui: &mut Ui, ctx: &egui::Context) { + ui.menu_button(icons::DOTS_THREE_OUTLINE_VERTICAL, |ui| { + ui.style_mut().visuals.button_frame = true; + ui.set_min_width(150.0); + + if ui + .button(format!("{} Refresh Status", icons::ARROWS_CLOCKWISE)) + .on_hover_text("Force reload of file status and branches") + .clicked() + { + ui.close_kind(egui::UiKind::Menu); + self.handle_refresh(ctx); + } + + // TODO: remove the "true" condition when the merge detection is implemented + // if self.has_unmerged_changes || true { + if self.has_unmerged_changes { + ui.separator(); + if ui + .button( + RichText::new(format!("{} Abort Merge", icons::PROHIBIT)) + .color(ui.visuals().error_fg_color), + ) + .on_hover_text("Abort the current merge and restore HEAD") + .clicked() + { + ui.close_kind(egui::UiKind::Menu); + self.handle_abort_merge(ctx); + } + } + }); + } + + /// Initiates background status refresh. + fn handle_refresh(&mut self, ctx: &egui::Context) { + let container = self.container.clone(); + let ctx_clone = ctx.clone(); + + self.worker.spawn(ctx.clone(), move |tx| { + let mut report = |msg: String| { + let _ = tx.send(WorkerEvent::Progress(msg)); + ctx_clone.request_repaint(); + }; + + match Self::execute_refresh_logic(container, &mut report) { + Ok(result) => { + let _ = tx.send(WorkerEvent::Success(result)); + } + Err(err) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Refresh Failed".to_string(), + "Could not refresh status".to_string(), + err, + ))); + } + } + ctx_clone.request_repaint(); + }); + } + + /// Initiates the background task to abort the merge. + fn handle_abort_merge(&mut self, ctx: &egui::Context) { + let container = self.container.clone(); + let ctx_clone = ctx.clone(); + + self.worker.spawn(ctx.clone(), move |tx| { + let mut report = |msg: String| { + let _ = tx.send(WorkerEvent::Progress(msg)); + ctx_clone.request_repaint(); + }; + + match Self::execute_abort_merge(container, &mut report) { + Ok(result) => { + let _ = tx.send(WorkerEvent::Success(result)); + } + Err(err_msg) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Abort Failed".to_string(), + "Could not abort merge".to_string(), + err_msg, + ))); + } + } + ctx_clone.request_repaint(); + }); + } + + /// Logic for Abort Merge. + fn execute_abort_merge( + container: Arc, + report: &mut dyn FnMut(String), + ) -> Result { + report("Aborting merge...".to_string()); + thread::sleep(std::time::Duration::from_millis(500)); + + // TODO: Implement actual merge abort logic + // let request = MergeRequest {}; + // let merge_handler = container.merge_handler().map_err(|e| e.to_string())?; + // merge_handler.handle_merge(request).map_err(|e| e.to_string())?; + + Self::execute_refresh_logic(container, report) + } + + /// Shared logic for reloading status and branches. + fn execute_refresh_logic( + container: Arc, + report: &mut dyn FnMut(String), + ) -> Result { + report("Refreshing status...".to_string()); + thread::sleep(std::time::Duration::from_millis(500)); + + let status_handler = container.status_handler().map_err(|e| e.to_string())?; + let status = status_handler + .handle_status(StatusRequest::with_branch()) + .map_err(|e| e.to_string())?; + + report("Loading branches...".into()); + thread::sleep(std::time::Duration::from_millis(500)); + + let branch_handler = container.branch_handler().map_err(|e| e.to_string())?; + let branch = branch_handler + .branch(BranchRequest::List(ListRequest::local_only(false))) + .map_err(|e| e.to_string())?; + + Ok(WorkerResult::RepositoryOpened { branch, status }) + } +} diff --git a/gui/src/ui/components/buttons/refresh_status_button.rs b/gui/src/ui/components/buttons/refresh_status_button.rs index e192fde..118aca7 100644 --- a/gui/src/ui/components/buttons/refresh_status_button.rs +++ b/gui/src/ui/components/buttons/refresh_status_button.rs @@ -104,6 +104,7 @@ impl<'a> RefreshStatusButton<'a> { .map_err(|e| e.to_string())?; report("Loading branches...".into()); + thread::sleep(std::time::Duration::from_millis(500)); let branch_handler = container.branch_handler().map_err(|e| e.to_string())?; let branch = branch_handler diff --git a/gui/src/ui/components/file_changes.rs b/gui/src/ui/components/file_changes.rs index 172347f..3fbbe3f 100644 --- a/gui/src/ui/components/file_changes.rs +++ b/gui/src/ui/components/file_changes.rs @@ -9,7 +9,7 @@ use egui::{Color32, RichText, Ui, collapsing_header::CollapsingState}; use egui_phosphor::regular as icons; use engine::{ - EngineContainer, + ConfigLoader, EngineContainer, MevaConfigLoader, diff_builder::{ChangeKind, DiffMode}, engine_container::MevaContainer, handlers::{ @@ -20,7 +20,7 @@ use engine::{ }, revision_parsing::Revision, }; -use shared::PathToString; +use shared::{OpenInEditor, PathToString}; use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; @@ -114,9 +114,24 @@ impl<'a> FileChangesComponent<'a> { &mut state.untracked_open, false, ); + + // TODO: Remove mock conflict entry when real unmerged data is available + // let mock_conflict_entry = StatusEntry { + // path: PathBuf::from( + // "C:\\Users\\AG\\Desktop\\test-repo\\data_processor\\src\\sorting.rs", + // ), + // kind: StatusKind::Unmerged { + // ours: ChangeKind::Modified, + // theirs: ChangeKind::Modified, + // }, + // }; + + let unmerged_files = status.unmerged.clone().unwrap_or_default(); + // unmerged_files.push(mock_conflict_entry); + self.render_section( ui, - &status.unmerged, + &Some(unmerged_files), "Unmerged", &mut state.unmerged_open, false, @@ -220,17 +235,22 @@ impl<'a> FileChangesComponent<'a> { }); } - /// Renders the primary action button for a file (Stage `+` or Unstage `-`). + /// Renders the primary action button for a file (Stage `+`, Unstage `-` or Mark as Resolved). fn show_primary_action_button( &mut self, ui: &mut Ui, entry: &StatusEntry, is_section_staged: bool, ) { - let (icon, tooltip) = if is_section_staged { - (icons::MINUS_CIRCLE, "Unstage changes") - } else { - (icons::PLUS_CIRCLE, "Stage changes") + let (icon, tooltip, is_resolve_action) = match entry.kind { + StatusKind::Unmerged { .. } => (icons::CHECK_CIRCLE, "Mark as Resolved", true), + _ => { + if is_section_staged { + (icons::MINUS_CIRCLE, "Unstage changes", false) + } else { + (icons::PLUS_CIRCLE, "Stage changes", false) + } + } }; if ui @@ -238,7 +258,9 @@ impl<'a> FileChangesComponent<'a> { .on_hover_text(tooltip) .clicked() { - if is_section_staged { + if is_resolve_action { + self.handle_stage(&entry.path); + } else if is_section_staged { self.handle_unstage(&entry.path); } else { self.handle_stage(&entry.path); @@ -257,14 +279,28 @@ impl<'a> FileChangesComponent<'a> { RichText::new(icons::DOTS_THREE_OUTLINE_VERTICAL).size(14.0), |ui| { ui.style_mut().visuals.button_frame = true; - ui.set_min_width(120.0); + ui.set_min_width(140.0); self.show_diff_menu_item(ui, entry, is_section_staged); - // Discard is only available for unstaged changes - if matches!(entry.kind, StatusKind::Unstaged(_)) { - ui.separator(); - self.show_discard_menu_item(ui, entry); + match entry.kind { + StatusKind::Unmerged { .. } => { + ui.separator(); + + if ui + .button(format!("{} Resolve conflicts", icons::WRENCH)) + .on_hover_text("Open file in external editor to fix conflicts") + .clicked() + { + ui.close_kind(egui::UiKind::Menu); + self.handle_open_in_editor(&entry.path); + } + } + StatusKind::Unstaged(_) => { + ui.separator(); + self.show_discard_menu_item(ui, entry); + } + _ => { /* No additional actions for other kinds */ } } }, ); @@ -406,6 +442,40 @@ impl<'a> FileChangesComponent<'a> { }); } + /// Opens the file in the system's default editor or the one configured in `editor.default`. + /// + /// Used for resolving conflicts manually. + fn handle_open_in_editor(&mut self, path: &Path) { + let path_buf = path.to_path_buf(); + let ctx = self.ctx.clone(); + self.worker.spawn(ctx.clone(), move |tx| { + let _ = tx.send(WorkerEvent::Progress("Resolving conflicts...".to_string())); + + let task = || -> Result { + let loader = MevaConfigLoader::default(); + let override_cmd = loader.get("editor.default", None).ok(); + + path_buf + .open_in_editor(override_cmd) + .map_err(|e| e.to_string())?; + + Ok(WorkerResult::None) + }; + + if let Err(err_msg) = task() { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Editor Error".to_string(), + "Could not open file".to_string(), + err_msg, + ))); + } else { + let _ = tx.send(WorkerEvent::Success(WorkerResult::None)); + } + + ctx.request_repaint(); + }); + } + /// Generic helper to spawn a background thread for a single file operation. /// /// This method: diff --git a/gui/src/ui/views/plugins.rs b/gui/src/ui/views/plugins.rs index aa4cde1..9383639 100644 --- a/gui/src/ui/views/plugins.rs +++ b/gui/src/ui/views/plugins.rs @@ -227,7 +227,7 @@ impl PluginsView { }) } else { let loader = MevaConfigLoader::default(); - let override_cmd = loader.get("core.editor", None).ok(); + let override_cmd = loader.get("editor.default", None).ok(); params .path .open_in_editor(override_cmd) From 861d1110e41d2c67f25f3f25d77c717a1076f1d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Wed, 7 Jan 2026 23:42:46 +0100 Subject: [PATCH 33/42] Issues #50 & #53 (#54) --- engine/src/diff_builder/meva_diff_builder.rs | 62 ++++++++--------- engine/src/objects/meva_object.rs | 13 +++- engine/src/repositories/meva_repository.rs | 19 ------ .../repositories/meva_repository_layout.rs | 4 -- engine/src/repositories/repository_layout.rs | 66 +------------------ 5 files changed, 42 insertions(+), 122 deletions(-) diff --git a/engine/src/diff_builder/meva_diff_builder.rs b/engine/src/diff_builder/meva_diff_builder.rs index c19ca72..596f42e 100644 --- a/engine/src/diff_builder/meva_diff_builder.rs +++ b/engine/src/diff_builder/meva_diff_builder.rs @@ -552,6 +552,15 @@ impl DiffBuilder for MevaDiffBuilder { ) { continue; } + + let blob = MevaBlob::from_file(&absolute_path)?; + let object = MevaObject::try_from(&blob)?; + let hash = object.sha1(); + + if *hash == index_entry.sha1 { + continue; + } + match mode { DiffMode::NameOnly => { modified.push(FileChange::default_modified( @@ -559,44 +568,31 @@ impl DiffBuilder for MevaDiffBuilder { path.to_path_buf(), )); } - _ => { - let blob = MevaBlob::from_file(&absolute_path)?; - match blob.text_content() { - Ok(new_content) => { - let object = MevaObject::try_from(blob)?; - - let hash = object.sha1(); - - if *hash == index_entry.sha1 { - continue; - } - - match self.get_text_content(&index_entry.sha1) { - Ok(old_content) => { - let args = ModifiedFileArgs { - old_content: &old_content, - new_content: &new_content, - old_path: index_entry.path.clone().into(), - new_path: path.to_path_buf(), - old_hash: Some(index_entry.sha1.clone()), - new_hash: Some(hash.clone()), - file_mode: &entry.mode, - collect_details: matches!(mode, DiffMode::Full), - }; - modified.push(self.build_file_change_modified(args)); - } - Err(_) => modified.push(FileChange::default_modified( - index_entry.path.clone().into(), - path.to_path_buf(), - )), - } + _ => match blob.text_content() { + Ok(new_content) => match self.get_text_content(&index_entry.sha1) { + Ok(old_content) => { + let args = ModifiedFileArgs { + old_content: &old_content, + new_content: &new_content, + old_path: index_entry.path.clone().into(), + new_path: path.to_path_buf(), + old_hash: Some(index_entry.sha1.clone()), + new_hash: Some(hash.clone()), + file_mode: &entry.mode, + collect_details: matches!(mode, DiffMode::Full), + }; + modified.push(self.build_file_change_modified(args)); } Err(_) => modified.push(FileChange::default_modified( index_entry.path.clone().into(), path.to_path_buf(), )), - } - } + }, + Err(_) => modified.push(FileChange::default_modified( + index_entry.path.clone().into(), + path.to_path_buf(), + )), + }, } } } diff --git a/engine/src/objects/meva_object.rs b/engine/src/objects/meva_object.rs index c719234..7853cd9 100644 --- a/engine/src/objects/meva_object.rs +++ b/engine/src/objects/meva_object.rs @@ -209,7 +209,19 @@ impl TryFrom for MevaObject { type Error = EngineError; fn try_from(value: MevaBlob) -> Result { let meva_object = MevaObject::new(MevaObjectType::Blob, value.to_bytes()?)?; + Ok(meva_object) + } +} +impl TryFrom<&MevaBlob> for MevaObject { + /// Converts a reference to a [`MevaBlob`] into a [`MevaObject`] of type [`MevaObjectType::Blob`]. + /// + /// # Errors + /// + /// Returns an error if serialization or object creation fails. + type Error = EngineError; + fn try_from(value: &MevaBlob) -> Result { + let meva_object = MevaObject::new(MevaObjectType::Blob, value.to_bytes()?)?; Ok(meva_object) } } @@ -225,7 +237,6 @@ impl TryFrom for MevaObject { type Error = EngineError; fn try_from(mut value: MevaTree) -> Result { let meva_object = MevaObject::new(MevaObjectType::Tree, value.to_bytes_mut()?)?; - Ok(meva_object) } } diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index 37e2829..e5df835 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -16,7 +16,6 @@ use crate::{ object_storage::ObjectStorage, objects::MevaObject, serialize_deserialize::MevaEncode, }; use crate::{ref_manager::RefEntry, repositories::RepositoryLayout}; -use shared::fs::create_file_with_dirs; pub trait Repository: Send + Sync { /// Initializes a new repository with the given initial branch. @@ -97,9 +96,6 @@ impl MevaRepository { self.layout.refs_dir_rel(), self.layout.heads_refs_dir_rel(), self.layout.remotes_refs_dir_rel(), - self.layout.logs_dir_rel(), - self.layout.heads_logs_dir_rel(), - self.layout.remotes_logs_dir_rel(), ]; for dir in dirs { @@ -127,9 +123,6 @@ impl MevaRepository { write!(head_file, "{}", head.to_json()?)?; - let head_log_path = root.join(self.layout.head_logs_file_rel()); - fs::File::create(head_log_path)?; - Ok(()) } @@ -168,11 +161,6 @@ impl MevaRepository { fs::File::create(&ref_path)?; } - let log_path = root - .join(self.layout.heads_logs_dir_rel()) - .join(branch_name); - create_file_with_dirs(&log_path)?; - ref_entries.push(entry); } @@ -401,8 +389,6 @@ mod tests { assert!(repo.layout.objects_dir().exists()); assert!(repo.layout.refs_dir().exists()); assert!(repo.layout.heads_refs_dir().exists()); - assert!(repo.layout.logs_dir().exists()); - assert!(repo.layout.heads_logs_dir().exists()); let head_path = repo.layout.head_file(); assert!(head_path.exists()); @@ -410,14 +396,9 @@ mod tests { let head_contents = read_file(&head_path); assert_eq!(head_contents, Head::default().to_json().unwrap()); - assert!(repo.layout.head_logs_file().exists()); - let branch_ref = repo.layout.heads_refs_dir().join("master"); assert!(branch_ref.exists()); - let branch_log = repo.layout.heads_logs_dir().join("master"); - assert!(branch_log.exists()); - let config_path = repo.layout.config_file(); assert!(config_path.exists()); diff --git a/engine/src/repositories/meva_repository_layout.rs b/engine/src/repositories/meva_repository_layout.rs index 470ef6d..137211d 100644 --- a/engine/src/repositories/meva_repository_layout.rs +++ b/engine/src/repositories/meva_repository_layout.rs @@ -76,10 +76,6 @@ impl RepositoryLayout for MevaRepositoryLayout { "refs" } - fn logs_dir_name(&self) -> &str { - "logs" - } - fn heads_dir_name(&self) -> &str { "heads" } diff --git a/engine/src/repositories/repository_layout.rs b/engine/src/repositories/repository_layout.rs index 536a85d..8dca574 100644 --- a/engine/src/repositories/repository_layout.rs +++ b/engine/src/repositories/repository_layout.rs @@ -16,10 +16,7 @@ pub trait RepositoryLayout: Send + Sync + Debug { /// Subdirectory for storing references. fn refs_dir_name(&self) -> &str; - /// Subdirectory for reference logs. - fn logs_dir_name(&self) -> &str; - - /// Subdirectory for storing heads references and logs. + /// Subdirectory for storing heads references. fn heads_dir_name(&self) -> &str; /// Subdirectory for storing remote-tracking branch references. @@ -73,21 +70,6 @@ pub trait RepositoryLayout: Send + Sync + Debug { self.refs_dir_rel().join(self.remotes_dir_name()) } - /// Path to the logs directory relative to `working_dir`. - fn logs_dir_rel(&self) -> PathBuf { - self.repository_dir_rel().join(self.logs_dir_name()) - } - - /// Path to heads in logs relative to `working_dir`. - fn heads_logs_dir_rel(&self) -> PathBuf { - self.logs_dir_rel().join(self.heads_dir_name()) - } - - /// Path to remotes in logs relative to `working_dir`. - fn remotes_logs_dir_rel(&self) -> PathBuf { - self.logs_dir_rel().join(self.remotes_dir_name()) - } - /// Path to plugins directory relative to `working_dir`. fn plugins_dir_rel(&self) -> PathBuf { self.repository_dir_rel().join(self.plugins_dir_name()) @@ -103,11 +85,6 @@ pub trait RepositoryLayout: Send + Sync + Debug { self.repository_dir_rel().join(self.config_file_name()) } - /// Path to the HEAD log file relative to `working_dir`. - fn head_logs_file_rel(&self) -> PathBuf { - self.logs_dir_rel().join(self.head_file_name()) - } - /// Path to index file relative to `workdir` fn index_file_rel(&self) -> PathBuf { self.repository_dir_rel().join(self.index_file_name()) @@ -140,21 +117,6 @@ pub trait RepositoryLayout: Send + Sync + Debug { self.working_dir().join(self.remotes_refs_dir_rel()) } - /// Absolute path to the logs directory. - fn logs_dir(&self) -> PathBuf { - self.working_dir().join(self.logs_dir_rel()) - } - - /// Absolute path to heads in logs. - fn heads_logs_dir(&self) -> PathBuf { - self.working_dir().join(self.heads_logs_dir_rel()) - } - - /// Absolute path to remotes in logs. - fn remotes_logs_dir(&self) -> PathBuf { - self.working_dir().join(self.remotes_logs_dir_rel()) - } - /// Absolute path to plugins directory. fn plugins_dir(&self) -> PathBuf { self.working_dir().join(self.plugins_dir_rel()) @@ -170,11 +132,6 @@ pub trait RepositoryLayout: Send + Sync + Debug { self.working_dir().join(self.config_file_rel()) } - /// Absolute path to the HEAD log file. - fn head_logs_file(&self) -> PathBuf { - self.working_dir().join(self.head_logs_file_rel()) - } - /// Absolute path to the index file. fn index_file(&self) -> PathBuf { self.working_dir().join(self.index_file_rel()) @@ -214,10 +171,6 @@ mod tests { "refs" } - fn logs_dir_name(&self) -> &str { - "logs" - } - fn heads_dir_name(&self) -> &str { "heads" } @@ -273,22 +226,9 @@ mod tests { layout.remotes_refs_dir_rel(), PathBuf::from(".meva/refs/remotes") ); - assert_eq!(layout.logs_dir_rel(), PathBuf::from(".meva/logs")); - assert_eq!( - layout.heads_logs_dir_rel(), - PathBuf::from(".meva/logs/heads") - ); - assert_eq!( - layout.remotes_logs_dir_rel(), - PathBuf::from(".meva/logs/remotes") - ); assert_eq!(layout.plugins_dir_rel(), PathBuf::from(".meva/plugins")); assert_eq!(layout.head_file_rel(), PathBuf::from(".meva/HEAD")); assert_eq!(layout.config_file_rel(), PathBuf::from(".meva/mevaconfig")); - assert_eq!( - layout.head_logs_file_rel(), - PathBuf::from(".meva/logs/HEAD") - ); // absolute paths assert_eq!(layout.repository_dir(), base.join(".meva")); @@ -296,12 +236,8 @@ mod tests { assert_eq!(layout.refs_dir(), base.join(".meva/refs")); assert_eq!(layout.heads_refs_dir(), base.join(".meva/refs/heads")); assert_eq!(layout.remotes_refs_dir(), base.join(".meva/refs/remotes")); - assert_eq!(layout.logs_dir(), base.join(".meva/logs")); - assert_eq!(layout.heads_logs_dir(), base.join(".meva/logs/heads")); - assert_eq!(layout.remotes_logs_dir(), base.join(".meva/logs/remotes")); assert_eq!(layout.plugins_dir(), base.join(".meva/plugins")); assert_eq!(layout.config_file(), base.join(".meva/mevaconfig")); assert_eq!(layout.head_file(), base.join(".meva/HEAD")); - assert_eq!(layout.head_logs_file(), base.join(".meva/logs/HEAD")); } } From 19f275072459192d365a82b8c5ff37c6a97e88fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Sun, 11 Jan 2026 20:26:04 +0100 Subject: [PATCH 34/42] Command `push` (#51) --- cli/src/commands.rs | 33 ++ cli/src/commands/clone.rs | 17 +- cli/src/commands/fetch.rs | 2 +- cli/src/commands/pull.rs | 85 ++++ cli/src/commands/push.rs | 120 ++++++ cli/src/commands/remote/subcommands/add.rs | 26 +- .../commands/remote/subcommands/set_url.rs | 24 +- cli/src/commands/remote/subcommands/show.rs | 7 +- cli/src/main.rs | 30 +- engine/src/engine_container.rs | 19 +- engine/src/errors/repository_error.rs | 4 + engine/src/handlers.rs | 1 + engine/src/handlers/clone/handlers.rs | 23 +- engine/src/handlers/fetch/handlers.rs | 69 ++-- engine/src/handlers/fetch/operations.rs | 16 +- .../ls_files/models/ls_files_entry.rs | 17 +- engine/src/handlers/push.rs | 5 + engine/src/handlers/push/handlers.rs | 189 +++++++++ engine/src/handlers/push/operations.rs | 88 ++++ engine/src/handlers/remote/handlers.rs | 12 +- engine/src/handlers/remote/operations.rs | 3 +- engine/src/network.rs | 5 +- engine/src/network/client_handler.rs | 5 +- engine/src/network/common.rs | 11 +- engine/src/network/common/pkt_line.rs | 34 ++ engine/src/network/common/remote_entry.rs | 2 +- engine/src/network/protocols.rs | 2 + engine/src/network/protocols/receive_pack.rs | 271 ++++++++++++ .../receive_pack/receive_pack_result.rs | 148 +++++++ .../receive_pack/receive_pack_state.rs | 24 ++ engine/src/network/protocols/upload_pack.rs | 97 ++--- .../upload_pack/upload_pack_state.rs | 9 +- engine/src/network/remotes.rs | 14 +- .../network/remotes/meva_remotes_manager.rs | 30 +- engine/src/network/ssh_service.rs | 27 +- engine/src/network/ssh_session.rs | 192 ++++++++- engine/src/object_storage.rs | 10 + .../meva_dry_run_object_storage.rs | 4 + .../src/object_storage/meva_object_storage.rs | 29 ++ engine/src/ref_manager.rs | 20 + engine/src/ref_manager/head.rs | 11 + engine/src/ref_manager/meva_ref_manager.rs | 22 + engine/src/ref_manager/ref_entry.rs | 7 +- engine/src/repositories/meva_repository.rs | 47 ++- gui/src/ui/components/remote_actions.rs | 59 +-- server/src/enums.rs | 4 +- server/src/enums/receive_pack_command.rs | 68 +++ server/src/enums/receive_pack_state.rs | 36 +- server/src/errors.rs | 2 + server/src/errors/receive_pack_error.rs | 12 + server/src/errors/server_error.rs | 6 +- server/src/logging.rs | 4 +- server/src/server_handler.rs | 391 +++++++++++++++--- 53 files changed, 2060 insertions(+), 333 deletions(-) create mode 100644 cli/src/commands/pull.rs create mode 100644 cli/src/commands/push.rs create mode 100644 engine/src/handlers/push.rs create mode 100644 engine/src/handlers/push/handlers.rs create mode 100644 engine/src/handlers/push/operations.rs create mode 100644 engine/src/network/protocols/receive_pack.rs create mode 100644 engine/src/network/protocols/receive_pack/receive_pack_result.rs create mode 100644 engine/src/network/protocols/receive_pack/receive_pack_state.rs create mode 100644 server/src/enums/receive_pack_command.rs create mode 100644 server/src/errors/receive_pack_error.rs diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 1b857cf..0f06221 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -12,6 +12,8 @@ pub mod ls_files; pub mod ls_tree; pub mod meva_command; pub mod plugins; +pub mod pull; +pub mod push; pub mod remote; pub mod restore; pub mod show; @@ -30,6 +32,8 @@ pub use log::LogCommand; pub use ls_files::LsFilesCommand; pub use ls_tree::LsTreeCommand; pub use plugins::PluginsCommand; +pub use pull::PullCommand; +pub use push::PushCommand; pub use remote::RemoteCommand; pub use restore::RestoreCommand; pub use show::ShowCommand; @@ -41,6 +45,7 @@ use miette::{Context, Result}; use engine::engine_container::MevaContainer; pub use meva_command::MevaCommand; +/// Executes the appropriate subcommand based on the provided matches. pub async fn execute_multiple( matches: &ArgMatches, container: &MevaContainer, @@ -57,3 +62,31 @@ pub async fn execute_multiple( Ok(()) } + +/// Collection type for Meva commands. +pub type CommandsCollection = Vec>>; + +/// Collects all available Meva commands into a single collection. +pub fn collect_commands() -> CommandsCollection { + vec![ + Box::new(InitCommand), + Box::new(ConfigCommand), + Box::new(IgnoreCommand), + Box::new(PluginsCommand), + Box::new(AddCommand), + Box::new(LsFilesCommand), + Box::new(ShowCommand), + Box::new(StatusCommand), + Box::new(CommitCommand), + Box::new(DiffCommand), + Box::new(LsTreeCommand), + Box::new(LogCommand), + Box::new(RestoreCommand), + Box::new(CloneCommand), + Box::new(RemoteCommand), + Box::new(FetchCommand), + Box::new(BranchCommand), + Box::new(PushCommand), + Box::new(PullCommand), + ] +} diff --git a/cli/src/commands/clone.rs b/cli/src/commands/clone.rs index 7991bfd..7e54937 100644 --- a/cli/src/commands/clone.rs +++ b/cli/src/commands/clone.rs @@ -1,6 +1,6 @@ -use crate::commands::MevaCommand; +use crate::{commands::MevaCommand, extensions::WithVerbose}; use async_trait::async_trait; -use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint}; +use clap::{Arg, ArgMatches, Command, ValueHint}; use engine::{EngineContainer, engine_container::MevaContainer, handlers::clone::Request}; use miette::{IntoDiagnostic, Result}; use std::path::PathBuf; @@ -26,9 +26,6 @@ impl CloneCommand { /// Argument name for specifying the server public key. const ARG_SERVER_KEY: &'static str = "server-key"; - - /// Argument name for the quiet flag. - const ARG_QUIET: &'static str = "quiet"; } #[async_trait] @@ -50,6 +47,7 @@ impl MevaCommand for CloneCommand { /// Builds the CLI argument parser for the `clone` command using `clap`. fn build_command(&self) -> Command { self.build_base_command() + .with_verbose_arg("Enable verbose output") .arg( Arg::new(Self::ARG_REPOSITORY) .value_name("REPOSITORY") @@ -82,13 +80,6 @@ impl MevaCommand for CloneCommand { .value_parser(clap::value_parser!(PathBuf)) .help("Path to server's public key"), ) - .arg( - Arg::new(Self::ARG_QUIET) - .long(Self::ARG_QUIET) - .short('q') - .action(ArgAction::SetTrue) - .help("Suppress non-error output"), - ) } /// Executes the `clone` command. @@ -107,7 +98,7 @@ impl MevaCommand for CloneCommand { .get_one::(Self::ARG_SERVER_KEY) .unwrap() .clone(), - quiet: matches.get_flag(Self::ARG_QUIET), + quiet: !matches.get_flag(Command::ARG_VERBOSE), }; let handler = container.clone_handler().into_diagnostic()?; diff --git a/cli/src/commands/fetch.rs b/cli/src/commands/fetch.rs index f7f7bbc..1fcf28e 100644 --- a/cli/src/commands/fetch.rs +++ b/cli/src/commands/fetch.rs @@ -88,7 +88,7 @@ impl MevaCommand for FetchCommand { }; let handler = container.fetch_handler().into_diagnostic()?; - let _response = handler.handle_fetch(request).await.into_diagnostic()?; + let _ = handler.handle_fetch(request).await.into_diagnostic()?; Ok(()) } diff --git a/cli/src/commands/pull.rs b/cli/src/commands/pull.rs new file mode 100644 index 0000000..71167a6 --- /dev/null +++ b/cli/src/commands/pull.rs @@ -0,0 +1,85 @@ +use async_trait::async_trait; +use clap::{Arg, ArgMatches, Command}; +use miette::Result; + +use engine::engine_container::MevaContainer; + +use crate::{commands::MevaCommand, extensions::WithVerbose}; + +/// Implements the `pull` command for Meva DVCS. +#[derive(Default)] +pub struct PullCommand; + +impl PullCommand { + const ARG_ORIGIN: &'static str = "origin"; + + const ARG_BRANCH: &'static str = "branch"; +} + +#[async_trait] +impl MevaCommand for PullCommand { + type Container = MevaContainer; + + fn name(&self) -> &'static str { + "pull" + } + + fn about(&self) -> &'static str { + "Fetch and integrate the latest changes from the remote repository into your local branch" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `pull` command. + fn build_command(&self) -> Command { + self.build_base_command() + .with_verbose_arg("Enable verbose output when pulling from remote server") + .arg( + Arg::new(Self::ARG_ORIGIN) + .value_name("ORIGIN") + .index(1) + .required(false) + .default_value("origin") + .help("Name of the remote repository to pull from"), + ) + .arg( + Arg::new(Self::ARG_BRANCH) + .value_name("BRANCH") + .index(2) + .required(false) + .help("Name of the branch to pull from"), + ) + } + + /// Executes the `pull` command. + /// + /// # Arguments + /// * `matches`: Parsed command-line arguments. + /// * `container`: Dependency injection container. + /// + /// # Returns + /// * `Result<()>`: Success or error during execution. + async fn execute(&self, _matches: &ArgMatches, _container: &Self::Container) -> Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = PullCommand; + assert_eq!(cmd.name(), "pull"); + assert_eq!( + cmd.about(), + "Fetch and integrate the latest changes from the remote repository into your local branch" + ); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/push.rs b/cli/src/commands/push.rs new file mode 100644 index 0000000..dd9e54c --- /dev/null +++ b/cli/src/commands/push.rs @@ -0,0 +1,120 @@ +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use miette::{IntoDiagnostic, Result}; + +use engine::{EngineContainer, engine_container::MevaContainer, handlers::push::Request}; + +use crate::{commands::MevaCommand, extensions::WithVerbose}; + +/// Implements the `push` command for Meva DVCS. +/// +/// This command pushes local commits to a remote repository. +/// It supports pushing specific branches or all local branches +/// Deletion of remote branches is also supported. +#[derive(Default)] +pub struct PushCommand; + +impl PushCommand { + const ARG_ORIGIN: &'static str = "origin"; + + const ARG_BRANCH: &'static str = "branch"; + + const ARG_ALL: &'static str = "all"; + + const ARG_DELETE: &'static str = "delete"; +} + +#[async_trait] +impl MevaCommand for PushCommand { + type Container = MevaContainer; + + fn name(&self) -> &'static str { + "push" + } + + fn about(&self) -> &'static str { + "Push local commits to a remote repository" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `push` command. + fn build_command(&self) -> Command { + self.build_base_command() + .with_verbose_arg("Enable verbose output when pushing to remote server") + .arg( + Arg::new(Self::ARG_ORIGIN) + .value_name("ORIGIN") + .index(1) + .required(false) + .default_value("origin") + .help("Name of the remote repository to push to"), + ) + .arg( + Arg::new(Self::ARG_BRANCH) + .value_name("BRANCH") + .index(2) + .required(false) + .help("Name of the branch to push to"), + ) + .arg( + Arg::new(Self::ARG_ALL) + .long(Self::ARG_ALL) + .short('a') + .help("Push all local branches to the specified remote") + .action(ArgAction::SetTrue) + .conflicts_with_all([Self::ARG_BRANCH, Self::ARG_DELETE]), + ) + .arg( + Arg::new(Self::ARG_DELETE) + .long(Self::ARG_DELETE) + .short('d') + .help("Delete the given remote branch.") + .action(ArgAction::SetTrue) + .requires(Self::ARG_BRANCH), + ) + } + + /// Executes the `push` command. + /// + /// # Arguments + /// * `matches`: Parsed command-line arguments. + /// * `container`: Dependency injection container. + /// + /// # Returns + /// * `Result<()>`: Success or error during execution. + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { + let request = Request { + origin: matches.get_one::(Self::ARG_ORIGIN).unwrap().clone(), + branch: matches.get_one::(Self::ARG_BRANCH).cloned(), + all: matches.get_flag(Self::ARG_ALL), + delete: matches.get_flag(Self::ARG_DELETE), + verbose: matches.get_flag(Command::ARG_VERBOSE), + }; + + let handler = container.push_handler().into_diagnostic()?; + + let response = handler.handle_push(request).await.into_diagnostic()?; + + println!(); + println!("{response}"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = PushCommand; + assert_eq!(cmd.name(), "push"); + assert_eq!(cmd.about(), "Push local commits to a remote repository"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/remote/subcommands/add.rs b/cli/src/commands/remote/subcommands/add.rs index 1a4d5c3..4e43d93 100644 --- a/cli/src/commands/remote/subcommands/add.rs +++ b/cli/src/commands/remote/subcommands/add.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use async_trait::async_trait; -use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint}; +use clap::{Arg, ArgMatches, Command, ValueHint}; use engine::{ EngineContainer, engine_container::MevaContainer, @@ -20,9 +20,6 @@ use crate::{commands::MevaCommand, extensions::WithName}; pub struct RemoteAddCommand; impl RemoteAddCommand { - /// Argument name for the fetch flag. - const ARG_FETCH: &'static str = "fetch"; - /// Argument name for the remote URL. const ARG_URL: &'static str = "url"; @@ -67,13 +64,6 @@ impl MevaCommand for RemoteAddCommand { .value_parser(clap::value_parser!(PathBuf)) .help("Path to server's public key"), ) - .arg( - Arg::new(Self::ARG_FETCH) - .short('f') - .long(Self::ARG_FETCH) - .action(ArgAction::SetTrue) - .help("Fetch remote refs immediately after adding"), - ) } /// Executes the `remote add` command. @@ -85,23 +75,25 @@ impl MevaCommand for RemoteAddCommand { matches: &ArgMatches, container: &Self::Container, ) -> miette::Result<()> { + let name = matches + .get_one::(Command::ARG_NAME) + .unwrap() + .to_string(); + let request = AddRequest { - name: matches - .get_one::(Command::ARG_NAME) - .unwrap() - .to_string(), + name: name.clone(), url: matches.get_one::(Self::ARG_URL).unwrap().clone(), pub_key: matches .get_one::(Self::ARG_SERVER_KEY) .unwrap() .clone(), - fetch: matches.get_flag(Self::ARG_FETCH), }; let handler = container.remote_handler().into_diagnostic()?; - let _response = handler.add(request).into_diagnostic()?; + let response = handler.add(request).into_diagnostic()?; + println!("{}", response.remote.display_verbose(&name)); Ok(()) } } diff --git a/cli/src/commands/remote/subcommands/set_url.rs b/cli/src/commands/remote/subcommands/set_url.rs index 361c958..f267438 100644 --- a/cli/src/commands/remote/subcommands/set_url.rs +++ b/cli/src/commands/remote/subcommands/set_url.rs @@ -1,5 +1,7 @@ +use std::path::PathBuf; + use async_trait::async_trait; -use clap::{Arg, ArgMatches, Command, builder::PossibleValuesParser}; +use clap::{Arg, ArgMatches, Command, ValueHint, builder::PossibleValuesParser}; use engine::{ EngineContainer, engine_container::MevaContainer, @@ -26,6 +28,9 @@ impl RemoteSetUrlCommand { /// Argument name for the new URL. const ARG_NEW_URL: &'static str = "new-url"; + + /// Argument name for specifying the server public key. + const ARG_NEW_SERVER_KEY: &'static str = "new-server-key"; } #[async_trait] @@ -56,6 +61,15 @@ impl MevaCommand for RemoteSetUrlCommand { .value_parser(clap::value_parser!(Url)) .help("New repository URL (e.g. ssh://user@host:port/repository)"), ) + .arg( + Arg::new(Self::ARG_NEW_SERVER_KEY) + .value_name("NEW_SERVER_KEY") + .index(3) + .required(true) + .value_hint(ValueHint::FilePath) + .value_parser(clap::value_parser!(PathBuf)) + .help("Path to new server's public key"), + ) .arg( Arg::new(Self::ARG_DIRECTION) .long(Self::ARG_DIRECTION) @@ -83,13 +97,17 @@ impl MevaCommand for RemoteSetUrlCommand { .unwrap() .clone(), new_url: matches.get_one::(Self::ARG_NEW_URL).unwrap().clone(), + new_server_key: matches + .get_one::(Self::ARG_NEW_SERVER_KEY) + .unwrap() + .clone(), direction: direction_str.parse::().unwrap(), }; let handler = container.remote_handler().into_diagnostic()?; - let _response = handler.set_url(request).into_diagnostic()?; + handler.set_url(request).into_diagnostic()?; - println!("Remote URL changed successfully!"); + println!("Remote URL changed successfully."); Ok(()) } diff --git a/cli/src/commands/remote/subcommands/show.rs b/cli/src/commands/remote/subcommands/show.rs index cc7e8cc..cc6536d 100644 --- a/cli/src/commands/remote/subcommands/show.rs +++ b/cli/src/commands/remote/subcommands/show.rs @@ -8,7 +8,10 @@ use engine::{ use miette::IntoDiagnostic; use owo_colors::OwoColorize; -use crate::{commands::MevaCommand, extensions::WithName}; +use crate::{ + commands::MevaCommand, + extensions::{WithName, WithVerbose}, +}; /// Implements the `remote show` subcommand for Meva DVCS. /// @@ -43,6 +46,7 @@ impl MevaCommand for RemoteShowCommand { fn build_command(&self) -> Command { self.build_base_command() .with_name_arg("Name for the remote") + .with_verbose_arg("Enable verbose output") .arg( Arg::new(Self::ARG_NO_FETCH) .short('n') @@ -67,6 +71,7 @@ impl MevaCommand for RemoteShowCommand { let request = ShowRequest { name: name.clone(), no_fetch: matches.get_flag(Self::ARG_NO_FETCH), + verbose: matches.get_flag(Command::ARG_VERBOSE), }; let handler = container.remote_handler().into_diagnostic()?; diff --git a/cli/src/main.rs b/cli/src/main.rs index 528e3bb..2d2c76c 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -4,41 +4,19 @@ mod meva_cli; use miette::Result; -use crate::{commands::RemoteCommand, meva_cli::MevaCli}; -use commands::{ - AddCommand, BranchCommand, CloneCommand, CommitCommand, ConfigCommand, DiffCommand, - FetchCommand, IgnoreCommand, InitCommand, LogCommand, LsFilesCommand, LsTreeCommand, - MevaCommand, PluginsCommand, RestoreCommand, ShowCommand, StatusCommand, -}; +use crate::meva_cli::MevaCli; +use commands::collect_commands; use engine::engine_container::MevaContainer; #[tokio::main] async fn main() -> Result<()> { miette::set_panic_hook(); - let commands: Vec>> = vec![ - Box::new(InitCommand), - Box::new(ConfigCommand), - Box::new(IgnoreCommand), - Box::new(PluginsCommand), - Box::new(AddCommand), - Box::new(LsFilesCommand), - Box::new(ShowCommand), - Box::new(StatusCommand), - Box::new(CommitCommand), - Box::new(DiffCommand), - Box::new(LsTreeCommand), - Box::new(LogCommand), - Box::new(RestoreCommand), - Box::new(CloneCommand), - Box::new(RemoteCommand), - Box::new(FetchCommand), - Box::new(BranchCommand), - ]; - let container = MevaContainer; let mut cli = MevaCli::new(container); + let commands = collect_commands(); + for command in commands { cli.add_command(command); } diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 9ae31ea..d99b754 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -10,7 +10,8 @@ use crate::handlers::{ add::AddHandler, branch::BranchHandler, clone::CloneHandler, commit::CommitHandler, config::ConfigHandler, diff::DiffHandler, fetch::FetchHandler, init::InitHandler, log::LogHandler, ls_files::LsFilesHandler, ls_tree::LsTreeHandler, plugins::PluginsHandler, - remote::RemoteHandler, restore::RestoreHandler, show::ShowHandler, status::StatusHandler, + push::PushHandler, remote::RemoteHandler, restore::RestoreHandler, show::ShowHandler, + status::StatusHandler, }; use crate::index::{MevaIndex, MevaWorkingDir}; use crate::network::MevaRemotesManager; @@ -75,6 +76,9 @@ pub trait EngineContainer { /// Returns the handler responsible for branch management. fn branch_handler(&self) -> EngineResult; + + /// Returns the handler responsible for push operation. + fn push_handler(&self) -> EngineResult; } /// Concrete implementation of `EngineContainer` for Meva. @@ -123,11 +127,13 @@ impl EngineContainer for MevaContainer { let config_loader = Arc::new(MevaConfigLoader::default()); let object_storage = Arc::new(MevaObjectStorage::new(layout.clone())); let ref_manager = Arc::new(MevaRefManager::new(layout.clone())); + let remotes_manager = Arc::new(MevaRemotesManager); let repository = Arc::new(MevaRepository::new( layout, config_loader, object_storage, ref_manager, + remotes_manager, )); let handler = InitHandler { repository }; Ok(handler) @@ -371,6 +377,7 @@ impl EngineContainer for MevaContainer { fn clone_handler(&self) -> EngineResult { let repository_layout = self.repository_layout_env()?; let config_loader = Arc::new(MevaConfigLoader::default()); + let handler = CloneHandler::new(repository_layout, config_loader); Ok(handler) } @@ -419,4 +426,14 @@ impl EngineContainer for MevaContainer { Ok(handler) } + + fn push_handler(&self) -> EngineResult { + let layout = self.repository_layout_discover()?; + let config_loader = Arc::new(MevaConfigLoader::default()); + let ref_manager = Arc::new(MevaRefManager::new(layout)); + let remotes_manager = Arc::new(MevaRemotesManager); + + let handler = PushHandler::new(config_loader, remotes_manager, ref_manager); + Ok(handler) + } } diff --git a/engine/src/errors/repository_error.rs b/engine/src/errors/repository_error.rs index faee1d0..1f99188 100644 --- a/engine/src/errors/repository_error.rs +++ b/engine/src/errors/repository_error.rs @@ -13,4 +13,8 @@ pub enum RepositoryError { /// Raised when the user's configuration directory cannot be determined. #[error("User's config directory not found")] ConfigDirNotFound, + + /// Raised when the repository is in a detached HEAD state. + #[error("Repository is in a detached HEAD state")] + DetachedHead, } diff --git a/engine/src/handlers.rs b/engine/src/handlers.rs index ff34bcb..948cb01 100644 --- a/engine/src/handlers.rs +++ b/engine/src/handlers.rs @@ -10,6 +10,7 @@ pub mod log; pub mod ls_files; pub mod ls_tree; pub mod plugins; +pub mod push; pub mod remote; pub mod restore; pub mod show; diff --git a/engine/src/handlers/clone/handlers.rs b/engine/src/handlers/clone/handlers.rs index c1bd622..bb933bc 100644 --- a/engine/src/handlers/clone/handlers.rs +++ b/engine/src/handlers/clone/handlers.rs @@ -10,7 +10,7 @@ use tempfile::TempDir; use crate::{ ConfigLoader, MevaConfigLoader, MevaRepository, RepositoryLayout, errors::{CloneError, EngineError, EngineResult, NetworkError}, - network::{PackfileCodec, SshConnectionParams, SshService, SshSession}, + network::{MevaRemotesManager, PackfileCodec, SshConnectionParams, SshService, SshSession}, object_storage::MevaObjectStorage, objects::MevaObject, ref_manager::{MevaRefManager, RefEntry}, @@ -92,7 +92,10 @@ impl CloneHandler { connection_params.client_key_path = client_key; connection_params.server_key_path = request.server_key.clone(); - let ssh_session = self.ssh_service.connect(&connection_params).await?; + let ssh_session = self + .ssh_service + .connect(&connection_params, !request.quiet) + .await?; Ok((ssh_session, connection_params, path)) } @@ -106,19 +109,21 @@ impl CloneHandler { temp_path: &Path, objects: &[(MevaObject, Vec)], refs: &[RefEntry], - remote_name: &str, + request: &Request, ) -> EngineResult> { let repository_layout = Arc::new(MevaRepositoryLayout::new(temp_path.to_path_buf())?); let object_storage = Arc::new(MevaObjectStorage::new(repository_layout.clone())); let ref_manager = Arc::new(MevaRefManager::new(repository_layout.clone())); + let remotes_manager = Arc::new(MevaRemotesManager); let repository = MevaRepository::new( repository_layout.clone(), Arc::new(MevaConfigLoader::default()), object_storage, ref_manager, + remotes_manager, ); - repository.clone(objects, refs, remote_name) + repository.clone(objects, refs, request) } } @@ -129,7 +134,11 @@ impl CloneOperations for CloneHandler { let (mut ssh_session, connection_params, path) = self.connect_ssh(&request).await?; let packfile_result = ssh_session - .run_upload_pack(&connection_params.repository_name, Vec::new()) + .run_upload_pack( + &connection_params.repository_name, + Vec::new(), + !request.quiet, + ) .await?; let refs = packfile_result.refs; @@ -145,9 +154,7 @@ impl CloneOperations for CloneHandler { let temp_dir = TempDir::new_in(parent_dir).map_err(EngineError::Io)?; let temp_path = temp_dir.path().to_path_buf(); - let clone_result = self - .temp_clone(&temp_path, &objects, &refs, &request.origin) - .await; + let clone_result = self.temp_clone(&temp_path, &objects, &refs, &request).await; match clone_result { Ok(ref_entries) => { diff --git a/engine/src/handlers/fetch/handlers.rs b/engine/src/handlers/fetch/handlers.rs index 8130689..43158f3 100644 --- a/engine/src/handlers/fetch/handlers.rs +++ b/engine/src/handlers/fetch/handlers.rs @@ -11,7 +11,8 @@ use crate::{ ConfigLoader, errors::EngineResult, network::{ - PackfileCodec, RemoteEntry, RemotesManager, SshConnectionParams, SshService, SshSession, + PackfileCodec, RemoteDirection, RemoteEntry, RemotesManager, SshConnectionParams, + SshService, SshSession, }, object_storage::ObjectStorage, ref_manager::{RefEntry, RefManager}, @@ -110,32 +111,6 @@ impl FetchHandler { Ok(()) } - /// Updates local remote-tracking references to match the state of the remote repository. - /// - /// This method iterates over the provided list of references (which are known to - /// be new or updated) and writes them to the local reference store. - /// - /// The reference names are automatically mapped from the server's namespace - /// (e.g., `refs/heads/main`) to the local remote-tracking namespace - /// (e.g., `refs/remotes/origin/main`). - /// - /// # Arguments - /// * `remote_name` - The name of the remote (e.g., "origin"). - /// * `remote_refs` - The list of server references to update locally. - fn update_remote_refs(&self, remote_name: &str, remote_refs: &[RefEntry]) -> EngineResult<()> { - for remote_ref in remote_refs { - let tracking_name = self - .ref_manager - .map_head_to_remote_ref(&remote_ref.name, remote_name); - - println!("Updating {} -> {}", tracking_name, remote_ref.commit_hash); - - let new_entry = RefEntry::new(&tracking_name, &remote_ref.commit_hash); - self.ref_manager.update_ref(&new_entry)?; - } - Ok(()) - } - /// Decodes the received packfile data and writes the objects to storage. /// /// This method takes the raw binary packfile data received from the server, @@ -144,12 +119,17 @@ impl FetchHandler { /// /// # Arguments /// * `packfile_data` - The raw bytes of the packfile. - fn process_packfile(&self, packfile_data: &[u8]) -> EngineResult<()> { - println!("Decoding objects..."); + /// * `verbose` - Whether to enable verbose output during processing. + fn process_packfile(&self, packfile_data: &[u8], verbose: bool) -> EngineResult<()> { + if verbose { + println!("Decoding objects..."); + } let objects = PackfileCodec::default().decode_packfile(packfile_data)?; - println!("Successfully decoded {} objects.", objects.len()); + if verbose { + println!("Successfully decoded {} objects.", objects.len()); + } self.object_storage.add_objects_from_packfile(&objects) } @@ -166,14 +146,18 @@ impl FetchHandler { request: &Request, ) -> EngineResult<(SshSession, SshConnectionParams)> { let remote_entry = self.get_remote_entry(&request.origin)?; - let mut connection_params = SshConnectionParams::try_from(&remote_entry.url)?; + let mut connection_params = + SshConnectionParams::try_from(remote_entry.url_for(RemoteDirection::Fetch))?; let client_key = self.get_user_signing_key()?; connection_params.client_key_path = client_key; - connection_params.server_key_path = remote_entry.pub_signing_key; + connection_params.server_key_path = + remote_entry.key_for(RemoteDirection::Fetch).to_path_buf(); Ok(( - self.ssh_service.connect(&connection_params).await?, + self.ssh_service + .connect(&connection_params, request.verbose) + .await?, connection_params, )) } @@ -243,12 +227,12 @@ impl FetchOperations for FetchHandler { .unique() .collect::>(); - let packfile_result = ssh_session - .run_upload_pack(&connection_params.repository_name, haves) + let upload_pack_result = ssh_session + .run_upload_pack(&connection_params.repository_name, haves, request.verbose) .await?; let heads_refs_prefix = self.ref_manager.heads_refs_prefix(); - let server_remote_refs = packfile_result + let server_remote_refs = upload_pack_result .refs .into_iter() .filter(|r| r.name.starts_with(&heads_refs_prefix)) @@ -262,12 +246,17 @@ impl FetchOperations for FetchHandler { self.find_refs_to_update(&request, &server_remote_refs, &local_remote_refs)?; if refs_to_update.is_empty() { - println!("Already up to date."); + println!(); + println!("Already up-to-date."); } else { - self.process_packfile(&packfile_result.packfile_data)?; - self.update_remote_refs(&request.origin, &refs_to_update)?; + self.process_packfile(&upload_pack_result.packfile_data, request.verbose)?; + self.ref_manager.update_remote_refs( + &request.origin, + &refs_to_update, + request.verbose, + )?; } - Ok(Response {}) + Ok(Response) } } diff --git a/engine/src/handlers/fetch/operations.rs b/engine/src/handlers/fetch/operations.rs index 2207dd7..6a48cf6 100644 --- a/engine/src/handlers/fetch/operations.rs +++ b/engine/src/handlers/fetch/operations.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use crate::errors::EngineResult; -#[derive(Debug)] +#[derive(Debug, Default)] pub struct Request { pub origin: String, pub branch: Option, @@ -10,11 +10,19 @@ pub struct Request { pub verbose: bool, } -#[derive(Debug)] -pub struct Response { - // TODO +impl Request { + pub fn new(origin: String, branch: Option) -> Self { + Self { + origin, + branch, + ..Default::default() + } + } } +#[derive(Debug)] +pub struct Response; + #[async_trait] pub trait FetchOperations { async fn fetch(&self, request: Request) -> EngineResult; diff --git a/engine/src/handlers/ls_files/models/ls_files_entry.rs b/engine/src/handlers/ls_files/models/ls_files_entry.rs index 9a9b306..0da6550 100644 --- a/engine/src/handlers/ls_files/models/ls_files_entry.rs +++ b/engine/src/handlers/ls_files/models/ls_files_entry.rs @@ -1,5 +1,7 @@ use std::fmt::Display; +use owo_colors::OwoColorize; + use crate::index::{file_mode::FileMode, stage::Stage}; /// Represents a single entry in an `ls-files` response. @@ -23,13 +25,24 @@ impl Display for LsFilesEntry { /// Formats an [`LsFilesEntry`] for human-readable output. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - LsFilesEntry::Path { path } => write!(f, "{path}"), + LsFilesEntry::Path { path } => { + write!(f, "{}", path.green()) + } LsFilesEntry::Entry { mode, object, stage, path, - } => write!(f, "{mode:o} {object} {stage} {path}"), + } => { + write!( + f, + "{} {} {} {}", + format!("{mode:o}").dimmed(), + object.yellow(), + stage.dimmed(), + path.green() + ) + } } } } diff --git a/engine/src/handlers/push.rs b/engine/src/handlers/push.rs new file mode 100644 index 0000000..f79684b --- /dev/null +++ b/engine/src/handlers/push.rs @@ -0,0 +1,5 @@ +mod handlers; +mod operations; + +pub use handlers::PushHandler; +pub use operations::*; diff --git a/engine/src/handlers/push/handlers.rs b/engine/src/handlers/push/handlers.rs new file mode 100644 index 0000000..4424541 --- /dev/null +++ b/engine/src/handlers/push/handlers.rs @@ -0,0 +1,189 @@ +use std::{path::PathBuf, sync::Arc}; + +use async_trait::async_trait; + +use crate::{ + ConfigLoader, + errors::{BranchError, EngineResult, RepositoryError}, + network::{ + RemoteDirection, RemoteEntry, RemotesManager, SshConnectionParams, SshService, SshSession, + ZERO_HASH, + }, + ref_manager::{RefEntry, RefManager}, +}; + +use super::{PushOperations, Request, Response}; + +/// Handles the logic for pushing local changes to a remote repository. +/// +/// The `PushHandler` orchestrates the interaction between the local reference manager, +/// the configuration system, and the SSH network service to execute the `receive-pack` +/// protocol on the remote server. +pub struct PushHandler { + ssh_service: SshService, + config_loader: Arc, + remotes_manager: Arc, + ref_manager: Arc, +} + +impl PushHandler { + /// Creates a new instance of the [`PushHandler`]. + pub fn new( + config_loader: Arc, + remotes_manager: Arc, + ref_manager: Arc, + ) -> Self { + Self { + ssh_service: SshService, + config_loader, + remotes_manager, + ref_manager, + } + } + + /// Entry point to execute a push operation based on the provided request. + /// + /// This method delegates the actual logic to the [`PushOperations::push`] implementation. + pub async fn handle_push(&self, request: Request) -> EngineResult { + self.push(request).await + } + + /// Retrieves configuration for a specific remote by name. + /// + /// # Arguments + /// + /// * `name` - The name of the remote (e.g., "origin"). + fn get_remote_entry(&self, name: &str) -> EngineResult { + self.remotes_manager.get_remote(name) + } + + /// Retrieves the path to the user's SSH signing key from the configuration. + /// + /// Looks for the `user.signing_key` setting. + fn get_user_signing_key(&self) -> EngineResult { + Ok(PathBuf::from( + self.config_loader.get("user.signing_key", None)?, + )) + } + + /// Establishes an SSH connection to the remote server. + /// + /// This method resolves the remote URL, the client's private key, and the + /// server's public key (host key) before initiating the connection. + /// + /// # Returns + /// + /// A tuple containing the active [`SshSession`] and the resolved [`SshConnectionParams`]. + async fn connect_ssh( + &self, + request: &Request, + ) -> EngineResult<(SshSession, SshConnectionParams)> { + let remote_entry = self.get_remote_entry(&request.origin)?; + let mut connection_params = + SshConnectionParams::try_from(remote_entry.url_for(RemoteDirection::Push))?; + + let client_key = self.get_user_signing_key()?; + connection_params.client_key_path = client_key; + connection_params.server_key_path = + remote_entry.key_for(RemoteDirection::Push).to_path_buf(); + + Ok(( + self.ssh_service + .connect(&connection_params, request.verbose) + .await?, + connection_params, + )) + } + + /// Determines which references (branches) need to be updated on the remote. + /// + /// This method constructs a list of [`RefEntry`] objects based on the request flags: + /// * `delete` - Creates an update with `ZERO_HASH` to delete the branch on the remote. + /// * `all` - Collects all local heads (branches) to be pushed. + /// * Default - Resolves the specific branch (or HEAD) requested. + fn prepare_updates(&self, request: &Request) -> EngineResult> { + let mut entries = Vec::new(); + let zero_hash = ZERO_HASH.to_string(); + let heads_refs_prefix = self.ref_manager.heads_refs_prefix(); + + if request.delete { + if let Some(branch_name) = &request.branch { + entries.push(RefEntry { + name: format!("{heads_refs_prefix}{branch_name}"), + commit_hash: zero_hash, + }); + } + return Ok(entries); + } + + if request.all { + let local_branches = self.ref_manager.collect_refs_heads()?; + for branch in local_branches { + entries.push(RefEntry { + name: branch.name, + commit_hash: branch.commit_hash, + }); + } + return Ok(entries); + } + + let ref_entry = self.resolve_ref_entry(request)?; + entries.push(ref_entry); + + Ok(entries) + } + + /// Resolves the specific local reference to be pushed. + /// + /// If a branch name is provided in the request, it resolves that specific branch. + /// If no branch name is provided, it attempts to resolve the current `HEAD`. + /// + /// # Errors + /// + /// Returns [`RepositoryError::DetachedHead`] if trying to push HEAD while in detached state. + /// Returns [`BranchError::BranchNotFound`] if the specified branch does not exist. + fn resolve_ref_entry(&self, request: &Request) -> EngineResult { + let branch_name = match &request.branch { + Some(name) => format!("{}{}", self.ref_manager.heads_refs_prefix(), name.clone()), + None => { + let head = self.ref_manager.read_head()?; + if head.is_symbolic() { + head.target.clone() + } else { + return Err(RepositoryError::DetachedHead.into()); + } + } + }; + + let ref_entry = self + .ref_manager + .read_ref(&branch_name)? + .ok_or(BranchError::BranchNotFound(branch_name))?; + + Ok(ref_entry) + } +} + +#[async_trait] +impl PushOperations for PushHandler { + /// Orchestrates the entire push process. + async fn push(&self, request: Request) -> EngineResult { + let (mut ssh_session, connection_params) = self.connect_ssh(&request).await?; + + let updates = self.prepare_updates(&request)?; + + let receive_pack_result = ssh_session + .run_receive_pack( + &connection_params.repository_name, + &updates, + request.verbose, + ) + .await?; + + Ok(Response::new( + request.origin, + receive_pack_result.unpack_successful, + receive_pack_result.receive_results, + )) + } +} diff --git a/engine/src/handlers/push/operations.rs b/engine/src/handlers/push/operations.rs new file mode 100644 index 0000000..aabf53d --- /dev/null +++ b/engine/src/handlers/push/operations.rs @@ -0,0 +1,88 @@ +use std::fmt::Display; + +use async_trait::async_trait; +use owo_colors::OwoColorize; + +use crate::{ + errors::EngineResult, + network::{ReceiveResult, ReceiveStatus}, +}; + +#[derive(Debug, Default)] +pub struct Request { + pub origin: String, + pub branch: Option, + pub all: bool, + pub delete: bool, + pub verbose: bool, +} + +impl Request { + pub fn new(origin: String, branch: Option) -> Self { + Self { + origin, + branch, + ..Default::default() + } + } +} + +#[derive(Debug)] +pub struct Response { + pub unpack_successful: bool, + pub receive_results: Vec, + pub origin: String, +} + +impl Response { + pub fn new( + origin: String, + unpack_successful: bool, + receive_results: Vec, + ) -> Self { + Self { + origin, + unpack_successful, + receive_results, + } + } + + /// Checks if there are any failures in the receive results. + pub fn has_failures(&self) -> bool { + self.receive_results + .iter() + .any(|receive_result| matches!(receive_result.status, ReceiveStatus::Failure(_))) + } +} + +impl Display for Response { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.unpack_successful { + writeln!( + f, + "Unpacking objects: {} {}", + "100%".green(), + "(done)".dimmed() + )?; + } else { + writeln!(f, "Unpacking objects: {}", "failed".red().bold())?; + return Ok(()); + } + + writeln!(f, "To {}", self.origin.bold())?; + for result in &self.receive_results { + writeln!(f, "{result}")?; + } + + if self.has_failures() { + writeln!(f, "{}", "Error: failed to push some refs".red().bold())?; + } + + Ok(()) + } +} + +#[async_trait] +pub trait PushOperations { + async fn push(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/handlers/remote/handlers.rs b/engine/src/handlers/remote/handlers.rs index a0c0af2..606e09d 100644 --- a/engine/src/handlers/remote/handlers.rs +++ b/engine/src/handlers/remote/handlers.rs @@ -221,8 +221,9 @@ impl RemoteOperations for RemoteHandler { Ok(AddResponse { remote: self.remotes_manager.add_remote( &request.name, - request.url, + &request.url, &request.pub_key, + None, )?, }) } @@ -266,6 +267,7 @@ impl RemoteOperations for RemoteHandler { self.remotes_manager.set_remote_url( &request.name, request.new_url.as_ref(), + request.new_server_key.as_ref(), &request.direction, ) } @@ -291,13 +293,15 @@ impl RemoteOperations for RemoteHandler { connection_params.client_key_path = client_key; connection_params.server_key_path = remote.pub_signing_key.clone(); - let mut ssh_session = self.ssh_service.connect(&connection_params).await?; + let mut ssh_session = self + .ssh_service + .connect(&connection_params, request.verbose) + .await?; let packfile_result = ssh_session - .run_ls_remotes(&connection_params.repository_name) + .run_ls_remotes(&connection_params.repository_name, request.verbose) .await?; - // dbg!(server_refs, local_remotes, local_heads); // TODO: coś takiego musi być w lokalnym configu (ale to chyba merge będzie dodawał??) // [branch.develop] // remote = origin diff --git a/engine/src/handlers/remote/operations.rs b/engine/src/handlers/remote/operations.rs index 7603fab..6c63a46 100644 --- a/engine/src/handlers/remote/operations.rs +++ b/engine/src/handlers/remote/operations.rs @@ -16,7 +16,6 @@ pub struct AddRequest { pub name: String, pub url: Url, pub pub_key: PathBuf, - pub fetch: bool, // so far unused } #[derive(Debug)] @@ -61,6 +60,7 @@ pub struct RenameResponse { pub struct SetUrlRequest { pub name: String, pub new_url: Url, + pub new_server_key: PathBuf, pub direction: RemoteDirection, } @@ -73,6 +73,7 @@ pub struct ListResponse { pub struct ShowRequest { pub name: String, pub no_fetch: bool, + pub verbose: bool, } #[derive(Debug)] diff --git a/engine/src/network.rs b/engine/src/network.rs index 85a2d42..52f2426 100644 --- a/engine/src/network.rs +++ b/engine/src/network.rs @@ -10,10 +10,11 @@ mod ssh_session; use client_handler::ClientHandler; pub use common::{ - ChannelBand, PktLine, RemoteDirection, RemoteEntry, SessionExtension, create_channel_band, - create_pkt_line, + CHUNK_SIZE, ChannelBand, PKT_CHUNK_SIZE, PktLine, RemoteDirection, RemoteEntry, + SessionExtension, ZERO_HASH, create_channel_band, create_pkt_line, parse_next_pkt_line, }; pub use packfiles::{PackfileCodec, PackfileError}; +pub use protocols::{ReceiveResult, ReceiveStatus}; pub use remotes::{MevaRemotesManager, RemotesManager}; pub use ssh_connection_params::SshConnectionParams; pub use ssh_service::SshService; diff --git a/engine/src/network/client_handler.rs b/engine/src/network/client_handler.rs index 0eb6790..4d88210 100644 --- a/engine/src/network/client_handler.rs +++ b/engine/src/network/client_handler.rs @@ -41,13 +41,10 @@ impl client::Handler for ClientHandler { &mut self, server_public_key: &key::PublicKey, ) -> Result { - println!("Server presented public key: {}", server_public_key.name()); - if server_public_key == &self.server_public_key { - println!("Server key is valid (matches the expected one)."); Ok(true) } else { - eprintln!("Server key is INVALID!"); + eprintln!("Server key is invalid!"); Ok(false) } } diff --git a/engine/src/network/common.rs b/engine/src/network/common.rs index 0d03341..d760a6a 100644 --- a/engine/src/network/common.rs +++ b/engine/src/network/common.rs @@ -4,6 +4,15 @@ mod remote_entry; mod session_extension; pub use channel_band::{ChannelBand, create_channel_band}; -pub use pkt_line::{PktLine, create_pkt_line}; +pub use pkt_line::{PktLine, create_pkt_line, parse_next_pkt_line}; pub use remote_entry::{RemoteDirection, RemoteEntry}; pub use session_extension::SessionExtension; + +// Stream data in chunks to avoid choking the connection +pub const CHUNK_SIZE: usize = 65_000; + +/// Maximum size of a single pkt-line chunk +pub const PKT_CHUNK_SIZE: usize = 65516; + +/// A constant representing a zero hash (40 zeros). +pub const ZERO_HASH: &str = "0000000000000000000000000000000000000000"; diff --git a/engine/src/network/common/pkt_line.rs b/engine/src/network/common/pkt_line.rs index a8ce782..f9efe76 100644 --- a/engine/src/network/common/pkt_line.rs +++ b/engine/src/network/common/pkt_line.rs @@ -1,3 +1,7 @@ +use std::str::from_utf8; + +use crate::errors::{EngineResult, NetworkError}; + /// Represents a parsed packet-line (pkt-line) from the network stream. /// /// The pkt-line format is a length-prefixed framing protocol, @@ -36,3 +40,33 @@ pub fn create_pkt_line(data: &str) -> String { let len = data.len() + 4; format!("{len:04x}{data}") } + +/// Attempts to parse the next `pkt-line` from the buffer. +/// +/// # Returns +/// * [`PktLine::Flush`] if length is "0000". +/// * [`PktLine::Incomplete`] if buffer is shorter than specified length. +/// * [`PktLine::Payload(Vec)`] containing the payload bytes otherwise. +pub fn parse_next_pkt_line(buffer: &mut Vec) -> EngineResult { + if buffer.len() < 4 { + return Ok(PktLine::Incomplete); + } + + let len_str = from_utf8(&buffer[0..4]).map_err(NetworkError::from)?; + + let len = usize::from_str_radix(len_str, 16).map_err(NetworkError::from)?; + + if len == 0 { + buffer.drain(0..4); + return Ok(PktLine::Flush); + } + + if buffer.len() < len { + return Ok(PktLine::Incomplete); + } + + let line_data = buffer[4..len].to_vec(); + buffer.drain(0..len); + + Ok(PktLine::Payload(line_data)) +} diff --git a/engine/src/network/common/remote_entry.rs b/engine/src/network/common/remote_entry.rs index 2a42364..f50ad38 100644 --- a/engine/src/network/common/remote_entry.rs +++ b/engine/src/network/common/remote_entry.rs @@ -66,7 +66,7 @@ impl RemoteEntry { self } - /// Constructs a `RemoteEntry` from a key-value map (typically from a config file). + /// Constructs a [`RemoteEntry`] from a key-value map (typically from a config file). /// /// # Required Keys /// * `url`: The fetch URL. diff --git a/engine/src/network/protocols.rs b/engine/src/network/protocols.rs index 4f7d025..cbcc14e 100644 --- a/engine/src/network/protocols.rs +++ b/engine/src/network/protocols.rs @@ -1,3 +1,5 @@ +mod receive_pack; mod upload_pack; +pub use receive_pack::{ReceivePackProtocol, ReceivePackResult, ReceiveResult, ReceiveStatus}; pub use upload_pack::{UploadPackProtocol, UploadPackResult}; diff --git a/engine/src/network/protocols/receive_pack.rs b/engine/src/network/protocols/receive_pack.rs new file mode 100644 index 0000000..f21e554 --- /dev/null +++ b/engine/src/network/protocols/receive_pack.rs @@ -0,0 +1,271 @@ +mod receive_pack_result; +mod receive_pack_state; + +use std::str::from_utf8; + +pub use receive_pack_result::{ReceivePackResult, ReceiveResult, ReceiveStatus}; +pub use receive_pack_state::ReceivePackState; + +use crate::{ + errors::{EngineResult, NetworkError}, + network::{PktLine, ZERO_HASH, create_pkt_line, parse_next_pkt_line}, + ref_manager::RefEntry, +}; +use russh::{Channel, client::Msg}; + +/// Implements the client-side logic for the `receive-pack` protocol (push). +#[derive(Debug, Default)] +pub struct ReceivePackProtocol { + /// The result of the receive-pack operation after completion. + pub result: ReceivePackResult, + + /// References discovered from the remote server. + pub server_refs: Vec, + + /// The hash of the HEAD reference on the remote (if exists). + pub remote_head: Option, + + /// Current state of the protocol. + state: ReceivePackState, + + /// Internal buffer for parsing pkt-lines. + buffer: Vec, + + /// The updates the local client *wants* to perform. + proposed_updates: Vec, + + /// Flag to track if we have handled the initial flush after the service header. + header_flush_received: bool, +} + +impl ReceivePackProtocol { + /// Creates a new instance of the protocol with the intended updates. + /// + /// # Arguments + /// + /// * `updates` - A list of reference updates (e.g., branch moves) to be pushed. + pub fn new(updates: Vec) -> Self { + Self { + proposed_updates: updates, + ..Default::default() + } + } + + /// Checks if the protocol has finished successfully. + pub fn is_complete(&self) -> bool { + self.state == ReceivePackState::Complete + } + + /// Signals the protocol that the binary packfile has been fully streamed. + /// This transitions the state from sending data to waiting for the server's report. + pub fn mark_packfile_sent(&mut self) { + if self.state == ReceivePackState::Packfile { + self.state = ReceivePackState::ReceivingReport; + } + } + + /// Processes incoming data from the server. + /// + /// # Returns + /// * `Ok(true)`: Indicates that the client should now start streaming the packfile. + /// * `Ok(false)`: Normal processing, no immediate action required from the caller. + pub async fn process_data( + &mut self, + data: &[u8], + channel: &mut Channel, + verbose: bool, + ) -> EngineResult { + self.buffer.extend_from_slice(data); + + let mut ready_to_send_pack = false; + + loop { + if self.state == ReceivePackState::Complete { + break; + } + + let packet = parse_next_pkt_line(&mut self.buffer)?; + + match packet { + PktLine::Incomplete => { + break; + } + PktLine::Payload(payload) => { + self.handle_line(payload, verbose).await?; + } + PktLine::Flush => { + if self.handle_flush(channel, verbose).await? { + ready_to_send_pack = true; + } + } + } + } + Ok(ready_to_send_pack) + } + + /// Handles a single payload line received from the server. + /// + /// The logic depends on the current state (Discovery or ReceivingReport). + async fn handle_line(&mut self, payload: Vec, verbose: bool) -> EngineResult<()> { + let line_str = from_utf8(&payload).map_err(NetworkError::from)?.trim(); + + match self.state { + ReceivePackState::Discovery => { + if line_str.starts_with('#') { + return Ok(()); + } + + let clean_line = line_str.split('\0').next().unwrap_or(line_str); + let parts: Vec<&str> = clean_line.split_whitespace().collect(); + if parts.len() >= 2 { + let sha = parts[0]; + let name = parts[1]; + + if name == "HEAD" { + self.remote_head = Some(sha.to_string()); + } else { + self.server_refs.push(RefEntry::new(name, sha)); + } + } + } + ReceivePackState::ReceivingReport => { + if line_str.starts_with("unpack ok") { + self.result.unpack_successful = true; + } else if line_str.starts_with("ok") { + // Format: "ok refs/heads/master" + let ref_name = line_str.split_whitespace().nth(1).unwrap_or("?"); + self.result + .receive_results + .push(self.create_result(ref_name, ReceiveStatus::Success)); + } else if line_str.starts_with("ng") { + // Format: "ng refs/heads/master non-fast-forward" + let parts: Vec<&str> = line_str.split_whitespace().collect(); + let ref_name = parts.get(1).unwrap_or(&"?"); + let reason = parts[2..].join(" "); + + self.result + .receive_results + .push(self.create_result(ref_name, ReceiveStatus::Failure(reason))); + } + } + _ => { + if verbose { + println!("Received packet in state {}: {line_str}", self.state); + } + } + } + Ok(()) + } + + /// Handles a flush packet (`0000`) received from the server. + /// + /// Returns `true` if the state transitions to `Packfile`, indicating + /// that the client should start streaming binary data. + async fn handle_flush( + &mut self, + channel: &mut Channel, + verbose: bool, + ) -> EngineResult { + match self.state { + ReceivePackState::Discovery => { + if !self.header_flush_received { + self.header_flush_received = true; + return Ok(false); + } + + self.state = ReceivePackState::SendingCommands; + let updates_count = self.send_update_commands(channel, verbose).await?; + + if updates_count > 0 { + self.state = ReceivePackState::Packfile; + Ok(true) + } else { + self.state = ReceivePackState::ReceivingReport; + Ok(false) + } + } + ReceivePackState::ReceivingReport => { + self.state = ReceivePackState::Complete; + Ok(false) + } + _ => { + if verbose { + println!("Received flush in state {}, no action taken.", self.state); + } + Ok(false) + } + } + } + + /// Sends update commands (e.g., `old_sha new_sha ref_name`) to the server. + /// + /// This happens after the Discovery phase. If no updates are needed (everything up-to-date), + /// it sends a flush packet immediately. + /// + /// # Returns + /// The number of update commands sent. + async fn send_update_commands( + &mut self, + channel: &mut Channel, + verbose: bool, + ) -> EngineResult { + let zero_id = ZERO_HASH.to_string(); + let mut updates_sent = 0; + + for update in &self.proposed_updates { + let old_sha = self + .server_refs + .iter() + .find(|r| r.name == update.name) + .map(|r| r.commit_hash.as_str()) + .unwrap_or(&zero_id); + + if old_sha == update.commit_hash { + if verbose { + println!("Ref {} is up to date.", update.name); + } + continue; + } + + let command = format!("{old_sha} {} {}", update.commit_hash, update.name); + + let pkt = create_pkt_line(&format!("{command}\n")); + channel + .data(pkt.as_bytes().as_ref()) + .await + .map_err(NetworkError::from)?; + + updates_sent += 1; + } + + channel + .data(b"0000".as_ref()) + .await + .map_err(NetworkError::from)?; + + if updates_sent == 0 && verbose { + println!("Everything up-to-date."); + } + + Ok(updates_sent) + } + + /// Helper to construct a [`ReceiveResult`] object from status data. + fn create_result(&self, ref_name: &str, status: ReceiveStatus) -> ReceiveResult { + let old_hash = self + .server_refs + .iter() + .find(|r| r.name == ref_name) + .map(|r| r.commit_hash.clone()) + .unwrap_or_else(|| ZERO_HASH.to_string()); + + let new_hash = self + .proposed_updates + .iter() + .find(|r| r.name == ref_name) + .map(|r| r.commit_hash.clone()) + .unwrap_or_else(|| ZERO_HASH.to_string()); + + ReceiveResult::new(status, ref_name.to_string(), old_hash, new_hash) + } +} diff --git a/engine/src/network/protocols/receive_pack/receive_pack_result.rs b/engine/src/network/protocols/receive_pack/receive_pack_result.rs new file mode 100644 index 0000000..053aa14 --- /dev/null +++ b/engine/src/network/protocols/receive_pack/receive_pack_result.rs @@ -0,0 +1,148 @@ +use std::fmt::Display; + +use owo_colors::OwoColorize; + +/// Represents the overall result of a `receive-pack` (push) operation returned by the remote server. +/// +/// This struct aggregates the status of the packfile unpacking process and the +/// individual results for every reference update requested by the client. +#[derive(Debug, Default)] +pub struct ReceivePackResult { + /// Indicates whether the packfile containing objects was successfully unpacked and verified by the server. + /// If this is false, no references were updated. + pub unpack_successful: bool, + + /// A list of results for each reference update requested by the client. + /// Contains success or failure details for each ref. + pub receive_results: Vec, +} + +/// Detailed status of a specific reference update attempt. +#[derive(Debug, Clone)] +pub struct ReceiveResult { + /// The outcome of the update (Success or Failure with reason). + pub status: ReceiveStatus, + + /// The full name of the reference (e.g., `refs/heads/master`). + pub ref_name: String, + + /// The commit hash of the reference *before* the update. + /// If this is the zero-hash, it indicates branch creation. + pub old_hash: String, + + /// The commit hash of the reference *after* the update. + /// If this is the zero-hash, it indicates branch deletion. + pub new_hash: String, +} + +impl ReceiveResult { + /// Creates a new [`ReceiveResult`] instance. + pub fn new( + status: ReceiveStatus, + ref_name: String, + old_hash: String, + new_hash: String, + ) -> Self { + Self { + status, + ref_name, + old_hash, + new_hash, + } + } + + /// Checks if this update represents the creation of a new branch. + pub fn is_new_branch(&self) -> bool { + Self::is_zero_hash(&self.old_hash) + } + + /// Checks if this update represents the deletion of a branch. + pub fn is_deletion(&self) -> bool { + Self::is_zero_hash(&self.new_hash) + } + + /// Returns a shortened version (first 7 characters) of the old hash. + pub fn old_hash_short(&self) -> &str { + &self.old_hash[0..7] + } + + /// Returns a shortened version (first 7 characters) of the new hash. + pub fn new_hash_short(&self) -> &str { + &self.new_hash[0..7] + } + + /// Helper function to determine if a given hash is the zero-hash. + fn is_zero_hash(hash: &str) -> bool { + for digit in hash.chars() { + if digit != '0' { + return false; + } + } + true + } +} + +/// Represents the success or failure status of a specific reference update. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReceiveStatus { + /// The reference was updated successfully. + Success, + /// The update failed. Contains the error message returned by the server + /// (e.g., "non-fast-forward", "failed to lock"). + Failure(String), +} + +impl Display for ReceiveResult { + /// Formats the receive result for display, using colors to indicate status. + /// + /// * **New Branch**: Displays `+ [new branch]` in green. + /// * **Deletion**: Displays `- [deleted]` in red. + /// * **Update**: Displays `old..new` hash range in green. + /// * **Failure**: Displays `! [rejected]` in red with the failure reason. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.status { + ReceiveStatus::Success => { + if self.is_new_branch() { + write!( + f, + " {} {} {} -> {}", + "+".green(), + "[new branch]".green(), + self.ref_name.cyan(), + self.ref_name.cyan() + )?; + } else if self.is_deletion() { + write!( + f, + " {} {} {}", + "-".red(), + "[deleted]".red(), + self.ref_name.cyan() + )?; + } else { + let short_old = self.old_hash_short(); + let short_new = self.new_hash_short(); + write!( + f, + " {} {} -> {}", + format!("{short_old}..{short_new}").yellow(), + self.ref_name.cyan(), + self.ref_name.cyan() + )?; + } + } + ReceiveStatus::Failure(reason) => { + write!( + f, + " {} {} {} -> {} ({})", + "!".red(), + "[rejected]".red(), + self.ref_name.cyan(), + self.ref_name.cyan(), + reason.dimmed() + )?; + } + } + Ok(()) + } +} diff --git a/engine/src/network/protocols/receive_pack/receive_pack_state.rs b/engine/src/network/protocols/receive_pack/receive_pack_state.rs new file mode 100644 index 0000000..7c5d747 --- /dev/null +++ b/engine/src/network/protocols/receive_pack/receive_pack_state.rs @@ -0,0 +1,24 @@ +use strum_macros::Display; + +/// Represents the various states of the `meva-receive-pack` protocol state machine. +#[derive(Debug, Default, PartialEq, Eq, Display)] +pub enum ReceivePackState { + /// The initial state where the server advertises its capabilities and available references (refs) + /// to the connecting client. + #[default] + Discovery, + + /// The client is currently calculating and sending update commands to the server. + SendingCommands, + + /// The state of generating and streaming the binary packfile containing + /// the requested objects and the checksum. + Packfile, + + /// The client is waiting for the server to confirm success/failure. + ReceivingReport, + + /// Indicates that the operation has completed successfully and the connection + /// can be closed. + Complete, +} diff --git a/engine/src/network/protocols/upload_pack.rs b/engine/src/network/protocols/upload_pack.rs index 4be246a..2bcb0e8 100644 --- a/engine/src/network/protocols/upload_pack.rs +++ b/engine/src/network/protocols/upload_pack.rs @@ -2,6 +2,7 @@ mod upload_pack_result; mod upload_pack_state; use itertools::Itertools; +use owo_colors::OwoColorize; pub use upload_pack_result::UploadPackResult; pub use upload_pack_state::UploadPackState; @@ -11,7 +12,7 @@ use russh::{Channel, client::Msg}; use crate::{ errors::{EngineResult, NetworkError}, - network::{PktLine, common::ChannelBand, create_pkt_line}, + network::{PktLine, common::ChannelBand, create_pkt_line, parse_next_pkt_line}, ref_manager::RefEntry, }; @@ -83,11 +84,12 @@ impl UploadPackProtocol { &mut self, data: &[u8], channel: &mut Channel, + verbose: bool, ) -> EngineResult { self.buffer.extend_from_slice(data); loop { - let packet = self.parse_next_pkt_line()?; + let packet = parse_next_pkt_line(&mut self.buffer)?; match packet { PktLine::Incomplete => { @@ -95,10 +97,10 @@ impl UploadPackProtocol { break; } PktLine::Payload(payload) => { - self.handle_line(payload).await?; + self.handle_line(payload, verbose).await?; } PktLine::Flush => { - self.handle_flush(channel).await?; + self.handle_flush(channel, verbose).await?; } } @@ -109,66 +111,37 @@ impl UploadPackProtocol { Ok(false) } - /// Attempts to parse the next `pkt-line` from the internal buffer. - /// - /// A `pkt-line` consists of a 4-byte hex length prefix followed by the payload. - /// - If length is "0000", it returns [`PktLine::Flush`]. - /// - If buffer is shorter than the specified length, returns [`PktLine::Incomplete`]. - fn parse_next_pkt_line(&mut self) -> EngineResult { - if self.buffer.len() < 4 { - return Ok(PktLine::Incomplete); - } - - let len_str = from_utf8(&self.buffer[0..4]).map_err(NetworkError::from)?; - - let len = usize::from_str_radix(len_str, 16).map_err(NetworkError::from)?; - - if len == 0 { - self.buffer.drain(0..4); - return Ok(PktLine::Flush); - } - - if self.buffer.len() < len { - return Ok(PktLine::Incomplete); - } - - let line_data = self.buffer[4..len].to_vec(); - self.buffer.drain(0..len); - - Ok(PktLine::Payload(line_data)) - } - /// Handles a parsed payload packet based on the current protocol state. - async fn handle_line(&mut self, payload: Vec) -> EngineResult<()> { + async fn handle_line(&mut self, payload: Vec, verbose: bool) -> EngineResult<()> { match self.state { - UploadPackState::Discovery => { - let line_str = from_utf8(&payload).map_err(NetworkError::from)?; - println!("Received line: {line_str}"); - } UploadPackState::ReceivingRefs => { let line_str = from_utf8(&payload).map_err(NetworkError::from)?; - println!("Received line: {line_str}"); if !line_str.starts_with('#') && let Some(sha) = line_str.split_whitespace().next() { let ref_name = line_str.split_whitespace().nth(1).unwrap_or("").to_string(); - println!("Received ref: {sha} ({ref_name})"); + if verbose { + println!("Received ref: {} ({ref_name})", sha.yellow()); + } self.refs.push(RefEntry::new(&ref_name, sha)); } } UploadPackState::Negotiation => { let line_str = from_utf8(&payload).map_err(NetworkError::from)?; - println!("Received line: {line_str}"); if line_str.starts_with("NAK") { - println!("Received NAK. Waiting for packfile..."); + if verbose { + println!("Received NAK. Waiting for packfile..."); + } self.state = UploadPackState::Packfile; } else if line_str.starts_with("ACK") { - println!("Received ACK. Waiting for packfile..."); + if verbose { + println!("Received ACK. Waiting for packfile..."); + } self.state = UploadPackState::Packfile; } else { - eprintln!("Unexpected line in Negotiation state: {line_str}"); + eprintln!("Unexpected line during negotiation: {line_str}"); } } UploadPackState::Packfile => { @@ -182,12 +155,14 @@ impl UploadPackProtocol { match channel_band.try_into()? { ChannelBand::Packfile => { - println!("Received packfile data ({} bytes)", data.len()); + if verbose { + println!("Received packfile data ({} bytes)", data.len()); + } self.packfile_data.extend_from_slice(data); } ChannelBand::Progress => { let progress_msg = from_utf8(data).unwrap_or("[progress error]").trim(); - eprintln!("Remote: {progress_msg}"); + eprintln!("{} {}", "Remote: ".dimmed(), progress_msg.green()); } ChannelBand::Error => { let error_msg = from_utf8(data).unwrap_or("[remote error]").trim(); @@ -199,25 +174,30 @@ impl UploadPackProtocol { } } } - UploadPackState::Complete => {} + _ => {} } Ok(()) } /// Handles a "flush" packet (0000), which signals a transition or end of a list. - async fn handle_flush(&mut self, channel: &mut Channel) -> EngineResult<()> { - println!("Received flush-packet (0000)"); - + async fn handle_flush( + &mut self, + channel: &mut Channel, + verbose: bool, + ) -> EngineResult<()> { match self.state { UploadPackState::Discovery => { - println!("Finished discovery. Waiting for references..."); + if verbose { + println!("Finished discovery. Waiting for references..."); + } self.state = UploadPackState::ReceivingRefs; } UploadPackState::ReceivingRefs => { - println!("Finished references."); + if verbose { + println!("Finished receiving references."); + } if self.discovery_only { - println!("Discovery only mode. Sending flush and closing."); channel .data(b"0000".as_ref()) .await @@ -227,7 +207,6 @@ impl UploadPackProtocol { return Ok(()); } - println!("Sending 'want'..."); self.wants = self .refs .iter() @@ -241,7 +220,6 @@ impl UploadPackProtocol { .data(want_line.as_bytes().as_ref()) .await .map_err(NetworkError::from)?; - print!("Sending: {want_line}"); } for sha in &self.haves { @@ -250,7 +228,6 @@ impl UploadPackProtocol { .data(have_line.as_bytes().as_ref()) .await .map_err(NetworkError::from)?; - print!("Sending: {have_line}"); } channel @@ -263,16 +240,16 @@ impl UploadPackProtocol { .data(done_line.as_bytes().as_ref()) .await .map_err(NetworkError::from)?; - println!("Sending 'done'"); self.state = UploadPackState::Negotiation; } - UploadPackState::Negotiation => {} UploadPackState::Packfile => { - println!("Finished transferring packfile."); + if verbose { + println!("Finished transferring packfile."); + } self.state = UploadPackState::Complete; } - UploadPackState::Complete => {} + _ => {} } Ok(()) } diff --git a/engine/src/network/protocols/upload_pack/upload_pack_state.rs b/engine/src/network/protocols/upload_pack/upload_pack_state.rs index 7cf301c..b3ac49b 100644 --- a/engine/src/network/protocols/upload_pack/upload_pack_state.rs +++ b/engine/src/network/protocols/upload_pack/upload_pack_state.rs @@ -1,8 +1,7 @@ -/// Represents the lifecycle states of the `upload-pack` protocol execution. -/// -/// This state machine tracks the server-side progress during a fetch or clone operation, -/// transitioning from the initial handshake to the final data transfer. -#[derive(Debug, Default, PartialEq, Eq)] +use strum_macros::Display; + +/// Represents the various states of the `meva-upload-pack` protocol state machine. +#[derive(Debug, Default, PartialEq, Eq, Display)] pub enum UploadPackState { /// The initial state where the server advertises its capabilities and available references (refs) /// to the connecting client. diff --git a/engine/src/network/remotes.rs b/engine/src/network/remotes.rs index 2cb1a42..fea831e 100644 --- a/engine/src/network/remotes.rs +++ b/engine/src/network/remotes.rs @@ -2,7 +2,7 @@ mod meva_remotes_manager; pub use meva_remotes_manager::MevaRemotesManager; -use std::{collections::HashMap, path::Path}; +use std::{collections::HashMap, fmt::Debug, path::Path}; use url::Url; @@ -12,7 +12,7 @@ use crate::{ }; /// Defines the interface for managing remote repository configurations. -pub trait RemotesManager: Send + Sync { +pub trait RemotesManager: Send + Sync + Debug { /// Registers a new remote with the specified configuration. /// /// # Arguments @@ -23,8 +23,13 @@ pub trait RemotesManager: Send + Sync { /// /// # Returns /// The created `RemoteEntry` on success. - fn add_remote(&self, name: &str, url: Url, pub_signing_key: &Path) - -> EngineResult; + fn add_remote( + &self, + name: &str, + url: &Url, + pub_signing_key: &Path, + config_path: Option<&Path>, + ) -> EngineResult; /// Retrieves the configuration for a specific remote by name. /// @@ -59,6 +64,7 @@ pub trait RemotesManager: Send + Sync { &self, name: &str, new_url: &str, + new_server_key: &Path, direction: &RemoteDirection, ) -> EngineResult<()>; diff --git a/engine/src/network/remotes/meva_remotes_manager.rs b/engine/src/network/remotes/meva_remotes_manager.rs index 4ecb01c..e96a801 100644 --- a/engine/src/network/remotes/meva_remotes_manager.rs +++ b/engine/src/network/remotes/meva_remotes_manager.rs @@ -1,5 +1,6 @@ use std::{collections::HashMap, path::Path}; +use shared::PathToString; use url::Url; use crate::{ @@ -15,6 +16,7 @@ use super::{RemoteDirection, RemoteEntry, RemotesManager}; /// This manager handles the storage and retrieval of remote repository configurations /// by interacting directly with the local configuration file (typically `.meva/config`). /// It maps high-level remote operations to low-level TOML document edits. +#[derive(Debug)] pub struct MevaRemotesManager; impl MevaRemotesManager { @@ -47,19 +49,38 @@ impl MevaRemotesManager { RemoteDirection::Push => format!("{table_name}.push_url"), } } + + /// Resolves the specific configuration key for a server public key based on the operation direction. + /// + /// This distinguishes between the standard fetch server key and the optional push server key. + /// + /// # Returns + /// * If `direction` is `Fetch`: returns "remotes.{name}.server_key" + /// * If `direction` is `Push`: returns "remotes.{name}.push + fn get_remote_server_key_keys(&self, name: &str, direction: &RemoteDirection) -> String { + let table_name = self.get_remote_key(name); + match direction { + RemoteDirection::Fetch => format!("{table_name}.server_key"), + RemoteDirection::Push => format!("{table_name}.push_server_key"), + } + } } impl RemotesManager for MevaRemotesManager { fn add_remote( &self, name: &str, - url: Url, + url: &Url, pub_signing_key: &Path, + config_path: Option<&Path>, ) -> EngineResult { - let mut doc = self.get_local_document()?; + let mut doc = match config_path { + Some(path) => ConfigDocument::load(path)?, + None => self.get_local_document()?, + }; let remote_key = self.get_remote_key(name); - let remote_entry = RemoteEntry::new(url, pub_signing_key); + let remote_entry = RemoteEntry::new(url.clone(), pub_signing_key); doc.set_table(&remote_key, &remote_entry.to_map())?; doc.save()?; @@ -99,11 +120,14 @@ impl RemotesManager for MevaRemotesManager { &self, name: &str, new_url: &str, + new_server_key: &Path, direction: &RemoteDirection, ) -> EngineResult<()> { let remote_url_key = self.get_remote_url_keys(name, direction); let mut doc = self.get_local_document()?; doc.set(&remote_url_key, new_url)?; + let remote_server_key_key = self.get_remote_server_key_keys(name, direction); + doc.set(&remote_server_key_key, &new_server_key.to_utf8_string())?; doc.save() } diff --git a/engine/src/network/ssh_service.rs b/engine/src/network/ssh_service.rs index 960851a..df77134 100644 --- a/engine/src/network/ssh_service.rs +++ b/engine/src/network/ssh_service.rs @@ -30,11 +30,17 @@ impl SshService { /// * The TCP connection to the host fails. /// * The SSH handshake fails (e.g., server key mismatch). /// * Authentication is rejected by the server. - pub async fn connect(&self, params: &SshConnectionParams) -> EngineResult { - println!( - "Connecting to {} as user '{}'...", - ¶ms.host_address, ¶ms.user - ); + pub async fn connect( + &self, + params: &SshConnectionParams, + verbose: bool, + ) -> EngineResult { + if verbose { + println!( + "Connecting to {} as user '{}'...", + ¶ms.host_address, ¶ms.user + ); + } let client_keypair = Arc::new(load_secret_key(¶ms.client_key_path, None).map_err(NetworkError::from)?); @@ -58,7 +64,9 @@ impl SshService { socket.set_nodelay(true)?; let mut session = client::connect_stream(config, socket, handler).await?; - println!("SSH session established."); + if verbose { + println!("SSH session established."); + } let auth_success = session .authenticate_publickey(¶ms.user, client_keypair) @@ -69,10 +77,15 @@ impl SshService { return Err(NetworkError::Authentication.into()); } - println!("Public key authentication succeeded!"); + if verbose { + println!("Public key authentication succeeded."); + } Ok(SshSession::new(session)) } + /// Builds the SSH client configuration with sensible defaults. + /// + /// Returns a [`Config`] struct with keepalive and timeout settings. fn build_config(&self) -> Config { Config { keepalive_interval: Some(Duration::from_secs(10)), diff --git a/engine/src/network/ssh_session.rs b/engine/src/network/ssh_session.rs index b2522d1..7b96c09 100644 --- a/engine/src/network/ssh_session.rs +++ b/engine/src/network/ssh_session.rs @@ -1,11 +1,20 @@ -use std::mem; +use std::{collections::HashSet, mem, sync::Arc}; use russh::{ Channel, ChannelMsg, client::{Handle, Msg}, }; -use crate::errors::{EngineResult, NetworkError}; +use crate::{ + errors::{EngineResult, NetworkError}, + network::{ + PKT_CHUNK_SIZE, PackfileCodec, + protocols::{ReceivePackProtocol, ReceivePackResult}, + }, + object_storage::{MevaObjectStorage, ObjectStorage}, + ref_manager::RefEntry, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; use super::{ ClientHandler, @@ -53,6 +62,7 @@ impl SshSession { &mut self, repository_name: &str, haves: Vec, + verbose: bool, ) -> EngineResult { let command = format!("upload-pack {repository_name}"); let mut channel: Channel = self @@ -71,15 +81,19 @@ impl SshSession { while let Some(msg) = channel.wait().await { match msg { ChannelMsg::Data { data } => { - let is_complete = protocol.process_data(&data, &mut channel).await?; + let is_complete = protocol.process_data(&data, &mut channel, verbose).await?; if is_complete { - println!("Protocol finished successfully."); + if verbose { + println!("Protocol finished successfully."); + } protocol_completed = true; } } ChannelMsg::ExitStatus { exit_status } => { - println!("Command finished with status: {exit_status}"); + if verbose { + println!("Remote command exited with status: {exit_status}"); + } if exit_status != 0 { return Err(NetworkError::RemoteCommand { status: exit_status, @@ -87,9 +101,6 @@ impl SshSession { .into()); } } - ChannelMsg::Eof => { - println!("Server finished sending data (EOF)."); - } _ => {} } } @@ -104,9 +115,74 @@ impl SshSession { )) } - pub async fn run_receive_pack(&mut self, _repository_name: &str) -> EngineResult<()> { - // TODO: push - todo!() + /// Executes the `receive-pack` command on the remote server (Client-side Push). + /// + /// This initiates the process of uploading objects and updating references on the remote repository. + /// + /// # Arguments + /// * `repository_name`: The path/name of the repository on the remote server. + /// * `updates`: A list of reference updates (e.g., branch moves) the client wants to perform. + /// * `verbose`: Whether to print detailed logs to stdout. + /// + /// # Returns + /// * `EngineResult`: Contains the status of the operation (success/failure) + /// for each reference update request. + pub async fn run_receive_pack( + &mut self, + repository_name: &str, + updates: &[RefEntry], + verbose: bool, + ) -> EngineResult { + let command = format!("receive-pack {repository_name}"); + let mut channel: Channel = self + .session + .channel_open_session() + .await + .map_err(NetworkError::from)?; + channel + .exec(true, command.as_str()) + .await + .map_err(NetworkError::from)?; + + let mut protocol = ReceivePackProtocol::new(updates.to_vec()); + let mut protocol_completed = false; + + while let Some(msg) = channel.wait().await { + match msg { + ChannelMsg::Data { data } => { + let is_ready_to_send_pack = + protocol.process_data(&data, &mut channel, verbose).await?; + + if is_ready_to_send_pack { + self.stream_packfile(&mut channel, updates, &protocol.server_refs, verbose) + .await?; + protocol.mark_packfile_sent(); + } + + if protocol.is_complete() { + protocol_completed = true; + } + } + ChannelMsg::ExitStatus { exit_status } => { + if verbose { + println!("Remote command exited with status: {exit_status}"); + } + if exit_status != 0 { + return Err(NetworkError::RemoteCommand { + status: exit_status, + } + .into()); + } + } + _ => {} + } + } + + if !protocol_completed { + return Err(NetworkError::ConnectionClosedPrematurely.into()); + } + + Ok(protocol.result) } /// Connects to the remote to list references without downloading objects. @@ -123,6 +199,7 @@ impl SshSession { pub async fn run_ls_remotes( &mut self, repository_name: &str, + verbose: bool, ) -> EngineResult { let command = format!("upload-pack {repository_name}"); let mut channel: Channel = self @@ -140,17 +217,12 @@ impl SshSession { let mut protocol_completed = false; while let Some(msg) = channel.wait().await { - match msg { - ChannelMsg::Data { data } => { - let is_complete = protocol.process_data(&data, &mut channel).await?; - if is_complete { - protocol_completed = true; - channel.close().await.map_err(NetworkError::from)?; - } + if let ChannelMsg::Data { data } = msg { + let is_complete = protocol.process_data(&data, &mut channel, verbose).await?; + if is_complete { + protocol_completed = true; + channel.close().await.map_err(NetworkError::from)?; } - ChannelMsg::ExitStatus { .. } => {} - ChannelMsg::Eof => {} - _ => {} } } @@ -160,4 +232,82 @@ impl SshSession { Ok(UploadPackResult::refs_only(protocol.refs)) } + + /// Streams a packfile containing necessary objects to the remote server. + /// + /// Calculates the difference between what the server has (`server_refs`) + /// and what the client wants to push (`updates`), packs the missing objects, + /// and sends them over the SSH channel wrapped in `pkt-line` format. + /// + /// If no new objects are needed, it simply sends a flush packet. + async fn stream_packfile( + &mut self, + channel: &mut Channel, + updates: &[RefEntry], + server_refs: &[RefEntry], + verbose: bool, + ) -> EngineResult<()> { + let mut wants = HashSet::new(); + let mut haves = HashSet::new(); + + for update in updates { + if update.has_zero_hash() { + continue; + } + wants.insert(update.commit_hash.clone()); + } + + for server_ref in server_refs { + haves.insert(server_ref.commit_hash.clone()); + } + + let repository_layout = Arc::new(MevaRepositoryLayout::discover()?); + let object_storage = MevaObjectStorage::new(repository_layout); + + let objects = object_storage.collect_reachable_objects(&wants, &haves)?; + let objects_count = objects.len(); + + if objects.is_empty() { + if verbose { + println!("No new objects to pack."); + } + channel + .data(b"0000".as_ref()) + .await + .map_err(NetworkError::from)?; + return Ok(()); + } + + if verbose { + println!("Packing {objects_count} objects..."); + } + + let packfile_codec = PackfileCodec::default(); + let packfile = packfile_codec.encode_packfile(&objects)?; + + if verbose { + println!("Packfile size: {} bytes", packfile.len()); + } + + for chunk in packfile.chunks(PKT_CHUNK_SIZE) { + let len = chunk.len() + 4; + let header = format!("{len:04x}"); + channel + .data(header.as_bytes()) + .await + .map_err(NetworkError::from)?; + channel.data(chunk).await.map_err(NetworkError::from)?; + } + + channel + .data(b"0000".as_ref()) + .await + .map_err(NetworkError::from)?; + + if verbose { + println!("Packfile sent."); + } + + Ok(()) + } } diff --git a/engine/src/object_storage.rs b/engine/src/object_storage.rs index a6959a7..1eec718 100644 --- a/engine/src/object_storage.rs +++ b/engine/src/object_storage.rs @@ -73,4 +73,14 @@ pub trait ObjectStorage: Send + Sync { wants: &'a HashSet, haves: &'a HashSet, ) -> EngineResult>; + + /// Determines if one commit is a descendant of another. + /// + /// # Arguments + /// * `descendant_hash`: The hash of the potential descendant commit. + /// * `ancestor_hash`: The hash of the potential ancestor commit. + /// # Returns + /// `true` if the commit identified by `descendant_hash` is a descendant of + /// the commit identified by `ancestor_hash`, otherwise `false`. + fn is_descendant_of(&self, descendant_hash: &str, ancestor_hash: &str) -> EngineResult; } diff --git a/engine/src/object_storage/meva_dry_run_object_storage.rs b/engine/src/object_storage/meva_dry_run_object_storage.rs index 47ce6e1..a854d34 100644 --- a/engine/src/object_storage/meva_dry_run_object_storage.rs +++ b/engine/src/object_storage/meva_dry_run_object_storage.rs @@ -75,4 +75,8 @@ impl ObjectStorage for MevaDryRunObjectStorage { fn object_exists(&self, _hash: &str) -> EngineResult { unimplemented!() } + + fn is_descendant_of(&self, _descendant_hash: &str, _ancestor_hash: &str) -> EngineResult { + unimplemented!() + } } diff --git a/engine/src/object_storage/meva_object_storage.rs b/engine/src/object_storage/meva_object_storage.rs index 9c447f6..2ec86e6 100644 --- a/engine/src/object_storage/meva_object_storage.rs +++ b/engine/src/object_storage/meva_object_storage.rs @@ -228,4 +228,33 @@ impl ObjectStorage for MevaObjectStorage { let path = self.object_path(hash); Ok(path.try_exists()?) } + + fn is_descendant_of(&self, ancestor_hash: &str, descendant_hash: &str) -> EngineResult { + if descendant_hash == ancestor_hash { + return Ok(true); + } + + let mut queue: VecDeque = VecDeque::new(); + queue.push_back(descendant_hash.to_string()); + let mut visited: HashSet = HashSet::new(); + + while let Some(current) = queue.pop_front() { + if current == ancestor_hash { + return Ok(true); + } + + if let Ok(object) = self.get_object(¤t) + && let Ok(commit) = MevaCommit::try_from(object) + { + for parent in commit.parents { + if !visited.contains(&parent) { + visited.insert(parent.clone()); + queue.push_back(parent); + } + } + } + } + + Ok(false) + } } diff --git a/engine/src/ref_manager.rs b/engine/src/ref_manager.rs index c2e45e2..c58c1ea 100644 --- a/engine/src/ref_manager.rs +++ b/engine/src/ref_manager.rs @@ -116,6 +116,26 @@ pub trait RefManager: Send + Sync { /// Updates or creates a named reference with the provided [`RefEntry`] value. fn update_ref(&self, entry: &RefEntry) -> EngineResult<()>; + /// Updates local remote-tracking references to match the state of the remote repository. + /// + /// This method iterates over the provided list of references (which are known to + /// be new or updated) and writes them to the local reference store. + /// + /// The reference names are automatically mapped from the server's namespace + /// (e.g., `refs/heads/main`) to the local remote-tracking namespace + /// (e.g., `refs/remotes/origin/main`). + /// + /// # Arguments + /// * `origin` - The name of the remote (e.g., "origin"). + /// * `entries` - The list of server references to update locally. + /// * `verbose` - Whether to enable verbose output during the update process. + fn update_remote_refs( + &self, + origin: &str, + entries: &[RefEntry], + verbose: bool, + ) -> EngineResult<()>; + /// Removes a named reference from the repository. /// /// # Arguments diff --git a/engine/src/ref_manager/head.rs b/engine/src/ref_manager/head.rs index 4b28d3d..8e41e8d 100644 --- a/engine/src/ref_manager/head.rs +++ b/engine/src/ref_manager/head.rs @@ -25,6 +25,7 @@ impl Head { } } + /// Extracts the branch name if the HEAD is in symbolic mode. pub fn extract_branch_name(&self) -> Option { match self.mode == HeadMode::Symbolic { true => Some( @@ -36,6 +37,16 @@ impl Head { false => None, } } + + /// Checks if the HEAD is in symbolic mode. + pub fn is_symbolic(&self) -> bool { + self.mode == HeadMode::Symbolic + } + + /// Checks if the HEAD is in direct mode. + pub fn is_direct(&self) -> bool { + self.mode == HeadMode::Direct + } } impl Default for Head { diff --git a/engine/src/ref_manager/meva_ref_manager.rs b/engine/src/ref_manager/meva_ref_manager.rs index 1d7bbe3..2c2931e 100644 --- a/engine/src/ref_manager/meva_ref_manager.rs +++ b/engine/src/ref_manager/meva_ref_manager.rs @@ -1,3 +1,4 @@ +use owo_colors::OwoColorize; use walkdir::WalkDir; use crate::RepositoryLayout; @@ -221,6 +222,27 @@ impl RefManager for MevaRefManager { Ok(()) } + fn update_remote_refs( + &self, + origin: &str, + entries: &[RefEntry], + verbose: bool, + ) -> EngineResult<()> { + for remote_ref in entries { + let tracking_name = self.map_head_to_remote_ref(&remote_ref.name, origin); + if verbose { + println!( + "Updating {} -> {}", + tracking_name.cyan(), + remote_ref.commit_hash.yellow() + ); + } + let new_entry = RefEntry::new(&tracking_name, &remote_ref.commit_hash); + self.update_ref(&new_entry)?; + } + Ok(()) + } + fn remove_ref(&self, name: &str) -> EngineResult> { let entry = self.read_ref(name)?; diff --git a/engine/src/ref_manager/ref_entry.rs b/engine/src/ref_manager/ref_entry.rs index 928a3bb..9809d65 100644 --- a/engine/src/ref_manager/ref_entry.rs +++ b/engine/src/ref_manager/ref_entry.rs @@ -57,6 +57,11 @@ impl RefEntry { } } + /// Checks if the commit hash is a zero hash (all characters are '0'). + pub fn has_zero_hash(&self) -> bool { + self.commit_hash.chars().all(|c| c == '0') + } + /// Helper to create a local branch entry (refs/heads/). fn new_local_branch_entry(branch_name: &str, commit_hash: String) -> Self { Self { @@ -79,6 +84,6 @@ impl MevaEncode for RefEntry {} impl Display for RefEntry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let short_hash = self.commit_hash.chars().take(7).collect::(); - write!(f, "{} ({})", self.name.green(), short_hash.dimmed()) + write!(f, "{} ({})", self.name.cyan(), short_hash.yellow()) } } diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index e5df835..198e6ba 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -7,7 +7,10 @@ use std::{ use tempfile::TempDir; -use crate::{EngineResult, InitError, errors::NetworkError, ref_manager::RefManager}; +use crate::{ + EngineResult, InitError, errors::NetworkError, handlers::clone::Request as CloneRequest, + network::RemotesManager, ref_manager::RefManager, +}; use crate::{ config::config_loader::ConfigLoader, ref_manager::{Head, HeadMode}, @@ -34,7 +37,7 @@ pub trait Repository: Send + Sync { &self, objects: &[(MevaObject, Vec)], refs: &[RefEntry], - remote_name: &str, + request: &CloneRequest, ) -> EngineResult>; /// Returns the layout strategy used by this repository. @@ -49,16 +52,19 @@ pub trait Repository: Send + Sync { /// directory structure and configuration files. pub struct MevaRepository { /// Strategy for resolving file paths within the repository. - pub layout: Arc, + layout: Arc, /// Component responsible for reading and writing configuration files. - pub config_loader: Arc, + config_loader: Arc, /// Backend storage for repository objects (blobs, trees, commits). - pub object_storage: Arc, + object_storage: Arc, /// Manager for branch and reference handling within the repository. - pub ref_manager: Arc, + ref_manager: Arc, + + /// Manager for configuring remotes. + remotes_manager: Arc, } impl MevaRepository { @@ -68,12 +74,14 @@ impl MevaRepository { config_loader: Arc, object_storage: Arc, ref_manager: Arc, + remotes_manager: Arc, ) -> Self { Self { layout, config_loader, object_storage, ref_manager, + remotes_manager, } } @@ -90,7 +98,7 @@ impl MevaRepository { } /// Creates the physical directory hierarchy for a new repository. - fn initialize_structure(&self, root: &Path) -> EngineResult<()> { + fn initialize_structure(&self, root: &Path) -> EngineResult { let dirs = [ self.layout.objects_dir_rel(), self.layout.refs_dir_rel(), @@ -105,7 +113,7 @@ impl MevaRepository { let config_path = root.join(self.layout.config_file_rel()); self.config_loader.create_local_config(&config_path)?; - Ok(()) + Ok(config_path) } /// Configures the `HEAD` file to point to the initial branch. @@ -305,12 +313,19 @@ impl Repository for MevaRepository { &self, objects: &[(MevaObject, Vec)], refs: &[RefEntry], - remote_name: &str, + request: &CloneRequest, ) -> EngineResult> { self.check_if_exists()?; let working_dir = self.layout.working_dir(); - self.initialize_structure(&working_dir)?; + let config_path = self.initialize_structure(&working_dir)?; + + self.remotes_manager.add_remote( + &request.origin, + &request.url, + &request.server_key, + Some(&config_path), + )?; if !objects.is_empty() { self.object_storage.add_objects_from_packfile(objects)?; @@ -325,7 +340,7 @@ impl Repository for MevaRepository { refs, &heads_refs_prefix, initial_branch_name, - remote_name, + &request.origin, )?; self.setup_head( @@ -346,6 +361,7 @@ impl Repository for MevaRepository { #[cfg(test)] mod tests { use crate::MevaConfigLoader; + use crate::network::MevaRemotesManager; use crate::object_storage::MevaObjectStorage; use crate::ref_manager::MevaRefManager; use crate::repositories::meva_repository_layout::MevaRepositoryLayout; @@ -372,7 +388,14 @@ mod tests { let config_loader = Arc::new(MevaConfigLoader::default()); let object_storage = Arc::new(MevaObjectStorage::new(layout.clone())); let ref_manager = Arc::new(MevaRefManager::new(layout.clone())); - MevaRepository::new(layout, config_loader, object_storage, ref_manager) + let remotes_manager = Arc::new(MevaRemotesManager); + MevaRepository::new( + layout, + config_loader, + object_storage, + ref_manager, + remotes_manager, + ) } #[rstest] diff --git a/gui/src/ui/components/remote_actions.rs b/gui/src/ui/components/remote_actions.rs index 898fc25..f2f67b4 100644 --- a/gui/src/ui/components/remote_actions.rs +++ b/gui/src/ui/components/remote_actions.rs @@ -11,7 +11,7 @@ use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; use engine::{ EngineContainer, engine_container::MevaContainer, - handlers::{fetch::Request as FetchRequest, status::BranchInfo}, + handlers::{fetch::Request as FetchRequest, push::Request as PushRequest, status::BranchInfo}, }; use super::IconButton; @@ -64,24 +64,17 @@ impl<'a> RemoteActionsComponent<'a> { let branch = self.branch.head.clone().unwrap_or_default(); if IconButton::new(icons::ARROWS_CLOCKWISE, "Sync (Pull & Push)").show(ui) { - self.spawn_action("Syncing repository...", "Sync failed", |_container| { - thread::sleep(std::time::Duration::from_millis(800)); - Ok(()) - }); + // TODO: Implement sync operation + self.handle_fetch_click(upstream.clone(), branch.clone()); } if IconButton::new(icons::ARROW_UP, "Push to upstream").show(ui) { - self.spawn_action("Pushing changes...", "Push failed", |_container| { - thread::sleep(std::time::Duration::from_millis(800)); - Ok(()) - }); + self.handle_push_click(upstream.clone(), branch.clone()); } if IconButton::new(icons::ARROW_DOWN, "Pull from upstream").show(ui) { - self.spawn_action("Pulling changes...", "Pull failed", |_container| { - thread::sleep(std::time::Duration::from_millis(800)); - Ok(()) - }); + // TODO: Implement pull operation + self.handle_fetch_click(upstream.clone(), branch.clone()); } if IconButton::new(icons::DOWNLOAD_SIMPLE, "Fetch from upstream").show(ui) { @@ -91,27 +84,43 @@ impl<'a> RemoteActionsComponent<'a> { ui.separator(); } + fn create_runtime() -> Result { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("Failed to create runtime: {e}")) + } + + fn handle_push_click(&mut self, upstream: String, branch: String) { + self.spawn_action("Pushing changes...", "Push failed", move |container| { + let rt = Self::create_runtime()?; + + rt.block_on(async { + let push_handler = container.push_handler().map_err(|e| e.to_string())?; + let request = PushRequest::new(upstream, Some(branch)); + let _ = push_handler + .handle_push(request) + .await + .map_err(|e| e.to_string())?; + + Ok::<(), String>(()) + })?; + + Ok(()) + }); + } + /// Triggers the background fetch operation. /// /// Since the fetch handler uses `async/await`, this method creates a temporary /// Tokio runtime inside the worker thread to execute the future. fn handle_fetch_click(&mut self, upstream: String, branch: String) { self.spawn_action("Fetching origin...", "Fetch failed", move |container| { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| format!("Failed to create runtime: {e}"))?; + let rt = Self::create_runtime()?; rt.block_on(async { let fetch_handler = container.fetch_handler().map_err(|e| e.to_string())?; - - let request = FetchRequest { - origin: upstream, - branch: Some(branch), - prune: false, - verbose: false, - }; - + let request = FetchRequest::new(upstream, Some(branch)); let _ = fetch_handler .handle_fetch(request) .await diff --git a/server/src/enums.rs b/server/src/enums.rs index 4c61543..4697a46 100644 --- a/server/src/enums.rs +++ b/server/src/enums.rs @@ -1,6 +1,7 @@ mod active_protocol; mod channel_state; mod protocol_command; +mod receive_pack_command; mod receive_pack_state; mod upload_pack_command; mod upload_pack_state; @@ -8,6 +9,7 @@ mod upload_pack_state; pub use active_protocol::ActiveProtocol; pub use channel_state::ChannelState; pub use protocol_command::ProtocolCommand; -pub use receive_pack_state::ReceivePackState; +pub use receive_pack_command::ReceivePackCommand; +pub use receive_pack_state::{ReceivePackState, ReceivePhase}; pub use upload_pack_command::UploadPackCommand; pub use upload_pack_state::UploadPackState; diff --git a/server/src/enums/receive_pack_command.rs b/server/src/enums/receive_pack_command.rs new file mode 100644 index 0000000..ffee2ab --- /dev/null +++ b/server/src/enums/receive_pack_command.rs @@ -0,0 +1,68 @@ +use std::fmt::Display; + +use crate::errors::ReceivePackError; + +/// Represents a command sent by the client during the `receive-pack` (push) protocol. +#[derive(Debug, Clone)] +pub enum ReceivePackCommand { + /// Requests an update of a specific reference. + Update { + /// The object ID the client expects the reference to currently point to. + /// If this is a 'zero-hash', it indicates reference creation. + old_sha: String, + /// The new object ID the client wants the reference to point to. + /// If this is a 'zero-hash', it indicates reference deletion. + new_sha: String, + /// The full name of the reference (e.g., `refs/heads/master`). + ref_name: String, + }, +} + +impl TryFrom<&str> for ReceivePackCommand { + type Error = ReceivePackError; + + /// Parses a raw protocol line into a structured update command. + /// + /// The expected format is: + /// ` ` + /// + /// # Arguments + /// + /// * `value` - A single line string from the received packet. + /// + /// # Errors + /// + /// Returns [`ReceivePackError::InvalidCommand`] if the line does not contain + /// exactly three space-separated components. + fn try_from(value: &str) -> Result { + let parts: Vec<&str> = value.split_whitespace().collect(); + + if parts.len() != 3 { + return Err(ReceivePackError::InvalidCommand(value.to_string())); + } + + Ok(ReceivePackCommand::Update { + old_sha: parts[0].to_string(), + new_sha: parts[1].to_string(), + ref_name: parts[2].to_string(), + }) + } +} + +impl Display for ReceivePackCommand { + /// Formats the command back into its protocol string representation. + /// + /// # Returns + /// + /// A string in the format: + /// ` ` + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ReceivePackCommand::Update { + old_sha, + new_sha, + ref_name, + } => write!(f, "{old_sha} {new_sha} {ref_name}"), + } + } +} diff --git a/server/src/enums/receive_pack_state.rs b/server/src/enums/receive_pack_state.rs index f90779f..b6d9ccb 100644 --- a/server/src/enums/receive_pack_state.rs +++ b/server/src/enums/receive_pack_state.rs @@ -1,14 +1,48 @@ use std::path::PathBuf; -#[derive(Debug, Clone)] +use super::ReceivePackCommand; + +/// Represents the current phase of the `receive-pack` (push) protocol on the server side. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub enum ReceivePhase { + /// The server is waiting for or reading update commands (e.g., ` `). + /// This phase ends when a flush packet (`0000`) is received. + #[default] + ReadingCommands, + /// The server has finished reading commands and is now receiving the binary packfile. + /// This phase involves accumulating raw bytes until the full packfile is received and verified. + ReceivingPackfile, +} + +/// Maintains the state of a single `receive-pack` session for a specific repository. +#[derive(Debug, Default, Clone)] pub struct ReceivePackState { + /// The physical path to the repository on the server's filesystem. pub repository_path: PathBuf, + + /// The current phase of the protocol. + pub state: ReceivePhase, + + /// The list of update commands received from the client so far. + pub commands: Vec, + + /// A buffer used to accumulate the binary packfile data received from the network. + /// This buffer is populated chunk by chunk during the `ReceivingPackfile` phase. + pub packfile_buffer: Vec, } impl ReceivePackState { + /// Creates a new state instance for a specific repository. + /// + /// Initializes the state in the `ReadingCommands` phase with empty buffers. + /// + /// # Arguments + /// + /// * `repository_path` - The filesystem path to the target repository. pub fn new(repository_path: impl Into) -> Self { Self { repository_path: repository_path.into(), + ..Default::default() } } } diff --git a/server/src/errors.rs b/server/src/errors.rs index f1086ac..7abbbbc 100644 --- a/server/src/errors.rs +++ b/server/src/errors.rs @@ -1,5 +1,7 @@ +mod receive_pack_error; mod server_error; mod upload_pack_error; +pub use receive_pack_error::ReceivePackError; pub use server_error::{Result as ServerResult, ServerError}; pub use upload_pack_error::UploadPackError; diff --git a/server/src/errors/receive_pack_error.rs b/server/src/errors/receive_pack_error.rs new file mode 100644 index 0000000..c55c7e0 --- /dev/null +++ b/server/src/errors/receive_pack_error.rs @@ -0,0 +1,12 @@ +use thiserror::Error; + +/// Represents errors specific to the `receive-pack` protocol execution. +#[derive(Error, Debug)] +pub enum ReceivePackError { + /// Indicates that a protocol command line was malformed or unrecognized. + #[error("Invalid receive-pack command: {0}")] + InvalidCommand( + /// The raw string content of the command that caused the error. + String, + ), +} diff --git a/server/src/errors/server_error.rs b/server/src/errors/server_error.rs index 21d62bd..b90316b 100644 --- a/server/src/errors/server_error.rs +++ b/server/src/errors/server_error.rs @@ -3,7 +3,7 @@ use std::io; use flexi_logger::FlexiLoggerError; use thiserror::Error; -use super::UploadPackError; +use super::{ReceivePackError, UploadPackError}; /// A convenient result type alias for server-related operations. pub type Result = std::result::Result; @@ -19,6 +19,10 @@ pub enum ServerError { #[error(transparent)] UploadPack(#[from] UploadPackError), + /// Errors specific to the `receive-pack` protocol negotiation. + #[error(transparent)] + ReceivePack(#[from] ReceivePackError), + /// Errors originating from the logging subsystem initialization. #[error(transparent)] Logger(#[from] FlexiLoggerError), diff --git a/server/src/logging.rs b/server/src/logging.rs index 0948071..58b4df0 100644 --- a/server/src/logging.rs +++ b/server/src/logging.rs @@ -48,7 +48,7 @@ pub fn init_logging(logging_config: &LoggingConfig) -> ServerResult<()> { "[{}] [{}] [{}:{}] {}", now.now().format("%Y-%m-%d %H:%M:%S"), record.level(), - record.file().unwrap_or("?"), + record.file().unwrap_or(""), record.line().unwrap_or(0), &record.args() ) @@ -60,7 +60,7 @@ pub fn init_logging(logging_config: &LoggingConfig) -> ServerResult<()> { "[{}] [{}] [{}:{}] {}", now.now().format("%Y-%m-%d %H:%M:%S"), style(record.level()).paint(record.level().to_string()), - record.file().unwrap_or("?"), + record.file().unwrap_or(""), record.line().unwrap_or(0), &record.args() ) diff --git a/server/src/server_handler.rs b/server/src/server_handler.rs index 4055b00..34d5ca6 100644 --- a/server/src/server_handler.rs +++ b/server/src/server_handler.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use log::{error, info, warn}; +use log::{debug, error, info, warn}; use russh::{ Channel, keys::key, @@ -9,25 +9,26 @@ use russh::{ use std::{ collections::HashMap, + io::ErrorKind, path::{Path, PathBuf}, str::FromStr, sync::Arc, }; use ::server::{ - ActiveProtocol, AuthResult, ChannelState, ProtocolCommand, ReceivePackState, UploadPackCommand, - UploadPackState, challenge_public_key, check_repository_access, validate_repository_name, + ActiveProtocol, AuthResult, ChannelState, ProtocolCommand, ReceivePackCommand, + ReceivePackState, ReceivePhase, UploadPackCommand, UploadPackState, challenge_public_key, + check_repository_access, validate_repository_name, }; use engine::{ - network::{ChannelBand, PackfileCodec, SessionExtension, create_pkt_line}, + errors::EngineError, + network::{CHUNK_SIZE, ChannelBand, PackfileCodec, SessionExtension, create_pkt_line}, object_storage::{MevaObjectStorage, ObjectStorage}, - ref_manager::{MevaRefManager, RefManager}, + objects::MevaObject, + ref_manager::{MevaRefManager, RefEntry, RefManager}, repositories::meva_repository_layout::MevaRepositoryLayout, }; -// Stream data in chunks to avoid choking the connection -const CHUNK_SIZE: usize = 65_000; - /// The main SSH event handler for the Meva server. /// /// This struct manages the lifecycle of SSH connections, including authentication @@ -67,7 +68,33 @@ impl ServerHandler { channel: ChannelId, repository_root: &Path, ) -> Result<()> { - let line = create_pkt_line("# service=meva-upload-pack"); + self.handle_pack_command_start(session, channel, repository_root, "meva-upload-pack") + .await + } + + /// Initiates the `receive-pack` protocol (Discovery Phase). + /// + /// It sends the initial advertisement of references (branches, tags) and capabilities + /// to the client. + async fn handle_receive_pack_start( + &mut self, + session: &mut Session, + channel: ChannelId, + repository_root: &Path, + ) -> Result<()> { + self.handle_pack_command_start(session, channel, repository_root, "meva-receive-pack") + .await + } + + /// Sends the initial advertisement of references for a pack command. + async fn handle_pack_command_start( + &mut self, + session: &mut Session, + channel: ChannelId, + repository_root: &Path, + command_header: &str, + ) -> Result<()> { + let line = create_pkt_line(&format!("# service={command_header}")); session.send_pkt_line(channel, &line); session.send_flush(channel); @@ -75,7 +102,6 @@ impl ServerHandler { let ref_manager = MevaRefManager::new(repository_layout); if let Some(head) = ref_manager.resolve_head()? { - info!("(UploadPack) Sending {head} HEAD..."); let head_pkt_line = create_pkt_line(&format!("{head} HEAD")); session.send_pkt_line(channel, &head_pkt_line); } @@ -86,20 +112,10 @@ impl ServerHandler { } session.send_flush(channel); - info!("(UploadPack) List of references sent. Waiting for 'want'/'have'..."); + debug!("List of references sent."); Ok(()) } - async fn handle_receive_pack_start( - &mut self, - _session: &mut Session, - _channel: ChannelId, - _repo_path: &Path, - ) -> Result<()> { - // TODO: push - todo!() - } - /// Processes incoming data chunks during the `upload-pack` negotiation phase. /// /// This method acts as a state machine parser for the client's requests. @@ -132,12 +148,11 @@ impl ServerHandler { }; if len == 0 { - info!("(UploadPack) Received flush-packet (0000)"); state.buffer.drain(0..4); if upload_state.wants.is_empty() && upload_state.haves.is_empty() { info!( - "(UploadPack) Client sent flush without wants/haves (ls-remote). Closing channel." + "Client sent flush without wants/haves (indicating ls-remote). Closing channel." ); session.exit_status_request(channel, 0); session.eof(channel); @@ -152,23 +167,21 @@ impl ServerHandler { } let line_data = &state.buffer[4..len]; - let line_str = std::str::from_utf8(line_data) - .unwrap_or("[utf8 error]") - .trim(); + let line_str = std::str::from_utf8(line_data)?.trim(); let command = UploadPackCommand::try_from(line_str)?; match command { UploadPackCommand::Want(sha) => { - info!("(UploadPack) Received 'want': {sha}"); + debug!("Received 'want': {sha}"); upload_state.wants.insert(sha.to_string()); } UploadPackCommand::Have(sha) => { - info!("(UploadPack) Received 'have': {sha}"); + debug!("Received 'have': {sha}"); upload_state.haves.insert(sha.to_string()); } UploadPackCommand::Done => { - info!("(UploadPack) Received 'done'. Negotiation finished."); + debug!("Received 'done'. Negotiation finished."); state.buffer.drain(0..len); return self.handle_packfile_generation(session, channel); } @@ -180,13 +193,269 @@ impl ServerHandler { Ok(()) } + /// Processes incoming data chunks during the `receive-pack` phase. + /// + /// This method acts as a state machine parser for the client's push commands + /// and packfile data. fn process_receive_pack_data( &mut self, - _session: &mut Session, - _channel: ChannelId, + session: &mut Session, + channel: ChannelId, + ) -> Result<()> { + let mut ready_to_push = false; + let mut decoded_objects = Vec::new(); + let repository_path; + let commands; + + { + let state_container = self.sessions.get_mut(&channel).unwrap(); + let receive_state = match &mut state_container.protocol { + ActiveProtocol::ReceivePack(state) => state, + _ => return Ok(()), + }; + + repository_path = receive_state.repository_path.clone(); + commands = receive_state.commands.clone(); + + loop { + match receive_state.state { + ReceivePhase::ReadingCommands => { + if state_container.buffer.len() < 4 { + break; + } + + let len_str = + std::str::from_utf8(&state_container.buffer[0..4]).unwrap_or("????"); + let len = usize::from_str_radix(len_str, 16).unwrap_or(0); + + if len == 0 { + debug!("End of commands. Switching to Packfile mode."); + state_container.buffer.drain(0..4); + receive_state.state = ReceivePhase::ReceivingPackfile; + if receive_state.commands.is_empty() { + debug!("No commands received. Skipping Packfile phase."); + + ready_to_push = true; + decoded_objects = Vec::new(); + break; + } + + debug!("End of commands. Switching to Packfile mode."); + receive_state.state = ReceivePhase::ReceivingPackfile; + continue; + } + + if state_container.buffer.len() < len { + break; + } + + let line_data = &state_container.buffer[4..len]; + let line_str = std::str::from_utf8(line_data)?.trim(); + + match ReceivePackCommand::try_from(line_str) { + Ok(command) => { + debug!("Received command: {command}"); + receive_state.commands.push(command); + } + Err(e) => { + error!("Invalid command: {e}"); + return Err(e.into()); + } + } + + state_container.buffer.drain(0..len); + } + + ReceivePhase::ReceivingPackfile => { + if state_container.buffer.len() < 4 { + break; + } + + let len_str = + std::str::from_utf8(&state_container.buffer[0..4]).unwrap_or("????"); + let len = usize::from_str_radix(len_str, 16).unwrap_or(0); + + if len == 0 { + debug!("Received Flush-Packet. End of packfile stream."); + state_container.buffer.drain(0..4); + + if receive_state.packfile_buffer.is_empty() { + debug!("Packfile buffer is empty. No new objects to unpack."); + ready_to_push = true; + decoded_objects = Vec::new(); + break; + } + + let codec = PackfileCodec::default(); + match codec.decode_packfile(&receive_state.packfile_buffer) { + Ok(objects) => { + info!( + "Packfile verified ({} objects, {} bytes). Ready to push.", + objects.len(), + receive_state.packfile_buffer.len() + ); + + ready_to_push = true; + decoded_objects = objects; + receive_state.packfile_buffer.clear(); + break; + } + Err(e) => { + error!("Packfile corrupted or incomplete: {e}"); + return Err(e.into()); + } + } + } + + if state_container.buffer.len() < len { + break; + } + + let chunk_data = &state_container.buffer[4..len]; + receive_state.packfile_buffer.extend_from_slice(chunk_data); + state_container.buffer.drain(0..len); + } + } + } + } + + if ready_to_push { + self.execute_push( + session, + channel, + decoded_objects, + repository_path, + &commands, + )?; + } + + Ok(()) + } + + /// Executes the push operation after receiving and validating the packfile. + /// + /// This method updates references based on the received commands + /// and sends appropriate responses back to the client. + fn execute_push( + &mut self, + session: &mut Session, + channel: ChannelId, + packfile_objects: Vec<(MevaObject, Vec)>, + repository_path: PathBuf, + commands: &[ReceivePackCommand], ) -> Result<()> { - // TODO: push - todo!() + debug!("Executing push with {} objects...", packfile_objects.len()); + + let repository_layout = Arc::new(MevaRepositoryLayout::new(repository_path.clone())?); + let object_storage = MevaObjectStorage::new(repository_layout.clone()); + let ref_manager = MevaRefManager::new(repository_layout); + + if let Err(e) = object_storage.add_objects_from_packfile(&packfile_objects) { + error!("Failed to save objects: {e}"); + let msg = create_pkt_line(&format!("unpack error {e}\n")); + session.send_pkt_line(channel, &msg); + session.send_flush(channel); + session.close(channel); + return Ok(()); + } + + let unpack_ok = create_pkt_line("unpack ok\n"); + session.send_pkt_line(channel, &unpack_ok); + info!("Objects written successfully. Processing refs..."); + + let zero_hash = "0".repeat(40); + + for cmd in commands { + match cmd { + ReceivePackCommand::Update { + old_sha, + new_sha, + ref_name, + } => { + let current_hash = match ref_manager.read_ref(ref_name) { + Ok(Some(r)) => r.commit_hash, + Ok(None) => zero_hash.clone(), + Err(EngineError::Io(e)) if e.kind() == ErrorKind::NotFound => { + if *old_sha == zero_hash { + zero_hash.clone() + } else { + error!("Ref {ref_name} not found for update"); + let msg = + create_pkt_line(&format!("ng {ref_name} ref does not exist\n")); + session.send_pkt_line(channel, &msg); + continue; + } + } + Err(e) => { + error!("Failed to read ref {ref_name}: {e}"); + let msg = create_pkt_line(&format!( + "ng {ref_name} failed to read ref: internal error\n" + )); + session.send_pkt_line(channel, &msg); + continue; + } + }; + + if current_hash != *old_sha { + warn!( + "Rejected update for {ref_name}: expected {old_sha}, found {current_hash}", + ); + let msg = create_pkt_line(&format!( + "ng {ref_name} non-fast-forward (lock mismatch)\n", + )); + session.send_pkt_line(channel, &msg); + continue; + } + + let is_creation = *old_sha == zero_hash; + let is_deletion = *new_sha == zero_hash; + + if !is_creation && !is_deletion { + let is_fast_forward = object_storage + .is_descendant_of(old_sha, new_sha) + .unwrap_or(false); + if !is_fast_forward { + warn!("Non-fast-forward update rejected for {ref_name}"); + let msg = create_pkt_line(&format!("ng {ref_name} non-fast-forward\n")); + session.send_pkt_line(channel, &msg); + continue; + } + } + + let update_result = if *new_sha == zero_hash { + info!("Deleting ref: {ref_name}"); + ref_manager.remove_ref(ref_name).map(|_| ()) + } else { + info!("Updating ref: {ref_name} -> {new_sha}"); + let entry = RefEntry::new(ref_name, new_sha); + ref_manager.update_ref(&entry) + }; + + match update_result { + Ok(_) => { + let msg = create_pkt_line(&format!("ok {ref_name}\n")); + session.send_pkt_line(channel, &msg); + } + Err(e) => { + error!("Failed to write ref {ref_name}: {e}"); + let msg = + create_pkt_line(&format!("ng {ref_name} failed to write ref\n")); + session.send_pkt_line(channel, &msg); + } + } + } + } + } + + info!("Push operation completed. Closing channel."); + + session.send_flush(channel); + session.exit_status_request(channel, 0); + session.eof(channel); + session.close(channel); + info!("Session closed."); + + Ok(()) } /// Generates and streams the packfile to the client. @@ -197,7 +466,7 @@ impl ServerHandler { session: &mut Session, channel: ChannelId, ) -> Result<()> { - info!("(UploadPack) Generating packfile..."); + info!("Generating packfile..."); let state = self.sessions.get(&channel).unwrap(); let upload_state = match &state.protocol { @@ -205,9 +474,6 @@ impl ServerHandler { _ => return Ok(()), }; - info!("(UploadPack) Client 'wants': {:?}", upload_state.wants); - info!("(UploadPack) Client 'haves': {:?}", upload_state.haves); - let repository_layout = Arc::new(MevaRepositoryLayout::new( upload_state.repository_path.clone(), )?); @@ -219,7 +485,6 @@ impl ServerHandler { if object_storage.object_exists(have)? { let ack = create_pkt_line("ACK"); session.send_pkt_line(channel, &ack); - info!("(UploadPack) Found common ancestor: {have}. Sending ACK."); common_ancestor_found = true; break; } @@ -228,34 +493,34 @@ impl ServerHandler { if !common_ancestor_found { let nak = create_pkt_line("NAK"); session.send_pkt_line(channel, &nak); - info!("(UploadPack) No common ancestor found. Sending NAK."); } let objects = object_storage.collect_reachable_objects(&upload_state.wants, &upload_state.haves)?; let objects_count = objects.len(); - info!("(UploadPack) Collected {objects_count} objects to pack."); + debug!("Collected {objects_count} objects to pack."); - session.send_channel_band( - channel, - &ChannelBand::Progress, - format!("Enumerating objects: {objects_count}, done.").as_bytes(), - )?; + if objects_count > 0 { + session.send_channel_band( + channel, + &ChannelBand::Progress, + format!("Enumerating objects: {objects_count}, done.").as_bytes(), + )?; + } let packfile_codec = PackfileCodec::default(); let packfile = packfile_codec.encode_packfile(&objects)?; - session.send_channel_band( - channel, - &ChannelBand::Progress, - format!("Compressing objects: {objects_count}, done.").as_bytes(), - )?; + let progress_msg = if objects_count == 0 { + "No new objects to pack, done.".to_string() + } else { + format!("Compressing objects: {objects_count}, done.") + }; + + session.send_channel_band(channel, &ChannelBand::Progress, progress_msg.as_bytes())?; - info!( - "(UploadPack) Sending packfile ({} bytes)...", - packfile.len() - ); + info!("Sending packfile ({} bytes)...", packfile.len()); let mut chunk_counter = 0; for chunk in packfile.chunks(CHUNK_SIZE) { @@ -263,15 +528,17 @@ impl ServerHandler { chunk_counter += 1; } - session.send_channel_band( - channel, - &ChannelBand::Progress, - format!("Total {chunk_counter} chunks sent.").as_bytes(), - )?; + if objects_count > 0 { + session.send_channel_band( + channel, + &ChannelBand::Progress, + format!("Total {chunk_counter} chunks sent.").as_bytes(), + )?; + } session.send_flush(channel); - info!("(UploadPack) Packfile sent. Closing channel."); + info!("Packfile sent. Closing channel."); session.exit_status_request(channel, 0); session.eof(channel); session.close(channel); @@ -313,7 +580,7 @@ impl Handler for ServerHandler { channel: Channel, _session: &mut Session, ) -> Result { - info!("Attempting to open a session on channel {:?}", channel.id()); + debug!("Attempting to open a session on channel {:?}", channel.id()); if let Some(user) = &self.user { let state = ChannelState::new(user.clone()); self.sessions.insert(channel.id(), state); @@ -485,6 +752,8 @@ impl Handler for ServerHandler { } impl Clone for ServerHandler { + /// Creates a clone of the `ServerHandler`. + /// Used to spawn new handler instances for each SSH connection. fn clone(&self) -> Self { ServerHandler { authorized_keys_path: self.authorized_keys_path.clone(), From be407440b848a2e6da482ed752e1c684b7b6a9f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:40:57 +0100 Subject: [PATCH 35/42] Feature/command checkout (#56) --- cli/src/commands.rs | 3 + cli/src/commands/branch.rs | 31 +- cli/src/commands/checkout.rs | 154 ++++++++++ engine/src/branch_manager.rs | 24 ++ .../src/branch_manager/meva_branch_manager.rs | 52 +++- engine/src/engine_container.rs | 100 ++++++- engine/src/errors.rs | 2 + engine/src/errors/branch_error.rs | 40 +++ engine/src/errors/checkout_error.rs | 34 +++ engine/src/errors/engine_error.rs | 12 +- engine/src/errors/ref_entry_error.rs | 8 + engine/src/handlers.rs | 1 + engine/src/handlers/branch/handlers.rs | 87 +++++- engine/src/handlers/branch/operations.rs | 27 ++ engine/src/handlers/checkout.rs | 5 + engine/src/handlers/checkout/handlers.rs | 202 +++++++++++++ engine/src/handlers/checkout/operations.rs | 35 +++ engine/src/handlers/clone/handlers.rs | 26 +- engine/src/handlers/restore/handlers.rs | 246 ++-------------- engine/src/index.rs | 9 + engine/src/index/meva_index.rs | 4 + engine/src/lib.rs | 1 + engine/src/network/protocols/receive_pack.rs | 4 +- engine/src/network/protocols/upload_pack.rs | 5 +- .../src/object_storage/meva_object_storage.rs | 12 +- engine/src/ref_manager.rs | 22 +- engine/src/ref_manager/meva_ref_manager.rs | 13 +- engine/src/ref_manager/ref_entry.rs | 109 ++++++- engine/src/repositories/meva_repository.rs | 67 ++++- engine/src/restore_manager.rs | 38 +++ .../restore_manager/meva_restore_manager.rs | 267 ++++++++++++++++++ engine/src/revision_parsing.rs | 2 +- .../meva_revision_resolver.rs | 1 + engine/src/traversal/traits.rs | 2 +- server/src/server_handler.rs | 84 +++--- 35 files changed, 1385 insertions(+), 344 deletions(-) create mode 100644 cli/src/commands/checkout.rs create mode 100644 engine/src/errors/checkout_error.rs create mode 100644 engine/src/handlers/checkout.rs create mode 100644 engine/src/handlers/checkout/handlers.rs create mode 100644 engine/src/handlers/checkout/operations.rs create mode 100644 engine/src/restore_manager.rs create mode 100644 engine/src/restore_manager/meva_restore_manager.rs diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 0f06221..9f22bdb 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1,5 +1,6 @@ pub mod add; pub mod branch; +pub mod checkout; pub mod clone; pub mod commit; pub mod config; @@ -21,6 +22,7 @@ pub mod status; pub use add::AddCommand; pub use branch::BranchCommand; +pub use checkout::CheckoutCommand; pub use clone::CloneCommand; pub use commit::CommitCommand; pub use config::ConfigCommand; @@ -88,5 +90,6 @@ pub fn collect_commands() -> CommandsCollection { Box::new(BranchCommand), Box::new(PushCommand), Box::new(PullCommand), + Box::new(CheckoutCommand), ] } diff --git a/cli/src/commands/branch.rs b/cli/src/commands/branch.rs index 640cce5..7d6fed4 100644 --- a/cli/src/commands/branch.rs +++ b/cli/src/commands/branch.rs @@ -4,6 +4,7 @@ use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; use engine::engine_container::{EngineContainer, MevaContainer}; use engine::handlers::branch::{ BranchOperations, CreateRequest, DeleteRequest, ListRequest, RenameRequest, Request, + SetUpstream, }; use engine::revision_parsing::Revision; use miette::{IntoDiagnostic, Result}; @@ -43,6 +44,9 @@ impl BranchCommand { /// Flag to list only remote-tracking branches. const ARG_REMOTES: &'static str = "remotes"; + + /// Flag to configure local branch upstream. + const ARG_SET_UPSTREAM: &'static str = "set-upstream-to"; } #[async_trait] @@ -135,6 +139,19 @@ impl MevaCommand for BranchCommand { .help("Show sha1 and commit subject line") .action(ArgAction::SetTrue), ) + .arg( + Arg::new(Self::ARG_SET_UPSTREAM) + .short('u') + .long(Self::ARG_SET_UPSTREAM) + .value_name("UPSTREAM") + .num_args(1) + .conflicts_with_all([ + Self::ARG_DELETE, + Self::ARG_FORCE_DELETE, + Self::ARG_MOVE, + Self::ARG_LIST, + ]), + ) .group( ArgGroup::new("delete_mode") .args([Self::ARG_DELETE, Self::ARG_FORCE_DELETE]) @@ -158,8 +175,9 @@ impl MevaCommand for BranchCommand { let verbose = matches.get_flag(Self::ARG_VERBOSE); let all = matches.get_flag(Self::ARG_ALL); let remotes = matches.get_flag(Self::ARG_REMOTES); + let upstream = matches.get_one::(Self::ARG_SET_UPSTREAM); - let mut is_list = false; + let mut print_list = false; let request = if delete || force_delete { let request = DeleteRequest { @@ -175,6 +193,13 @@ impl MevaCommand for BranchCommand { }; Request::Rename(request) + } else if let Some(upstream) = upstream { + let request = SetUpstream { + local_branch: branch_name.cloned(), + remote_branch: upstream.clone(), + }; + + Request::SetUpstream(request) } else if let Some(branch_name) = branch_name { let start_point = match second_arg { None => Revision::head(Vec::new()), @@ -194,7 +219,7 @@ impl MevaCommand for BranchCommand { remotes: remotes || all, }; - is_list = true; + print_list = true; Request::List(request) }; @@ -203,7 +228,7 @@ impl MevaCommand for BranchCommand { let response = handler.branch(request).into_diagnostic()?; - if is_list && response.branches.is_some() { + if print_list && response.branches.is_some() { println!("{}", response.branches.unwrap()); } diff --git a/cli/src/commands/checkout.rs b/cli/src/commands/checkout.rs new file mode 100644 index 0000000..e4aec18 --- /dev/null +++ b/cli/src/commands/checkout.rs @@ -0,0 +1,154 @@ +use crate::commands::MevaCommand; +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::{ + branch::{self, BranchOperations, CreateRequest}, + checkout::{self, CheckoutOperations}, + }, + revision_parsing::Revision, +}; +use miette::IntoDiagnostic; + +/// Implements the `checkout` command for Meva DVCS. +/// +/// Updates files in the working tree to match the version in the index or the specified tree. +/// This command is used to switch between branches, restore files, or move the `HEAD` +/// pointer to a specific commit. +/// +/// ### States of HEAD +/// - **Attached HEAD**: Switching to a local branch (e.g., `master`) updates `HEAD` to point +/// symbolically to that branch. +/// - **Detached HEAD**: Checking out a commit hash, tag, or using the `--detach` flag +/// points `HEAD` directly to a commit, leaving no active branch. +#[derive(Default)] +pub struct CheckoutCommand; + +impl CheckoutCommand { + /// Positional argument: the target to check out. + /// Can be a branch name (e.g., "main"), a commit hash, or a relative + /// reference like "HEAD~1". + const ARG_TARGET: &'static str = "target"; + + /// Flag key for creating a new branch and switching to it. + /// Corresponds to the `-b ` option. + /// If a target is provided, the new branch starts there; otherwise, it starts at `HEAD`. + const ARG_NEW_BRANCH: &'static str = "new-branch"; + + /// Flag key for forcing the checkout. + /// Corresponds to the `-f, --force` flag. + /// Overwrites local changes in the working tree without safety checks. + const ARG_FORCE: &'static str = "force"; + + /// Flag key for explicitly entering a detached HEAD state. + /// Corresponds to the `--detach` flag. + /// Useful when you want to inspect a branch's tip without switching to it. + const ARG_DETACH: &'static str = "detach"; + + /// Flag key for performing a three-way merge when switching branches. + /// Corresponds to the `-m, --merge` flag. + /// Attempts to carry your local changes over to the new branch by merging them. + const ARG_MERGE: &'static str = "merge"; +} + +#[async_trait] +impl MevaCommand for CheckoutCommand { + type Container = MevaContainer; + + fn name(&self) -> &'static str { + "checkout" + } + + fn about(&self) -> &'static str { + "Switch branches or restore working tree files" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_NEW_BRANCH) + .short('b') + .value_name("NEW_BRANCH") + .help("Create and checkout a new branch"), + ) + .arg( + Arg::new(Self::ARG_FORCE) + .short('f') + .long(Self::ARG_FORCE) + .help("Force checkout (discard local changes)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_DETACH) + .long(Self::ARG_DETACH) + .help("Detach HEAD at named commit") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_MERGE) + .short('m') + .long(Self::ARG_MERGE) + .help("Perform a three-way merge between current branch, working tree, and new branch") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_TARGET) + .value_name("BRANCH_OR_COMMIT") + .value_parser(value_parser!(Revision)) + .default_value("HEAD") + .help("Branch name or commit hash to switch to") + .required_unless_present(Self::ARG_NEW_BRANCH) + .index(1) + ) + } + + async fn execute( + &self, + matches: &ArgMatches, + container: &Self::Container, + ) -> miette::Result<()> { + let mut target = matches + .get_one::(Self::ARG_TARGET) + .unwrap() + .clone(); + let new_branch = matches.get_one::(Self::ARG_NEW_BRANCH); + let force = matches.get_flag(Self::ARG_FORCE); + let detach = matches.get_flag(Self::ARG_DETACH); + let merge = matches.get_flag(Self::ARG_MERGE); + + // Create new branch + if let Some(branch_name) = new_branch { + let request = CreateRequest { + branch_name: branch_name.to_string(), + start_point: target, + }; + + container + .branch_handler() + .into_diagnostic()? + .branch(branch::Request::Create(request)) + .into_diagnostic()?; + + target = branch_name.parse::().unwrap(); + } + + let request = checkout::Request { + force, + detach, + merge, + target, + }; + + let handler = container.checkout_handler().into_diagnostic()?; + + handler.checkout(request).into_diagnostic()?; + + Ok(()) + } +} diff --git a/engine/src/branch_manager.rs b/engine/src/branch_manager.rs index 33dc14a..4250a7e 100644 --- a/engine/src/branch_manager.rs +++ b/engine/src/branch_manager.rs @@ -78,6 +78,30 @@ pub trait BranchManager: Send + Sync { /// or object deserialization. fn last_commit(&self) -> EngineResult>; + /// Configures a tracking relationship (upstream) for a local branch. + /// + /// This updates the `.meva/mevaconfig` file, allowing commands like `push` or `pull` + /// to know which remote and branch to synchronize with. + /// + /// ### Configuration Format: + /// ```toml + /// [branch "local_name"] + /// remote = "origin" + /// merge = "refs/heads/remote_name" + /// ``` + /// + /// # Arguments + /// + /// * `local_branch_name` – The name of the local branch to configure (e.g., "main"). + /// * `remote_branch_name` – The full remote-tracking branch name (e.g., "origin/main"). + /// + /// # Errors + /// + /// * [`BranchError::NotLocalBranch`] – If the first argument is not a local branch. + /// * [`BranchError::NotRemoteBranch`] – If the second argument is not a remote-tracking branch. + /// * [`BranchError::BranchNotFound`] – If either reference cannot be resolved. + fn set_upstream(&self, local_branch_name: &str, remote_branch_name: &str) -> EngineResult<()>; + /// Creates a new branch based on the provided definition. /// /// # Arguments diff --git a/engine/src/branch_manager/meva_branch_manager.rs b/engine/src/branch_manager/meva_branch_manager.rs index d670175..7f9aa96 100644 --- a/engine/src/branch_manager/meva_branch_manager.rs +++ b/engine/src/branch_manager/meva_branch_manager.rs @@ -1,9 +1,10 @@ use crate::branch_manager::{Branch, BranchInfo, BranchType, RemoveBranchesResult}; +use crate::config::ConfigDocumentOperations; use crate::errors::{BranchError, CommitError, EngineResult, RefEntryError}; use crate::objects::MevaCommit; use crate::ref_manager::{BranchRefEntry, Head, HeadMode, RefEntry}; use crate::traversal::CommitHistoryWalker; -use crate::{BranchManager, ObjectStorage, RefManager}; +use crate::{BranchManager, ConfigDocument, ObjectStorage, RefManager}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -173,6 +174,7 @@ impl BranchManager for MevaBranchManager { HeadMode::Direct => Ok(None), } } + fn add_commit(&self, mut commit: MevaCommit) -> EngineResult { let last_commit_hash = self.last_commit_hash()?; match last_commit_hash { @@ -198,11 +200,13 @@ impl BranchManager for MevaBranchManager { let hash = self.object_storage.add_commit(commit)?; self.move_head(&hash)?; + Ok(hash) } fn last_commit_hash(&self) -> EngineResult> { let head = self.ref_manager.read_head()?; + Ok(match head.mode { HeadMode::Direct => Some(head.target), HeadMode::Symbolic => self @@ -223,8 +227,49 @@ impl BranchManager for MevaBranchManager { } } + fn set_upstream(&self, local_branch_name: &str, remote_branch_name: &str) -> EngineResult<()> { + let local_branch_ref = self + .ref_manager + .resolve_branch_ref(local_branch_name)? + .ok_or_else(|| BranchError::BranchNotFound(local_branch_name.to_string()))?; + let remote_branch_ref = self + .ref_manager + .resolve_branch_ref(remote_branch_name)? + .ok_or_else(|| BranchError::BranchNotFound(remote_branch_name.to_string()))?; + + if local_branch_ref.branch_type != BranchType::Local { + return Err(BranchError::NotLocalBranch(local_branch_name.to_string()).into()); + } + + if remote_branch_ref.branch_type != BranchType::Remote { + return Err(BranchError::NotRemoteBranch(remote_branch_name.to_string()).into()); + } + + let local_config_path = self.object_storage.layout().config_file(); + let mut config_document = ConfigDocument::load(local_config_path)?; + + let remote_branch_parts: Vec<_> = remote_branch_name.splitn(2, '/').collect(); + let remote_name = remote_branch_parts[0]; + let remote_branch_path = remote_branch_parts[1]; + + let mut branch_data = HashMap::new(); + branch_data.insert("remote".to_string(), remote_name.to_string()); + branch_data.insert( + "merge".to_string(), + format!( + "{}/{}", + self.ref_manager.heads_refs_prefix(), + remote_branch_path + ), + ); + + config_document.set_table(&format!("branch.{local_branch_name}"), &branch_data)?; + + config_document.save() + } + fn create_branch(&self, branch: &Branch) -> EngineResult<()> { - let ref_entry = RefEntry::new_branch_entry(branch); + let ref_entry = RefEntry::new_branch_entry(branch)?; self.ref_manager.update_ref(&ref_entry) } @@ -232,6 +277,7 @@ impl BranchManager for MevaBranchManager { for branch in branch { self.create_branch(branch)?; } + Ok(()) } @@ -323,7 +369,7 @@ impl BranchManager for MevaBranchManager { branch_type: old_branch_ref.branch_type, head_hash: old_branch_ref.ref_entry.commit_hash, }; - let new_branch_ref = RefEntry::new_branch_entry(&new_branch); + let new_branch_ref = RefEntry::new_branch_entry(&new_branch)?; self.ref_manager.update_ref(&new_branch_ref)?; self.ref_manager diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index d99b754..5397d38 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -7,11 +7,11 @@ use crate::commit_builder::MevaCommitBuilder; use crate::diff_builder::MevaDiffBuilder; use crate::errors::EngineResult; use crate::handlers::{ - add::AddHandler, branch::BranchHandler, clone::CloneHandler, commit::CommitHandler, - config::ConfigHandler, diff::DiffHandler, fetch::FetchHandler, init::InitHandler, - log::LogHandler, ls_files::LsFilesHandler, ls_tree::LsTreeHandler, plugins::PluginsHandler, - push::PushHandler, remote::RemoteHandler, restore::RestoreHandler, show::ShowHandler, - status::StatusHandler, + add::AddHandler, branch::BranchHandler, checkout::CheckoutHandler, clone::CloneHandler, + commit::CommitHandler, config::ConfigHandler, diff::DiffHandler, fetch::FetchHandler, + init::InitHandler, log::LogHandler, ls_files::LsFilesHandler, ls_tree::LsTreeHandler, + plugins::PluginsHandler, push::PushHandler, remote::RemoteHandler, restore::RestoreHandler, + show::ShowHandler, status::StatusHandler, }; use crate::index::{MevaIndex, MevaWorkingDir}; use crate::network::MevaRemotesManager; @@ -19,6 +19,7 @@ use crate::object_storage::{MevaDryRunObjectStorage, MevaObjectStorage}; use crate::plugins_interceptor::PluginsInterceptor; use crate::ref_manager::MevaRefManager; use crate::repositories::meva_repository_layout::MevaRepositoryLayout; +use crate::restore_manager::meva_restore_manager::MevaRestoreManager; use crate::revision_parsing::MevaRevisionResolver; use crate::traversal::{MevaCommitHistoryWalker, MevaCommitTreeWalker}; use crate::{MevaConfigLoader, MevaRepository, RepositoryLayout}; @@ -76,6 +77,7 @@ pub trait EngineContainer { /// Returns the handler responsible for branch management. fn branch_handler(&self) -> EngineResult; + fn checkout_handler(&self) -> EngineResult; /// Returns the handler responsible for push operation. fn push_handler(&self) -> EngineResult; @@ -128,25 +130,43 @@ impl EngineContainer for MevaContainer { let object_storage = Arc::new(MevaObjectStorage::new(layout.clone())); let ref_manager = Arc::new(MevaRefManager::new(layout.clone())); let remotes_manager = Arc::new(MevaRemotesManager); + let working_dir = Arc::new(MevaWorkingDir::new(layout.clone())); + let commit_tree_walker = Arc::new(MevaCommitTreeWalker::new(object_storage.clone())); + let index = Arc::new(RwLock::new(MevaIndex::from_disk( + working_dir.clone(), + object_storage.clone(), + Some(false), + )?)); + let restore_manager = Arc::new(MevaRestoreManager::new( + index, + working_dir, + object_storage.clone(), + commit_tree_walker, + )); let repository = Arc::new(MevaRepository::new( layout, config_loader, object_storage, ref_manager, remotes_manager, + restore_manager, )); + let handler = InitHandler { repository }; + Ok(handler) } fn config_handler(&self) -> EngineResult { let config_loader = Arc::new(MevaConfigLoader::default()); + Ok(ConfigHandler::new(config_loader)) } fn plugins_handler(&self) -> EngineResult { let plugins_repository = self.plugins_engine()?; let repository_layout = self.repository_layout_env()?; + let handler = PluginsHandler::new(plugins_repository, repository_layout); Ok(handler) @@ -161,6 +181,7 @@ impl EngineContainer for MevaContainer { object_storage, None, )?)); + let handler = AddHandler::new(repo_layout, index); Ok(handler) @@ -171,6 +192,7 @@ impl EngineContainer for MevaContainer { let working_dir = Arc::new(MevaWorkingDir::with_ignore_services(repo_layout.clone())); let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); let index = Arc::new(MevaIndex::from_disk(working_dir, object_storage, None)?); + let handler = LsFilesHandler::new(repo_layout, index); Ok(handler) @@ -185,6 +207,7 @@ impl EngineContainer for MevaContainer { refs_manager, )); let commit_tree_walker = Arc::new(MevaCommitTreeWalker::new(object_storage)); + let handler = LsTreeHandler::new(commit_tree_walker, revision_resolver); Ok(handler) @@ -210,6 +233,7 @@ impl EngineContainer for MevaContainer { working_dir.clone(), index.clone(), )?); + let handler = StatusHandler::new( working_dir, index, @@ -260,6 +284,7 @@ impl EngineContainer for MevaContainer { false => Arc::new(MevaCommitBuilder::new(object_storage, index_abs)), }; let config_loader = Arc::new(MevaConfigLoader::default()); + let handler = CommitHandler::new( diff_builder, refs_manager, @@ -318,6 +343,7 @@ impl EngineContainer for MevaContainer { working_dir, index, )?); + let handler = DiffHandler::new(diff_builder)?; Ok(handler) @@ -343,6 +369,7 @@ impl EngineContainer for MevaContainer { working_dir, index, )?); + let handler = ShowHandler::new(diff_builder)?; Ok(handler) @@ -363,13 +390,14 @@ impl EngineContainer for MevaContainer { refs_manager, )); let commit_tree_walker = Arc::new(MevaCommitTreeWalker::new(object_storage.clone())); - let handler = RestoreHandler::new( + let restore_manager = Arc::new(MevaRestoreManager::new( index, - revision_resolver, - commit_tree_walker, working_dir, object_storage, - ); + commit_tree_walker, + )); + + let handler = RestoreHandler::new(restore_manager, revision_resolver); Ok(handler) } @@ -379,6 +407,7 @@ impl EngineContainer for MevaContainer { let config_loader = Arc::new(MevaConfigLoader::default()); let handler = CloneHandler::new(repository_layout, config_loader); + Ok(handler) } @@ -391,6 +420,7 @@ impl EngineContainer for MevaContainer { let handler = FetchHandler::new(object_storage, config_loader, ref_manager, remotes_manager); + Ok(handler) } @@ -419,10 +449,57 @@ impl EngineContainer for MevaContainer { let branch_manager = Arc::new(MevaBranchManager::new( object_storage, commit_history_walker, - refs_manager, + refs_manager.clone(), + )); + + let handler = BranchHandler::new(branch_manager, revision_resolver, refs_manager); + + Ok(handler) + } + + fn checkout_handler(&self) -> EngineResult { + let repo_layout = self.repository_layout_discover()?; + let object_storage = Arc::new(MevaObjectStorage::new(repo_layout.clone())); + let working_dir = Arc::new(MevaWorkingDir::with_ignore_services(repo_layout.clone())); + + // TODO: refactor and use Arc> + let index1 = Arc::new(RwLock::new(MevaIndex::from_disk( + working_dir.clone(), + object_storage.clone(), + Some(false), + )?)); + let index2 = Arc::new(MevaIndex::from_disk( + working_dir.clone(), + object_storage.clone(), + Some(false), + )?); + + let refs_manager = Arc::new(MevaRefManager::new(repo_layout.clone())); + let revision_resolver = Arc::new(MevaRevisionResolver::new( + object_storage.clone(), + refs_manager.clone(), + )); + let diff_builder = Arc::new(MevaDiffBuilder::new( + object_storage.clone(), + revision_resolver.clone(), + working_dir.clone(), + index2.clone(), + )?); + let commit_tree_walker = Arc::new(MevaCommitTreeWalker::new(object_storage.clone())); + let restore_manager = Arc::new(MevaRestoreManager::new( + index1.clone(), + working_dir, + object_storage, + commit_tree_walker, )); - let handler = BranchHandler::new(branch_manager, revision_resolver); + let handler = CheckoutHandler::new( + index2, + refs_manager, + diff_builder, + restore_manager, + revision_resolver, + ); Ok(handler) } @@ -434,6 +511,7 @@ impl EngineContainer for MevaContainer { let remotes_manager = Arc::new(MevaRemotesManager); let handler = PushHandler::new(config_loader, remotes_manager, ref_manager); + Ok(handler) } } diff --git a/engine/src/errors.rs b/engine/src/errors.rs index 96fc608..422b59f 100644 --- a/engine/src/errors.rs +++ b/engine/src/errors.rs @@ -1,4 +1,5 @@ pub mod branch_error; +pub mod checkout_error; pub mod clone_error; pub mod commit_error; pub mod config_error; @@ -15,6 +16,7 @@ pub mod tree_error; pub mod unpack_error; pub use branch_error::BranchError; +pub use checkout_error::CheckoutError; pub use clone_error::CloneError; pub use commit_error::CommitError; pub use config_error::ConfigError; diff --git a/engine/src/errors/branch_error.rs b/engine/src/errors/branch_error.rs index c316512..a8da471 100644 --- a/engine/src/errors/branch_error.rs +++ b/engine/src/errors/branch_error.rs @@ -10,6 +10,23 @@ pub enum BranchError { #[error("Branch '{0}' does not exist")] BranchNotFound(String), + /// Occurs when an operation specifically requires a local branch (refs/heads/), + /// but the provided reference is a remote branch (refs/remotes/). + /// + /// This is a specialized case of BranchNotFound used when the system knows + /// the branch exists globally but not in the local namespace. + #[error("'{0}' is not a local branch.")] + NotLocalBranch(String), + + /// Occurs when an operation specifically requires a remote-tracking branch (refs/remotes/), + /// but the provided reference is a local branch (refs/heads/). + /// + /// Example: Trying to set a local branch as an upstream for another local branch. + #[error( + "'{0}' is not a remote branch. This operation requires a remote-tracking reference (e.g., 'origin/main')." + )] + NotRemoteBranch(String), + /// Occurs when attempting to create or rename a branch to a name that is already taken. /// /// Branch names must be unique. To resolve this, the user must either choose @@ -31,4 +48,27 @@ pub enum BranchError { /// can be retried with the `force` flag (`-D`) to bypass this check. #[error("Branch '{0} is not safe to delete")] NotSafeToDelete(String), + + /// Occurs when a branch name is syntactically invalid. + /// + /// Branch names must follow specific rules: + /// - Use `/` as a path separator (backslashes `\` are not allowed) + /// - Must not start or end with `/` + /// - Must not contain empty path segments (`//`) + /// - Must not contain illegal characters (e.g. `..`, `@{`, `~`, `^`, `:`) + #[error("Invalid branch name '{0}'")] + InvalidBranchName(String), + + /// Occurs when an operation requires an active local branch, + /// but the repository is currently in a detached `HEAD` state. + /// + /// In a detached `HEAD` state, `HEAD` points directly to a commit + /// rather than to a symbolic reference under `refs/heads/`. + /// As a result, operations that depend on a current branch context + /// (such as renaming a branch, deleting the current branch, + /// or setting an upstream) cannot be performed safely. + /// + /// To resolve this error, the user must first check out a local branch. + #[error("Cannot perform operation: you are currently in a detached HEAD state")] + DetachedHeadState(), } diff --git a/engine/src/errors/checkout_error.rs b/engine/src/errors/checkout_error.rs new file mode 100644 index 0000000..5e40433 --- /dev/null +++ b/engine/src/errors/checkout_error.rs @@ -0,0 +1,34 @@ +use thiserror::Error; + +/// Represents errors that can occur during checkout operations. +/// +/// Checkout errors are primarily safety mechanisms designed to prevent the +/// loss of uncommitted work in the working directory or the staging area (index). +#[derive(Error, Debug)] +pub enum CheckoutError { + /// Occurs when the working directory contains modifications that are not yet committed. + /// + /// This error is triggered during a branch switch if a file has been changed + /// locally and that same file differs between the current HEAD and the target branch. + /// Overwriting these changes would lead to permanent data loss. + /// + /// **To resolve this:** + /// - Commit your changes: `meva commit -m "..."` + /// - Discard local changes (use with caution): `meva checkout --force ` + /// - (If implemented) Stash your changes. + #[error("Repository contains uncommitted changes")] + WorkingTreeDirty, + + /// Occurs when the index (staging area) contains changes that have not been committed. + /// + /// Before switching to a different branch or commit, the index must typically + /// be clean (matching the current HEAD) to ensure a stable transition. + /// This prevents accidentally carrying over staged changes into a different + /// branch context where they might not belong. + /// + /// **To resolve this:** + /// - Complete the current commit: `meva commit` + /// - Unstage the changes: `meva reset` + #[error("Index contains uncommited changes")] + IndexDirty, +} diff --git a/engine/src/errors/engine_error.rs b/engine/src/errors/engine_error.rs index 62007d1..5172639 100644 --- a/engine/src/errors/engine_error.rs +++ b/engine/src/errors/engine_error.rs @@ -5,8 +5,9 @@ use std::{io, string::FromUtf8Error}; use thiserror::Error; use crate::errors::{ - BranchError, CloneError, CommitError, ConfigError, IgnoreError, IndexError, InitError, - NetworkError, PathError, RefEntryError, RepositoryError, RevisionError, TreeError, UnpackError, + BranchError, CheckoutError, CloneError, CommitError, ConfigError, IgnoreError, IndexError, + InitError, NetworkError, PathError, RefEntryError, RepositoryError, RevisionError, TreeError, + UnpackError, }; /// A convenient result type alias for engine-related operations. @@ -145,6 +146,13 @@ pub enum EngineError { #[error(transparent)] RefEntry(#[from] RefEntryError), + /// Errors encountered during checkout operations. + /// + /// These are primarily data-safety errors, preventing the loss of + /// uncommitted changes when switching branches or restoring files. + #[error(transparent)] + Checkout(#[from] CheckoutError), + /// A catch-all variant for any unknown or unexpected engine error. /// Accepts a descriptive string message. #[error("Unknown engine error: {0}")] diff --git a/engine/src/errors/ref_entry_error.rs b/engine/src/errors/ref_entry_error.rs index 4de507f..b2c20d9 100644 --- a/engine/src/errors/ref_entry_error.rs +++ b/engine/src/errors/ref_entry_error.rs @@ -26,4 +26,12 @@ pub enum RefEntryError { /// The expected prefix (e.g., "refs/heads/"). prefix: String, }, + + /// Occurs when a reference is found but contains no history (empty) or is corrupted. + /// + /// In a healthy repository, every existing reference must point to a valid commit. + /// This error indicates that the reference file exists in the filesystem but + /// lacks a hash, meaning the branch has no reachable history. + #[error("Reference '{0}' is empty or has no associated history")] + EmptyReference(String), } diff --git a/engine/src/handlers.rs b/engine/src/handlers.rs index 948cb01..8873344 100644 --- a/engine/src/handlers.rs +++ b/engine/src/handlers.rs @@ -1,5 +1,6 @@ pub mod add; pub mod branch; +pub mod checkout; pub mod clone; pub mod commit; pub mod config; diff --git a/engine/src/handlers/branch/handlers.rs b/engine/src/handlers/branch/handlers.rs index d4e5e9a..ea1be56 100644 --- a/engine/src/handlers/branch/handlers.rs +++ b/engine/src/handlers/branch/handlers.rs @@ -1,9 +1,11 @@ use crate::branch_manager::{Branch, BranchType}; -use crate::errors::EngineResult; +use crate::errors::{BranchError, EngineResult}; use crate::handlers::branch::{ BranchCollection, BranchOperations, CreateRequest, DeleteRequest, ListRequest, RenameRequest, - Request, Response, + Request, Response, SetUpstream, }; +use crate::ref_manager::{HeadMode, RefManager}; +use crate::revision_parsing::BaseReference; use crate::{BranchManager, RevisionResolver}; use std::sync::Arc; @@ -20,8 +22,14 @@ use std::sync::Arc; /// The `branch_manager` field encapsulates the core logic for filesystem and reference updates, /// while `revision_resolver` ensures that user-provided start points are valid. pub struct BranchHandler { + /// Service for low-level reference and branch manipulation. branch_manager: Arc, + + /// Utility for resolving symbolic names and modifiers to commit hashes. revision_resolver: Arc, + + /// Manager for low-level reference lookups (heads, remotes, and HEAD). + refs_manager: Arc, } impl BranchHandler { @@ -35,10 +43,12 @@ impl BranchHandler { pub fn new( branch_manager: Arc, revision_resolver: Arc, + refs_manager: Arc, ) -> Self { Self { branch_manager, revision_resolver, + refs_manager, } } @@ -55,15 +65,40 @@ impl BranchHandler { /// Returns an [`EngineError`] if the start point cannot be resolved /// or if a branch with the same name already exists. fn handler_create(&self, request: CreateRequest) -> EngineResult<()> { - let branch_head = self.revision_resolver.resolve_hash(&request.start_point)?; + let start_point = request.start_point; + let branch_head = self.revision_resolver.resolve_hash(&start_point)?; let new_branch = Branch { - name: request.branch_name, + name: request.branch_name.clone(), branch_type: BranchType::Local, head_hash: branch_head, }; - self.branch_manager.create_branch(&new_branch) + self.branch_manager.create_branch(&new_branch)?; + + if start_point.modifiers.is_empty() + && let BaseReference::Ref(base) = start_point.base + { + if base.starts_with(&self.refs_manager.remotes_refs_prefix()) { + self.branch_manager.set_upstream( + &request.branch_name, + base.strip_prefix(&self.refs_manager.remotes_refs_prefix()) + .unwrap(), + )?; + } else if !base.starts_with(&self.refs_manager.heads_refs_prefix()) { + let branch = self + .refs_manager + .resolve_branch_ref(&base)? + .ok_or_else(|| BranchError::BranchNotFound(base.clone()))?; + + if branch.branch_type == BranchType::Remote { + self.branch_manager + .set_upstream(&request.branch_name, &base)?; + } + } + } + + Ok(()) } /// Handles the deletion of a branch. @@ -108,6 +143,47 @@ impl BranchHandler { Ok(display_list) } + + /// Sets or updates the upstream (tracking) branch for a local branch. + /// + /// If the local branch is explicitly provided in the request, it is used directly. + /// Otherwise, the currently checked-out branch is inferred from `HEAD`. + /// + /// This operation establishes a relationship between a local branch and a + /// remote-tracking branch, enabling commands such as `pull` and `push` + /// to operate without explicitly specifying a remote branch. + /// + /// # Arguments + /// + /// * `request` - A [`SetUpstream`] request containing the local and remote + /// branch names. + /// + /// # Errors + /// + /// Returns an [`EngineError`] if: + /// - The repository is in a detached `HEAD` state and no local branch is provided. + /// - The resolved `HEAD` does not point to a valid local branch (internal error). + /// - The specified local or remote branch does not exist or is of the wrong type. + /// - Updating the branch configuration or references fails. + fn handle_set_upstream(&self, request: SetUpstream) -> EngineResult<()> { + let local_branch = match request.local_branch { + Some(local_branch) => local_branch, + None => { + let head = self.refs_manager.read_head()?; + + if head.mode == HeadMode::Direct { + return Err(BranchError::DetachedHeadState().into()); + } + + head.target.strip_prefix(format!("{}/", self.refs_manager.heads_refs_prefix()).as_str()) + .expect("INTERNAL ERROR: Symbolic HEAD points to a non-branch reference. This is a bug in Meva.") + .to_string() + } + }; + + self.branch_manager + .set_upstream(&local_branch, &request.remote_branch) + } } impl BranchOperations for BranchHandler { @@ -116,6 +192,7 @@ impl BranchOperations for BranchHandler { Request::Create(request) => self.handler_create(request)?, Request::Delete(request) => self.handler_delete(request)?, Request::Rename(request) => self.handle_rename(request)?, + Request::SetUpstream(request) => self.handle_set_upstream(request)?, Request::List(request) => { return Ok(Response { branches: Some(self.handle_list(request)?), diff --git a/engine/src/handlers/branch/operations.rs b/engine/src/handlers/branch/operations.rs index 72e47c9..bf4a593 100644 --- a/engine/src/handlers/branch/operations.rs +++ b/engine/src/handlers/branch/operations.rs @@ -21,6 +21,9 @@ pub enum Request { /// Request to list branches. List(ListRequest), + + /// Request to configure branch upstream. + SetUpstream(SetUpstream), } /// Parameters required to create a new branch. @@ -80,8 +83,32 @@ impl ListRequest { } } +/// Parameters required to configure an upstream (tracking) branch. +pub struct SetUpstream { + /// The local branch for which the upstream should be configured. + /// + /// If `None`, the currently checked-out local branch (as determined + /// by `HEAD`) will be used. + pub local_branch: Option, + + /// The remote-tracking branch to set as the upstream. + /// + /// This must refer to an existing remote branch + /// (e.g., `origin/main`). + pub remote_branch: String, +} + +/// Represents the response of a branch command. +/// +/// This structure is primarily used by commands that produce output +/// (such as listing branches). Commands that only perform mutations +/// (create, delete, rename, set-upstream) typically return an empty response. #[derive(Debug, Default)] pub struct Response { + /// The collection of branches to be displayed, if applicable. + /// + /// This field is populated for list operations and left as `None` + /// for commands that do not produce a branch listing. pub branches: Option, } diff --git a/engine/src/handlers/checkout.rs b/engine/src/handlers/checkout.rs new file mode 100644 index 0000000..5fa4a3d --- /dev/null +++ b/engine/src/handlers/checkout.rs @@ -0,0 +1,5 @@ +mod handlers; +mod operations; + +pub use handlers::CheckoutHandler; +pub use operations::*; diff --git a/engine/src/handlers/checkout/handlers.rs b/engine/src/handlers/checkout/handlers.rs new file mode 100644 index 0000000..310d4da --- /dev/null +++ b/engine/src/handlers/checkout/handlers.rs @@ -0,0 +1,202 @@ +use crate::branch_manager::BranchType; +use crate::diff_builder::DiffMode; +use crate::errors::{BranchError, CheckoutError, EngineResult, RefEntryError}; +use crate::handlers::checkout::{CheckoutOperations, Request, Response}; +use crate::ref_manager::{Head, HeadMode, RefManager}; +use crate::restore_manager::RestoreManager; +use crate::revision_parsing::{BaseReference, Revision}; +use crate::{DiffBuilder, Index, RevisionResolver}; +use std::sync::Arc; + +/// Handles the `meva checkout` command execution flow. +/// +/// The `CheckoutHandler` serves as the application layer for repository state transitions, +/// ensuring that switching branches or restoring files is performed safely and consistently. +/// It is responsible for: +/// - **Safety Validation**: Verifying if the index and working tree are "clean" to prevent data loss. +/// - **Revision Resolution**: Mapping abstract targets (hashes, branch names, relative refs) +/// to concrete commits via [`RevisionResolver`]. +/// - **Data Restoration**: Synchronizing the working directory and index with the target tree +/// using the [`RestoreManager`]. +/// - **HEAD Management**: Deciding whether the repository should enter an **Attached** or +/// **Detached** state based on the checkout target. +/// +/// This handler relies on [`DiffBuilder`] to detect uncommitted changes and [`RefManager`] +/// to persist the movement of `HEAD`. +pub struct CheckoutHandler { + /// Interface for accessing and validating the staging area. + index: Arc, + + /// Manages reading and updating symbolic and direct references (HEAD, branches). + refs_manager: Arc, + + /// Core utility for comparing different states (HEAD vs Index, Index vs Working Dir). + diff_builder: Arc, + + /// Handles the physical extraction of objects to the filesystem and index updates. + restore_manager: Arc, + + /// Service used to resolve checkout targets (strings) to valid [`Revision`] objects. + revision_resolver: Arc, +} + +impl CheckoutHandler { + /// Creates a new [`CheckoutHandler`] with all necessary infrastructure for state transitions. + pub fn new( + index: Arc, + refs_manager: Arc, + diff_builder: Arc, + restore_manager: Arc, + revision_resolver: Arc, + ) -> Self { + Self { + index, + refs_manager, + diff_builder, + restore_manager, + revision_resolver, + } + } + + /// Entry point for the checkout operation. + /// + /// Orchestrates the validation, restoration, and HEAD update process. + pub fn handle_checkout(&self, request: Request) -> EngineResult { + self.checkout(request) + } + + /// Verifies if the index (staging area) is clean relative to the current `HEAD`. + /// + /// A clean index means there are no staged changes awaiting commit. This check + /// is vital to ensure that a checkout doesn't overwrite a prepared commit. + /// + /// # Returns + /// * `Ok(true)` - If the index matches `HEAD` or if the repository is empty and the index is empty. + /// * `Ok(false)` - If there are staged modifications. + fn is_index_clean(&self) -> EngineResult { + let head_hash = self.refs_manager.resolve_head()?; + + if let Some(head_hash) = head_hash { + let revision = Revision::hash(&head_hash, Vec::new()); + let diff = + self.diff_builder + .diff_snapshot_index(&revision, &DiffMode::NameOnly, None)?; + + if !diff.is_empty() { + return Ok(false); + } + } else if !self.index.is_empty() { + return Ok(false); + } + + Ok(true) + } + + /// Verifies if the working directory matches the current index. + /// + /// This check detects "dirty" files—modifications that have been made to files + /// on disk but have not yet been staged or committed. + /// + /// # Returns + /// * `Ok(true)` - If no unstaged changes are detected. + fn is_working_tree_clean(&self) -> EngineResult { + let diff = + self.diff_builder + .diff_index_working_dir(&DiffMode::NameOnly, None, None, None)?; + + if diff.is_empty() { + return Ok(true); + } + + Ok(false) + } + + /// Physically restores the repository files and updates the index. + /// + /// This is the "heavy lifting" part of checkout where blobs are written to disk. + /// It delegates to [`RestoreManager`], which ensures that the working directory + /// exactly matches the tree associated with the `target_branch_hash`. + fn restore_repository(&self, target_branch_hash: &str) -> EngineResult<()> { + self.restore_manager + .restore(target_branch_hash, true, true, &[]) + } + + /// Updates the `HEAD` reference to reflect the new state. + /// + /// This method implements the core logic for **Attached** vs **Detached** HEAD: + /// 1. **Detached (Direct)**: If `detach` flag is true, modifiers (like `~1`) are present, + /// or the target is a remote branch. `HEAD` will contain a raw SHA-1. + /// 2. **Attached (Symbolic)**: If the target is a local branch name. `HEAD` will + /// contain a `ref: refs/heads/` pointer. + /// + /// # Arguments + /// * `revision` - The resolved revision target. + /// * `detach` - Whether to force a detached HEAD state. + /// + /// # Errors + /// + /// * [`BranchError::BranchNotFound`] – If the symbolic reference path is invalid. + /// * [`EngineError::Repository`] – If the `HEAD` file cannot be written. + fn update_head(&self, revision: &Revision, detach: bool) -> EngineResult<()> { + let head = if detach || !revision.modifiers.is_empty() { + let hash = self.revision_resolver.resolve_hash(revision)?; + + Head::new(HeadMode::Direct, &hash) + } else if let BaseReference::Ref(base) = &revision.base { + if base.starts_with(&self.refs_manager.heads_refs_prefix()) { + let _ = self.refs_manager.read_ref(base)?; + + Head::new(HeadMode::Symbolic, base) + } else if base.starts_with(&self.refs_manager.remotes_refs_prefix()) { + let entry = self + .refs_manager + .read_ref(base)? + .ok_or_else(|| RefEntryError::EmptyReference(base.to_owned()))?; + + Head::new(HeadMode::Direct, &entry.commit_hash) + } else { + let branch = self + .refs_manager + .resolve_branch_ref(base)? + .ok_or_else(|| BranchError::BranchNotFound(base.clone()))?; + match branch.branch_type { + BranchType::Local => Head::new(HeadMode::Symbolic, &branch.ref_entry.name), + BranchType::Remote => { + Head::new(HeadMode::Direct, &branch.ref_entry.commit_hash) + } + } + } + } else { + let hash = self.revision_resolver.resolve_hash(revision)?; + + Head::new(HeadMode::Direct, &hash) + }; + + self.refs_manager.update_head(head) + } +} + +impl CheckoutOperations for CheckoutHandler { + fn checkout(&self, request: Request) -> EngineResult { + if !request.force { + if !self.is_index_clean()? { + return Err(CheckoutError::IndexDirty.into()); + } + + if !self.is_working_tree_clean()? { + return Err(CheckoutError::WorkingTreeDirty.into()); + } + } + + let target_branch_hash = self.revision_resolver.resolve_hash(&request.target)?; + + if request.merge { + //TODO: Try merge here + } + + self.restore_repository(&target_branch_hash)?; + self.update_head(&request.target, request.detach)?; + + Ok(Response {}) + } +} diff --git a/engine/src/handlers/checkout/operations.rs b/engine/src/handlers/checkout/operations.rs new file mode 100644 index 0000000..d273aa8 --- /dev/null +++ b/engine/src/handlers/checkout/operations.rs @@ -0,0 +1,35 @@ +use crate::errors::EngineResult; +use crate::revision_parsing::Revision; + +/// Encapsulates the parameters for a checkout operation. +#[derive(Debug)] +pub struct Request { + /// If true, bypasses the safety checks for 'dirty' index and working tree. + /// Equivalent to `meva checkout -f`. + pub force: bool, + + /// If true, forces the repository into a **detached HEAD** state even + /// if the target is a local branch. + pub detach: bool, + + /// If true, attempts a three-way merge between the current branch, + /// the common ancestor, and the target branch. + pub merge: bool, + + /// The target revision to switch to. Includes the base reference + /// (branch, hash, or tag) and optional modifiers (e.g., `~`, `^`). + pub target: Revision, +} + +pub struct Response; + +/// Interface for performing checkout operations within the Meva engine. +/// +/// This trait defines the contract for synchronizing the working environment +/// with a specific point in the repository's history. It ensures that +/// transitions between branches or commits are performed with strict data +/// integrity checks. +pub trait CheckoutOperations { + /// Executes the checkout process following a strict safety protocol. + fn checkout(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/handlers/clone/handlers.rs b/engine/src/handlers/clone/handlers.rs index bb933bc..ec8860f 100644 --- a/engine/src/handlers/clone/handlers.rs +++ b/engine/src/handlers/clone/handlers.rs @@ -1,24 +1,25 @@ +use async_trait::async_trait; use std::{ fs, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, RwLock}, }; - -use async_trait::async_trait; use tempfile::TempDir; +use super::{CloneOperations, Request, Response}; use crate::{ ConfigLoader, MevaConfigLoader, MevaRepository, RepositoryLayout, errors::{CloneError, EngineError, EngineResult, NetworkError}, + index::{MevaIndex, MevaWorkingDir}, network::{MevaRemotesManager, PackfileCodec, SshConnectionParams, SshService, SshSession}, object_storage::MevaObjectStorage, objects::MevaObject, ref_manager::{MevaRefManager, RefEntry}, repositories::{meva_repository::Repository, meva_repository_layout::MevaRepositoryLayout}, + restore_manager::MevaRestoreManager, + traversal::MevaCommitTreeWalker, }; -use super::{CloneOperations, Request, Response}; - #[derive(Debug)] pub struct CloneHandler { ssh_service: SshService, @@ -115,12 +116,27 @@ impl CloneHandler { let object_storage = Arc::new(MevaObjectStorage::new(repository_layout.clone())); let ref_manager = Arc::new(MevaRefManager::new(repository_layout.clone())); let remotes_manager = Arc::new(MevaRemotesManager); + let commit_tree_walker = Arc::new(MevaCommitTreeWalker::new(object_storage.clone())); + let working_dir = Arc::new(MevaWorkingDir::new(repository_layout.clone())); + let index = Arc::new(RwLock::new(MevaIndex::from_disk( + working_dir.clone(), + object_storage.clone(), + Some(false), + )?)); + let restore_manager = Arc::new(MevaRestoreManager::new( + index, + working_dir, + object_storage.clone(), + commit_tree_walker, + )); + let repository = MevaRepository::new( repository_layout.clone(), Arc::new(MevaConfigLoader::default()), object_storage, ref_manager, remotes_manager, + restore_manager, ); repository.clone(objects, refs, request) diff --git a/engine/src/handlers/restore/handlers.rs b/engine/src/handlers/restore/handlers.rs index d4f199c..9a753d0 100644 --- a/engine/src/handlers/restore/handlers.rs +++ b/engine/src/handlers/restore/handlers.rs @@ -1,16 +1,8 @@ use super::{Request, Response, RestoreOperations}; +use crate::RevisionResolver; use crate::errors::EngineResult; -use crate::index::{FileMode, index_entry::IndexEntry, stage::Stage}; -use crate::objects::{MevaBlob, MevaObject, ObjectEntry}; -use crate::{CommitTreeWalker, Index, ObjectStorage, RevisionResolver, WorkingDir}; -use chrono::Utc; -use path_absolutize::Absolutize; -use shared::{PathToString, StripBase}; -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, RwLock}; +use crate::restore_manager::RestoreManager; +use std::sync::Arc; /// Handles the `meva restore` command execution flow. /// @@ -27,39 +19,22 @@ use std::sync::{Arc, RwLock}; /// /// The handler supports partial restores (specific paths) or full repository restores. pub struct RestoreHandler { - index: Arc>, + /// Core logic for reading from the object database and writing to the disk/index. + restore_manager: Arc, + + /// Service for resolving the source revision (where to restore from). revision_resolver: Arc, - commit_tree_walker: Arc, - working_dir: Arc, - object_storage: Arc, } impl RestoreHandler { - /// Creates a new [`RestoreHandler`] and wires all required subsystems. - /// - /// This constructor does not perform any I/O; it only stores references - /// to the necessary repository components. - /// - /// # Arguments - /// - /// * `index` — Thread-safe (RwLock-backed) access to the index for updates. - /// * `revision_resolver` — Resolves revision identifiers into commits. - /// * `commit_tree_walker` — Walks tree objects to find entries to restore. - /// * `working_dir` — Writes files into the working tree when requested. - /// * `object_storage` — Provides access to blob contents needed to restore files. + /// Creates a new instance of [`RestoreHandler`]. pub fn new( - index: Arc>, + restore_manager: Arc, revision_resolver: Arc, - commit_tree_walker: Arc, - working_dir: Arc, - object_storage: Arc, ) -> Self { Self { - index, + restore_manager, revision_resolver, - commit_tree_walker, - working_dir, - object_storage, } } @@ -69,188 +44,6 @@ impl RestoreHandler { pub fn handle_restore(&self, request: Request) -> EngineResult { self.restore(request) } - - // Maps a list of source objects to a (path: object) map. - fn map_source_files(source_files: &[ObjectEntry]) -> HashMap { - source_files.iter().map(|o| (o.path.clone(), o)).collect() - } - - // Determines which entries need to be added to the index. - fn build_index_entries_to_add( - source_files_map: &HashMap, - index_entries_map: &HashMap, - ) -> EngineResult> { - let now = Utc::now(); - let mut entries = Vec::with_capacity(source_files_map.len()); - - for (file_path, object_entry) in source_files_map { - if let Some(index_entry) = index_entries_map.get(file_path) - && index_entry.sha1 == object_entry.hash - { - continue; - } - - let entry = IndexEntry { - ctime: now, - mtime: now, - mode: FileMode::try_from(&object_entry.entry_type)?, - file_size: object_entry.size.unwrap_or(0), - sha1: object_entry.hash.clone(), - stage: Stage::Normal, - path: file_path.to_utf8_string(), - }; - - entries.push(entry); - } - - Ok(entries) - } - - // Determines which entries need to be removed from the index. - fn build_index_entries_to_remove( - index_entries_map: &HashMap, - source_files_map: &HashMap, - ) -> Vec { - index_entries_map - .iter() - .filter(|(path, _)| !source_files_map.contains_key(*path)) - .map(|(path, _)| path.to_utf8_string()) - .collect() - } - - /// Restores files in the index (staging area) based on the source snapshot. - /// - /// Adds missing files, updates modified files, and removes files not present in the source. - fn restore_index( - &self, - source_files: &[ObjectEntry], - paths: Option<&[PathBuf]>, - ) -> EngineResult<()> { - let source_files_map = Self::map_source_files(source_files); - - let index_read_guard = self.index.read().unwrap(); - - let index_entries_map = index_read_guard.get_entries_map(paths); - let index_entries_to_add = - Self::build_index_entries_to_add(&source_files_map, &index_entries_map)?; - let index_entries_to_remove = - Self::build_index_entries_to_remove(&index_entries_map, &source_files_map); - - drop(index_read_guard); - - let mut index_write_guard = self.index.write().unwrap(); - index_write_guard.insert_entries(index_entries_to_add); - index_write_guard.remove_entries(index_entries_to_remove); - - index_write_guard.save()?; - Ok(()) - } - - // Collects all files under the specified worktree paths. - fn build_workdir_files(&self, paths: &[PathBuf]) -> HashSet { - let mut workdir_files = HashSet::new(); - - for path in paths { - let collected_files: Vec = self - .working_dir - .collect_files(path, false) - .into_iter() - .filter_map(|(entry_path, _)| { - entry_path - .absolutize() - .map(|path| { - path.strip_base(&self.working_dir.layout().working_dir()) - .to_path_buf() - }) - .ok() - }) - .collect(); - workdir_files.extend(collected_files); - } - workdir_files - } - - // Resolves paths to restore, falling back to a default path if none are provided. - fn resolve_workdir_paths(paths: Option<&[PathBuf]>, default: &Path) -> Vec { - match paths { - Some(p) => p.to_vec(), - None => vec![default.to_path_buf()], - } - } - - // Writes a file atomically. - fn write_file_atomically(path: &Path, data: &[u8]) -> EngineResult<()> { - let parent = path.parent(); - if let Some(parent) = parent { - fs::create_dir_all(parent)?; - } - - let mut tmp = tempfile::NamedTempFile::new_in(parent.unwrap_or(Path::new(".")))?; - tmp.write_all(data)?; - tmp.flush()?; - fs::rename(tmp.path(), path)?; - Ok(()) - } - - // Writes a file directly. - fn write_file_direct(path: &Path, data: &[u8]) -> EngineResult<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let mut file = fs::File::create(path)?; - file.write_all(data)?; - Ok(()) - } - - // Removes files from the working directory that are not present in the source. - fn remove_extra_files( - workdir_files: &HashSet, - source_files_map: &HashMap, - ) -> std::io::Result<()> { - for workdir_file in workdir_files { - if !source_files_map.contains_key(workdir_file) { - fs::remove_file(workdir_file)?; - } - } - Ok(()) - } - - /// Restores files in the working tree (filesystem) based on the source snapshot. - /// - /// Compares file contents to avoid unnecessary writes, writes updated files, - /// and removes files not present in the snapshot. - fn restore_working_tree( - &self, - source_files: &[ObjectEntry], - paths: Option<&[PathBuf]>, - ) -> EngineResult<()> { - let source_files_map: HashMap = Self::map_source_files(source_files); - let workdir_paths = - Self::resolve_workdir_paths(paths, &self.working_dir.layout().working_dir()); - let workdir_files = self.build_workdir_files(&workdir_paths); - - for (file_path, object_entry) in &source_files_map { - let source_blob = self.object_storage.get_object(&object_entry.hash)?; - - if workdir_files.contains(file_path) { - let workdir_blob = MevaObject::try_from(MevaBlob::from_file(file_path)?)?; - - if source_blob.sha1() == workdir_blob.sha1() { - continue; - } - - let source_blob = MevaBlob::try_from(source_blob)?; - Self::write_file_atomically(file_path, &source_blob.data)?; - } else { - let source_blob = MevaBlob::try_from(source_blob)?; - Self::write_file_direct(file_path, &source_blob.data)?; - } - } - - Self::remove_extra_files(&workdir_files, &source_files_map)?; - - Ok(()) - } } impl RestoreOperations for RestoreHandler { @@ -264,20 +57,13 @@ impl RestoreOperations for RestoreHandler { /// Returns a [`Response`] after successfully restoring files. fn restore(&self, request: Request) -> EngineResult { let commit_hash = self.revision_resolver.resolve_hash(&request.source)?; - let path_filter = match request.paths.len() { - 0 => None, - _ => Some(request.paths.as_slice()), - }; - let source_files = - self.commit_tree_walker - .walk_commit(&commit_hash, false, None, path_filter)?; - if request.staged { - self.restore_index(&source_files, path_filter)?; - } - if request.worktree { - self.restore_working_tree(&source_files, path_filter)?; - } + self.restore_manager.restore( + &commit_hash, + request.staged, + request.worktree, + &request.paths, + )?; Ok(Response {}) } diff --git a/engine/src/index.rs b/engine/src/index.rs index 3e15bc5..4356e81 100644 --- a/engine/src/index.rs +++ b/engine/src/index.rs @@ -21,6 +21,15 @@ pub use working_dir::{MevaWorkingDir, WorkingDirEntry}; use crate::errors::EngineResult; pub trait Index: Send + Sync { + /// Checks whether the index is currently empty. + /// + /// An empty index indicates that no files are being tracked. This typically + /// occurs in a newly initialized repository before the first `add` operation. + /// + /// # Returns + /// * `true` if there are no entries; `false` otherwise. + fn is_empty(&self) -> bool; + /// Returns all entries currently tracked in the index. /// /// The returned vector contains borrowed references to the underlying diff --git a/engine/src/index/meva_index.rs b/engine/src/index/meva_index.rs index 2e1bf08..f268742 100644 --- a/engine/src/index/meva_index.rs +++ b/engine/src/index/meva_index.rs @@ -216,6 +216,10 @@ impl MevaIndex { } impl Index for MevaIndex { + fn is_empty(&self) -> bool { + self.entries.is_empty() + } + fn get_entries(&self) -> Vec<&IndexEntry> { self.entries.values().collect() } diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 71d3a13..07eebe4 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -17,6 +17,7 @@ pub mod objects; pub mod plugins_interceptor; pub mod ref_manager; pub mod repositories; +pub mod restore_manager; pub mod revision_parsing; use errors::{EngineResult, InitError}; diff --git a/engine/src/network/protocols/receive_pack.rs b/engine/src/network/protocols/receive_pack.rs index f21e554..325b1a1 100644 --- a/engine/src/network/protocols/receive_pack.rs +++ b/engine/src/network/protocols/receive_pack.rs @@ -100,6 +100,7 @@ impl ReceivePackProtocol { } } } + Ok(ready_to_send_pack) } @@ -124,7 +125,7 @@ impl ReceivePackProtocol { if name == "HEAD" { self.remote_head = Some(sha.to_string()); } else { - self.server_refs.push(RefEntry::new(name, sha)); + self.server_refs.push(RefEntry::new(name, sha)?); } } } @@ -154,6 +155,7 @@ impl ReceivePackProtocol { } } } + Ok(()) } diff --git a/engine/src/network/protocols/upload_pack.rs b/engine/src/network/protocols/upload_pack.rs index 2bcb0e8..707611e 100644 --- a/engine/src/network/protocols/upload_pack.rs +++ b/engine/src/network/protocols/upload_pack.rs @@ -108,6 +108,7 @@ impl UploadPackProtocol { return Ok(true); } } + Ok(false) } @@ -124,7 +125,7 @@ impl UploadPackProtocol { if verbose { println!("Received ref: {} ({ref_name})", sha.yellow()); } - self.refs.push(RefEntry::new(&ref_name, sha)); + self.refs.push(RefEntry::new(&ref_name, sha)?); } } UploadPackState::Negotiation => { @@ -176,6 +177,7 @@ impl UploadPackProtocol { } _ => {} } + Ok(()) } @@ -251,6 +253,7 @@ impl UploadPackProtocol { } _ => {} } + Ok(()) } } diff --git a/engine/src/object_storage/meva_object_storage.rs b/engine/src/object_storage/meva_object_storage.rs index 2ec86e6..a2ceb7f 100644 --- a/engine/src/object_storage/meva_object_storage.rs +++ b/engine/src/object_storage/meva_object_storage.rs @@ -133,6 +133,12 @@ impl ObjectStorage for MevaObjectStorage { MevaObject::from_compressed_bytes_with_context(&compressed, &context) } + fn object_exists(&self, hash: &str) -> EngineResult { + let path = self.object_path(hash); + + Ok(path.try_exists()?) + } + fn layout(&self) -> &Arc { &self.repo_layout } @@ -167,6 +173,7 @@ impl ObjectStorage for MevaObjectStorage { Err(e) => return Err(e.into()), } } + Ok(()) } @@ -224,11 +231,6 @@ impl ObjectStorage for MevaObjectStorage { Ok(objects_to_pack) } - fn object_exists(&self, hash: &str) -> EngineResult { - let path = self.object_path(hash); - Ok(path.try_exists()?) - } - fn is_descendant_of(&self, ancestor_hash: &str, descendant_hash: &str) -> EngineResult { if descendant_hash == ancestor_hash { return Ok(true); diff --git a/engine/src/ref_manager.rs b/engine/src/ref_manager.rs index c58c1ea..26b6e94 100644 --- a/engine/src/ref_manager.rs +++ b/engine/src/ref_manager.rs @@ -55,8 +55,26 @@ pub trait RefManager: Send + Sync { /// or commit pointer. fn update_head(&self, head: Head) -> EngineResult<()>; - /// Reads a named reference (e.g. `"refs/heads/master"`) and returns the associated - /// [`RefEntry`], if it exists. + /// Reads a named reference and parses it into a [`RefEntry`]. + /// + /// This method attempts to locate a reference file within the repository directory + /// (e.g., `.meva/refs/heads/master`), read its JSON content, and deserialize it. + /// + /// # Arguments + /// + /// * `name` - The relative path of the reference from the repository root + /// (e.g., `"refs/heads/main"` or `"HEAD"`). + /// + /// # Returns + /// + /// * `Ok(Some(RefEntry))` - If the reference exists and contains valid JSON data. + /// * `Ok(None)` - If the reference file is empty - represents empty history. + /// * `Err(EngineError)` - If the file cannot be read or the JSON is malformed. + /// + /// # Example Path Resolution + /// + /// If `name` is `"refs/heads/master"`, the function will look for the file at: + /// `{repository_dir}/refs/heads/master` fn read_ref(&self, name: &str) -> EngineResult>; /// Retrieves a list of all local branch references (typically stored in `refs/heads/`). diff --git a/engine/src/ref_manager/meva_ref_manager.rs b/engine/src/ref_manager/meva_ref_manager.rs index 2c2931e..1c76f40 100644 --- a/engine/src/ref_manager/meva_ref_manager.rs +++ b/engine/src/ref_manager/meva_ref_manager.rs @@ -41,6 +41,7 @@ impl MevaRefManager { let mut file = File::open(path)?; let mut content = String::new(); file.read_to_string(&mut content)?; + Ok(content) } @@ -52,6 +53,7 @@ impl MevaRefManager { .truncate(true) .open(path)?; file.write_all(content.as_bytes())?; + Ok(()) } @@ -87,11 +89,13 @@ impl MevaRefManager { impl RefManager for MevaRefManager { fn read_head(&self) -> EngineResult { let content = Self::read_file(&self.repo_layout.head_file())?; + Head::from_json(&content) } fn resolve_head(&self) -> EngineResult> { let head = self.read_head()?; + self.resolve_commit_hash(&head) } @@ -100,6 +104,7 @@ impl RefManager for MevaRefManager { HeadMode::Direct => Some(head.target.clone()), HeadMode::Symbolic => self.read_ref(&head.target)?.map(|e| e.commit_hash), }; + Ok(hash) } @@ -131,6 +136,7 @@ impl RefManager for MevaRefManager { } let entry = RefEntry::from_json(&content)?; + Ok(Some(entry)) } @@ -217,9 +223,7 @@ impl RefManager for MevaRefManager { fs::create_dir_all(parent)?; - self.write_file(&path, &entry.to_json()?)?; - - Ok(()) + self.write_file(&path, &entry.to_json()?) } fn update_remote_refs( @@ -237,9 +241,10 @@ impl RefManager for MevaRefManager { remote_ref.commit_hash.yellow() ); } - let new_entry = RefEntry::new(&tracking_name, &remote_ref.commit_hash); + let new_entry = RefEntry::new(&tracking_name, &remote_ref.commit_hash)?; self.update_ref(&new_entry)?; } + Ok(()) } diff --git a/engine/src/ref_manager/ref_entry.rs b/engine/src/ref_manager/ref_entry.rs index 9809d65..436bba7 100644 --- a/engine/src/ref_manager/ref_entry.rs +++ b/engine/src/ref_manager/ref_entry.rs @@ -1,6 +1,7 @@ use std::fmt::Display; use crate::branch_manager::{Branch, BranchType}; +use crate::errors::{EngineResult, RefEntryError}; use crate::serialize_deserialize::MevaEncode; use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; @@ -23,22 +24,29 @@ pub struct RefEntry { } impl RefEntry { - /// Creates a new [`RefEntry`] with the provided reference name and commit hash. + /// Creates a new [`RefEntry`] with the provided reference name + /// and commit hash with path normalization. + /// + /// The name is automatically normalized (e.g., converting `\` to `/`) + /// and the commit hash is trimmed of any leading/trailing whitespace. /// /// # Arguments /// /// * `name` – The name of the reference (e.g., `refs/heads/master`). /// * `contents` – The commit hash associated with the reference. - pub fn new(name: &str, contents: &str) -> Self { - RefEntry { - name: name.to_string(), + pub fn new(name: &str, contents: &str) -> EngineResult { + let name = Self::normalize_ref_name(name)?; + + Ok(Self { + name, commit_hash: contents.trim().to_string(), - } + }) } - /// Constructs a `RefEntry` based on a higher-level [`Branch`] definition. + /// Constructs a `RefEntry` based on a higher-level [`Branch`] definition, + /// automatically applying the correct system prefix (`refs/heads/` or `refs/remotes/`). /// - /// This method automatically determines the correct reference prefix based on the + /// This method determines the correct reference prefix based on the /// [`BranchType`]: /// - **Local**: Prefixes with `refs/heads/`. /// - **Remote**: Prefixes with `refs/remotes/`. @@ -46,7 +54,7 @@ impl RefEntry { /// # Arguments /// /// * `branch` - The branch object containing the name and target hash. - pub fn new_branch_entry(branch: &Branch) -> Self { + pub fn new_branch_entry(branch: &Branch) -> EngineResult { match branch.branch_type { BranchType::Local => { Self::new_local_branch_entry(&branch.name, branch.head_hash.clone()) @@ -63,19 +71,43 @@ impl RefEntry { } /// Helper to create a local branch entry (refs/heads/). - fn new_local_branch_entry(branch_name: &str, commit_hash: String) -> Self { - Self { + fn new_local_branch_entry(branch_name: &str, commit_hash: String) -> EngineResult { + let branch_name = Self::normalize_ref_name(branch_name)?; + + Ok(Self { name: format!("refs/heads/{branch_name}"), commit_hash, - } + }) } /// Helper to create a remote branch entry (refs/remotes/). - fn new_remote_branch_entry(branch_name: &str, commit_hash: String) -> Self { - Self { + fn new_remote_branch_entry(branch_name: &str, commit_hash: String) -> EngineResult { + let branch_name = Self::normalize_ref_name(branch_name)?; + + Ok(Self { name: format!("refs/remotes/{branch_name}"), commit_hash, + }) + } + + /// Validates and normalizes the reference name to ensure cross-platform consistency. + /// + /// # Rules: + /// - Replaces backslashes (`\`) with forward slashes (`/`). + /// - Disallows names starting or ending with `/`. + /// - Disallows empty path segments (`//`). + fn normalize_ref_name(branch: &str) -> EngineResult { + let normalized = branch.replace('\\', "/"); + + if normalized.starts_with('/') || normalized.ends_with('/') { + return Err(RefEntryError::InvalidRefFormat(branch.to_string()).into()); } + + if normalized.contains("//") { + return Err(RefEntryError::InvalidRefFormat(branch.to_string()).into()); + } + + Ok(normalized) } } @@ -87,3 +119,54 @@ impl Display for RefEntry { write!(f, "{} ({})", self.name.cyan(), short_hash.yellow()) } } + +#[cfg(test)] +mod normalize_ref_name_tests { + use super::*; + use crate::errors::EngineError; + + #[test] + fn accepts_simple_name() { + let name = RefEntry::normalize_ref_name("main").unwrap(); + assert_eq!(name, "main"); + } + + #[test] + fn accepts_nested_name() { + let name = RefEntry::normalize_ref_name("feature/login").unwrap(); + assert_eq!(name, "feature/login"); + } + + #[test] + fn converts_backslashes_to_slashes() { + let name = RefEntry::normalize_ref_name("feature\\login").unwrap(); + assert_eq!(name, "feature/login"); + } + + #[test] + fn rejects_leading_slash() { + let err = RefEntry::normalize_ref_name("/feature/login").unwrap_err(); + assert!(matches!( + err, + EngineError::RefEntry(RefEntryError::InvalidRefFormat(_)) + )); + } + + #[test] + fn rejects_trailing_slash() { + let err = RefEntry::normalize_ref_name("feature/login/").unwrap_err(); + assert!(matches!( + err, + EngineError::RefEntry(RefEntryError::InvalidRefFormat(_)) + )); + } + + #[test] + fn rejects_double_slash() { + let err = RefEntry::normalize_ref_name("feature//login").unwrap_err(); + assert!(matches!( + err, + EngineError::RefEntry(RefEntryError::InvalidRefFormat(_)) + )); + } +} diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index 198e6ba..555cac5 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -9,16 +9,17 @@ use tempfile::TempDir; use crate::{ EngineResult, InitError, errors::NetworkError, handlers::clone::Request as CloneRequest, - network::RemotesManager, ref_manager::RefManager, + network::RemotesManager, }; use crate::{ config::config_loader::ConfigLoader, - ref_manager::{Head, HeadMode}, + ref_manager::{Head, HeadMode, RefEntry, RefManager}, + repositories::RepositoryLayout, + restore_manager::RestoreManager, }; use crate::{ object_storage::ObjectStorage, objects::MevaObject, serialize_deserialize::MevaEncode, }; -use crate::{ref_manager::RefEntry, repositories::RepositoryLayout}; pub trait Repository: Send + Sync { /// Initializes a new repository with the given initial branch. @@ -65,6 +66,9 @@ pub struct MevaRepository { /// Manager for configuring remotes. remotes_manager: Arc, + + /// Manager for restoring the repository. + restore_manager: Arc, } impl MevaRepository { @@ -75,6 +79,7 @@ impl MevaRepository { object_storage: Arc, ref_manager: Arc, remotes_manager: Arc, + restore_manager: Arc, ) -> Self { Self { layout, @@ -82,6 +87,7 @@ impl MevaRepository { object_storage, ref_manager, remotes_manager, + restore_manager, } } @@ -94,6 +100,7 @@ impl MevaRepository { } .into()); } + Ok(()) } @@ -153,7 +160,7 @@ impl MevaRepository { if create_head { let full_ref_name = format!("{heads_refs_prefix}{branch_name}"); - let entry = RefEntry::new(&full_ref_name, commit_hash.unwrap_or("")); + let entry = RefEntry::new(&full_ref_name, commit_hash.unwrap_or(""))?; let ref_path = root .join(self.layout.heads_refs_dir_rel()) @@ -175,7 +182,7 @@ impl MevaRepository { if let Some(remote) = remote_name { let remotes_refs_prefix = self.ref_manager.remotes_refs_prefix(); let full_remote_ref_name = format!("{remotes_refs_prefix}{remote}/{branch_name}"); - let entry = RefEntry::new(&full_remote_ref_name, commit_hash.unwrap_or("")); + let entry = RefEntry::new(&full_remote_ref_name, commit_hash.unwrap_or(""))?; let ref_path = root .join(self.layout.remotes_refs_dir_rel()) @@ -308,7 +315,9 @@ impl Repository for MevaRepository { /// 2. Populating the object database (blobs/commits) from the packfile. /// 3. Determining the correct HEAD branch. /// 4. Creating references (local and remote). - /// 5. Setting up HEAD. + /// 5. Setting up the `HEAD` reference to the initial branch. + /// 6. Restoring the working directory and the index + /// to match the state of the initial branch. fn clone( &self, objects: &[(MevaObject, Vec)], @@ -348,7 +357,10 @@ impl Repository for MevaRepository { initial_branch_name.trim_start_matches(&heads_refs_prefix), )?; - // TODO: checkout (restore working dir) + // Restore working dir + if let Some(hash) = self.ref_manager.resolve_head()? { + self.restore_manager.restore(&hash, true, true, &[])?; + } Ok(ref_entries) } @@ -367,11 +379,15 @@ mod tests { use crate::repositories::meva_repository_layout::MevaRepositoryLayout; use super::*; + use crate::index::{MevaIndex, MevaWorkingDir}; + use crate::restore_manager::MevaRestoreManager; + use crate::traversal::MevaCommitTreeWalker; use pretty_assertions::assert_eq; use rstest::rstest; use std::fs; use std::io::Read; use std::path::PathBuf; + use std::sync::RwLock; use tempfile::TempDir; fn read_file(path: &PathBuf) -> String { @@ -379,29 +395,46 @@ mod tests { let mut file = fs::File::open(path).expect("File should exist"); file.read_to_string(&mut content) .expect("Failed to read file"); + content } - fn get_repo(path: &Path) -> MevaRepository { + fn get_repo(path: &Path) -> EngineResult { let layout = Arc::new(MevaRepositoryLayout::new(path.to_path_buf()).expect("should not fail")); let config_loader = Arc::new(MevaConfigLoader::default()); let object_storage = Arc::new(MevaObjectStorage::new(layout.clone())); let ref_manager = Arc::new(MevaRefManager::new(layout.clone())); let remotes_manager = Arc::new(MevaRemotesManager); - MevaRepository::new( + let working_dir = Arc::new(MevaWorkingDir::new(layout.clone())); + let index = Arc::new(RwLock::new(MevaIndex::new( + working_dir.clone(), + object_storage.clone(), + )?)); + let commit_tree_walker = Arc::new(MevaCommitTreeWalker::new(object_storage.clone())); + let restore_manager = Arc::new(MevaRestoreManager::new( + index, + working_dir, + object_storage.clone(), + commit_tree_walker, + )); + + let repo = MevaRepository::new( layout, config_loader, object_storage, ref_manager, remotes_manager, - ) + restore_manager, + ); + + Ok(repo) } #[rstest] - fn init_creates_expected_structure() { + fn init_creates_expected_structure() -> EngineResult<()> { let tmp = TempDir::new().expect("failed to create TempDir"); - let repo = get_repo(tmp.path()); + let repo = get_repo(tmp.path())?; let result = repo.init(None); assert!(result.is_ok()); @@ -417,7 +450,7 @@ mod tests { assert!(head_path.exists()); let head_contents = read_file(&head_path); - assert_eq!(head_contents, Head::default().to_json().unwrap()); + assert_eq!(head_contents, Head::default().to_json()?); let branch_ref = repo.layout.heads_refs_dir().join("master"); assert!(branch_ref.exists()); @@ -430,12 +463,14 @@ mod tests { config_content, repo.config_loader.get_default_local_config() ); + + Ok(()) } #[rstest] - fn init_fails_if_repo_already_exists() { + fn init_fails_if_repo_already_exists() -> EngineResult<()> { let tmp = TempDir::new().expect("failed to create TempDir"); - let repo = get_repo(tmp.path()); + let repo = get_repo(tmp.path())?; // First init succeeds assert!(repo.init(Some("dev")).is_ok()); @@ -451,5 +486,7 @@ mod tests { "Unexpected error: {message}" ); } + + Ok(()) } } diff --git a/engine/src/restore_manager.rs b/engine/src/restore_manager.rs new file mode 100644 index 0000000..4e79276 --- /dev/null +++ b/engine/src/restore_manager.rs @@ -0,0 +1,38 @@ +pub mod meva_restore_manager; + +use crate::errors::EngineResult; +use std::path::PathBuf; + +pub use meva_restore_manager::MevaRestoreManager; + +/// Defines a common interface for restoring files and repository state. +/// +/// Implementations of this trait are responsible for reverting the state of +/// the working directory or the staging area (index) to a specific point +/// in history, identified by a content hash. +/// +/// # Errors +/// +/// All methods return an [`EngineResult`] that may contain I/O errors, +/// hash resolution failures, or issues with accessing the repository's index. +pub trait RestoreManager: Send + Sync { + /// Restores the state of files from a source object to the worktree and/or staging area. + /// + /// This method allows for granular control over which files are restored and + /// where they should be applied. + /// + /// # Arguments + /// + /// * `source_hash` - The unique hash (e.g., commit) representing the source state. + /// * `staged` - If set to `true`, the changes will be applied to the staging area (index). + /// * `worktree` - If set to `true`, the changes will be applied to the actual files on disk. + /// * `path_filter` - A slice of [`PathBuf`] defining which paths should be restored. + /// If empty, the operation will apply to all files. + fn restore( + &self, + source_hash: &str, + staged: bool, + worktree: bool, + path_filter: &[PathBuf], + ) -> EngineResult<()>; +} diff --git a/engine/src/restore_manager/meva_restore_manager.rs b/engine/src/restore_manager/meva_restore_manager.rs new file mode 100644 index 0000000..8203c06 --- /dev/null +++ b/engine/src/restore_manager/meva_restore_manager.rs @@ -0,0 +1,267 @@ +use crate::{ + CommitTreeWalker, Index, WorkingDir, + errors::EngineResult, + index::{FileMode, index_entry::IndexEntry, stage::Stage}, + object_storage::ObjectStorage, + objects::{MevaBlob, MevaObject, ObjectEntry}, + restore_manager::RestoreManager, +}; +use chrono::Utc; +use path_absolutize::Absolutize; +use shared::{PathToString, StripBase}; +use std::{ + collections::{HashMap, HashSet}, + fs, + io::Write, + path::{Path, PathBuf}, + sync::{Arc, RwLock}, +}; + +/// Core implementation of the file restoration logic for the Meva engine. +/// +/// `MevaRestoreManager` provides the low-level mechanics required to synchronize +/// the repository's state with a specific commit snapshot. It acts as a bridge +/// between the Object Database and the user's environment (Index and Working Directory). +pub struct MevaRestoreManager { + /// Thread-safe access to the staging area. + index: Arc>, + + /// Interface for working directory layout and file collection. + working_dir: Arc, + + /// Access to the underlying object database (blobs/trees). + object_storage: Arc, + + /// Service used to flatten a commit's tree into a list of file entries. + commit_tree_walker: Arc, +} + +impl MevaRestoreManager { + /// Initializes a new instance of the restore manager by wiring up its core dependencies. + pub fn new( + index: Arc>, + working_dir: Arc, + object_storage: Arc, + commit_tree_walker: Arc, + ) -> Self { + Self { + index, + working_dir, + object_storage, + commit_tree_walker, + } + } + + /// Restores files in the index (staging area) based on the source snapshot. + /// + /// Adds missing files, updates modified files, and removes files not present in the source. + fn restore_index( + &self, + source_files: &[ObjectEntry], + paths: Option<&[PathBuf]>, + ) -> EngineResult<()> { + let source_files_map = Self::map_source_files(source_files); + + let index_read_guard = self.index.read().unwrap(); + + let index_entries_map = index_read_guard.get_entries_map(paths); + let index_entries_to_add = + Self::build_index_entries_to_add(&source_files_map, &index_entries_map)?; + let index_entries_to_remove = + Self::build_index_entries_to_remove(&index_entries_map, &source_files_map); + + drop(index_read_guard); + + let mut index_write_guard = self.index.write().unwrap(); + index_write_guard.insert_entries(index_entries_to_add); + index_write_guard.remove_entries(index_entries_to_remove); + + index_write_guard.save()?; + Ok(()) + } + + /// Restores files in the working tree (filesystem) based on the source snapshot. + /// + /// Compares file contents to avoid unnecessary writes, writes updated files, + /// and removes files not present in the snapshot. + fn restore_working_tree( + &self, + source_files: &[ObjectEntry], + paths: Option<&[PathBuf]>, + ) -> EngineResult<()> { + let source_files_map: HashMap = Self::map_source_files(source_files); + let workdir_paths = + Self::resolve_workdir_paths(paths, &self.working_dir.layout().working_dir()); + let workdir_files = self.build_workdir_files(&workdir_paths); + + for (file_path, object_entry) in &source_files_map { + let source_blob = self.object_storage.get_object(&object_entry.hash)?; + + if workdir_files.contains(file_path) { + let workdir_blob = MevaObject::try_from(MevaBlob::from_file(file_path)?)?; + + if source_blob.sha1() == workdir_blob.sha1() { + continue; + } + + let source_blob = MevaBlob::try_from(source_blob)?; + Self::write_file_atomically(file_path, &source_blob.data)?; + } else { + let source_blob = MevaBlob::try_from(source_blob)?; + Self::write_file_direct(file_path, &source_blob.data)?; + } + } + + Self::remove_extra_files(&workdir_files, &source_files_map)?; + + Ok(()) + } + + // Maps a list of source objects to a (path: object) map. + fn map_source_files(source_files: &[ObjectEntry]) -> HashMap { + source_files.iter().map(|o| (o.path.clone(), o)).collect() + } + + // Determines which entries need to be added to the index. + fn build_index_entries_to_add( + source_files_map: &HashMap, + index_entries_map: &HashMap, + ) -> EngineResult> { + let now = Utc::now(); + let mut entries = Vec::with_capacity(source_files_map.len()); + + for (file_path, object_entry) in source_files_map { + if let Some(index_entry) = index_entries_map.get(file_path) + && index_entry.sha1 == object_entry.hash + { + continue; + } + + let entry = IndexEntry { + ctime: now, + mtime: now, + mode: FileMode::try_from(&object_entry.entry_type)?, + file_size: object_entry.size.unwrap_or(0), + sha1: object_entry.hash.clone(), + stage: Stage::Normal, + path: file_path.to_utf8_string(), + }; + + entries.push(entry); + } + + Ok(entries) + } + + // Determines which entries need to be removed from the index. + fn build_index_entries_to_remove( + index_entries_map: &HashMap, + source_files_map: &HashMap, + ) -> Vec { + index_entries_map + .iter() + .filter(|(path, _)| !source_files_map.contains_key(*path)) + .map(|(path, _)| path.to_utf8_string()) + .collect() + } + + // Collects all files under the specified worktree paths. + fn build_workdir_files(&self, paths: &[PathBuf]) -> HashSet { + let mut workdir_files = HashSet::new(); + + for path in paths { + let collected_files: Vec = self + .working_dir + .collect_files(path, false) + .into_iter() + .filter_map(|(entry_path, _)| { + entry_path + .absolutize() + .map(|path| { + path.strip_base(&self.working_dir.layout().working_dir()) + .to_path_buf() + }) + .ok() + }) + .collect(); + workdir_files.extend(collected_files); + } + + workdir_files + } + + // Resolves paths to restore, falling back to a default path if none are provided. + fn resolve_workdir_paths(paths: Option<&[PathBuf]>, default: &Path) -> Vec { + match paths { + Some(p) => p.to_vec(), + None => vec![default.to_path_buf()], + } + } + + // Writes a file atomically. + fn write_file_atomically(path: &Path, data: &[u8]) -> EngineResult<()> { + let parent = path.parent(); + if let Some(parent) = parent { + fs::create_dir_all(parent)?; + } + + let mut tmp = tempfile::NamedTempFile::new_in(parent.unwrap_or(Path::new(".")))?; + tmp.write_all(data)?; + tmp.flush()?; + fs::rename(tmp.path(), path)?; + + Ok(()) + } + + // Writes a file directly. + fn write_file_direct(path: &Path, data: &[u8]) -> EngineResult<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut file = fs::File::create(path)?; + file.write_all(data)?; + + Ok(()) + } + + // Removes files from the working directory that are not present in the source. + fn remove_extra_files( + workdir_files: &HashSet, + source_files_map: &HashMap, + ) -> std::io::Result<()> { + for workdir_file in workdir_files { + if !source_files_map.contains_key(workdir_file) { + fs::remove_file(workdir_file)?; + } + } + + Ok(()) + } +} + +impl RestoreManager for MevaRestoreManager { + fn restore( + &self, + source_hash: &str, + staged: bool, + worktree: bool, + path_filter: &[PathBuf], + ) -> EngineResult<()> { + let path_filter = match path_filter.is_empty() { + true => None, + false => Some(path_filter), + }; + let source_files = + self.commit_tree_walker + .walk_commit(source_hash, false, None, path_filter)?; + + if staged { + self.restore_index(&source_files, path_filter)?; + } + if worktree { + self.restore_working_tree(&source_files, path_filter)?; + } + + Ok(()) + } +} diff --git a/engine/src/revision_parsing.rs b/engine/src/revision_parsing.rs index 83b4208..5306e3b 100644 --- a/engine/src/revision_parsing.rs +++ b/engine/src/revision_parsing.rs @@ -5,9 +5,9 @@ mod revision_modifier; mod revision_parse_error; mod revision_resolver; -use base_reference::BaseReference; use revision_modifier::RevisionModifier; +pub use base_reference::BaseReference; pub use meva_revision_resolver::MevaRevisionResolver; pub use revision::Revision; pub use revision_resolver::RevisionResolver; diff --git a/engine/src/revision_parsing/meva_revision_resolver.rs b/engine/src/revision_parsing/meva_revision_resolver.rs index 765ae83..5dd80b8 100644 --- a/engine/src/revision_parsing/meva_revision_resolver.rs +++ b/engine/src/revision_parsing/meva_revision_resolver.rs @@ -104,6 +104,7 @@ impl MevaRevisionResolver { entry.commit_hash } }; + Ok(res) } } diff --git a/engine/src/traversal/traits.rs b/engine/src/traversal/traits.rs index 68a691f..23e072c 100644 --- a/engine/src/traversal/traits.rs +++ b/engine/src/traversal/traits.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; /// Implementors of this trait provide a mechanism for traversing the tree /// objects referenced by a given commit, optionally including subtrees and blobs, /// while respecting optional filtering and traversal depth. d -pub trait CommitTreeWalker { +pub trait CommitTreeWalker: Send + Sync { /// Walks the tree structure of a given commit. /// /// # Arguments diff --git a/server/src/server_handler.rs b/server/src/server_handler.rs index 34d5ca6..7f9b4aa 100644 --- a/server/src/server_handler.rs +++ b/server/src/server_handler.rs @@ -427,7 +427,7 @@ impl ServerHandler { ref_manager.remove_ref(ref_name).map(|_| ()) } else { info!("Updating ref: {ref_name} -> {new_sha}"); - let entry = RefEntry::new(ref_name, new_sha); + let entry = RefEntry::new(ref_name, new_sha)?; ref_manager.update_ref(&entry) }; @@ -600,6 +600,47 @@ impl Handler for ServerHandler { } } + /// Handles incoming raw data on a channel. + /// + /// Routes the data to the specific protocol handler (`process_upload_pack_data` + /// or `process_receive_pack_data`) based on the active state of the channel. + async fn data( + &mut self, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) -> Result<(), Self::Error> { + let protocol_to_run; + + { + let state = match self.sessions.get_mut(&channel) { + Some(state) => state, + None => { + error!("Received data on an unknown channel {channel:?}"); + return Ok(()); + } + }; + + state.buffer.extend_from_slice(data); + + protocol_to_run = state.protocol.clone(); + } + + match protocol_to_run { + ActiveProtocol::UploadPack { .. } => { + self.process_upload_pack_data(session, channel)?; + } + ActiveProtocol::ReceivePack { .. } => { + self.process_receive_pack_data(session, channel)?; + } + ActiveProtocol::None => { + warn!("Received data on channel {channel:?}, which has no active protocol."); + } + } + + Ok(()) + } + /// Handles SSH `exec` requests (command execution). /// /// This is the entry point for Meva commands. It parses the command, @@ -708,47 +749,6 @@ impl Handler for ServerHandler { } } } - - /// Handles incoming raw data on a channel. - /// - /// Routes the data to the specific protocol handler (`process_upload_pack_data` - /// or `process_receive_pack_data`) based on the active state of the channel. - async fn data( - &mut self, - channel: ChannelId, - data: &[u8], - session: &mut Session, - ) -> Result<(), Self::Error> { - let protocol_to_run; - - { - let state = match self.sessions.get_mut(&channel) { - Some(state) => state, - None => { - error!("Received data on an unknown channel {channel:?}"); - return Ok(()); - } - }; - - state.buffer.extend_from_slice(data); - - protocol_to_run = state.protocol.clone(); - } - - match protocol_to_run { - ActiveProtocol::UploadPack { .. } => { - self.process_upload_pack_data(session, channel)?; - } - ActiveProtocol::ReceivePack { .. } => { - self.process_receive_pack_data(session, channel)?; - } - ActiveProtocol::None => { - warn!("Received data on channel {channel:?}, which has no active protocol."); - } - } - - Ok(()) - } } impl Clone for ServerHandler { From 10d8ad9a24342a1c7ff12b234fa320d184d3b1b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:34:18 +0100 Subject: [PATCH 36/42] Initialize a new empty index (#57) --- engine/src/engine_container.rs | 3 +-- engine/src/handlers/clone/handlers.rs | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 5397d38..c33365c 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -132,10 +132,9 @@ impl EngineContainer for MevaContainer { let remotes_manager = Arc::new(MevaRemotesManager); let working_dir = Arc::new(MevaWorkingDir::new(layout.clone())); let commit_tree_walker = Arc::new(MevaCommitTreeWalker::new(object_storage.clone())); - let index = Arc::new(RwLock::new(MevaIndex::from_disk( + let index = Arc::new(RwLock::new(MevaIndex::new( working_dir.clone(), object_storage.clone(), - Some(false), )?)); let restore_manager = Arc::new(MevaRestoreManager::new( index, diff --git a/engine/src/handlers/clone/handlers.rs b/engine/src/handlers/clone/handlers.rs index ec8860f..2a462d9 100644 --- a/engine/src/handlers/clone/handlers.rs +++ b/engine/src/handlers/clone/handlers.rs @@ -118,10 +118,9 @@ impl CloneHandler { let remotes_manager = Arc::new(MevaRemotesManager); let commit_tree_walker = Arc::new(MevaCommitTreeWalker::new(object_storage.clone())); let working_dir = Arc::new(MevaWorkingDir::new(repository_layout.clone())); - let index = Arc::new(RwLock::new(MevaIndex::from_disk( + let index = Arc::new(RwLock::new(MevaIndex::new( working_dir.clone(), object_storage.clone(), - Some(false), )?)); let restore_manager = Arc::new(MevaRestoreManager::new( index, From 36ef675d4e26d97d555437419c06d8fcc31a416d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:25:54 +0100 Subject: [PATCH 37/42] Improvements after testing the presentation scenario (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mikołaj Karbowski <67231265+mikolajkarbowski@users.noreply.github.com> --- cli/src/commands/config/subcommands/get.rs | 5 +- cli/src/commands/config/subcommands/list.rs | 5 +- cli/src/commands/ignore/subcommands/add.rs | 6 +- cli/src/commands/plugins/subcommands/list.rs | 5 +- engine/src/diff_builder/models/file_change.rs | 2 +- engine/src/handlers/checkout/operations.rs | 11 +- engine/src/handlers/commit/handlers.rs | 30 +++-- engine/src/handlers/commit/operations.rs | 2 +- engine/src/handlers/config/handlers.rs | 104 +-------------- .../ls_files/models/ls_files_entry.rs | 6 +- .../handlers/ls_tree/models/ls_tree_entry.rs | 19 +-- .../src/handlers/status/models/branch_info.rs | 22 +++- engine/src/network/common/pkt_line.rs | 17 ++- engine/src/revision_parsing/base_reference.rs | 3 +- engine/src/revision_parsing/revision.rs | 2 +- gui/src/meva_gui.rs | 6 +- gui/src/ui/components.rs | 71 ++++++++++ .../components/buttons/more_actions_button.rs | 57 ++------ .../buttons/open_repository_button.rs | 46 ++----- .../buttons/refresh_status_button.rs | 56 ++------ gui/src/ui/components/dialogs.rs | 4 +- ...ch_dialog.rs => checkout_branch_dialog.rs} | 124 +++++++++++++----- .../dialogs/clone_repository_dialog.rs | 35 +---- .../dialogs/commit_changes_dialog.rs | 2 +- .../dialogs/create_repository_dialog.rs | 2 +- gui/src/ui/components/file_changes.rs | 6 +- gui/src/ui/views/settings.rs | 3 +- plugins/src/enums/invocation_payload.rs | 12 -- plugins/src/models/payloads/config_payload.rs | 37 ------ server/src/server_handler.rs | 2 +- 30 files changed, 313 insertions(+), 389 deletions(-) rename gui/src/ui/components/dialogs/{switch_branch_dialog.rs => checkout_branch_dialog.rs} (71%) diff --git a/cli/src/commands/config/subcommands/get.rs b/cli/src/commands/config/subcommands/get.rs index 1ab693e..3eeaf04 100644 --- a/cli/src/commands/config/subcommands/get.rs +++ b/cli/src/commands/config/subcommands/get.rs @@ -70,7 +70,6 @@ impl MevaCommand for ConfigGetCommand { let default = matches.get_one::(Self::ARG_DEFAULT); let config_handler = container.config_handler().into_diagnostic()?; - let interceptor = container.plugins_interceptor().into_diagnostic()?; let request = GetRequest { location, @@ -78,9 +77,7 @@ impl MevaCommand for ConfigGetCommand { default: default.cloned(), }; - let response = config_handler - .handle_get(request, &interceptor) - .into_diagnostic()?; + let response = config_handler.handle_get(request).into_diagnostic()?; println!("{}", response.value); diff --git a/cli/src/commands/config/subcommands/list.rs b/cli/src/commands/config/subcommands/list.rs index 717ad0e..4246f60 100644 --- a/cli/src/commands/config/subcommands/list.rs +++ b/cli/src/commands/config/subcommands/list.rs @@ -48,13 +48,10 @@ impl MevaCommand for ConfigListCommand { let location = matches.get_config_location(); let config_handler = container.config_handler().into_diagnostic()?; - let interceptor = container.plugins_interceptor().into_diagnostic()?; let request = ListRequest { location }; - let response = config_handler - .handle_list(request, &interceptor) - .into_diagnostic()?; + let response = config_handler.handle_list(request).into_diagnostic()?; if response.key_values.is_empty() { println!( diff --git a/cli/src/commands/ignore/subcommands/add.rs b/cli/src/commands/ignore/subcommands/add.rs index e327891..214db6f 100644 --- a/cli/src/commands/ignore/subcommands/add.rs +++ b/cli/src/commands/ignore/subcommands/add.rs @@ -8,6 +8,8 @@ use engine::{ }; use globset::Glob; use miette::IntoDiagnostic; +use owo_colors::OwoColorize; +use shared::PathToString; use crate::{ commands::MevaCommand, @@ -58,8 +60,8 @@ impl MevaCommand for IgnoreAddCommand { println!( "Pattern '{}' appended to {}", - pattern, - result_path.to_string_lossy() + pattern.green(), + result_path.to_utf8_string().cyan() ); Ok(()) diff --git a/cli/src/commands/plugins/subcommands/list.rs b/cli/src/commands/plugins/subcommands/list.rs index b7b0fa3..05b98bd 100644 --- a/cli/src/commands/plugins/subcommands/list.rs +++ b/cli/src/commands/plugins/subcommands/list.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; +use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command, builder::PossibleValuesParser}; use engine::{ EngineContainer, engine_container::MevaContainer, @@ -7,6 +7,7 @@ use engine::{ }; use miette::IntoDiagnostic; use plugins::{CommandType, EventType, ScopeType}; +use strum::VariantNames; use crate::{commands::MevaCommand, extensions::WithScope}; @@ -46,6 +47,7 @@ impl MevaCommand for PluginsListCommand { .index(1) .required(true) .value_name("COMMAND") + .value_parser(PossibleValuesParser::new(CommandType::VARIANTS)) .help("Filter plugins by associated command"), ) .arg( @@ -53,6 +55,7 @@ impl MevaCommand for PluginsListCommand { .index(2) .required(true) .value_name("EVENT") + .value_parser(PossibleValuesParser::new(EventType::VARIANTS)) .help("Filter plugins by event"), ) .with_scope_arg("Scope of the plugin") diff --git a/engine/src/diff_builder/models/file_change.rs b/engine/src/diff_builder/models/file_change.rs index 720a272..f18a2ee 100644 --- a/engine/src/diff_builder/models/file_change.rs +++ b/engine/src/diff_builder/models/file_change.rs @@ -101,7 +101,7 @@ impl FileChange { } } -/// Formats the `FileChange` into a git-compatible diff header format. +/// Formats the `FileChange` into a diff header format. /// This does not include the content of the diff (the hunks), only the header lines. /// /// # Example for a modified file: diff --git a/engine/src/handlers/checkout/operations.rs b/engine/src/handlers/checkout/operations.rs index d273aa8..02bda6a 100644 --- a/engine/src/handlers/checkout/operations.rs +++ b/engine/src/handlers/checkout/operations.rs @@ -2,7 +2,7 @@ use crate::errors::EngineResult; use crate::revision_parsing::Revision; /// Encapsulates the parameters for a checkout operation. -#[derive(Debug)] +#[derive(Debug, Default)] pub struct Request { /// If true, bypasses the safety checks for 'dirty' index and working tree. /// Equivalent to `meva checkout -f`. @@ -21,6 +21,15 @@ pub struct Request { pub target: Revision, } +impl Request { + pub fn with_revision(revision: Revision) -> Self { + Self { + target: revision, + ..Default::default() + } + } +} + pub struct Response; /// Interface for performing checkout operations within the Meva engine. diff --git a/engine/src/handlers/commit/handlers.rs b/engine/src/handlers/commit/handlers.rs index 431e5dd..af7b189 100644 --- a/engine/src/handlers/commit/handlers.rs +++ b/engine/src/handlers/commit/handlers.rs @@ -81,13 +81,22 @@ impl CommitHandler { ) -> EngineResult { match self.should_execute(&request)? { false => Err(CommitError::NothingToCommit.into()), - true => interceptor.intercept_with_plugins( - CommandType::Commit, - None, - request, - self, - |req| self.commit(req), - ), + true => { + let mut request = request; + if request.author.is_none() { + request.author = Some(Person { + name: self.get_user_name()?, + email: self.get_user_email()?, + }); + } + interceptor.intercept_with_plugins( + CommandType::Commit, + None, + request, + self, + |req| self.commit(req), + ) + } } } @@ -123,12 +132,12 @@ impl CommitHandler { } /// Retrieves the configured username from the repository settings. - fn get_username(&self) -> EngineResult { + fn get_user_name(&self) -> EngineResult { self.config_loader .get("user.name", Some(&String::default())) } - /// Retrieves the configured Git-style user email from the repository settings. + /// Retrieves the configured user email from the repository settings. fn get_user_email(&self) -> EngineResult { self.config_loader .get("user.email", Some(&String::default())) @@ -188,8 +197,9 @@ impl CommitOperations for CommitHandler { fn commit(&self, request: Request) -> EngineResult { let author = match request.author.clone() { Some(a) => a, + // should not happen None => Person { - name: self.get_username()?, + name: self.get_user_name()?, email: self.get_user_email()?, }, }; diff --git a/engine/src/handlers/commit/operations.rs b/engine/src/handlers/commit/operations.rs index a8ec3ee..6378b8c 100644 --- a/engine/src/handlers/commit/operations.rs +++ b/engine/src/handlers/commit/operations.rs @@ -6,7 +6,7 @@ use crate::objects::{MevaCommit, Person}; /// /// This struct holds input data passed to the commit process, /// including the commit message, author information, and execution flags. -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub struct Request { /// Commit message provided by the user. pub message: String, diff --git a/engine/src/handlers/config/handlers.rs b/engine/src/handlers/config/handlers.rs index e162313..2c4734b 100644 --- a/engine/src/handlers/config/handlers.rs +++ b/engine/src/handlers/config/handlers.rs @@ -21,14 +21,8 @@ impl ConfigHandler { Self { config_loader } } - pub fn handle_get( - &self, - request: GetRequest, - interceptor: &PluginsInterceptor, - ) -> EngineResult { - interceptor.intercept_with_plugins(CommandType::ConfigGet, None, request, self, |req| { - self.get(req) - }) + pub fn handle_get(&self, request: GetRequest) -> EngineResult { + self.get(request) } pub fn handle_set( @@ -51,14 +45,8 @@ impl ConfigHandler { }) } - pub fn handle_list( - &self, - request: ListRequest, - interceptor: &PluginsInterceptor, - ) -> EngineResult { - interceptor.intercept_with_plugins(CommandType::ConfigList, None, request, self, |req| { - self.list(req) - }) + pub fn handle_list(&self, request: ListRequest) -> EngineResult { + self.list(request) } pub fn handle_create(&self, request: CreateRequest) -> EngineResult { @@ -120,50 +108,6 @@ impl ConfigOperations for ConfigHandler { } } -impl PluginsInvocationMapper for ConfigHandler { - fn request_to_payload(&self, req: &GetRequest) -> EngineResult { - Ok(InvocationPrePayload::ConfigGet(ConfigGetPrePayload { - config_file: req.location.get_default_path()?, - key: req.key.clone(), - default: req.default.clone(), - })) - } - - fn response_to_payload(&self, res: &GetResponse) -> EngineResult { - Ok(InvocationPostPayload::ConfigGet(ConfigGetPostPayload { - key: res.key.clone(), - value: res.value.clone(), - })) - } - - fn input_to_request(&self, input: &InvocationInput) -> EngineResult { - if let Some(InvocationPrePayload::ConfigGet(pre)) = &input.pre_payload { - Ok(GetRequest { - location: ConfigLocation::from_path(&pre.config_file)?, - key: pre.key.clone(), - default: pre.default.clone(), - }) - } else { - Err(EngineError::Plugins(PluginError::PrePayload { - payload: input.pre_payload.clone(), - })) - } - } - - fn input_to_response(&self, input: &InvocationInput) -> EngineResult { - if let Some(InvocationPostPayload::ConfigGet(post)) = &input.post_payload { - Ok(GetResponse { - key: post.key.clone(), - value: post.value.clone(), - }) - } else { - Err(EngineError::Plugins(PluginError::PostPayload { - payload: input.post_payload.clone(), - })) - } - } -} - impl PluginsInvocationMapper for ConfigHandler { fn request_to_payload(&self, req: &SetRequest) -> EngineResult { Ok(InvocationPrePayload::ConfigSet(ConfigSetPrePayload { @@ -195,7 +139,7 @@ impl PluginsInvocationMapper for ConfigHandler { } fn input_to_response(&self, input: &InvocationInput) -> EngineResult { - if let Some(InvocationPostPayload::ConfigGet(post)) = &input.post_payload { + if let Some(InvocationPostPayload::ConfigSet(post)) = &input.post_payload { Ok(SetResponse { key: post.key.clone(), value: post.value.clone(), @@ -247,41 +191,3 @@ impl PluginsInvocationMapper for ConfigHandler { } } } - -impl PluginsInvocationMapper for ConfigHandler { - fn request_to_payload(&self, req: &ListRequest) -> EngineResult { - Ok(InvocationPrePayload::ConfigList(ConfigListPrePayload { - config_file: req.location.get_default_path()?, - })) - } - - fn response_to_payload(&self, res: &ListResponse) -> EngineResult { - Ok(InvocationPostPayload::ConfigList(ConfigListPostPayload { - key_values: res.key_values.clone(), - })) - } - - fn input_to_request(&self, input: &InvocationInput) -> EngineResult { - if let Some(InvocationPrePayload::ConfigList(pre)) = &input.pre_payload { - Ok(ListRequest { - location: ConfigLocation::from_path(&pre.config_file)?, - }) - } else { - Err(EngineError::Plugins(PluginError::PrePayload { - payload: input.pre_payload.clone(), - })) - } - } - - fn input_to_response(&self, input: &InvocationInput) -> EngineResult { - if let Some(InvocationPostPayload::ConfigList(post)) = &input.post_payload { - Ok(ListResponse { - key_values: post.key_values.clone(), - }) - } else { - Err(EngineError::Plugins(PluginError::PostPayload { - payload: input.post_payload.clone(), - })) - } - } -} diff --git a/engine/src/handlers/ls_files/models/ls_files_entry.rs b/engine/src/handlers/ls_files/models/ls_files_entry.rs index 0da6550..5baec68 100644 --- a/engine/src/handlers/ls_files/models/ls_files_entry.rs +++ b/engine/src/handlers/ls_files/models/ls_files_entry.rs @@ -26,7 +26,7 @@ impl Display for LsFilesEntry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { LsFilesEntry::Path { path } => { - write!(f, "{}", path.green()) + write!(f, "{}", path.cyan()) } LsFilesEntry::Entry { mode, @@ -39,8 +39,8 @@ impl Display for LsFilesEntry { "{} {} {} {}", format!("{mode:o}").dimmed(), object.yellow(), - stage.dimmed(), - path.green() + stage.green(), + path.cyan() ) } } diff --git a/engine/src/handlers/ls_tree/models/ls_tree_entry.rs b/engine/src/handlers/ls_tree/models/ls_tree_entry.rs index a70e29b..abf4f87 100644 --- a/engine/src/handlers/ls_tree/models/ls_tree_entry.rs +++ b/engine/src/handlers/ls_tree/models/ls_tree_entry.rs @@ -1,5 +1,8 @@ use std::fmt::Display; +use owo_colors::OwoColorize; +use shared::PathToString; + use super::display_mode::DisplayMode; use crate::objects::ObjectEntry; @@ -21,15 +24,15 @@ impl Display for LsTreeEntry { match self.display_mode { DisplayMode::NameOnly => { - write!(f, "{}", entry.path.display()) + write!(f, "{}", entry.path.to_utf8_string().cyan()) } DisplayMode::Normal => { write!( f, "{} {} {}", - entry.entry_type, - entry.hash, - entry.path.display() + entry.entry_type.dimmed(), + entry.hash.yellow(), + entry.path.to_utf8_string().cyan() ) } DisplayMode::Long => { @@ -40,10 +43,10 @@ impl Display for LsTreeEntry { write!( f, "{} {} {:>8} {}", - entry.entry_type, - entry.hash, - size_str, - entry.path.display(), + entry.entry_type.dimmed(), + entry.hash.yellow(), + size_str.green(), + entry.path.to_utf8_string().cyan(), ) } } diff --git a/engine/src/handlers/status/models/branch_info.rs b/engine/src/handlers/status/models/branch_info.rs index 2c960bb..beb3569 100644 --- a/engine/src/handlers/status/models/branch_info.rs +++ b/engine/src/handlers/status/models/branch_info.rs @@ -1,5 +1,7 @@ use std::fmt::Display; +use owo_colors::OwoColorize; + /// Represents information about the current branch and HEAD state. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BranchInfo { @@ -29,24 +31,32 @@ impl Display for BranchInfo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.is_detached { match &self.head { - Some(oid) => writeln!(f, "HEAD (detached at {oid})")?, + Some(oid) => writeln!(f, "HEAD (detached at {})", oid.yellow())?, None => writeln!(f, "HEAD (detached)")?, } } else if let Some(head) = &self.head { match &self.upstream { Some(up) if self.ahead == 0 && self.behind == 0 => { - writeln!(f, "On branch {head} (up to date with {up})") + writeln!( + f, + "On branch {} (up to date with {})", + head.bold().cyan(), + up.cyan() + ) } Some(up) => writeln!( f, - "On branch {head} ({} ahead, {} behind of {})", - self.ahead, self.behind, up + "On branch {} ({} ahead, {} behind of {})", + head.bold().cyan(), + self.ahead.green(), + self.behind.red(), + up.cyan() ), - None => writeln!(f, "On branch {head}"), + None => writeln!(f, "On branch {}", head.bold().cyan()), }?; if !self.has_commits { // This is a new/orphan branch with no commit history - writeln!(f, "\nNo commits yet")?; + writeln!(f, "\n{}", "No commits yet".yellow())?; return writeln!(f); } } else { diff --git a/engine/src/network/common/pkt_line.rs b/engine/src/network/common/pkt_line.rs index f9efe76..7b9da5b 100644 --- a/engine/src/network/common/pkt_line.rs +++ b/engine/src/network/common/pkt_line.rs @@ -54,7 +54,22 @@ pub fn parse_next_pkt_line(buffer: &mut Vec) -> EngineResult { let len_str = from_utf8(&buffer[0..4]).map_err(NetworkError::from)?; - let len = usize::from_str_radix(len_str, 16).map_err(NetworkError::from)?; + let len = match usize::from_str_radix(len_str, 16) { + Ok(l) => l, + Err(_) => { + let raw_content = String::from_utf8_lossy(buffer); + let preview = if raw_content.len() > 100 { + format!("{}...", &raw_content[..100]) + } else { + raw_content.to_string() + }; + + return Err(NetworkError::RemoteError { + message: preview.trim().to_string(), + } + .into()); + } + }; if len == 0 { buffer.drain(0..4); diff --git a/engine/src/revision_parsing/base_reference.rs b/engine/src/revision_parsing/base_reference.rs index 854c930..8eb7992 100644 --- a/engine/src/revision_parsing/base_reference.rs +++ b/engine/src/revision_parsing/base_reference.rs @@ -9,9 +9,10 @@ /// - `abc1234` /// /// These values serve as the *root element* for more complex revision expressions. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub enum BaseReference { /// The symbolic reference `HEAD`, representing the current checked-out commit. + #[default] Head, /// A raw commit hash (e.g. `a1b2c3d4...`). Hash(String), diff --git a/engine/src/revision_parsing/revision.rs b/engine/src/revision_parsing/revision.rs index 0a834ae..ce21672 100644 --- a/engine/src/revision_parsing/revision.rs +++ b/engine/src/revision_parsing/revision.rs @@ -9,7 +9,7 @@ use super::{ /// /// A [`Revision`] consists of a **base reference** (`HEAD`, branch name, or hash) /// and an optional sequence of **modifiers** (such as `^` for parent or `~` for ancestor). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Revision { /// The base reference for the revision (e.g. `HEAD`, a branch name, or a commit hash). pub base: BaseReference, diff --git a/gui/src/meva_gui.rs b/gui/src/meva_gui.rs index 5fd92ca..02a499d 100644 --- a/gui/src/meva_gui.rs +++ b/gui/src/meva_gui.rs @@ -23,7 +23,7 @@ pub struct MevaGui { clone_repo_form: CloneRepositoryForm, commit_form: CommitChangesForm, file_changes_state: FileChangesState, - switch_branch_form: SwitchBranchForm, + checkout_branch_form: CheckoutBranchForm, container: Arc, async_worker: AsyncWorker, @@ -168,7 +168,7 @@ impl MevaGui { if let Some(status) = &self.repository_status && let Some(branch_info) = &status.branch { - BranchStatusComponent::new(branch_info, &mut self.switch_branch_form.is_open) + BranchStatusComponent::new(branch_info, &mut self.checkout_branch_form.is_open) .show(ui); } @@ -325,7 +325,7 @@ impl MevaGui { .show(); SwitchBranchDialog::new( - &mut self.switch_branch_form, + &mut self.checkout_branch_form, &self.repository_branches, &mut self.async_worker, &self.container, diff --git a/gui/src/ui/components.rs b/gui/src/ui/components.rs index e831a9b..d80c147 100644 --- a/gui/src/ui/components.rs +++ b/gui/src/ui/components.rs @@ -5,10 +5,81 @@ mod file_changes; mod navigation; mod remote_actions; +use std::{sync::Arc, thread}; + pub use buttons::*; pub use dialogs::*; pub use branch_status::BranchStatusComponent; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::branch::BranchOperations, + handlers::{ + branch::{ListRequest, Request as BranchRequest}, + status::{Request as StatusRequest, Response as StatusResponse}, + }, +}; + pub use file_changes::{FileChangesComponent, FileChangesState}; pub use navigation::NavigationComponent; pub use remote_actions::RemoteActionsComponent; + +use crate::events::WorkerResult; + +/// Performs the actual logic of refreshing the repository state. +/// +/// This function runs in a background thread. It: +/// 1. Fetches the current file status (staged/unstaged changes). +/// 2. Reloads the list of branches. +/// +/// # Returns +/// +/// * `Ok(WorkerResult::RepositoryOpened)` containing the fresh status and branch list. +/// * `Err(String)` containing the error message on failure. +pub fn execute_full_refresh( + container: Arc, + report: impl Fn(&str), +) -> Result { + let status = refresh_status(container.clone(), &report)?; + + report("Loading branches..."); + thread::sleep(std::time::Duration::from_millis(500)); + + let branch_handler = container.branch_handler().map_err(|e| e.to_string())?; + let branch = branch_handler + .branch(BranchRequest::List(ListRequest::local_only(false))) + .map_err(|e| e.to_string())?; + + Ok(WorkerResult::RepositoryOpened { branch, status }) +} + +/// Helper function to fetch only the file status of the repository. +/// +/// This is used as part of the full refresh cycle or independently when +/// only the working directory state needs updating (e.g., after staging a file). +/// +/// # Arguments +/// +/// * `container` - A thread-safe reference to the application backend container. +/// * `report` - A closure/callback used to send progress updates to the UI. +/// +/// # Returns +/// +/// * `Ok(StatusResponse)` containing the lists of staged, unstaged, untracked, and unmerged files. +/// * `Err(String)` if the status handler fails. +pub fn refresh_status( + container: Arc, + report: impl Fn(&str), +) -> Result { + report("Refreshing status..."); + // Artificial delay to give visual feedback that work is happening + thread::sleep(std::time::Duration::from_millis(500)); + + let status_handler = container.status_handler().map_err(|e| e.to_string())?; + let status = status_handler + .handle_status(StatusRequest::with_branch()) + .map_err(|e| e.to_string())?; + + Ok(status) +} diff --git a/gui/src/ui/components/buttons/more_actions_button.rs b/gui/src/ui/components/buttons/more_actions_button.rs index a66b2c4..cc3864a 100644 --- a/gui/src/ui/components/buttons/more_actions_button.rs +++ b/gui/src/ui/components/buttons/more_actions_button.rs @@ -3,17 +3,12 @@ use std::{sync::Arc, thread}; use egui::{RichText, Ui}; use egui_phosphor::regular as icons; -use engine::{ - EngineContainer, - engine_container::MevaContainer, - handlers::{ - // merge::Request as MergeRequest, - branch::{BranchOperations, ListRequest, Request as BranchRequest}, - status::Request as StatusRequest, - }, -}; +use engine::engine_container::MevaContainer; -use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; +use crate::{ + events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}, + ui::execute_full_refresh, +}; /// A UI component providing a dropdown menu for secondary repository actions. /// @@ -80,12 +75,12 @@ impl<'a> MoreActionsButton<'a> { let ctx_clone = ctx.clone(); self.worker.spawn(ctx.clone(), move |tx| { - let mut report = |msg: String| { - let _ = tx.send(WorkerEvent::Progress(msg)); + let report = |msg: &str| { + let _ = tx.send(WorkerEvent::Progress(msg.to_string())); ctx_clone.request_repaint(); }; - match Self::execute_refresh_logic(container, &mut report) { + match execute_full_refresh(container, report) { Ok(result) => { let _ = tx.send(WorkerEvent::Success(result)); } @@ -107,12 +102,12 @@ impl<'a> MoreActionsButton<'a> { let ctx_clone = ctx.clone(); self.worker.spawn(ctx.clone(), move |tx| { - let mut report = |msg: String| { - let _ = tx.send(WorkerEvent::Progress(msg)); + let report = |msg: &str| { + let _ = tx.send(WorkerEvent::Progress(msg.to_string())); ctx_clone.request_repaint(); }; - match Self::execute_abort_merge(container, &mut report) { + match Self::execute_abort_merge(container, report) { Ok(result) => { let _ = tx.send(WorkerEvent::Success(result)); } @@ -131,9 +126,9 @@ impl<'a> MoreActionsButton<'a> { /// Logic for Abort Merge. fn execute_abort_merge( container: Arc, - report: &mut dyn FnMut(String), + report: impl Fn(&str), ) -> Result { - report("Aborting merge...".to_string()); + report("Aborting merge..."); thread::sleep(std::time::Duration::from_millis(500)); // TODO: Implement actual merge abort logic @@ -141,30 +136,6 @@ impl<'a> MoreActionsButton<'a> { // let merge_handler = container.merge_handler().map_err(|e| e.to_string())?; // merge_handler.handle_merge(request).map_err(|e| e.to_string())?; - Self::execute_refresh_logic(container, report) - } - - /// Shared logic for reloading status and branches. - fn execute_refresh_logic( - container: Arc, - report: &mut dyn FnMut(String), - ) -> Result { - report("Refreshing status...".to_string()); - thread::sleep(std::time::Duration::from_millis(500)); - - let status_handler = container.status_handler().map_err(|e| e.to_string())?; - let status = status_handler - .handle_status(StatusRequest::with_branch()) - .map_err(|e| e.to_string())?; - - report("Loading branches...".into()); - thread::sleep(std::time::Duration::from_millis(500)); - - let branch_handler = container.branch_handler().map_err(|e| e.to_string())?; - let branch = branch_handler - .branch(BranchRequest::List(ListRequest::local_only(false))) - .map_err(|e| e.to_string())?; - - Ok(WorkerResult::RepositoryOpened { branch, status }) + execute_full_refresh(container, report) } } diff --git a/gui/src/ui/components/buttons/open_repository_button.rs b/gui/src/ui/components/buttons/open_repository_button.rs index aa1f10b..95bc306 100644 --- a/gui/src/ui/components/buttons/open_repository_button.rs +++ b/gui/src/ui/components/buttons/open_repository_button.rs @@ -2,17 +2,13 @@ use std::{path::PathBuf, sync::Arc, thread}; use egui_phosphor::regular as icons; -use engine::{ - EngineContainer, - engine_container::MevaContainer, - handlers::{ - branch::{BranchOperations, ListRequest, Request as BranchRequest}, - status::Request as StatusRequest, - }, -}; +use engine::engine_container::MevaContainer; use shared::change_directory; -use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; +use crate::{ + events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}, + ui::execute_full_refresh, +}; /// A UI component responsible for opening an existing repository. /// @@ -70,12 +66,12 @@ impl<'a> OpenRepositoryButton<'a> { let ctx_clone = ctx.clone(); self.worker.spawn(ctx.clone(), move |tx| { - let mut report = |msg: String| { - let _ = tx.send(WorkerEvent::Progress(msg)); + let report = |msg: &str| { + let _ = tx.send(WorkerEvent::Progress(msg.to_string())); ctx_clone.request_repaint(); }; - match Self::execute_open_logic(container, path, &mut report) { + match Self::execute_open_logic(container, path, report) { Ok(result) => { let _ = tx.send(WorkerEvent::Success(result)); } @@ -105,34 +101,14 @@ impl<'a> OpenRepositoryButton<'a> { fn execute_open_logic( container: Arc, path: PathBuf, - report: &mut dyn FnMut(String), + report: impl Fn(&str), ) -> Result { - report("Opening repository...".to_string()); + report("Opening repository..."); // Artificial delay for UX (to let the user see the loading state) thread::sleep(std::time::Duration::from_millis(500)); change_directory(&path).map_err(|e| e.to_string())?; - report("Reading repository status...".into()); - thread::sleep(std::time::Duration::from_millis(500)); - - let status_handler = container.status_handler().map_err(|e| e.to_string())?; - let status = status_handler - .handle_status(StatusRequest::with_branch()) - .map_err(|e| e.to_string())?; - - report("Loading branches...".into()); - thread::sleep(std::time::Duration::from_millis(500)); - - let branch_handler = container.branch_handler().map_err(|e| e.to_string())?; - let branch = branch_handler - .branch(BranchRequest::List(ListRequest { - local: true, - remotes: true, - verbose: false, - })) - .map_err(|e| e.to_string())?; - - Ok(WorkerResult::RepositoryOpened { branch, status }) + execute_full_refresh(container, report) } } diff --git a/gui/src/ui/components/buttons/refresh_status_button.rs b/gui/src/ui/components/buttons/refresh_status_button.rs index 118aca7..2514957 100644 --- a/gui/src/ui/components/buttons/refresh_status_button.rs +++ b/gui/src/ui/components/buttons/refresh_status_button.rs @@ -1,17 +1,13 @@ -use std::{sync::Arc, thread}; +use std::sync::Arc; use egui_phosphor::regular as icons; -use engine::{ - EngineContainer, - engine_container::MevaContainer, - handlers::{ - branch::{BranchOperations, ListRequest, Request as BranchRequest}, - status::Request as StatusRequest, - }, -}; +use engine::engine_container::MevaContainer; -use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; +use crate::{ + events::{AsyncWorker, EventError, WorkerEvent}, + ui::execute_full_refresh, +}; /// A UI component responsible for manually refreshing the repository status. /// @@ -59,12 +55,12 @@ impl<'a> RefreshStatusButton<'a> { let ctx_clone = ctx.clone(); self.worker.spawn(ctx.clone(), move |tx| { - let mut report = |msg: String| { - let _ = tx.send(WorkerEvent::Progress(msg)); + let report = |msg: &str| { + let _ = tx.send(WorkerEvent::Progress(msg.to_string())); ctx_clone.request_repaint(); }; - match Self::execute_refresh_logic(container, &mut report) { + match execute_full_refresh(container, report) { Ok(result) => { let _ = tx.send(WorkerEvent::Success(result)); } @@ -79,38 +75,4 @@ impl<'a> RefreshStatusButton<'a> { ctx_clone.request_repaint(); }); } - - /// Performs the actual logic of refreshing the repository state. - /// - /// This function runs in a background thread. It: - /// 1. Fetches the current file status (staged/unstaged changes). - /// 2. Reloads the list of branches. - /// - /// # Returns - /// - /// * `Ok(WorkerResult::RepositoryOpened)` containing the fresh status and branch list. - /// * `Err(String)` containing the error message on failure. - fn execute_refresh_logic( - container: Arc, - report: &mut dyn FnMut(String), - ) -> Result { - report("Refreshing status...".to_string()); - // Artificial delay to give visual feedback that work is happening - thread::sleep(std::time::Duration::from_millis(500)); - - let status_handler = container.status_handler().map_err(|e| e.to_string())?; - let status = status_handler - .handle_status(StatusRequest::with_branch()) - .map_err(|e| e.to_string())?; - - report("Loading branches...".into()); - thread::sleep(std::time::Duration::from_millis(500)); - - let branch_handler = container.branch_handler().map_err(|e| e.to_string())?; - let branch = branch_handler - .branch(BranchRequest::List(ListRequest::local_only(false))) - .map_err(|e| e.to_string())?; - - Ok(WorkerResult::RepositoryOpened { branch, status }) - } } diff --git a/gui/src/ui/components/dialogs.rs b/gui/src/ui/components/dialogs.rs index 37b25f7..3118c97 100644 --- a/gui/src/ui/components/dialogs.rs +++ b/gui/src/ui/components/dialogs.rs @@ -1,15 +1,15 @@ +mod checkout_branch_dialog; mod clone_repository_dialog; mod commit_changes_dialog; mod create_repository_dialog; mod error_dialog; mod loading_dialog; mod register_plugin_dialog; -mod switch_branch_dialog; +pub use checkout_branch_dialog::{CheckoutBranchForm, SwitchBranchDialog}; pub use clone_repository_dialog::{CloneRepositoryDialog, CloneRepositoryForm}; pub use commit_changes_dialog::{CommitChangesDialog, CommitChangesForm}; pub use create_repository_dialog::{CreateRepositoryDialog, CreateRepositoryForm}; pub use error_dialog::ErrorDialog; pub use loading_dialog::LoadingDialog; pub use register_plugin_dialog::{RegisterPluginDialog, RegisterPluginForm}; -pub use switch_branch_dialog::{SwitchBranchDialog, SwitchBranchForm}; diff --git a/gui/src/ui/components/dialogs/switch_branch_dialog.rs b/gui/src/ui/components/dialogs/checkout_branch_dialog.rs similarity index 71% rename from gui/src/ui/components/dialogs/switch_branch_dialog.rs rename to gui/src/ui/components/dialogs/checkout_branch_dialog.rs index 8b839f0..e175a8b 100644 --- a/gui/src/ui/components/dialogs/switch_branch_dialog.rs +++ b/gui/src/ui/components/dialogs/checkout_branch_dialog.rs @@ -2,11 +2,19 @@ use egui::{Align, Context, Layout, RichText, TextEdit, Ui}; use egui_phosphor::regular as icons; use std::sync::Arc; -use crate::events::{AsyncWorker, WorkerEvent, WorkerResult}; +use crate::{ + events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}, + ui::execute_full_refresh, +}; use engine::{ + EngineContainer, branch_manager::{BranchInfo, BranchType}, engine_container::MevaContainer, - handlers::branch::BranchCollection, + handlers::{ + branch::{BranchCollection, BranchOperations, CreateRequest, Request as BranchRequest}, + checkout::{CheckoutOperations, Request}, + }, + revision_parsing::Revision, }; /// Defines the currently selected category of branches to display. @@ -19,12 +27,12 @@ pub enum BranchTab { Remote, } -/// Holds the transient state of the "Switch/Create Branch" dialog. +/// Holds the transient state of the "Checkout/Create Branch" dialog. /// /// This struct persists in the main application state, allowing the dialog /// to retain user input (filter text, new branch name) even if closed and reopened. #[derive(Default)] -pub struct SwitchBranchForm { +pub struct CheckoutBranchForm { /// Controls the visibility of the dialog window. pub is_open: bool, @@ -41,7 +49,7 @@ pub struct SwitchBranchForm { pub current_tab: BranchTab, } -impl SwitchBranchForm { +impl CheckoutBranchForm { /// Resets all form fields to their default state. /// /// Useful when opening the dialog fresh or after a successful operation. @@ -57,16 +65,16 @@ impl SwitchBranchForm { /// and creation into a single unified interface. /// /// It delegates the actual list of branches to `BranchCollection` and uses -/// [`AsyncWorker`] to perform heavy Git operations (checkout/create) without freezing the UI. +/// [`AsyncWorker`] to perform heavy operations (checkout/create) without freezing the UI. pub struct SwitchBranchDialog<'a> { /// Mutable reference to the form state. - form: &'a mut SwitchBranchForm, + form: &'a mut CheckoutBranchForm, /// Read-only reference to the loaded branches (if any). branches: &'a Option, /// Worker for spawning background tasks. worker: &'a mut AsyncWorker, /// Dependency container (unused here but kept for future expansion). - _container: &'a Arc, + container: &'a Arc, /// Egui context for repainting. ctx: &'a Context, } @@ -74,7 +82,7 @@ pub struct SwitchBranchDialog<'a> { impl<'a> SwitchBranchDialog<'a> { /// Creates a new [`SwitchBranchDialog`]. pub fn new( - form: &'a mut SwitchBranchForm, + form: &'a mut CheckoutBranchForm, branches: &'a Option, worker: &'a mut AsyncWorker, container: &'a Arc, @@ -84,7 +92,7 @@ impl<'a> SwitchBranchDialog<'a> { form, branches, worker, - _container: container, + container, ctx, } } @@ -252,14 +260,14 @@ impl<'a> SwitchBranchDialog<'a> { ui.with_layout(Layout::right_to_left(Align::Min), |ui| { let enabled = !self.form.new_branch_name.trim().is_empty(); - self.show_create_and_switch_button(ui, enabled); + self.show_create_button(ui, enabled); }); } /// Renders the button that triggers creation of a new branch. - fn show_create_and_switch_button(&mut self, ui: &mut Ui, enabled: bool) { + fn show_create_button(&mut self, ui: &mut Ui, enabled: bool) { if ui - .add_enabled(enabled, egui::Button::new("Create & Switch")) + .add_enabled(enabled, egui::Button::new("Create")) .clicked() { self.handle_create_branch(); @@ -293,35 +301,91 @@ impl<'a> SwitchBranchDialog<'a> { /// Spawns a background task to checkout the selected branch. fn handle_checkout(&mut self, branch_name: String) { self.form.is_open = false; - let ctx = self.ctx.clone(); + self.form.reset(); + let ctx_clone = self.ctx.clone(); + let container = self.container.clone(); + + self.worker.spawn(ctx_clone.clone(), move |tx| { + let report = |msg: &str| { + let _ = tx.send(WorkerEvent::Progress(msg.to_string())); + ctx_clone.request_repaint(); + }; - self.worker.spawn(ctx.clone(), move |tx| { - let _ = tx.send(WorkerEvent::Progress(format!( - "Checking out '{branch_name}'...", - ))); + report(&format!("Checking out '{branch_name}'...")); std::thread::sleep(std::time::Duration::from_millis(500)); - // TODO: Integrate actual engine checkout logic here - let _ = tx.send(WorkerEvent::Success(WorkerResult::None)); - ctx.request_repaint(); + let run_task = || -> Result { + let request = Request::with_revision(Revision::reference(&branch_name, Vec::new())); + let handler = container.checkout_handler().map_err(|e| e.to_string())?; + handler.checkout(request).map_err(|e| e.to_string())?; + execute_full_refresh(container, report) + }; + + match run_task() { + Ok(success) => { + let _ = tx.send(WorkerEvent::Success(success)); + } + Err(err_msg) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Checkout Failed".to_string(), + "Could not checkout".to_string(), + err_msg, + ))); + } + } + ctx_clone.request_repaint(); }); } /// Spawns a background task to create and checkout a new branch. fn handle_create_branch(&mut self) { self.form.is_open = false; - let name = self.form.new_branch_name.clone(); + self.form.reset(); + let branch_name = self.form.new_branch_name.clone(); + let point = self.form.start_point.clone(); - let ctx = self.ctx.clone(); + let ctx_clone = self.ctx.clone(); + let container = self.container.clone(); - self.worker.spawn(ctx.clone(), move |tx| { - let _ = tx.send(WorkerEvent::Progress(format!( - "Creating branch '{name}'..." - ))); + self.worker.spawn(ctx_clone.clone(), move |tx| { + let report = |msg: &str| { + let _ = tx.send(WorkerEvent::Progress(msg.to_string())); + ctx_clone.request_repaint(); + }; + + report(&format!("Creating branch '{branch_name}'...")); std::thread::sleep(std::time::Duration::from_millis(500)); - // TODO: Integrate actual engine branch creation logic here - let _ = tx.send(WorkerEvent::Success(WorkerResult::None)); - ctx.request_repaint(); + + let start_point = match point.is_empty() { + true => Revision::default(), + false => Revision::hash(&point, Vec::new()), + }; + + let run_task = || -> Result { + let request = BranchRequest::Create(CreateRequest { + branch_name, + start_point, + }); + + let handler = container.branch_handler().map_err(|e| e.to_string())?; + handler.branch(request).map_err(|e| e.to_string())?; + + execute_full_refresh(container, report) + }; + + match run_task() { + Ok(success) => { + let _ = tx.send(WorkerEvent::Success(success)); + } + Err(err_msg) => { + let _ = tx.send(WorkerEvent::Error(EventError::new( + "Branch Failed".to_string(), + "Could not create branch".to_string(), + err_msg, + ))); + } + } + ctx_clone.request_repaint(); }); } } diff --git a/gui/src/ui/components/dialogs/clone_repository_dialog.rs b/gui/src/ui/components/dialogs/clone_repository_dialog.rs index 36cd9ab..769d7bf 100644 --- a/gui/src/ui/components/dialogs/clone_repository_dialog.rs +++ b/gui/src/ui/components/dialogs/clone_repository_dialog.rs @@ -3,18 +3,15 @@ use std::{path::PathBuf, sync::Arc, thread}; use egui::{Align, Context, Layout, Ui}; use egui_phosphor::regular::{self as icons}; use engine::{ - EngineContainer, - engine_container::MevaContainer, - handlers::{ - branch::{BranchOperations, ListRequest, Request as BranchRequest}, - clone::Request as CloneRequest, - status::Request as StatusRequest, - }, + EngineContainer, engine_container::MevaContainer, handlers::clone::Request as CloneRequest, }; use shared::{PathToString, change_directory}; use url::Url; -use crate::events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}; +use crate::{ + events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}, + ui::execute_full_refresh, +}; /// Holds the transient state of the "Clone Repository" dialog. /// @@ -281,26 +278,6 @@ impl<'a> CloneRepositoryDialog<'a> { change_directory(&target_dir).map_err(|e| e.to_string())?; - report("Reading repository status..."); - thread::sleep(std::time::Duration::from_millis(500)); - - let status_handler = container.status_handler().map_err(|e| e.to_string())?; - let status = status_handler - .handle_status(StatusRequest::with_branch()) - .map_err(|e| e.to_string())?; - - report("Loading branches..."); - thread::sleep(std::time::Duration::from_millis(500)); - - let branch_handler = container.branch_handler().map_err(|e| e.to_string())?; - let branch = branch_handler - .branch(BranchRequest::List(ListRequest { - local: true, - remotes: true, - verbose: false, - })) - .map_err(|e| e.to_string())?; - - Ok(WorkerResult::RepositoryOpened { branch, status }) + execute_full_refresh(container, report) } } diff --git a/gui/src/ui/components/dialogs/commit_changes_dialog.rs b/gui/src/ui/components/dialogs/commit_changes_dialog.rs index edbea02..a2885b5 100644 --- a/gui/src/ui/components/dialogs/commit_changes_dialog.rs +++ b/gui/src/ui/components/dialogs/commit_changes_dialog.rs @@ -38,7 +38,7 @@ impl CommitChangesForm { } } -/// A modal dialog for composing and submitting git commits. +/// A modal dialog for composing and submitting commits. /// /// This component renders a window containing a multi-line text area for the commit message /// and options for commit flags (e.g., amend). It delegates the actual execution diff --git a/gui/src/ui/components/dialogs/create_repository_dialog.rs b/gui/src/ui/components/dialogs/create_repository_dialog.rs index fdaf1c5..210965e 100644 --- a/gui/src/ui/components/dialogs/create_repository_dialog.rs +++ b/gui/src/ui/components/dialogs/create_repository_dialog.rs @@ -199,7 +199,7 @@ impl<'a> CreateRepositoryDialog<'a> { /// Executes the actual logic for creating the repository. /// /// This runs in the background thread. It: - /// 1. Calls the `init` handler to create the `.git` structure. + /// 1. Calls the `init` handler to create the `.meva` structure. /// 2. Changes the process working directory to the new repo. /// 3. Fetches the initial status. fn execute_creation_task( diff --git a/gui/src/ui/components/file_changes.rs b/gui/src/ui/components/file_changes.rs index 3fbbe3f..a515c52 100644 --- a/gui/src/ui/components/file_changes.rs +++ b/gui/src/ui/components/file_changes.rs @@ -52,7 +52,7 @@ impl Default for FileChangesState { /// This component renders the "Working Tree" view, dividing files into four categories: /// 1. **Staged Changes**: Ready to be committed. /// 2. **Changes**: Modified but not yet staged. -/// 3. **Untracked**: New files not yet added to git. +/// 3. **Untracked**: New files not yet added. /// 4. **Unmerged**: Files with merge conflicts. /// /// It handles user interactions such as staging, unstaging, and discarding changes @@ -365,9 +365,9 @@ impl<'a> FileChangesComponent<'a> { }); } - /// Triggers the background task to discard changes to a file (`git restore`). + /// Triggers the background task to discard changes to a file (`meva restore`). /// - /// **Warning**: This operation is destructive and cannot be undone via Git. + /// **Warning**: This operation is destructive and cannot be undone. fn handle_discard(&mut self, path: &Path) { self.spawn_file_action(path, "Discarding changes...", |c, p| { let restore_handler = c.restore_handler().map_err(|e| e.to_string())?; diff --git a/gui/src/ui/views/settings.rs b/gui/src/ui/views/settings.rs index 8549c91..336598c 100644 --- a/gui/src/ui/views/settings.rs +++ b/gui/src/ui/views/settings.rs @@ -128,12 +128,11 @@ impl SettingsView { let task = || -> Result { let handler = container.config_handler().map_err(|e| e.to_string())?; - let interceptor = container.plugins_interceptor().map_err(|e| e.to_string())?; let config_request = ConfigListRequest { location: location.clone(), }; let config = handler - .handle_list(config_request, &interceptor) + .handle_list(config_request) .map_err(|e| e.to_string())?; Ok(WorkerResult::ConfigLoaded { location, config }) diff --git a/plugins/src/enums/invocation_payload.rs b/plugins/src/enums/invocation_payload.rs index 83cd492..2ae4933 100644 --- a/plugins/src/enums/invocation_payload.rs +++ b/plugins/src/enums/invocation_payload.rs @@ -20,12 +20,6 @@ pub enum InvocationPrePayload { /// Context for the `config unset` command. ConfigUnset(ConfigUnsetPrePayload), - /// Context for the `config get` command. - ConfigGet(ConfigGetPrePayload), - - /// Context for the `config list` command. - ConfigList(ConfigListPrePayload), - /// Context for the `add` command. Add(AddPrePayload), @@ -44,18 +38,12 @@ pub enum InvocationPostPayload { /// Context for the `init` command. Init(InitPostPayload), - /// Context for the `config get` command. - ConfigGet(ConfigGetPostPayload), - /// Context for the `config set` command. ConfigSet(ConfigSetPostPayload), /// Context for the `config unset` command. ConfigUnset(ConfigUnsetPostPayload), - /// Context for the `config list` command. - ConfigList(ConfigListPostPayload), - /// Context for the `add` command. Add(AddPostPayload), diff --git a/plugins/src/models/payloads/config_payload.rs b/plugins/src/models/payloads/config_payload.rs index 711d237..04bf72f 100644 --- a/plugins/src/models/payloads/config_payload.rs +++ b/plugins/src/models/payloads/config_payload.rs @@ -1,29 +1,6 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; -/// Payload provided to plugins **before** the `config get` command execution. -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ConfigGetPrePayload { - /// Path to the configuration file being queried. - pub config_file: PathBuf, - - /// Configuration key to retrieve. - pub key: String, - - /// Default value to return if the key is not found. - pub default: Option, -} - -/// Payload provided to plugins **after** the `config get` command execution. -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ConfigGetPostPayload { - /// Configuration key that was retrieved. - pub key: String, - - /// Value associated with the key. - pub value: String, -} - /// Payload provided to plugins **before** the `config set` command execution. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ConfigSetPrePayload { @@ -63,17 +40,3 @@ pub struct ConfigUnsetPostPayload { /// Value that was previously associated with the key. pub value: String, } - -/// Payload provided to plugins **before** the `config list` command execution. -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ConfigListPrePayload { - /// Path to the configuration file being listed. - pub config_file: PathBuf, -} - -/// Payload provided to plugins **after** the `config list` command execution. -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ConfigListPostPayload { - /// List of all key-value pairs in the configuration file. - pub key_values: Vec<(String, String)>, -} diff --git a/server/src/server_handler.rs b/server/src/server_handler.rs index 7f9b4aa..acf3bc8 100644 --- a/server/src/server_handler.rs +++ b/server/src/server_handler.rs @@ -661,7 +661,7 @@ impl Handler for ServerHandler { error!("Executing command on channel {channel:?}: {msg}"); session.data( channel, - CryptoVec::from_slice(format!("Error: {msg}\n").as_bytes()), + CryptoVec::from_slice(format!("{msg}\n").as_bytes()), ); session.exit_status_request(channel, 1); session.eof(channel); From 770f4610e5fd35fc33dbec2b8f18ab6cf71f826d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:55:54 +0100 Subject: [PATCH 38/42] Fix/save index (#59) --- engine/src/index/meva_index.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/engine/src/index/meva_index.rs b/engine/src/index/meva_index.rs index f268742..e26467e 100644 --- a/engine/src/index/meva_index.rs +++ b/engine/src/index/meva_index.rs @@ -402,10 +402,7 @@ impl Index for MevaIndex { fn save(&mut self) -> EngineResult<()> { if !self.working_dir.layout().index_file().exists() { - return Err(IndexError::IndexFileNotFound { - path: self.working_dir.layout().index_file(), - } - .into()); + fs::File::create(self.working_dir.layout().index_file())?; } let working_dir = self.working_dir.layout().working_dir(); let meva_repository_dir = working_dir.absolutize()?; From 83cb3d0af2c8d4c957b1f524b9a954feee4764b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:46:31 +0100 Subject: [PATCH 39/42] Readme (#61) --- README.md | 761 +++++++++++++++--- docs/images/distributed-architecture.png | Bin 0 -> 68584 bytes docs/images/feather.png | Bin 0 -> 2331 bytes docs/images/gui/gui-branch.png | Bin 0 -> 125799 bytes docs/images/gui/gui-clone-repo.png | Bin 0 -> 91607 bytes docs/images/gui/gui-commit-modal.png | Bin 0 -> 101743 bytes docs/images/gui/gui-config-error.png | Bin 0 -> 65926 bytes docs/images/gui/gui-conflict-editor.png | Bin 0 -> 92593 bytes docs/images/gui/gui-conflict-mark.png | Bin 0 -> 116030 bytes docs/images/gui/gui-conflict-menu.png | Bin 0 -> 117620 bytes docs/images/gui/gui-conflict-unmerged.png | Bin 0 -> 113552 bytes docs/images/gui/gui-context-menu.png | Bin 0 -> 106630 bytes docs/images/gui/gui-create-repo.png | Bin 0 -> 84887 bytes docs/images/gui/gui-dashboard.png | Bin 0 -> 89690 bytes docs/images/gui/gui-diff.png | Bin 0 -> 183824 bytes docs/images/gui/gui-history.png | Bin 0 -> 104178 bytes docs/images/gui/gui-menu-file.png | Bin 0 -> 87866 bytes docs/images/gui/gui-open-error.png | Bin 0 -> 85313 bytes docs/images/gui/gui-plugin-edit.png | Bin 0 -> 118708 bytes docs/images/gui/gui-plugin-register.png | Bin 0 -> 119198 bytes docs/images/gui/gui-plugins-list-disabled.png | Bin 0 -> 91464 bytes docs/images/gui/gui-plugins-list-enabled.png | Bin 0 -> 103446 bytes docs/images/gui/gui-settings-view.png | Bin 0 -> 69846 bytes docs/images/gui/gui-staging.png | Bin 0 -> 98226 bytes docs/images/gui/gui-status-bar.png | Bin 0 -> 107598 bytes docs/images/gui/gui-sync-actions.png | Bin 0 -> 105599 bytes docs/images/receive-pack.png | Bin 0 -> 107808 bytes docs/images/upload-pack.png | Bin 0 -> 103100 bytes 28 files changed, 670 insertions(+), 91 deletions(-) create mode 100644 docs/images/distributed-architecture.png create mode 100644 docs/images/feather.png create mode 100644 docs/images/gui/gui-branch.png create mode 100644 docs/images/gui/gui-clone-repo.png create mode 100644 docs/images/gui/gui-commit-modal.png create mode 100644 docs/images/gui/gui-config-error.png create mode 100644 docs/images/gui/gui-conflict-editor.png create mode 100644 docs/images/gui/gui-conflict-mark.png create mode 100644 docs/images/gui/gui-conflict-menu.png create mode 100644 docs/images/gui/gui-conflict-unmerged.png create mode 100644 docs/images/gui/gui-context-menu.png create mode 100644 docs/images/gui/gui-create-repo.png create mode 100644 docs/images/gui/gui-dashboard.png create mode 100644 docs/images/gui/gui-diff.png create mode 100644 docs/images/gui/gui-history.png create mode 100644 docs/images/gui/gui-menu-file.png create mode 100644 docs/images/gui/gui-open-error.png create mode 100644 docs/images/gui/gui-plugin-edit.png create mode 100644 docs/images/gui/gui-plugin-register.png create mode 100644 docs/images/gui/gui-plugins-list-disabled.png create mode 100644 docs/images/gui/gui-plugins-list-enabled.png create mode 100644 docs/images/gui/gui-settings-view.png create mode 100644 docs/images/gui/gui-staging.png create mode 100644 docs/images/gui/gui-status-bar.png create mode 100644 docs/images/gui/gui-sync-actions.png create mode 100644 docs/images/receive-pack.png create mode 100644 docs/images/upload-pack.png diff --git a/README.md b/README.md index 94ba311..fbe6f5a 100644 --- a/README.md +++ b/README.md @@ -1,163 +1,742 @@ -# meva - -A distributed version control system written in Rust, developed as part of an engineering thesis. - -## Workspace structure - -The workspace contains five crates, organized as follows: +

+ +

+ +# 🪶 MEVA + +

+ Rust + Cargo + Clap + Egui + Linux + Windows + License +

+ +**MEVA** (ang. _Modular & Extensible Versioning Assistant_) to rozproszony system kontroli wersji wyposażony w interfejsy CLI oraz GUI, zaimplementowany w języku Rust. Projekt został zrealizowany w ramach pracy inżynierskiej na Wydziale Matematyki i Nauk Informacyjnych Politechniki Warszawskiej przez zespół w składzie: + +- **[Adam Grącikowski](https://github.com/adamgracikowski)** +- **[Mikołaj Karbowski](https://github.com/mikolajkarbowski)** + +Promotorem pracy był **[mgr inż. Tomasz Herman](https://github.com/tomasz-herman)**. + +## Spis treści + +- [Obsługiwane polecenia](#obslugiwane-polecenia) +- [Graficzny interfejs użytkownika](#graficzny-interfejs-uzytkownika) +- [Architektura](#architektura) + - [Struktura projektu](#struktura-projektu) + - [Model rozproszony i architektura repozytoriów](#model-rozproszony-i-architektura-repozytoriów) + - [Warstwa sieciowa i protokoły synchronizacji](#warstwa-sieciowa-i-protokoły-synchronizacji) +- [Instalacja](#instalacja) + - [Wymagania wstępne i środowisko budowania](#wymagania-wstępne-i-środowisko-budowania) + - [Instalacja klienta CLI](#instalacja-klienta-cli) + - [Instalacja klienta GUI](#instalacja-klienta-gui) + - [Instalacja i konfiguracja serwera SSH](#instalacja-i-konfiguracja-serwera-ssh) + - [Weryfikacja instalacji](#weryfikacja-instalacji) +- [Instrukcja użytkownika](#instrukcja-użytkownika) + - [Praca z interfejsem CLI](#praca-z-interfejsem-cli) + - [Praca z interfejsem GUI](#praca-z-interfejsem-gui) + - [Praca z serwerem SSH](#praca-z-serwerem-ssh) +- [Generowanie dokumentacji projektu](#generowanie-dokumentacji-projektu) + +## Obsługiwane polecenia + +| Polecenie | Opis | +| :--------- | :----------------------------------------------------------------------------------------- | +| `init` | Inicjalizuje nowe, puste repozytorium. | +| `add` | Dodaje zawartość plików do indeksu. | +| `commit` | Tworzy nowe zatwierdzenie w repozytorium. | +| `status` | Wyświetla stan plików w drzewie roboczym (zmodyfikowane, nieśledzone). | +| `log` | Pokazuje historię commitów. | +| `ls-files` | Wyświetla informacje o plikach w indeksie i drzewie roboczym. | +| `ls-tree` | Wyświetla zawartość obiektu drzewa. | +| `show` | Wyświetla szczegóły zatwierdzenia. | +| `diff` | Pokazuje zmiany między zatwierdzeniami, zatwierdzeniem a drzewem roboczym itp. | +| `restore` | Przywraca pliki w drzewie roboczym. | +| `config` | Pobiera i ustawia opcje konfiguracyjne (`create`, `get`, `set`, `unset`, `list`, `edit`). | +| `ignore` | Zarządza ignorowaniem plików (`add`, `remove`, `check`, `edit`). | +| `branch` | Listuje, tworzy lub usuwa gałęzie. | +| `checkout` | Przełącza gałęzie lub przywraca pliki drzewa roboczego. | +| `merge` | Scala historię z innej gałęzi do bieżącej. | +| `remote` | Zarządza zdalnymi repozytoriami (`add`, `remove`, `show`, `rename`, `get-url`, `set-url`). | +| `clone` | Klonuje repozytorium do nowego katalogu. | +| `fetch` | Pobiera obiekty i referencje ze zdalnego repozytorium. | +| `pull` | Pobiera (`fetch`) i scala (`merge`) zmiany ze zdalnego repozytorium lub z lokalnej gałęzi. | +| `push` | Aktualizuje zdalne referencje wraz z powiązanymi obiektami. | +| `plugins` | Zarządza systemem skryptów użytkownika (`register`, `unregister`, `list`, `info`, `edit`). | + +## Graficzny interfejs użytkownika + +
+ +Ekran startowy aplikacji (Dashboard). + +

+
+ Ekran startowy aplikacji (Dashboard). +
+

+
+ +
+ +Widok ustawień z formularzem edycji oraz listą globalnych zmiennych konfiguracyjnych. + +

+
+ Widok ustawień z formularzem edycji oraz listą globalnych zmiennych konfiguracyjnych. +
+

+
+ +
+ +Błąd ładowania konfiguracji lokalnej przy braku otwartego repozytorium. + +

+
+ Błąd ładowania konfiguracji lokalnej przy braku otwartego repozytorium. +
+

+
+ +
+ +Menu główne aplikacji z opcjami tworzenia i klonowania repozytorium. + +

+
+ Menu główne aplikacji z opcjami tworzenia i klonowania repozytorium. +
+

+
+ +
+ +Okno dialogowe inicjalizacji nowego repozytorium. + +

+
+ Okno dialogowe inicjalizacji nowego repozytorium. +
+

+
+ +
+ +Okno dialogowe klonowania zdalnego repozytorium. + +

+
+ Okno dialogowe klonowania zdalnego repozytorium. +
+

+
+ +
+ +Komunikat błędu wyświetlany przy próbie otwarcia katalogu z niezainicjowanym repozytorium. + +

+
+ Komunikat błędu wyświetlany przy próbie otwarcia katalogu z niezainicjowanym repozytorium. +
+

+
+ +
+ +Podział plików na sekcje Staged i Unstaged z widocznymi ikonami statusu. + +

+
+ Podział plików na sekcje Staged i Unstaged z widocznymi ikonami statusu. +
+

+
+ +
+ +Interaktywna lista historii commitów z rozwiniętymi szczegółami zmian. + +

+
+ Interaktywna lista historii commitów z rozwiniętymi szczegółami zmian. +
+

+
+ +
+ +Okno dialogowe zatwierdzania zmian. + +

+
+ Okno dialogowe zatwierdzania zmian. +
+

+
+ +
+ +Widok różnicowy z podświetlaniem składni. + +

+
+ Widok różnicowy z podświetlaniem składni. +
+

+
+ +
+ +Menu kontekstowe pliku z opcją podglądu różnic i odrzucenia zmian. + +

+
+ Menu kontekstowe pliku z opcją podglądu różnic i odrzucenia zmian. +
+

+
+ +
+ +Okno dialogowe do zarządzania gałęziami. + +

+
+ Okno dialogowe do zarządzania gałęziami. +
+

+
+ +
+ +Stan aplikacji po wykryciu konfliktu w trakcie operacji scalania. + +

+
+ Stan aplikacji po wykryciu konfliktu w trakcie operacji scalania. +
+

+
+ +
+ +Menu kontekstowe pliku z opcją rozpoczęcia rozwiązywania konfliktu. + +

+
+ Menu kontekstowe pliku z opcją rozpoczęcia rozwiązywania konfliktu. +
+

+
+ +
+ +Edycja pliku ze znacznikami konfliktów w zewnętrznym edytorze. + +

+
+ Edycja pliku ze znacznikami konfliktów w zewnętrznym edytorze. +
+

+
+ +
+ +Oznaczenie konfliktu jako rozwiązanego. + +

+
+ Oznaczenie konfliktu jako rozwiązanego. +
+

+
+ +
+ +Pasek stanu z informacją o śledzonej gałęzi (2 zmiany do pobrania, 4 do wysłania). + +

+
+ Pasek stanu z informacją o śledzonej gałęzi (2 zmiany do pobrania, 4 do wysłania). +
+

+
+ +
+ +Widok paska statusu z widocznymi przyciskami akcji sieciowych. + +

+
+ Widok paska statusu z widocznymi przyciskami akcji sieciowych. +
+

+
+ +
+ +Lista aktywnych pluginów z filtrem ustawionym na zdarzenia pre-execute. + +

+
+ Lista aktywnych pluginów z filtrem ustawionym na zdarzenia pre-execute. +
+

+
+ +
+ +Widok nieaktywnego pluginu z widocznym przyciskiem Enable. + +

+
+ Widok nieaktywnego pluginu z widocznym przyciskiem Enable. +
+

+
+ +
+ +Edycja źródła skryptu wywołana przyciskiem Edit. + +

+
+ Edycja źródła skryptu wywołana przyciskiem Edit. +
+

+
+ +
+ +Formularz rejestracji nowego pluginu. + +

+
+ Formularz rejestracji nowego pluginu. +
+

+
+ +## Architektura + +### Struktura projektu + +Projekt `meva` zorganizowany jest jako _Rust Workspace_ i został podzielony jest na 6 modułów (ang. _crates_). ``` meva/ -├── Cargo.toml -├── Cargo.lock -├── engine/ -│ ├── Cargo.toml -│ └── src/ -│ └── ... -├── cli/ -│ ├── Cargo.toml -│ └── src/ -│ └── ... ├── gui/ -│ ├── Cargo.toml -│ └── src/ -│ └── ... -├── plugins/ -│ ├── Cargo.toml -│ └── src/ -│ └── ... +│ └── ... +├── cli/ +│ └── ... ├── server/ -│ ├── Cargo.toml -│ └── src/ -│ └── ... +│ └── ... +├── engine/ +│ └── ... +├── plugins/ +│ └── ... ├── shared/ -│ ├── Cargo.toml -│ └── src/ -│ └── ... -├── tests/ │ └── ... -└── target/ - └── ... +├── Cargo.toml +├── LICENSE +└── README.md ``` -## Getting Started +| Moduł | Opis | +| :-------- | :--------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli` | Interfejs wiersza poleceń, który pozwala na skryptowe zarządzanie repozytorium. | +| `gui` | Interfejs graficzny dla użytkownika końcowego w formie aplikacji desktopowej, wspierający podzbiór funkcjonalności interfejsu wiersza poleceń. | +| `server` | Moduł sieciowy. Obsługuje protokoły synchronizacji z repozytorium zdalnym, a także uwierzytelnianie i autoryzację użytkowników. | +| `engine` | Główny moduł implementujący logikę poszczególnych operacji na repozytorium. | +| `plugins` | System rozszerzeń w postaci skryptów użytkownika, uruchamianych jako procesy potomne. | +| `shared` | Biblioteka pomocnicza, przechowująca logikę wspólną dla wszystkich modułów. | -> The installation guide assumes you have already installed [Rust](https://www.rust-lang.org/learn/get-started). +Projekt generuje trzy niezależne pliki wykonywalne: `meva`, `meva-gui` oraz `meva-server` odpowiednio dla modułów `cli`, `gui` oraz `server`. -### Building the project +### Model rozproszony i architektura repozytoriów -To build all crates in the workspace: +System kontroli wersji MEVA należy do klasy systemów rozproszonych (DVCS ang. _Distributed Version Control System_). W przeciwieństwie do systemów scentralizowanych (ang. _centralized_), takich jak SVN, gdzie pełna historia zmian znajduje się wyłącznie na głównym serwerze, w modelu przyjętym przez system MEVA każdy użytkownik posiada własną, kompletną kopię zdalnego repozytorium. -```bash -cargo build +Diagram ilustruje współpracę dwóch niezależnych użytkowników za pośrednictwem węzła centralnego: + +

+ +

+ +Węzeł oznaczony jako `Repozytorium zdalne` pełni funkcję źródła danych (ang. _single source of truth_). Przechowuje ono wspólną historię projektu, do której dostęp mają wyłącznie uprawnieni użytkownicy. `Użytkownik 2`, za pośrednictwem aplikacji desktopowej (`Klient GUI`), rozpoczyna pracę poprzez wykonania operacji `clone`. Jej wynikiem jest lokalna kopii zdalnego repozytorium, oznaczona na schemacie jako `Repozytorium lokalne 2`. + +Zarówno `Użytkownik 1`, korzystający z interfejsu konsolowego (`Klient CLI`), jak i `Użytkownik 2` przesyłają swoje zatwierdzone zmiany za pomocą polecenia `push`. Operacja ta aktualizuje stan zdalnego repozytorium. + +W celu zsynchronizowania stanu swojego lokalnego repozytorium z postępami innych członków zespołu, użytkownicy wykorzystują polecenia `fetch` (pobranie brakujących obiektów bez integracji zmian) oraz `pull` (pobranie zmian połączone ze ich scalaniem). + +### Warstwa sieciowa i protokoły synchronizacji + +System MEVA nie implementuje własnego, niskopoziomowego protokołu transportowego opartego bezpośrednio na gniazdach TCP. Zamiast tego, przyjęto architekturę opartą na tunelowaniu ruchu przez protokół SSH (ang. _Secure Shell_). Całość wymiany danych, w tym negocjacja historii oraz transfer obiektów, odbywa się wewnątrz bezpiecznego kanału. + +#### Protokół pobierania danych + +Protokół pobierania danych (`meva-upload-pack`) stanowi fundament operacji `fetch` oraz `clone`. Jego zadaniem jest synchronizacja stanu repozytorium lokalnego z repozytorium zdalnym poprzez pobranie jedynie brakujących obiektów historii. Protokół ten jest uruchamiany bezpośrednio po zakończeniu wstępnej fazy rozgłaszania referencji i składa się z dwóch etapów: negocjacji historii oraz transferu danych (generowania i wysyłania paczki). + +Diagram sekwencji dla protokołu `meva-upload-pack`: + +

+ +

+ +#### Protokół wysyłania danych + +Protokół wysyłania danych (`meva-receive-pack`) stanowi fundament operacji `push`. Jego zadaniem jest synchronizacja stanu repozytorium zdalnego z repozytorium lokalnym klienta, a także zarządzanie stanem zdalnych referencji. Protokół ten jest uruchamiany bezpośrednio po zakończeniu wstępnej fazy rozgłaszania referencji i składa się z 3 etapów: przesyłania poleceń aktualizacji referencji, transferu danych (generowania i wysyłania paczki) oraz raportowania aktualizacji referencji. + +Diagram sekwencji dla protokołu `meva-receive-pack`: + +

+ +

+ +## Instalacja + +Zarówno narzędzie wiersza poleceń (CLI), jak i interfejs graficzny (GUI) są dystrybuowane jako +samodzielne pliki wykonywalne, co eliminuje konieczność instalowania skomplikowanych zależności +w systemie użytkownika końcowego. + +### Wymagania wstępne i środowisko budowania + +System MEVA został napisany w języku [Rust](https://www.rust-lang.org/learn/get-started), dlatego do skompilowania kodu źródłowego wymagana jest obecność dedykowanych dla tego języka narzędzi (ang. _Rust toolchain_). Zalecana wersja kompilatora to 1.88.0 lub nowsza. + +Aby zweryfikować poprawność instalacji środowiska, należy wywołać w wierszu poleceń polecenia sprawdzające wersje kompilatora [rustc](https://cargo-book.irust.net/en-us/getting-started/installation.html) oraz menedżera pakietów [cargo](https://cargo-book.irust.net/en-us/getting-started/installation.html): + +```sh +rustc --version +# rustc 1.88.0 (6b00bc388 2025-06-23) + +cargo --version +# cargo 1.88.0 (873a06493 2025-05-10) ``` -To build a specific crate only: +### Instalacja klienta CLI -```bash -cargo build -p +Klient wiersza poleceń jest podstawowym narzędziem do interakcji z systemem MEVA. Jego instalacja polega na skompilowaniu kodu źródłowego oraz skonfigurowaniu ścieżek systemowych. + +Kompilację należy wykonać z poziomu katalogu głównego projektu, wykorzystując menedżer pa +kietów cargo: + +```sh +cargo build --release --bin meva ``` -Replace `` with one of: `engine`, `cli`, `gui`, `plugins`, `server`, or `shared`. +Wygenerowany plik wykonywalny zostanie domyślnie umieszczony w katalogu `target/release/`. -### Running the project +Uruchomienie systemu MEVA za pomocą wiersza poleceń z dowolnego miejsca systemu operacyjnego wymaga dodania ścieżki z plikiem wykonywalnym do zmiennej środowiskowej `PATH`. -To run the CLI binary: +#### Konfiguracja globalna użytkownika -```bash -cargo run -p cli +Po zainstalowaniu oprogramowania, należy zdefiniować tożsamość użytkownika. Konfiguracja tożsamości polega na określeniu wartości dla kluczy `user.name` oraz `user.email` w globalnym pliku konfiguracyjnym w katalogu domowym użytkownika. + +W celu stworzenia pliku konfiguracyjnego oraz ustawienia wartości `user.name` oraz `user.email` należy wykonać następujące polecenia: + +``` +meva config create +meva config set --global user.name "John Doe" +meva config set --global user.email "john.doe@example.com" ``` -To run the GUI binary: +### Instalacja klienta GUI -```bash -cargo run -p gui +Aplikacja kliencka z graficznym interfejsem użytkownika MEVA GUI stanowi alternatywę dla narzędzi wiersza poleceń, oferując wizualną reprezentację historii zmian i statusu repozytorium. W celu skompilowanie interfejsu graficznego, należy użyć polecenia: + +```sh +cargo build --release --bin meva-gui ``` -To run the Server binary: +Podobnie jak w przypadku klienta konsolowego, wynikowy plik wykonywalny (`meva-gui` lub `meva-gui.exe`) zostanie domyślnie umieszczony w katalogu `target/release/`. -> On Windows +Uruchomienie aplikacji desktopowej MEVA GUI za pomocą wiersza poleceń z dowolnego miejsca systemu operacyjnego wymaga dodania ścieżki z plikiem wykonywalnym do zmiennej środowi +skowej `PATH`. -```bash -$env:RUST_LOG = "info"; cargo run --bin server -- ".\path\to\mevaserverconfig.toml" +### Instalacja i konfiguracja serwera SSH + +Proces budowania wersji produkcyjnej serwera przebiega analogicznie do procedur opisanych dla narzędzi klienckich. W przypadku serwera SSH cel binarny nazywa się `meva-server`. Właściwe polecenie kompilacji przyjmuje postać: + +```sh +cargo build --release --bin meva-server ``` -> On Linux +#### Generowanie kluczy kryptograficznych -```bash -RUST_LOG=info cargo run --bin server -- ./path/to/mevaserverconfig.toml +Fundamentem bezpieczeństwa serwera MEVA jest asymetryczna kryptografia oparta na protokole SSH. Aby serwer mógł bezpiecznie zestawiać połączenia i potwierdzać swoją tożsamość przed klientami, konieczne jest wygenerowanie pary kluczy hosta. + +Polecenie generujące parę kluczy wygląda następująco: + +```sh +ssh-keygen -t ed25519 -f ./server_host_key -C "meva-server-host" ``` -### Running tests +#### Struktura katalogów serwera -To run all tests in the workspace: +``` +opt/meva-server/ +├── bin/ +│ └── meva-server +├── config/ +│ ├── mevaserverconfig.toml +│ ├── access.toml +│ └── authorized_keys +├── secrets/ +│ ├── server_host_key +│ └── server_host_key.pub +├── logs/ +│ └── ... +└── repositories/ + └── ... +``` -```bash -cargo test +| Katalog | Przeznaczenie | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bin/` | Katalog, w którym przechowywany jest plik wykonywalny zdalnego serwera SSH. | +| `config/` | Katalog, w którym przechowywane są wszystkie pliki sterujące zachowaniem serwera. | +| `secrets/` | Katalog dla danych krytycznych z punktu widzenia bezpieczeństwa, w szczególności dla klucza prywatnego serwera. | +| `logs/` | Katalog na pliki z logami serwera, umożliwiające monitorowanie jego pracy oraz diagnostykę błędów. | +| `repositories/` | Katalog, w którym serwer przechowuje pliki i historię wszystkich obsługiwanych repozytoriów. Każde repozytorium otrzymuje własny podkatalog, wewnątrz którego znajduje się właściwa baza danych systemu MEVA. | + +W systemie MS Windows odpowiednikiem katalogu `opt/` jest folder `Program Files`. + +#### Plik z konfiguracją podstawową + +```toml +[server] +port = 2223 +address = "0.0.0.0" +host_key = "./secrets/server_host_key" +repositories_root = "./repositories" + +[access] +authorized_keys = "./config/authorized_keys" +policy_file = "./config/access.toml" + +[logging] +level = "info" +detailed = true +log_file = "./logs/server.log" +rotate_size = 10485760 # Rotacja przy 10MiB +keep_logs = 7 # Przechowywanie 7 ostatnich plików ``` -To run tests for a specific crate: +**Sekcja `[server]`:** -```bash -cargo test -p +Sekcja ta definiuje podstawowe parametry pracy usługi sieciowej oraz lo +kalizację danych. + +- `port`: Numer portu TCP, na którym serwer nasłuchuje połączeń przychodzących (domyślnie 2223, aby uniknąć konfliktu z systemowym SSH na porcie 22). +- `address`: Adres IP interfejsu sieciowego. Wartość `0.0.0.0` oznacza nasłuchiwanie na wszystkich dostępnych interfejsach sieciowych. +- `host_key`: Względna lub bezwzględna ścieżka do pliku zawierającego klucz prywatny serwera. +- `repositories_root`: Ścieżka do katalogu głównego, w którym przechowywane będą wszystkie repozytoria systemu MEVA. + +**Sekcja [access]:** + +Wskazuje lokalizację plików odpowiedzialnych za uwierzytelnianie i autory +zację. + +- `authorized_keys`: Ścieżka do pliku zawierającego listę kluczy publicznych użytkowników uprawnionych do nawiązania połączenia. +- `policy_file`: Ścieżka do pliku access.toml, definiującego szczegółowe uprawnienia dla poszczególnych użytkowników i repozytoriów. + +**Sekcja [logging]:** + +Konfiguruje sposób zbierania logów w trakcie działania serwera. + +- `level`: Określa minimalny poziom ważności komunikatów zapisywanych w logach. Możliwe wartości (od najmniej do najbardziej szczegółowych) to: + - `error`: Tylko błędy krytyczne uniemożliwiające dalsze działanie. + - `warn`: Ostrzeżenia o potencjalnych problemach. + - `info`: Standardowe informacje o przebiegu działania operacji. + - `debug`: Szczegółowe informacje diagnostyczne przydatne w trakcie diagnozowania błędów. +- `detailed`: Wartość logiczna (`true`/`false`). Określa sposób formatowania wpisów. Jeśli ustawiona na `true`, logi zawierają dodatkowe metadane, takie jak sygnatura czasowa, nazwa pliku źródłowego i numer linii kodu, w której wystąpiło zdarzenie. +- `log_file`: Ścieżka do pliku wyjściowego, w którym zapisywane są logi. +- `rotate_size`: Maksymalny rozmiar pliku logów w bajtach, po przekroczeniu którego następuje jego rotacja (zamknięcie i zmiana nazwy). Wartość 10485760 odpowiada 10 MiB (mebibajtów). +- `keep_logs`: Liczba plików archiwalnych przechowywanych po rotacji. Starsze pliki są automatycznie usuwane, co pozwala na kontrolowanie zużycia przestrzeni dyskowej. + +#### Plik z definicją kontroli dostępu + +Wsystemie MEVA zaimplementowano model uprwanień oparty na rolach, definiowanych w pliku `access.toml`. Model ten umożliwia przypisywanie uprawnień odczytu (`read`) oraz zapisu (`write`) zdefiniowanym użytkownikom do poszczególnych repozytoriów. Wpisy w pliku `access.toml` mają postać: + +```toml +[company_project.john-doe] +read = true +write = true ``` -### Adding dependencies +#### Plik z kluczami publicznymi klientów -You can manage dependencies in your workspace efficiently with Cargo's CLI. +Plik ten przechowuje listę kluczy publicznych (w formacie SSH Ed25519) użytkowników uprawnionych do łączenia się z serwerem. +Przykładowy wpis w pliku `authorized_keys` wygląda następująco: -To add a regular dependency to a chosen crate: +```txt +ssh-ed25519 john-doe +``` + +gdzie `john-doe` to nazwa użytkownika, która posłuży do jego identyfikacji w systemie uprawnień. + +#### Struktura katalogu repozytoriów + +Katalog zdefiniowany w pliku konfiguracyjnym `mevaserverconfig.toml` jako `repositories_root` jest miejscem, w którym serwer przechowuje pliki i historię wszystkich obsługiwanych repozytoriów. Każde repozytoium otrzymuje własny podkatalog, wewnątrz którego znajduje się właściwa baza danych systemu MEVA. Gdy użytkownik końcowy wykonuje operację push, przy pomocy interfejsu CLI lub GUI, serwer autoryzuje żądanie, a następnie zapisuje nowe obiekty w odpowiednim podkatalogu. + +#### Uruchomienie serwera SSH + +Aplikacja `meva-server` przyjmuje ścieżkę do głównego pliku konfiguracyjnego (plik `mevaserverconfig.toml`) jako argument pozycyjny. Dodatkowo, poziom szczegółowości komunikatów diagnostycznych wyświetlanych na standardowym wyjściu (`stdout`) sterowany jest poprzez zmienną środowiskową `RUST_LOG`. + +Polecenie uruchomienia serwera w systemie operacyjnym MS Windows: -```bash -cargo add -p +``` +$env:RUST_LOG = "info"; .\bin\meva-server.exe ‘ +"C:\meva\server\config\mevaserverconfig.toml" ``` -To add a development-only dependency: +Polecenie uruchomienia serwera w systemie operacyjnym Linux: -```bash -cargo add --dev -p +``` +RUST_LOG=info ./bin/meva-server \ +"/opt/meva-server/config/mevaserverconfig.toml" ``` -To remove a dependency from a crate: +### Weryfikacja instalacji -```bash -cargo remove -p +Aby uruchomić zestaw testów jednostkowych zdefiniowanych w kodzie źródłowym, należy w głównym katalogu projektu wywołać polecenie: + +``` +cargo test ``` -Examples: +Polecenie to automatycznie skompiluje kod w trybie testowym (który może zawierać dodatkowe asercje niewidoczne w wersji produkcyjnej), a następnie uruchomi wszystkie funkcje oznaczone atrybutem `#[test]`. + +**Pomyślny przebieg testów:** -```bash -cargo add regex -p engine # Regular dependency for 'engine' -cargo add insta --dev -p plugins # Dev dependency for 'plugins' -cargo remove regex -p engine # Removes regular regex dependency ``` +... +test repositories::meva_repository::tests::init_creates_expected_structure ... ok +test repositories::meva_repository::tests::init_fails_if_repo_already_exists ... ok +test result: ok. 47 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; +finished in 0.04s +``` + +Jeśli środowisko nie spełnia wymagań, testy zakończą się niepowodzeniem, oznaczonym komunikatem `FAILED`: + +``` +... +test repositories::meva_repository::tests::init_creates_expected_structure ... ok +test repositories::meva_repository::tests::init_fails_if_repo_already_exists ... FAILED +test result: FAILED. 46 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; +finished in 0.01s +``` + +## Instrukcja użytkownika + +### Praca z interfejsem CLI + +#### Wyświetlanie pomocy + +Interfejs linii poleceń został wyposażony w mechanizm pomocy o ustandaryzowanym formacie komunikatów oraz walidację wprowadzanych argumentów. +System pomocy dostępny jest na dwóch poziomach: + +- Pomoc globalna: Wywołanie `meva –-help` (lub w wersji skróconej `-h`) wyświetla listę wszystkich dostępnych poleceń podrzędnych wraz z ich krótkim opisem oraz dostępne flagi globalne. +- Pomoc kontekstowa: Każde polecenie (np. `commit`, `clone`) posiada własny ekran pomocy, dostępny poprzez wywołanie: `meva –-help`. Prezentuje on składnię danej operacji, wymagane i opcjonalne argumenty pozycyjne oraz flagi. -To share a dependency (or dev-dependency) version across multiple crates, declare it in your workspace root `Cargo.toml` using `[workspace.dependencies]`: +#### Edycja plików konfiguracyjnych + +System MEVA opiera swoje działanie na plikach konfiguracyjnych w formacie [TOML](https://toml.io/pl/v1.0.0). Umożliwiają one dostosowanie zachowania narzędzia, od podstawowych danych użytkownika po ustawienia sieciowe i parametry skryptów użytkownika. +Zarządzanie konfiguracją odbywa się za pomocą polecenia zbiorczego `meva config`. + +**Domyślna zawartość globalnego pliku konfiguracyjnego:** ```toml -[workspace.dependencies] -regex = "1.11.1" +# Meva Configuration File +# Edit this file to customize your global settings +[user] +name = "Your Name" +email = "your.email@example.com" +signing_key = "path/to/client/signing/key" + +[editor] +default = "vim" + +[plugins] +enabled = false +collect_logs = false ``` -In any crate that should use a workspace-provided dependency, reference it like so in its own `Cargo.toml`: +### Praca z interfejsem GUI + +Oprócz interfejsu wiersza poleceń, system MEVA oferuje dedykowaną aplikację desktopową, która ułatwia zarządzanie repozytoriami użytkownikom preferującym środowiska graficzne. GUI stanowi nakładkę na silnik CLI, zapewniając dostęp do ograniczonego zbioru funkcjonalności systemu. + +### Praca z serwerem SSH + +Bieżące utrzymanie serwera MEVA sprowadza się do zarządzania plikami konfiguracyjnymi znajdującymi się w katalogu `config/`. Poniżej opisano kluczowe procedury administracyjne na przykładzie wdrożenia projektu o nazwie `company_project` dla użytkownika `john-doe`. + +#### Tworzenie repozytorium + +Fizyczne utworzenie struktury danych repozytorium odbywa się poprzez polecenie `meva init` wywołane w stworzonym dla projektu katalogu umieszczonym wewnątrz katalogu `repositories/`. Wprzypadku projektu o nazwie `company_project` polecenie powinno zostać wywołane z poziomu `repositories/company_project/`. + +#### Dodawanie użytkowników + +Proces rejestracji nowego użytkownika w systemie składa się z dwóch etapów: wygenerowania pary kluczy po stronie klienta oraz zarejestrowania klucza publicznego na serwerze. + +**Generowanie klucza po stronie klienta:** + +```sh +ssh-keygen -t ed25519 -f ./id_ed25519 -C "john-doe" +``` + +Wwyniku tej operacji użytkownik uzyskuje plik klucza publicznego `id_ed25519.pub`, którego zawartość musi bezpiecznym kanałem przekazać administratorowi serwera. + +Administrator, po otrzymaniu klucza publicznego, musi dodać go do pliku `authorized_keys`, znajdującego się w katalogu konfiguracyjnym serwera +(np. `/opt/meva-server/config/authorized_keys`). Każdy klucz powinien znajdować się w osobnej linii i kończyć się identyfikatorem użytkownika, który będzie później wykorzystywany w polityce dostępu. + +Ostatnim etapem jest zdefiniowanie uprawnień w pliku access.toml. W celu umożliwienia użytkownikowi `john-doe` pracy z repozytorium `company_project`, należy dodać nową sekcję o schematycznej nazwie `[nazwa_projektu.nazwa_użytkownika]`. + +**Konfiguracja uprawnień w pliku `access.toml`:** ```toml -[dependencies] -regex = { workspace = true } +[company_project.john-doe] +read = true +write = true +``` -[dev-dependencies] -rstest.workspace = true # alternative syntax + -### Generating docs +## Generowanie dokumentacji projektu -To generate documentation for all crates and open it: +Aby wygenerować dokumentację publicznego API poszczególnych modułów projektu i otworzyć ją w domyślnej przeglądarce należy użyć polecenia: ```bash cargo doc --open ``` -To include documentation for private items: +Aby wygenerować kompletną dokumentację, uwzględniającą również elementy prywatne (przydatne dla deweloperów rozwijających projekt) i otworzyć ją w przeglądarce należy użyć polecenia: ```bash cargo doc --document-private-items --open diff --git a/docs/images/distributed-architecture.png b/docs/images/distributed-architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..742b6a8cad746dcaef7d9d9523d1b907d8219d2a GIT binary patch literal 68584 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 literal 0 HcmV?d00001 diff --git a/docs/images/feather.png b/docs/images/feather.png new file mode 100644 index 0000000000000000000000000000000000000000..80e115206154dff759800ceb173e1a0a4bcb8985 GIT binary patch literal 2331 zcmV+$3FP*PP)6IdY%LmXX@`mmOsGIb0-;1CBS2u7kjuGzB=P-xJ^NkLa~Dd8?bul$>?cL>_r2fy zzTfYC-~ay|0{B0NXu1U?g8)GQM};{mOjINlK)zfk$dw5NhRPW^nlhtS)9VVfA85Jt zn}@;xDiokOQtZ)eRIose3g>At(QJ)aWCE7OM>#VLT9{j21h`ZT7TX9|2!AU8OrHU< zP#`N)NY<9fu{8#s6gaFETCC829$s`!eD=UJc+-$Hpy>RMM8+eJ(o#z3HvBHBiFhip#-cbhS(U-JjX>r)c_ z0||LXod$BWYG7Fwmd&dKnqlFq<{RK4f@$^B9V8Er{+==e*(zMSr|!oGmR6NL$CqO= zX;Ln4bjjQbz(m4$YHV${07Wxt^+U4UV0R#TVzfLZ29{NoF8}?e=RVUY=Vx%gFyK{*7l>Z285HR_zra$heQ;y44PJXnZh6 zqA|E~+XAzS@;}8b3q&w+06SyM`QtXr*pOH;&k{y#}LpY30(sYP~kE#WT`Ugj$ehT-!)u&p=DsCF&QUq1I0Poicj9& z(5O@4hIIIZ)fS)sT@DwRmM#J;N>*@oeYd@l?FDCZb-g&>i{p zrsiC&dS*KOpu+)yK^ydpcwz4HYEUQ@@p_JBf%N%+bbBF25b<(iaLm51|NZLmo4ub* z)YpUt6jHJ5)9oA2m**Q6q$8fsJVBcsto{&GEnNgssU)#PmIEpjf>6&v{fB>ez_fTD?Wd@AhyqJGPv#;XT1c_*cT zO;0U-JDo{SX8JR94RiktoCL%yF>qSp8cco^JYcW-q2NkgHW0=9Cm9+z#Ij z(oX<|CgrYfB3B^Y#I(YGB_m{X-jB6CK>?~Za%TnUm0tDjo=WnMjZvV^43;Qw^ zpVGh*Q|T%Mfs{u-iX_3`V*wgPA`4l%i?AUL}`G z5CjqXf?@w;#PdihaNYM7y>2QrCfzD9F$z2#oxlJBdQ&B!DFT*DfT7VmiN z>E+udp?4gT8qg|nW3p#N{~$0C60*w60Fxxn^QmyIt>g2Ze?Re7#xEv7(io6SFh!#J z(1;UAmkZRTGXcZpK!hXq>pj+sQZc46s1?R+wZbUDMA~F)WNT-C{g(HStYg@u%kb0~ zNOYEp1_BW19{_o-5hNOQyiO^T=FTrKthv(FU;lA^%hBd`OTEqQv*u~ld3u$iz^GN_ z88pfQeh($ZFv;$JoZ23x=-5<Fkd0>vg6|5M;Q@c3OwO zUh~_(J`ssgQ}*RYku-=M9=~<;H^0ZB0s&B$mSilR$H6mA`|pQ%xYvLH0r-AJ!D@b3 z21Yzx5TEx!2-)m_=NbW%%QNoaaDB_}V@+*Gr%>}4j0xg=jWTb?sz*;qM8X$u4-8#r zX&QW%UO;giu zbO;~v`n&<bW_3eINgHs?udLj8Vl;p;L9w3`tOtVI&yo%*LhVU@-JcG#ZTG!T;AV zFNmU9h9p9y&l3o_T|{u$;r91;+egf87Hf;e?(9o)pEczeGlf_V;D*E9xUfLKh8NGW zZ1~`bhWDBWoo5Gq!Ja71+}E!;8H}43PgdmD?pQhhc>Gon`If+OT*Mh7`w24Uj!<-n z9|e}#Soq_ zY47y}I(xmu?Fhxt(kE002ovPDHLkV1huh Ba>oDw literal 0 HcmV?d00001 diff --git a/docs/images/gui/gui-branch.png b/docs/images/gui/gui-branch.png new file mode 100644 index 0000000000000000000000000000000000000000..d8bbadd63582a8a6dc22e10dbf3625bd38f10ca4 GIT binary patch literal 125799 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ⅅ*$rTSl(<>7~cf2 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 H?mqf|=)nID literal 0 HcmV?d00001 diff --git a/docs/images/gui/gui-commit-modal.png b/docs/images/gui/gui-commit-modal.png new file mode 100644 index 0000000000000000000000000000000000000000..11d066f9c9679b5c710f0d5c20e94c1388675472 GIT binary patch literal 101743 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 UbSS}X$M(B=`KCkZCGXh(13V>%OaK4? literal 0 HcmV?d00001 diff --git a/docs/images/gui/gui-config-error.png b/docs/images/gui/gui-config-error.png new file mode 100644 index 0000000000000000000000000000000000000000..4b4dbd636389fb12dcb3862baf38c6a212d95e6c GIT binary patch literal 65926 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 kF@QmjZP^j@V#Kw5z0iWg83QL5AkNbj9MAR^M6 zlz_B^UP1{ZkPy$;Xeh4FCG; z=-yv{9r55`WBnqvQTmwm=a8S_Lyf=61}?9$z8rN`*HQoLugX|1n*A}>_v6nWnfv|q z*QvH&uS1>S0;j+JV&*>9Qa28=*_b(QXJr}&U|`?LTyy#iXSvGj z@I#k4IELk~K2!I?7Ei?$mE&u@;Z4ysqh7WipS4Pv<@(9`5(VlB<2)Qcf0#&AU0BdM z?&KHH*(B+f#H4424D_WalQD=u9yga=`q%F1uw)f-vEP=?7n%I3vtr^Co(}>wHO{{I zvL&_n&WEw^zSm`BAM9-tDBv3U+Osam=zSIlhV?P<$^mb@ANDC-f5n?_yK>)+$qaX;BeTp@{2BzYUImg%o% zEE>ms;;H`2nDH!4sjrV7$xIY=@Koi+bOAzlPH~;sOP|}iTRlK)^do+^Kj!EH#Wxn` zN_V}@{$5#IH&cw%FIQPsN#MtUmQ*4u>N~}^e@1$TY*)%r=Rw}0yfrGd-~V1pJm)QS zhbU{s(K$*<@%VdFM2I!Saxt2Rgv9!3m&d0D{mip$^k1I_B}j8i_wkp=X%?9JkJW`l z9MeO)3}`k-LY9tCk!Bq|NbTZGSsuL~gweC&wBQ@R=3$NLm8cbwJ)SMeX9X)Kx3d2U z!Pv!QWpYPCoQX|rToGP>+iWYem?Ec1?p>p=juOSx)Id1rDjiYH<+Pkv6J2et= zQ~EfUZg@w^+@BddrkcNn=ex_Uyp-EZN9?woZ~gol2(0_M)V}}k&5LeEdXByeFI)dS z*S$DD6F+`qwE9(}VOB=J@(KF?pCl_&HW|ZPP8&2+QSUXYc_vf5b*WXtXJLt znHsj!!+h6%t_Hd7Wox#h`Tc`qarw1h=E;6ge^KE;>pYz7ir(Vpcl~4!U2qrv*pvP? zM8xLCljrOh`#h|G!06^lr;Rkm?ANwp=;05C2cT>bzQGYwHbI`*eR$Av<#u`$vSKNb z7n5+o$-MJ$)@Rw=kvUzD!}dTIYoEj2MZI24O^k4_!9GWA<3O*7%Tr2*&*|rWsW+5u z*0mj86tyDxDa@1B*xV^qfK^7Ch;igX=O9QR3%+WbLXDY^c{?wo*AVkWk@WV>Sm^I7f~fSbAoOb(gmiY+DciAK)enhm`h zzoMzCCv74$O7zy=-|@*a>ih;n^<>x2*pwqos#1*m{OzgX<WwMK$!=qvmaQ(RZ2(fs-Ej_vpLRIVdwhH&$4CmDo&qNd%P z;OM6Xs~WL?yxpDlbpL6R@{BswlOj9}!uY6Y!<+KzSC}ff-5fF;UVd8hIWPgeIe;|8O`)0WKFo|(ChSL>x!S8Hc)AYC zF%`Mr=Wl?_$gRtnENZgV`aNRuEV8)dPNfq`qyY0u2SkY#LSwAdR=hVEFupLoBij8X zO*{oU;sxWwK+6pmv%`8VF9-NN|4iH!p0vR?QGsDBPaOZ^g|AJfgv^mAsbO~{dtR% zytX+SDo$XI*>KKJdTmpJHwS8FJKkP9-PrCt7|*?pY`+r42T6IMVRI1Y%?A0Sy_QzZ z>IZ}7FGQvM;`^Dw2NU5Ez2z4J0K;zb;U_S`8s|=Yt}hT`lBysm^w@3KCD#*TOWL8^mkuZSXXgPnWzKcMRX1h8N(=3%$_c}zzs(HhOZCZ5ZcD|?owB2!u)N$*5j zB2T~i-z^2MH7SK1yz8m@Yr;f-%XvShye?e7JnlkjaI>-<@7F`pptC;rOh6ZL_U47g zPX^MJggY~#hNtMA3QJfLMx3g)W9mE0q*rIY{clQ9H78To!Nz{DPT8@5E0Zw)@Mw&%r4T zDvSwKBJWMqHGXH^B;hK;B2i@V&AI#~VIUy~a_Z0RSW$uyEGrrM>8-~DeawYBDo+c8 z&ldLGa!n=hdJcMU%hrF!T0~bn#G}}1)c@59CdIb*7{5FY@#bG+c9HtQP@@(m9bb%W=XQ^_ z4xI5P^q967au;%wF(KthT$1JDu(s5YCZ{T8)QAvM4zu<~(F0V!$WXaMLFiG1p^iriGw|k*!^(NzVw6<5#4U$$cQhlFL-5EcvLA< zaqEP#0sz2&a6g#h1x`vz;%WXz1I8r70PExB$(G<|5O?=fQ(*bQbfbT<3S}{#&<=na z?D5yn1f+%T7277Jc{e_`{#h5Yg?Jc)+8x$LbGI|-R94=#)r40I-fm_0hj$(0wfbNQ z9NQ}kY}StTv8UP+Q^yOI96~CuV7z_@WtQ1Fi3%{Hf*8!K(jh{?@L52Bi5f9wuqy;h zwNd?|cD06zGi?zn58m{M4sWjTSt{z(2S8t8?uTat9SjL}E!fV8^AC$Sd8 zuIna%#GUqfE>w+CreAC(5^OO{Vig62Y*~B>nSUc<{LDp@S?SpM0?x%tqGHG`R_Y%D zC`-w`*JD+(^Pkf@VYDbd@54w}c$i6qpRBBG5s*UzQSCa&Q{qS=EI}mxMH2(B&u1_A z#Djj_f zzCT86L`ori{pw4amfg*vJNM)ED;2sfl(&GAByGzKhow1YkuUiVZX**026ThxB6wM{ zUc08Da)e$U)Gj{idiv-~j%}nI8{Pf%f9UYBmw!Vt_a1k+5nzvS0;$zzNdPxmb8zgk z-0H^Os=c{dh=yTGNneJNq~D6`Qg(59+*6Mxl{Bksg?qf+8@<@8X*R&Cy(T51msVTR zrnc7S8?uf|L?%19FJ!$beaQy}9^`EGDoTe=R5`mVg-(u4R4_!>um9^b*%|>A^$CYn zIdoe_)4ZF(-~6$WO&_*5n&UfTACvmH;ySALmzSLweV5Dw*B-b%&c5QO;bLlawb*7) zeuB18|HQ-6a@KDbIaX$EdAcEMY+Bofa2D;QIbx)FITgS8YPP5#PJL9_x zBU8rXk(<&Lq196V3gCX((b4fEr-9rJy2nE9vB4bKYnk=?InAYQUQO zL_uM4YFfP3P017%(5aG3%uyq|AvQAv&hIup$kO< zP2Ht4=L2tXJpKBkZ|vmywz(P4*>7K+Jc_@_nk!PLiNh`4r4p7y*NUg&eTRyi2^&>0 zRgB0qXLM?9+7X&{;6>_ z^4;x2P6rW-o!fg7#c1NZsht8ULDIZE?JxDWif!rc>LZp%L(>$%1L!SHb(F1FvnlET z?Y(dmCL#x(>&$f)L$QYWehyt)mBYEy?==mbcekz z?GS#Z$+Wa7T|e$$L{2ILLy2i(M|`T&9J(w&C?E-m<)fOowq`O$yu5KK>1(pX)gRQ3 zagffw zd_(t2d`b1jV(OmnP;J`cutyo%$j)-%$~)f+8{(|IGlB<)1^iyhqNl2nkeB`7nU$3b zs1rw=uk~L}k6R=p`qI;|!V1V>eR<}0FR>qOr&7))Dm;5<$r8Tk4YT8SDrJC(+v4|L zNOY>|zqP6B2BK$s>?+m^55&AtINFD@Yde2G!YB6#VxcoLp4SVSQd3t~bmNZu2Uo1< zP9!TX;jS=ee#?-3_pk>J9c?<(c;BSln*UbcLJVtuxpR!P2fr6y9j7)L{Z@gs_eV=E zZvDo^kEWUtkmbCNa{UFOa=C5+ij%XNNN|?GG@}C0p>m8 ziA7_+zfE6o8y!|)x^^-ev36XXbpx(a3#LG#@}KlguCDRao*1IFaFOi|A3W@2MTNwY zMojKBv4g1dOPHG7qmhs|TEn*_52`DisxRe#@l!ax^*nI0y(R(d-q%vy5b@dy3KZ<99OL$wymtNXOsO}w$xxi<+rw@ zW@m|O@s+5(DFx1mnS$2jk5Ad)%X#fjzdkETPl3_^O#!lSh#PrX#ojZ?@E>PFeXp;QW|Kv#j@tx;Fi+*cw!2I{)icng^mY+S})2Y6G-&dNdAb##cmxmvMN2caeA-D*> z+p^!>TaPy`#%aH>t&9e=+%>HR(+~C_qW{qaxwi;}BB+u}$4f^NSyv|yvaqy7i`ZSx zYpwZ|Z=U!Jn$`}RHq?K}t!`_<-P!sL_U(wOL~)!2giSZ@R#1|_jri{AH$;ukQ9iM% zq)#e4-_KRKqs3U9DMH|Xs~9t%;+kndIb{V|%f&Tw7JT(@J;qz$ed5T|S8i$M!%&aX zBCmX`NihbJ=IO6y0aC~_P|pM(ndW$z`AK1MIhtAZ&Fg1{ddAg+7=8s@RxoDv^dZcM z9g?GKN@2iH-lOZS+IOC+UwoY!^51pF%+teJOE)|arJ7P|Fg&6;&<0Z z2&*}Mvc%>7!SaO=Tw(s(D#5SP?<_gvyi1i8ru@7E)A=)N#NId+--zGDja-43 zrzGnRDmstdA`Y}ZQ5U19JvWjamN_1y@5jNo@fJ(OpRR2+!mZ+pUd z=8WVi6t;Vb=eKbz%vdg&6c(tN!LP59cji@CM6pjei5iUfn6{u3|YSb%AR|cOs%9WF%$>_*KXQ{)-{I>DXETeOn9LdQm24)^YH3% zoGk8^Snd9YQV{2gtOqwwpsLHku=e{Ni}%=$Pebbbd_}`vRvVP8?>ldDgF^Z~RNP;% z3h89uOi3vl-8)m$a(5iWAe(JNFGRP5!hDuvWlCN7YK@%xc#9@`W!)>S;A^V9(ahR& z{;EqG`33{Xo#o)U2_wR4-w5HgQOiD=DW#_ZHO>s#QvA18;8|=^Dh63=cTTp>Q;QW) zEmAv)a_a@fX4$-P#ZhGWP4stlFLG?s7B#-|sXI_36;QeXv?b<;`spEnLi0Qu{9ZZf z`HlIEOcwF(ROIEVBe@u_O;}oVwoHb2yl=4WKe1 zj#_d-8-HaeB>oOZR?utKKLS@=LKjL)OZ3YVKHnQGo;)PzzS?}S7e||n)of6GgI2zu4}}&&cQBuAw!#KWNdKpH0@j(8s+vI7K|sm z<3*CcPkA^0@;xgRa_%L}*`qqE)4Z-fM;sM7DDEMC|G2JZb#KlNdZIPmzZ}=dk2qoK zP+ENJn~Ei|-R$S}YSVzgTj&^|EHKS7 zl*4cApv!Wjk{xC*LAXpbxzhyQcyZ!+X0J=lX{t7dhx8ZM0mE9A?TX*fclrBaI_PB; zkZq2qt1!+{(7+L27fAn9V%=oI{l~w>624n{M{7s>aj!!kNOrN`2A9H9c#nb`+O==9 z*wrk%<+-~1xgFzi%@~|&F}RWxd9`QHc;J#4`C!)QDu1gX$1biHoxzVrdHumiXk1>f zr~MAtf&cu^lsWZ$B?Io(hq*h^ia75NM#ZW! z9&U%7tz$omlqgZtJCu*qs@N{O_cCMW&C)&G3Y&`jUX_?tYLI`&v>}HoqC`A6%Snbo z)^-W~P~iim<>~e1>pie}i#@zyCl&v=*g<|;QfTc?q`gYO*vb9jwRfTTCibNne527| z#!7vrVHt`6p%f27&B%$@8A^$dChn#5p0ZE6SO-~IT`xlx7_cqBlkrOM56a)REW`S8 zYRzm52`BLv#RbRsCZF+9!!8)cY(_pwHkt`j&?Exn#4@Fx#i%Fib+4D*8+FThx{`8} zuxH&-r3nfNelQ&znn%8Tj;U$985ZG78W#=3%u=AxUU)a1j4h{TFQobXhL5845i-=! z8#3X-O0Ep$vea|}wWFzSyxRdGl#^Tw87`dAlKEO!XbJc$56pU z7s~z$FuSqWr~&-(z7oS(O8FmihXi!mtPm#mY>atbf0F?IJalTV{<5v}rq*|7-dllc zmNDa-Au2_cGZki3JTUfBD)WkoN(MGPS1P4z+y<0io%hCHr_(CnG1?~cOrQXKZO`X8 zPz8-K&EcF9)ZV5EHtgvw&bGcPQr-gY|8WIXIF;s_Dl#fNL#p_!4`5Yt9nTd7WLg@P zD`jOiZ@HY1FaQFP)m%fED{iJVd2P_hA747G`xG{Z8 zF^tiWuEIo0*dio40GYn;Gngta&!A|?YgL3ma+(P&vQ_PYQU6+rn*-hK-$h{QV`?QM z3O~Tj`0fbv-VJLXfd|bcAnA#y8%)M7cKsT(sJZ;Xb`8fS)N!BLh2J_jCjQeeKd_UY zE4#I#nq~WGd()xdH{CTB#N8g%^j+yiO6N2a?bTlclF)6OpFxpmF-w0kUZy z#HAWc%3Ah?edlm7RY*tSf%MGqXLEN^P~(Eop(5?2)ab^=9xfZ475p(Mq(&+TMtT8+ zTI`=Q!ftnFL<7tL#p7kqdwQ~cm!hQ%Mn?Jn@#ok*y>ack>^wWD(uBY$dGY;kK1dPgS|^yK@!{_eH&qP^BN8D*T{3R9cUfB3v{%2)lt(Qo2X_KH`FF7W8Z4jynn;DJ$H z2K&2n*URI^lH^stoRQvkw_&W*Nlr*5mxg>y2PzjwNxTa$?RFN@UOR9+UGw~jt!Y37 zFREMeH})7T^%NDZnEYrZ5{CE7`PiZj{PG*0U$4J=?NDk;Db8%_v)nGLWKcD_54Rls zX*uKb5|Y9NQeQ3CeRqO6bCAQ2aY?LaPnz`Dg&p}rl=vQ|WC>)Jng$Ug?fXa02hM30 zNoX`Xd%Uo{?ocuQ^9lPyo8qKW5ZKiwRL6G{^dXAgDa(A8ICwM(=^h2%DThB;kw^I3 zetKyEDJXRXI{qOd1!Hj>D{tTWPt5>R;#51%{P+B=!F z-twT`51An0VQCHo^gZLbk1(rdbB@_=5(?tqyvMEM%{nwXLHaG|Gy6o45rF-W-MR6B zpqT-;JxUL^pfTL8vA@AR1)i3ic%|51(130^QTd>`6p?amtUI|t-8f;5Gym4A_Cyn=l%DL3Hs>H?HtZL&98-(rEAG&Ap>@oJK)W;7`<%LuF(w$^)br-qR ze){3}DzP|ah38#h+Hy82GyjhMz5=w-R4t^)bfC|95wid=YGW|WVreseX&~}eev`A> zln_}ZID@u#E^V~p*^}wdN}cgIP&J%?1CCW=V@{8ikv8WA*FWbNtf{3fLAXBY<<8rc ze`qcX^&ztfTdm#N$W1j=`7YV9safB%slbe*twyS233+Ky8*do46oNbWJ9Ps^7Ke*+ z?w&ZeLEi$vH2w{zMH$~WrYk*LGm?_@55@aACRKa;g|HI0&Knt<0d?baEmSpg z;CU^#duquETgvCmk$JzT#J8{FMnnd?V#bk%WVx#OFb}Ez7TD=9K@@d$+zFkU*a&!p z=#&@;9jnO#hO{GL&tJlxkiM8#~o0LH==vlIXy};Y4O}5~D zRj6A>hu>pR$NZxaUuXwl2~A<7Wsk_iwcL%5p74q zxUMm7hwQ(6n8B3qsFd&1tGzW;hYCu~zJ?>emK~*hzY9Ljqc#Roi3vpHVWn#G#^$I^ zBPPD#snSffKTJ%r?yD;<_=xKDJ3loqCOxROHqw_Jyfq;G&f|RHiwA}&?`RXMFcFv< z=`jTeqml`xP2{eJOgylwleslQc%uh>utgaKHjXIfUP6Z1=|flQp?YM497`6>^rRwe zZ*h`NuZGPwv*-|RTou;%Fc59??QJ1WxU4ncr?6V$(Z&pCG1$z5?SmQTriYmF#eFHE zt(NwT%-_CNaNAg6)Z=WPi_I;A{Njf&`cMd`yGPjmb(pB&J_0@zHo982K6yi#iPPf@ zS3S4dOr`@R!J$Mi_lt<9UyaMN!Ir8=&3x*veapNc;a*AplR_3iZ&V)y`y4x_p6G`+ zKEEOP@*cP0T8$XcuXB^Y2t-+9l=tO>2bHm6z`gf}DBV+7Av|;{&3U^N+@mx8nz0|N zCiE~?t&pk(rPY=v=~(;N9Gv-*ne#EX{S@iP<>Z%{?FxbOwU!77*9`9V(UMND-|(LO z;Jlg9U}6`0h&lCS-M+-BeQBHKH6HbAzUoHl-QtLtwbj)Ravo!=Ee=5)LvJsLzknCV zP5F@(A?B2IJ&ZVff7EpmOrqt`Hi4lg419j)5`K%G9*PqL0z%ZNdT{{(CjdR7g>f0= zyT1FcX&WJGqSoWyNv6mRkPJl-Ytn`yB~lkNEU}6FjQl`m3g4+u&`vO;Z2DkQp(hHu zV~W1>x82|iN4`MK&@)j;d1oVtYzfTsJTP{PQ7jKK3G z<&sE8vpE%GE)EiuxrO}*hpc=+&ee?rV(yi{1%kEE?a}eSSF)&+dH+&3Pq)*ua7JdL z=gr)k#uFDbN={&$l=0K4U()sJ?JQI@x#J|g8f%n$-HeXWg(TgPocp7`+2aSA1@)d2 zRpEBrz0%utACq_jDX-(jO2Llqv-Dl%`X0R*wX1<5(jr6Vnt;M!0Q3oCfLXr5=b{qQ=07zOP%nx{=wWZYdTj0qG(^_B?H-Y_>YDp7*AhBFG)M(HkixbA?c1EH{F}iv5yqC3e6^n;M&p??v(X>H+s^N-R;jEjxQh+@}Xax5IGf`k#S={o$b=%z9&>XYhzu zQPS82Q|qj#f7)oxz=P}V6nqeQ6H?HH3Z>ZkE&SDL`-$AbR3$8wH@BM)Sa?jq1}8|a zqZJ(>W@{$>L^GT+t3M=5!nM}|nx8<)09fs3rN zw>O>9dLCur>oHdLk=aZS1bHD@<(;+4R;i|WnHf+@yofHY?3uhxm3pB4RFv__W;qYj zbuYmYM?Mf8^PSp!z74;0NjtpznLFRnp>IRLKsiPDp5I41z&css#P9qF{Yu6icRxaZ zkWq3X_1JaflU&0pC>^4DN5C)M80XSG+M{VSRgY@-a#yZcG6^Q~+X5^Pn8nip>+6eS z3N$V6#;Wtv%1M*iDk@Rl3mqp3+JW@!qT7MHJ2}I!DgM#BBhx&CC{5x76S58i(V?d~ zGu8Yp$oHe1b#<=vs5*kND=nYZ4)y=NBcT?0;8P#w*jlI1@@&N`YZHY}BVfo_^W77w z=QeaUsBr>S`Z(fUxwHO9FFoaPA71##belW=yO}N9?m{G_VehNZ_F6OL9`G=Lo>HLO z5V-O4_FnL>!sejGqg>g>&o>3amFpj5@+IAF^G6x+3 zA?1*1xeyn=NNjK?b^j@?n1+6zQL$;a@E;saKaMsTl-vsZ)67sLdOI09 zhVa`)1a~#!8{4%-pSNwapyhKgq|_gP=I2qxk3kAH*@vEqMTff%*jp*Zupgg~;Jbw0 zgyt>Hj@k!LS!zc;2~uaXN!r<{9xP(~YKmzJC@n%n-9!XE?hv=x;iSatZN2vQz*mp= z46sWj`?)L6)K9sjhnd3jY&Vms>CMgqa}zs+bC7{gB{8aDgm=?H{o*fd7w&B~!TCaC zLn-36yDH^?)m8Z%mznWl>fX#l)Xn0tlF_hSEue1Dtw`#L=7vuB<=y)z>FjS{x7TeYODgtmvqu>AE zBH%9?`emiVJ5l|bxa=|PVEp6r^;737fI9W2Ken%ub|3p=XH7@FB8+H}xAG!0Z@Gv6 zwT$rl)G>NqtI(4isk{V-Z3!2JQdVrm zn0Q|KZWOJ4-Iy}Y#0WD<(c`_Qb^g5!TdDy|)V~$B7|Z3%B=_YIKYa00WWPdJ6ziC0 z*NngIg1gP}!M)~LQ)Ob-b1q0|QIiZ~`FD$EcEMS{Lf%mPqqE12&J9LL4}{`$AF7oc z`N#89=ZRi9Bb}TzFN?jL3CX>QpV4mh1=lcxJnPSH zoTMC!^<9g;CCL4Z&J?LJ{GbIG2AzDCmgr2h{IvnHl?>qhU{Fkx@7o#L824c{qaK8T z*kXM=kw`4Mv{eKu-u=MlEGU4|1X1R$yX)^j5t1dMQ+4-7W&^b}8JVK4Is8mshuvbO zgM{B`T(f&)=J^1ihD+>|!1L;t4&R#3o*js}i22(iS!_Bz>rz?aEsj-pnx$H{i{YBy z7mBq}Y_{k4Vci_)A*GQ-+Wm{pVKz&BM(jA8;8AefziAOc7=7|8K@L1Lh$aFekMH)0 zaJoo2rIr*ath&rBH!a6h8C*CTm`T~@u>w3ba9?wY)-iu!&u``K`Wp9^a=c_Y!jc!5 z8za1XXLLSjY)WxnPV-Ob5O>3v&#AEkY`32_Yh}0BpO=v%Zx;^VLnpuDgIp{NZyy-56ITX{?YRA~@>L2P&Aj~+!#5|{c=w;oHcRYtsibc@d&4JBOONKqo6b-Fp!F>Ub4w_% zPm%mo^p^E~`Q}EZ>f_%^p4T%p-c*`S$oE&TUsITgX#XcL zPWo8y*WLc$`o-qXFWSfJWG#34{UJ3vUu!aA?yHUr5UrGrxBz@lO~;BH-|!3CK~QIJcvl1- zjHZOkz@&tGr!C|iFmY3ptp@K{6*PjYT}4m z+mqy<+eAkjd4sdCpE%nt{Ms?KupLJGzcrBLPZ_W=L}@o?W>!AiY~A&bA3Sevp6JK! zd`dPd(A!H_kxS_zNuMCPrU&a!+k5NWd8bJb{r-c}vRxNgs(1P4IrWXTSH-=B7aU%_ zjpp;B^Y*2AZQ8eNn^=#AyYcxW0egY4Ct6N!c`&X&)easLb1yrP*y`i6XI3fb&wguj z9J{G!-`~%`q(^4N-9JtbI)@Dk8hCcgEW!5m@0Hea)~r>Wxzxu$hL9so^V2^|51!}^ zo44lMm-y5&@h$uq20q^=po1U|o$avu_Y}5kpq2oSXlo9S)ckoDNZRdos*)T3!Xsm@ zLOcaVoLp8@9`y3|0Gz0&b5LoVsVgqSL6}VMJbrLmp-GTH(Yra`e-BU5n8Wo^_h(;% zQfFTsdSq=^@lYoMWy;4j_~#!C6pMIp$3VPVF>3m9q_{2I**7LDK(2HV!@KEfks+XO z9~^deF5gL`P=Xc1a5XB_K0m^)(O^Ni_ldTK+#9Rd-*6>lVZkG9&N@Fk3XyDt^PIpl zc@Aj`XS+Yjmt{5N9S>eEbY<~jksFEM94}8l1b*gklRh=_WyDSOUm=K=pY^$43fPnl zumNx6ebkPr1xq&UoN*W4^hZq-JZhu{5}0Y)GeZN*2`|@d3j12X3WP9=OlL6~X%YgEDQ>a@h(!rq=NvjrL!YOAan9_G03C-*-d!sR~0Rg&lQ`^{(sM`hrWr*T5CSPU`#{w=T6sS-+n%- zH?{vQ_P%DZMaB5{&esJUy8Y2evi9SY;Ei+@`p;Xz2XBlBeM2LJzIa_SN=>Sp*JWuR z@ege$pRby9F?CuXgmKKxL$hCnCVOEsFro-CKDN~tf?U?|G0^6QGTTtOJ^tgUI|}dq zRJyL+nE45iw zzab0LxZ$s1AqW&M9;?$PggeUwBXu&B3SpfJKgy?AsrVE5_@S3JSt7A&!f+asp{_=r z0CXL9RRSCh*(bC07Su5*#Y{^~-IB->bA$GY)((e0wIN52uih34Y{8$R>@`6xV?qX- zfTGOJSAlD{|F~1FL1<@<7JVZGc-0!va?SJyY>`UYW@~%5GYHka@QF6~Sk9|?e}WAC zF)`ME%$ZevDedHG+dr(Xz_@+nWj-8YvBk)VEf|a-W!YZ-2yCVfvfBFQ9L8%^3AN?Z z`B4pwgB)_#V2i66Iid{-Zw|DQ_xBps%%;Y3GYdn>%0`WVOcZ1FlUOwOHUD@vw47=4 zLXr>kB5Hd(z6v_A;M-c&x%kaFwktv$D#}|LY9@7;`C8)P>AFVi zOvPeX@4xN9P&HxK!SuwWF%#7r+%GXRuzgowO}+XOi(11i%Br_&-s#l&OjW-!R)2cC zACVRIgqaO^{31bIDt^3qu9m87o6HM9m%rN7NT#*Lif?(HZdlT6-}&sxnf4^NY&)Z4EJ8fS zN(^V97U|A*=4e9WILbs0RXca=(ldAJTYhHrM$3v3q=0{y&Ug_EZ42>mzq!>h+p_+K=+?t@l`(YAx_tWAx6mmS~5(koI10gI;Ore%9~SGa>^o zU}c=FY8bTaGwDjCBcsd%{9>6Z=XynCG-$U??_`U@xK95U0+wAw7iMWwwP7E;RwdY# z_g?UkX(#s*B!3!7DzR!}ILPUcl=ZrC-+jy%68yUztJHSVOZ3zL>lU$vI|iY%D;N=5 z<|KnvrJA`&dI{$r)+_?VcRv!EgQeTh;yjkd6k0Hp6bhVBi9$Yvu9-5^1Qy!Nk0ya6 z3tqAwOt^{Ed>*xhqK`gYvcysqE^J?dH&%1K3E)^ zvcZpehxDC9&Vto;>Iq|0^*x=;<(#6&c**iDqyAvn@)7)qk+G)N`+hHQqg*{KsIpzj z6%_*8eK@N2sRG|p4z;#f+HLzfg|X6)=2pk#GrVJWu_QJGS&%WL=ob zon&JsbB3sZ@D`ei992hT(G4ax68I+tRl zG_KattTwlsQ-QQdcbj*tK8v{WrEo?dbMbTNS~Rod;LMi&fkMlgJlfV0%}s(XE&;RM zR}R_e+kaD50dnj39BQ5Hh4qDDW@Aw#G;U-R*>?BNc=LRb_~Hg6741>Btv^XeecdSdG#O%Pyo$bQt!G4hZs;;lW z4PQXd{&GFJcrt*aG5hXa|3}BP< zH@2xD^0xx-3NouWd0+L&cGn#HA~S5wcCogc>LW+XOWrG};LhTAznOaV6lQ0uzPDu% zF*k_~b*R7U`KnbJa}C_Exkrcwl6!sEJ+Pv-KMq+9#-yQTDkMfZwMvmwm{9VAo6d!^`yXp#1;#%Tw)E?9 ziUCiTfy}`;t1#V36Zpr(Ufr(Au~41nLIg98S}UfDd2j(#B)Dpy8zH{6`YidW zQYn>C)-W%Va$vJVyvbbBX69ic*AoMhUOI!gQpXtwU}(W-7lG6TD8&OFkJ?`qEcQM! zV)tq0M)Gg#7EBiLStrXC{u``;A&X|_x<(*oS zN}d_z>9h2$C1^#NkWMP_6&8#nq2G$Dg4ybX`;4}PVPzEJNn!X(-Hl{ z*H167H5yQX;-Kk|Y<=H}KUTW|U4Dg+2ZSwg5vYO;Ys!y0Tcb88(>EZ4+9=zG2O7bUqs?u)eZg#w?a+rAN)E{Ug?g8{S9-|F6 zIc*&gOsLBjNLZZo(^V{1vmDR-SclGcn#gr~Y=cq<2;Z}BNqY6XB=wLvgZE+Ol6ca3 znFgLZo8kM+-GV^|v!0U76X0wFw=7-2c{M=8lR%<&`KsHYNYqctZS;4NZ3_(qIyWe- zE*P0ISCC5vLW!D1X@6|U!?Iy}mdCbSG|OFMid?2bwl0cs_TpY1-w5ywv|aprg$=_( z8iScSGR*D~lsFF4Mu#Hid&GD3$A3Rt&=sv*4gzFI1E#)4%j#Piya|Z4@&l531QTQN z2U-R}^-DVSif#B?1-b0c+@oE$=sf^N5fXm{kJg-VC1iaq0*RyZU;49hT!wR5Fd%hD zNK310#kyr@3m%#uy4VIw3Q-2QpPK{>{mts7U6m5ECDNcf@m|(aE9J0iCz;~=19`%- zX^FvZA3*?jpCiq3D>o1WlowB;jTXmrYaXKgqRbZI*t$9s^Ge@{FGjaizelWCWFt1g z^eturkWq~_JvLLm!77^Fw^3qkzRGEHlQaQCgkVT6|1fG(C$Ni*2MRTHs)6*+argV3 zS)_N?-vq@^cM1Z<%0uWLyJmj~70wc=Uw*uqc?54d_?en$i`0&AiEgK6ztaot3%kC# zrH8p5bxOdnjSUNlLXt$@g6|IXo!5E7jyd2_{qDc+1=g<}m&T8`_`=;AqbJ{R9KY>3 zZX0T>woe2W=hqztbsgukx&JHQl4}inmEUnKGbd_pIf7RnDSd<*%Nunu=%!#QX1>3E zpSF+z`_gH5MWJ|YCsg%F>nzCB7IgZ?&+Dhe#KJ8_epkQ|AO57Bh9tmpOcXix7JMCgVG2> z>>zKrE;lj)(pKh!A4Z*m z+F}@WaATsD^VnSNuXFTqa05WHK#wtH4c1@Sf-!L5u4Kk07vxcJMLMe{t+#dm3c|A; zFMWPdDmoN;K17R-?`!_+Xcw&uiT4-sqx3u zB9rcc`bREhprdecsYE{@G|CC^vtVWWoPASKYHX4XmO`v1^orP?ei5E$64>N$#qR%4{VZ)$NilYIqSe*Qk_MOjBSBH9+-- znvv#C7Q3KfzYYKK%-kp!ge9ixG??bavjf|aKtA}{L2zb0HS`_NcxkoG4FjxT5Zb!P z9l+w^S6xN=dtlN?;JcoQQk0d@kLZGfV5b7-UZutPXuv4!yqM?Pb#~a@Z#De#R+q2; zDDJ~TaXG#-dufXzHcUPW(K>KF>rc|gb^T4+Rpj)yN3*auqV{RWm%};D=(y4bQOU9G z+%GL;ULAMvR#@!|@KzETHX8<{v zDk#eh=#S@e`*iC(yUmNrxww;6zUU{9`}3Wk-ZtnCKjw_@+YwfAquv+I={gyGz2$O_ zl2jATaBo^g1&nW8w=IhL_xS(a_YImXPIu%KK|g|9;c4Vr0PyyeCyWlx4exr20v!2w~Z_lu+X!()K<0=(b%<@(E3E0+bWgmgkuwrNI3ihBlyVQjxa`?VOz*oa%*Do985D8v{c9AIRr`>2x& z{S8E{H`{LC$<4-Afwzy{X`K5f$w|2pOqU z(lv)O=iKLx>)zMi`w}Ee%DJ|^<#6M3v2(Ueq}YS>SuEH9VOrWNG$ecr)TXcgOq1nH zt-`mD1UZcYgzH43LWQwfb2`**KDVOK(y!zoyh%3zo2FfbK!U2_}o z$zUlg?*bqVK7|ePzy{=RaY--^#1Kr>^y7_<-Ai;hhXZ=zg3jZAfJS_L@%RK8^1%%o zDRn>wyWIL=!Zwm)zKiSVM?tyi6vX3=jSs2i25|P78z5R`8Xq4wzFHju8|mJ>&P)?y zcKVoC8&zjaq5zjF(;YH3MKpamsX7#Y@Amr~ELet?rX~aq2#G147WpQ^*3#nPYIvC; z&whq0vZvbE3je*4(GHQ8Sjim7%Z_G=$7BTDShdd2{(h%GX7-`)?qP1HC*Sm3UA{rR z&HFdMdUs@^DzQTx(=yZ;UF&wr63q9_F?8d6-`-6KVq(g}dy4A6)SYXLL*rp(Wmp5Y z=ZdVjw-0vrBV5FR|7z65umdyh3^jo76#NbDn2R@;g6I?9w8h==fBZ=Z84&tu%o9ey z4rioMsU21_8QJmq6`F2RFq#;uCE9dqx^6i;w=$(n6Xg@Gqeq~#N9{L1I-^>o{u~e! zOnTk*>+lU>NWTdUC^b3`QuG2o?12$jPxDt;3t zSvpUj+2)ib;~?xLDShtUo@y5I5v&Fv8kMD@fefvNpPp1Jogd0D*lKT14Gak()}e@G zw3`stS+h*<+k+z+$w#Rn$ z*u(_Ql%7v>(=Qs9??B2|@Qda4Y_-KDHqTsoZu>uSu{q+62^x6r#Ss4YQS(pZvt~Rc z*CIqJ;{!ti#$po2F0rLyL)I7bmNuU6am^W>r~SQ%Nx|OgstZT+ELtg|{Pm`3rY$5h zwWf+jW`!JG%l#=nD&59rR&5v(^UpN8F?)Kh{6*n4^X#(%v`EG;OE?^iFoKFGzN&Ah z05~yo{jW!#224#%eziZORAxzPINvBddQ1f%Hs2Xa>fYW+4M3@>aX}RBy(h*hcW+7? zxZ@lWzSlDh{#uERsFDX8G1eFO?pp;i@*I^)%4>}tNtLvHj-9CsCFif{uWSllUSP1Z zUKUL;rza907m^bnoo~gI=zh7`t13;^z{q`EGnt0;}t-N?sJZzR_;VfXgF5b2H>-Vuj2*dPpx+)|{eXcir&-J7~? zGFJKrQoEP2zo9Ls!^)izEi*OC+YEEgyVwl{IRPY)!A5}ybZqJI=AG{!;Ickq4UYZ6 zwGm&SYGil;wymAxPBR|f7;7xVMBAsc2ZdhMkGb^@Z9HODJtAARw)9O*U=*M&^2fGo znkfzgYy(|K$u+kPw!=M&wQE`{tfeDZ3XGvsAeQBUHyJU>jB zdFioyZJ*&2AC4RG^G&|tSiLx^hhUH2gXdsNOUrRDn{7eT?&p_x<1FV(MrvfauO~qz zVsv3z=Cm8NVRi4AeQg-U7-=ZtIy>Cy0_GWsz4fic+~sUVbjZm?0$12hDU$Tc+*EfU zGOE;vO?Hx2#@>^^wzXE8>kraDG}`f%2k3!xM-nxZ+Ux;N-le^ z8zpk4Dx2C?1S*xBUDsIN?VUM_MtLepw=Q;Cf84-jdA=UYwrS)hn)SY?)@t;t?ev-B zQ9pBQS-}g4>}DK3-1MPBA=C;|TpouT|NH(+zj_QgW2+5UIf}{WDJP!sLf%VLJj|ml z+UsgNj=cDN^{pJ=ci0h^Pf4oN|_ZBmOVC7;GUtDV^?WyF$$jLb!JKjbq>Y3*AGw7%kRni@O(zc zAo+N}%_K%3DYbqqpp$_+lEz1+gu?YKWlV6na@fG#NNKPGxt3N1>C-n*^M3Nj9D#qKg1TSUh5OC=wwiwxlv0U^7898KY1-X4vE|lVGpLkwhi?IH= zqPBs8f1X3CGEN7rDa2INlXEMOKx8{w%fx%zWJAdso;pnn%+m;Da=YnL?J+hVZBF&~ z#WEb>-7ck?7;i`3@>eS>t&uv{S%2%%R4GV5e|uPX8iwnK*1V-1qO|!^w|KB3-K`Si(_C&{ zxrKJVX{<(uLOY<%A&2$F2(|2&w62dZYPfUPS6`gFm{`1<1UB9Ztqy`O*$cs);brzB>Pn>nA&u{h9Lzl02Q5oz2`vO; z!H8cxK3D58GJ*{RPE48i8H_;060aVWf;ALoHv4GiI;7qcj|QW}hd zB(cNHAvPux;W(_9M9l22AYtq@$S(k)D|nE9GU~lzcE8p+7Cj=cCWy;`>67ObiteBC z$kLNbk_M3RfTm}GdEt?Sii+jvc6bhC*d=GGc4mltLDPjMQZHl^x;{sHVcL^pn%&ru z_-*INK$rv9H#G_K;Y0TgkSCJT20uf>#$ZWLJ|A+Y1X+5pUruw!)KL;ylBZ1Wf~5)zm<%3tdO=` z^>Vx(6i{$zN3~#=>0ej&{)5pIzf4Byvb5vrs`0-4jK@~#Qz(Z2UI*%7&y3G-O_pno z+!vK->PHDz&yqh4Ew!mHX{kt2!W5x;8|N%uo>D2Tq2O7!$^Fd*YX9+}qi~)H3I3i@ z@0R}77Otx&b3Z@qp3!+-c9hyCL6r$vX6ra=?Q@1oxpWCnjH%BOGYK4OG0L~ST%>k( z?-6uvG?22WTvR9XY%0Eh52Zu{h|PQclu6Os@Cd9yW1{p9Y4c=8<&Wg26^>~8!e_I9 zCsd)_oW9W*2_Vh1aiOJtm`Cxg-Q{$S^P-Kt!)l>P1BZ+^{i5$nlIF=^^{1H5QAOWG z1Nz_!Bz%abBh1Kxr&F9)RdrFaYJCn?>4Az_idc`E+(ops2=Te@rSD*xw36S!h0Er) zwra(z9b4qBYVI~ox=}x~=>hUXy^SZOt+~Ch%>D4nnU%|ch=a~Vcd?_6U2&QXP`K-% zy20saYhlB17PaOp4uM^#k~xJL9o6)QHkI4j9Ok#Op_GZ zQ5MoyCEo*b6V2#Td;-Z7u0#nIC`d}n0?q%FTpB(oIDUIK6=b4z-tHJbg$~-@1{4;N z|NP^o$;?Dp|66E}b#&W~Hy=->v*k-62U}JOc@dS;z+GT;`zYOBAdX@ZlGOxC8Jl8b z{KAsarDb{HV5^=oGZXK_QAp<{kh6Cc4TPw;E58ruEY7|h{2q+G4X%KXeKtteX<+w+ zmVtxuA8rIBD+c6M@eUYhW{SsK2Z7F_?5RmE+U^n!Ifiwt5`RtoGVQRABol~nN@XV3 zS1b*H0Ofl-Q-$^BH|d=jcz&1ZX1vaL)|fy|R++@VMps`xf{D-uWd)4%EtH*Y8Nflg zT<52npzmHDv^l2Hl|w_!h?F^M@z^7)l%(u;mllWrYzXib@B-bvA(b#xBMYa7I&(VC z^HX_4_7MZ&6Pq@YWj1d82$#@#&nGgrFN2IMTgT^bq7^9==ajLDLV=KAa6ufC7Hx>w z+mne}z)vV}Mpe`Gr%}|@L*Vk?ABqa%9#I7YK?F%!?3EDL+*2=4^J9+PIrsuLf{ky% zbYuIiAN=h&^k+u#G7ch!$-kKT0amsg1bEo66hGzN+6>C#ene9rM~QHbFx^epM}e3+F8F|WTmNT zv%TrqJH4v5cC-8o-uW_dv1YX-?$1uv@J`M!bc5Y4BE6!oJ>o<<+2{7fiv+AzBDHL< z)PC4C-t26dKBHXcrj<*o#=9?blVjh!l0h9GTQwo+tvLuayW2Dgfsq6b%Ni{KzJgA$ z5+>#n$=g(5tp!O-pa=jaIrb|Ogy#lxf!L%D?WySZ0J@|yUH}5Ci1I#3oV6rgcq3p( zp#0fX^fX{~V0gG6@LLG&CN+Tiy0Pv8a_k9bLqQQctjSWn(hF_0#w|au-=E=F%$TOB(4@!~ZttVkV?O_+A!))hywTDGAspIE z)c`fOGaaNH+$N+ItWmrT2TfK`Y>p4txoL)>dXfCbP;WTq%pq7B`?OgQa!N ztKlDLSUTo-Y{86?g~!bJbN!AcFpV+C<~uxNzbAmzL3s;!+4J2S^%IDa+)qFiym|`c zqj%=&wfBr_cI)A(N-KC@4fGbL%MC3~^Of>`A@b6g!CgUDZ4P>_ODHO=5tIY|csm0i z4)^yIaUpJl&Tf&h_%-AS@H2RtT7QqJd|hxEE;Q_8CwsZ)<1K-=W#f5rh4#FZYqRZV z?9fo_w)$>f05c-)HsDLBnJvQ!Nax)#u(KMWZwoYZ0lWg{)jVd5H9(Cvvh175hTHLi z0*xcTJ;R*HoLWwJbGF;prv!n{!Nt6LqSm@wxv*=2cihY~wi--!=jQ&U-Zqv^;iVTe5Hz=d4DWuj4X#KqHmR@Yu2aQ$PHgIlU7$f-2~a1W36kSj0u8F zd<=&dU*e;VH#EY$eE!u!*`uq~)U;=4nA_Hv{?^_0?bW!`5{_3;I~i&S#}t*f?8hng z!iIp;AOSIgtl0my!Cdl#v@q#LDDTV#dL@& zC?_&ailh{6k^;}mbz!GH5faaLqn5;;pd;j&Cbvp5Ub{CpB5$sie^%xK`+)R5WHmGo z`F`~34E*b7du^`BENB7pVdyeE__79M`us{s*m&X3PRoLx>%X1;7$l7*!MZz|_3j9d z=dzR1v}{X?rCFJ&2iY90YoICt6$*5qoM)G!!R8Mga-Ow`ESl{;v~bKIeg0Bk*uH6i zdx?0dZgCm~#e_F-nj%1-mo}G#?HCEB*S1^tS%Y%YHZk~|iP}s+o|>S)`*NDcDX9z+ z>D6RCjamfbY(8qZ{N2AA@-lVAYsV2yW?OViphF93{3CfzOIR$@QkJ>H)`JPD^ur4d>?Ol<;`u~kwyo5PWe9V5lY=P25 zzp~*8^)(12v57pvAca@AaoxlSuS&{TbCgH?SWrx!BEQUiRs}eAbzabP*q$A;6Jc5y z1M0XpuY&al+lV*`e%$XUDSLy9XOYNagcg8jmNDIbf0wQ8^}-w6%Q*Zh@@W>xdr@pr z&*iX>@=D{Zyr?3+sRn75RsxeD%O_oq<-;zIc~x`Im?THSB%3N2Dx(_cb1+&+@|ay3 z$4N6$27VGnjh(9(Rtb-kJP>K(y|H~Xdwv_%SlBu9`fJL8i;WWQ;h=EELs zaiuj4w{aYNtVeB-F*7c0d}I&Mn?IwyY;J$?fGmVYF_veF#K`Cgv(&NgGO?d2?9~c{Cz6EQBc`w-tb_gZq zHaOGKxUuJ_#s@{ce+da=9*Ib2F8PHHpHqRqyYXHEzqrLKKMj#m%&_7N;wT(o4vOGU zg4Tl2%vf>ulG%_A%uGW3?qT3nJCJT>MY9;_=eYDE!{U(p4aXQ+34UrZfzwF94Uje z{#<C9eCuOOVOfkkaY=|z2K|RziRp&_!jL`(^8ttQ)DV{kw~6j zg}JFL-oF#Sw>#&W5wd?vkMF~qQ*wD9)>D34l@}zV6!(&zm zvv_NL?qHIw0u--vW%8A^7yGjjBsi;^UX<@z&Gz{A*tu9?mEasLxe__n;Z1jJXI!ks zA=omA&AsiH1;~jqssfi9Sk)Mq`DbT3f4noCiWm7jU77Wk$y%p?G}CCua@r$v^rUY~ zUxjA)vOEZ}NS&{x#300bmvts+Kkzy@MR{-8>{ar_8(bhziq}T5i;V#kaTee*G9E#$UUw#v@U= zGOGJ#EL^#4rdA1qe$j1Ut4T@1NZFZ))?M;1)Y=*Tcb@;G;Gj-KaN z@WGBlm%0@>*g zW`Ecz6xaBzUO-$Kv|CNx@B@Z-Z4A9Vm#*^-14zo|-Jd#INsq4pIOzAE_=q8fX03y2!k(aRqx8_FM-OW6lMJ)8nu;|PuNqz{uqjyC>gSZ}pdc7J*DUgQgxt&g_I!SGhJV6-T^&33x&2m*@)R+=>A zY!gKRidmn5hLeHDQWT`g_tB*LiEj!P(kw8?9|m<%iIivb$cZY$c2#Fx+V;9 z$rWE~*?6RMC7BfF0$BB6vw@4Vri8IrgQ1Wz%fcmxLme+di&rp7o%A0HfAGdvTQJe_ zM+_G-lm2)s?u=0M8#7$jyzvTUU2lB7JS(VBcH&|tVteGcj5d{kU0Xt?S^Gd2a$X6a zVhJLy7qECgMVI96l*gn7tkF`hUmu*oPBEc#~q(e+KY`@61^B6Z1^Po7STsS7jM z4!1nw!ZJ02rG7wc{}wnu7bhv#Rs5T_iWo>Tp1E2dFk-h*U)o!~?6*z8=N{_kfgn}l zdw;ROTAG~xj1`XkbJCBs-)n8**ZW$iQ?+4`Q^YLSQ4vp1L79y-D#U4RyYvp+Sr#WC zUhq<{A%Yw_cJHRejnWA&JT43=0-%eH93r;amcZf2+^Q7|7-n~mm$S|vW)jMZBz&Tl zD^)K0RI=|QTZ)3PQZ-aBz3-NZ$R24&fBh?~SZ@L^ak>=pIl@3!v?FN_O&(lM5j_A~ zxjHj?q?li|q1FYWUBQyeQM5Qmh~oAw3M^#hVk*^PUvt|oFh0Z~8}Slbf@paoA&H(a zHF==s8Mn^wOmnjp`Hg4T3k^pGTjZ6N%HdGF246@@y*LG9-VC+c6!m#5u^58yk z`XD702AIZaiwL&_HHt%6$8i^II(LpSNKG+|@`pjNP@Uv7|TjbjMAPW_T$69*Tz>MV-pqRY!v4;eaqMQ zEM0V;G|2Dlvy96vo^o?#`tLvnm&;sBR-Hhj)jI}oQv4JIWU8}-=WS|v=Mh!e9J1|s zN;u@6@(V{pC;4X`H;$ER51>uZQ2-!4nRiLMNkm$BmUub93kxNwX_O|rr_B}hc~Y91n#vN!80Y&sP^Qd4koTT5sdKH`G_Tftkh`}p zg=?+7;id+iTnvmUt1@})iku+ zYKHwLviC$t>5bhWd`~I$VnlB4K-t4np__`|=ej9b&_ne^C3C@YR)zo000B5JQN&C} zu#r1?iF|Fc*U@r$=4!{(K&IJ=Kc_)K3UC(cKg`&GKyH+e+BaU)UsyavZcI2VJd5&g z->9-jQGRW(pWnaF0_1leZ@2*$y6kof0!A};*WR&OCm=;s!T!5u0E@={n&!gmK9++} ze#VkqaDEURk;3OF@&7$KAglv`noXg<&6K%z7fk)rItf7QoJ#+j64#Xcum1q~@skV+ z%f-J+ALSMu|M5&r?rsAA{>&pu6&mEr|w4sCRA6uhVZ7 zilhJt8iP*~JC72QnpG*~{wc3et-&Gs*}!_-_tbqq&6JDgez5$6e;|K_JUre^A>jJj z?vo9aDXZC#9q;_z!`qexw2PlJbP>5V%1pq-Dg2;VXRI^`5-&Rg$r88%mM(#JViGR4 zj=6_fwm+JA);TVWhO>J|Cc73k-G#3szV`5tMatEEhEehj?L!6Wn+*k~57TajQt-y? zg8=t64fk~?AAj3g^D#*Q z)p{5y8aaqHc;%uKwRo25+7gmj-z8dOn7p$DYJXnOT-iixHnP2$Suvr05Bd&=j&jV%5c+X?<%BE_Dq4b~RkE+*s z5C4_~U!KOPZk)!~hsX+)&AXQp+lU~9t@rZBgw`lbUS8~@psZ!kT)8Xr8*ODaa; zt&^Z)$1tp8*}haS?~CHdwBhPIIb+o~AiIJSfsGX$o|QXTBprFof0deaa2w9hjGNK& z3DUDWnC^7HmOS`b?n^$#wax6~X*LF$Z|37V_?R*#u)(Ys`cbC|A#! zuP18?*`~`p%ryxvdm$*yuy`uLClpKOlR#WkYpXe@BJI)yw9*<;4118E%%7lu@`J$~*g_NSP6)nm96(xrY zeA-Wu?<23zRE&LEy3O-fkd~(2EnRMCRxz#}sUe1mVrE-k^{`l-jefcdy~sQy?m7^P zk_kA7-kC&aWP2O;v-YqSxOc===#`y+A|0Qioh{#s71~$w)#J1@L(OpDi+~N7=9Oul zO3ft+AA62Ke})B&m*)VD%gzyIFp;e|=$K+?n#0;6qW-0)HfC{!tC&E-9;%NJE8>3z>tw1oQwjG>+-8+c6T*zlq;9#M)TAtgsDweDylz^h!Cd%R;?aQ zJxG3iW<4+ME=-;KaE32e!812cqxSPE%+*Wf0PeE!-asFG*w1|VwY)>~pAqpb9ELg> z;3~pS6%tcvq%)pH8M;B8%85j&M5ZOGkqJK-BE3WJ}*|EYE;D z?jg$n6}&di&Hu>MUhvA^v!fPAL=5l_mmy>1$^m2W)N#8s76=P?| zxv&KI=W@f#zec-1Nhl%tl3R;1Oq={|$)?HIh=0nHtq5e|=AP2EX$+TaF(Lky7@X;r z7yjNwfdkR?X!|r~_BwwtY*69+Gyt2`WTDpvHh@u#9SySax=I&}qN%5RPD~CpqY_D(i9z)BoVR$Zy3^Cci@LVU$S*A)63ZsKMM@p9Rc>@_q z4-pw|)5~jfl9WD5J(Z&Eo-mv(`;AkU!y|GqWS_42N_@~!!Qny=cn^*xdbjyT60!Du zy{*mCvA?fFM3Ayf4fRT|y*TNyM;hV#+i;|+?1uF>!mWCe`BtYzBE_fHoIOb)c6Tl+ zRvPtcMjMduM#<)-?1YbROBY}4gCv(YsK zyK$Y+Gptufas3VtY9#{YD?B2xWTAuT3Jhj%a;aKy%*}m^W;{RW9;TYZkP%zJJmdV)FDNx)(QbHiHiKm7c5E>Yn(Z zJ3vyP62z1z4Gx~7lU+k0IK~$faQChb7~LS=o2Kj=osp*pw~`)JVfR zzg=!`K0Y#VKo~O8A5p3rVBm9oMJL%=KWG9YcLt{XxntzwkAAe6&fW$%#&Ha#O^Sfz z14HE|L)61)%^`5{+qT5$bj--a$W$)~D<~PVk>XQi`6gGwrN72c&GWclCrgRF%`)un z=gbsn=4s0ehIbXBw6<)u4NJv@-fq#mjs=;ZpL%8 zaYBPUF9X9YA_y5_c@i2+=bcIZ=jds$`R>cpjw_`eG!Fg7BhIzygxtJ&>S(HH>ai59 zz>|7A5lM1Njs?M&BK9vX-FWWqP*sQjCwD@@a1 zz7sup64m%2V0NB4JJFf@s(iY?qvCr3?+vFk%g#XT3X{R7^R;!rwzJ5uUM7hmC;nD+@c7PQVj>!0U+NT&nZ`drGo&ahQV4uOa!1Pz;j^QZoaDwxn>kNHIdjT&uG zL-fsdCpBtAa+JF7pdIuFY5xbbW1#XE+PSRxpU_SyC^zpfwBwPAsRsHdD)IoLA`P#q z6$0an%w$-(uMJ<*Jb9#X1hyEivs8*+0^e)8W`C^*76_7*3ga?0T3o2(BYZ+jTibY} zioMP5rOu#LAdxxY6^02tzS{NC0|+879^z8O^icX}mo#k96TVg@us~W?pVkGkfdH-X zAL{9T49UifL8@N5DQ+PXWKzKEylThN$rW9sG+ZNp;l~2o_l)8pVj{oi0%Dsm%|TPR zOz%RuE=e;(tKSWO%f%t`v2yS4;~qlJKLbKD96-=BiF1ycGW2c#s=?e0PK)^dL8I5o z-0U@F#~H;RjIVMr2LhZn55MpVDLK3tI|m;1HX6!G;8(;$YdAM1dMoPqH5hCIc!aGM zm^`)E&3HDz4%9qh060EgHe?nj4-$8E0oqT2Drw)1@pB+j0~=GIq3+5?VX10oY2yz6 zgwJ;MRNux#p$d2`Z-2_mH%w4Y z6&$E3OMvehanorR3$xiy_|vF0*UXu1fLM=dv1DLlud_El4_+poJHTpk>5Mx)g;gh3azHV zf-_744y44SF#W3YQr#sd6h*DMI77I&AbWvs5Y5gy>UUpzfj#`wudL=k`I&-1lC}1O zeQ8G(WM6d(i>3_j)cs!S$X}E9Pp=FU?-{zGmF~3u@vfF;B+a0F1`);PVZ>d%$ped! z==0VPZnoEfK)y>00zOm0+nABo zf4Go76fWAnvBf$pz*Ij>Kh8Bia#*uCV?w|_7r{d;4NsI){liq-JhXgN;h0l)M(@Yq za#|JM7wpvI3D#;lGyP@fpCh|UnZ`a;im~CI!{88GHwNd4OO+{#cn4V=IIvdAk0BFq zW;`hp5P-ZTHqPyIMK%Ly2H5XD2Yl;qvkN|-(?&MH6+HX~I9_nE#92=*SH}CdVGbJO ze+Dg~LDOd=m)iDOFZ3+Zu#w%TB`$5cjs=gW4N)#0p>iRyQTj=>@ed{QDakX8kZC!V z;AK7tq$U$9ep5uWa`&7x`;Ra^?;k#aCuN*FsB1?xxCVhnaFTb5#)~l;*kr<(6DGGy zG;^nw0Up8e^9f`k8QY*6`WO(J0#;|Mi6@z=34*QOXYIZ73ZmfKy{UEmtVvGl(Hf}L zr_jPeK>v$AK_?RXqyW9MahD>0`3Od08!AQG^kt;n;WmSp_sboM?>{rPN8i@JhpYWh zDDp^#;3$m!WAl$M!5jAU1<5io`VZ0W8rTGpnEGAkA^`dT0RnrodMOh$)6>8zHk8X%rz8rw&o zYf*$xadGQ2QL;4mG6{E_vDkZyaNSS40Yc>j{{W%nEf6a0k@n@i*bz?qH}u(&WP-uh z_{2*j)@a8Al-DCz6P(oI(Q=R~Ov5-t&UNtm9x0_NbU->%(^_3PF^6iPgJYxMe)T56 zm~gj0Cb>_MIGBi#XQfQ=!uGoo|7IrYN}`N}-_Ox=e^WeKFPY_1_Z`Qr@WR47WCo%H zj?44FY}JSAy}@h^ZFo zL4Kue%X<0gyv-Ld2nnXoScoDe1~=H!EpYHMUHBKE$zm1ik9y}n#P6D#n#y~S&3pNP zo%Fi-hvO37lZ@jTj^Wo?i4G5R7e0SX^)g+ow?j+DGg|tBg?71@{F((Y2U4nhJzZrU zo&4HOG1c?;2f=JR{_uA)&6L-BK^A1XXufMDuH6)=)j?%AO9{T)Je2vx9qU6QX==Ep~jpL_>nM!pc&E z(nER?u;RA@7p&oxL(^hw#`b*?M{?3|<1(+=x7VZGcodx0ymHdFmdZRq-|@IAx$VT( zsWI(LwK(@E;Oc4)3!v3mi*#C}NMT}(ga$P(r3iZ1Ji0=pytLqbCl*%fqqcW&&(wJV z5e|S#zSiERM)0p}-xCINL%=QR9~k^zj8_p>FD)(w0RiUY4zBRdyxKqzw#eg-mj|k9 z<3^CS-+Np9<3rV1jpkR^GyqJRue*|BV_Uz<|Ekviv=xi#!9w`i1w11obWm|nkz zTfgZ@mG99s*5x%3$hMsI{*D^B9a~)4Sj2;;<%A80x5|@b2o%0+LLgxn{cOXIot5)V zdukF8p-#s|$t$f0Md_$HICHaAsgPjILt-;@Yx+-+v1xuaWz zP*gCF@{da>={iWBWhq>WFO5moM-f{tXO3r%JemK+?o-d&MB(=kmZ}@#A$W$-Wz;MbsqDSk*fU{P0Od0N`)5-3qiRo;T}?T-4y><|9-QEEBEXXgCAGWTrB;uU;^du*7I}vXf(mh_` zYL1$SNR*p5ir*L?kVR~+%iE5NBA|R(?>I@CimMIPX`O7n627N+@$Q6G>x4CXQ&qT8 zMk*8#d#|XgZ{4PfB8mgKj==VVy+b$9WZnc+PyX0;6S6|jQ&}m;f9}k0so_cB(Ua|R z$c&h}!pulLPAiCn-w{uPpF9v1ELG&H1qg*NI2>Fn)KF`cS$YelI0TG))CG4;fs9uq z>!&HdzzDaSAHO^30WF2vO@ioYv@c-dWNkmYzEY_Fkr&1|RVty_?#iIA%$1OA@HdoC z>b5F`ZaGfNzQ9-1+?4NcL#A(Xf}e0>Ggo_c53~rf^Hu3I#o}Ss!YT#bNe@;qksxK; z49$Pe%;%P|O$YZu-we{+bQZQ9r66nL8Jj&qd>1BPhxYEy zB%yAzA|BuMTFR!C;q-hT+PdsW=A$Q)b zCn)*A$<+G~ZQ%BH(i0bz@$X4;@!GAGwgL^g6GGwGPW?;U>OI+8Gx5S5hDboX~RYbt}TaBy9`iG--X4Mt)r9HNnQ2iW`Cv z$#M)MNyDTUFz(kcl~#3JRs9^e*tdI%9Rc^JKRK2D`Q1+jjaP^!(zbODuzcj7cBRd% z?EGFjytf_ce1%N;C)lrqCDWbYkOBp(7V{QtV+uvVIC?ZmYCA}3xBqso+x2SuSMU7L zwl_fWrJNbS2hoGu;W0b-romkYlXG8v*#t8~9iHO=fTt3auJ4%c78k*?o@$Rm$@o=O zvI<_SwKN|jJJwLWO+-_hU09UpePde{ExnbOxi@Y#Gg>_jdsN76s_{C*Wbur_%Tu5h zOLBKw*GtgjZCx*1_)X5iJ3MzUApHo*q2q&j>7J|(>It#Hc>uNUj)uyb?(4jsTgYwn zZJ|4u=m}(IB@@{_?CE(1U4f%0EGrTJyooh^v?soJs-WF8G{2tndFK%ScI{dq7jluV^xVUUL@i=ApdBcDi@K2u;5C9$?ZzR$W(bNX-+dvt3>X}nDV#kg7V z8$@tCuyc9b3d#gUo)d51cUh4vNf^qs#=myJ&wm79T|gk4 z0JYaZ2G5aeP=1p|R;D!=%45&t*V9OaoRZ=vGl+l{B>Lf~KOgw9_r{K6;a<*ht7pSn zqT_<2ud)iu=Tp0)D_x0YhOzza7;9)sezGO9j4h^xR(Fe#AbpBnn76m!6w&B|q-Gn{ zisCQrdfeY*Ck>0o?6XC2=>iZ0bT#$2#c&HAViIi-ayEENtMRxZ=3!W{%~T* z{BWNT0SNW-jGg)*$m29q0V7^(Ge9bxqE@s)!pmYKpXYFPkZcetlCru>5v!{-UIc`d zkt*Y*kYis`PwXeEyL}42V6Ia4d=X}1QKnjZlD>(6@s+`YrA&-Dp95~yJTNPPEn7`b zyx)o~*ET1O1HrK5)b3N#k$$^ScZAq4eYWL&@l&|8|7KHJjd9EYW$xHnb=!f~#yORy zzR-;JZ*4`*RLfB>Ax&}iLXM~g-Y#LxTkMLd;)nJ*NV{Uqy1?)pGe`ioT=~J2cn7s2 z>gTg$f)9D)Lsuei*@vy~OE*<|}m4l0Y8@X?Q z408qu+ir-ExX*tF)kZo~F0`z3OsB3Mx*o0`v6jJ) zm<+tUyd2g?p3f6eE(qzRcb)OncV4-8>?HD;`xMQSEK0{aT%)8_=YHZJhS}-Hk!$BZ ztQ*@LkJ5+ z83FH7s~v>cKF)J;nb3MbWE)hfoaD^z9A>sA>TRYILc~nUQM#a;!3c639ez`3;1I59qO# zVq8n`_<*1(4X<+Y(1PxjZkKbL`=sm4Qdm32kGI`ucLAd(6%@T+w+s8zkb_vE7Db<# zBcPPJe?y3c?Q@t|BU)&p7r%Hb;}5+j5GT zW@AG{LD3&Rq^?ZeW=S)b|!zclcS(5x+QVaD2>%q55ChMNp|g6wLMe$L)H~O&b7TMo&?OdYY6Z$ z5uZ(31lk)6u+sQI8!c--iZYL`qxk~xL8e)i4z$DF3vYS?r_M-nGCF*jc)jiixRDo8 zZx)FX7L|S-1o%x~1;m|)`?j`#kjwvHuEvw3*rKHwdfG+DbLCfgQtf}nh&LZ2yPtA) zv5KS#V)ctz3lfkuZ1(z0W`H``X9Qkp@qhYPBCn8w24;@`CQS>FM6|}t;T(FurR(xA zAG|s8P4sNw?f=Q&-#fJdG|1hd>4`qSQH8Hkj$KsSow_5NFoJ9FOlTXih4|0y$O4?1 z>~Uw5nr;PMaGuuSTC_9|xU=#Q!=ImgorV>sbACU!Q6c)ymJ_(Fg*G^2YIe3jh?EN= zHISp*OJix%9r&kC!0;^(;UvxP`(%l04Sc#(L`%l(Xq$8P7FFXB{>$FEFQQW%1M`x1 z!kNwaI6sN)xSm`bJ_;4bz5*yb#^bp%4vTH~1+yIpI?36et*|$SmG(ZP%Ivc5XR7VJ ztyLAeg!!JYFXhh9K%?#~Hc3u=f2KJtE|s)YjU~G4#2m6$*|;C#7eA0_)5aMnIJd@w zo|C|}0Fjd! z{=DbG;GG|XX8zv-Os|iKH&)}eut!FA1DyaQQnr?^KH|-Ga{z}lFw0oWCh6e_%^TR* zss?#AJi96s1-duDmnc#qUjN&hzB8n`iEjofUM=3<*Mx^cAjav4FX1fL#mIJeFMna* z@;Lq4lIOoWh{qZl85B_+%TeuUf+z`gxC3?pt{>p7Lly+Z{}~y<%V1rZW_h$Dr}X$Y z=|K|}x&LNQ6!;#z6>PfKS?pyh%rG3&u~v<`5MViMDA`B1lO(2`o;S^P@^>5szxoPb zO2&XkG!xd&9IKr1C)XNXa6stqIU?8p(s+e3fZi`wH|Ew28qWJ&tLh}%u;s`+(hwX- zWC0~T0T^DRJ2A#UJ&D4%f-NyhV+H-i&GCw)cazi2bB!+DcWD@ZBitcyQSY*DV~?S0 z<13eEL=g4HzMCd_$tD=?Sztqe!N2mU7yh>J0|NN@>C)U`%5F`R|=la zyLrZra`9DFb&*L@w9zQ@uXYUAUrb@|lzG-(nIDj%igZ2)N<`*IRc78RF33pb_{2{` zm-kDwIYye2AJ}eOqz-|rXmR7ybhgr(aXVhB4Sc4)j$!AV_s2@V zhywV;kAadWOdzK|z-Wtmu1EH1xTJ>w`Z^{T%&2UIv2v5HjYUBn?~G z6aJ0_Gd89+jw3we#923RdC=#Pn2i_F{|-s1pAVW}`3FB)2*7eV{5eE2FQt59H;5zf zr}%N)_;Yeu#0u`c+xFn13v^fEq87N|H>&$BplGTfkRu0+VO}L22gufAqqXCF6%jvu zF6KA0v*?$^@rn%ZGK#W-U<}ChuabUE8=vA|ofI(zmqn^y8g;yd;X7=4y@uqTUQbee z@*b17b*yN;Zi(@(w{((3rJsX^EZrv|eHsrGSe?-uq`b9W0nF$-7a7ojUeMyoj+F<6MWgx9>;ELNjIWL z@xU5>CB~MyGc{xFz;4tceF%1xag&|robY?6f4Ecf&ddDN?{e*WHUdj2<}}L4FW-Du)8 zFw=UkJD%vaHp`^njT>2N8!p(q3Z`Gg&MmsbhnUJ{j0uTNEq6}JRXpnI=Hl~&f*g>9 z)7X!+GSz>EtZ%gA6HW$hW4~m&3s1Lox$jMfmzzCEQ^jQ=M$(>i+#X!xP4^}I*8Hdd ze-(gng5;W|Xm;+BMqe<@sKkA8u7h%PqLTfOx*OYT4hm$kFKRz14{blvZapkQm?`r#Xh(f#H(xgU*{ygGoe>wq2DaBIj$27=wSL-GN zCAilJe0+z{uls=i{k^;pWk)&TBT}+Bl&ezxNH8Nav;1dcVIy`S)w+sQMvaN(tnl3G z1y8sKv^b9lnbtWZ5>R#U_Vlao9tM7WY*heAwEv8Bj?SkszwAdve<-!c1O2q749RbazhYzEIVOGO>t>j9`C=F!exK& zX2#af--rE72k#XAb{*%h2eJWqaf;t>jKI)vPOjHUJeGu8#hSfSAipS&j78hrq6w@1 z<1c7GbsI_k-NS5qQ{;Sq@coY%2GOoPbc7v{yQ9=B6sa?nTm9Y0<$%rq=&^g)KYRxo z4d6S1TJi#S|L^#Y3IN|RO2K!iB+USRqZS-7`D-}jxkDG|AuQayQwzf$Y`sg+~W+{4RSJ%g|3C zUz!DE_6|MRZe49=cR2fADeOcnR8W~PFd|$iKe;6n#l?w^qbd7pWuc*cblY&p;Xz+z zUbig0ckHW7i9DZof4+Qt@3(=M2GAa_jbj>oZa4G8KI+H< zClssV@m07=#_Y~o?3IJ!G+1XGre~DTN^Mx!Zv5+?*!%Q;>vsC%#ET`{J4>snmM~kx zhb7tLqJl$c_5vgLREfErD4*B@wGNHX#hh=GSfQpkzXwZ7GeybP`^3b@_2S^e52P%e zXm7$skuwK&glF>>(@_K{EUPBzlSWM3NZ~E%*%R;AS*3yp9ko|1qw4Ic-4eIrqJ!du z0!zmHaDEe|&n7m_fK*`4SRvT{z4nONiw$8OO>?w{!|s%p-Sl@#0J-osY3B4Rd@ zk)c?Ulps`YQpJ%(*4Vj6-H^2&8VxXrF3Tz+Y&`+HScsGQYBH zl-=G%gm%ljO{Na%?5R<5pl_~CR2$>M?b8hDI@5gQ&@1Bj{tj9;+ypRx~ng$hp0pTSsM z#5j&SLJ+w%c61IWdS+&TO0dfv`~#F1a?~AIO6_`e|CzR*JW4|B$pg>zV>~M$HKnUh zmzORnt5@q8a+(?5_)~TYtCeYXtE@5666Ls9Q&(g~@Tp_Cpjbb{Y!ay!y`Lq%rYbuR zgvQJAXbbgQG#MJ@d{45fhUu0yO<>Lf7;DM@FJ=nX%wqhwFy5uXAmsR_RGm-Ut9)AA zF}XG@{nX?QyjMxx85iMs3e_zyIcS%xR2(bV z)7faNG|9fs6?wBxls;g6m|^aUnsi+*K)4WgGKweu0xC;w702W3oNcT>i@m>=6QIWG z2Q0GPPyo6W^nz>Fmb@#&KOm`nO_H0cBx26tV45?w1Q!!}(#ZAg1NdU8<`xQf~hP1%+$hRsIMje6cq)UI-Pu40N6@i#GvEr8S?VAC+6hjZ(=GS}(X|6mv`oK8&7T`aaBdWtv zU4#;fcVqf(J}*R>Lqs1Lugrr(Lf61H_eur8nh;~v#D`$sgJPTyzC#il0U)>*4HC>VX`Qc_KCg|M4xxbtupTka$*`XPPVdJP@v zrC)~gSwdPk!&;|2P}!N0=QtzqTzxW{+~>P|pY6HJOIys@D+2VL0Tm=(9!uAzqHUg zI92yRqb;w>;y#W0HJ1#f!{tJklg~^d=SOHiCsPkGHexIsX_MqqBD^N~d_<{tQOdbZ zZCiC`uPje;0HT6PD+-ue8gKt_#>KR}eM_nR^d5g(`3uqEjZgcP)-yyKC|t);Bzr|M z?=)=fs2}gae;%6r<@fMzWafy4_Ki`On|klQLXHo=Q>v}5Et{!P8@;$C`D$3uN~%Xk zr2hH&M|>?eAcO<^p~SGx1ff5Jhm=eSUi7gXnUHV}>j+|xZw*NXNHda_bwezEFUuaR zhStyAG3%(bv{x(4$J=U~T*Ib5?76VNqqlV+(4*Lw3=t7$^q&NO zqdBv1i|WCXI<1a7T2X!&HjDkSqpn@s_9yyGgVx$qJ|a(o%icQnWjB3qp67$s3a<9AYiS*;qv!u$cgu<38ev26U7?Ex>TI@M=h-*v*m_Zzxom(2(g_N{kNbXG| ziL-5Ce-T5Bi{hq7BeQu!FKd~%)i>k?pyh0sX(;^nv5tgfMHOP}X@fN? zu+HP1S|YE?{@(eBOI;U6k2|k4vXQM!s&AGhe)zB;Lq4A?va(a9!i|OPJev z7p;t+uRnAgFv|BivAirs&^Hb<{Zxz-BW#t?1}j8WP4PG7uq_I_>XK87uVvb3xU@u> zN`5DuSI%rd3W~GfDV7)Zr|zA%;}(t{@ZXs$iQJVW90Oicy`2X@FMIi!gqQ7#wrS>@ zsa8t~84SNiFFkCmFZZ%(vjH3<`&wo{INhP zy1^lWEG~4dnwf{db}|=)tq)!jP~Gi;^jmBaG?D_0YP_RjT|Pvlbm)_LdZ1-B!oh`< zFDIHO!*`0EbYMZx#P(_~-8LWzyx1#+WV+qi*HTf4AF8W|vEx@oxAt7vDgEfqUAwHg>i($Y^my-xg|qqT0MCOeCb{1xy8AxeuR@H z=4xyqVej!hTm5nknSAq;v)pzEG9s+`>7j$Olb5e+)Hye;6lads;x}SV=rS$|D)!cT zS!OtDo*JTh85(-{IhiMA11`3%vfJyX5NXFmB0zz{g{UeHQqz=#N+XB%doLbZ9%!(6 zO+}vT_#%!E*d4v3ZGJio)ts*jRuRn%ZW-lc2ZAJVEx81!mv7X8Rg&%qv%hQrDKT&E z39LckvY81?{FWZtt*5Y`p*#85ax9dW=ersuHQZ+*=^LT;l+v=nV586AM=oxUm#<-+`d5q+_H`w5C+kl{P_;+)ncEr=}Z zy?q*6w2J!ch|rpu<#^&;0ysx@IVMyiAjYO!nZ;kW>wL7U|HyM6i%okL?FY=k%3K-x zmwS5TB<61JQukBs1W+4LYp0yG$l#1oMTz(3pp}!ol2e&gGjJDLu+PMJ;*-$@R> zQAV4y#81m7%e}+%-mIQLq z&M$uQL|aL#ybXM3$g==fw|t=tIfpd5 zhOeGtkH^BLw6w(sSWRe?Vgd_Yu=Dn_*g5VryI9T_C8Cay_8BA$6Yt6l`P{p8J0Dzi zEjr&S4UD4ynfp|l2*N}UGmhlhaF|qw4&{%2Rv$4O-IP)hyyIa)*LZe7u6s+8#N|6U z+0TqiLr=@NsT@WnA)xK#D$}Ei=)c;mmFx_Ba>$(JeLt>3K{m)-_-NmDCmBc%C z2c5kfm}&Nlk!?I?Q_#usQu2nxw78dQe}h?#`1Q$D?;)yPW>twVrd?&eHBj2-&T&eQqE60G@@ZDjor=x_v2>HwXa=I_4uhkZqp3NNdABqT+38lEDCwIsIF!_K0Iwasj}`m*-|44v!0Di za&54&t|54%sTRAB?)0k)B<%%g*ru4@G+~GWfu&iu7A`@8q#=CDz;6EVPt@8u=~2(N zn`ep!B+oGX$pbE2q8;DyxkrZ9Qn#pz!@@CbW`lCsX+v!eg%*qy>niJ6Oc21%SFS7h zkm}!dXSV2)2i&}nRXZFjyQp^T-mLTRN|y7swFTY-5Hc$U+pwP&j#ua= zk5T|^Ot>@5JgLUz5V-Pt_uhjW4)DyG)%ukho0eR;#Z#3Wv2V3na{VQ>-dWVdalyLz zodSMTIXf}{U5thMXd6w73_sjEl^f8;+G3v7+r+4umil#oF()_mtBu@p&O5Df_hOr3 z4XY*ENVm)XdLaX6xnTTp5q@)hL>p2X|2L**n?BqshSaFl^=}bZ_Z3>vN9)|Y9;%>` zJif_p`wE5rRI&gb!Wc_X{q+L_FB)ert>&|5!)bHPBV8x1ZmN*EmEd`Y`TAF^Gm|qq zhMUS#GK5L+coMyvJCGprMvsFhSwBd^PxsHl_w zPXPcnQ5$Tos&pHmMNNErwT_Xxp_G~B=kNazNa?vzR^hk?AXKI&%&ZV71sX-C5IQTXW7&TvkTSXte=yIzOdvBUN)N1}RUp*|Ryp}rG~WU5+I(H@ z%%@m$Aem$kwwO)AA_SC#`bk=?mJc>9oPreO>J701lwt??z4I2p+WdRGd>j{4eBZLo z!BV+tBzGJG*%2S~Opxk%H4LnzmMv4oFtT@M60Pf>t%k*_cDOK~kiW<3*>uJAS7&Ys z#({m_{dbHxpWNc>nmVm&IiHr9Ib!*N;+uA~_YX2CraiKf_zpnpw4_@vruT5UYmZ>5 zZcp>PYgH9*%AQ<%h5yhgx-fe4zwkk`4BrxC3-5-FvMYOTN<`-zxbY|S)Y?CuC*7RE z%+N10heYMJ`vxPJX}$Eu%td+okO$8P#K*epPH6i+LVe-y$DaN-IN=oka&s3`RA%ij z@OqWwjY6m^5Cd5@BR(&60PpB zI`N8%?sr>Lw;Cj{8{FbJ>bJ3y1ty{1;OgSYwYq7rRoZdhT)Nhy(1Mxw06pO5p8eJQ z7AX!UA0XGj`JrRhuaE+j?4buYKfEP3^r|FqGwv#rg4eHT-c>W89W>!w1J5Vj-0{%fnxq9(Dhk+!0+g+Qt{`)B^RYG? zM5K)Zg#@uDu;EYP1)Q$E1l|7qb$BPvIchwL+%H3mr)wOec{WY%y~8~Zbe@ePXjaap z0mUvcExNw}DBfz*+;`#0#RRdI2kx?sKKb^T?2QgoHl)q*{1Dhhw`Mt2YWyTw03hqU zjoAS2?mbk=#gUwwZFl^dRqoG^9L;@aQhz9f(?wiLoBprpnN7G=HiVQAG`=--_X4my z%Rc0ZgAxa5l;~SG`_*U^=vSGd)9F7-5P-rnBLOZ8&(6~>@*%eBPO&RY+}CKHn=~GH zv(wB=R`Z-XH6C-oc1PgC-)_tLU)<=5cRc78RcGT~Ghp14ce*4^08j#8GN(Qoh{Jb+ z4OAv|Xi8zEgg{BYMk-7szVafsy4$sve6KV?BubauHIUz zbJba_{z})slIWa{Q#3d6$TFm&fL8RRPq$s~jt<^8_~ZEs8V3eTjsTfQdcABG6(*lA zgOU6LcL#+P!d1hg=89>}E=AwdEAEy*Tg>lyYXE7|K3KsgkYXxU+*waQUckWk9mzJX zL>$#GW5;aG_ZwE;aidPd2NRl?M(@z*3x%kQiZf~ATR-K(J+OEV@jp&dz_cIiI#?BUy*LT6CAm4~xjf2Kx zS#~&alfIL!tDKojW?52f#P@tYSnYyV+)o>rG!hi?p|mr`k7}{k8}O$<>EAOkX|lyE z6x^Sn6e%tC*PG;;-oNqT_SwD`Jy+HmQGfdA&3Uk!sSi4Lw(R@%9E|2rY zqI`ebGx_t+eg*ZRUvhwnEz!*cxvz~ujo;<3l&y%~CxvSJw>f_jd-xEe{+*hl>V~r3HNas8I#j2x&}}u77lGfC13&ACpe05!h?&lMc7xL?W_RyI*+uwKwVY+=xZ{7v z%8DL}Y}k_60E(~5BDK%E#GGL$ln_s19eR9Y8n9owk(0F1x_27AW|7ar<5HB_}{# z4Lq;I#kQ}y020qKfyiG zZI#seKnjR;Zb<;6vK^N<$Rdd(%g{tjcd*RSM)Hh)RDTi`34?gr=M*|f*(Q){&jj-b zNt{y9ipsk?nB1CG!nQ0>A!*Axi_n;?m0*bg=af6rSxiErt*)2g99?4O&Nr~?AxXdB zyxjWS)-*xIeR(stA{(h2P@WZMwbF-$St|HY>e|Zdj7fA|W;L$Z(|GaOv}YXjk0f=m z?k`UDQw;`raX)!?u3K4s5t7>kbiB61_euSg!WdILpUEZI#4K&$h}Us{ax7tbo^kAG<8 zqxW5}IhUWrGt2L({+iv0nk6FT%naKlUZo&|4^LIu79}Hc$OE$N_B8HvyNpY8oxCnz z1YR(>xUABl;N6-Pg`-CfVO~~e>+`uHE=8(XTw4r`8H*+N1bH%fvItj>#p7iMA$p#n zX=~p`GkU!GH{Ytr{sa8IbWsa##w4<#ALM91BJBZf#EESCQ}g_FGJ^41YUh0B)+qME zO=BmuC3hI=Zu0fNWx?ki?1*JqJ5hl^X)x^#>6{Y9qDU!)ybh^_C+~*t-nCoQoQxSp zUFeRsq4yd{5Mmlgc*d&9+}sP(qjISixUfz8j=qHLS-M?h-WP6G8s$q?Wk7jIeT^M2 zjkLh$s;B)zZVJey^Ej`@(@VourSWE-#VJKR{y(*tWh(l)EPhVtHcXM?*FOKq2nydx zW*ZX=550@mJpZ0nN&m-#9IZvG$TttJeZ+UljN#4KhU65To@JUE2CV-8f5JKZg`r75 z{goNJBdS3cOaNCOrHYs70`<$I)>F7HPk|hcsi_HpZbLaIlksq^L_&&tcF=G^`Qq_G zDRH~J6uUF>uW`pL+NgBvUPDBxQ=Y@Q44=!~lHy)>MMYmx({R$?+N`*Jf#ykgaeDE} zRjaD-*8_@-;AhsbP8waPW1WQ2m|u#;OE(OG zI_nhK=%o9Q1V;7s_>tZH6{Y{PD ze)_w&)Wg$+jdWl<08P$oslXaGH{dX$bK^+(`SYN_gUE&L1f2$e|5DvHI+Vn^BJenw zL5Af2?U~VQ3L)y%my-3W+_XekM;U2P^9*u##S8_*_pbHYkbn{4#BP zw;g%_1C(Dr_n{ZS4H-#>a)EZzecU!wQe1aYi1uBK_h+3~{S6U;N7En8Z>;l0YVF)N zbF-6MQw>kZJ2!W=5xjId;(AJg?)j9^be3+t z0Z;u*>6hcz%(JNrg6nrlm?C0a(1o2ztREv^yt4Jjsro^6YZCc$PNs~^uxK1S zlei~OCLB_ulk3C~983T59G)79)|lm5)BkLDDRnxaC$wmObW{vua@aGMUd{4pFLX5% z2d|~4=;;Q?$Vl)PEbWxPIp&}osLro zgE>%&{B-Z$1~Qs_9XVWrVV0dFW1bHpGp*D8j7h5bAsvac<1Ge0T@!pT*fYM9Qo*^1 z@{N7i)B>|`e2H_{LyCSWi1|^IZzTsQa;5oCACD?i1Mb>*xbv_7*IMtmf3@BTBL9zC z?<<`GPo#gVt6%ia*eO*1V)4a*xk0f#qKxY$dqPWytS8q`GyFRayA$?>{r^zodf}e= zRx*MYTa_lLAqwh}1JrN1a)Q`XtI~@z{C}d4GXTDg;#`>ggklX4+r;~^=R#o1DP4=@ zl=P*MTQ>!N@a*I}Z!PA&>F#|v)1{~CSnZomo>+41AL1ao$#Ary7%mAnuq{n=Bf`dv z`nErnnYv4iAENFYD`j@MVm@%{{@ddl6xXV?-n+b_Z#L@4b{oh-$|~qmg5Occy|M4& zLNh7My1D_ucG7E?U-88+)G^%|%i3Q&Ii%yg2Q~GCzvfu0V8bYNB$l7tR=;OATDX6D zihjB?Fw<^0JnG-Ua3B;*H!8JulwnG{(xWEqe!1d5RGrEvBT6dof(a>zy%$XEt?sHS zkKNI2CeoM4r?agc13u21n{6E%rfzIgSF?t$|B*UZ-nnt4p`v1|Vl7n*L4W*1TO4CM zUk*J6WC(CLt=tqIt&3_7DYX(smazR!=akmg*Jow(NM!y3RFPb0yyu(SEGzc|FT=b( z`IlaEd9AYW=BrEZsHC$46`X(yy2QmoYQpV*Ae$@yKsG<-wLfQk37WAew+cF2hA-_;9TiT+F!}#Pp+cGU2+o7)x|?giFCQSCH*t`8CH9T(Jf}n``?EC+uz{I;?rvk zWvKKx1CmmO*;r#;Y$iYrl+Wf$(GmRZqp~H0sL)?ravZz8qVs;h~ ztBW$%Vj_d=%;t~OHUmoM3!vVCQMwWExGLHu5OJk%UbKpYs<<2nuWu36m~Wku zEqerqcr`(E`!b=A7hV(%>E3}V1=B_)>6UJ>AQzxE6|+UW_h24v2M%(+%dZIu*WNr> z7Na~sml;L?=-|y)F$e3@x+uWc_wM0ywT`r zEQJLb(cjh+;B)*DYZm!$x!y2!Ky5k*MF~4 zZ{7rHAj)t5U+2>00?#+^7zBQ zg$$Vbo&P`i+%6JS`Bta=jtv^xf4%)a^0xqua;opooL*)G1rR0vjSld}(>GdI%sw_< zx%$TK!}Gg07*k49nTr?yyP(dV?R}T_IQ#m8ajoG+W-PKBj|Ln_3;;O6F-<}7gPLsL zr!h~hD73GZ!LFoh9kPeC%jE8tC3<+0a2wP9aJ@3VqgX2S)~nBnE);-v;_v@Cto9?1 zxBikO3y}Aji5b7rgT|sVJa~T0>BnBnu>IE;78nresMDpTsXCv$M<%6-#egSFm;5_; zEJUVmy~IRcoRdt^`x3Wsi1KYS%?jyw1{UEYr0FGhPeA#d)kMcr9XPg z26UhrO9i@IP`bJ;<%U@$@u^f%s(|!vFaZIQF{Qs3T9oxSdt zO8Z^5h74=TZT`(L2V;!xctB501!cR+zZ!(%^cyx5v^o{lcJA(rYPD$GRbf>&I$!31 z`0`sX+a4H5P<_8%Aok2Af{OW!fbHFLRchf^?!HYA%pvR@4V8vXl&+Q$%3v*|6~Y1R zt(2kPs{g8ipC9o=&hTOzw1}XV_bAhuN+hYFK^l6r)gRY!><-5*DsRQ>KDPsbIDsjh=)}(8wVe4tkzqG+vh?)hYE~`)EeIO1XD+VKsGzPN3(QsOC_+~x?DeN($5K6 zDkV@qZRleA;aGKxZrI_7K-1oEobqihY7ofi`}?RM2JDTS98u8Kmf>m-N4E7k=TB`x zAW)K?OS%snffL30(}Aq_yTDz6bMN(azIp=}QkHIRAj-N(1qyc_k^19{5(Qn#K2HFm zboT1N6^soVuz5|lTCUwi;wj3pMUG%8%@nlJGFdgXc5gw#}W_1CAZ1@PD z{h8^B7j63dA?Nfi{W8_R&)=0Yt={05eZ*azhl^b!{^D}~@PTee#JUqXiT6dcahY$d z?DA)_Ps$rNGy3P?#gyJN(|$vAoG^m=#oBU#KMA*8@v9{oFC^aj+Tf2|=dbv8bL{6;mAHdhELVrx zzciDp+lQIs{ya}bY!XSh9O~5f9)qXDL&7`INKOIFg-xFSV+r!=u_g zIHA%*4!6RI10~?9^=|S^u>yLIR6C zbn`MixaQR_pB(AK+HMlsP6i+5Vo~#)y7Z^fhpY z>$}a=gqLc=c!u0Mv}G1fsH|h`qOKA*ND41h#nz??qRA&Fw!Uhn~#KnYQ4X_Q04!TSZK*WMbBQ z{}`F4`h6G=S&gk?B)h_nbJpA^k6?j1JMggiewUHQoV!ib4-GcTgjw#uH%ehzA3F>= z?kuIn>6_ zMMq+z+z7)(g!NAMB4xbN>%_E3b=f9YtSa<7;rRhVd|OWL^hNdgJDJ7y!kuq+_*&X( zs+>|4e00ZM-|~MtJ^8bqodyKDlk$f|ehoa9E~TuiG2y({$3Ebkg_H)ZkxF=sW4q;o z*kq-wqJ?aek{@7};^x5+c%|9R={EU*Zo=Fe76yHT!ENUU;TKhYSMeqN8AO{p4s)`C zKovBMgtN2MARFo1A;mc-zxc3bn6KLsTnKCoc!Lo>yE={u1t+)k?%afS(ufji;d^Cc z*79My-Y*iYaJL`8QQ2NbnlLokjL**@tA zZ|fi~uPr=$j(sT#-2CPQ4iOG{he<_P zy~^L%AC{;dA(D4PTC3KGG9j`<<7>pOKdb&nVO&asd^@r-znYj^`MW;>AgTT4Lyyo_ zaLU%)nuo8-)_gZSxv{kgh@r$O%)gStB4)@05xPsF$OX8J&83;H;Df-4=JF9zlD@Ge z+o?~as^5Vyki$~EZb$)Q_or)J{&(KJrv7Fi{NLQD5sFFKtskH>>IT{q5vEDgdk*Tv z{Q+uFlKo5J6-nDdV}P_DFK1r8 z5k9fvKRO@xD>bC)>cl4<&9h(TKC*}U$>XFaEHR5MICq#?`+Cjl>aw6jaLbqp{Lt!Y zle94!6-?A{Y!+VgI51>v%|@sXR}=sKw>dvHW`y5K(}ookzVM^0h_}Y<4^#WE%uu^& z`{NnTB@6Z$?Blz@;uT3R?o zf;^cn2hf2!yah|>1_lfXk=c%{tuN*yv%&jelpljYLKD|ZCh7y*h~Q%J1w-xR6*=FP zDGwkQ*z|Hhy&p^SWAyY|)zryhKG+Lsb0X~_PND^Y2ELZK5?Z`0K2d|7JfBHifj&3P zDb1*Q6o@TxMzxS(XmpL_dy7x=C)VW;aNkxwWLX>W3_9*;-h93jS&$BhSe}`opoKOO zI`g^Vq9sHE78vSh_rxdt=GJh>N`QoAq+bJ6@PX3~HDwXfd9$7D ze4y*!%xK*{gO~GsxH4|>_fEncT1MPNjor-Bi1ofACl@8B<;~kf_0cw%l_8x16=O>q z_(WrCuMs?&;2T5x%-C|)KPR-5@13wn`)wVHDuKMJNz71P8(h?dYp;&0=#k%ln+YN- ze}F?Q$lO7g1;T934z_vhY^&2mGXbeYhg*OMqeZ7t{=xsl(0D&9l<;d^M(1=8fL&y#;)xIbn#e-h`{+ScoW9b=3KSn2c!?LaF$Y zRpCoWA6K)LbV~wuXSXS80{n;;)L}vHU_A=Qz>_uofw2)0j0|Gb5Z9ZWXSt$RUS4%) z7TfmeQ1VSqdMa)DP~hrq@t$gLWA4fM-FANKPC4<(ja_ae_9Ex|4{_$0r7EO6LfUVF zgCGJO?wuTOA|{vT#x7J_h86phGDJ@PrxmW#a!2`K?HM@DIH*8rwSdusuqKaL`&EbH zU2!O@-CNMbbVmofaBk6kK<2ptntq_l2kjHgVlnq@(&f&J-xTwp(Ol)jF7KE7TAle^ zSZP8Y!U?xuPyLjH^cT5n>w4m^&IlLW1P`4 z<=$CvW$aLa?=L;pvUUvM8y!~gy$WXwImEEt)^HpoC0;cr__WHe!5mO zlNJ2eJ!eR*GhBzjSk>(RRWyoJ#;iTeti5CS+G7=z)Mr`NeRnsvXpia%#)l)jK`BQy zx?HV^!|iQtIez4t+>M{ED!*Xc*IvStb++$XSw`o0^>Gkz8!%`JwOi$8ip6OfVQc^V z*)%JiLP`%g1zVXTN}AI`^+btE)rV zW(1%sSLw1abu&d952!#k?8mFs$2Wl$g=r^aaG@K)Gj8<2tR{#c$VPfSw=3-nskH9H zSFKidd77>uh*K5|R-xZb3E#f4hvb+o*tc5ii zE&P#rsg^ysyOgUh&bfAJ#&k{DPrqdbx@pm7mGueS0^_Orgq2_cPGG)-vOe}6U&7w_ zl=YTKc@o}ye+uZY*-NRkMBv>2Q(@fmIzX7eqB=lW4EPy4SQXH2;da2S6@&Sjcl7WJ6sY39Q4qWQ7BiMSbMZOE;Ans{wAC)lo8u7<%jAZn|wS0mVl=g{) zT~a0h1u34(6Q~U3qdC?%Kt+N?8uRn4RDus;IHWBmAR>^_J0b<@gdaQN-X14@TiO-M zmX;wgYsG)JMLX+{$!3kyhQ`1~X16`>*u7q=<^d*o#qQ)30G(A_xIq|%!0Jf(e24iW zj)y}^d)SI(BZ>wtH0W*q7HByCv8uhaEYPXcSf*-Wyfzu~`!(WtmKQqRJi_=}^rL!e z%VQRiU;YF@GE~~E+WRoeEH(y_K_E&Y?C~A{?%2`*5!BsOe^p-2W^2(J5qk}E3-HW6RewDduCBOC^-gNW`*hDMR)&=&}_8-e8o{`o3{ONJ##~ z2R9$2R|txzpO2ok&uR~G-E7)USBD_5JXq*wVOceily-xhidP5w&p>_#2Yrw*Wo@m6 z*@`hVAV^;Z7J45%a|t5-`9gHfJZdy0TX=q`EJvRF*uJ<1IXW({o-t|k&hGImLY3f7 z>`;7Td@6<*S>(ojyfsH?2-RJ}kH_MEaIE*^<*>1qtflB<=b%NYT)8IVu1_v0 zi-emYUB&dk$Zfr{Mf+@HeSCogjsGB(76>$1e-4;R&DgWCN9+vDAyo*??b!Opf3%Xt- z^W#5y_@FyBM}YcQ zF}LoBRs@a+cIBZM7%12Mj_KAROAZwHldF-+{3>)J28;+j{(`)0Ir$&M!j05NIFVd{ zcpr_9g}oyy9PXbAFHqP(O401TGFuD9O@vy?)CW3d`=$I-N^;OC`&9eCs*#qc+L~eH z>kKRqjcoB7@fu<1Rvp zP8`ZU@;zvTzgJGTl2u3BBNwr~%4qrG-MV~t_5w@GTK8dpD&%fwo)XFOmUER$klY^` z=z(fG;W@Q1wRXcoW>To}qH4i2vI~j4ubStbs<4MtBFx@4&Y=NtUu9+Oz{e3~CMpw6 zTnri|rMaJp!AZdpkzNn&*V13WqPX~S3sE!c`=!+~31Qj4Rbho%fnHKRn+3+C zToN%QG{i2>!*k+-+2%n#>2mJ7>#fqzrD^J^7dJR*ynb^-(;NI>b(&jC-$4sxO z&5iuL-+rdT{Ssk6AFT}&FQR@f3}N39Gg`I>v2r?+#fr;*|JBzkM(mJ8LzgiiP+pYh zfIsH4Zbu9$p#dXS!(TBq(lc={EOBsXrsK;zV~J$t#a;WA9qhfI8pdlfAgG z{WyXEYv6;FksLG9jXXyLi2v>pQmps zpnA)@<81F=!fV+ft%<7j^)LrcPo{`kr7AXKw;}`J+WnUBU(EmcalkM7Cu96%^L$PL~I*h@ayPbPd%hWVzPw|>6` z)CtORodRT9?WL)wkn1(jS2TaRqSp7U&cm?ArxjB|k9ySZF61GW$F!r^0PogKvsJwO zyol^7dynd4sfI(>kssBz2US50r;^2;*p{&i2BQ%^Ih-D+ahWu>fWHVrc1KG0xu5*5 zO>u>_vz~gYuLSz3Z)x26>f_VGL)?NGa#D98`7AET+{u0Fa9?C<{V08r1osW0>YxuJ z!Nb;Ii;gfME^m+iBpyPqILwQFSPAc}%P6|PdY!%`Zx%DXE3=o-zMo*~;S1!TwZPlS z_$i80Ddiz_S+pVMJQh1^j5kf-~-4`MD%1gw-*#y1*k)UG7DRIM#WEvBpk1+ zb0aAT>Q$ZsoBwuAa?{W1bKt~X`WHewv8kL)r|eu7{EINMPG?dR;Y;1ph6td1( zLLVZj)1O>;sOy2RAKhn14_2wj*T|a30yf?GMCt_QHRDbdmBgf-XRatAQrP>!q1;kj z`#dnWm+;jmht)W#oGm8kp!CFZxh^Iy-j54C_QcSi%S7_Nphqa#DNU@{4~lD$ZQ2RD)t-Itc9{90E0M!7 zYi^oMfI7OmwT6{Co7?@8hK4apB4q24Kp-+y`2z?#7t0-rO&`=!t zTlLYSfMVXH|9>kGvq%l9d33?}2hO7qxiXlF&{SD`NB5Q(l>cqpC=#dgW@!_Pf3tX4@L9CmAsMOS@{Ywxh{kkO5pfU@TLty3XJD z8sXwqjq`Kq{zKV*eHGL}pzy#T+?o|QvQ0ef0EJVS-eQ6F^FW{5#quZx-Nv?&3c#&j zlvW<N!s)_B6&;c)ivdso}YGVMGvx-De?Q?NPPz^{0?j_mBN zteTZYDquIx`D0hr04YE@N@O$Relkb;F)Uv+KL$)k$}yScQ4Q@O25CNCdBHb!EHmjz zat{+c^V@U{+3=cgqJdXGOzHQ3zU$;DBTDQ`pKT7@@m~H>hXXiip)DPj9~P)Uo)zi8 zIpCB(1RiSD)^*d+N1v_Br!&%iU8m@OMeeP?KHk3`2+Fr?wnVH9+*Px7Mni`zeWDcJ zGXvHad6Ae>}`k94}VXei!)MLzL)?a7U6G&qLaY zUvYE5dXi`S0ZbDoFz80gXFx3?6?emb^s-(koNH*Tw_L7LJ_=eITGDR%_m?Ij^`qrJ%TJ-ZLp#~QWLxO|rad7` zgzMj@lP)yGRqmE)9;{D}0@PTB>EGKT^c0_w$n{{Yn3nl-zbqb5mXa9E?6wfz~ zuI-;B)b-t+*~a+IjS)8Ix|J;#=~w{$Gr}cT`hZ)HjSM77(!@D2V9TDT30J7L`#4r6V;`1O;gc zy#$gl7H~vBM+KFtNGEg%1VT^*6r@P#Aqhwgflxz9Nb;R1&dm3&=UMN&|6pZZ?mhRM zbNAV2m*3vUkHDo97%0X#s@02B-jW1B#0+T7D75$#|52|!Ld+cKB$LDYG026q3vUFk zUR$ngLlzILR!zK%8pyY6u`gf-I4lP+V!0Es1O5HBaU}zckhV~qM}B1k=WE;AlOO&} zG^c>q1#Hy{9+%1sozQ@++=ooqFBQl0!$$(Wd4EAC)0Ob;URciTcM;Ir)s<$#+V$n+ z^eTDr{fSCAyBzMf3_PaAiK7`8$kSLV z4}>Yq#hsYg?IQ z=c`eGd3twQhq|ECXVN(xsJGMdjyV~%9E1*1Z+OV;J-a+jiXYWnW;*bgj2_f<&j0{# zkD<8ZXbO267S!AT*x>+_@4*d!Ip5cY>P`y&{#T#L_FfJ!^%JUQKZ=mS$s4?#1Ln=e z&y97@Ke4)A!mpCRIAt7i?UixcIQvV=+81S`#qW^>9-9zEW!h0k53YW`8c|DyQd!-e zQ-zs8M;Us!>keT~ny@RGof)1a=sRYG8nwD)LWl6ic?WE>POW4zPrZsWQ=W0)jyhbb z$zXSbnc4ZiGqCHMVjd_~w)IbC9kfXsUIiy=tthkC@kaASp*@h54`LAlNvpX8mV6MX z%~J(mYk>QUh4$863O5X}*rBNlZU}_TAA#)tTQJ%pnY)--<-kgJXo|R*6?K1$SOl(} zRVFL0sw&*wcNRSni8?uQ>SRd$UaJSp++!(|th|y|;bAa8F=`J}?N?V%q(yYW>$+2R zxavIgiF@BOoGt~VGOip>Wz>M#($R*tRf7nvpTlaC)uroa@Oc;8P+DOtvxjCg9Q(#~ zmn(N3Wf#e^i`vUl6Yd$hi)Mt!C+}T$;5MVTeNZM}$*Gn@vog_A_iipt-7>8;5r2_8 z4{UDTO-%8f>Tc+lueE1V=@rM}Nv@}9L)*pLWWi8rrQ)d=Cv-O?m2@LRfakzbJ#jd;sKpbf9ol1l%p-Y;ueyV!apkWEvWhl_(M|g4Z=RToGs$8axWBQ zFTakC<_^hnZ_R$(UAE$JsqJE?h}uDB*42k`kKOCBK{FK3JU9 zxSbQM>=ahEr||eec5h?Ep(MdS-a!;{(vt-3=Ru6+x6;8}_c`W0$?94pC;=n^xev;ogic5}^0%E`e6LSo zY2Z}vE|@%iTxVu~^8KLkq@V~(pQYWx(dOaTu-T}^G+CLWI*lzbLf67rehq8VJC_FZ z%hr#68X z*G_#g`Tq3NERx$y#qf^hvI3-H^Itc_-gSot=rXg70dmqSqWpRrkCl0KKWGiq8?b+3q+t~w_%8Pg!RPP89J>5^JI4D?eLf-lohBp^=dYCm zb5O%gFMTma`QGiy==MhJx<3%mV|UowI>U7)8u8+@hy8*S2yE$`u6}Xp;*|iVLPdsW zpK#b;wpnFfNf~i}X$3L9Mm+9(Q_rFA2??BqKiwDMF?UqqRhGs+>|4@{y@$HE z;QV09TucxsxevPK*cA{eP3L~}Av4+1hgZ?Xrqcn74z6BLZ}!hL0s;N?v7iIa&a%yx z=r{g5wso@Hj6Ato6pPGz+WB1}J<3r`Wg<7;S$lAU*k|7cqMzX2th{&grv@`6!cXB}T>5aK%q20M?@yeP z#1u4vZA-UUEp<|_%_t0bPxrv~gB|>w#7o=BfSXjBQe6Vt8aVrEt7<%IDvv>kzpGFtP6Dvs@CI4QIfCAMH}}C(ZbqzWNo>OJF0nkJ|(P(zP_v zcRzB6j4wQFSb9ryyWc$jFsLVCC2(o?Xbrio*>O<_zk zF^g0SjAWOuNez(2R8H&6n$bkOPaTk9&G{r+PCyced4BT(3n>^1zRG!8y14LW5FSz5 zE}zBZTf&D2m-*K6P)h|6`Rpx94Z*9;4hQE82wc`|#}ocGwL{5!r_dAePef_A0FQjz z71}WgoX&DiZoV+_k$vLZc%MWxch$yd`_^Rt4t>TE+11a3`_Tz+Nk)4gnA5fJ$1F#` zk=*H^elgVCVsn{zXQ}XaP4F7sXSiVK}@X8B(8t)+(d1)m`Tsr*j4 zp1&*?zZ6xj&&=mI!MC95q$xXX&EziR`g)+V2ZYbL+fK`S5wkOsr6~3ak~h6CR_wJ3 zpxoXE2HF>}LzdHV2$8u(izFYQmBeUIs#vn}kj^9pyzHTSebQ(;)fTYk4QXy`%yuA0 zi&E_zg6Pt8MqLlGGl}eXw zblZ@|Q_GT2`==;)tZO{I)O~iBLr4V4AiZ|>Rdddi;vTBO+-(8u(KJ`U;S74Hu9xh$;WGr=(VeH zDh7(P{%ezgqtfu|GdESF@Fsf9e8=~bmZY$Lx6Fr7vYL-swDY0OJL%Wq@coje z0H_Z4MfIJ!O6{nzbNp4y)c&1{YGa7lrSgE5OgQYDj zzgUCVpbSn=%i>-KEr@?MH_OH6xKhFT;8X6&P0YC0wscwx{O5(*A}88?M7J%FVSGAg zset5**&n~Vcm89DZ0C=RPmW(YH#4WAD~2%TXM=~ml%%bUQ2j;luPyO!M-ur~4WMxK zA0Y?+PleZi-9zB*DgCEsmhE^ci)?3HpTF*rz@NrNxR9E9x5Z>e&U}Mg@d5r$`h%{! zT)&UFFTr2HLk9Dbm-!bL&RjGpm{FP!X!bha*Q}J|zt~*MD;U|rjAa^sh>_7M*8Tac zBh!bi`15DsZCn?I(ea|JhVE~dS)%@%TNqhYJ%9Ll+$gVzAEDqArkx)r;gj`Q@?>0q z2deW$!nbtA)tgf%GWY=?K&2X{HxxH>A73PRCooDWVe8#Y?S5c5DqbDc_E|))437T^ zs{$rY-!wMRBkvd)rDaz>uu{AoDh3DOom3-;$A$t%1%TRsulG@J`bX0O;m3#|+LH)N zQOtKIR3+cYF)a@J4*(Wg;)rD zH34t5RiI&XC7(<5eFTEyKM3=mS>S8Fg@Q2$btcQbNO7VY!uwfXxo0d&sn)v9 zK73bkn8wofAU#)QU>mE?fN}oNC0mP`z5PK^a^nSzC53y4l;I z&EKZmH{SCb3Meg22Ck3&{Cd!gR;?L-zP?WW>qPQrm8Ed!C&_Pv0Bo>wi_XMF2CyZ( zWqB)&C1(IT?G1>@qxM@#>(H1Wu$Gj6=|O&c+29`O6B0T%dgj5QN_s)g&PPU=Y?+>A za&pRz5IQ}wgmL9F1c&NGj?~EpjHmF3w{%tXZ~OB7lTTDyy=PF%RV6?vjHB*JS@}H( z9Af99*Fpxqv7qJZaUC-7hTq&e)h9dmwPL8)XLy4r1|W4FNL1lQ%kv-FlgBH(0ORKV z?voBm)4A$(aladaBamc~S5}U^F~gEX^{FqVEPEoHHCi)KrZva{TdPS{y0*eYT5KLw z{n5MhKehBXg77{FI~M5QM+6{FEDki_nVwJ{&z2g{%@O)@W@cbW>Gzsa%e5U#Y2C6F z44lK9q&&7@EVQiioxY@_Z;xG2H2&#E%ZpvH8{HDb1Zw4Mx>G6c_URi1!VicY ziCmt5tu$(L2n%y*@nUzD?`)TT=SY1PLznI0#(Fh>&voFq2TQ!&y@4+z6cS(-oyPD? zQL6<$rZV`9Qw$>&Q5CO8!OL3cVd=w~9H&~? z@SoCWbR6q2DL6(Vj}R&y&5u`yU+$b{##GoV>53H$`$J}9o~qtBv56aDW_;vkW*Ym^ z0x8(Ddj5Mc_962m;9SfuSf!%wHI0E766YO3qN0pmj&wmW^i+SJg``j?1&Fx62Agc^1)q)$+__l$y))E+w_yl6E(?gj%4XSZd9v z{`l_Bjmj1lkR7)NVu3Dc*;l-oY59E#n)hyKqog$|*ZZbyR7y)1X)GsdV|MeFLaCK0 zphehjoiu!T`ccJ6K>>qEPP=8$-#0Yi7B#2FpId5xFY8!&2Y9)ps)i6qvRvrV_xdo6 z`Rm)7v#)VeJmmylsiP9}#gTS5P+Z(*1TjG`0%JmVjfDH$?hvz z{rJ7I;+0UByM>@{?!MdOCmqg949FkIpBi_|V*9kkq8ynMemavSEkH#GAAKprTofRv zp31UP66KD5X&dJeo7ERnr=KpS;0`N!6j>GKNQywQb~jNQto7YMG1&G+p4(cvZHH8 zpNw(Z^0;S@PQN842-m<##SR{HQ`-~cl@_xh2JBmLb*bt0=O-*BADGvWGje6sLNuy` zun9MnBi3FmvNL<4K3pQlNgt?_+}=N*0ZlRLanW^DjX7#SEDF}fG^qD*f+4K%G{$E= z=63~w+zf`FQd$F8JfEFhhH=?B)%x$7zt~QSo`BeZ+#v7fnqC}yXI^f)E4^OwPF}yz z{qhW&L^?4#!L`hNTU6u`&zi~BXSv0)T5z!}o3n?Vk2a%vv8T$N;X#7(pLI#J zae~ovH?UQH-#36FC7u>;x>z+8IB-T%@V>y?*M5!#+0YvAsL-VnR5x&OrL_8HWYW_A z&hXHhE=gflD-SeyI4qDD&XKxtqPKG09PXyPsCS+ZQ8|BnoCYr1S0Z=E*2lW)<>sw@ zfwA;cVj&zNBnUmrM!ynOTLy;R0B6htbwc{1S-X{Q{SX$#WA1BzzAb-up>~I(m|K9k zxmN~I7@}^W;^~J|v1*bcDcsIXvIvU%&hsRK$EM71$FHMKNk68nL^PADD66M~QrH6| zVD~ZtfY)r<(?AUrI8l_+m}XOh+T+L5-#QRaRm?S6Rm*MiwVz!eOmO?+bFUGZN_&Lz zPcH?$^&+esi#Jew%FKkXs5rzc2{&5^(BhQB&Wxa^a~=FZ&$nz2z#wl?xLEYea`UlU zi8$!q8&z1yadf-v^W}Yh21d;!-jWO|AqDc{1>rHs_lV*y@WvU_=y*6a6T;k2Xmc1# z_WQ7)S2*e6z_wLysKnk}wPN(?@`NC~?;qyg&ArLj#f&zDmuCHzGE@-hIJg{5U3{Prbe=Po9>3~0L2%Xk2Hq=ssiTs4zKvD+_I=3vM9AbT zbn{+#a|RoYd)1x!MyqXEoWui}A zD7@yzUYh1ZE)QccwAdG2M2x%O_Qs9CcyvqWZ7yiulWuN|e5y6{Y0p}0W%cELHC|wJ z)v!m<1KEKw7QG-S6Ed~f&a&VBi>1Xz==Q#b?7 zVR?{Ojd?!Hq?6%%FW)J!k9`eO}l`z&8scFSW z1Np@QXHxN*-0`8RgJURm5cC+<1rn2Hinxy>Lk&=hd;P6kr&sSk`EVtPL+$(DT{e>Lch*mh1p^O_4gq zTLR&;p*WA_wLkZkXc zF4yD8ueg)pUXY83uTiESa<#{k!c?V`b&O8}c4(sZvkO*(O-ssj(lSYeF zH?57Mmn0c(#_VUxc~F%t7~D#5TD(|c8u@Sil+sm z*EH5L+Y8P@qb`%p*i8;00bBhcUJA|Xeo`Xo@ z%28!fVLZ0e?DgjMV4=O4E=I-yCuisdEU#~}8zk(O~;qPppo z-WcyHFEo@I5F2PWL(#$d5Ja&))M)RQsa%Ty_B6Ii2{jjWSVlMOT(eM&=q8}zTBG({ z?w{I^s25McXk5GXX12~l9mJ+^zlZSARJvhg=B>ng{-Fm6jXDG496Esu{cP?%^Hrs7 zJ29hXt?DW0dn29csp_$SP>o{-H+yYjqZP${j($)o-$qmZ63xt(Ox}HE>=l zZ>qWU#0ZBwBje3Suj=i$XL=O)2Mzc{R-Wn7ORLj9osQV zCMtzp0%1pa=2)L_-e(0*d8$f^=zPJ~Q%PbM+R<6-et^N;r_ zcdFv{BBaE>wx4yF6;TrYQ%`7hskXpCaiRmc zX8uLjvhXTt&D~*U{!g(_we27zL%1kXJHzJs=^-=3Cs=qYXX7$Yo1VG(&oRtAt_M$% zMMG43Ga7Y4k<>KV=nwWn_kr&zRP0_VZwW3tpHfCd>ga^D?>3{XqA8_Qkkzi}oOwu`(Ph#`ZWUo@jmz3F2F#e=4Wd_R0i(3ZH7>uGH{0G9+1}_NW27 zATnqmxv>(>?(z6IjTJU?(}Jyhj%FmJ%)j?+a?93)KFN@9lVw}4ZEOhOYG)x=vaTl4 zbQ6V+z^jMSU{(^f3g#lEjoL0)_H^@U=6Fj%kd+;C=_xOUz-#p05!_XP|83LdF1UCt zn+2bDb5LX5DRVJ96^576S`TZo@`2$n7=J2NJ>6O03$B1vsPtqk&(-s9^ zV7Z*i{^8k+nY!&st63@oBIqGPdQoJ1DUX(Ae z-ym;eqHnB zx0#U6ah=npn&Lt|olZrM1@BeKCLxn$IVXe0WzGy@pvD9y$tmjcF=m3;Dv~<_*VWpj zcNx2wGdc{yak7VZE^(LZnUgE4&lXVa32tR?_|xn9&g_6<{>vhu0r zJ_&xgZ3=qa1IwCOv`1~mtQAogH;Fh7y-%UNZBqwgZktMMX`j&V=qs3c;fB)BCUk8f z+Kw8Y<5q*aww$M3m_Xr9n?pGU7jvkUQyO|tzx=I_UhzPcP4Hv7L-wok>@AAgopSnZ zU(Mxf^e3*40;qS2$&%0F5mx)v6YM|3QrHskT?cl&-%A@6^*TA24Na^W4{h}+;4&Rr ze53r?jByB0Xn6(x2wUe)GH$-Nf%q=<84O!?DDoam;sTP^e0oQSSZJ_k*?VMWElC44 zyk~%jQlOx0FB9>knPK2y_bW;KR{9kvc#d};X2r$canD|=Y(t%v?w9I6E`;O_DLiii!6b-IC+QKc(Mn|`!_Ef7H9|2HeKJF zhYwx&?OEIjVAyr_L}O+mh;rNzd(48kwb_O5@Ao*PaW_by z{lw(!z;}a>?*$+>gAz>uhdAT!nmG4JIZ^n-<0%Q9vV1cUf}2^CPT5zp>jd05jm+aN zi$Pcmu8TWlbH31dot#gr#<>F-fbH_ycN=2FE`QJ#MJMr| z>@O>iVumcF>RwK{??cKA$O)iIQ)gXmTEiYX@=m}GZkq9z&$dne3&+~-COf(6+vDKi zIhs+O0OyXR1|$f+a=Ae#*qEm-ww*?sZ~c9L*Bl7`_Y75{y=XS~z}7q?#!Ox8#&QkS z8&W(tu4{I@uz;-*k6Rz+7?T4}$8uP~Yl(B83qoZ0; zZT(H|By&C{I;O!k5Xila94@UX=VF$jk@C8G3OFxpVSUo!`CzyG*Qof5jQfHfV$Jwyw< z`OQ_I6gI}#ck~-({8sO{G&5Iig*xYLM!pv@@@F!0pp*ZxS8)O7EjzudC zXv(`nXbGF`%9KNgl&X8t>JHh_8i>%A;D~ z?6%dm1&0U?A*fDL$k_g&5R|2aP6_`MjwsfB3@qvCs?( z@MaGJBn{z&G(ay22$b}kFGfM{rnS#3>z#EYEF#YAiyxD38!O>PN8j$vjHX1iMd<^7 z^`wJXU5~@EX%Kg{?Q5JwdNX|ZOl-LrL4aYq@XeNuw#!peYfOJ!76kom3)G?YNYXR$h^r?4``N5IA@W0>$Lmm#wjHmdZIY~5!NY~X+RA+HBN%g+(M0bA+ zO?D!OP+iXksALdiTZBuajk>xopL=hXW=Bd>3B?U+-7>>>S~}SiKA$&*<%+vUDnrr zd-4r%stO1^iUX0A*_i_YRWuBq$S%Ux)G%shSCww-C4)~q=am7{M|?crs5c8KUtxX` zYrMCaZ9fjZE^PHNgGRYl@r4H&czZl;+zfk;F_n~VRlk>TyuO;?fa)h5La)=2? zVU1*xQ>wXbKidkgH{H9C!-!~gJoj>}i+R1PI(xOOb3vgne#^oY$$U|r_SxqtYwts+ zJxGu@$RBYf4|^Yi zan@dX<8A`KvFRJ->zTVnk{L-b@rgJn%m>b^A_OVnCJ0^^qwxEstt>2)h?tJ0$!B%!mH6%Y6D(l;Mr9fpR0kgKKSRuq!~Vw zD#Z1tCf|c6#n`vy!p?Njt5E18M>F%II3be{#UC7^&K6JF_mDabHOM2BwK4etVjJ&N z&|m<1gga_yB}si8s_@6guu)YnuekG3Ok>j77rPQ(fC4%v!qWeKLDYWPqcy`+de}Yw z&7~|0JHTy*5E^Ax?(^DoI46>a^Me<8nf4}= zGFi(I{#Q5e;7g>Hu2fkun%E;>f+!RV9M0!Ob0L9>Os8ZVCmHJCAU3R_t{yg7DGI4( zunP!`D}1@My;;9cQ{9;#LIYWEky?z3;el-@5(oM5$>$ZzkT7vM}Cg}kxZg0YbrMsTSaa`0^pbE_*qWTg%h_$rAOVM zHS;eE9{8mS-c$EnkoQhYjjmBu#os$O>emdmi+wnLrIt%pr3XemkU8=~W2M_$lx)ju z9=uq5=sW-AL?jB+R&L%*tZ6S57B^A}e$YZhy(u?s?wl?o!4)<$V|2fwEjfQbu>?TuM2+6f+Z}z4nMy(KA^-(dlZLqIk^i5q~)}-_8(g`a<=sl3GmjxG7ku zEj}T|tn4~h=3R0djog1sbRzvCqy@TT>C}ql-aoQzBj=kG50QMuSPnYOq5`gyHZ^OcV z)QgGJN17;AcfMPDCz8&JoSJa`%l`eYg#8&`cspAK!wgYX3JYRJFOhGy)lONrWAcR( zU_lmx#@GtEmnj#mxKG^Ac^N6H8=n7s{7rd|Mas+EyaNKA^Pb%K;@nO}$=vM8R}-jo zdf-6ek@u}v8ohYEOYMYda~tno+3(%%#+pHam*_PX%ycE>7rwe=|0*8}BW^qBw$@GH42NacG#(SEn|inl5%Pc>ks0qj zVH1RaB?yRM#xlDoN?zGX|TxP#w z*&B-e)3;|&oy?-W@Q0>0y$gUQXIM4AGsVnI3jS@Y)V7m^fFkV~0KB7#ciLZ$3`|gZ z1-SxbCpduZlUHK#P&xT4X_!AfmsP)!;9c`ZkkeZE(*0Do8wU6f8~f{^-d!mpHbF?_ zk+oVp2+!gc(7r3v*uV2-7cP^X-*if0))Xqv8m9SOk`@k+7QRLcdtReczRiQVgMK)!vc4&+|?lz~`mwmn7bIP`$JATX~z9%`%TQ#Lhu5K#LJ&F|^06n2x zln3q=fjnIbiezT-ws2o5{LRtSzm7LH9c_Fr7>K2!LcQFj+xH0di-#+0sS>|Htnk^O zQ-(4>WQv_R=>%^@Wt+&vJFY#+s2w2}O!8^$= z=q1&*`<4t+N_Q7diSNA8ZrE5N2p2wWbVMMs-a25j6={K+IydQfuQI2Xi4Fu0*7AiTiR%cVRv`# zi5S)%9ld+*`1qokDCdtRsF1roN_B%k!>KJK^#n_DQDHQ{jfV2h1<3YdlGpIY@42$O zQ@G(Ld2zFlx5YIF2pR&8W$717TaNQ@tSx+Y5kJ4M;1q?=Vv1n^yrweKG7PDx5ZE}l zMMgj&MzvR6?v{tiGXLJ9w6j@~_q^1UKK-#V)X;jc6?OOhpVh|!{N+NK>Lc}nAMk`m zIT2^YYudTx>Gr^tPTI^gd(J&M5_vS-tz_Zb*%LorT9m7vbwck?yy2!rQ1jrEq+7+T zVD#8CLsYF`F6Kbtn*c#e(MEtQ04m}B{a_@!)?y!=({E(fp$&Uy9_PsJyRVH3@t=sGv8#m3%mn}aUUE5>u)lh?DYCoeo;SRi4>L5>lnt-v z*NhoYs6E(jzgD-O8VlZiK^hl9Dffa-ICQ7lkAG%)y^VgWrglSPk=_@?g$PZ4B%qwx z$n<3129&=t9`z*hi<*N{>)Yz&88JJDGk9fV13KM(Llz@~JsY4=k+L%9`e0P`4qd45 z06-NF#T+>Qw`g>RWwr3su2g{xUZjGq75}A1bH{`h-fUj{fpUrMM@H~kq-HL3!L0%U zlDr7cUcETnrdV>YVt$x&h*=M)7*kZOC^~7!8{_GqgBF!K2-oBN*wfz`Y!geiP zot^eTRt?~wMX5;ee}S(V_Yqzw$KFyR%k*QcG+>r{Gur%rWV2H3@7}>m;C82!VTa6> zwp$6e;fVf-*5r&RGHSw|at^(3oUJzPGIwN)K;h&{K3^?2>BgleqpZQ1HW@H0J3^L% zfb}Vn-TjJ{t;@!2egX)e2CbE!XW0fs-FxLaoY6NKF0mb8t?GjSj7T6{P6aNfQ9%7| z4HP5Gld2){@9%tlGRum2B<+isSm?%lL#=75+}aBGw{Q)S|L-Sf#XCL36PuHxtPFrD zqzex@zpAQ^Pj1oR$SRargXOaDDQG{qG2G{P4X3I^ zli<^V(CZ$ptNHgryq;_yyrV?g3hbwHlDBk$PTD%e@q+mg=;WzdNv7J)dx6hOt8aWDTsu} zZGEILlNf#pB|a&?eg-y~dWk>r7mg5BPinYp@-f_ex)4hKF72s^c_D)=%5kUE@r5nuk#01}>)QP5|$)%()CQjLgv z2G$31TxB_qUxk8C>Y1cz#w^cMDP1O?Xp^ho<#WN7vOfNWGhK0r0m~nL93XG(mkjXs zr@U^bozMdnsD99^q}Mv;v6k!Ot@Zr6c6t9M{>}v`oYn**$QZRUhsDh=dH;GK<~6oc z{z&0^e?5(`L>oVgFO{r8XeJS3gt}>wOK^IuH0)hNJx>Kl6F#*!{jSJ-3~w?ttAhr* zVb&itfHAOCsb5oFd%IjA>jb}48lS?K%2l)0?@PsROUc$)`z}IXzn}~S$$aw~BMn#z zE73E!bGp<=DuJ@fMWvA7KL=je%i^q!7Is5U7c^DO-AT(BUxNN@*zwE%h%Ns2;#dFs z2u=-sIm}GMP}g|)=Ticn2NeOZlaecB1 zk4+x&=OZim6#h{4>?JE0=IRfH3tZ?$IKKl0p`vu~_1(N5^>p~Rc_y(y0l`3$jffp> znR$F5y5DvBa*}e}#0yM^Qd^~I^ZF__dB}GAIYZ&c^nSsut1ZPS#a0f}Ri`@fr@V51CE4Qw}2%;D9+UKwS!z!QF?je%$=|{f= zbccQCv;~<~%TFq<^+n>+&CPHsCcD z$Nuu9YqtA9#Da5i<@(z4-25*~sEC79Rjubs(#`U*xlOV!*BMnI%Eu?ao7M+d zD(vW~%KE_H=l$wm|7xP?_lx*hb<)La6mNRf&vy&B(}(<=y#!GX=Gd&bV#k zB->8+T~{gYr00e>B=R>@K(!eL!E6lK7iwViCE)w-KQ9WnIN_F?12yxB79Iq%`4Fk~ zt~f3PZ(SF6g|~kel>-l4clqc#NgI`ln|h5{713C4;;WHH&9r*M5{soI(jDyt|6CU0 zd~G3Ok5c>k4H+P4_y5X9|IYURjp4{BLJHF;!=39Bl5q3b&sQl2xf$f{qf0TAf(%1~ zciaAF&i`-CfvP8{Hjor`wzK|jskd7dZQa7Qz#?A022eH`iqx>=Hf0X7%%U#ws8 zlmTVve*TX8PwK(H;=#bnM5$)&Qd@g7ID6w4b3!u{YFf%QX^Iu~KlV?sG1C3ncpyct ze;Gf|TVFll^>z5tpKaIvO$)@y^w3`X-R8&r)tjiuUo!oFSr@cGO^gx5SC%i=^oRrNX$U%!iuz{8Z5)+Nhb&m2W2>#&K{3*e7jzZR9Jui(+CN{o5< zf57yAbnAM=k8*S&l~zg)Na1&J^{>_A9TUv_pYD6{_oQ8Up6MnR6kRn z7>DOFISmwFj}C1Aq+tamf;j1~`Em}E zai-unvCB~J`nn$B_{AS1B(vU$(F^}x{+HwaXEwxAsoorvlJr!Ck*nf+Q>FGoH>b%R zEDi)^V@vV&&F{`_BEST~{DCHv<*e6kM-wQY$z+3 z0RhJ^B=DAO?yrx7VBxIg<+uR_fU(f|# zS@>Q5jX=&z2MvnW%|HI5EdWL9m!cSDI8*SbZJ5482clFx;ItsdFYLu9ONS@v7RP&< zGhUx2-$}rqtdp9eWsv&vw*UNht-rtXNV{MNh-lk?$n6f*-nhPf?o*7-6ipxb5+ub4 zEU$zGUHv1?39hHeEk3FaY5O&TxWCA#b*<0w%=WSs>7x(l5kngg|FUtrm{LWw686|X z8}LS<#eepNsH;FD$qZ(DQE3mc1HW2Qcv)lr!jy>^P^EcEb0dIywZN*>(` zkYc{E|Jid#(^E@(oo*jK-ra0=Cc^UN+LL?|nL#WsJWy5)Qj8wFf2+Bw*)bBo-vuZwzapYC}{@r?Ib5-*10VVDo} zpkX>%@0xwx`m`zJ?G!OL$V*5+8e{qVXhWToJfxnqdH7O|)9o|58Ki*W3fSiYkSXwC z?VmCH&pXDyi|WD3AKH{(lj*pp7)$MkYRzMVD&EV_iDD7ND;#N$1&!eCN^SFM7Jne4 zeAgK-N5pfo|FgPq-K_jq z>c!utN4|X=8g=8__khA%Ky88-#PtaAl?~O z@ZIn^2n&^hvbWPO^@mlxu=J{H9tf`)+wyhg!|}{$QNg|~B2%MYeU4hFP3&Xx3Y_yx}4%1MwP_k$#iP8DJ;u#{^WqUEFd8-8g* z;GBk<1ZHEneKn^1i&Ck+uSd4;J8PjLL;$wn@jlrWvl&XDej?HEsmDB0ii=QaKNsO ziC#~m*4}zp7Rjwg{d_mbE}1TqNpaHH_vsVsez5KNRrL2#^R%;{?ilx)wMk|bIHuhT zJ+t1?SFx*Pl=N?}&s6TrzTt6!!Cl{?`+v*X{Ij|LQxm5ka<{DZ-4}9R=f?IO|LVPb zQ)s!{4(_bu&+d~))XBqj%F5~aUO^uElY!WWVQC=<+biXXl!k8~KpMOm-yy^&zIld{ zI1)8iv|`p9Y~_ympnax1E+benm)vD*(D${kRR4*hKE`&^?eHG@$$eQLSr+sE7;sL` zY3}zYoR7zz&NvmT+3;8AbmXCA+62M#+~(!rcrN30}bFjZ*n(kD~SubI4?shgGQO0|*KdM?^9c(Y$ynLfk3Gz-70`?DE=jUfh@ z#5ZbL(tT!1XwnrwVcMvC+x^g@)HCNDYM7U?y-&CzP`79Hqu)$-qJ7|7pJ%YN zvhl&blDQ5mh6@Ljnx2M&4#U=vf<4}YdmJWH(O{408^^e+n?O!QoAkk?qQvIED{$m2HYN>&wTCdUD zfQ9>iALvW)CEnf)2i}HtZfd@ISe1;=c@>ZAF6e9zX z(MKhFZ^1N~A=q9JQ0P)kZ12(IQp@YlUzw)lblmnDP`^p-IF#BWt$n<0=i7Dh)9_ev z#HoihTL|=anuTS&PsM}p_w7y6>a5#M}cg+9~Ayf{Py+xAp!bC>o>oz?h3YP zI`~|Y`OgAMI|U9)+xyA<+QyL4>b_Uxzkfa!{?b(P3bBN-_}ljDbI845o4psRr^vWu z)?Tp9L*}mHqVGR@r5hdOeUDQ`w~SmNhPf%XnVD*!l~as|4x>2x^gGwo5P1GjiP?^* z!(_FT^3_tE>sps;B;L_36>9{1%G`tthR=Hcx!&2!yPd2#$;LMF)t4U670F3S4&SBQ zXc%*zY9zAoqJ$bR{U(Rfp)ifp74<4ig?;fLS+)J;Vj0ej@P0$}{4e*CX}z`O6IZoZ zkI{uEW_az;;|@Ci7%o?6@D+6DZ^rtZw7(5|mU$$+*`#I7p+Y~2QHj`cNqu+MU5yvsJVU~Z` zL{+Eiw<~#-Wzsk}e#Y((Qky??^R-aliw>^uHE@te0W|q8f{cT{{9eqeAq5~WZ?6Ih z#2C(lqr*O^85K-0?S8iD_{=6zzB*g| zdPuU+^oyfnADLU+bc}Bu4Be5^lKH;(_1|$w*E5wzbY(=dd#r-)|FXO;gq{(Qd~TX* zq~K7OK9~gSk{N;Sj*gE$XZS{#czHo&@*y_l(}|x z^@+YPA>rQafE3$VHN9U8abNa<2hUZ*<715Phq*bzasRO9o}%_QIEe}najR2rg(WCb zbfJ$phK{g{_+a|9ZFh751B3Qk&jm01L%cEP@g19FoQ2|iN0`XeosglBkms-6L5P@E zE`2}n=eTQ|Om3e3G$y`We3VvbX5k1|_UmZrtlsK;;PT)lW3I5hu~NxCanDy}70u&a zxq+NDF5%p1V;`IHwKXOo?q?g}mnEF+?W+Dis@^-E4K4ovziqD$qrGjZqPVw%+Esfc zRV}rt5u>OP1Q9D%iCc=+yftDAMeI#z5VuuQf|S@PipEH&5 z&Uv5nKIip%zG#+>b#=5?4Y`!E$||0M?K_vgm2EX10(&P7mJIqWYyWrW`1gTvPYdp$ z_qB$rw6@<+rF2Qt9^?RZJDyF3=z`w^3vM(Y`_DSciT+@?7y{v$8EuWl6Jhdc7020& z`3 zz+v*gUAN!u?Qpn7Ani0tLAtqlU~sd|PEqF8z5hKoG`ybM_c$lUj#ROp5d9uY4%V5B z4w?{!pMICy4zMevc z;zwG%{@@qttsHlJOKDSaHZvVMBO^>^3rDWKt0kC_i=h8gRvu(ZXP2A&1!ihTT+lUw z+^fGaRh^4281T%PwX+{`wLw)&zYBH&P&KzhGiyEB$ z5v&q2e=w)gB5U!qdwUtmcRJiX6Z7A%?dPM$cF8|H?XDx=0H36Z^7zY??;{hp?)BmZ zwAFj@E>W_5E*vc$L3?Z)!&kO) zWA%Ud4v7CdBC2A(vK{0oLswBH$)~w@l(3DV_!_})cjE%6mF3v~fMIO5G}jhyuk|BZ zC!1j=%yQ;EWsxMs^R4WZ7T343xA(bbGHs9B6=%d!l4|~Cn0K9SeKcmT^)XLNo$Qk9 z#|S^p8+nNo`IZ>KRcUu=j_qRS5>41X_w$$Y{>7IV5k*py*Yqz3dvkH``0lIfjnN|} z6m^m{+WCu}qulymd-Si1d3AZ%;V_BoN{0Jm`QJInK3Y^cJfE^ocO{>+=2~g`*I&*F z+|O9K_Fvq@g0+-9nwWSqZfZtdRcmMX_H5!e76r~-?MyupgOi-=Qo(17W zqar3$3aQ2~4LDBo-D~asv!(cIC$i+|op0V%9C{oHOKix=WBA5Mq&oqj=@ z0UU`~-`hmz`3A5d$cbzP4Q|=D`zUx%l#N>Zp@3p0{n96K(&oJqYF9B{9Dm_^Pg&~a zI+E`h$px;{vsdW$Uq%_ z7jORhj${g)E%=3lf14Un`r^|qk;|t|JAyYjdj>$>z(BUl=~cuXY8PwN@_xo%pW@%$7Nt8{hQo{Vhl(n?;)M zAt_hSzk;9Aa(&rlI|_3&^!lK3iLfP(nSTr$|Hl57&p{o#?!hZ#LH5v< z38YK^R%7!@$*Qxfd10J68I1^mC?i6YVEYDoYY7wWw_->`&#jh_-l3TwuLB` z;_d)VEt>1UO`CnPjr!|Jy4|M46O{zv*Xoxm=Wl-JplvDKxH~1==A$*4x zzXeJ8np!j03*m69A3^Qn)_*Z0HrwB0f z7+~4GJ?M^7srRU+Q7$9LZ1g0C^7_brq`!015N%Rs;2zcnHZwWP{V~}F;bSZK*-!#- ze`Eh2mpg>3fup;F8BG=qIurTh*tl#2X&l{M3+l;4wn94<(8Jr1!T8NQ3GBHkHJan&Q(W&ZE-elS~9XzC1 z0jl9;NR3vZ(~!C6z*V`~)CXgE;LI^6Wtw|s$=&_ZYmQVj@!vc#;h*8kh0(7#uThb+ zkIS#Y;HQi!sa}`M$#3}fEgsYl)wbmot50BgpIA0sZQqsp^>v^7ukL-9{?Rmgt43F= z+<1vvnA7dKV{hH<4c~KOeXgrTBf}(91t)C&A(ke4?FdB*o@&=eLQYMXznoZ5&9)m` zi{$mLBgu)QM4$Gm8jH@cO&s#Q&z+3exoBmvA`X|m#45JrTc^Z*I z-t=EaYY={|kbO11J7rGvCUbMN?L=sH4OzF%NwO_Iw|Ul;F08%*GetE&xA|12{vf5cWv(O zYWRyLyPs>9-OCGt?e+__HZ?@8;w7`9!E5v#UbzOFzXvL_Q#rnk&6p9tis})J<{LD; za^SojGq^~58>GbM?g;+nvc-Ad2ed6);-g6sk15V0>#4uIE}VFu?4A6vCbk6gU` zI7mh4i6SF zl<4De&*w;;I}^+9sQo$5{`e_D@bn_kRDk>(y=v5J~dK zq$q2l9J`{qC-8&KO5H0uHJN)lT>nqGOYAK@kAvxAX8Mw(tgZE!%xf=owrXknq0@ew z5WmmfwQ9J7QC-P{XWV_UIUfgzl3qObox=2!T!<%jCL)*NhX3pX!^ zFYlP&aN8Rb-A9C+9Ly6{iDgI?0=85Q#A*`Z-^h~ z5I>)Vu!X#F4;-BQ>we@0PA5uX9XK|cRC7$3enyA@Q=T>6QVUQc{~ZRX?D`K5Bfb!T|5Hx39s+IP)!5k_OUU?7FU9*aX!e8f`*%*_^zhAHv5Dx_mAr3N^ zK!09GL%(2ttq8V+itRZ@EsOm8_W-+;k|+mh%?82OtL^e2CD&m><$-SOV3+{*JCv7T z4)B(#)Sydnp~zVI-(NBROm5POV|(p=UAqg5`C}rHywaolDP#ebO1NG`$Skx5f9>Vg zhcuM`X2eQ&JCM}_^hX^$V~|1r^Ra)2eQ_vT0WLv)^qU^{{gl?~T5s*Z%&w`0t!-d} z^Pp|j8dm9K-{uF7o?YeFUK9g|E)}?`UOKFF+ESybK)Q|YRFs?e)S~|^MDpRwe!W8y zX)S{#h)}KH>53?<5KV733l~v+uQ^;^>a;Zk1e)UlNSA-FOhwxi?X{z zG7Y@MSY!YGFJ{k(hKh!yzWA!82{e^pXOsCNgZaiRK+;YF+FdRxL6X}VUdk$~zdoJ~ zPX7DHYm~)@PSuy{NBh=1Z6g+6>e%mQORi@1S*>Hu5Z<2_W~Lwh{rN7D3=0XF4P(R8 zhkK?siu{k475!(qabL-dZcL8;;I;#-YW!c(l*7iKLg~X*G-!7NYGx+PA%OJiw z76|qWq55!?Hgh{JjVIGVu|{#@{>=SqWZrTwP+iGB3Og7SPVxA-_nJqXc?HweAAY+4 z<6el}l!jVV!^>udOBhB?4m@ZghIqWOmV?WUd<{;K(o>%ciKh}Tk#A2s0ZJBww*3F1 zgdIs24?}UP07ofqQz5+6TKHWD&~TlCWGpcQ8e-m=O$u0QVBMnNx%#rRmhGc*VT!33 zTZ*LtTEDFpwRd1>eUe&REn#0bP*;~pc-kaeVED8jYq7TE#Cf>xEIi&E=Ktuc=25zF z1?`&|(sS(b&fgQ6gBHm8TV>9MGOxC+L`_NMG^<*|Km~Mg;F+rIjwxZ|`qRRBIYq6r zXKA0yxgw8Ihq-^|;2PS4I(7G5#d3nAhpqs6loa?TGM_Fq`6`?5gWxN4GOzYxXD_i0 zdhj{0|J8PQ0cwG0gN+0*?@xQw?luEWi#0c1$=sM3-dfyyGCLEOrSd`YtPhZTxL5}t z9ha%xnD*f?UD{QU21D<5cAsAEg7ji{jwsF>qhS`8KXSR=$~1?NccJXn7>s4(v}HAQ zU@OCH#x4Kp@L6b`kYif$Zzyc9b(rDRF;a_NXLQipXgI6srNiGWe?;1ctXIa>z7_GU|SvUnb_PP z{6J+P;nb)ThbmgEc0mT@MXfiX6>e3l$N$t87K@E6GxgGxv&?i-D1+B;o@Cc|PhB*v zcb?5DT8jr=uUBX(O!&B8gZ*ZFC|@0UbtMswu9nVpezBU$a3ff$)wpM(WI<(LG;=4N zO2A9-%L25U(`Y}L>%B#VjxE-6?%l`%@7kp9i`?LT>OEu?KZ}#Fy^{7M11O z3D^D6()5POt0oT;eu#cz^LJ6X`t?G+s+jY3e?wDk!k&f~1}Vd?XVwCLmrNu`kA?7^ zgeCFskArWZDG)nDp4e$02Wzn*QG(-L_o)=c5}CYtWHEM!W{~<)+krxgjpCYDn+f#S zh^LJp!En)d!3&8A1SlclTB@2<^8`#UCm_RTno7h({W-{Y!gI-SFLGL~7o&v`SyD&e z(n9CGCO@~gy4Lof3ZJ)#Nc7;?0>&zh5=9UKaTKc)bY5~WT>gE#X!FaopZ5tr!=$-9 zs5L;9-Dg*Jtr^F!U!D^_gIkK~xYp*BgY2ty<;|OaYQc)Y|5%=wPvY>f#+Nbx3Bs4) zi|R}AIrZK91ys7O%)7EJe3Wjr@UxZW1N+&QJB4YdL*?gFof21Egis-LguTgJ_cDk- z3l15xObCP^N{atAH6Ed}YiSW~DVw@c}@JQiNPNOt46dGg{ z!&$*hglX8t1t-Z;TTOVxX_t=JRx-Ue1jTVOK!}NO)FO02C0}UPxk^^eD^u(R3t<9A zu^~Ym6NyZ6x^qFaS;cW}+Zb~?YIY0%Zg4a4(HC{FpkczLVDri4;O&qL{D_*B5!k^4J z`hNva+ns$@2M%78jNp4y%(H3!Kj}|m-{?f2s#QH>H zh>s>qcGU#j-_O)Lr_>Avuk@EvQ!kC@WU5@;J5Ud~M)S9veQbyL8gEyaXFNGT6#e8a-dSbg6VbzeNfZXx9uAP$+fywifSj^ z%RCNj&=<<($kc=~&ce&pM;y}bX6@IHk9_yhAVI4K$eoJ_uR)k2L+uk{3Y1EU0$;7| zHVD?twJ}Au<-n&7k-51rGuRv&Gw(%VIED;+N@cWTz6N)elF@aGhPH@({0WVitZa-p z&NHHi@6hzctjuJC`8qA4L`oCGa14D@iA0#&%-v`=qfcr`*6^CqvLR`v8 z`7&3RuuKo`{OHGuPVDDD2=#Mu!HdWLEV8T(JCHef&0iT6O>9e4iHh+pJz|tkL($x7 z_+1fkYZ#oq$__WWwjT&lbuyBxDCb-2-ue0u$xjVtJ<7;XaqK~&9YpNWxbji1t0)gR z+c6vyZbe#g8nzPCC^x##rZtF>g2T<3H72>&t)iCpk8F++vPVUq39h=e1VHNAjznI) zX4EtH!@)j(=k9~vPj}YlgK~1k5S#c)C8xt0>bdYEXJ^!cvt5Twt&g9K!gFtohSVdP zoSZ$$k|Jf}0vcm{Q~&V9fv2}0AC$;e zWNltO(8SkuhsxF}A9aFGrY4?Yez*yCM~pjU1CvHA{d3!&kU_fzUtW!8VxckNOVsAu z(wMqqg9DWa_u3oj!lY}RZ+tWoz?g|2m|51WSzD@Wt7WEZT2UdgocLrMH`-_kYQy;~ zsG1Po)|Qwl+I!SYULPZE7T;SPpM;dA(L(GPjh2zmczJL-8@Zld+FH=O+NdCb+ee-H z`L}-L*Z>E%dYn(%hFw}Zwvh3shx?ZrrW<2;HpAYd%Z@H;mXFLkT{enAxUO8?f$;pBYVfUeI8vp zqnFEgCfM=e5m3mqJl~mAyO(ky6{g8;{>q(U&8Tle-a=dd~skfpJYFbrxJp|~D(}dSLk?y{BD}ECcHUNF`>{Ba$R(fK2?V|dk zpU7CXZy6e5i3~Un;9zj_V`A8Gb}3<7%}#fKvfEtis-;9fTxy>Fo>fOtVYrK9prn0c zgtO40%OXxP%YguLjEuDA?5(<(ZF_W)7jvtg++>aqdWKH6usyne7GH$TwVy|^%)gh@ zGb@g~Wv(X#X>=m39Ck;vyCe}wCp^4BaWJdEA~GMY4gf}YfHF$V&Q_aJAP%{JlY%=X zsrs3k!{ovV=;T8{?54`RakODC?KY!L6M5E)`VKjdpWcDC)d$D`6psH$MFqN&=Z+6v z@NVE;!U#Q?ob$F4q9^7;i8jvfwK)_~sN z8DINWv&9(~@5rtFe%jNkGzEV8(jg*$I5AlI&Pw<6HccO4zh*O>9_RXHc%Tq7YVlKc znetLAl2Q5+aDnM;7iqnu>CaRK1VslGez9tG>vkPI0xu1*1+e?;U7Ay9ll48PTA@Zh z$H7Vd0$he-5gB$Q_)%eRG0W*jdD!A(o%t1eGc)=tCL>n(+80MkL`?T{(-`WTrq-h7&bTwofsRei>Y ziTSu%6sjq%I4hQiVYO$nteBS}tssQ%fKO!b*Iy0J0lTEhS4<9BgI~T1n$9(GjZbA# z?}s9q2E4YzwoW(L)$9d{**{^=DoMS3T_z>K-prJonJgE9R49A?WL7{&31mz&Yho%E zu%rg;T8|}lP6_hZ7b4BOZb)@xj#DjHYN#7k^HpQd9p^_p{*+c3%qF7h4Rs)qgV7UZ zPp!_adbLTqWu@|soY&~Pp9hfj$x+6Ia$iKG@;pAPDc&C8kxt83sT-q~iY{@%u&P$P)>NVl;vqEVjhjCVPjh`n zw|yWhd-EbqX%JTd=ZHb{yfr4A`a^BM!X*)g=@K zs=|HS0?XD{Z*&ni2*7_fRBuG+Rt>xhTW_zlYX{b)9XW7Q-*D(l>yawEI{}$RN3BLG z`EpeQWleX1!sQG|XrZ=NHCa)v3MdP@B`j`lZ#<1UJQI$1R=V`*_={#I&gNQP zU5%HBvAv{y_9x`qNoY=?l4V!j_WjmZhu2%Y*EK83waFks55-6$CT?K+Yz&pHw7s9p zc6RS#u1@@6CuF@&XlQ8Rrl!(HHmotGF@7RK;ttm43mVu5l!T?Tc$0lu#w?pMP@xFv zQYnVMK4|pu+^T$oXdCBX@UFLdr0B*#>EznJJ0wMOKe2Thwo6OD%T#;+#xgizMKx1r z)+VY4|Jhx}#H?OR3Pe5aHE33n)|4Tb?o2n0pc{(<;aTD z?fS;yMqYrjpYeb30e`1QKy;qVP1t_sVumHW)Bp~rn#P1fMyF!gb7G~3Hzouj8Fhar ztG27exjkFaeD0|!P2$`R`&MB({2e%U3jlkX3oIAx50AGg8#6J}q4$dZxs^yXX>m3@ z>R#iB8cd{@R!Z3GT7Jct&(wL?(BU$o7^vEiH+evOaO`%QvC;)9M=W{xP<>QXG}a%T zUoNUK4kk_N+qk1`vW_+L^F4A$uF%-D%OXdt&peD=KgXuoLvk{=;Z`ypf$^mS)^qWM z2mG^?B{dJ=r@Thicu3WdMa`IB{y0Z?TuKPApy@D0;G(-t_kb@&jwp9+EOBv3V*q$f ziFXfm@xy-DfW-kZ-TYn!%O*XvvwW$gFq_{A_a}VPG*?+`PH~=nJ4xB{FX&a8`6opI z4lP{lWUAF&J5y~^y267_q4Z~! z_#SF0L=0coMsXZjSJqtOEAzFTk)wKbmU-SY?7c(_wM#xw{6ckgGV8{9t%cP*Vfx#~ z1RB`9-xSmCa+WlnZC6*9R3!RM>p@d?!;W*VXyAer$A>&u{&n6`3z@;y93m*YS`(ML z&G|DitqxuBkyU{@+4ThE_Egp=u? zKis=ucV9sCKu26)_OS$D+&#ld|7i%*9m=+?qAh3-*^@ z+fLLdey=~Ex`qw7YJKuFduPh-EPW0WRtKx1Z4*YLtagy^yx7PvnHXTmMTDx5y>38V z+Jtu3Ler}v$H$kgIHRr*;}jj#%_AMgNbH%%&+i0sFLRsIRW!AWZv-OV-yt*&{iM8^ zzheNM$V8e-Y^K89m2Ru(c)q2-3XwItU~?o!%zD=+R$UY&+L!f8?{ z7k??sou|z>Hp18*{e~p4o^7F~7^$AhL!%cUyN&cPeRxWo>!6b%cfErec4{=8p6MAmewABz(^*Sit`6}joUroGJxF*ReMv_+3(XY~IrXl`!|+TGc(AS7a<~{S zP)m@jZ~Ce}d{%1csqFFA4Dttw(c)8u$0iS~L0zb>64 zGq5|qTI9GYe67yIRrn&6P;Yr~lRb4;EDW{ANe%nAZV8=tnQqz19sswnQNk0VH2I7> z-!C;|ue+~U$@ENkOYp~p+a)-~%}*dsUZdW1Iw(6~3xjOgsOZ#l#ubQE>by_;UCaY% zb0kXh5a$5OLM^Bpq!}sU7QMKyh zcweoPj(Yd7;^U@d!Q)QwN}{%dr(A^-SjN;lZ9{S`XbIOgpM8I7S>POFrqD1!vA!N? z|LG?>%wNOkS%Fve25x|MyY6$^YJ1XbwsNzvm;*v~EGuo};8d*z#EvFfhV@aCG8`DN zQu57cM=8Uz712jRXV@`3%J5PwvBc^5d1@J#>|`oC!Xz)!7pk^#i5$J4cQ45-*OKfa zks(LBbgJWvXkul?*)5lUEm&+#UjsNgZon)s!nj3@a!$)AD6} zq6hTlx5|>jDP8`6gYBo2b1q&SZ(v=?{4dqBeBJmJ`~j3TE>du3T;wD_Ky6d`$_laL zef!k}OSNg1ov0QfY^@j4ZZiOWOs}sFHNQ-E84c-LHa94*yqYE8 zlu=B49hVufmf8^g$4?0pp=7%@^yHR8oln_TjOc2Qi-ToGj|ZMsju_!Yq|7TO%U+TXGbv?Guieoi&Mz9F$S$}y0 z?uU{*a?QI6i!?c=%s)SKbb2uttn@hjs*?C9qF+vL$Kp2__~%yeSK;-7=2oi4phApd z!AYYHK$&Vg>#1&NY->9}>E}z;FWI(3_(w2y`iP+JDz8>}9w9Z5r-GJVj~uq+M(nAi z#Cha|z%MqQK_BY<2C>In)15T5z^&VQXFva`Mkk11tv^HE$Bl=Zd!f~iwp-8K6l;Xb zHp~#coeSYln8-&Y$)bAVmiu^32&v_3kbRzhj8Jptk$Ghvw7c%qNB9G;cE__TA0~Ff zgdFEQ`A{QggFf3mkQ@y^y6hRYki8p**b4T|>70S)Ee5!Nwr;{}52snDoi0YkLANg` z0af%vFo|L)$URq7CpOgv&Xk%Tm}OGYCKrn_X#^ryp6lwgWz73 zTf{Bu0s`H#Gg}SbpAL{T#_8lG@Pnq+x%i6)NZT11^SBPgTz%2A zM>*U+7v-4~S>StwAW)tEx#F7gzVR%-CMG4qp_j`WB-UGVvVo*@6`K1vo3pz54_Qf)EBoqBR1^oy=$X`04&Q04^tbkP zIw?-i&^t0t-6+<$saONH8rr0jSe%oHGIhac7M0!-667b!wPk5FzO~1$!|E#tXGKgq0aFo z78HP7Z9$ZUQ+>?Vg_U!;bGGkP{JEF^2ja}83 z>(5hrH_XjoyKQ`_nuU4>ZOyg%lhbhkQAt;86XAmxZ(d5}eA-B3M!`ApFLkn3amx6@ z5$4AMxrzDYNq#C3x7o5^!wxYieOWWS7}kqKTU9twKn8?&|7O*la~(F~tNTmKNtb-C zxE-f)9>Gcqpmi)vgc?Ai{PP*9>`7)O;t>~Det9ghXH5}Ve+_}l{TR5tUF3LulU7%L zwOZz1T5OijeukCL`Qx=unWm#gHt35Js@amD2PRnpYp2EstvMLW60>^M?#g5|nA+|c zxJ=R{X<1Lfj4N?6O5lU4f(hbQPK=C($?~YnUJ-6EuRSiOJmb7FD?h<@?$F9QR`4#ig;dYLY!dUY*J!&!wS5W7J zswyi?IVgVDhn2lqp>q2m8LV8r`Ka97y{M+7286~F>V!tl0@PlZ z&<&OBPDxx)a5}3?563<^Y8_Yqn>f=RdbA zKmaH#9Q0HWqt3a_i~H}}IO79vc4TvkF$=3}bsU1q@Hx9W7}@T^{pa(+G_E%Lqzw=TaMtkc*xFsLCr@| zh>?F)lzV@yY0FF?#=OFgUy@7aD%&bZSTm@Ry^>!D zWn6W~L%FHFVJxBJy)0lBGA2CCcEjebs7|8Xgz@{i1a!cl8_m2N5r#WHJ1Jj6;Dy*Z zEU_!^O>eg=<;fB?Kux00u69@D*zCgu|Ad93(#~zfXWE~RLPOOimKe+dXug+KLsp(T z3^S8+Og!lP*+|56clIamgL&(8HZ&Jwd%aZcLH}gld3MR9jwxTs0FzgyBdG5FT0F0u zg*yDK+=N9^1-XN?O~~r&QN0i+b0u;r=-RXT0HeF zYIbryQ&p-rEss{bUfoL^?r^U793|J+(~NlUY_$KCd7iQ@1 zhYZ7hG%by|iC$~YWMJ-5zq(Ow?9G}z(`6|4hF9BWQQFvA7SsKsiZM4pZBKMKyk)RP z}&zv8x71po~xjHV7!tQ`CHX?(B#(?A^M}(34#}!?P zUn36o6PEY3j(Y-barh^M{%7F^DoEATdF*+6)jZZJtS>wkQj?KkRme=I|^)Wh$nHcYQWh}96izO_GoP22G@hteQoZjPK z72pmhb%^ArRhKgDlZUKadN-5mr)!Cr#o!i5daz{y&v6z;B=WY3$2 zh#R1~41mVgUWi2_!txxu*KFONi9)QaW|xYL(#uN;`dL{wG}3FtNC@)b4u!s_8op+* zKzE)Uzd%*0$Cqjf40*N9_bs$9*rD$T$pS3omV1wbKXi5qslWUMMU?oR@kv&llt>J4 zv|?Wuu13|~Ml^o%(J*Ig`*vI!WR-VGsua9I;>*r`kTw^!^1{hBJMA#Jr4!3AaStV1 zCo6YaD_f+vy@DGhj-y7JWCZ4&3LhP~Cunyye4f(az|@aH?Qr@K;b2Wu zy_#~!7Ge8wQg((1G}=bYY)`Ci`s>(FK5ViVZStSt)#_KY4=3< zw_ey*`eK`?|8EqX9d2Pt%;t7pyf5~vtFh6RerWf@@BTnbZA|~C-#*`LR`;|w(Kxoi zNE`Bbr49)^alLH3#+ni}ZOmqg&!hK)kEVZTM8|Sd|EDTffmSYiS7xo?pF67GI3UnL zd+a0B@bjtC9*TccWl(#nkQJxfiI!pY#`7s`6Z9^vL}vcH5>c_=FWK?Bwx~i?x(K;)JLimYYtUh6D)-2~&fQ?O&IW1K zrY3UgJM?w$NHwIdp+zD*|a(u!Fb8tugXW8ESySGZP(TsrpbuGQ>TiR(QYw3?co z6!kd~*cYStSz%u*Z8V5=!wrQW2>g3nT7W@5uARmhUq1;G(xc?HE|WN z0*MqCgr*F^Cht~P9$}d37f^4^8IF(X;4ba3F2UW(2pRK~3#GaRgrnfrIgIh|3>Se` z0FA{U-mBopK-s{t<4*L^)!GJ*T0&ap$g#Qjyy%3J3A6o+{ae(Y3XMGjY(^0hmk8{) zqp37m-8y=|%b%tPz#tt2awWtq-}-|_@@xLl#(DJQeOIZjIo(~1jB$xv#Rv77Mv6)a zM4TyqEhb!>c}8>pij`N#*O3`0Il%T9YQcnG5u95oVZR+cwU;HlpC64JN6)p^XP~?5 zm7P*CY@+3`*(m$)MuA(_Z93Z$A+pV=M5a-^vOcVIJ_LWWlJ2JF zD~sOhPWP((nrm7KnN~_o(St*e#SU8*RO{4`TuuOqs6!7q;Aaxg)n=pBu749{;{gZ^K+9u3+Z51LkHTG|@p z25${R=nIqVymn9+UG(Cv!&_3z%ypFLF}6a}A?On`Aq~=_7Wp){yXkcEk||{z+qChS zno(3JT79I_3Li?qXj4FvoMp8~NuM;ha$y!V)uHzOy_)`D0CP>Tk|mfwPpvmR+}&xu z7BIa9d70;z=p8nVu$crD)L|fp;j~xx`2i&AWO(cBo#JC3hg_4R0dJ8pA-Kb}=@x%7 z7pdBb?C@SRep1Qay6;`X@(k4H1-BqH9hx?nX*x=o|rJm&zV)h~jFvF+Te7B)HM!K5aZev;mW9OexeEUc+l*AlPuYY3Fw9sn6@y9dTdmXO3-OSIu=pVsmm^t2skY|e3abq zXTea3b(pgG4gNFXVTeJ}{pJ-mY~mZMfL5O8Vc2EN!6;Q`?`uc*#YVlzNWwNOcVqKc z|0G`-VQ2xcUM`(oftptg&X_`J&*(D4{AO>V>86<-pR&}{%ArFDI!*5P-z?_@erJ4) zjbYKK?@~~q_h7MR9=W=-1hVt|)x^tVQjOS)!qBXz^!pb@Z>?b$1L9)BbJ0iGWj%@o zQ;KTnl~}{$(`;<(5L-0Y5c>5lWzxhM>F))q_h!q#Cz^=qvSLfheI0kwmMmyoOC$N*LAQCDa~va;js43AtR#8tSoxf1YEUU0(7aqc#4hFi+j z*DqXTC!=y*(lu`z@**Q?xs{m-1K*VkEQB{!_#8wEm(AN7dv0TGM_s!fFyTw@7Uu8d z0yHMdG;zTWRz5R3*QK)bnUwp|oSDsG%mnx}x=^ePz8{%ckQL9|N=|gXik{sbq)&r- z@NQQnt266|4lxDFC#usy#YwCbpT@d-_D@Y17sm=z*lbb8Ok?<6-kMXuE_Z?TwtL?! zCgD=i;R$I^Hk>ums4|r$Y8(tP4Y6B8Y)}O)wWqN=e8!!v8R51`-`u^NGl(YayJx|8$spvMjFrghNpnV+^noIsN}%q(~Tv{aP1@-9+t#-EtNM zoyW(R+J^Pw#I4!ex=*OXmVg%kvgdgsV{BHB_Gv|3hCa`O4p1d;EcMW*zfQh^kVJC z4;HV(H|A2H-O;E1Dk&(QKfC0~(qA?gAVGJJlvTI$(}r*5Vj?0SQ`iAhQVrKKY6-=1!}X6TCEj^ z%~olquf&>AR4qiVQFu5QpW;Y|+TBhm(Xg0PD8*sfv@w*UR2gC5K*{=MA9V#S!c>6hCDJg>MHJsJ*_cQ^8PlQV;EB1xUQQ+Ao-DNkfYqn3`n@|3& zW=v}FsXR;P8>n-sUav|;(@4LD1Xp9iNt+0zWMxfb=0IqyV?y)l)$`cXhFif+7fp8-&(svpWhM09ot#^xVyjyKmAo1=D zKts$v`(6d;B&<20oNF+yX9dCTBsH#1#G*_mZv;pFkyX+}RIcE^Jt??_)$5tYqARAu znsV7oo#YFTTIFV=Bdr{*Ks9aIS$l?IHtdDec`S+pBx*eAnNT0@<3S5Z>z#{X6T{d% z-H#t5j(__Z|613hXZkRPvc^8{Pam3V`Yj%+KR-(t4QOe2;K285Hp5)SE>>M`pBLNck zD#rx|?yj%@xL3E?7I|f&Y!i>B+3{kaaPY)_i)`OWi+Y`|O!K4W*BoV1lb~klja0uI z;!7LvwANpgZLiF1XNrMo1gx^^jzH~Y0Bo-4{DeaCk{-bTK&Dzbykb?@VYZ=1#t=2u zUt`D#1ur2bobhz^R10NIDo&~LLJ28Jd3{Qzu7ryJ>^XhkYlXgKKOXBDUWTz`_T#P- zCY)*B9UI_WpaVLy7wJtCa0DGDTgjZ5I}ve)bTEHb_u;6zP+hmz#!CMsSH^xgEF6*H zm(oHUS6x$-nUF6E%dPRS3*yE|c|?5IBSWP&%8P;fGxsxn?BFt)WhiYS6eB*%)M{u+ zFP50^kXHqIdyx@x7)2J06br=2mN{b*ntoz@_>_(<-f5x+=#tg-aSoKt8k=1g$D23y z{hw)OmU3Bl55X4wmhDN#_>ud)D0W-OSNAn2pF#eB zUQjFxn?QwV{M7(bG9g%mc;*8=U8?_8jhbW7E~W2BeT>`Z96Q(CQjE#Z$z7ErrvtvL zs$@aQH>*3LjFzG4Mq#WvV125I)v#3Nr7o0Zv*vo6_JMa=-cui+xh<`)K9^?n(^Yx> zk-e`@OyX_(`o>OaJ{;H1tP83v!x)ua`gmS3iSy4C^(iUbSG?EJLSvJ}G^=L5{9|vq zs3WUruBEchQV&qsmi$&*OZqHXMSa)45cpAc98ie&ctk*byPp5(ul8$VJr-8h4ZU@3U{7*C`)Iqm1W37%yv%1<`|be~PGJu~00r5!BISc;JA#ApOG z*CgjS-kAaO8b?iJc8|AS214Lwu$iWr^D<}SmvoGw%lS27(Bl@3e9#T2A1Q)a!FSeV zD4@N(4^3=Fq^X^G@w_;NoARQ{jggg}M4d=ZU+`a;-_P&*;6^q7aP%@eIaSF_cq}hb zx+2S$5Sn+1eKphp?YYZ(^IEB` zS-sz3{IOg68@4WZX?8-!E``E$bMh;$-Yl%@evYOP{}`)&4z_4w*mj%ViV4Ri%NK0g zxRE!3x2RCpbnPyy;~#a{hFL)jEMjIXb;lN#irgfqr0Td-eJJx0D7khug^r<(Xc`zp zn0d3vp$2FYe(Kx4C@0k_a?-i<yaCtB{!Yp4c~DH?j|M93Z_SGTF9!LPW@?6PjvgO*l^m-`{N0sYMLmI zT3eRpiO+zE!LU<0#DaR7CYR4QL$uUeYKv2k$*~=}IzHlOW|*Cb)9EHZ zmZpVca!R(m9@Aq>Ww9j6w`0wwQ)hyJMcB@kGYxJrNXuc8a@iML=<0i?>W5BJezPh; zy{R5UT9%H5Dyr+W&U|?)gL=)a-f2V|q&o5Sl>Pv3A{_5x%fRBXedAN>AL*Lc&a?Uc zt@~JSjutw>F*r)$v!Bp$TY1jigxX5+b*Jh(CS?0CHLsxFY!1o=R8hxX*!tQ!Sohys z#m{h^{0Diqo;IJy!ug^pE0?yZ#kyLrXHv0zdbWOb`E>o3^%nG9x1+bbx7jA~3k&s_ zX&BVYQww1|E$7LOiC7;^=2iLpz8V}G!>WIqDSo1D*ZN{kBYr*oa9yW$Y2EEr1*L|m zP+J?+8`sTxYsL-dRdprL3!R{9yeSXMU>O$FleDtN1@(NN>t)Zi8x_=x-FR}yyCFM_ zSHH@V=B3?U;i2QoQ-3@N{oL7xD5&SVr46-Z%v5@EXpErVD670}^|Z~|u^qZPKH}#k zCb8osI}sPA8$Onf&3c@TW1}wXjzK9OgZXqJr{YUyEMKSXYE@HPnbdFPGhfaBZZxcbinH?kHyP|@MURUi?EsNEA6<=Rd4(hEgT{pEgqaWy)0Uh^~oImEd zk0&_>N2opT`}0NZd8=-oIy!&kKaz&&{2r%SZQ4d1U!`NLGN`xQ^-;gC=RxZp{v3xA z)t0MyP%pA+C;d)nzOGng{iAJaT0XJstXYfOGD%QxzAg1C3o%-lr|o>cJmE8~ay6Y- z)7S5>7;hLBjt!sMectSPTI*^vUt4kcn+@u1=3HX))_0ps)2u7jTg=P*r93Q!>1%^} zrHU&KYXNysPvdNSbvHi)+lw327--qq?qNXmF$;y$k zpq{PQzJJOp%D+`D^+p(XK~|}C-Hu}Qu5MnZv0u+)N$bq#B|*K-{zZ4S#QJKks@}1f z^3OxNeU+ZeW@*y$jaKyoBhpiwJGV0f*^UO5Xvw8JpoCa;9_Fw57vVuXqd5ina z>Q_p2?1kE_+`pPn$Gf_1>Rr=WYRcB3)P$91l6C3xYu#q^O@92Buh~-id}qA{vkK~o zmBs4Mf_jw&sTYb?J&*l{36m7+&G&~gDK%N2=Jor?pKm9+>PzdQk2PtJ77#00*|8hPSIwg@_o|>?u^T!S`Pqm-{aUv_ z*nO{GTDCFOLA~Oh{-no`mS@YJ{1_~o zUR_$b+HRxOS(|yZE?dv6*R`1P{Z_7b+s5*KwYnO=f8r^*W*WcV@{&i>)M=TtTTlL2E#GKWKQPQRudTvbYS)$ju4rzO<3cN$N9h!8d{u> z&W~Y|Y}*IdtA15&kCTbdw_6a@YewssB&etH73awb*sQjU;jY#d<52a}6uVNLm-Tp6 z)0*WTA=v7ZLU$MKM-KIM{-sJhA6NY0zFb?}1pH%pY zx3z51k1@TWH*`zeQcu&_hhli=piNz_lKj|?T^%EKoLGI>@nR>e(Z^4HJt}{Zk3lQf z_-!n*I%_kJ)@AFN^}3qby5qlG@3xJ~w=!8hrPIxe%AoSp1&Z31$F?B~-znMmNbJ}D zw%v=!m#yxC%pXH-OCD;j{JlC*{zH_OuKrS;hn2=*{o7g7_;wd$^SQNSD_DIsr8=mCG7pA&+t}b8oej8OjEw6rCf7omoh}_eYrZ^sIgu{PdRn*9`rGbD+lDB7r&v%g{M&A;CO=O$EpJ&?4^@0w+Lk7i zqWrx&Fu$N)e#|AyT?vzHkLqC_b)oApf_js)I+%1H&su%?hOVWjqP{LRy}rS@&Ht+d zsbx@4)Em!daeR@TuhM1OaGILXd`iO=u`D4<~KZ_jH)3PR6p6aGu z{lw~QU#E53tNo*tj#vM<{WjTt&Ep0|etqTZk4F5$(pfeW)EgsgZh7&Uk;jrR(?nI( zR#=D1(QdoiPhYh8&-W-Q+a$+DQCddpAMVDws~=zNN_Ae=<5f*-%EK}=&0R-7>r0hq z+JR;hmJd7)TV6U&y_&;visRJZrlnqqlf_ke>7p4UDuvvC2q^YRthe<1gbDRHH2hxb3o(W@zrl~(J#-64c z(}I+yaSUoX`plF*H!B#_+dMn}Gz;oAJtn7njhm(>>(_L)@7tX_b2#|nf@_!=$cPuRGzw;E_P#3PoLxF!sga&^2q8^5!=>Px=pQUn`}E* zfwsl=-vsrTNawZwVt0H?JsF)BIzc*K#tlm`&$<5}u784w%HH!!00000NkvXXu0mjf Dx4d~+ literal 0 HcmV?d00001 diff --git a/docs/images/gui/gui-conflict-mark.png b/docs/images/gui/gui-conflict-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..8f1c64b9c31da79dc7aa4329e3f3b595f8f19122 GIT binary patch literal 116030 zcmYg%1z3~q_rHbIi-44pf(R-lh&1DsG7zvRM+k`IM&}5F5h5)hEg+$EElBC^W*ZG7 zM+_J+;{V|L`+t?|g0WrCb3f-k_leK>oU`DkS`X-G*lEt3IYam8q3ZK9XD*P=oH=hz zbrJZ)vJdMAyqtA@{y_OmK{w|T@aBT$ea-u4&J>5!9-2}B@2MRg8aSUhbE)C<_iT%O z&YLr5PBI^<-hb&~yqZk$j#F*?*w#Io;p2s}L83m8B>9)wuE(1DLz=mGUb+^tW+FI!3@x=?mgESy$(!rSA{d%;$-Y zkJ$^*S$KHj}Aam0vkPMaW|e$uAgN=HocHRXhxzJ&QStlkn!;FP%r*d9pn7 zgg=Z0GcQM0Yw7o_Cci!T(K(@2o;j5L5L_nUh#R0<&vr?dg5EwJn=3FvRnfo#<}^UL zpH^k>ZTk9nC)BDFUYVLa`qw1SJ$Ysi6!DmPexK-bl+% zX4~?HKZ7zAuRjA9e36vF0KcWj^C)&UJQ@@6d^YM!l;C&A8Dp+Uj|e8ds)#6Ut)!rS z29tE6*)u+l)Imduc<*O?x7zjN1qhA|)VEyIwWw!|Gg^&1Xx|(s_+q%5s;dws71>JT zInk1cWGnpk*5kU6mzh*gpB=Tf?0mx0we1LXQx~%A4)x#CS0ZQsx{ClAB$W|YNK1Rh ztw!Ip@mm|(?+?nvyO!?$7E(EAt5-km-=Ns6>pN|4jA*$8ktuWH-5Rw1jDT%+YoJuqM`0gXY_Q2M}YUJt- z=MUiNiaN07?tsH?YkRUUK=}Z3X3MwCDU3vK6>1C-hzpw{OtM~rH6P(RWpG& zI~g{+5?HTYgKF_am#H`0Fk4aZ^VJB3SqSF9q6Es-K1?|>->~r$BkW(|-bvet4^Z`q zlRHqDz8i+A)odFQd^Ct~JHN95iR33;wdI7jmtOAJ_$<#3KZySC+}A>%`xS*0gjPJl zT=vz9P90OWZaY%8PANn2-|Dck(-^s*+iA9AzpQ6NXAhtK0Cgrcy(?$ z`nx{n0P!%2{P|v(^1MN?=FP$zH_KD0coV`w_R20=kFWO9^2%`^*o8cQs$>nWU!YHg zPilt{{aa#U(3AK!?+Dho?`aC5mr3b7mzb}FaE$#6O_e@0ohA*;d`mmpmch&w#L(@U zBnH9U=B;WKFApj{a-pYlPcifF$-ZLzu00s$NVeGQ5qLCcopG>FOx)q@&?*2u`c$4@ z8^@3C(VxBfG9^a;Th(`pdntdLYDTE%#-vYu)kf_=%rGM2%)P25t=ZJKuIAi)!*C}L84D|~pwV}Ks zmt?d$^gb(l-d-3w{7MoMU88hKbXkW<+Ps_rBO)asi1w`D!8 z)xJ15q*&o=jJr$B30^}AtBoUQUu>hP`!42a)AY&SviK&ZYN>W)^5w(9BhSUowNWgL zh!4cv95uD^Lcz@(fBAYC1}S?c2ov^EX4Vn0VprLqW1$H6)|(jyF6q?zdNrvVWP06@ zv@^`q^0H~!3aa!$5*b^iCynV^;#(^zBLX*)Q@=Zanc?`zqe4(qd&v4-ID(br2 z7+q%4j9ZSo$OQonp^3%NALcG1%XNyD9X%-+eMhnIsBZ0kRmSXZ-*ob-_CPNlu6*?6 z4H{-3DHsanKbfH!s$O+S{!;VSwe=;N6-oK!o1R<3AoaC+*Qh1URkn1yFYD{c3|#a( zeti>GR=pysmJd?jhXtpAf9rFcG|V{OKOznR-=r%pe3~$+oaS>=;8>98#V$g)K{*Bw z8|Z!nWwi4~+{y3tE$e#Illwy+3K9`Cl*|h2JD<*tbq66tv91F|Wd_i`aM%WFN3|{jRMxn+j zWk(M$Y;UgO!xFVSsM!F;Z9C*V5cYCBxlCGqrr&!zUxa-&7Bg`R%L=z1ua21zQ|*{K zfzt4*IxpUwwAW1Qoze>`cz58pZ`FkubQa=CMvJ)GLqZBOCizC;j&KqLDGGX`fl)@$ zXJ!GupnDR*v1CEI-RXbFPwn}|kJjhw-y|trnmzKZ`4^Ae$L~#r%UbTP$`V?5d|mEZ zc01jD+vzeiH1z#dN;5+&hneQ{m#Axgy*UE^Se$o-C@a!1Dkd$;$@b-b*>ZNx)qb(8 zmsh3CT&V^8bHce^l8g&MS~GivC9U*WKKmrR8OS@vqkKpyprozBM~fdU^m`Hf^J!#` ziic7@ys?_U=^*UC90syhdJVVIcvY#reElhR0vYSIy*TZ)v%GY$y|i|)vyS)LO)At{ zBwCD2B5@U{?{^k&mC?h)qBoyLq4Zj9_zxWu5$nif?=WZU{r!4Lu?t%93PYMnGHrB* zX5Ur@kZ~(R$nX_$6Zeh**&u>+@?68?h5VB{&dfr3UqUvh{Y8w^cJ2B0^S^4pN<2`o zS)YN#sgtcspWA@HcJHnZ#dWBZaB&>KE@!d9E_SWtr{^y{=_#}(^-KAgZll}!Vlc`X zvo16{!A0fUS8s=nXIDQxygRX(I)IfWV)K^nVmp`a;^qnk(Q0f)qjax4z6IM^pH26{ zbcKk`70^zD_`~0$2KSab#XNg>+Z&r0Fo%b3kuq%P1vR(_Z}%yHdOAu3yAi`z0>x_f z`YlT0UO8>dKx>Q@MeOl>6(;<;mwRNbLIS)Vq$#wyJz2OFJJEaB$~c?_^2ieArdgR9;N{Wrtw&Q;4@wyt zHgIcDu>X}tYKF_8=vr`nDzSSfX`wS=t!97QR!?4RHkPC6Ns5}WRTqACbZ*xfbEweA zTJLkyCF!%+vaMdLnHaU1Av*|!4KR@1)%7y{WfpFL4fxECeFU#|dcJp^-+%GbqWy=( z#XXQ=o>*K|_KjYA?rgfHpud&<_amd*+sMa$4gOYQ-=5KNmsdICZsX=2y;yb;rgF_U zG-OWY7b#$-N!ELcwQYwx#q`u(eMo>k>)|2au^qN^ZZdLejfxfUXGkxWJYGjy*=~1^ zb1R+)kZC^Mq@pQUd@SbiM4)O*1SrNr{|$#8sx|Kd2JRF`)!#g+6(ijr*a{D z{!6|}{|KjuJ8Yf>v`sYN23FAIJ+UukFk%DInm&AfvxXtGOYAr5mE+LgpnGK&VXbdH z&N>&C;lqR9T;iqUf<ga2jEi~ z_W_w-wIi)3?$jkz^|DBZK3l>hg<}~d)y@soCJ3oF^y zVZEBiQ|Cu)3BuBT$lKEP1O?=jn|wvJA1rEKgx(N&%S`L#u{F^Jew04D~OO|gMDCPPXE8UTz)k-Z}*Gg0RJ-`ot0)B|_v#vO> z%oLie)$I_WKdAW;KK@e8-7@#f}VPVF-Jz>t#Z-{dKK=}Lg)E|D@m&au`XneBPUWTu>n|0mrSR<_F@unqdQ#u-( zC+louXi9~;knX^R_K|D70!%!i3*3qPOi;}|k~$d=;Mw&lO) za!pY|w0SH$l`W13u86>l%p8?RG2lCH*uOGTT}kK7vyzqbU5{E7;Ts2=T!OuIhW)6a zIQ7XsarA1v5cWkOXaTf;{Bdw&M!rWsWw+T+OaaH`_$%_qIUdLFNB9qZE#k?6mqZA> ztD46%8q!O}l(=+aRwbHfW7w4>3x!&^kL23hOZ%lx6qvZld$&G^er&kpdR=;KwEI@` zJe#qBw#V#VabRjtIA1JRRbHY59%HxHjOY+XewN?pJ;LToPjKUdP)AwvxVqU(WiB53 za6PO*qWvnNV$E1y^ui1_&cvGq*qp^hF|O=SyDHlantX7Ers=y;|7ZsGB&(j93lJ4v z(d2cpg1yB@3Qx}cFh%or7yz3&;3!xTMJ*qCol9lgeRy5lGNa)WOn*)D@Q_w|Jv0vc ztCLe2S<&2;B-N#PvSf+eTV#a(V0nN(?hu{zqGMmQ8J=hrLG#jC9sBb_ykrjAf4@7^pr_d7j9vl*tJ2c?yrS zb+HNiFD8A#J5N(6279GvQj=fF9)Qv9jDU58@Ce#g3Hw{oMV<WZV7bIFr&<$ktUE z$MZ@rUu`ruMZU)ZbTYb=vJ;ZqJy?ORed6wH+2eRV&S2c4g5(hW)2m_`ei3!m)_yJk zS{}MGmhJwro_nHEs3??h?u9T=rZ;j z(!61$UB}8kDLEl<6{r%{Q&mB{sv1l*-pNLzADe?=EW$&H zhO#4tDHyNU2m1r`14-n0LA_{PPiOMVLf6;n12IHU?$11zm>3qQ+oW$ni#iw`$r0|V zgR4aU{P}Zlbw(Jm$0zX1bDB=5;>F3a5`t%IKRXqJ_3VVRb^8U{iWVTJjGPovs|254 zDTrFqS=gZSG+i1ucRPtl2;TkVn?wD~cRT)aGS`k{-^)W(110ace4Fo2UyMYK5k|z7LNySx}`m6Ayy~X?upy7 zQWs9@kqmx9Xnp<=)wUvXk^)TLsCp48hZAL*5Y`ui-iKwNnHwlA`iG0buj@LKcng{$xaR9XnsGV|F_ zJ^s;o|H?aM0zsc+a24n$ko-hhi=QifwW%v(wjgOg(rxK)+S<~^Bg)WY@!Sd!t#GfE zDgCgqy(Vc%Fg0;L**!xfrgh%U0PKHWJiO#x$+eQ*5aHurErE7-+^W-ceGF%b+ADcv9@@YZNXFZ7tJrX6UJm!(3nWjf5r!mlF(ne}OICw+FOOsYh z-p^5Nrwe#KkfjwGzD&d+nK&$-==44A$?2WBnrt+s;d2>PBE{0JnZn&P{~(qXZ)N63|j&$v*j67-MUcHZ8B} zNP6b6XqL~qyTH}!`M7!+pUI&9$X|l~i|iY?D;J;L4*V?Nnt#vBCr(QGnrS9)u0hK4 zjY(M%k&QILWRJ$S9e>4ewmq#Y?$0>~ysN_I%kvzR_?5yTpUbKuIucX3l~X=>-c!Qk zJJsLX*v@?9SBP4jeQ4+pnJZYazn$~1TXb-E!>{x!&kVj>l(gIveeq&;!k27>To73L zkYGtz9U~kGra0-;9xh6Bo=5$wB>j3n4ca#!8|wT<0~$~?A~QqITPet#>S5%HS+vep zY&p1!gwN%7NL#WxXT$86a#pf1JQYNiR3@#=f)FGlv4 z^412*$TmEwVFmq`m0On4FzplA(Acpu64d6a7UH&36RSEI15q2@+JV`0D4yTW8XowV z1luo;!+tVXR~=~4DJi&Mt24JASO=DW>%Nkywu!C?bTsw8+|NEA%K^ku(S%6a{5C;* zO^29jSf#1_+N%1ShMX>GY15ubhvG!2?Di19s%M|&W2!#p`dY5!&2=i$oCB1W+-G= zc_s6tk0o7yg5P#t8OaAr>*+M@U>^5sJhv+nkz@1f1{QVONH$r8nM}3-77P%=XU8^T z1Jw-(KKLl=e=}wSW@3JZ*Wc>IDoIdPuQ9w|@|7)@+;@~9 zfKu737U|h_ngLF$m`+@M}LbpBpG-&tT#fEh2MSB!O zGDQvLw$W40a_tBIo-F5gWo%Nz-d(8a&-n^IQZd%hLA8I-#OH5)f;3Fc+SvSD$+YNHzC-?6GFFd5 zPI~olxh+Pz6`U&kH|X$ZSc?o1lby_$dF$_4sH;0u{fW)?*!N0M;3~Ab>?VlxN#rx_ zY;=S`hvNUg;WTL7yiEGP+fNr-{3fY|%>_$dze*!k|JP;yR#y|=p!Mqf5H?U|_6G#R z-Tzqa|DLXyA;G-##6d{nQEKjM<$t$czSUlhrBj?C28H(@^85AiUDZFB&pUAPoLwnT zM@dh56PsXv#7@6kIv1r;aU)ir544PxY;)>Sl?O6-hkHw?p-YC2Q^t_OE8oYpK(?;< z_4B%2nn|rMbak{Cq2#^iKsq^|JSp}0BWWL6cIw@E?0ZV?D?F}H_WT+Pi6F0H3*+6| z#cL(KA$dD9DB)Fg-p?Uh{=Cs``mbw?lU#=l2udgW;qph@1314EvIz!S)~5~~Ei#4X z<|9fJe1Dk!z5e2GKDa9Gd`79H;!P2I=Z7|i{>toZ0MPbRk^GXq|1-{Dvs@%Q#p|d1 z@!l-Xv4zVLBbe&lUwydML6EN956y(>{k@*&ucO^0=WcP8!iYd_uU5s4U*|WUUGQw} zkWCH-@Qsuo_jB%cv*(abrHlOKpClFoHQ!UZZnX)vJQx%cyq}Kcc5GxMe7@U#$F>&d zKB=h1_(!o^@WR^T@#_7BMCf`osi3qS>@f|;{d#|1q=^1|WMEa&K)hAi)?2=KdQT}B zmG3=Ag<+?|0lE5#cW2KpbB}+Z6}57#_S$g@5v|Bsdx7uuTJKe%FE4wcCEJvDJPfv1 zM2_3!HvRbX!1N4<<9&Q(?eR__;?nt7^kn(9vS^GaJ{fW{QhQvUS*$(Lt!@F$#8|O_ zgIJq`gg5p3h6|y#xpTU19ngv~U&$VB=M6YtYPP9q;jkJWe{Lkt{_H5j18&dn=&8$L zpo2vjsR#Wc?ww!7rL-rjJc-yIrIS@-@69H!g~Y$)FyH8sp8QIQ-|7(KX_>ns@g^uU zGW%{n)qq^yBp+iD1J(Pne-Nn)S(xWVmqpw2^elOAEqWpizl;-bm6b|IOWKIN=Gv1Z zd*nibZAC(YRT{%z7EpXe*6BOgYbkR!;~+vkrTd}98y~z%cRqq)M^&DQ%+SxUd%503 zh{n5#3g^VQsh@lyx-(i!uI&qkNlT_K*>fR2R`id{jZ1e3mwwLAz5~U|Bzon4SsHFt zG47tmnwh?&@u_|jRT9*B@5qG8_2ye}UV6_X&o6yfe}^bVC2Z?* zC|vUPzH}vr^}F~4w5Yjgfp8XMGV2+8X$aT+TnYv9-$ zvMf8SUv}YVG`ih0^bG3?LJ5t-lwr}13{7e~6saTYu=;I>#K!J$632Z6qZtXC^ZFc} zO3-b(XXY`5bqTcR%x-m4 z{JI1dL;ol-U8AH=onwsIZqkR8Y=8$M%n>148&nK9Iz5U>TcNMNRb`?(;EUw{&XKB* z*TAm02@4{x&I&yFI`uO*w@aQF26Uq1jNIbk82eh>%){&So(7*Y?)Keu?8)2g{1l!~ z-{&aDlEgfxnKTlHb^8;ZRa?yZzds@XuHX)wrY|Rx76Yo6zpH7s6oIdDG(27+t@XY4`;TN34=U>%lR+vnQo~QYrv_V=;z%Hg! zytmr+a-0Wb9D6DUviz0(X`T2#n&_+iz1n8y-5&i#npUSjdRGp*^cc`{rjW=>SvLES zz(eQJKTA{bwrg7)JVscb)f+#6mNhcgw^8}j+^OxEC6ZBdHA<|1X}S8cje#+7YY!zU zA{;z2*x%nj6ZNt)cOPwBpe%S0a$o-zPrZ5Tn^C+vpXr^dw3`u1axK}_#9cC>toEd% zo9(Zq6t^lTQ)ko*Q6F-1a*BQ}S6%SqY{v6E0B?Y6OY9}MN9n^=g!2_Y!2)^Qz9fqQ z#k4RI>qIE+0fmJB@x>N3w$u^PMn%ma!T=$zZHz#0v!j| zzkXFh6#e+`q@HeJeHMwO!P_`H6aTn6z%c}<2g4?A7y91R{U`c3Gx?*d$-$do>q9dMz{PL(o_-E>sN(GH-tG<7>hUpU$|2Bn%-U}VopH@%$tWhgjLht|H zOl(#Pszt$rB?fRr`dDsS75@_nQ0RW=o~( zh>>*IEiqh$t8<5FrJeT|`Vj>JYBulEU;6_9=P>Fp;*rtQjA0$rlea0kKjLR??hb7( zv1Ikfd+33ayP|~xJ$FWxEp$kB@wgHU_t>3Oim>F|_J!%g<_>SF*BKAO-N5$MuAI@@ zJt>Mfi4NfxZjSZ~o$;($TFLA!U*dFh4Vu=<2DCPxfd;ZPolD<#opzCe%>V3I70hV! zYSmK0>dn5K-qCCss=lSIj9;?|t8snSkb;fxb@hYSO0P_%lUFlsLo(S~F0`z6(eZ$= z`4!7Q9qqybpjAJvR1^BvlClRsYLBHRu@7X@uvsCyED)84CrU^mTP~ zSh7{Er&(jcP+24Z{whH}2VZ57#|JBwfOcfGL=?g&>uJU8lsU3u-t{5}pKFYPwio*v z0115s*q?*=u0b0(?5m2Yp}e*Q=qwHyhi}O$6Fwx|$OR>OGk#ZrOGtBsO29wGWdL%n z_itiotZSfG!p@)xQ*|G46R`C)#Hd+_%!5W?w=0No8==Y;3O2)Dk(#|3U8QbKnutH< zmu}vBZid^8y{SaABF^dazs(4N#LOjK?=&m-20Cd}d;4RiJq7E6eEXXHEOfcC&(08` zU8vAy#Kf;U*juOoWo9JD3Z)e(i{OGiOucIvM&8G#&VD^yu}9+Epa6wT0CeEUX6%s` zsTnWP1SzV!=xB3+k8fvt`k1^P{$ly#HyQg0 z1@}4%wo%u%lfBvS#G64KPOYnBr6S4RafVL8mCHYK?V$B@JAdc9YYe2SD{*avw@4N{ zq6!>hYoun8WE>xoOFutwjg4fDZ_fzJ85@#|pSddQfwHD~BhZ-cu z0OTmKPJ;Pb**Gn&u$vYrjbV4pG8Ij)bl3|4su2MXmdkC;pRbw^Ls1Q8OMy*1!?q~^ zx|h>lRrXlHAPZqjnQ=2;y<-iX9#sO+yqcwjnNp)p1c~imL*r9)Df3oxKDJcO$DT{) zH{<8CxJ)=tnhn@n%1$*T5pc8D{{-*8$eg^K949nGuyc92f4f&5+Kzifq|$_?MZCob zX~zlvz(%^J`JHGTId2RR={WyD;{y4pISBU1!KCQ}goJYsH+Q=BjmxQ(7xC@l2i*43 zK-aQpQZ_xq=3$ceuS2gdS6>m}=L`#ttL;KMY*jvA5wBnVRnPH7)^=DJ%%Ab&nj9Y? zv+f9CYL+b)Oq0s zK2b$qrKl1B?sf&$^6OOtIFH%T9aUN335Owzvvj|9b~3_2B|h}^DYd%*2DAp(E~<$a z-F_3MxKLerw2Q$(khp~uujL;y<(p5E<%Slho_v&gEeg^u$yZ2wdOG3qf1WSLdP@LI zr8sf9usH4OFG^pn>a8Ge>-0hQNgq6DZ|b9*WB13=CS{f&TVCt*5!iaQrO=o}AdhHG zj{@;Y((}~#yOs$Q6J9I%dOhbvtyx-W&`g3_pTql2`z{f3nQi5;5p8(33)H6N#tS&& z!(V^lVQ+qBez8u*WM;xA3X{)DlYti>f>9a&r1KEaX+!MZ2TR!k{*bE8W;z<9AkR76 zY5i44-B=m%y__S&b7~nj`0T-$w=Zn9IB-o6UuEuFY1$BgK0YG1i0^F1zDtB4;vwiu z%0}!|@Su1U`*rnm=lF>G#ntOIWZ?o{8iSobFeGSK^OL8()K3K8WNUYz$3*q^6fb>! z+~j2$+4t7wz6I58Lms?L zcR&JrTrkn{qaO`l^|Sw;h00x zDuUWa|6=!7@#|V`bu@?&u^QA+BzlMaoJK=tobmVtMd1r@tDYjhdqRe^|KAb0VnReg zJ)!K1SjnpjVm?zcBBBc2$0kU6(*!{*{!p^@#gg8S(CzYsRV8AHWoLJQY(e_ZW&j=J z=dK3)B8BAs*cZQX_(jC7fCBH7HmvN*xnQB9$z1h~O;_R4;YifA98+u3W(!Xu*jCnG z(eQQj9X!vb{>F{_-m?d(e#f3aizxN!eSv6wgBteB=V^-nAJQnOK;_eFpc?lFJy)sM z=m)PSdB{h1u8phlrQ~kBmuTfN*mHfmJ8moH9LyyRO_Ez_X$UB#howIUiQ^Hy45WI)kCT+ zZvjTfi5|4~O(ebf!&~8B%~v*R$OLEG{BqqSjZ?wWU4MLLzIQ=Jte)lBXy;}6I?=0q zD^(jNIbdq(APu;Vhe6tZfH}xbJr(5BsWBU+A?lc+9$MlVbM-``*!AT22=`Uo91B2; zWkMBE3;F9DZKLIH!z;VQ;+&g3|HHF9{) zDr!|3_uV~N6{od*`KB<=uiM`pJ13+1~kwYB?Ldk7apVaq&XUw;CIk%7iAmy;V z9LbuKW7sL(G97x#%^{-j~RM zIq5Jto)N8BJbaZs)xyUX-VHqBN|D*-sXTN<#>0L#4 zFDOe%!IY7!0`AYV5X}}6d7r>ngg2ELdsObBo{H|xwl5~!)8F9TW^}^;7f||!nVEJ4 zw35C`6Lvmx%JxG_yQDUsEp43tMUO4E0l$s6umgBTw{h#u6z7kW^kF0#A^_FCHkqnS z1FxTZ99-+KLE8W%`(aHwU~{$w6YKetk5 z&C`s>_{_jm;Qc?>W*7v^F8=c>Nm?i`r#-;qKM))w^*Z=QY!O`i)B7U#VFODv<8}3~ zi!sebrVTUI{Z|3<4RW+CbqW(Rbf+zjD`vywyLWxAYKWr0LptaPC=>5Z_?omE0kp8P zGFFTfHyyPKNnd*DcwlHzNK<_rd>a9Z}X<3OI&KSTh z(h(5qg6iE@n3;+y07{Pg-r@xKRD{|!Zydp8M}Slt{*$jAG5xY=hr~cOT`p z5+00f;h%Wmk1nzewr(%ZFYJOFhg`xZ_NXJ(hp+|(Ej`hO7H&f0Y_FaIVNMlx-gGVU z0rx2bTVig%xw|uBf?=&Hk_K3B_md-1CAXqiFWmuJC%%YOIeAo| zrEpq@VgXBdLx#Vu0WCDDV9o2O7Y-XBUx_kGk5J#5*=_TtfygJ1vHyoEG1Yr3*oa)* z!tG4S>F{sTbI#^7L8&7~?#bkxLO-sSEN4I&;3Jf@@bQ`Z3?s9rq~IEGGwUgXQAV+5 zbw4y|<1MXWqtHA;ASl2T&Y@hNaud$Z^qwv3KSHE`I+H0x&cjrUPHc4=iis$q1X#>a z(&HBJm}%ec956O?ilf6$_Iv!s5m$oVPuPUVyb;t+vD=(Jgv|%YZdA>4yR&^dbLwXpuu6rYS&g(3In^#C!YzL|(2{HHo$iLB;JD-rF zuTc8{5Gf{s0H<8~4d6P&YO4%ZS(t*AP1k{Dfz{K|kKv$$n-fCGaJDex$67!+g77<> z<$=h%EIutQ6S`o(MK8B_4WmR}ErqOzY#!{aSYl#l0Or0}WKFM~k4<0T+;Fh*JK+n? zYmW9xCx@lwm4TiI46MZgGV@zoDPUZOA*2=$25Vgv%FQAHY*rr&xRCbxVO!+K8I zSV?>0-iV<&#%vW>A2ELwerfKMUyG0y~0{| zKbe^+fasvhM6&WIde~O|X0ZB-4Xo1OGu18(yJs{MbW1HnYo04xS?k`OXooHj{y-o+;*N+JltqdQz=GoI#>sYn>T;GXT)1He!V&? ztJyFxq%P^b@L=8GeQQGH$zF?+okk+0Vkrx8I>zUsQ}G2A_?5KPrH6{_5VL%L)J)^O zQXtV>?TRlagtQP`QbA6Yu;Ty?tdbV{`cIlCcB5A=ik16w^wt)wsH!aGHz^;n2L0Fi zQblj*x}~84hZ2x;8i}xVfS8qJ{49H1@3~afWFlVN^-+g!#uzA9GMNi_1h#tVtO=>yYMW-Q!>1JPo_Na#F_pD%)oF z^mRK$ZeYEMU2euNHbrYE{Z6VOiW6QD6#N(I*x!&-X2X>x5QZEfGU2Jt;XmBjFYh_b z&!>b?ETDxN0dFM$kedS|#!o2LrxLn+exAkrJq4@7?|cK3o|W{5g>x&8vre8qvEnw+ zj$S$Qof?*sMdeNWX^X{AdIR;$Dy@54mwQDy=`f~})Mkk@!XIH`#V_Vze4R(n67o)D z+qkON2?gLgIrZ(~GW}X|9wsAx$NRI)cvkMBZ|I5Rb@9MTS6yTAu&%ypu(W%laV2U% zOQ9W7;dap24s_g7$RoE}P(H}X_KPmuyYv`yLHFi$ZI=`0xjJa^{qwV5f*4g|%MT98 zBQ4|TlpCy9=d?g?Ox&0I0xSAd0vn5edv!9szo1eJcR_j6!Ypk($ftrs$=BO^P_l{& z&o;PW=+t%t3KY>&u|!>`qWM^G!e}51_D6OKdyBe9hvEjUEAu&bV**k8%7ecKqG{wm1lR5)S;8=wR-2MId2&2rA#oM#^3jv>x8pp{W+O2) z&``s;#Wo_5KREaCeii%VZO6pI%2=-Ot2I>^yX1RM<~We}ZbEWpA=1NRrN9<Jx%nW+7Z9?q}NCj)zTFc)#+&6ucdEN*ANaLSy$Jk3}AGmX0DOJ z@{V5=MYl6dvcGa_H^VDTZI!2~;|)L!3hDL`4{44W@58T-wS9Z>gJ~0Z_YS zB;&KwuBRqvVng9~@RK~^Y89~?;REoZ5HQuaPrW}iVx8?j%i|LfC8V9-A95qAK;gy+ zbqIvHz13IVPUqz)F(R-^GgBbiktzF$qxs0IuCP>(rF+PI-WJr6mh4bIssSW07uoC< z`TjgMp^|6y(lO2i*G$Q|#O-rzAx{adI$=bvp_p(|fvHmn`I$y9*NTM#%GD&UaJeQr zoQ<@!&3+x>TQah|%!H?+7VNdDhTQ_MF1yL^BB65#7&6&ZX=pxTYEdf}dHz9-~KjDiP%6{R}v z*|Ws?vsL!8&v4gIywFy(APWaDmH=$kSB$W$W}@_+H~HnmPDf-C(npx7_Fy?T-L#1% zgRLJxZw{yMW$hF!8ilq$oYV-AxJ>1-qr6(r{yg!hFlk%Z@%6>fw#4QnG|{kjpWOVQ zJ0Ilbk6t*I;&|H?KdXDCDvxZ@{6t!jgS&dhP}*fIb0%r2YO5QDFmmZVgZaSoy)JLe z8EY<9v%HJ-a+aU)EDgox(}SRV$@tGg_6{rg70>amQw%XGv&Bf5n^eVDiA`5TSo@?=(N=2L9yFgL&%fyd45+lQ+M4X1 zLK1T)%bvK{hF+Z?Hgf$O>>I=Kj2t%RXd-RBEJ^AtXG;`X{XjdnfYqo6_rBl6bVv$l zlj}qVm0nf5)dAtvM7z_Sa-ScTS`AZ&q6In2kg%iOT--26fo(mm*CN)*G3NQzlEKSR zLKS*5@-+6U;>}wlcr`EYzU@kw2YBYY;D;HUMN|HP=!O2*#)_&S-60D{x|;%4;u8VG zmr0=GJ#*50oXL0%+Y)NGHS#*-WZmzi5<>ePyk(QCW_;(ALg|{+d+f2@_h2ole8d>D zFnLNNRj}6~c}$Lt7j3Vbh1A5pX)oi!*;H~{y+3j)KDh+0Od;+#00ZrLU5?1(pF)+4 zm{p5{Jb6yf5;F8T)NC#_yT<{Ry8QsTJ<7G}Tev@R(tIn_5?tuL}wCcTL`s*x|zeE_69J z1k&4oDQY=%qkisOi0$dj z0VZ|`YOp#R?su3G>Rzl43BS_3djP(9gq$*&@k7Oy?zQHZfx7+ z#^2H3$4ygK4EVv4$ZJdZ3Ld}xrmkf9o__s(DZ1KpLK4~;M{8fRc2VR@N+Ivp2Kz4Y z46HSqk#)-n`cMvu8>Q=MDcD`{bxYn>hqqs*gyYsr(hg~|lH-_&WMwn@ zZ-3`ha{z9+_KhihuG~Td8EDl5U7~jk7t<20*-wZ{C+37-`j@2MPzO3t`(COXo&u_0 z#DTb5W4{9il7UVWt6Y`9ur|;->z_G};(8I+4zRoyxf|!sr2)3nH+1t6R%rX8_|+cA zD=nac!uThl@Pq^#jF+_0MKJ^qy;(XDg0$;QiC))z6)w5 zU$c=TS&@L|5OqBTky4Xg1OWQD`pK?8*TBmOa#a;(I{N7TYWh@kMW0Kwb~|cJZqgj@ zeZ0u!>3s0J-gbG(unUYM)I`Cnd3^WkSUvZ)2efCuZqc}*0DnbIqDY3<>sIW+m(#mJO%D3`EM>vGzd=F!+!P|Jbw3k5I zv$C#2%m=&VCl#r^`pUiZdcVTJ%qJA0Og}M82a$H0`wt3s9nEj zN^e`0j~H+F0b+=w&B{meGRkqbbD3_lfU^q7HHhb;grZ79g)JvXG4Qrho`7oD<+#kO z!tHmsdC|xuo@oOp#!F?lr-0JU1z=;505_A<223`XfB0@fffAn@J{-3#96JW8+jTA3 zdFJ0d&8sL{QH7RM^0S86o9g}g(&)x$wubZ;7XWL$UDx!>kl8??~WdbUd zYP_*uNA>1$pldyMXZNLRCcw05i2Z6hx6N{#iVw1Gs z7va^jq=UJRLU)<}Mo0J_-CXVJ3X4MkrGXU7?&~^=4C_}_? zA;xKYO*Ql$B&jy1LW0cg@LB5hDid8`c<&8*pKN?@ z`a{H1@qT*ty1P0n2=?xmp3T3lLd^etP3!bFz!8JIW{e>afP}kOTxrMr_&xD4Z4K5i ziS`56|EO}{I`;4V=!8{)t3Ne9s|1B0HSg{IX_o+M2|fo!Yu+c6{hu_aB9#ePTqc>B z7V=e!us`lR%?STV3Oq8R-2qyClL0q4ut@h*?3TM%ExUT-$0U%CNo9?O4fq|+Q2gEFHo_Sz?FfoRL zg@pwQ(0fpzTP!)x>+lO7ZmGP~a;l@_DOx6m*fZ0Bv^_=6 zoH7wio0nv!&Yb4|3SjUAgL4_sBrGJ_BYb8U4i^9hfA90msRfFZ^(t>Z3u|w0uaK|` zKkbKApBw{mDu`Dl00s1YnjZXbVL`~7sz}y09KwAk7v@S>ekzG=lPDn|cK&%!P zpbA&raISjtq7=eJ_Vfqg+^)JG?*RM=@{~IUJb04s%AH>`Hz`TDQ*)^YI`?LqzmC;8 zAa7z%yQwCe9Cfm%ko2^f4hG1E)27LCtiD;W%)Jk`{Ih=!K^31q5CQ|f-->A3VLeM% zRC3*ePuhA@R=H z$V&`syQAjx$c=hh@ZoeI4;1ftNp_Aw8kmJXCFQ@`it$YV><34-x*b3(K<8Q`|AS%w ziLv)jZ@r`EkgomUK9ruRac*AU$b=a5~o!?PDjtkqM$*~#HSpF zPj-KDZTlMEY1?q&K*JO0S;U@__|$OjJeEmc^4i{MO#VL;by07>O}NpNzab;i)l%H{ zMq+2Hj9IK|46=1-=t;0!1Heng_YdjU06TVd0mVi%xamlTj# z2H_20e!=L}q`rPz#Mf)UZt634rzpyUK7C_i0H!y1T2yza>Xv&drwhvZ9}6)2nFI^! z){J8$_2*3(doA5yns>+r;GA}1W|VTVWPvjQjjsR-&|hmntAJJS#S3%RkG%S41@s6& zzqhf&p>Qb+yu>#dddM(t;`P%NNy`0-} zVat;tAOf^CssO^;A6TO#g6Su${!#sQEfMqv##$QSZ zkD7Km7=Een6NekZwXNWK7B?-NJh(LWhZrSSuZG$46!Z~j|F9*y#>+dVNW%a0+cKL_ z(Yaf&_!lTwloKn=6#%XFk7Ota_0(v^k*p)Ud;(iQobcb)q1-YPK+*|VFu1rT(Msq! zpEP&o`WbAsdH&7L&y>f7Tp;yf^?JM~E6O~VV;QfW!}&L~9{|gU$wt_x0wVNu`~xkT z^Lk*D{Iv-ezE=++``rg7kB@y$4x3b0!oXU&VZTV(EvyGAm;t>u&EEa)vb1{v{q?7} zfuQOkEcbhd6tsL%k@5*tds6gDq2#VWgOT@Z$_?;CO=s}Xx`_;Phw~^xw|{FhoO-pF zW^NObA3#+3#(j@r%a0M*h3=0t>=qL{pm|0`jnD$FshWQ=F6hOl6%v0`*V_ON`=WQ1 z?vu6tjv7k^KiI<1P*(-&qv!Kg-S>6P;~r}$3)}+ot6bweAT26g8L#q8W|O+)^{k;r zkk!j*0LYZ~v%S{KOul5sUpIZz>D{vl;tG^|fklGYefEti7JSn=CZBy%eg89>@n9L$ zxvM%ghmY0dIQPJArte@4&J?%G<)W}(zUp*0U)WS_QS9A{WVp;sjm!QR`8@WA^6(VC z{=G=%26~$U|1qQ_&m+MRd!TR$2lmiIP=+xu$1w+S2N4F144l)tIR5~Yc~xU~cypiP zPv8+TnC$7Q}&x%9fc8`iO9cp?L^h)w11;lB3n6l?%`Aba&s!O>@;QUdnfAvSH@Z=!_p zw;?_3O8%-$Iy&40c(DcQ04GQ@92AfH4+vIdA&^ah%N7n-RP zKnMdIn3MQ>0GMhlZ&gJeJ#?P(FwNcTQ5V!ptJ&^RUpcj_K6thVBv{Sm@5%Qbw%^f? zO;b!8RowW6hiAWvE#DQ7^KphaE-*934eH)K$ur9rt7^@&?^~kjJ}3>j$7_AmCe1jM)V`00%%EI|M8ZStI)2593Q#cfAKZ{A9-t#~E5P(ag=ar!yUr$}_ZAj5#B zy?!jA__D~eMEO8Z20Ux-AaR;WGg&@D_~yC6)8nm9Td7ED9fNLc_?HCZ52O-l!jB~^ z1gEdKvI<6r)-)I-ymWvA;YqRR2d^BPWABYX>7U@xv))rH2MluI3-bHIADQl0b0AQ!xIXpbF!;$Lxe+UUw1CYv~rCLOCzk5p;(4qXYDV^x&!mLXQ; zON#*7Xqo&@NBh1&7XyJ9Z$FFhp?VSS-DGsy`qtDMkwlcsA?dW|<`rFXryAILpXu)M z@QbQ~6rQLh>VOv+`Cap{lIw=}5v4JU6_V z2Q3eMHn$Y%_p3Xdr=5SVeNNxSJ3I#cx}9rQRM)egv^&}z)?2cOe5 zhV_G)il5cLPAC(SEs`y2Cpy;G1UnOOTE6a}a^bhzU!N-F-xVkQ(K5laSoeL}o~$dR z7_T$t#pg)Vs$fJ0)~4)4Y`Cyb-jjHqAgHA&(7epMW~YwXvuE0G2AX_V!8$`}t4yaN zGD_qh)uurGo9kN5pX!ry^Pv)3eKh!ZfsQ{x2}s$Zyw0AszaGlHYJ5EDvr;m+Otb~G zjq^16Ho`}aol$PPRkweX9HhT;SC|at&$wcjbnHIwwb&Nb130oD*g*v0&>n8U^jODGz zkN5T&p;#MfXQ6SV#PctOYNgy233%ZpH-jLt57jUZBfqIspxLCaWI8|_6K1b_Ak)<( zQG^CW-6&j#^W8vCWvU8L#nC6Tou@S<)M~s`4KG(+^xr2frdDJfRKcnYeTdq~kv(2l zY9l~3ZBtKwu^viWps$v5OiOOLVaYWTSFLfgm&S_*vwvR$`!VH<_*}?ITVQmv$W9s= zT%c9jBIuJ@=a;S%7QYoxk`D~JucB0^wiIjYN@8}WJy@^z$XSV`60c~`Z%Uf{`IcW@ zwDoowGz+^7zX$sQ#b$?D!b6ZWBcvpnBXOQnQl{SS?`kd5Is+|$sIIK2p|kc`lI=5G zca8Uis>4UyAL{xwpzL-%A>P9ktg!=Xhk2!;n4lQGr+S2--L=4GLvo1t59uuAOOu=qI54w9q5&C4xCBsBIZ1?;W*iqpZAe?W z{ShbC@k!5s@V?gW6 zsjTQU*$f|tj8KN47?|&PG46qTcP=0ip;(P*Rcqe(I6l}Q*c_bbCj_{h1lGqTf4|W#=jl|Svs=X+ zrAr6tE~@#?miNk0*do51c$j*=i8@BPp#3((dEn`!3fXjCbkvIPBTkqP!D5{~Osuyn%A319a*90y`6t6mxhIqdgPSta97z8hF(uk|_!6~JGL|Kl#~wyHb70_Dvh`-*%y{#0>-1y0qe|E#8kaQ+gfJVS-{o{Axt4p;9>F&x>azlluipo53 ziaWJ;hzjxQx|BSFZG$t(@o!uHoaMhl;(wk}Zk$3z`uKw?-{JE2PH=Dr5Sm5NUgU{6 zG&#{F&ACXSg^`F4ayphlTD@9FJh`!uQTZ$Xo!@$LNt+NPGm?*2im>~pk=N#hOFbQ2P)2W5gAY8Kr3so1w z)AD9eQb;Q0)g#UUqwSS@AJToD7}qW(4Wt#ut6)j$6!X$M)rwXSk15o>k3Q`%8{BI+ z4MZ*EE}Z*zePZ5ciej-D_ZlOgBn@To z7HG{#bxb@k7C4Fbnwq#7G-kHCgcUct9>7DB!`Go$quj@TVURdYWJOI*-E;S+1t5?O z&;MI_#j;4PBd-^eH*mmBGtz@^^!SM4vyoe+pPS!elW^e2_D4!g!@fI_@N9ql-c@p^ zu2Y2i^|_y$Z{@;C`U%=Z=M#|TCfZ&clT5AUMw1PO6iY450UYmW&z~E_b=h`T8khyo z=6A+>>H1I;;9Vl&#mYed#dil1;x$H7aY;AE$x@I*lN*7^M*k6Y-$m1k5w0W-`1JkM z52Sf1ixMu5;W|iGdTUkftt3p1h(SzTx%5UcTQ1&~*3jk6m^d35KF#D4HNDfrZ7#vx z_2m8IxXol~luqN(>B7;4fEPnFY6S?5cpNUoIvf#+YINRAq<*=;d=w`EU=1rJf`$lN=?c^4Hib7q2!R^6npj28u zBy(KKEqmX_%63^>K~2YK;svZyEll8pR?te)z_angYh=psR?FL{PvN4D#7jcK^g4YP;0-{&CuJvh10Pf~cN)@_rF+hd`eM^6fJUW642`feVSZ)nP{Y0;y&lM5>K~`xDKSI3 z2k7@QyEg3oW7y8XEwz1$%cs%XEO&(`=;Vw}qP%a<&$Vm=jKgkA-ZX-`qvr9CP2LqE zt0HW%w2{p=iJl2@tXLj#o8k|(qrxcN2CS(rlEPtSOW#*^Kr478mrhq|dZ4xOR9Ec5 zGqfV#H0w0=w6VLbqNQuBO$=g_)pBwq?4~8k{$|eCo{><`i@CzAD^G%h2yze}t7NYp z8xZL9UTv3?@YsAhD)>10LzQBJmDZE0!VY%do|E`3iU*jkM>*ak3TZIh!$zmDPT~x> zqHYgkahLO*N@iu3*2%5-UBmlTpyw~eRIkN|V``pGXc4qY%C= zA+^he6EDX}ntO}V?3uM6W`P|zBI-Sy;Nn?O2dc+3PTfa{EBO4Rs)XG8F#a5hQ^b5_ z3Z`8!q^TPqU<{vU8{m8EsvPlru`{@z$grl!P!|@hbmm?wxgRoUKDKyMjs?@lkNAS? zJ$^}W@e$EJ%3BG(WvtbBq45Td+q%#N;`6jLm*k5lD3=eRr0LC+a+@G)x0#_p^zyC+{(A^ZO9qj0Tpl!TC3(QP;;4cd?v; zMFoVi3DCi}ETiv@#q6l}UKYB}iJrZ6N(aT3xCjq-t z2i>w$Tkz3PDS}QZE7zgK(vNFb*C-vzn&(m{b*k^OuRM{~&$@0xe%ILdG}ST1%(p=? z!AABbHla*b1-re9mA?tX2z5S~-Q6kkn4q#UW@zM?`G_{~DfO~C@b+Ean%i}@ay<|! z$53*5Ek`oAy=^KZ#OTe0#n(w1vw8VbUHSC6&MzkJ8=c8L<7%`AeG4sP*1Xp+12A^1 zf~gnYX~_f&#++|>WQ^*-PivUP$SK0^-}Tw~W<0$2*-cYD0HrJeyjY9P>`XphGmF7D zU~+i})ws|Z(Dqucy0QWwv|P{IlnfP%~ zkb%8*fB=le{ProFDYb6L)MXFT5XX1MGGZR&_?%%1UGb+<()s~hD^kgU_xwhYtP3RV zDIxOn{8!6~_%3)1_!Wfiu&qpvMoTtgU_rItftw*Q(+ZXssvGgrRCGAay&3(CW>HK2 z=sKJUw{Ao6&LL)7k#bM9rU5@zma$&i5!n)1;+^v``zH8X`kx@7f%!@%H!ELbGMUec zdrB#Y{DQs7>yXo@CxTI`@sF z*Z1J0zUsgtbTW)Pg^*+%0!24Ba(Ez-=g30QjVH+?D2mg*w`7QpS!-*lcfb>>zClbvtPKE1}d8Z8Wb z%F%A3XfOrWOME&MLqil_!|K?{9J=vN!-rv1=$x<3iSU8*(S6JWNWxaBul^%x$$hUiG1^igt`m3jiv_Sh@UK|I>Nv}r&pe&@>Rk9 zAd&;>jCaXr@Ng_cq6GQ!0xZNr+Puk~U^FZ*v`9n9*r8&r1su=C_T}7VJSYb*6Nl z_ZG?}4RXec#we1n1h_JIUt5u+u4JD0>?odD6So;oPcY*q`#pzcdGvGQlAvwliTg&b zL043{j;v=yx$1gi=w0z} z&rIfxz33Mg4TEJ)?O~mTSZ`3BD|XaO+EDK8k2daonmDX=q)WhptPn#6*4J%vyFtI1 z+6Est%8bXdjvq8{t*bO8rJn3fOhkHA()f5>Rlp)^FI79vw}tk9DIlM0BhoSE!}D9Z zL`xp+KErrdOg$xpjQ*K%sh9soNN?v#-DH3QrTwwH36M0E3i4JbUk(q=lkmFBm`g{-D7ufYr_U z2`W$ilE_;20$GNg`bHW{9_km+w)^Gw0GH+0*9-T!nk{b5>`;prf8o;%B%(B^{!N=aGll2tIK#Z2O&O0C&kl$>eF$3 z4{l%dEU3T8n@CT-HGVZehU$og3iht+azYFQ`_K0y$738!D2$@E3eGjorbucOFg;~M za;BWmA#~(@X2-nwNmHBujPp@0ucHjHvRXI#jY4(-xUJ_bBjk-GY<_UYLnMu{#veSa znBPzli#!bvseNDvz`2O1*MaJMb{O=;_3|47PLOwIESX(RL4XGq($Y>#k=JpdsRJIY zs4PGJ*26JMl1q1)V9@X{3gO`QrD-PK{$TZOF4G6bGz}(mUqj>#8qU+|1Oo5lPrii? z*&~}hKsY{;QZ(SMRe1mSL#NV}m&9t+ zu_72~sDKqUtj|Lfv73jUzNoKv&0xM7fwD$<0%KzZzwVpjiy9Z5b+?3Z#+L+p3cu=) zVBOUy;BU>jrWmj+LQ#L#8{oEh6n}ka59mXa+!q?=-RklNsvyUl@6_g11%q_2U)cb? zGtRVs6wsctdTYTrNGN9)NG9e0aF`1IEdMaU-=?Xe>7A5W!GrSd%OK;$>+1h8ZlDHb zPcg!t(ZE2gV7vMqaB{h4WcFM^0h*r?FK^(~7IS+tf6AUhU3rI<3J;h(9V(kCR2ilq+Z1Fg{eQ?Qkq5$` z4@adlukjch%m~TG{ER}j#zR_|{DNP*w@p*%~+Qf_%LjQ`1nE|X7i34Q212>lv&nE=kF8Gpc6pA|J$+u?-Tw1`Z}>uY1!8^}Kt6(l*D8y41xx&48?xETGJ`9#r$^x_FC zpUQtjH6pTd4~u*__1yX=A|-rs-ZYI;BRXv6L@egZ&())7`=5iCAQ&9^Z-)*BLs|86 z70UCVJly7jXl36$7R`ryfBg{Mi?`$co0ecGE}=XVw@pLNOypnui-j6Dl99OicKkCX z-@p3;zxwMrCN{=&VRzH~zAZ1GSAPdqh{jznpL_WxavEbv``Ya%`l;-H(~?yg$-})` z_|leVcJuxt^WC8^w<(`sB;J_NFU8$QzCP%$e+Pi9T+Tu&D0s(O=hove(%o`c}H z;%x{thD)lB&J!((_pmZvmI{q?6Olm|WkQSz{E1)h&+oh1Vm_P}5L2?m(JGi}hn zzrV6ltOB-ykyb%~YM~yHmy;BK!Rhz^$$`LYW)wbZ|8gqU`6#bcOln(Si*79@%I*0cap{4;q2 z0KXW7O#Fa*CHNiCp#yWwlkU#G|4q#Q=_eN4lig1B>}Ip4@-i~&fdg=)w5X^D|425- z5s{^%earE5hzRZ>V~Uxu7-hv_m7em377!T~Tf@KsAA2E9v(fE;15zx__yEgW_Cx!Q z2Z#;fPH}p*_Ph^uc^D&mSAakXGt7}OmV0Oj;?`vzN4_pv8!T1;gQ%igB3*aySq4~Zp*7;SX-@blz0zTQy zn?M(&nFC0sCM>X1O#!)-k9v}{N0BGURT~46ARxP}Io=FA8e8+hK`LwBBXI2BSdw9x z0Kr){M^Jfk)SGtt^e98NN%o;v>XpOx|7|8K@3uPR+9DWkU`gM9{d&zdj8nm6B2(W5(hXNvyRZyNJ;g~TB8e}Z!gXbVINm&E=P2G{N-}0WDQCJ!H9?T`Ufdcyl zz}P?Q;q5N=x1H>k)s$eMEHL8WQQNYVQII!~tFib3>T@?h?uNZxU1~udsw-ACy5N5& zC8{*5_qC@{wA$<28Rk$Ej|J9Ht0@$FBnr7@>Q9*#ddv!m+aM2L)S?IwfDP98`noNC z`TSmmJKh~aZTXX@-vKp|twnp{6qDz&usG06iG#W*tOmID$JVDB_!+qyLbp0@cIMow z*`FpEO$CkS6Dtr)_3yl540r@mP1z%!dKDs-DYejRTa#~>z8|^tyfqi2ZcKr6zcK)7 zbqu#uOJs(tOW=o@U7ac~5(?=A!v_NLf+7$}{)vtzw$?e79lqggIA-NI=Zd-@H2m=4X+|_JE<= z7%=BT2FIP%F`#wQef}l0T}m+GDeg0Qy8TO3{%2?@y;qe;3!m7-OJpuXXiU|`Ug0O+ zTDe|A6#s{BKUl{PWr8O>&uqu@TIMJ|D532v^#&?XUG7TdqFXT5H3z@!ZFrwB&$o;M zC7|t`ekowsRFWUi(X^N_RnB^OV}66!79HEd@IqwE69%~}4u8yjFaw0F4uE`)I&*1K z)O(>(5-`hl^ETNjNOF?=Q3$wpGxV)8HnBk0K>Jw2R56|e7kRt?MBJ@HUJ4NGa6qH1cASyLU|MrdMQvIOd++4 z-dubzAL0~Y18Tk(kf$}N-w3^~VqNQ+U$J;Csi8B!56W*s!K>e8qso%S|Mo{|It5 zWYSW)??=wvtmdeDum+}aR1{m{^v5bRN!blsj@lB@l$~m59%(ftFWPSznQuJfU0Klv zM@-etk5P~YDpQ3woO4EmhBjrXSIu*8Olz!LHO0*1;{DeqWgSxgqzlcP^EOq4#c7AJ zzA13M3?l!?O1VH7n-BQ*5@G=s!d5+3^(8*?o0v}kD}K3_+)3YI?(NS*7OyRax-9O3 zilgO1n+#3Y#TwpMkR?-ZW@~c|=sb(QJTigug?J=DVw#%1o-goe zU*$DB?sfDgYGX%JeC$#FiRDtti;}@UIE$-idYrlu~>Hasr^|;f-{Hu0H+8_aI zsg@)paP$PqrxiL7?5aLMW;p|8+w3e4_om9(o-|Tw4JBR=vM7U+_Pl9dzKHEygH5Q) zK&J%cxscJ%Q$IL8Fd@D(rl!8@2sW9NDCf?by%BVh?bV&`2~@Vuy)ozXr@g$OqluV6~}veQG8#tvaYm!y$OWdYa`#)FM9fM_i5KN-TSO1d!YP}R*ewS4Sk;n zVyz<$x|z(&CXaFF85>2FB@HYny5EL9XVqX`HCc&rWtpZfxZMnPMAKdm#2pHAiFQZc3j+aO5LQt}Na<{RD;rGMB0* zb~{$`H_cTKm?OWCOmjFg`yLm9Eq;v8_bo8sH+pvP1CiKnPjAEGADD-q`z^g<%K9s2 z)GT(acOT>z?-CefQ+&^6@BI?AMdwVWtgL*ihoTj6$$$6aQfTd>N;w4lfty*n7q=nl zq4TT(tVOeU?E<}xNx&6X97E%ExNUd|*A7k=EtpC`N-x%)TR#8Z)pbUXWgg!a1A@*H zKQxzvb1bRpbHuRw3xk^n%iLj21GhgOLsHFYCAv^H&Z8r&Ss4E`@Q50^+;@Wr{Sn|6 zNE&PbTQVv>L+Q2WsphltNn1mu*te=6gkZHZ8~j5@Ankj^qk<<}iWC`dXiW>mww$qS zpWHj)QDs*ezLIBYrP=Az%qteuACebjDdubqxEin%50W-9^VPZQN9tbCO?pVgS9MM} z%;V@>o6zE|Q_%JfCAkh1XuWGv|zM15%U~wJD-83Z` zGF`d-E=qb6)dZc;yNZ2+&nQY|I*F~t^3>>|ys#H=9|h`7vqB;$3ZaA_cWzt~+MTU0#X~J-TYl&2m)H zt5q)xg^LzbfzEOU^nqjzw65iiVy$WwJf9Sph`kg=NdpaKKSCXm5(~zif}b#qH>Y1W zkwBP>xSlisA@B`|FDz3s8RLC_uCX+xhG;iwzLtwE1kmvl=^Z81CO#Q{ABZJ0J%X~y z`Y8sDuvS&~5;!@@7$mxsrDzE0LFS7sMKFeCA>BqKih1P21B)mH9fH!vt zJUEGZ*IzCvVjofTH%BqsXP8}Ttm=9I=Z-mN5Vq-MSSYa_aIOBzUj2@heMfo~;-JEH zN!!idhKI{mR%xV<YX(^+ii6A1vB_G+4Ph2FWS213H{som8()oVPw`4 zYmvlgz3B?I@^;!#3BWam2(|1u#_X_!3xA$~7!MjxzRh4%;L$ZUCV`~bSEyhT&Uz|* zxEaM>JH{T!9dpYMUrNj;7uW?)KGE%;GV%oY#^}ij$Vhv}*s{l^?TU(mIiT}1bv({2O0@j#FwSqETqRWd4ZZn;JHdg6SO&t}`I2 z%MwwFr>!;5gbC(w(44239S-uqYm>%4`(j^M(QSmvloo^!vH{shZz`i1Xhv(wW(7qwI@itHj+kiIF-BM40Xc^sg~D`(mkghz#d|QH_z=aqw^{I6BbmfaYyDa#f6wCWuwrU8O)# zx8Z$c|04>=0&QeR**%TADryp`oLz@ebJCw!>-LS1g=R%^m=5eVU(Z}q6%^1oKM6G= zj0ARhXiKwn)sq5FrAG}rRNZhAsaop9B~v`qP-sDjZYWGuI^G5KSof2#FMZepcUJOj zdVCt!t-TP$9Bz_CAr_e`zZh|JO_5UjWna<}-mZ?m?G4`0C1gY>H6zV)-BU-hRc3`~ z6`o^Eki4hx1Y`V*z9fvU+NDtUpVDrqS7^qEVk`A0Z^;el+ipcxoo6(kz7)2@V1DRS zMIxf@(>@n^u1(ri+P<}AhmO`JHBB&WiK0_v_Fz-m(hcL~HRk^8^gx6IhU>Z87uX~x zks!sA)$>`=ZOp7Q^1NPRWt7ArVP~`~r6TjkmD``SSlPC6vQ-PAHJPym2swR=mTLsd zoXRB+kch%u*k67RMbq3+x0m5sQk!buh8?#v;&Ar7)CPJUR>Av;IjXD$d`f(z=gYDJ z%rE29D>ItC)$@;r^6cz!05mg_Hpe#7uHGdTxM4}%*!Cn+BxFEAqd7t zb*Oqew%M{f3Rd1w$gl5zc5Qll!HaUQIqfM9*rmAskxJc1&0<`k+2^U^yn6W{?#zY{ z>U`V!U@@qs?G`ep+AA1uxYkJ&>CG^T|6z?I5b7Vm(5criRzm;Bozm)Jo{OSOcul)` z@y$;f!<4Ek>74Wu$2Y4eiAyeocWx9M4@;8>g@k|UGQlhJ<-AvG*(I38whUy^>^0yiZYJ*rG7UI z)7`ZaSNDdWUFY_oFvYZdrlf8D%emD(uC{1sqg2)Q&|PkvM?O(Jf~FL^MY{*?A)D-x z4chqUW1>2mflq$yfADmM0R$e&M*q1-^UXGiYGjjtJ;U~HaE>sw@W`zVn^)}Wc4_XL z=4F>lJNo;S^?P8zi}UK{VRlTe0oc5r0=;1Gq!Ae6m|<`{jDaqkl6g)9)bnoj#9WQ4 zbLM^7_g6sxr5kv|(j$;uPTf)}_@put@)Y=(+kZuQ(@*FKvJ+kzH7I9`M zelD+a)d_;Iq2`PMvXsRp{9lpzSX=v2(MTS23OOGc0Bgs1mQSSAj$AG za)de8umzxD^Cfa-6ToXV3mK{~`|GNAtfA)Xw>gMjMF}We=HJ6SPA%*DhKl-ld|$fd zzE|w$f&RWmZQo(h?^w@0O7YX|m>g()l;a`2{`-cr0CeD?7}9V?15g|lkgp3w zb#g>TOuj40f=r08?w(@WwkmLeDD|+PdqTt=+C7v-QiSRc5n&ARGc;W!M16eYkoQ+N8s2FL_kofmXU#6L6h#Iqi3!fj*~6GWjL|g~mWbFkOATseY3QM5RKh^S@4< zL7Mop8+$+4qtL+5)72AW;)(``t=<@u_x62=LTKSR5RHv;H;4gdHqfX4ImXGYLdVbx zMn@!v+$gB2yCEV8a@M^t_C0Y0EpaBwQv=2^lLG@4_MIiU%MMI=>r*C%i6EE=^7flPn~xIMWFr&0Ag_Qz|Djy<&B4Ov@H2j z?qQWawXL$R_FrwPx-hh30YbK= z(6$8<2D?@Oea=kcq0k3?fG5eD=Wsi~19)Z^!rrfaj06FR&QSl%xWz}eEG%Je zLI!mbP4hk#U|Mly^3%I zuI3exP`?c6tiK^|^BjOU*~gUZKScmSRJjVGm=-|fc~_1U8)6jzmUiG`$@m)ZaQD} z0yI+I4GlRe9YB3XbC1eAmIQUa4koaqG+Hk|?MFcF09l8i z@Zd2Zi+{F(CRv{^<)37|KBRV*oD<66gt`fmi)`;zfJO(+9w|}ZQy)6<#>B~7E)jXJ z)p-bHj;>U@KO_OvUI7_5#mU)*OZP(nv}8N@Mx?ATNt9GLKO!9Q3*maY{aLO_GQ)8K zTtcDseG%D#hOCqU%*Y#!z&(gGN7|OPUi*fgI0a!OfUzvt$i#gJBrmcS8884I%3N6t zmO<7F4JF+qf7|v7KSB!(SfP9%UX$1}m^(8PYJf+PQ8GFFbZ(BeJrE8oq3R)@^T}XQ z)@k-@0YkQ;~-U?)`q&09*NagisUUs3P>%t4Xfd&xKvZR9+6x~QB05qFW z$Yf7|t=o77ewgREVeG8-jeYeiWQu^CN7IS%p~6fv4$=>#p_?iqx;?s$3?p>z6WRvu zq?R=zv0>usUj)(gNzeja;C@;&Ojo<_S3)<9U>$SH5gK-u;8-h`w`}cAelKTvC$L=U z?WZOW#5zX5prunsl_4zDG$3k-SKwxKB67K;=R*R(L0 zpH@Ej^3E?E^0%(a_mG(W5ey&&#IitMu1R|v9b&1zfb3Y^Q%>N|#Geym&q!T9wqwAm z=y|bT`FGSF%~O@%1_I8Kj0}A-m#8uYAtgEG&rjvq!^4VvQ6#ULzQ10+v523c>7gQ9 zovq|XxOkK+5=K#mdr@>>O`4N|Ls_$}B6nchEBz!4xud-2(^kxhL`tcP)d zG<(W1I^>)pUCbppAfk@n1%B2c!kMnH8KlgV%=fk`y#lFTk&dP zF+zTGnl3N`b*zO$oyl7yoGt6$aF6#FGic&{nT(#BDQMrN>kJlDFHcRI;3sWA3e8OY z+~+mNqNgf%YHNFJcEu;WrS19RTN?s=XZWFH(K|Q9;2dC8dQ)6_)Sks%t`Oj|vhjh+ zu8)3CXaiY7KRhyi)Jj3(k)EyNwoJSy6Z?*8^GbzwFO1fJ`&kJQX2YCVWRi)eQoV}dZ0qxbX`L(qDuQ(3=VEBtBd0Fa0wyV>ZYRJQQE-fZ zS&eS}-k_P#?HNrje8#;_;x6ONQg0cZ_3Z`M^EHQipTj?yv!bD{C?>lj(9aP#{ zA6G$eypRt0+BF3uL6*1i(zJB7oNKSHzat5$d*(FGkgDgdbym4Wa2#BUdu*aq))ntp zcnK*o->V+db)&S;iyzOfV*FYjziA;!bA*+xJj^iuU4lLEz7~}q>@%)IA%+X00{>ju z>OzR`n9mSYZ_FLe5v5OVUo{xrpGs7?0E(NEaur?MaLE;2>Tm^Pr^(Q;#`I98=K@*i zAvQl&S=Jtkw`_58%)%E3wGHg5k^~88T9Ls*7apkf1gUIJy}b|1qfHIf|K`ifIKW72 zut-^5NWMnRtC$w@n5l(THowN64_+>aPrxBKJq8LzvCmAa6=06(85zmz0r9jLwT+zQar|%*pgio|&lbv@w>p~5-Q@1v_NhnC0RS1$EPiLSoflA-nnP6&PnVc6( zEnj9$&l}A5`G9hrPe2X11vLtcAmay1K$u~ck*dY+kO~jpOo%MQTBBxb;bAg-8;B@G ze7)rDb76(EUVKI{C{?GuF|Cfy6bgWuX4!fv)TQBh6h;p_&^B;vlHeT0Cw|kh1>7P} zQtJE1YT@p$6+n5kvtQxyw&nDYbfMs$a-tHZaZb!7PUXWHI{DEBaY+u?&HI{!9FWbK z!;}MoYH=R7iYXk@2>;QKOa?PMZ)KgAroKYL)&n*l8)VW{L+Ggc48V7biyi#)v+D0u zyK4!}BW+MiJa~BBTA}#Rl%V6iK7bs#(rkiRe>U}yJ6ufEeL z&doJ%(o>9`=dZ|^F@ViTmWk;<)znNDr2It*97oLM8>%TLbETqaMj^iqN+Nd7jreuY z@RT93ny)?@k#g%^4Gb2d+ed`zw)pueT+N_#_7j3Be%Q<_9W-!`ustb35UHuISAdg% zpRBDI^4MO8L=!t}&f}26udiwpXfPrUvWWt&)-w{wYA?+hj2FMueH`B3gczVJ4so?$ zsshTLahkkI3;?m%W!r`?n)%3wEeWl%CM)T0G{-cF&>cTW1I0wcg&{yPmb~03OMnUX!)FhB>I&mO4SX4JCqEI3} zm=#tS4!@jGG!a_(>Ni z>?L^1ZpgbRV(+g%%0|gfAoe3wj?`22M zPOV4DvHLt5=vf2lzXk`o!wbFgt?#k zPz9AiT+X-|6(gDQO-_+p$pqL4iVG*0mveq)h3#qeY*&J7oe&$*2NufzwjR5LBz`g= zu39W|<~*Ok7k94p`nb499#ccq`%A`u79oqdr$;#L1tWSwzIh-}IFdNKgj+gA1FQ5Q zOa-$n#xwnT>6AG@C^wyw|9z_i8aD_PH%&NMhTaZ!DLTK)$4s6-EJ7 zzur~A{4ynL8V1zV9vj+mOeR~HY^x3M>09&cy^F_JUe|8AdA zRt3dpC#7A;xv-O|apcD8jXg0_TBij%XyPQF?ObJHX$~&x18~-Ul5Q+{Ec z4fjap%8Ue*otIVOJ&lHtqx|%U-D^d^Zww2GCvm|z*KaV-Z~qHBK~PbK$U`lx_4OUB zJ&%+gMs8QzGdUWx3%; z_7OP#9WyiI@sVY*i`~_KKi?GJ5q_WFVEwqs z!dxlJoj$acChskfj`eHn|4IQjSFM6L5w}05do{Mc0BneOnA8GYqH-2Y0GQ{qP%7oo z?@-QqgxCnT>{qQP6-EOP-jzbjV|7090)*47q=0HM)9=J5?I_nzX zP~9Nt2qGf^S6#CbMK9iw0?ekrXxbn-jq}uR5D9b^)0nZM2O`i70X;em?v@*`bX(i! z4p3RTx_qzKQ?&=^=(wb7qg*7Rhqpt|L8$xv7lGg^aM~@|0J>fRs%$?5vT1 z@)ea^KJ@f!S71I!?qS0NpP|3LdYqS0QLicI@{(cHu^b4G#z3|L6Z5N%T96K|QEZC> zjkxZ37SP?Kp`lF+$~RdB&1eJ?Qe|{skp>zMu^Y`GJl=jl$E9W& zp!P>MpkNin*B0P_a)X$gIgmAHjs>99BuMa`g+kZ&03qI3o=mn0(Y;UvMFj!5MD4up z#vFiraPgtn-7Akk+Ro4h$S4`RdvoQH4sEM1u!i&70&#>31Q2El+v{s;Mv!ewV+s7X z+((Z<7Jd^?$4bRg-U~ScxXvV`iz$sQ2P)%Aa2JvuBfw%9(^%iPLR35@G67$p1W21+ zdi6=r^=<8@;sBszU%9xrD3R$Qira#&K5?Nw-V>nllzF-Bpw3?s>&=Wz=|oF1ga${a z{(P!2xh3REBzO$2V^p~bmP-DZ)KbpOxYw5Ek;Do`+dONkfoC{(`=bk@SV9C~I-7LtvGyib)+m z65PPUxh_ofJ`@8bkE~1pos72Z9P1=>BNEL7gjT`vXv2U!rUJqW$487_44ZqfQ3OC4 zM>ca<=S~?;rk6VKMfrC?0sm#rlm&HrLE#6q4sx(iu+|RHTPbHlcYRJH1i_TqlPI$! zKTG4sE!!Rgy7Ucgz4-oiRaOyzAj?4%|4;lQXt%Ruq|4q4prRcCVybULf&^9cg8D{Op~$Wm-tux9JPh%IystTA{&I6_njavf9CvJb6Y%Z6-bsS z%7i+C4DW~UYO+;!NmAGLXQjQqu--I*Me<(zT2yE6%zC6%YJjzmNte=B))bTRtb4sI z-wwTG8Tmm*q$pNY`jasC z>_{|&5IK9(pu}E z@2~qy*od@6$0v=p(fuA(z{Zss|EoV8nGh{#{4(P|8E29>#;|1kXbzqZDmL5bI!=Bj zCyzz@7)M*RXLaG6r_rfi1M3Dmm^m= zR=Q2PHUrW!yA7<(CqZ}pbuhGtMax;IeC~2cKml}uKVh10Q<NC_e#-AH$Lhal43-Q5iW=bi5V`OY~P9~b+Ay$3?-kP6$ti(9Yrhg!Z$#U7h|O{+;#>j5lrz1{W>!6`a{23 z96}0Se2tGk8fqH>F*nO7fU{Ss3hL7jc-6+&A?p%0fw&Dz5zM1cdt1x&2oA86$BJZ& zFb;#Dp+S)QZPOn7(T$K~);jeUHb@nfcMwLnysjts_xN>RHVk3;dL{8xclov6p*!Ic zo(wYrv$b3L-0=0kcE`cjZ}k((yDjxMPv!3@yIe$tPbTwxy z^c;C+L0^ci;?#{_P@wbl?fjmmz7;pmH9-^8R9?%*_{(he6O@BFw>Tg%}c0`G#6s-hb{iA`j3m~5TKjxR8 z(Q@<8NZ_^2(%d$mL%6^Opi`cQ@}vHr5a<8FXF-P#J%JTwKg_dFX+MN}1u_0#sl?zh zg01oY<9#tz1b5i2hb`}iZXGZ&_!k8W9&xtGqb)o@eY5~2@Q_uLKzRU-Kz#Nu4K9Nx z=0}U#JDN?Vq+qdf{%FM*GD`z(J+Achz5mYTNP{I%E>hiAnYdpa9)Q_IYd!E*b+R6O zg`ydu(CNdnUr!t$Z99Z~gj_&tjBEY-I?T>GGnC67Kptg0$DN^oaBIN+qB^D6FK#;RwO$M54pVlB z7_4M?jq3qPKg8AGFG^72CpQpk-zJBMEqZfieS+^G)gt2;TtxR4PS8$WF2&LLH+Rn{ z1kjt(Ixxqm2bIQe)*FWM&ZpKXPLsjmyOp51)0nTE`&%j{4OF~KvxV`;H6XpG0n8OU zr9!s=V3J*J)a`7ui}fHJYK22az{5u{qFE)}f)XB~!m~@t3fb!t+=?BesO7_qMn$T` zf-vDTmONpz3A)fKwil}R`;P`SOf(c7&etS zK!HF5=7YbIZ5ZM~JC|FXw5Tw$q$@}stQ0lRX!yd$$NQy6rx3;(^%qfoZrwbk^R~sn z&6m`-4aRgfSN?kkpyx=s^+$vRqNvPC6=jsr7gt4+P(ycNMt^+`SFpQD3fTwC;@}D2 z_D`PIWF_~Y!9A!0DCLLX`e(Oj2k#{G-U;+yFDuS4gd=QwHUFIBY-WtI(KGaWwOw?3L|*jn8x zr#mC2BHt(MErChqAsDsQZjBJ)YnoCNKAUO;C_p74Ws1Lyydc@8D9zzXnw)msO+Oqn zqAL~H3VYE%hXU{Zi&b<_Ec$K}2|Q=Ixaq(`+*Fpdp2`X>*N>xb~C!8j;y zQC3p?VF-IAN&=&34ldhLY9`7+ayoFm)l=aIG=W`@DpiPPmPm6RnG-EXDd|B4 zNLs1`L?*5`36S&7|q)(nw(`kMNIK3=tA6BtHKuDB- zGI|v7s993?GV@OTVIz~e0M4R`sui$Or*@6RLepT4mH_f13JxY9HCAnX1vxJ70K5*Z zhL?PO^s4CA!hs87SZ`02lh!FRR;HcR84yL z*k?#6E?|P?6)rgB@O$)!uwITlyAVYLnMg>(=?iS?9h{lcP~J9a_oV01TwR%I}FyF z)Zx7RI@3q|z-7YCwRrl)wH5O!$}VC7^D@(H&yzG<9;=*f_5NvIDEfd3ImVByIAYI9 z5%)_v#g=XDRfZ3rDzro|`O$y4{xgA(#JGX&rd5A5?h#NH2erW=!@~cdL^$z1kARdT zRGyX~!PXi)h$<)E0seWndj}f4QYRwAXjz*kxH0ZA43}|ayf^y&-44=1WS7$w5?sQp ze2e2w?lVM1F$h9N(O=v}g@s*#x1;Y2A(!rB-u1`saASnM3b}aRL3SnQ@v`bKxNU|x zWSxP%ef>zLRJRl_h@dt%d^YJsD$W3+B%A7SAey&CLlqR*PT0pCJ~W`?ij{C2NJG5ZRWy9djtch-ZX2|LKHdA=Qm`3H z1d_XsN?+)W8wn6irsEobNtAy*mngB7s1Ek7=^2Q{VHw2x@<--T3}Nl&#BKS8n&3)U zVg05EJdvD?b1u*y`)NWL=ah3tKP$IvwYkuHKCo4D2{J+wA98hPHungoS9f+6M~2Mk6T9I|q9nb;YnV~n!t zPJ0L!9B!22hP-_F?Ppgdg1@=A@$NMcJZU6THzB{R*@NaF_i%QYti=>i{gptD-=KF^ zq@nI#^J{nP4BSi-O~bdZbA|3{!{m_DkVlpC$Exk+H zieeMnlLF`JAgcF|0+Z4P9oS7jZti)l#F5<9ZLg*hGe+Xn?>?nmVb%((ZRsaXpf_e)C_?+R+Je%RM9PP_e^7yEX`aP1=EGNNoL;lws&Cv>w(#KrzVb ztL$hmK&5{}HibP7Ur4gaVYFDn;Szyegj)U-xU1hHF#9UnFrbUM$RiLHonbTBv3A}L z{Nn6MbELPDE|c`*=;Ox{#WK>KClP}}QvUVL)^ttqovZY=dw1G33&!z06Ntm?v3YTp zdSSg_M(8w1z084-$jR#!X6h7ZGZK+)`2F#}Aj9`!#Pmv7BGb*oIDw;A&GBjLw+RJd z+rPtzLyic5(5ub7CFmd(d~>t67+%z=5?=UqSI85K*OY2j4@xX;r~~8zd9~b;^}hl)773hx~x$iYsti zPSj$IdNBI2u^>YHbeA6mslVqdB9TKc>lT57a{vo95x!#oRA2~&FZy99wk|RS{7QGX zLAAeaK;O$r3H4UDGEquzT5?PLjPW}JoAme^A7A8=NM~V&i{-^{F{D=eD}p$0U1NKE zY^9sy_dxCm@sq1hMeVknU3}~V7uDs>GrY7b7f~2pXL- zM+FD9*_sP0fpT~L=oc&mR6|7VAnOq?FbEj3P-Gs_)}XFM%)ews&68VFP+&QDI7LaY&%d7L0^%fr@8^#v->4V zc>}|8L1^|QxOW(Io3Shrzv|M#Dk+T6o=$&#Z1~)Zsn`njf&G^Q4HXQxc>aCIFBA6u z5L?xK4T@8)aRTgCv33?zL)MTLn8{XbCdx5v^=*UlUB-7f(KksQTR^@9P9|myhreLU zaeO87orG3A5dT-fC z;|P$_dTK*k`1PFAPsQ*2VCh6j@t-%ve2N%w_>?xWr=Cz&KNLl#{#W0KW!KvhOe;)D z{$^mv_CC%nJxq4`+J1ov!*MB#SbYOa&uYWDF#~Fj{t0+)^+%($wVV?5FuY;N6O}=+ z#9CyF9z^4*r!NP^LTbM7N4It@I}{^dZNcWqt4&ir0<#U4b_{eXZQY)@WHLt75JVV5 zeTuU8^tIFCDL6*1T*AcEIx0A%^->Jf)$ey}a9v68m^g7mxA%u43ikRUb|#VET$G>-Td zCZw1}RR*45F%ZAFO@gFc2owKwnfI<0mfz#nWP5fATm4)KiGCk0#QD!_uw~&x3ccRY zQ#1)xTM`}8S`Sclj}V}~vx%nW7D_8D9t?E-EYM0QwNP)xd_1sdS(DV+X5yrL0s_Vl%ZvG^$m z@}}7eBWwCNuNEkmqLCSZp@NW@MQ;jNv9)YIuR?pHOSr(`Gn^;tG%q_t_}>KR83THcw^JbW;by`W396hxc#p{3v33aK-<2*(|1{BLkiSkPe9B-d zcB>3Qi5CI3}UwJD!BB_@;oEnw9B`91YK?RD69c-Zi^|gHw}c!$!?no z){xDVr_GQFGba*8mxV1?mxrq(@_RYX+LN?W3h;CkF=f{Ng2M#(L$9B=V~&h4^C-N3 z0709x)pa%umGRG5*CD^E8*;hxN6#$+f%YUg^gGdmCn$tDx{k|4gwVKkeI4dmLCphZ{JKk422O0er$gT3VX zB>fEsdWkTNHf0q>+sSuN@1M*R+b15&;9T_+cNa!zkiLp+BHuLau zeg`SG)+I1$eU=MtNrwRl2c_){X;Pt160ShrAl{L$(-ZPBnsb5KoN+w^c2oI1#~ww}C{OLMKoB{XH7-VAl( z8466B@@uzF6ejfbK(aX(qZbi+yV;k}?#yF)X#+=$Zi8xfwT{WNvD&b*b-z5Tj_eK_ zK}NCuT7Q(e7kv^VyW?AcT)@^IS zhcP+s1VpOBMJNcKszO1pbz4M?TVrg@nivr#9e`Ri7FBpW>a6^Q z7y`Nof+&q4gXXt@m#Rs!QzDP)k0Za;V@4iO2F_z9;X*hOXb}YQL)!20)vOMmU2R~C z8!?bJtI&Y&T}$Ts#}mT&`toye6p3V$qJFfgo*j8xo2`TsU(RHgr{N@mfXF=@cRY9l zj2Xs;&M0XD+9f*{p7=QKb}=TF-IX$Ta>6rU$g$S+0yL9GSo7lF*l1d<6ZIifE>!5j z>FS3def6Xr$HcW@XIf)C<*BMNP3rkzQaf|TGnixC`ljN;Ox)Y(!5YfOjpSa-KXtoq zV{+4HtIy_uZ?&IlE4=Bz{L?CEN?Jw$Z)!ykJgv9NoX zLf)%D>aRUi5JqOWsDk8@y{=#SVcSFZ#rh0>l3y+>#|6|W$uQF&=MQVu= z)cJj4Ox(<0QQJW(lqjZFI2_!cZm< z|FU@G#`m&}QbP=$os_L`)C#yGYK-;c{A)chux}!tjuq*P_ zBF~cCM%`w4fXR=9LPV061L8^+q-9ik6`ylzMK5rps z5C44LN#ixQ<3`s)En@;Z*PG=YRlEOdP_z#P~YNTctyBLR&1u#tOmJq&Bwc;}sNcLfU?{txW-+h?RPqLvo&JHd8#X zQ5zBlopG3y@FRJhqx-7LK-?uh)$#UG7@;FMe-V zeR5Bb0QEqHL3s^tJ<|`QU1XFjlCZ_BSRno|=u~lM))N-JgeQmVJ2qYf)ap)e&c9@EJ@utJ9nferD+$HaC|jBAcr~_I zJ@M>DOR8l&6lXbJI9`Ov*iP3T2LZVEc^Ht0>Ed|*HvRm%_cd*8;Md1C^?((kj;t9V zO{nZrcCNE*r4c$Oo=+k2!iWTgVOh8Lw|DP0qj)m(lJk)@ zVSMr&tdy)X3c-Onq3E9w04x=)lX$PqKA`g%DbKrI%g_DGJ1fyCvQJBOJwEGgRwgXz zYMn(~ZYzl!*SevJSHcw){0?I@=Y7g1z!E+D#R?7R2_)XrJYF-K`O7&t;C=bm?z_W+ z;NIqE-b;eGoiw}Vqg|5jSR}m&PsIw;KrBxU$~uGM?vImg@6?*4=RQWs?T5s9s&}%s zBAsPG-KCmd9rY)NxOJcGF`2jBf=os&sWdlbUf?`&(OIR5Lygi%L3h#D7V*t z?H1CfO&k<1`)+{pIfON79(Wmsntm@z7A?)Es+&~|SdCN|KmIu?92Irr=>s3yvo@p) z-^~{%9OXsh9er!p(x#F}E(x%Z;x+Bv`Y&-0Dk$VFD-Tqym)in%F{mMl~@r zR#i`#4!;;Q6(?&&Eeyr_VT%yWVJ$umlL$Nz(zo(1JI{`&uC1?Beiu6MA^uoDuT?&2 zl+DENqpzs3KvxX62t;?oe#P{Ei5V5qs17AQdF@jcO>iGs zJU6e5LqilGqof!8-~SrCkGLJ(OGW0(jn~uG7O0;3m4}ET|LlnW`$M9AH4a525Mv!@ zQX~lnR|fYCzF=$tO<$EdQh=M9UK(>;S^#Ik0T1KzKJejQAEU-_TSq|6o-o5XI%^4_f1F@=$L{YH7?cK3S-Ad-(&4?wYwYc^-{I zaT_0Du(;yv-*Je~jX^TRQey*c9&itttKl>&zT5i~T^KsM`y7&}q@?imHBTan3>PNp zs`vks_K5~{d{9-LL}#RkXQ&=mI*br|5*)4>2H+{D{g{2{R# z-@yPH=RkfK8v-$Xf=2VdBZ(iF9REj&`v%l?-SNxzoj6kCR$$81#Z&G1sw)hTV>lM+ z$$)JKiX%J(p?vmW-jUu2lmn~LFQCpQgpFFu^aKLjw#d#5$ew0ZH6nSUDDF{4E>lCR zY7e>1{==7D;+j);HnezwH0gkq=ix7SE4JCK8G$DiwH91^zol@%0iz^9V+}2^Nri-L zAMfi3p`JKUjoAN_ZAQO$?xd=8g|3RAFoe`6P*w=Ii=E8_0*%ES$kn2M&qyX6{w)?e z84}MyHc<#6);*r91%gh2@V`W@R2K{n@D)KSb}2#HZ(nq#Z7`)KA=@yu_`*Ds0|gBO z1#Z|K=#j3*hhYuXH8eCphiPh4v?HpSG4K_zYrVpTHi1~!ID;x}#&8}Q7ef8m|2eDi zS^~w^frw!X$`CafrnF^j&nkipqKlLD0mr)PW0cZ&>#xp zlmk=>B-DcDya^JdYbAgzF%8uB)`vhB=>q1xHT>!@Oby!^hoDF34DoiLxW9o_v@pPb z<@UI=q`w2XRa_yt(Co|WdgW_um}_9bD1exnbW=pLfBb_z_Xq|-^I(E#w6Xl{Es1A+ zP&6nnn>Nn$^Z#7rodJt1Ubm#G4Y)KzC$0dTqPEVeW+rE_`iDmYFnw6|*Mt727C+px zHMQL!da4Y7c7Fi!SE>>P0%4R{ptRR%{d_MAlaV3_kUuiNZY`h$=d-*ipzqjGRX0Gi zfDcRaIOM$oXIe{_;aNG+`U4GdUp4S?X=ql+f#IcYr6icZ(=(E+MDmg2!cMsXffZxe z-R3QXb%VkO85jH{7J!vO@2Uve2JT^%^-v{Edl@NqJ3#|xXgsDxq~8Ue9)%jW-=DL~ z3JU;|{@VD9^Iu>tI)3_{=Q-w1}OE^H_df3StQ_G3o{Z zAm*c$ki(|z8x!EjWU&w$#r9?f3(wu^0nl63NoRvh-Zo}c^A06fm^p}Z-v-&7L4KKD z-tC`$yPZ&6=FqH5n-8pW3Xla209=erESF&?N+sOI88%S7h93f?Q!%ynt%umSjNi~I z2(&S~`g+`RKeBnA1_=yLm+^<&|lugAyK67H)u`mRlFyw_m-qBxTomGOG9Y{LB5wZua4aq1nyp2 z8YiLaU|A=Y zi$APChq!R(3k1SWCjV4OwL2&xeglNq3y^tv>*X+u&@@=~Mj1~g?SPZIAe#cikoFHN zCBf6SOmGk4$^Qz)IJ_zib@$))g3Kp{l21gwF3qk$-9ouNdW zy{B^VEbYCb$LWIHi>{fduONtCaCi`FKZR5Z&R6UQQz?WID6XjFmjFNpf-Tm@8!WpZ z;X4Ip1Sa8S)Hz(PMOPzwf+vF4`rLppP{_Rj?DJjP++c}oJoBeH1ODq`j|bA-^#G+o zk>OKgB|&3~vQaGTc2neDk)QM6c;w15(#YvXs2~DX3UP;n2;tf+Kk=?;LKWE*Ot1KN zkSygmGFU}xHKZK^i7`=S)vrc`%#eS2d6Cp39mHEY>`Hq)tLuhRoO`JOX!|D8DvYNN zpqG>M2dbOMC5f*uSL$CP{8P2@t~vPo{B{tI7JGZ{_1unipPh7YE79}N6y9GB3-0Ar zZ?nfp43~I>HV}pH6aK)e)$P1NCUtESq#plaF{-K^SMxIz`_WY9^2_I5lX$+? zBItmDLv%%_6>><^mJ;Aoa&B>gGpr4x*68=D}ve9>;zTk-DBC$-w@AXGQ$617T~v zAOjcKCH8B(xeM|RS14Gl={9?T{)RTqhvzFc0t!t~4n%JvF(T=%#%|>g^7ILkoIwfk zdHuo=)0e7D{-$Z7yMe(GopG`Sg#(3Kz0FQ=YPnvK^kk8QcfdEQY4P2U28Wh3vTzxrWbAZV>LLPes&0tFBC+m zODIYx=7@S?Q-QEpB7#qIwmGmNiy*T2t7 zJ(t5X1sho`gc%Fp6xiA$Svzp>W2Lb_>>!UPwFoz(Pmqj^qKlzu^?X*^zIiprY`Ih> zd-`fFwMJIcj8e&61a~x54aJIdu=cIqdAEA~2-Y>f{(U?HeXx9Y!BB_+Y3Il>A4Odc zWl^?>7dC3bP`Jkem~P0jK$%m12!r-&G`nSQ)}L9P@@J%fMF&G0H8eW&LJe#hb5 zM7!F{4Q1k?@-=AD7>)5L*H05M6HwZfq%-#!PV;-aaWpghtAa`}tb^6HQ-Bd*vw~o- zJ)k9=R&98sL%KEqVD zUix*-MAfab=6kQ3OZs!|Y578eW%F-!1PyRshd=lC1WKr$*4rOE4lR1Ys4J%2lX*MG z(dl4|CuVSD6G*^9lB>nCY=Tgr+k0%GZ3%7`HwtBy3=PKg0IV2Ak;HFslq$lzHIIJ> z`)A&~@i;nMwA$BcNn3X{89cA=Tb$Vv+)8$>`KZ+3fX8V(IV7F)(@?v?W7$mOFQGJF z*e&t-Rzj3_ z*F$2F-NQqXi`0mD6OK9qX=BYMskhI^_1Rl!owWbnzmq!h_`8&p>wZy*3}fm(if}dh z0e0F_##9?@L^!AFTNAwtdU6s=Z}sE&4%LJQxK+jrec$!cC@oo=cjUVpC*1rD zx>~5pXfN@mjygLzz>HvwCgD~gwiS6u+do_FnC5Jz* zw(>KwM6~MNp~}nRGoHP(qt$t|IlfX4w|E#IBohxqV=a!qJ9b~BdGvx|feTvTc5-#f zrH0ODAvE#bWGC5X>RGA3^G@pTd`e;N9B3JJREGAkreB}~_K+&)5pm!kT zCN$of!S=#Q77sBla?kZ-$o+yj_q+Uo-rUYCd;a?fnNh^dajaBdxd!HjTA;70>yzxq zO^)gP{GPvf?Wq1Rv~}yjTnpVzdbJXcH2x z`vWsQw9oWoY&SNFTdPT`cXwH5YG)Q!g?E`(?T>GY_60*8n)5a4FAMMYwmPRQU|#5z zPRS00!ot+QE-iDFo5{@*#C|6;u}^W){(Le!?FtJek(m~CuVd#}Yg)FH?lyu!an`j= z&dyG~v>_pFpgcHEcjr=t_?_DncZsRzxm;P|%b(WYK^+rXmm_F?e)JY={T6?TTde@o zsmT*K-Ri&o0bRsFW3O?<2QN6``;jBd3$NTFa!v!e&zp3G()Vt(e{l42FengxaIc5i z6LRlqVV0BT=w3?8p>e8z6?74Y6)YEGz8 z?du}7fY!E`n(}a>u8Yb!d#nHp^Ez5>>1>|#@$zwV;Pm2~t4gWVB=aaC3_n6@QjXEz zPv!;>-Tsm!-D=#t_bwD8E4YQ35GyKq!}NgU_VCbYCa{q7YGQ9i6N};G>oLz`4)qmn zkMV>++H3<5+U^GuTLUJK^6iElC|Vo#zdDZ3Z$K8@*|e11(b`T(U_I6gqmahw^ktb8 zh69u6i`o#M9}=d0rtK|`Dl7~P7jo$9IyKvY($CJt^}?yl`E9xqXy zm*<HrRwbL6 zvcviOT!~zEfN}gtD!JwLRP9=C)~GXI;OF@9+`zECeBMB|#>jiKJO|T@gY-=_U8liZ zrt6uR=S#B zMX&+pxMip^{9^K6yZP`7zT%4F>@=Jx@e|Zt^WVOGvpT7maQ+MXa{v2Fm+~vYdph;i zlHp=y;;|e#hvy2rtyz5Is5QP0P!+p}TU(*nH!fYqm!k^&bO*J1ABZKLCMay0-gCT5 zVY|eA)jtUe>jORwRq7_X-J=8lXYXwjKGO9l)`T#eeS$X`ex=BR$xEXyLBz&GbiRjSLJy0>)f{3 zBXP}cum>-mM{%M{3{!@Y${fmnX~gNI*vh4wvtJA0lsShk9KS7hty?0WlX?BqqdaZ7Qj2$TtrOA>-*UkDg@F-X6SZZQWTg&BA$2{~nc}F;tLw}e z(pjgoNHoqXssYoVKZug!-6?R)L@;8w>$BjH_JLxuTI>mLXYp3PSuxwyqU#zaw~%o! z?muRfqj4;QhSDU0TD!P08XZp_&W9K~>fTx92st^^$Qhr6LF%5Xz1+2YGOo~9IYv9P z97NNg;c*m8UzYoYu;uHi$E68^jl zfvxmpHfHa7z(onFwL(WbyHaVp68#h|H;wg65lDMq2u_0Z0Xlx*<`!+=*cogiHdfyj z?A*=z46ocw5!`VOHIB%VFlOL6P2Mw+hZQqE_5xaECUSzKG+nR7nlG zX*hYpM)tyCvRh%hAy?kH4E;|?_@e1ga)zpR=xs)`qz|`5B%c3-fw6o~xAzqBLP}?P zm;?oeJkH;)oaOy$?%LUjnzR~0T)yro<4Xr-+6F11)(6zagOwg@&GNkdT2A(b#IpTfhhIy4vGu`x7(&i`k%<2@ZoSS}ukv~~zOmuz< zGk&Xa6PzhYSWL9XxqFfeN?RKfZjV)Alf|I{=me8DK|L@q<1yC+noiPw3Hh92I-_yT zAkij#vmkb7_JiJ%UMz?X9|Q9+m6Z_vQj4jQL-ek!PXmedOxojJGLeHCU1{D8d&>8~ z@1lHJq(!#%5J$~kpRD!Ww)6qha&cxq zukq;3D)QvpE0_%Ac*sia)wkovi*|UkS(!nenYjI1s;&6&e$r2+DL?l!e$%>ORsYmL zJnJuzux!$DQ(|P!bO%~5QVt1bDXwF)mZ89-4(;U{Hxn)9IbdKydq8B~4$^)BmiuDC z-jH%s00!G_co#f&wgAK;nuqm8OtgeKu%i&G;SJbHFWiSB|2hBe4jHpO zynAVLr-P|e)i1SqfVYZ!JFy{~7h;Ejn0n@``2zRQw$d*ERU0=cO$1^rJaiAH%QN4E zLq2FHyeNLG>Zc=aFM!_Z`H+~-$TlIL?&*!j)k#*=d;7j^HA0w(J+zXWiJ_9v9I-{5QL~$|65)la#b-9P0(n91oHnop#52j0=s| zeC*(hEw*Yd+oFynIMW@%W+xWY9%AH@s#yy1NjZz}K`T?|GBGdwjhA0uI~9jQV5|iT zn!x(aDBwa|EmwRRAR-_PQKm{m;=*2_=bV|SZJiy;Q_P&dF!w=U!d2^5`gsh#8T&Ky zz{0cOGp|+JU-WK*XuUh0kZktKHveptcPGAg{nV?Bwot+A!0RGL5}&I!l(Ym~SjGT9 zSbbNc;&o#taG>_3#nlIgGxM&{au60C>4U4jio#WSH)$onQBoD;KM$=a5)v05D2@^m z;#7(9PJ@Bz+;I~fB5)rZ-AcVBD3RF}QtuvS*V56bu}ttfZbu%`p4c2pJA(V*9(y+Q z&-mY{iuqlY3=JK9;Vh{ka)dpj!Bdg`sQ}hD-_P!cb)c2M5B4<4r29|V(o6UYzm-iyMyliU61fC7*1lL~y~7$sq4r)lAei!2op9Eak_;z3_?Ftrp%CvY zidL5|otOBd;KDzwkXd;P%r)Q?(Vpzy>;N)g_?^;+t-`a(`k)%cK6Dc0x%Ww21E6Q; zRNJo{m>ibRe1N;m2YSZ`;8EhTm1{NbsIHgw?e@cGfU26WO2xu$8|7;ttzI5x_oY#m z+sZITtGTg|mAX>(YghpLdTZV1j=Oytep$i3;Y3|RI(h@WIb#Bz!1p-cRHCAXZE_o_ zHkEJL4BT~HI(o@15vr}!TLl$2O*^P`uR@*Bv8&CGvj6FOh9?_e~jfH75BktejUZtyV~z?t&7wyj;0W(}^S$ z{x$EYgK+tAqCn*m*pq(8G@$Ln!YB~nZvTt&5SEpbSd-(LNdDj#v;bxbObS^giXXx4 z*cpH<8u+Stc=pSFX*bg>X9_O?IqeGETeRLA6MW+y`ac)In)b`8RQRXw*9_QxN6%J0 zfu#HxzXs)c(9>9m)99^vR-C)<1c@aayeJ_>qqUZC-fMx2apjr;uDLpUFf(cNiN^BG z?2e=&rp!Ay|{y`+Q!g z$ypXEEJ{b=xp$MGq@7yr#A?CM-K=s~l|{M~KXL)5^Fer2V2nNNR^@}L-rsO47#Mp+ zOraiJ5e8c~PpDE6n*}^dZ5Y#&)h_GLi%o7NdPeec=41e78ka(i<`}$rVVv#DOLQ*# zd^bb*XT?4)f-+a?q_T5rZd+=iD;;*OPg={{vl;uua027Ooe2{JI+z{5_XSr=RrG}U zZBY%w!@On*LPI>9oGM&t74X%mN7fS233=ohgBp7Kv>}Q*MM0Ojd&AYc52jkiXKsQ% zsxxu5XrIl6%yF;@mLx?KF799Cx*;;f&~7iiR6NsI5JQxim`Z_jwj&}O#QKY`7Ppmt zrFry4s0JlL*XLJKbZ@YlsZY)n zsEkkfIqD=&`OkH!j~Z)~mx5tn)(g?P$4wU3B-LV8EyR3rko&1oR|Q;m zSp{dq!c@{|{JUNs$O1F2gcA}l&Jkh$7~@Ixg?b%R??skA_^D=Y#76s@9T$w*KNegs zF$B&+6y2%I)VJVdMNd zL36eUOoE%6oDl)H;b(hvDA`|lqXTjrKD63`?9Mu^*eow|TTD!ZRkLWFl(^RS#?-rf zcx~g`70}>ZPQmHH{Imdr2Qy-0h??YLE%(ctalNz}hoLiAv zBl;sg@MTTI7fra}c}ayt|g}x3m$X8XR zEOi7(msM$?CsiZET;FkkE<7e~iv=@`Jx-SFtGOE`Ka`m9ohdZ7pzVFbk7dq3fpCJe zzma441G+EpaeD12RUahbop@(30bT z?bVk-YKrEq(Qo(t{sn|CjytmJv`Oi`pORvEyQMVNmb&ffsC%-9Ym8tn9Goo^H)*CX zkIT@{^)ts(m@_vN`BGfNOF2h2c(Xz|>I}O%4%B9xUj$%;qZC-{wle%yV2FA$WE&fz zcfFbS%ckYw!H1gOM!T^X{l*}$MFdYvWr{k2oCn(Y-19b9X-rN%6Wl*z%fu&7IZ=_o zYlo|LLhjXc-Ciexf61u`Ui)b{n`~hc`R1IcBw6#Y@-DLLZv*tFfS=tDn3OgKXBtxN zEw*U?{Yn_VkFQP*fBNiCKlIVyhb!1z3ZTCTQpv7GOIs33N<%_Q3A|%mbs9GQdx5Y~ z^mmJ!AA9Uwb&+S0np4d@Cl{kuQPDcHI&2oq86r>@1^VGA(&X-AB-Z|)_Z9Xl5cybA z#o))>KmF1I>%Z4b|VB-nF(2Ymn%iNzWndC zWzQ)iRRaJUTuQkA(LK(RdmYOu*dkogtBw26?a`Zh*Z=DPRiIB=#}_ zHb%w)AU#-XPE|rNXrNqLXreK71I`Xjz-GxH`tM!ViP)~-pkzBJ%;uj%8-RQcpvf>q z`Z2EOd0GbNR;*Q91GOBWNL0wB2n~G^|3a2=7rB6dfJ?APo)JMN!L#K2GBS>gk1BAa02R^iR1x>LO$+_(jR z4xCH8@oy9Nj!W{gijGpLg>3D}b_cE-AAYpnJ*|LyW&BDByxI~_2WTx2e8)_-9$ z=i!m`CHt6Jp(YntYVZSGmvdkcxR+CT0XQymfGz!WAOPCOYgK%d=1>~}LP`?|^fHhp zBvgQkM?lc1W_*kZa!{JXUhs}iFl$!*VI&5g`MJ;i@vPPbv&=If#+6kAf!j0Z4WvY~O06T)B>*5sa#@@fFzc0~iT{NDDluR{1 z(olb2@caZ22j;nS8=Vg(a!P*;6_bZ%ohHm0o zIm^?al-k_Q_P9EPm=9fy9;?CB^UeozwJv?ow0;g0e>yckD|Uc@ad*=g`2WiQWndJd zmDgGV8U&LM4ao6z7Wn~SP6Lb{zzeS9HQ)9H-fE*aW<>j2;x70m(wTm8gBx_-lxIS!QbBf4nf+<%i6GsR>^5I)c9E%o8SkA{h}NypZQ3P zTWB*&=mgbs_FM4iRqJ39EvL)c5&LjMQ~2HNHI4_*?sUuaMd}YAh7#nYu>wW2wczI` zz3W;{u67!di{O(FD{dVzNJ@?SVwqrQdOyf2E!!Q;R)d3NyT|QR-c0vROf8YSL;iPk z!iV{lBlcbqC5BY`3m-RB;)W(=KAmKr0Up^W0nMVsh1=!CiWA_@ehMkF_5tA(!&+x| z@ijYnb;@ncnOQ>5K3}U<9FtBFq{UJQ2b1XGW;*iRwiN(#(XLqrdP8HNV6oRgGDv2_ z88glm~1s3Sb32A3qY&h{@T< zEI|2$4Gnb@g=%qq{W4Gk#j=mF_WNds;B7Px%Kv=k*egE|m5XWuZqUn{Le}jLBgssR zfOFls7ET_DID%_jy@v}>sI_0BIqjNQ+skr#M1+IFj$x4%!h7G;27&*X>m-}hgiD~)tjaRQ)gb(npT^DKBJaw#ak#s`88(%i7A*daiFjQ-VA$k{1?)q$}snDV}=SerO|( zh_1K)y(*UVC_Mh?^^&A@AlNCC%i!59&bdDeOz6;in8{jAzi?Q6x@2LyYKm*3-mZqv=a*mhDae(7 z1M(Vsavv_nMFPD?lMBPJ;cqM6bw^zGE&%I_)EpmrrWhncT~YLW8I9fnPhxzlz?-gg zArR?M0N(s|0(qBv(fDOr@=^dhuS?uW9b?JEkh9}zH@D3``0va1q=!>+55+0e-7e~# zx)#{TbKq|P$TQ0QRb|fjS=bM!I_mNaY0E% zB@IPj%`xcMC3;c{tbMFPxq|&C9afy>hTQfCLjH&QY7U>gSiV>Tp7oKah?J6`?KSrE z6FYAjbf2jhDrAJk)k=}?nHOD1&byuTzWv6!?tnUInW4`Q75?x*7p0UtLFSe7xi-OF zR39r>7N*gJA3rMf3CC+M#$CNi}Ri7tj3uZkEto`GH?5e|}ES z`JV$_4h#JAhw9Pi{^#gms%8rRi$g6|bItVK0qeN9xN=xeeB9?=8a05rK5r8l8JX$q z-BqiPlT}NxPt>b!SwbtwSx^9B?l@szc20Hm4Vp)>)avp{bp?x37YE3!s?=>jCU+l8 zIt2r^9kcxV<2L)!mEh&8Ot-OgB)l?RJ)AgG(YG7KX(p&ohy84C_gi1>dm6kFLK7bk zy1!c;LhU-;P@|O#c%o&Vn+q*cn&Q4m+CZN~)$g&X{t?E*#L_DyB59ioV3WZ(WG z%|7!&bjyHyF7kA*bwSQPeX;PvnPqtEmvC1;l-$|ezm=E!husp-2D^FIQNT%k02XST zr*B+K&a-6ieREo6kToo}3h++P@%?Ch1Za)$)WN4f{D&zh=!geQ;R&^##d}Iix=uy7+&O+>AfnG48UjQTROHg{~@D+1Z-8{VW+WI+FML2M*b?{GOU| z1cO3^LTWDmbn{gu!u2k}6AHrwKA#U$<$wNpmJ#T&WLw7Kpi|}Gghs{Fpte5PtbA9I z!+%vwu9?PY{Y^0D(Dd%{t}lPq`^8?64hIg0Kicu(expBYZA!id_$@mv2k!v*H{N=b zbF{8470J~kA)|*ogAj?$9dlbF{JON68Z_|CS zg}4KxhIpC)l}mk(V5HmuY|%=$&y%5YFE|q9K$ksTeTc`(FhP38eLQx*#$ZgeC=@Hy zdNdA{Vzq9lg*r@j_75s4A&xTC!_^7uy4f~M(hgaJp#*kyrcu(Z^*&*Ifl z;f&wPFTXxM8tNwGv$C((A+HUt zG=Q=R8uUw=CQp6|3chAtD~37ofYE%*9{Oc z;SYhuT`iCe)`0Ea8Xtbd(xYa2zdJ!hgcR*-M0RW}jdWJ)ojG$u*u^`-6+jvIiDo=JW zII-dO=UJ2HN$e;3;=&ps9740}_fnPal|vh|N!v@DbCtfrD&@q>wq^8=`2FSQDGk=u z|IauOAv^#o{P5NQqb>`>_je3mW{B+Ti#Y}rCIi4#$2P$Wy(mF+%5X^#;*&S#`?7L8BSIk`L9J0S*Ej1eT3f5FFKiG#iBY1w~YUseqlC#%;wrXtsTQB zjG{3$V#@<{Cu6qF3&0lh_sp+ak#stE?>tN-qOCNU`)LYDl?7g7=DuKLS2a`mjv~Or zVg>3)IapelJ7|(zdHTIZ=N#!0&L?&bL(x|(^Q6ic5ASDw*D<_HDIi01&G>%iiv-= zB!om=5Q`MAXXNXHKJOZYrSoAIVK-u7E~292L3i%FwVau?CcgA%ZE!uc(z%m6`Z=SO z-b6JR#4w)@>q(qW+{=|fzt!rwV8-u{{`lmsiRIt*=p$8+)SO4aJKzqM-KZJymJQ_P zNdrBx<%-$UeR41YJg@aJ-SpLtzwy{C#FYLPqo)eqPZcwlGNqUvR z(g7Ms^WF=msDKiM0>N=kQlDv7qWa`-DZP$5!5Wa|sxL6BFL(ZZt3i0lP~_oBv-xlv z{)ZO)V#l5UY<>_b5|i_D{W1H4=ab7^)Q?bqqN#1bvYIB)K4m-_*yFyI8%#d6z|`M| zhq8E5!;=rvYxgdI@#w@XdY>$ChyLM&eRyvLG{m0tzC4*D@y`Fg1Zi3Z{h-@1q6AY- z&<+-8g^2@|7Y_RUsT)#kMw&Btq%4eiOT-A?Z}XoXyEL+EKt(3m=-mD4jG0U!0pJRj^kv5<$=v+hHZH|y$2Sv$?F`MXnjvv_+JN_+K$4-7atgf(? zy05!NvaJ8yWFo$P=1sd0%Yf6B29@OLnSTLP(^NwQx2gZe{8?3N5yF6xZF|cac-r-D zMrCL^oS&#%tUd<|vMzZUxOFJdXIjs)&HnH90^ z+~6eiZuU0Rn6N-#jJhe<$(2@xp2T>14-Uzr_hkdjzxP&S zLT-6Kcu#`-9Eet$YV?72x4{x;Unsp4^fLlIFQKG0h!v4}z1Y}5rN@Ca04(*TP{)LY zj1{q1V2ut(HoxKDgkr4!J$~M*#z|c=svPH_G*@uX_1*QzT!NKY$ZGUEWqCW;~NQvMaX;5o>fjwZq zD`;8^-y5h7@Nkx7J(HO@KigX-N&wgb22{%M3&Q}o(JK7}rMv;3^HwM9IR~m(@xAci z%i-W@1JK|>+E6zBXz(g@7%xJKqF?ZYXv&OuEP4h9X|C2S_JF*pf;uS|`!cyMcMmKx zKAPvr;_sKi^+k{&XagWnGgZ~>)6{vwAi!Zp6GZ;EzE8G!Kcsph2sLIAYMO-L~ZT%_O5r=~u0F6oo zKLYI9GXWOaactqUxfHlAp%HQmi=!ZGpkx|MM~a{Y>I@1O2+)ZP$(!J*4jMRf$_uhB zhg<@qp3~o>cE53Yemd@k)=iqw`et49=(VLm@Jujx#o!kpQ-XSU<@`k$>a`4``^^?t z$X0j_@Y%=aVAw{t!0Rc5F?_qj4y1&N!+ARgyQ$y=UcQIjuWtD=wyp2=9%rNw0dsyG zU#_cC7j2s`n-yHMAcyJO@~M`(GCf-wH1oK4spxg7!SpEP){6hgA|L{YqCeQ`WJ4DW z)4}jj-U5@n)SRQH;xG<2ESRBQ&kk+*w?GVIQ=^o72M1aZ1^%1Eo{y z)3Pf(OhGSRhALt+Jp^XL9V>?@e>NqS>k)wc`1=!^3Bk{p&!2y6v6j z`nUcKr_YRS^!NUiXDXS=FAD)gQkAZEQ$X4G%DyHcqkH%4)V3P9AleKb$=ER`mAPX8 z90lKjVC_6?Wp3~CEC8k-gEtTlI+Xzct_t@2=X}F%>;HOgNFqty#Fs`IBp>Vfkm|Px zFKdleRVcTmWoJFm1v)6e%@iW^)PR@kQZl<(sBFN76W^JC;N_6leF!v%(}c1($=%Js zaMuH@SXd~u-ca)Qg>|}eh|(0Fj4{U(1KfvyTF7}go(H3#>ah?}(Jz$JvwA4iVX#Hm zgQe_^%YvEAzvwXnP{BURGs8r$ zQ7EhHGtF{ zrKte_=Icvp^yp_8R10FvRiI`NLGC5U`|Sa_%Z=p;&& z9zUvfF!9w!`kmyUDV#ZYgIKI%kPUjY$1#;j*GmHi6Y&8*# z3e4$Hd_oa`qxym8b^ZP=MuI>^Lo z1#(8XMVx6lSj;NlsMVk=UqYF+#LhTiT1x_Q+o|N(g=(Kg9~$exb=r`(I{^QQR=1Zk zMPt$d7W``>h8KhKtV_QIaQ0pDlCP>u=WsBS(rJKC_1@q#zRly=h4v`RM=+4wLM=YFv8hj43Z$z?gM7tF?>L(OU z3w4qnk$lp=RM0*ka68Sn2~S9PkL8)t_Fjx72}tP1XTV;(WW#gHBz6Vh1HS9BkK0NB zXR$K>`fHJ{gGODfhVKzb!PIuJKe|BPtSPivNa$SYFF%jb-3^c(=6j?~BX-r8teBp^ zlINr}vZz!GFN=FSF9|`EV3`=W>~vhUN?w#zQjs7p-vszs>h7jTDAQtrQo+171s5!G zoZ0Oyzm&m)Kh^Av2n9}6YlAd#l+Boa>b$PbG z-q_>G`ZmF0@ZVMu{4kE*$(H)0CNJ+z8g6ZkBctM%(#G(B@7{C@(TdR`;p8YxkHY)b zygE4BdnFemC^_g?dirGBlP*d!l6VzRYR8_BlA<4v@Djerc_b<+f{Cj3BZ{bIVK&N^ z`FwbD<&h$$|9-;jz&6k_&nBmdclZLXJjaQB5ssgVl`6aXrh0TfZodwlMT%Y?uv zx@a`$;z#tw3e^Jzl@rg(r>={W+xl`wOM#%eSq~;^ZD-gQOQ}uY@!_JTeamt7SqmmY zEovOA#jqWyBPufVXnP5x)$s^H#OEmH@D)L3ooqg%EYtd4zI~k+(ISacb=gLc#>~?8 zj`y^E2Mao#Yus?1OO*x}WU8SCLQgX7!rLoaFYdy#5W@bCO8!|w^gmD}*R+d{zo{I~ zS$4g9sXX!Za#X55NCmXn8v@f*=UR)f`r-23iExnAueXKB$FDR<`;YCr-yCk=a zU%6nBtFS7INAHv0P|wzoyCLsQrFR#Bu%QlCM36{eOTKZJZcpIPS9eokGU@jR!qExa z-mp>Rg0cZ)KIdnF#~wLn8(!)9T4w}2r2pL(2j-kWBPR4*i@#hP1>HcYwKqW?F2E9+ zl_9q&nv@(c!&CC_OxSGPL`#*B;rs5Rq-B>T!CJg~GwOIx7VPJwX)3=jF}ydowQO^y z=jKxC=%R}U!zHd}HL{^>W3sZf(sr$3a&^v1%y3huAka#qV$?N;ei8?_}4n>FM9HW z^SB@x_YiOQGRkuDPyL$l%Mv58E~SRmUa9fiSnn>9@MfglsPXXisG1pJ*lhdF?-8h1 zKK<~LrQKG%+b$Ey>$aYQ5V+bf=qCH)B`eu~ReSOipUNZB8)eC4S;41ksA0q#CThN{ z%RHp8dMp>a*jCtjaj$-@oe0baYBB*i#iua#nVj$N-93zv_K09n zS`4l}fxXW5p~%S7{CHjr{;k@FN3OL=4`eA4qq`+EgKN(TmCVJWasQIoXFWY6<=uiJJsch~I1mm9#0~sGRCwRTUlG zz!2ott>e+ zB>fcLvRiFZqOkthCh_dSzhd%VfD)(marK1EWObV|#l8!!{1e<--Wvw~tvQ%<=?y$sQL32bOuIODxRGH_RN-q}HG-jFt zXJ9-LO~;p%H-snmKe2H(ycSTqACXa?_WoLo>On?FZ;)&k>*lT5j4SosC@zXg>LH=e!9_sCk>Z2;N~bpgi^%AuwLCGYoA8n1kO-GnV|7Y#oh=W+v^%h_ z)qM%??M5!VWJ$9&@O@UGulxqV@43yoKv!t*l}3qRg*p-!R98Mr*JxX9O7{u*E|xBv z<7z=vKdbV=ndg@P;>oR=mldaK=YBe`+3+C=l^8fl-d9mt0}Ubxn!E@e%=+b8GHg_u(uaa3C?&c4`>!>aYuD!;5}2(Zc~9%M>-(X99b zmjaG@Q3IrT4T5F*v+6B;`UfkIsQj?b)c4&{;rmducX4<5aoJ>&-)F3Hxmru*FW27A znYy6B`+TntitJna8Odwiv(_YcwhHxr1$%=MjNf%)nNWEZ4`Uq5u>*^5RJVF(1Ui+AC|7@{5$_v}alQf-t%_^yJ-LTOpoA}|a?Yav+>uyEGxNpHf@wjiPmOl=(K<}M&+tX1b`FW4{GI%$SPzZ`1YEue^ zWn-`UL>?^f(yqA%94+k4^)gI27lk;t45 z3uyT_PSZneZs%$YO3D0Jx9tif&!$E~cmL3D^pyIFhS zs20P{$i&!p2mB8ce#0kr#f6U+-j{9FXuJRLrww#)h`e#Bd2}nk8g3sgEqLS{h0F&p zNDvOZ-+I)N^xf|xWhc`3eV;XQJB#x=O)3#{aou)1k|Td~ z3AqHoV|s&9m(}|CBNoCkoTwav{~}^g^dRx&0HSj5Xs|X3UYEohms8eLa5JpZv;GM2 zP$QH*SSKnVaRhDZv`V*5ST_24$%nmtl*i?1(e8-KyuS-L7-yw(W zGAp-RC;+w8vwlH*(zs84LSz_OG8Bv2Bs-hdB*bx7IQS=ch{EaBb~dd#`Q_KFc(HuZ z$$1VgmFW(vJLbi;1+CKbPs&>ES6hyEJpIj1*h+o2)0stA7i3KGWmJqMm3{8Fp=;dG z$)91jz~g;MN1ia+-S1ai&gB$4iE(%Z$JX88tTu#!w~6ll{r=Bp`7%eMo8nbX2cOeR z^dorT=xq$gv0P?-+>O4!{2OyUTz#V1eR8+gcBXB$<;{Pz7WXz($b0lP)uNbpJcY@b ztm=$O*@NnUJOQuUBUSx=CHi9h((p?#Xe>hRuVf|$Jl zIYBy?LuX~>?=9B`j9;|epq%f`2*zgv66{oRzO1Qv0k}g= z=kDCnFrj^Au_-t2zoOXlh!6aNI;Ept0Hp>PAH=v<5WX7g3Z@jA8Z{SKx1lctAG!Q| zS=yf;S>%qIv(mcjY?R~(|6#ib*z_g^^6U@#YZLsF(7V&qeD{&XyhJPBu*b{0GnkAd zJzRPdFVUp2lhK;@lW~B>PBzxo>lP{Z?v)Q{>}J@(qpZ7~xV(23YC`6UR|JF=w+zbF zl{+g%EfVd{g0EcY%N9|2JQNasu2&GIF$;Y5X%II^|162)9WZJjpB z{*V9g_cS+5)rMCWREZ&k5pgMP&DkP1>{Tv)1TajNed&VGX^FN}gS3#Dqc8emmD(2; zLcT7$f0y-$oDoQ%*y7*vEZatITIV4`!0y(l! zyOD|+6>B>p%sPe5L0ZQ3Vw*tQN2z{;?RqfYWBIy>a8D((#D?+02`$EoIcc+AM9aGY z;W6Ca%xFJCa_K*7sK+~$xSn#`lPSJCe{8P25#H;H(#g0EI%tDwl#G>?^pWamR*{jP zC$9M9IY_h%Uo3UcQNH(@W3A%V^ZP$_XgLs&(w$@CdR$&UN-;@NQtC~XxC;>*S-RNY zSQ(|N1h}mwE{}(`gikK!>6zFoOf&Tgws9l;1Xh+2(T5X^nWMniPPvl9|=o|c4K(_a^kjF|?V z@Vpa7lnZT+3{?gggL6*>ewybIr4GEs6rzbHRNLyP1`X|;Mj}j38ugB>wc8Z#IubGk ziW_D}>{Hn&^I4No_DWR2$C=2hIakvA%lfX8wi7)n7aI!el8vOi@1?}eOk1v(rG~E$ z4*JrUE-CDVIQD~eZl>sH%~qlKq3B^p@}9y(#3i@QOc*IcjQNK5gKI)QLoCH(>)ZF1 zwj77=6or|`xWV$dr!};i?j^K)`=>H;h#q2AY37o&MV`LO@J?qwB2bWD=q7z|sSa(9 z3l4KXXOG;B;0O&+PF;tQ6~uYYEESJVmkl?l`F(~QM;*}ZmZ_jIM z>A0djMr(f;u25_kkX<{mTlfxLOm0bkmsPMWNgCXREf-bqI`$&IjbpLtny!7jzP)P5 zshJwdN@{U}aAk!v)7qRq(&+xZm{MTQ+)aw-b}tDtTXO&QO?!#V=TWGLRQ;hEGJ#jv z*rjK9|0>?)wd^lDXf+*O>ecfwWo@)-U<$=q#2->M#P-q0y1Xkix<_2e%!rIBD?Xm6 zcKYLJ%o2%q`nAhV_D$yykG+QlrA~G6RFw%B)`meVbs`OnY)v1-?~)VY-i4*_qFk34 zYKxCOY*fA_v9>-*0QtcmxCCka^rOcJyd4;J(W8AJJNdP z6Bzx7?zfwaFa!4vXMF#HzwuI(oe21%UkTr2jvzkxHZVx@zm(m4J(+jI>3wdIM|&&D zn?$g{xuvZ&dendAdgL&WqZV7hkF}W;UB9XSCCuOfzHaW6!`(=0y>buC$kAy*V%|H>GHAtcwGSW{~zOo*c9;gH7DbTJI#VuZC0)%`yazkAS5z#BDjB8 z;x}~Y3)^J(sz&7aLYdk^U)kEam|6K|l^wo`a_U{sEf8FGQ1Hq4=;C|j_M#r!;JJmJ z(`)A~*O%PrGnj)zyAxb{?h@haWWmKi`g!EM%s_3>rjq5#&T!VV1<+AGVXOozl1s!n5HZ2`N;9lgi;#Yo}^;DT<)l+QnUiWf74VhTAl!yeI zpur$jK)~v|3vEHn^2twa1Is((O0G4gwyhFQGs)T>yT}|%Y*zj1*gzQwj4~>6ovqzZDh6!KiOUuPcE^yD_~-PPGGq0_oXfzivk1_G zwY_|TE0X5CiX|b#F>&q}rZyvD% zvl93h@+j6kZ?pFSy$}>tjc%KbYQC*Ju`UBhSH-1UF>eDtF4oNYm+g?9rH;+H6ESrd zYdMKz4zY0eNNlh>r7mPL?2&4Hmd4fYD}G=&JeJ(dTjFMojj8h?s<76^A@Ea~Xt!VO zw$gm}x)OI9rRj;TzE$KuT~pa^m`U*jdliTHyO<_HGpQn9l~?!awaS(cxc^=C3xm|%A|uT?cMHurAOcDZwBNu!C!{^)Yjb?UUz#^Kpl!8h`3N%blOzhI&dl zHwryQDo0;_3_)QmWxn^~?$0fBcbECoucqifyNaqo_gx2%wT0h6+e@2cj`KfX#*bQn z>HzRV_S*=;61n2;Fi-B;d7oyyaK-M-NWZ#I+*!^PosQv|MU9jW4}E90QVq~ol;{5* z7jhkj+PCd!ILcNfQFV+z@rG8hq*8@$Ga^zA?^vixy|u4pf^)q1HN{}{<+6M0v;Wn8 zm8sHgTvYtjzVp^FVl8n_440Gu#_`RzkQfe%>GD{R2!fiSt1(=6wvdkQ%4x8@42m`} z>WAD!x}$;z+}N_DAh;J$%8MY|+I~QNS!{Xm>ILCg_ah^I&s->mFdvNBu*j{IajDk3 zRG^~F!h+FB85AuhNh>jMaC5e-GjJxAVO%3}GJ<~s+6blBc)%4`W0mR$i4YM`VxD5i z)LU%}FuNJrfLu7*G-flr2cNXA2*RlQvfWMmew=+(UI}zIT{U;Xh;k8tvr<)M5K(+b zcXn}ps8pn`?Ze;pRt0PM#cfXzgQ&WT&%51}otq+fz=W#ENvf%iOa2qhWEnqlQ11Zn zD5&+Pd-OW|Pg5xs2%7X<3=f7dbOAOo{5EK{d)a+*!9juk)>cdYfhW#^ju|R3i#nJV zp_pIl#~gi1%Al)*4rbYEIIM5E=xDH?6!~C*e~Kh#U7tP}*0;AXqZ7elZESbxS|7D_ z_Ep!rL#G|tpKK$#H)xUQ5Rb-7dlJ|5FG)BJG)E|64=d=p^N(sT+yzP$sc^1Cln0Od zq(@q=dz9~!Dic6DmB_W1DQ`S3bK8j|Ru5$0MSS!=#1CiNp^7e59Z~Kcj@iZyoyPDC zmM>V+cV9ETf|;-N3!hF}q^yxvdlh_{GS;$?|BjyEP^8S(#Fs+qr~=Mh)|Dr4EJsQn z1{pjIL*!8)#pwS{9>tU3%6!iaQxn8aWrhL_tBzwhd%sqMQh3ZlhpEt_;IXI;DUi93c%PAHevQ(Z7Y@eOc3kn_wD^^K9Ew z{Wh;!3LhSHMh+={)G} zIsy6mKI?hV__6{_ub&J9z-j$l0$#|lw3x^FO(zUmW6aolV%!ftPLBEXn%l&*EskbTE75a2<;pkJpMQbMcrr^^B z>gK?K#0ocM>%NUZDW8ECRW|h6C^~B1tlG=k+}mM$t%8g}B)W%_>HlfXr+Pta9&R)% z51NUvz}09@)mbUamBCc(2=jG%;9eSKbZ%wu&g2exAK`NlBevQky^m|?k#`rHt+Vst(;avdvSsnYO7-L-% zPztubW7Z-LLRdi41|AG>aE~!5eSdeY(uq$*4w=Xrq@q8Q+WMfGerjIW%e_h;VWr0o zLEGM&w6i}w&Ig#jAJtD)s&LbgT&MkYb{J-$X3s9*`rLt)A-8m(^n|UVrA}g2@?ZZM zpq{}4Bz<Uviq9B!NE%*D&M1Ym1l`27t>`1M=E_wg4LMzeQ{e+ zwnqy2xpaNPpXlxx+FI7_y5en$dxhN^b2Z^gl$z0HS6kKfSAaB?Y^l{gBT`svJP@h?Tb+^+j^Cxy^E!dATsC9n)s1jAH)pexBtj)g_^B#lH znXS+e=l02u*x9yZ^q5#yU0YvoO4cmSe;T|c>a%&*S_COT?6(EWWK?`)=C=uRRjko~ z&#wGW*kg;+e729@J5t2bPad5aw?!!N9Jrm*`Qz(9mPVTQuAG#2HyQ)_xbOUyzkj z!DJ#_?SV>(KfE2x*Sf@=dfKR(=&ag>Wq{bYAr+iGafJ(3MiXqrt=Y~mWu~BhN;$h? zCd`hl2DSuJRd@HvvlhO4cFn=edF+kPK1HfBnp)+n7nPCaSTpz-kOhYoT%M!|nZmvs zds|5(b>;g^Wd}b``HN*|W;L)2yvB<1Ki|-R$djF^2Gue3V1DkZhOCP>lpO({P>+uw z;n;2~$l^A7=ARb0uH!ZF12Q8e3YI``Q4B?TVk>3@6algzkmxZQ{LN8i_avzK;gFU~ zL6H?^yI$;?>8!mkt-9J#7_|L`h5d z`!xSH3`}qVN8tL)&{`!ja_M=g>ry?HytINK=j(Z@svX%}e?)eAUUcR1v5eymK%}v- z(`}sY1|~seV7yz-(gz+iUW~JX$H)yB?znqtt;}@yZZ9o9>o`#5huL&38EaGnx9dv# zXpjZWx_{JvR#-73m+ha9&_e=Op^(xigrJ@v-x!t6f`;&eRrV0U7LKyWLiWg=!-z6VScc3gULAW%jz6DP;zJZ33yV*)I*U z-Oul9Xy^rkv8B}jb?&}}*5-Zxy~np`Qv9dPu9_0^dh*K@HjueFxXYJ!!F&Z!t#g0Y z8L*^C|G%i#9U62?AXu@mSyA*qWzgy!f_j|HtG2Auqy#gCC(ZV)w)76%FX{X~+7g%G z^59gtvDj-Z9DDc^RrN&nHr6V!6q+c1Nmm}qZ0ytL3s%6Bqt9G+wtqU2(IK3vAeS9l z)TSUx{eo7Vi8JSG&uG0h;LASd9L#xneExtI!Mx`ijNVmGJZ>kz!-_Ecj(o5x%y{vo#3URZ z_dG=#{H>SW%+`Z)_kQQ}lk)2r4!H+^KvZ>O*sG+#h^_ch>r*R#WRNWPL>4Xszk;zZ zjdp^64yn$_Zu~E~APZ+sTUg+{EbVSM^F4$?L!a(fN`xVfgB^zse!u5!l|1#EsR&N( zlA3hHa(o;&>z9tZIuR$IU8kba>DEQhGs!#ix_7B{MoY4#w)o{I>%>D9#2;0EzKkW; z44oACpPvQvZ5|MFJFiyhCj~5=mo{KZX67rQ%oE|2pTD)!jfA@6?|XR}UbUXcYNW~u z!;P)1+`i=EtL>DmC<|f#{g^GCRPQPQb;iISNuv6+I6d`~0}IQeyrOoVG~<0HeN@F= z;tp+eWR0UcSH691eI1kT_J7{kd0?SJJ7H$nAqjw=682FCgW?Jx?$!u9YgE4M(t-i^ZbH z(r}T|I<-6RlDTLY|J7C%Tj^R=)BoI(Gw5T#>9pXYJmLO5+5$hF(VkA!YJ5C3o#XED zEMU0!M4xTiPMa~xzv;o_0QopHlaZAyPGuhX@j zA46Sp6~x<>diSc&|06wM{=m2Z$PFQp!3lz?&)373ApYVh!i#sbF(`n_rY1ns`_C`; zcZ=UDv&ynY-YDl`qwYFY>JdN}3<(yAR@o3*2n2-M-g-AH&t&1DPziD`#vQDdhEW4R zROSkKFCZvG*K_{{Ov*lJSpH__&-WJOm;$pvzssi(U?WVFfPD5#|`z=6#T4c~GKDqI1|Qs`5kW#UPdSF2J;_Wf!X%{4cb zb1t7_50_v2`TpYf!?L?yVIrs2^0w>@?;EWnlf-}T0~b}67mEIGJBI#3d-s+&F^Ac+ zkb<>!BoL?=F+ltP5ykpJeuE1T)E7fe5I}_F1b84`WD00jeCzie1SS_i!v|RKv%Msc z%RLkXq819q^V)AwsG{2Fv(ZL%2KO^%`=!C${HS*9b9!$HkmJ6CD_Ottb`F19q5drUX_pZp z?~%mmIpYb$u7#T!TSla5VZa<;} zCg;!&B+p*CiHI$A>1mNv2F733=>`LlfL4kJVv*V*gg>O#)6g&=>!#fph=`Q`aJPxd zBeV$aE*Ks_pHMxq;9{#NNAmulFO(+D!|U?n2?{Q?{yE>!vW6@Yr>SvM)iUIgv=%7a z0?5T2&VSFv0;Q~;v~^`S>s*{hw);lz6*a2!rIuRAJbXxLkOD9wX~Fb=)(5qASL5EI z7aPF)Sg`}S4h0MV6V-e`livh4yRs4pC|k_1R`X#9NM&QG03s*)-uME+<0iyCTY3k~ zyx!FM_=xPT-f&no?F|w6eab8X`bg&-nGz$>tr*MLL^Su;uqR)N=4|?pD8?^M!t;(?zr%Oi*l{$B6>u-tz8kTjc7Pkz?q0VpOhbj0HlgP%I*j-itKvUY z?nC;V_h;o&Z*0XKt9RZ1OweJ`^pdLq_Zy(j4wU0FGW%FGEMs`}im`frk$RHMuG~a^ zatqMawg6qmaY@W=e#EL{xmO+x=R2Dnyv4|w$L?^Mcn>`dHSA>N(j0ifelF`hJeZYR zqDl)~025RiwY#(h(9AQFnFP6ORZjsU;{;Ay>6*!+<3P$EyACsrVyKsOZkKJAb!2}tij!-U5YbObKjbtRp zZLiN(1)I*EH4G1SDv0vww601%C2$XD>X!JCX(HKrHIISH#1?w|eI^WDybae?9aDYIAm zU#=2Ez_O_T15yPXFmDL)w*BV2G}v?W`qZ($yEof?0^nWii3t)cAAIIMy{gpc^Br`) zq!>E5GTzvQ#ph(RzQ#(7XaeP>)5kRhUd$Cf!?kdwrvcYiH7=a!` zl_w;mq{J=p`8K$Qtfr>%9k@?#I(+8Wb>F6GwI>M9J9@^{ds(`w@;w*>_N@751~>Cz zy}OEc&M#0v2gH-S=5ou{K`8e+{GH6+1|cuOK`DW4r=t;F`#1JY8$)>mdX2! zGpO2+DwA(yGX%T`p0OVipUaZ=>It}Md(?H0xwp);Iz|K`GQ*32eB;MZkv1>-1`$I0 z!fsg;Z)|kjr07`7<7P7R2GqeIxq7k-|@xSjb)7UD$n2lk7Oi)p36%>A)GmzR?wJK;p+uA4DN` z+>D8{S*mnUskAIiw7$dDex7LzcD31k(%u(|Yv(#xyQ0aUnEtv&5^`c;kGg9)3RE|C zy8PazteYDh|BIsr3=;ZPPKo9)YkUAf;7}#|eKX%HQ7l8brgep^S+JN_(RACMc5alJr-ZQpNs#DOvD%*@n^D$)pN8{mOi zJpgV(82WxQ?(#0aGA7lQIh~+JUo74P4(MHyAaN|l61!BY8>~R)dJc*FB>7lpyCXDx zbPZkKgNxkDI$bg>-)A+s&=WQVq#DppNMK#v_$aVUv#>pr7nuYW3?eIs>=IbJyY-Pl zf04GF@bjPdhZ@FRz26rTW~q96PNx9wAQ=+AG?ZOoc)0!S`l_NLM9|kzR=FmA4SlfK zr=g}Y-6NEN&rg~o_2d|%wV)Cw1@F_Y8IWKKyRz!h)5ElRS9{+qRDS@z;G${%#O02m z^`K3h%2pkdUWXl3H7r;Ls=Gp9Y4w%B+{D+FLTHoO=?RP0TkRxGO--V=CI7QrnEP@LFs$^Pdpzvv(;wk2Y}t3bSBD!9W$hu$1&v_;hA+lruc zadMsma}K}u%$*DxXu*94{4$O-0^6lly+HPUF{dQ`EC$#o2Xo}gLBpow*zG*h_o8b- zNBURoI!-FD{p-cU7Nu??#Ik9;0Rh?xqLO6BfsT~OuIJ730~r6zTKP$5OoLwczWI!m z5_`7Tx;|q>jI1ZA*e&9Npn^Imzx{?g9l-fj8l)h@;!y}DkTkFWHGrg5c?ai6mhKJI zYU1a0V`B@4c`=ef+-m?`c(wdm?@QCK1m_l;C(~5=)md?VE8sQmeKfAEe#3c#6 zA_hDtzl)YS5x;_(O^H5l5VDY5AJMDBR;1*+I9D`%gA@5FG|l2}HCPC-VoN~ODhxIx za$wz7leAUck0)S99|XmM#IVAhfxtwYfSf21wGkw(YE-k7hliK9(mn^LE?;MgmpXY# z-=|9kK6EWnV+#x&Dz|mt(ZLE^YCP{$vFel&u|qUAHTShCfWL0-=(6yKTh?LkUK5E< zAZxcEs14!KS;2;>r;#OX`Q|86JZ!gLn9nJ9M*99Sy?s4hvNyfO?Y!%Myl{F0nc61) z4NNc7U7int`nhJbbU{$xC5@YF^(loJg_Ezt5MyayvfM2(;Wja;6Km9zY32;WrP5 zUsmTu*MHpUFlOn9v6I-Lb>Ka0`>IX#T81ZH|Rh!R&nNDzG|v3}{wt z&Dns=dj;$Uz$rVmWZ!jbZN&aS@O9u7B}~nlpPH5sWXZG;ZK1Rpn;5?RzQBzUgnBdg z@|u``*orAuM#S6TSS-^yKc&HAs{PvtDE20RxssiLD`5Qn)X}WC<~{%rMq$F}CEQ$iDAVwi&x&7>3_@O1Hk>*Zuu|&mX-s z+q0c>o^#%x_wv(3izYd@+ofHbp?3U_hA zSjHP3ggBf_raw$_>%eNEZF8M$(NO(l(sdAwAI@MIf(8ai{m`Sg9Mo+btEp>YF+G~2 zRj4@-vsVO}=Yf(M*aGX*pG%PApI4JD>;w8HfsLCii+>zh^1Dw$ZmkR( zx_b}^aVHH*Zpe9@q=`#66jI_OQALeVqvBosw$6> zFb5V-?PmAD=xUR`=L60UEWf2)#;^XXV~ed|dDvtwsc~7F!+~jgYO~i3xPH3Iiezp? zMy4bSD8_O;oLUavurj{!j-B0^8JoIV9N!}OI(9!`y?yv4kg?FG}(Qipm59=|qdO$zC|ful{!Z~|~K`~rkV>9%1c zk0F<9v9RfcTVUAe0=mYXKCs*!-U5vIAz4lz;|8F-Er*sv9-w4gig|w-Cg1fCbzR7F(W@<>l+WG zP<;QK0Kp6Zy+}y-B^JG2o>KxPK@L?O9Y#Yg;bK+mx5+r~cFHxt{k!yjUBMc-5tIPw zdFkdm*9le6VW_s%)G<5?a*IC>2ULbO9`*wcF2i_n#A_`=f9f?!gJMW`JU^12>ITN} zVyNutv<4-HpZ|*F;A$frMe!g46hpD&?0(U6ORn3IZp;YEJ@Dzu$D9^ip%6- zoM#6jS5RX;(*3u|d#5?tTaDYYV5T}Qrwi=alqfOtb4Rsh{IU;s?xi7j_P zYXa1)e8GL0G_fT*=XCd+gN;h&P-l)YGCzsOLW8K-L5$lL7)Q*lmLOd;WYWRq425n& zkje)%t=mfc$0grM!Px(gKXU@Arv}i<&+du?@$-S z6eseokp+Re=PxAp02X0s&0=pp%nFkPBa<_iNm%$wE_J1Yq5olhVpQq7Yp2%^iVV z&%Rg@{QR(9^V4s8c;sy;@SH>B%E6INsiTkh#weFh`_X7Eld5gGI%P4=M^-gb zMsxj^zCJtLJ0vY&s9=Six!abi5W*{kY&0^T?H$DgE3Yco}B_wPEzmM&?E|~;cM}QJm zWEpuR(P}lWuQcC@J`{WM!Ig#;RG+oDP0Jfsx~sO5M%Gy~{+atT!KDg_kU(W+qj1ib zwKlsp?9ss8>1h_|86lx5%+&x^ZqdMQ9@nhOL*8h9gz`wqSg08VHX7{DGl8@;R4V;h zqkE7ARKFwmTKr6pY_1>P;Ypi74Jv8ItnJu<4 z-D)q+gCAwI*jG1zUFmchrQV2$>$m2YjVYJ|Mn3aE;tdM11CW4NoG6(?6Ygx1nT)12 zVO-u|mbLD+Qw%F}o5H(wz3*(i+v)8q>&$ZUZFZR#>J4q6Y=HSSzrX`hc3i;E*Ax>N zAHPoULuy5x&+wmx0{h5SGd^r@x~%?k8378MDarExZLELD~dI_OZtN&?Z zqgsaoFfoXDw%H{$Jz}rOVqIewCr))21*NX!GYfgs+k}&B$4I1{lHi1?^!+hZOW<&5 zu-T)4N|xfP3}7^@r%Rj>SjX}IBb7e+3Cgf8M^KEH=#sjAK5dNZ;$Qo2PtoO{Ec-=iSej6aNxq~lGqc*bK zdbUYh(`Y-=7YzIYvPBjlkDX!CUuDmB>s)zl!m|$7HpugTUd$tPzNheMk|Sv=4SugU zyv4NjEu}*nCE1XtlmjPa9|MsZs3-GWi~^_1zb@i2@dBc;xC9j(lx6a(ZMs%w+gP^(<6^CwjoHheGRw3yaCr zkk)$5+*Wy%lc6C=#t-k4c>$f9yM<4}FoF5I4STTfEh~6&3hQ`Ay_T9oN4t2nT(QMG zj4aC@)dw%nDM)G#o1>(&7xvG@l~HGm$z04|rm4uQHXl{qQPvYs1&j zR8G?1`53o0ceR8zI}Am77!!P(V%F;xRTZeW#5YMSQ)|88v!>0#WAAv*d-*O|E+sHg z0lS)wfg5!DTtOEy?ya+nQ%GHX_O{huzV}ByeB8BGdY|WQE61rIF4(8R*GXoRp6@39?&HO%7@t3xhc#7^(`MeCY zbv2J@uUjPj4L#43jgnpd>@=NjZ>kS;&%`60J_khj9BnU*OsXb5%%Jt23u(;(^BB zAt+b6M^EHr1*WcL+3_BMTs>0X?IkZWh8e1@4bK1^01#R2Yudv*3CSFqU_SrHr1!KIv%yU_J#LQPDU0>ciu+~WXuU%YjD4L zf0T{AFX6t4id286QaJbYRo07)&!-2?0Bt0cXNLth{Z(nyiV}2~G&%kCs&kb%5$AKP zOQy`{^)lZm;cX_KO^(F+;D*mwxA*DR%TwuYhSElUm*jZ~a_)Yk9Oztw(Pe^<)JG@4 z?tJCfd0YSN*J8gQ4 z#GGZrAzw?e;f8gRwvuh+QTu0GZ;-4p&#`ci?%sF7UX0It`YW(Wxi?yk2Sy=Tu}O=- zG-QKE?Tq0TAox=7B`YUpr(55TO_jEa)v5U{C}T$kDkHyQG*hZ{y1Q>NL!xu#5-vgRx$v+@}~KVNiw2 zyfZ0N?UJ5L3n&6zGN_UI&7(bTPiSxZWW=o7m~6!NUNGYX*V)iUA8rJt0U%Zw zmsz#$@7^M7C})M85Spnvr=AqMP7@p7jY9?(Fhy5l90If~ON0cl>Q`6ou$YRJFN#uo z{6Xzw$dCUsZ~Xd78%HeT0ZL6DC{u2Q5mx~vgvvBczg+JeGE^Fo})F-R-qev3>LLfN3bW1je-iiUI^XTRG&8*P8RTO z(Hqll6;Vh&W;u$se3PfQW>(Ryj9)QgVrId#SNeL&|G7~F1VPd1(`JV z$g`+ozQ|i=LXPMrCOh_>u*a0#tgt<~MXW5#hj_*`f}~j*F}jwZ2(@~D@g-j2QLb|D zRZw`!zi7|-QV#q~GG`b14ZWu}n1;7ogj^RfEfFyR(C7=_9|gzF0rk=3Xxzn|Ogl#> znI;%#A>Q62lV8AoB<1D!cKPl za~PQ|YK6`L@NEcc=6jtXhmc?&dy~}Um%-O<{YXp|fGv?5z!xr=A@ULjKGpb*RTRW} zeT8JZoEm6fpfNl-eyPH6RGbhH`oZE`I$34m<%lq~!$2Fz^8wI_kF+$wiO5<$ttH8y zZR`>@P#IA%GUK#}39c-q{u6O${(?}uDh*CU!^IR-d2*P~Ns4izwEJUxSbvaOGMLyU z$Yws44y2)*`;R^%cAe)~Ji>M94eg7|z(kICH%-ogle121;S^1>2Pk)QYrHLqGR|$q zcpX7wT4W+@UO;m9p?2eYY3}?aK)HqI00m@D5C0|H83Y<6|%f*)CB@osMH4g4)egOAr9%mZT?@F?@dw9PZj1 zEDm(~V#x<-wA~ml%!G{-jsf#e79IsTkWl&QC7&Z8Fg}|Ul5rXT$F;u?sH;lC;8lI$ zcdG?Y0%tI>3kK$l9Wy2irQZ%fih?TIIBt|SM&L@U4$20_P7jUS2@ zJ15+~T>-%0WBTj*VK!aS@YRFr8IwxKX(Xk(si|x<+B(v4E`Ue!3_=#4PU6-8a4-mv zg@eroV%z0@U}|0fP_SC4l-mod#k9SM)oTE^;web%NAX&5-^9cazl4hJERsb!eA z3b~m$6hZ#YB~Pb*9*xfk-TNymdnA<;mdV_lp^*6){2FhhzeP`#RkQ}UB_s+~D?-W&i?^2?w2N?g9F#3E(D~r)C~Hsu!vU>ZRoym=Uo)n-WTti>oaU=El7BcKiUnsra6^82bQ`f-r0i!V{-$dC5G$99lo!mFwI9rJe;*GRXkl2=C1QFymmdHhXSru6 zn^sMqmK+921EU-ZE{*gpu(og4pO8fo?t%+t4s5}<6A2u_JLDMs{({D7RvLM)YTz=e zqd{%sIb+|F#;_6p#zqQGWeyt@S zTOrm!24H1GVClbkc%raXp|CymTDgq|tbkN5(+|&~y$sOb2SlLIJy%Eb~EK+&}rG6JmHm^{Ie-*H}H zeT?peta}Di5v1OO%n}oT`>yf(6962l2DNg(#hUq*aVVzl5**gh%jbzDzN~dZG@p1BMM1HABYU zr|xK>yL}MuelSy3z=81vTn+GmZFSdTSMOZCzX5nqjV@k>ALNLklz^Gll zV}Oh2m=)4$>9`PJk?%bYJy8zG*#;1Y%3f{yiouU&ube?tZ&f@!O&mXCX5pWhpSx9r zwd1O92DyZbL4VDCI5wf$6)!V@DLA}^bvR)u^4ha5BHXkDK|N(z9q@%tV;B&;=eaXF z+e22$rjkxHTf?`R(iAfCk|EGk{;}nQ8W>b0hPaHJqB*{+?Wequ3op0x_f@Z7NABDnltH51_kCge(t#l|K%Vrhbb{znCAOBISK6m0z%=S+jCB;~-*Mrf z-T?{h`?kuwotQw?5DnOYsWSA~zUB?tJ3|h$g!rF}r{_ z!9T!oik!<~Y1Hic%91o4#&6vtn3W~V?KQTi_X`;`A&<+~;ho-seVBU{jaNwSayw-? z>PqP0xrA1GNz?EHClN*fJ45L_LdR~&=`5;EB|k%G)kx-~+B_r-m4i@$(%FkB?+lTe z3+7GA9OmEY``cpO@T@*9+6TqrE+5A-dzu%i6Oo{#p-TF~Ta56^*xOF}5d9bh4cUs@>fMaypQmAivnA7$xhq2rD zy3+W!LxXs_wOBt?v{dhwj1uB&&Fy2L9R3c2Gs=A~2xRsefNyaQs+vd%J$_7Ufd)>Iloj!op*O2ENs z%D2=$q2!EvvMGTxpe$yx7Wy}Wr+v--QJS@EuF64ma)WYY&u4ua8bL<)4F_qhYGLZa z!uKgXS%32=dzv_d<6-k{eORMjXVvj>N@ieAnq8)r1RU0;j9xc zd6oeFX&(r<8Ye`vW8wI&Jaqh1Apw2@gO?~wNSWTgGY(+jA*xbqG2 zsD@bdC3!PpfoX{YY*+P;!~>de?J8H=SQ^Fr@SJ7hllwaawa?C}$#3GN0ib7mm{I=# zlxe5-)aH`VgBb~Bh+o@l6uI{H-Ywaf9VNXuHo~`totI0iy_V$^+%&7$aIHIx=0(bA z7DYX5XFgX*fZb?#GzUs!tKNLYmo9V@+uDYc1HlCIOqqI`7wej_m#(4i38<-~=FSKP zV&LQM+zO>rfIeQ&uaz{bfL2C_GOC}sR%W1QDfJ}0lf$}kd8MlwO^In*TTwag2Sud~ z$A&h_v6$2#WB$2_vjI33h*vsz;s4fM1N>PY^tp)#?m0 zKrU1|ECAh5!T>+ZeEQ}GdhJU-uh+OVHfQCfEZcdG_)JI0xIG~ory@Ant{;)_qWq7E0XNLQWa>Kzny+kCKp7c0bjCL6HIKRNE@1-7c1xstZ$VI6*-G;#wL~fVS zi?wuwJ1O)pMzvqo!{ z?xCZpBN}3AEt{j2jTJpMu~lt4nD@`q@4W$W_nvV>OSA7w*B9miS>cOy%#54Sctb}~ z!AEwpjw&QG3)}ci(|OqBI3HDr&O*bY+cG!4j^K6!Z9qBMYf~{Ap^%_VXPUvPn3Lz= zniwYDb`J|$Nf38qWOv$xmS&aE_b zPvI%s-SRyg&a$5D=o_)~a^vJ4be6(5_KZ${q#0FiUF4H{Z|WUe`NWW<^W&*|?wcnr zjBc;V^dfJdJm_d?6j6=BA|~v9%!CM4>XOA_KOuUa$L*mhJBr<*Nw(*nMIP?XKKF+>rHTcF8p{Bb)Tyw=izYNQ$LqyfL?8(Y9EsWj!s zYf)0q`Y%M1xN-Yq*3Jx|F{llGLf55SJc372PrrLX-a#R1BR%^8%J`Mzq@m+<>5O<6 zA)eOIzJSOO*=7@5eNSpr!`}&$%A2MTx_Xhz(sC0#L=*}ub8XR$R=pgLWHAb<-@0ZE zkVupn>_W5)mJC8#RnYJ7JRlJI`^KIdev>qx>d|>SA;5&B7d7&o0bMgMMcLHM17rlh zipS`+06L0YwcPLNZla|$&QbD(a;D&P4CkCQ>WNBRV`q)jgGaQ!w;tsCJ;_N>d9ZHF zsEAqz23Sg$O*q!_zznxP`%PcqVQc}mniG7Cj~gFK18oWVN^p5Mkj z*yjbJMU}Y{n*Aa6kNB4EcNNb>&`E9TqOUZ5I5I3F+Ijw4gTcKt#98XCBSwjclSR)h zTeXaAF46S8nIRY9Z`gkNkkn;krPU9K8WKR1gq_%hNjl$@Rv4@#?!PJ zrc3*(;*)V@8SxY^pJ^ajBA${N$rjvhJ24tz8%Kb`E-4#25+~=*0?8EPn>z9is)g>we2upAllzB4;0d ze_1mbsXU|V%BPc5ajMJgRIy0^l!c4SOQN$D67rc9o^fN^ATV3Myi%A#sAWF7xR2N zZK&q#-aTE_KXwl*jbcB>bI53Cq;O6KFhS}CPqYIhzB76?^Ht^wGEoh=>7={kJD`Q9 z-sizj;%I$u%&h&81S%pY?w@-`h$hwU{FHFe^hqAe{Q+G8AXsO^{-|l?VUHq9Y%n!` zEz8J7!}W;_ZL|(g%+eu?m!?1(AEXAsTr?Ru^@naWg=yBV$}8{aDO&gBSuUuQD?4@2 z@N@S4r2OIYXF_}x@{JyWBHnQpLb8s{%LK{DMX>JUX#Rn9X6Ck(9l19H3gl$1`D&V?v z3y=uYS*ujk7s5yQDvdzk0Z0w}XbW&^Kx>b!yJI{vZaOnr>E4-XMAmgmZ2S#-iH z(yH^?Y*!bz+)JJ8EdV=JG@?2NzGR(lXl8czOkJIme-BVCOhhAGHuuEiHh{ccPf*^M z^caJ?wqwynPNKa=Xb7jV{HCw3|GhxeDEsEi^LO9~#3)!xrOYUg`kxTJ@p)~E&Q=vH z3@<@iFUVAlzzW&eE@2TnBS4U-u$3St;3I1Jv3iL_##?*~AlF&G>P&I0?L7yFSAD5| zn&r$~Bd@{1!ER!=DC^c^Rt{1$;!E+2^L1f6GVJhh1CZouwsuOlIvLdtcMA4Z^xk?4 zqp=l$Mrjq0sBPUnWnqzYK>uJRCFD_OoZzSAY&jVB$J;d(s%5fI5(NSqmz;lUMWFy3 zI+vy7H?*Gb#>`9@`|9X|YfgoD8y!=p%ZR>PloV(=KTf3+H?8)s1gt+X5PhBOKV7H- zPUppaX}iK9*E({U%>7(*@4AR#|3C~*EPg_Dec_x4#gh$9PCuVw?DUC&G_EYzJ1Szr zmhHX1swawWR zK+^opOu+}SnZ`~x7ZNr%tgF0pBKN2bm3TXu!?vG>g*C>6f_Ra>iuC(2O!|-Rl|lP# zD{T|`v2#ES&e3O{FT0)`-+oW%>1j$NZdXs+mj5$81$_EXy{Ex3ZN8`l`j3l}V-KgW zdUW=G53;d4K4|I+=K*MbxDsL1ePc!=M+>b0ROl79PAieK>rkE)&5ML%+H{JQD?97M zh_ycWQw?)cn5a2Q5B~Y5y<*@1fU=n+mO*Nd?z<=fy1Y-7_6j#I4GCyV=c|wy?w5wI zeN(eX9Q~5DH(-Q5ix@FHCh51IK+_fBW%Z~hZkEXQ}+9%lh_7)tN zFB2($eJ&*Lo~me4Ge`7IY2F`i(FA@eV4RP>XDe>1t9w!-{q&^;Et!R|jL#;npm+mq z$?u*vlvKDc<#?H6?BM2knln8|x^38p)b7oB7k?hv9h|f!ik)IkNT=tV0v7)#&1z*@ zo7tZ1{rL;p7CklcVJf9awr8mkQ2hXGRn0X z%XWrNJQHeX;!uo-C)rC=0&m^yAQN}*Npi)O9LG(yXpqsDf`2(BP93i)*H_e1`!NWC z)~U`CKe4NOwq^eBP@8Y4uMbh*x?K=nHQSM>`I%$$(F#9r08JogQ?G4FW=gxGL_`eJ z&tpwq`ov1-^W#vXRHD4GM(sF-bFX#sbxRk#4(cFpooi1yY`LonPqyE<^{QWg|Mn5l zMom{l)w-e;sAy*(i=1$uLDm4Y1>(*p?|%A4>VF@g9%?u&Z4& z@aK1E41|GQl*}D14Ld4+$LN+8VoZ7esH%P3BTmA#Z0hqk9CR|G^WK16w&RYzck46X$S(0+|5^^89V6x8 zv3WhNBUe(I5f`v~dyFbKf0s`s!NIf#O;jzJ1Zw>Qmt5(hQhHe}At(eh1{CSxaSEInW zd@#Ad=w!_{%>*NsDO%m!y@Hb2Aab0;x+OZ}iK9c6na(5g3v-1V%PR@6^fKWIhSBTM z=LX?)qHaW6bM&d}$p@kSBP_5l>8rM|&pZ?c<0uI{K9u~3?{vX4d)36*8%70#%s zO1kFQlkyF<=W9Df-ImQ_lF)D$6e*fMug)osP>aFin6T6=ZY#;T_cxkD(u#nvEj1~adZ$yJ+z{I|;QP^jLGhfiE| zy=z=KAKUVSAb8RpU8KcsT_KpIyV+HCKfnimRl~0PjY&hKbJa#Y>;P2NoaBI8IDnW9 z&rX+_c`6jB6kLf@^PKl_E`FYi*e-mL=9}s(3{{(mGdAi$I0zy0vA%+vzAz9w(gcy0 zMLT^4LTO*f#>Pq5{NSI*Y+%M@86307Dx1AN;vTbo1!LLs4@=n1WBm`fH!lO2F6I(> zo!Yiap?UAWMs3Z>oq)1>7QElkdM%MP5Mh?6)o2zj*)B&SU8x>M-DQw)vN-8^`sUlQ zchggs7ZR$)hBr}b{SA(Jb5OqZb52c#mx)=~_6q*&dpN7}}GJ6%NJSh9#~OjjopkV8(1$ z0gtS87Fs4_a#OIE8zea zs!#A-x)nCqgYha(bB}a$7_B;94E8ui#CSZ{prIR&=a+9Km>`|INQOb_Py~{rJS>-O zyE&}Ltlmj6I^>9zr~<( zo_M1dEew*CV0>se3@a(xnIA4MPjMq7m>6R=&@h+?i7l?NoRIya|0NxHF$EE}i&IE9~Bi!?l&3OJ{ zUr_m=KAS%2E-HIXw6l1F*aIf37}Pnv27SYv5~Jj!G;QlAy~g}&rj)SfXgFZgeoK4G zzq=P)$9wO@=UR++Ze+f6452G~d>eWINTY+j-@SA<$3X@Ft}lZRG74maD0Pn!ZyS!A zIy5)fZff(^F>nGCF1?uZXOz2-NzVM4>K9;l*=!F1cJx!)2aL=VX0bV?O@p?!mCg1nT9pBe ztUIDeORE#A|5zMV7RHD++;(8 z*)(oLEh3nb=2QJu{M>YREW!|&we%0@+#77XtG#mzd9;lHwW-$qTl1s`XwbiO0G zrGI!g`hMqlC#b(|p$-QyS5jsg1^9F?8_enLXu6A_<2bR_j2@fhE97%WxQsSC@p$Rx zhJ<+vxj=RbB)XgRpDkb=v!>*G-ma-UjoP>2_=@NH%;ZKJn6UMc*tE6tRgYg`j1v5W z#qcpM3qB|Sa-&#wd7|K_qXEzL0QgbAKt<-3H*0fBvql$2v+6%Hb^UT)ntSi}89a%*E+Tam0h3F4 zIHs@!(NuitdP%X)*UqzYULu$BvH=tU8jYl~550a#j79WP+J_hMQbL8Tv5=ZoWcnMA zCVO975q;t!G1D%yAW1(D*$I;?64B$mx`$P&OkcusPODi?*mtqJYHI@^pYffzxXjT| z%WHcI+%M;SSBPXgs7`-}?Srm%bJ#*Ej#_62qD}^!#b2kbtX;-BtO2Jc9HU()l3fe-f4$J#%)(pWdCzUN`4nsVwGsPKtVMY2$o! zOdWY{>J|Aq^0Q)bixKra!Ny=ATIH`8_XfRAQ|mJXy!-R*QDBi?Y_Nfad~V{i?*^&` z3Bx4WigZTrG^Rb3IYDA;d(5E3W~MXt7uTE`-TBjYJdN~f*WyeVeQdelgS?MfzX-gJ zO1rOjM9tc-PS7h>P)!|-c>d8r6PXJaSKVU6-FqX&9;8n;+t(BFl&7-E8F*^m%!Qff z(K919_=aezNLp%gG-Y3x_of@!UBkZqO*Y5ywQlTvRguptdWx+wNV0dxcRX^GQmuX1tMvx|BwD2^g*koh_`vW3icsh1z` zvATd+>89WntV0A}V=uE@J${SRp=J~gLqe2{_v%3$b219kSC-9`_av+B& zw~AN?GU?zb#+VsDsn-ujIJm9_FbN#XMokas7s06&k9oc;4chH6N>luo*q-Sn8xV;W zGya5|Lb6rQ4^@|37-n_MqEa^B`eKOfz@?MwtaR$ZRzKr~z3&WO0mrMK{;rG4~H zz;njnEKQ)wn>D(Q2~nS!*Q%_4+-2S{i8fdC?k1 z2B){dz}=|fEMhW~;$U4PogQks!RE4)JV8`d-MU|zgrgF0YTcn+%Q5gCC-2)JLd>`d&?I{?dFDT9;!4U^J=DEKE4Dr)A066#f(|_U+_}F?NtkjHr=aSJJ_HWX7y52SDC@^#t(-9@G;w)S0|v4 zgQf0CgY%_R@ts0pplJIooq{s_`8rKTz}^3fn-$ti{`k_hiM8NsSj?|A{K?-CjK05x zMUcV!*Aov-(_hBMqA`01VLM^0Uv4$hsO#wxUvT}qd`z^SucPzI)SZpr*!`UhU&*Fl z>q55Q)?J5wEoNQyxz@_Cm(O>w|GT~=DEW&Y`@P1ou z3vjTlYvqfW$h>|)#QXd_ujH3{Z<^}xTlaan2Rq#RWna3lEcL33i(-G|6@$-!L|qid z+_;`AZr8j^QIpq=_2-vA>FoUlLY$1|IdJ=HSo~yhN3nYm+$Htm!G^gx%}jb)7|=+?6KH zI4Ri2v9-HnU=Ac(YII7i)#gXsH2A2kwz2Z0xKy?1eatTmq~fPxuA^>)p=S&@>W&R>e_Z)>US*vBp4k5dzWEKA;7n@dSqi=~;ICKWV;RTkZvRc^ zq;J;VdtV2?+W-~s#GZUD&Ay@3K4k7UyftH)kk2@C%S}mv$^P zV_J@;{{^>e%LC9mmgA&C-iPJjUVTAy%Wmu9tppa#zhJLBzwepygMV*if@JB`W{UhVG! z!MiqmiqHT0bqoIc#f+_rE!w80CeL)~Ed`vIx6Xb{$jd-_pZfvb(x#@eqa)Hj zrXyo-XAN+6|FLy3|ML4&e2#lHmL$H^6=A`j=}@^$tiygI?tUuVSUe`Z7dL*#WohGk zfkW4=!l7|hRh)4yY{eRYtUo`c7~XCC01nqmoyQ0lZS8hE(4i>mm!c29zml}g0V4>d z@>Mi=evRNnLE;j zgzM)J0zNEHWsj2Tx__PGA=!U+@64Zg?S#=Wn$$-c*1`uqeO0?~ZnqH!;-f8Z(L7VU zP^$8}%Luol9E5L-IuVt6L z$vEIhLtEg`y~l1)YdK2K7lRzCh} z3JyO14hVlHlm9Ua+I`t`ToPaoRf!Ml(j3c- zEDKTCy)NMlzfT8u40OnF@pZ}4N4BLSv_?WFY-{sgiPQuPRSj(iQ)7gk7Qv$C$0qeh zKmO0Q@SiVo@|I%TJyy)m%lJ5D>x)x^fcNK3Jzmenss*p6b<>&egU1Rj9kXTdspMsj*#ZYAW2?I(pe|a!uF% zjG=A;NNjO}Zj|cWt6xsSy!US{4`Nh)>~^QRQX-<*CX;|#K>3p`4ze8*6S^=aC!e~W zGA<&dp!9}i{NQ0JmCHOTB-T0sA^T^A!Q+dk>o2gjE?umcIws|^@4)$%A*KJ^K-d$eqxu9T!-lT5%7nkGf4YF`q<-M8n{C^+3vhUPc#&F*sfBgLC zjX>Bj@}i%MY85(|OiKU0`R99_dJYy!C54C63o4j%hxmTKb@#7(W(oJwF3}HW@$NnK z`*qfR9_t@-Q8#+~BomtBWY@6;!{+~JO-T#5A7Pp;dy)A!_9f3SSv)lvc95gv>a{z3 zzrR~XagYY|5ql0IUb3nVYuWoxTYi9fJ@2XV^KEx1k%^yMJJA0qk6`gehJ!<gAm7-`66IuOn2{PdMp2k$n(zwnXR{B%S<$1@evakIJA?hQdWx2 zY^9z+^_uUoMKc_5n#c?NR|ou?(gHi z^64*k^kZZw%zd@a%`N%QX7b|5!}|Zx&e=H0zwgn$i#oRdql@Y+!ptiy;Q^x~7cJ^J zIz(!Fsy}{>4(#Za9eY&W_wN3~XDx{R>c4;B)9N0}%I>`HAMep40!5kEq>e3iCB^uk zm+9-3o4X=X>Z-LjL`U~;&p#UBzKh(kn>U^Eojh`0H<`fq|89?Exn{Y49{WM@6aVXA z#kxzUBE|>4mDRBL7yZ#)500DR{^vK*b55QkX`235^wGn0-fk{pGh$9^1x#uYTHhIO z2ECUrPrZ1lybw7!$5kQ@Pqu7Vw(y*KkKcT-e)s=S_1%GNxZB@VEn2h7CRL?Xd)1D% z+Nxc&C}O3m_KYp4RWoXDwW+;>pk{1p#SUVJ*kXJ0z3=bd+xuVqA$gwXe9q^L=RD^K z{C7cY+AI_08Xif&M0eUW)#7VsIwqk?EI<(~Am#9h!E8Vu+twRLdwadv7H&M1Q0Ky} z8Rhp--uVAYFw)*iEV+^svX^y7$22|)*FZGTX7#|ZJCQ^1@m(|MAYE}px$<9fRK4Y; zD_WkKb5_8@tAcsRlWU*MIGp>u1`g-r2M_nie5jgKG}SdWOb~jR@Vqf*0nkrYcxcsa zVt4+N6 z&0PMgP~59<@5SI-f60VRW;8BtYHaG_w6(sQ8&4AMEm!sG@yVU>JG2qpeNNP3*BX6lOj&I6~{YxO?NHf~FoJ)LY) zc&QF^{l#C>WuCZHw3*Yb-T$&$BDx7$!|s2L{fo;!)0U04AV`8fy5VZVp$(byk$DYY z`RePJ)q2n^hZyt!QB>Ljb?VC*H~ozjp4nc*aJm`itm?ma%SprzvR*dmcbk$RX%hOM zQxghJ`av}~KP4|DiMu|yym)8E>ZXAm#?CKuOyz&fgJqeoJ+an8NrM|3Vbp%M;_M%&_DgBbH6ZJO6v0&+2>l#O7vI^=;Pt$Vr-R*VI$PrcX7?jH}b3 zR&9n^q~0PsfBpw-A4WGwG%zd_(3Nw&d$`TeI=RCA&iS`MtNtSS|2f8cEx#l$PK)b9 ziCpIFU9}C}0@9eskqK9e1h3?84N6q*FCxLryjk447-bs(d5+dl52w+A3~x*G^H7bq zZ5_R>lxb+I(bHjuAj(M$H1`qtIYm+MuV zts@1>0KkwtDgKYC&c6JdE@;ElMWIuH71-@{rG27_!S_el`jR>>9PR(=h_q1|aa{(r zzkqa8A@{EI0EQ*F7l5Z+=R?7R_`R$KoMMffqT@;bD`c7qzAP0T8>k$~;8I}I4MamJ zahS{Hu{F#8ntGu+K`Qtz)wBq(`bI8EQ(MO0^z=g3rJ^V{zw^#G>q8TDt9rcn~{ zC?!$cNf!Lb)yB?I_vK#}XaoL-57|~$T)eZtGkWbQ)GK|?HNs|Q`b8Nph0EO2zx1z! zsm!bHqW1!)NCNaM!%e zj9OY6(2c#$zm&^iY@%f)z|{J(%hsp88%x_b)*9Y_QHz^w=|NRY>%={#xC|xNp#dLvyg?~`KOWs) zp9Rd8NXW`+dnc=<1T_+x>YAtFXSRnC|7A{p{Wod5Zt0VV{%K&%qz!>D8>|Ng2Ao1a zdQ5M<1?X1`Y%0OUkIP?C+m=;Ut%x+fo{TQv(fjmU`}(7~@>t)Du)?o1XWz`AKUV&7 z`IesT^`rMhg~L5o1(m~{<2V_25n)WaY7{8Y%`>0Q##@Lny~dCGQSpe!o0N`(oCh2!S8NwbCo_pRU2m6ZwE524h-~l znFZGMr^R|mky}!_HJJ>u3`Yp#CQqB*h%4!9OcO_9jV6e{wz0GBelPor1L|)u(LZNd zW%UVg(78dHr?k=TJ7cI=nf|9!rj{&j*QBQYHZ}L&!`J7x`CD62t}eAX=Bpd?XwZxe zUpwLB9vfhft)I{o)d?g)im%F(DLxsZz8IouU`4f*t-gT$d%0a)dvK(TDhnI(_a>+| z`HLf;NAQ*srPFHv7qH$m=@)+N^n`;P>yi4L%H*8ItW8kyD9XMzPPdLUVz(* z;}E?IhBp%II$oJJCcVt1W(T5j12h?x~T7$k*(cQ zbA2@-#hQLGnO=-bFk-u7#e&|UMB7!pd6RyikUr|UtPM5YN36<}o{ZaGjISs)Eh#18 z-AnPq;-e>{dBBesk8d9F((lZCCtZcsdgV9^`NvwBur>`IPEKiyXGW*fhz7m?Gx6xX zww7!fS6W*Z>tX(UB~d)pfe22hj_`7Qzb^f(&ce#GOOEyKt8lfe12x;G^WJ8Cz`6{r zYx~hX;KIGuxiQlX3#B!R!EHcA4~?7bX_jn#9icIzT$jjBnAHD4KeF&<8V@t{t~~?m z=}^!wDcUl7&oOPUkNagoGDU8FVy?u8{O0o>y|tdbE6L3#$;o)PbiDZ`ZksSzz!z}r zyRr(!ttJtkA!~0vFg$o;I4Zp=F=#$_b%B;$f_oYe`lL6z?f3zhkKz{SP5G4EcmOSr zjN=r9hqt7BXkbq3XQ#Qr{trCRd%Wmj#2mKKfNY&uj&bCzq)Bsc)kmjZ;@Tr1pZg8>Yf!DPe?u23A+wv4ReoR zI*xOX9iML6nttxRFXlZWy`MN})pT{%+ncf-!)~<~KYcnv*a6_o^ z2#+~&d#uaTi86jD#m67Fm-$xEq*|^rN9C|B`Xi%Q@5x?+al}CmKP4Lp;aKjaAGgy~ z010mkT=usKP5k@04R%9TC<*A3`#OTyWoowHNsK+#IqxYwSKQwPThY^heD8Y&N%u(WMttrK0FV5IP4xDUnV<1giW{|G5s06QN#irPlkbc8v3y$6#{f zWOBd+pMb83=%XdT278Pfp^9>)7K*K%5<;K%qovzfiN*R{t(Fy8C?n;cF1Qq7eo0fxr`EAieZ@yY)vt2J2O>`by<%+zIElrsHvmYQGhD6~WW2={ z&ie+>m6QJ+hjoq3=Xsyz`SI^*${uRAxJV)h#?*$=(yr8F7CU+Zw0bBi)vEDZAf!Xn z4!tjfzV`}7uC?vWI)r=-6h4`$OVAo3VTXG~igk!sBu%Cpg zdLD-%ax;=`8^4gFirJ#pJF_a4@~m+A{r-;<(`-ja@x0B(^UlL~C&^>|9V&CpCvKhJ zA{3T+A)_Bf5D08TljXiO`pB9k2)~e1xS?qD;yQhd4Z3OeoSF^d;3RYEr@@>6y`6 zvvVri4so12jLip1&Dn-m_AXH9+~ie<;dw`7#h|>`sQkJEXzfJDJLBK}ics1=UrPn_ z;2rztLtx|E3o^&yD#zbahs`_W&1Ddsc|On-f#ML!^`+OQs^taMtRtOfU>;P0yuAJG5ei>|A&P)G}>6ZMpSRAEwrr6Q7MeId>5%-)+(g7qo?uf&{GDnBL?ZCj`$a}y@4OqYR5$Tc_GRDuFE;F`Rh)! zB#-CJg77$FqZO9#riJl0n`*Ahv8J)8p?`>xt~H8?8#}dP8jjt$wc=Gyd^$~wU!A3* zPf}L~FEus}jxhw$cZqd>1Z{e)+_@A1m^w9kpo&=`7ta>QbUZ87$h8Y=24M=3AdKo9ALU z*pr?Ucq~e{e-IjUT^eENOz9cNz^~pO^i-ov|6XCCMq(t1`R>hx-bF4ow-DgQ$;>LS z)@?63o%vP#&2iJUQ$i~eO13NL?JsxWQ=Xa1m`$=K7>dlF}Mx|XXm;yDAsSz#jaJo4$GIl14 zh$ai_c`pxvQ=$p4o%@9GKl`#+pq3hym(Sy6LY1?PHa5rf`a}$(Pw)kmcx^zAtG;3~ zRSS6b+8fLqy}kfl6G>DeR?(zEU?Y)`ZbsPJHq1Ju70w}1&@I&SLC+OoQUKE5nA$w@ zNT{V@Zzm&k@FZoB!83iD?RVGVKm2t1U zl)>3=sC8RwyH?ZA&2DcWUxhXwht~8xkzTlE0p1g`l-@M99kLoMa~L>Mm$x}neEC6} zD!aCH{`5Jdfu%>W?bOvP`OLO4*BUhS-n(X^t!x%V$YM_f3S|3UEnW{u?WP3`{gxNq z@udu{bQSksAZDav%rjo_W75$uH|I-=zwGTzUX|JQ7WKX~!=PnJ(+-QwL}6Z=;IVB$ z@NcQ3Yg(Hu788Cq#j5X9|Iqbt+&Si4Qf%IIpfH;4WG^iBT;@)WZ#u|fb7BQgQ|7J} zki@l?9DcXH?G%W#{&#S!PZczyx#(pNUsd&0$^2~R`-corsB?V=BTMiw)vqekFE+V8R?v(RqxByezo&*K$ug6%kBgHev4Bv=?#s2edRTZ$)#d(aX(^;CpI>YIHVgezyKk0wa=?6Oo5V(?>!t zcCL2)SE6N?F-h{$iwhQhLc|6luW0ciS%S~}_g@?}q1ZZxzDjGbXFukdn=~Gn$V2Pu%NoshE zJN&F&)6b$(DP_re8W|*_=D8Ro0`put;D;@GxgXyU;i)RC_@_ZWN?wXubW>${G!9K= zpE~v5K;Pjh3~_d+pWw^-udq?Ol}#ZQ<@x6O_*P1O2*CS~-rnWBI`peJ?&7Htn3{a< zBIucS#Ux#lEK$HV_+1$zkd^8;=2klvxkBkzUK-R{SLrHDYw>{)=~7#;9(KBj0rtxQ z1@?JltXz#$Zw2Y!@e_FHKR+H>*n{-!i%x0)9PX@?G?Y#7PSw3IGqeWL0%^1P`8Bxt z^);pRF9&3beol*;wY+!HoZ+>1MLd5Tb$?fIrubN$YwwYlC(2;PxotGSWpm3}gRYgM zvwgeC0p6qMn}g@+D<4ZUeS`lKJo@Xiy7nA^fkz7c9_M%$Yu1Z3 z`E`$jfq~6kUGMC1*8XaFOR??-Jk59@_+?7TUZvN(xu>j(?OBTromBuU6|ESq?hZfW z{^k#ZE2F@`TELJv`SuQZXCnvWuB3@8JWvFQ?x&=O={XI_(*se}N9X zybPH;lwLfmv4+)7s_<67%ulTx%FV(45~|I-bLf9`!p~>aGuHp(rQ+7Q+L8MGZ&IsU zHj?@}le<$Lr<41iSec2Ip&`cAmN!(?v0#1JP1^{w;GiTA8eh~;BiX@J+^T(-A0l#O zKTxE=({)hk_te%&=c0QV_B9@9t)b=W=A@+I5_0&FO|_zME_Bl5=dyOXHOZj?Pv6F` zl~=%vkKXP0Cux2kn)Y0E({`HDSs!_r>CZp0MSmDR&lqr`sDiXRZA6P)U|xJ%UU{~- zFcBzZGJNqcR7;~fMr*7E8+>at2usC9?P8+&b^jS>C_vs>`j?B~RD~s`>D?Roo=b;Q zNY2*1Ja@-BPF5-8i)DtOS{3gn>!W4+@75>K+T%L6*h}f20b<5Vve2ZJVw!fcoZgx#$ex=4u`!(3hFAoBb3q_X*LU2=e#8OW0+ z2lA{GeZkaOGH(3huZH2CMrXsC6gVHPZlx2Pc%>rQV(B zadL#}g?ntEoCI!|kjokB*9CQVV-^N%8`K3E&y0>ooESM7^2a0D zH*YSRa3@uDSKS!?%?v;G%cP;)l(Szi5SwKF~l#UOxv9QN8)b%X1L?LaSmJ~`KVfyD-SH*nh>~|Bj_XD zEx~*uQp((Rid&GDIa+ql&6HCDjc7u7-dIs^mP=mFXBYLew4{$sGGv*j#N+_9+Uz~` zYM$2|V67OJa9PN@mT$IF7ngj!bSq&u#?`@Z+lLGqv~%zP5K!&?DC~O0bGI z(JRf9dHn7TnlHQ+bd^dWb~8GB&2cY7mhBpGfG5|?U#CsvQ|W#iz{cKqW7iCZ*nT7_ z#Jqa;r_tB#3lCI$X{Tfz1=Rt;u` zzf&n(G};ye8degA2sTr63vEm+qlO%iUMB^8J z_SAu5N>Mhm@Ssrf8|mrj`^^lCCWsG(+cTB1&J)_iskuKiKM3GXdSv>O>Gya5Da-p6 z#xrd-)Eks@RAeG%CqJ)gf8&0wFSz~TeH>S_?F{*$#~91X2i&r*uW2eX=|Km?R7K_+ zW|R(Ttk{Tt=-04#t#OYh4{6`oXnxpT4)6qhzFZ$&K1Dv?E=2fOrJJN&EM&wOCLl7u zuxj~LDL7?EJqU-b!~rg!(%gmHr2aG0;JzedAxy`Hi`t@h7gIcb){F6ytx-{}Q)yE! z?j4~5Xme>fJkwKwv8EyO+%(@Fy@UIT*cJQmE(IZ{z+j5y2NAW8y1>=R_p=@|1cbLd zb&Rtezx%Hr3>@ne!wXEM;&WU_RHVEWpTkG@5t$%kdxl6H;n#uq1j@$ zOf61(O;vk-ELCR>*y^0VW{qna5!O~X-FqoL=>Br8f<)gbjt_BU0Z@rIqD{suz z+;MQ4qC*B;TFls8dbvt9TIcgq>jf?d3cvnDg^kudQ$jjixF##sqOGJMraWV^tW1uW zPbj#H&NN}T&*@L8(L0oiK_)q)Jt3GpDb8I|K5|q_g2!i+X_E8yn3=|O)ULm0)32-&*v|uLMnt|MI=16%ZzFJ0O+n( zAXazIGJ$FWC9^DNu7Rz5wd z>%6Udb=NgGg1J{xZ}(KZy#0y&rAshR?}zUW#VlG!k}-$g(&I(j?p6Jr?ILmU;C}g( z_>n9oeC`?zgx!l*ZEG#&)+cO*1bZBKKLBjqtMm@5hi6Szp_#nk~qw1X5oJAME?nxei$tv$DQ?v4<`H}9{(;0I2_R(VN9fxIE7kx)q_kh zU1zOLGPo`GJ$GE17RGeA0gs&5)psX~2ZIH|@k#2*0P?!9-^;I3{K}f_2lt(quNKD{Cz*=~5r58bj2b2Q#06W!t|Tm+6!_DDB_AI4!D* zV(xlU9`KY`>_OH|!Tg3%?XSEx2t@`q*`?m@k$NC6^IhG#mt6w2;yLvZ<}-!(Y5GX9 zVs998YW+jfVLjONhAz7HGF>qR37*rKSa??;X}}IQM+X7BwPM;%+dDr*e8v0(PRUPs z@3f#}F*|}nl!XJGt>qFWHfP7b2&T?GTJV<|GjoJR{;#|#d&tKyk9-l=n@eq^!hK!2 zY{e~NU){+kG|8;O`0mXn|qZlwL>i+0%&LBnXk-%{nT}+AT@^ zbP|u*yMyGcf`@7{hANT`5@ix?8W|MHmJQj+7Y5q++%!sidWrpF9k2c0`Rvg}hF`ekvws;?<6_XR9u zd^Z3YOHuMpAbfEXo1LFd2>iH}B@*a^mFB3DgZT-KpKEyBS)uyr??e$|*lB=`#F2JQaEo&S-ossJG?f_JE+*b0!Aw0^f=tRIH6UoxdNeUu2@{ z*`r6Ws@lh=2Fhx{z&?1;GkfNk&+;5{)>HBYYLDRM;q$Il2UDxy=_j8;s^9&LrKUta zZ{Kx#*WmDeBZW%gw2|0Jupch=1;heA+$(+Qxlu4 z7-L&dUzIZkREVg4eoMN}UJVLKt9|WQlB`kh*iECM#vLE*i}><+BywJjKv#B~9`&Q< zmu24uL1Fh~FKMFN$5Of5DZ{U}&k4J&mh<%*y_>NlRg7r~H~aZ|?))ko7K?FyZSTLwa^UDiRHc4)W!pDd5k~h$|Oew<1J;Dk;#0Pn#z+?;cltz=A1UqG-8$N_~uyyt@z{bq@Y*_|GTaq97 zp0535#4FxHNIyB9MOkP-7<=i3sWdmlcAg?T)B9}&x1bn<0u{M; zUUw>%bKa_|BZefX_S=CWann+CfV>~}*4QJ+4Y#~?uREY;`NHFtdv!cYV?Qt`y5+rM zYh;nE5D3N>-%y@lfD*(_!VN9LzH*IKEW*PNjXekwFXt0$P4v^LBN|-71}4g}8rH3v zw(pdwgn5t=H>FF3M?bC%_o!i{4isG{X(Wqn(>~|G7!YbZ7%@BFiy1I8Wnb(yhC4nv zmb{hKt3!38N|vNh=Pa0?jCWU6YP!&J+RylIIvG=03pJx|VGMw`+Nf7gRy3`qeTn}5T)@iin9vUMf#m2PKaS!yWl*x2N{9RQ50FulK6=y@ zAH>chihHTm?$My3;kHz-GID5IpkQ`#C$xCAV;rPda0$FH@A&+Ip=#UxmPqs<1j@cF z*#R+Q3o_vf40h`vUHA}a6*yex3f9ckN{*$oyz32juH|q4Vt@P8;x}CT=JJw|I}4YB zPUbW-p?rtV6^=ZiuNM5d94>@=+|Y7`^`@wcH?j(6n4K&O2rl=I6k)Ir06_ zkXOH?{@hf#R7vKU9%pQSn2M4*>$XPvK;!fHsFtn{TT4{Ak?n)D6Ezz=58bt8?CSlfoC zC|_9C3E&p^)eG#YRVf|lR{OUci~8ge;*IV5ac|FbN*H!{N^SJOfX_a@0>N!>;*M8` z`ky3m(GuURi;6T-1P#?d(ireo^Ncn#`vNhRsl&yNx7BNQB@^|uKbKstR($b7)muk* z?wOazv6|M5SQh~GZ>tgKbVv|$Oq2(^vBlX!avT+)6nXZltbycu-J0*CYq%r?og(Y= zV9B^(&iIa%m)!fV7lF~H&xp_MWf}3g-=ynGhJx6v3RWJ|0l}Xf`4}<_@qCpl4=?<( z;wQ&~XPq+p5c=7J%iq_B1?gLWJ%0E0zVh;iaX0_I?S)oi?2%Wts4}{rEM$==Lcypw?eyl3C z3gGVXX1g|=wI?IKkEXK9PDzJpt|5B_5rzTD9ejJhTL43#d ze%)?qa}BL;6#oOuRzaP)OZVk zL-?F>$v&L^sk-*~yVS%9*S?m|T;0**ln5KZh#Tu>7UDC{VaOZoW8i%(xX4iI6`u&C zh}vLxOAu$O($_V8e|3Gv%TIA@R+hwRwf3StCI5BG%3wqcjg|*;$1Z#* zeZ6dqu4X55#^lQ=SWH`HNgCB}Qt`>xt}cv~x=Lfe=HgkB%9&uxG^n-W;lO@RjoFMG z%K|*ckly5s`!26qEjr3Urd|ODf4&RX%cDd6PpSN&i`c=9leD_CSD7q=mlIX~l_bxKl{`sGl{FO6t zp9N%y%>JsX_NKyJ(VlLVOfNgR*O8e#%)1GFclaSU{aAiUYI|$MLCaCSFLiIl*Ygjk zq?JF6zkKCGmFYip`F$I_SLtUV{j{Htxw_=1Cvrn9)w$c0UWq3UUBl>z4GAl?WEd~9 zUCs^v%CtzmEo|3#W?fMKe&F(6azB>@nIF@LVcdYtQ^mvB9>bt&Wbi^gHwrXfYc8-c z&8MuJ%`&TBfhzfsC|SErESgjC^R%K5^TL4Nks4u0ZN$GD+ND14nc_(uyyo#_eX zD8t#DljbvRnv)((xrlQlpIUNG8!0yl@~xsS?J!O$T_ehr!a%LL#EIgNYn^>j%!TDw zefZbZtprkM@7h_Dxd$o+U0w+@$pz|&VmTYc{>Dna$(w^Y9>HwLYFSB~o!r(^e@g3t zN0$Qtg$#ICjA0q38(yosW!fpu^u*Q4>r{FqPs8@7IH$a?!w4%wPl+$~*lSIBHmzQ5JSy97^~)fsMyfWnU&}yEK^N8r;<0|4>;O zr_mE7^-N~Odt#{)b}7%k?;kuT%T%W>oaBMYRD`$>v^|pvmWsDiu_Y#TvmOJ4h1Yze z23tJ2chKSX;h5g)+FLjF%o``#Bz3uC#HXE&I(cI-q4MG}De95w!)&xI65|TVAhct5 zYy|o@=s6b^b(kM9_3T1_VH*trNA?sETin^m*SvH?9C857fc|2hDP!~!BuoX1Novt57huP9&`nUMZeF5^46v_dHBT*McY!m zONM3Kd8o%RICRr(Yo$l=S9fH@I-d^Fu#74XuVMwctbQJwXa0R3xH##`-p`_4M~yjX zj9(cPTF5n?wXNEFRx2#uw{BS4>_f{d3;mIDzH%HOVh75_uwGEHtUvR6btkS_|0pHd zrcPxDeRmA}`d!kG_ruh8b(<&Idm_vA4e3Vey3J6XGC!ZF)Y1?mp)NM{6#ASlpkezl zu+c!LEBEr)S*}d|R)pTMpfi6`{}I(Lm|`0Fz<6dP#(J-4MsARccp`k$c#VGaWP=`6p zc=p=!cu=K;pXbZBYjrGFC7Lb~OlvFM;^JcW3kJ_1pWAypnGe^j%RgG~Oqa78-(ALd z7|3Q(^yXVzUhVL;xO)-NZD^Fi+-$C7Mrg7Q^H+bk@;&#{pV3Y5=Ft9f%f8Nd5Zz6e zdVgAhaL=9?_MO-~W!sOwG+EVC0GC0uCh+em+KgJIsmT^wmLGP+=bQWJp2`@(?vap3qfhPi?(lX1_dMp4ivUBNliJt?v%>`m1l!|w``%i@Z0z1c3xH9~dR zVYPGtyVPqJaeIqt;B*^a?c*__88{uz^dR^dJ^p~~F#F=PDv4+FwsEJ-+T$BfC(3<9 z#I2KA3R9J%>1Auw)f|dA5=82ljlHYvspoatX*(M+KpP4YUe?}@G}X*gcP{Ew~+4gwRI-EkyWdw-D#(cp0D_Il@$`ZXhBIraS$rpsla}) zO_Hy@EoT>3>t0kUGthc^2`^2y@f5*zm)h)TKF9ew-AoUvLx(HXz<7t-BF+3-j$~NR zzLbr8^nAx|@5&>_tA-ha2RT&`CMZ>s=ia)ZP~FFu>F+$h^sdA=x&PS$2>P`xsZ*xd zmSen#JOw)a{)?MNDJsYgz~37YTdjkdXOjIKw9&;03WOJC#AH6pq||qn>S%D0ekfxe19;dk!>I+jy>^uf&)4W4=K$j3v5Ss~}{E zhsDC?#w*?FPW>G>zzO`o3FX(s*XV`Q7`~8310&~BD8)id@{22Q-KzjwEEV$0{?)E8|qlcQLTNST6$xe}eAA6yuB3w9@>3MlXx0_BmYT3Vw;&Z6;oXm z9-%RpC?g_5GEhA5<5!2Nx(wA?K+h_%ih4os-gYTzd;RCaP`UX|5#|$u`^~t9G(OBY zKRA}9s_fiXcrT1&8)E;d2auw5{ti4imXZ`d+M>2@+O`(Lr1>99Xg>!!4qC>=W&rO; zrc4({czPzGKPP|85Ne`EK#5Og>Rz%|B$W3rYG$S2wJAm@0^}i@bZ3)o8r~Z``eIwa zp<_bflCG1EM*dlYo~w0oV9QgpUJpUo8)Cq9J6}I191kC`f+^fNe_R`93`vXgqpRy_ z9}~PDl1!-SI2)^w`hNFYDOtFO_BYXK=ahPa#=l}L8ASG0fK7Q-M+olsN6_{KR8DUp z_aGK!8ttYkWC)WF>Em$hWW<*&>YIo~dwqEGHEyOoObq8M0d`C>D2ZV8lFrLM(_D4} z#r+OYem>ADQEoZIG-%w}Sny)J05Yb)Ur+n8a}>DsDVC5Hc{%kM1%h{8KU4a_9F-&N zA6<{#dIcaYjrvE@DcW+;<;27U#>TgnL(G=Lp)rcU30gyY!}OkQW_m3o`uIV z;y&ol>L$oWSVFyJjg#buUH0@OtzJ@u?wZ5T8fWNplbr!y(+G0Za7y4FwexOL(VjCd zmTz+FHOo*Jjd^Nu#sltGpILV; zGv0Cam60o~3AR6hP*)c|2Th(62lcr<(r;JP|NaVH&5aL%uwBi7Cr!HqK@+0Ja3QPc zF|5Nyg6fj|RU7=tm582Rm({^HkW768P2}`vvG=Y9zH86aGCO6Mk>CJAIh;9nAUT(8 z@uI-{yD<@4IwvdNBt4bm*9QX55)(YBH4@Nzlrfg-U*&9OB~F5iKitEqx<=ZwWyK|*Iq+-2oFyA?l18K0Ma(h59% z|6&Y|GM%&3eS|rjCSA>@RhnR5wbxXzg;us=h6A3|0K*h?%-s%ZcEI9m)M*z^SuUvB4+Sm`E<yGGFk2{;33)=3OkZ z@+g&Om5qD4zrwN=A9RpZOpA4C z7s^6Txby;Ji4N{+yFi{|Hy%kDyr z)+Mlzxk4mZr+ezUYznM&ey@+LBxXVa|K&wzw{^1hOamyN`$E?yQu8_A?&oT$b%??r zIO%EtI|USevKnRY{u;#RmxBkcF|NFhV#`VsXL*u(0d)r`xxs@HabN^yPNdjo3?46C zT6#*+6G2CPGJcNp@j7Vb^nk@Lj{OecpVrmQuJSb||4#FTPIE#YkyptZ=H|*nH$8nM zers{*VT$EUa*cz+=QWBV`*&h%wASxPxxaVH_^WZd0Cdr!ru{W6dC#`DFa19dy#85U zVXbji_B;trwo1I_|3}2BXF1he?&UGDL;VoC_u+>60HtwJ^5e({tM{WPR|f89-;q@>Y%kdEYLR<4AP_t67#uS%Y;?v$O^n>&0*WBu0(7 zia^E*E^+$PYFJPl_$B_h_EHeTO0glB9;l!pYX*7Aa5({CT|G-H9S&p{5U))YpL^c8 zR{*_rACpPXwn;8;`1YbMV}2tlqIgu+KPOpOS^3H_%ws64H@EvFx+fltw&ukt`_s-X_fsh&pb4?T#Q`n( zbm1O4b!M2&&LtO|#8VgPM}AaAz>?p0-NQqQZzuZ+JRS;sXe5|hy(KVSWNDqt;FITb ziBo$90X>?T{g-KXRL*4H&f{{I2Zp5X? z$?>GJs`0pdbd_Y}lg&(}yOrv0ndr+4J~adHdg>FZ*mA>o(Cc8skhn_t@j_faR_8_2 zL&w0t4})iL8$t=h$VZV(r(keb3G8(zsNh=m)*j*n zs+*gK7YPz}lJTncrE1+3AyNTZRx*M-93d+O!ZfO^`c z$hyajYegwhq2}|CHTiiYM+!LC{q=Mh(Ui5tHd9_hgd*v)P{JbpESdFEbrJ!|7q=Uq!61Jy}@r-XZDb& z6>HkJ;QF=zIC9`BZi%Xujs%bvs3rVxpQdTDN>6KI#qyv;?)!sBW_2PQ_4)mJKQ{UN z78S)`W=%Zn~3&B{pDD9Q0TL+@$tYm6exm_)j%ZcExlx-Bi`2yJ5ea zslgw(m=oxWtFEK@DQT|#;Pg`V;iH)|F5Z#--zo)#1DDW-5ZAMXQ;Zv#wRLf~1M|-% zV9CBDl%3w5fftdtY60T#WZe;Z$#<{Sl2R&cIj}3`Tc@QtRk@+Sld&-6gm^y}{yBd0 zK`FXs4>opP0?@r>cqi;ydL-4RyXZFXyahqgC|}%%FR{OZyVm<>c$Lq_}5+_njn_WjCF3@ z1JS^zySmjhYRrqqz1Afw_sqy=#=Z4c?v)>>N|l-8`qR2rH14g*G(8^9+Vw3DUrPa zIXt#amUh^}`WEeK?P1Qxeo#PqK+i&ZfF$f3HF0apYV|aO~ES}Hf zPK*6xWGvqPnlWY{$UlWS$6>#Z^jsfw&_Ac9{J5Ih~P?q+*2?|7T53vajQ--qi1$FS6e-3i#rcB!X#E$MQn z1%~~BYsh^}(mLiwf$@*(RtD-7J*mM@{CbJjsdOEuy(=-?aAso((dFHGf zq?K+A#y`u8d@$~vHy7t-+!M9PcFmQ0I0&P{oK%T9sz0d)x%&ei`|j#x%wbyFPKz~- zQpeFF%EV;p6XqC@Ck&>A#>(1bjp{z0Q__7mxGh1fYuLDVRe|h~yNkA{ zdfs=#$~16OQOcHHUwFA=%CBCRB*r~Ip1Q_8T&>uX5fQ0xVdI|EF29fkZm2y-hj=?y z^;)XM{n)xomK)2U3VChrUdf!$6!iVc9BpYuZEmc@bqZ2 z*O$wj%YrtkK2E0gl^qL9pd!i7@O@O?@BR8?)BKq6fHvX^0olDx`{j%M zhPHY8j>$CnhCx}78Nl!TW_852VSDU$&0(%CaJ}g59h1-XfqLY8h}{YJs;WLNcUoZB zKM~`e$qU>sdOD(UPutcr?qNTbWf>-^F~RkZ?U2hqpaJLk+^`p&Ms>nKZUFRc^y@~+ z(}B%>x4OnX#HAMq?#C7<0*^|=P46(+VZh=7NxDRCW>6905iW;Z$nW_i&3Q2Vi66#2 zc+4+9l2`MoRnTt8EUkTgH}NZOp z#EzvelsS@VZ99xL>sp(u(Ka6oa4wJg-8r5PARH$aj4uz1tlhTjJWkeRIXz-bR4z#R z!$m;765-Wr8tjzhk#a+@oPzXs8gh=%0=n{fDWw_>%(vont{2x!C?`p5+KWEJbehYE z{j7l0;8D4NsiHDTo{8BkgS$TE@emE6T|6ug7f_6}$qX9hIirteGH=n3i;`@Nxem21 z^w-$BjH@$m)nuL{pXsVpW*X8j8Ty+jANnIa9^0nyK9aWDahI2gee_eUt}%FRp)TJ| zkEtR2cxy#~Jm3(SQlcEH4(MXA=opz^NY_Cc6g;xMEo1aS9- zkjL9qDsu`}M!gT9i>dX@X%j#l09}n~U#jQ6&WAx-abxF4&|cTT4H45=+q_)}8&eO&)hR9^^_F{B2pQXauhLh?VvV@}t!xS}5Z zBSI1VAXfPpubnIO+8zJ%Yd8gM5SQmg(C(IaOx8%gpP>r0cstzEf-3?F`JB4e^&#r5 zxvnwJeumR{cx+E8jc@y5x@q@8$A90=zH!ewDk{tUJFKeHecjdYn9l|!jC#KME_cBE z$u=1L+BEJLA|CWVykou7fIYT2hI6#xef_}aukXWC`Gw(0|DQh&%Sw)YR#M*7E z*N1S7U!P>L?PiLCFn;6UTv z+8qCQL-`n2b)!HXAMdH|$=YH@v-}Nu6%QG!}OtbS{d~|z&tax9;Anx z3hJoWGdxZX1@fW~_no7b(tDTVw(WB1ox>*UZq6Jn{W!(22Q<0YH4bYUe%y@(b&RiP zKCZXv@@?_8;YoRb)aQRl0waxk(M@3Bx=uSLz4j14_RP?Z>ndC?jHwUu$(Yc(FgE&> zuW?{`@w!mPNK|Y$?#*LLeYS#7>I!XG>;4EbrVfUmL4`JNXT>%~ljY$#zbK@S;L}I=p za%slBH5zBESk>yRsmEa;M>rtWVIN})TbI!XI3}4qoySGiZrjz-Tim?33=Yci<3fIu6CdJe+>1;fUdZ6%z$DRUA9rK%HXR$++jRL(+PIe^ za~M)S5R7|MIMlc|ciuuixbg}-I1R|cx~`ii%2F!R{l1NU-6(lFr#0>YC@4Ou5tZeB zH}0uFcn}BY#y#IJt=>!1W7lr3x@pwNhe#3B(xmZPg2;h)#J~^WL8C-)q?dozNKph% za9M*<&d~{z#I(A?Bgop8d92d5z?yZf&Du??>k{EMmd#<`ju~^n#|sYJJe|i~9@lxC ztjXo+5%ppmg%d+{47#&)I`VKKGEr=6=fq5E+{3%UQaJ$1ak=A((Mp%!qH!hrpo}KerBu_clTznjOeeS1mZ$Hpgn6oFZ)JTuNtF6Pe=dJt#_wtCx!u0<2zFp%O zLHXjQ$~ZvzaV}xw*SJfJTPvg9-!r&sy>nP; zEa^{82fkpV7GEONGv#<}=L@)Tn8=~7N%CPFt%2#y=8}?pIKG3o!?BEez-2MnY&%0A zZGTNc>XtFFig8bMF4u4tFb+wKkE&W-lXA9t9=~KtUF7iBg5BU))6q^C(Y~K9-9xcj<~ecX1zS&7sab zP0Snf>%P8=PrU*>kQaj={~x%PL*JtgvUE^W0 zsxB{YOrlj|lj}uK%)j_%lWePA{Oa;f)cw)azou7Oht%Dh} zO7h|O4&DxK+?zqTZ&!N8RqVU2{5ZmPhCa0DiFuGou0zKg_W&w_eC2aV?b=j_hPY|R zw$JS-b4tFcSov4YxTpJtFXL)>HEbv@`paA0wEH0UcZ_?6DNlWg!rULXx=GR?Pc-Ja zkuUdS8=VaXM0r@AMt(wAH(cW^MjYyv0}pBBpEXik!MLXd9UB_=PMWpbw!c0#21VPydTuDBp@pN_SZ9}-1z1$dFUYGmZ*rlegeV`6^W0_mm)uZ2U=@>+u zJCN#WfB)E&TuCGKPSYS#L#A}>`WCW8y2d?W1>@eas2TUP?V0$FpPx%~jd5c@Iqtkd z_v3;jEl3$J5M`PMkr(z)W8cpdJ>QaiSnrY>K4qcD}B zyh~mWXf4&caGwyoqw#Su705967+Hc?f4uie^rA+KsdMgi2D++^o5+}BKvUGxO}>^{ zk3){{wHVZ`XtsQO!?*{-&(!uwVQH458u#kyeSXy1z3sN6RZZ=;+4Jos82852hdSi? zGCaoGwr_7c5g(8HL&iVyx(flF{f+7_rn9ha8mtP=<9%BS>uz<^?t{L+Y5qS-M90000C_v9a@$*?aGc8pr3MPs(XUGiS~_l3fDc zX+<-r0l&_=KUY;eQ`B>71$c46_L1hJGiOSpC=V?z0J@ncVDmFxNQRV%}7;)01&`78Crt7oqyGN>uW2(g>*Xg_HP z44i^())S{>fd9H?qtH;;$x;1-n&F;q+{WYv9(#GG>j_iDWIG7*^eRi~dr64Wa0_|`yhcr);lg~Je z5ZIe#=+iG%IX>i+PBAUM&@EJaYiML-Y{Z-sTDV@$I|QX5tTeq+?>SZ)kKi0-=-=-K zBis_W19^ltR%O)5*p>62N3#$q*p=PwyBqHw)as>H5blZf=sGY<;EgI0OG1{vOX1-& zucETccKA}e5;_(r>2!P+)X5l+>$t=p@?QM4VCMKhY~1&A7RxR3M{v8zRMVnZTqNK6BQ+Mv-wN8ZJHMu@6v?g)^i4lymwuqh;zY?FxV*P4 z^Jy)b(B*I9#{4|5m@b_9-d$}b&FciW^Dv>T&kq>}-nw67ALa z{ayRa1iDuR8ngt-P~}+ZN4Xa)igjKUFSZH?Xf}eQn#{~VMw5eV_Ct@D&{`GlzCJlK z7r#Ak$<2c=%=@1sO-WGdDpQ{8|(Hc#?;o(1KuJuDj1(?nD2H z#HHUi|Cyz-k6rka+sf(#(Fd=}ih?3Pg0y#T49vyE#&F-^UQfCm_>zX4GrD9(h2hhT zQ>2Eb7i}0#i=Mt$`3vpe@8o~2T_l@pVms(yI|>?z+2`ozR)2mWvw`cw9twj`zEfr0 zyQr{*<-VBcc1^CNB#oJ)5xjP7NbaeJfa2iS6ODne9ri7oO#xU(>X<$XX8~F`(8AqJ z_*f$Ul%fh8-{dJ|qA<)G8T%7E!xy z_7TDAE&4vkhw;}5zqFna?`S$8F8rA1Q5!lo8{18jSyh^SKoxc%;Q8C9$@Ug9ASEVW zJ0=gjacOlH&G8iXU__E4&h~En>`kaB1)lr=C&L`EUn*E5xXJ}q-;>gW+fe4*B~s@2 z{MppYY7B&|>7LS(XsQK*kHFpS`Q-WgMSb#n^T9h~f2didL%@1A2D{{0}6X4U)D z62>%h?!=Z+2=aob_Vr06dzNgPS@(Ovj2TSa>vwlWcctRfO#EZ$96`WFxn-2L>Ey}s zm69)3UdSQPvMcZQV>Ls2o_>Aj!>-(}m;u(ISmg$*j~&jIZ;|X>)3;D%B1DpG67}ii zVsq&%gbw;2TKxa)n%U^#3IbP|CbXl@@!41>`As@H zO6Kv0IG)I1x!r*KLG4rx(D9a@LYS&!vzWbJL#um$bs2uD6W{o#`1zCk zy+E3*2hUfdMA?lg@g0$2=^!6F2bqK2rrjfmud%%=jq&y)5xI!q9Z{pP&X(;CHrwOG z-Dpo)3;9NKJIZypTVIC57+OH4F-@#bqnBsbOywrEKlc6I(syVIxId{CxAN$h${U# zJd5qQ%M*D~vDB5;K6VgbTJ<-u2+B@8sYBp%>)Ceu{cs(A?bf>FjP(SQi72e1!j7Mx zNPVB{jg0sCjS&RO9AT_{`UULS3tXj*qfjmLIf zTL*3nn2QKv%9z2FiybZO(h&I|6@-%-fvtdEX`B1HryGa1PblkE>&J_>Ct(-UEEh)Q ziZ#`w6`syk@lf<~lFa?X;~1xHDZm2jiwDMPRu%HBw{AS!G!9jd*RT7*tlPe9JoFCM z6Q3c6ENwDly_=TtIqaO)BjJ2QpKbpyA4V=8Wl2{oXzk8m%9xPyIM4L747)Vz>Pc38f8T3RXiud4c@|nZd<|l zs;>`zgYo(O1|X*FzsnnQ-MQ7+ZDhv2J_a#MuQ+;g=J)nGHZn!KGmIhPtJUw9d8+*0 zt|!+Ct2%~VyoCZx^Wo^Jdl-fw`-%FvDIw*~nG?t<;#lUHcGk@7J7W-9^!gxR->$oA z$eoWZty;j-8H^~-p5_<{at$Jak%ICX>S#q2bwkXCHhlCBJ9YlOzX=B(1b(V7ijZG4F3aF!l5l`x>7s9Jm;4=uSs3@RLn6 zFHCX#p=rvqC`Dyzt{N?^E#90%lgZV=*aLdLVw%<>;E!R2_F8*e;vASMD4i2~uxs?1 zL;dj46G8~hiA6{6;D_%`Quwv$7g@Y7?yS;GX*;r82t6%r;QWIYlC?A0iWMv93n#hp z*LbMA{ULI`zg_Bm@gDvMzrBta*0H{X)MBlLUF*?lMK9nXx-Y zZ-sy-8m13+rFwD}v6O>#WRgAubP^U#$`UU-&K7A;n-v*M%NMo#O$umZZ`VwF#8C4l zNgO?TOvh&si`XI$6fnyqICJb5CTbg}9LPFs&VdsilkCf%JAe{<2z^GynA*-5#7OO8 z3RWIqx>g=ces<=nX=MPlP?@}XP3~lWHp?H~jSyPMqMVh#6ZHmF;hmS)qfu^q&3Zk!zkk~wWoRyVS6ls4uR*&?uNGPV)Du$ei)H7y<6!cB zvxnJCqHY(lHF`BqX}L#N3~5%4t5H1iPmN9P(a992myE_D7I@PGI6q*BZ_^bB79$1Y zOYqvEnH+vi0IyrwA5J^>k1Quu%K2Ubkf||q1FY#bfKNXe9c?w=87xuM>x8=$u@~(evD< z-9Ri%V@zHBNlbhQ$`TtB8y{lD%hppaVb!JqI;xU-X6ETxnX=s}N}Q)wKoQM+Tjl&o zrnnynA$K9UL+@1e(D z;x8AW>DalyIq#FLJ;Rl$Fa;UQhL1k5lf{FCLng{4b1I^o2MNRR9o&5X`Vk9f7yR}NEJMg&T}ZWiRoRb^zedsD--lHOOcQ(lX8wotxc5(b_8un;mZ<@cZv?xQnzjgk9O&Bc%_o=Wu=gS}(^e#`Y>=qqIIV^p;Y z{BUYm-_xC5FAmbyWdxZ~q(o`zv#p4mfdiSPJ)|U-X3kHZJ0i8)j@m}du2t(sl1%+# zd(+XIj%OPV4?CSy%{Z=l{JzT{xohr+v+ksw8+`M73v~mV>Iyf3(-AOm1q5+aO)*s{ zq0Zj(S|K91IBRivuU|j7LVBNwh_c8-uWS5MRomSw5dryywVF0wWPC!^No_W@Qz0QfW+w-o z9cCP&CD!GsGWkezvS^Jp8#^~Kn%DR^H>_WJ1*~9L46+2A&p~e^>h5DIsCu#JfB*_p zRy6wkY>`b`#N-@vl&c(!_)X%h#dT%W=jArxZK~4wB6Kx3s}HYyIBIq^1>?(HK+7ft z9nKl82}pCV0aL!Y5gJM?F4vB-;)rR_^6&R61qz#|KJtuaT^~Pj8z+|($7MIh<=+D6 z0MwpqSzD)K=*aDRIM0hZ)M~T@C;582^!mfT8eV}cGd#veaJQAah*)4>B(1x)q;GPNPYu|TzY|e7&<2{BciO>fl<6~6gzM#=^?afOna%13P)73D@35j%KyG=D)xTDBu z$baND?#_+ohWE`Z@=t&I(aGo$R}V~Iy(ZmGXyN4L;7gUzTiqwJPw1|tU7Sv6z8y$o z8ituxAoo)Yjpw2x*1XNToI?(y=WkCjVIM7yLHoYM?e9kErlkuHeD{1 zf(?23-wHh9;^-k>)9|A9Hc;)gl=rTBleN{~zJ1$UpW|<6$>M78>8Dz&9y~r)sAAh` zwKX3z@#*qs?16^J2;!<{CSBxED>(NVv#Q44H?R?RG*u=m>o4N&dKe)b{+V0{{yXV< z8VlaF|HUDy`7-;2Ob7fM94(S(isrLNXn(%Qo~)#mly7YvDVkr$)PCq;287{*{Zd@# zt4=#!X;@wnF+p9^4I9n$IbKGSpPIj3RFfB_qzvXqf&5BU zOfvRKhEvb^@rqi9ljJys+zfkCfXqh5l4Z)BRIANlqB|VDfM*EkRUlcWFS6_seb(fv z0OPOR$lX4IDzw0Qeqp5nfGqm(8ZhVWgP-wA6&DT)Dx`5#1+%e4rb%Lm%cT)h~FZko=sLH4-AI3tF?$N}u!PAM3@hi)ExV z!s6DZb8}Pr94#bHmWNF;E;86vsrY*$Jn0WIpc1XRadeq3i?qE20s4L$qqyG3y%3(LOsQ2xWNd}w} z1)-;hG)yoNC*M~?h7Q(hru<2XXU1N*e3d(Vo!#YITgngWUfea|<@mZ_^F!xruET##>lV-cLJHYGb{@Mh;|VmgtJjjW&1!Y4$TU zOVC!kaptqo;DMl@xt#tYfiyvV(?@jGOVeX>BlhJ1OC~Zo1<$OolA-RH>!e_}Z``#>_jz+Wa zCvaBu4SOH<1PM90mIPDb;-!QdzE=*8PDpv#$o1?eCbxMnFiz&(&+=#L6`*Acd2{i8 zR@sxzS5b~8U%s@}@AkkTg_?t{qP>{$^2y8E@*Cg294&}+k&q7+MgxM_A*G862=`a& zkl?&g$vINN+P9pU-fJvz%l5f)?FaV?@itlWADy@-o`Ve%Bm2d>N$MEA^Zr?_-!0czPdjBkQ7^WsJG#G8urXLca$w7dEE=$_-Ld@$(>{R>j~^={Pn4|C|0JOBI3$l$wF*y47=q*``}s&1QJS>y#r-G$ANA0R0k=vt1-c6CjN ztA*e74E>m+SE3nasE3aQE4VTml2_Kn8SEUIMO zV^`KCJMr>pYvjayq_+2HgE5?DN!xQq(;;qy;=a#FpUNOX#=7)IU#na*%gSKlzkdcp z#;rV>@`N4tg;dD8sJG{s31^r_ZUm%@vNRx`z4j@_!LZ%o_0AJps1)dH3U`;@bUSyOug4C53S@cwtmQ!A%>cKvjaZ1ktQYAn^ zrJaj=S;gOQ`YOOhdEZ$l*WqBYBhyL6%$S5K#!jdYTnN9fbuO4@Q854+w9N0ZxY_pP zMh~?;T{z9jcRIJ5cw4x86fl@kxlbu4D6pTy7)o6jS z#rmj~*eA|2ks-chHqI(~Fzm7RK_9se$+MgdgGDHgvZF^hh~RAYA90T%p~)Aq5qw{^^ssJb&qP*NJMmmJoc@h%tRp*~@Yt$2v>&{XJB2 z@~W@>&GIW<@>xz*_i<^+X=n(WS8N*JKUm`reu6E;s0+F9MX*B5^5f#X3+L*S8`&<+ zvDbox1j2jk%_!%a_U_1QGQa>c`rl<94}q=u_Z3^h!m4` zslktcDrpEElPb=HaLV!h?*p2-Wm-;rBJ3IYFLxAnUf5_a$5Y8!Mn~@PbZbvA3WUB1 zGVe(iN|2~wIA`G~JngsJ`qvb)9+VF~SFgUCU?L*FdMNtKviE|F!N32$Ih-=h8{v?yMByq0r?LCQljG@=a{vF$12~OpsjctlCm&sAp=0xe ztLe{(+`Ldf#vw#9_ngwH)vxxWlbuTxua{6GccvIipA1|r*uN5eI&6t43A%>@RhvVB!0VjKl$Tq2UMPbpKm4kbmDf zHy(}1N*EYg1W9tW&-{2N5FwiucU@zx#8>H%FO~k<{gfYsK@)cng?^n%H2xd^omSb+ zaD*bd9tI`mDjan%OzaUGPc}x39R&<@X$2@a{@YV&2F}OtT{`*8HT+(%4g4H5`dU!l zfVfzqI7GXyv8<%5G^8F1FXX||b9%I@e7B;%-gRGFdgYF;Q^3)hd2M-*bmzc-exppX z8MjWT#M8D#vN#SF#)-5t`TXpZN(E_*m7wCc;rCd+iENo|p!Il5z5Xpuo;VWl8X4YZ{oIc5i+F4H&NN2Hg@pcRpe?GDz9N?_rPB} zkrncBZ|||;x;67{uZ{>Uu0Ec?ye1m47yC^4)wfxLvsk;5#X9}*!v@#!f1T<5@~@3%0s^qYA0}U8U($=@v9oMIUm)AyjKr)Mw>N|=di-aDMPqlB*+ zY-zTgC(XJ(F+C>SS!Z$G>-TD)$#O3{-07_CRXCBAzy800q*)ow-Qcy?2%^{0|8prC zmJh$|uGY`}sQ!6f`@eOP^~>{gSPCISAB(&)aT7E7cLRW3>(qU0fT6%TxVvxvm802> zBVz*PoFfbr=w#B!j|X~wd=v7YQ~3Kr``90r)X|Y~5XU`<@PFq8?0f}JHz4a)6Lv1j z%`L?L4IVnLQNp^SLT-mFnsU4n8Go@E&MX*vEl74f)vz!BJ3SH(=Tj|0{cnlDP@)JC z)Lh%`)ed@t|5g*KpE&@Uvpx~dulId5|DbYM@MF>%(63Oc!g`ZdAL_&4+kfunLLr+d zW5!=Q)17mT0Y*Q5lD&>v{*wRtubYI9e9{`TnyL*~E5NN@R*zDVqaK0&{r{@WJBa$v zdDwfrIO{2hG%$Mp?ylnC4D575|@&tzpHlE z-vg`eYfbaXVK z6Zwj;9jc*7=;(3>qKgUmi?n=U6P+|I&LO|t$M2??coZuruV6J@pd93d=44(f|6b~@ zH!hnx?HxwF?KqUD14#i_g<0*ajF;zvyvD6N1=qV%r3RZ~@9EAZ?v$;5%dY`Kh?M*< z+f{&88YwV>w7tLS`u@L(*zqZ9B91gJMev(rzXhZ+80<+V#XsicCk*_QH29%HX1H-} zEJ}YslN;7CVY$*OTq+E+Y7VAy&o8VpzLk<4n*yN-bzn62DR3=3Om5GLff<;%w(jv{RF0aV1yD0@s~T}N zYhR4dS+b+XZBnY!ye2v@fd)p_^zP(WZ%Vm+Mq7liU~sjY5T5~z!`DFozc?2mokFg% zIBX9Eq$)$Yd5O&8@G=C4zJls z46vNHC5(`i%UMHKQF1nd-^i@>^z>ZSVi_P7`-=%szJijsYlFE_2`AcI` z3zGn>(OBQTt^C|}&PB{d#(V3fI>@c#uI7s?k8a4$g>v$zuU%yl{kc)zD-Y=uK)!cZ zc0x{iLDmY9RWK6(%wevp^IPEOF`xJTIuhxt)^r4#0iHKRu%8_cXR1G3MFTOBBM0(s8{ZHy@$q)NGC zgc=X8w=b9nkT#IeBN7n`_(1+C9w}H?7p!j4Oc598iJk}YQHjeDezcZS5cOR%U3MId znhkb!xNwdG|9ke$W!6yL)pv=K&Qr3`*&xOV&yJH8TJ6N!p($=Z*T>5R()<$4e81Kn ztmcCSn-=hYA4jGLcZ+pd?Kt=%P^ej;jSX~e<{B)`tt$CQZS$%6Q3>IOFV6Yd-3XKs zn(9uI8Jz?=*&qNQk{}K|-=7U((;flk9oh1JBOhJ#hF^&bP9wVMrys4}QtbjoFF zz8}`{Ms4-`ODVXu;>Ide+kN=qo-CsJ*?lK66Gs>kw}ERQK^)Ihd+r0C&P=yn;*lz9 zA2az^tXoce@mLYW-&UH!FfibsIxB$pE4gx!tPtOH4w3dzo9C_n@je!p9VPDu96)K9 zY0!nT{%qBhIA*~T!##_Ykl|!3fa*JFYiK492pel=00@@k%E*Z~{#Ar0pq<-=TYo6J z0B-39+o((!KASJSIN4(g6Kl@GU-5e5qqKD{M=f)hpM56i#|P)a zgh0s)wM*pCr*$#~A@eeub%RSk)q8|O@`4Sa8h5XF@FyFjmRk3~Of7J1uX% zu3c+q3veQ_0vyXmal^r{D)<|O_In z_&Wxc{tD!y82$XOjgme0Q9zm1`OWCqGY!mng%C5J3k{-=R!N(UP&W9UMcRGrqh(7S zionVbqCeJ-h|@@V$E0#<t`^mml82Xx zdg1geMePf_h=9NnUZLG}pF30k<$Vt=OZpCk+bu4YTFW1ZXUBeQOt-<&ks@Sp)I#ux z!`8TMMiN;S5I015+}IbQp$Vp5rLA{qaEdYd5Pr-X*k->f8+ezvQH@Qn%1E7$++PpA z%CgE};_^fCi~Q6%m)&j&q`>H-&+6x+Y1nCrmVr+m%Dy_5uOdzdG`5N_uD+>TXxrTE>ru^)E~xw?;zJ;+;v|Y&Jr}Td*AZn$p?;O;I=8nUmZWR@nISEGDz zO@1hH0|SFvrK?gcMFAMs_G)m|cpE>tTxe*4gN)n4lYBM{ATT3z*W7^xpdIRD)=S`|J3ec$bC`pCcY7bPUx@)ZlNpQ9igWujjcI*L^e0+lAsZ?TOg0$G)V=FXt`e-Zy&}aGb@4nPJ?GfnGZ&LA2 zk#x08FVWYc8F!)73V+{)mvEVRNV{J`mMU!ZI5p;7oI`@4C3!xl`-<2FhG3w8d>`gx zc!{Dv-D1&RzwDIP`;#W6c>(0uz%1pEXfNVz0#rA~Cf=z>jS#jbQEgQ3zWttTTy&NG z$0J)af}NLmmn*%V7z1yMdOTB&3m!;g;E%&NV)eNbfLB^EBu89d$Vme!vW`--0 zTjuI-6N7^$xikDMiwYKL@GdaTrdt6mR@Dg?W+T=DfGGGii}j`0-eQ7z{GHXa#791V z7fCe?Z`MQZvRWE=ioYyXeL2krDDAh$RL-U6Z@m%u$!0|Gv>{BvoZa8C$aodV9BwsF zmbSC5otDteH9dfn!V(XY?Y=~uggvM z{blOWuYwEXP7ihe?^3dUkrzw+_++g+Yl|)Q;o(06B;WI?g^f=zqq-YNxLVvwZicw< z<_2=Q2pLoK2iz==Io0DwaG4>jWfunx>)yAH+&DiKaB>6z;zXqTAK$8wkBe@-^~?C0 zXE*;A%~}k5*)PhIpc;B-=46SeVK1Ywf6#5H*W(O%|1Oz%fN=2SRE@0giM@Fu5{7?E zDY*PNigg20yCDu8HHB7RWH7uf|BptG&r1Z!4b#<6Xtb3&2%KZfVk!BgQsE2k$r8H0 zo>dq$Hc^a|WH#($2P=>Q2#G49yk(=E3bsIrT*G-)q373iMGvhCUSK7mkxk#I8<0Q@rzKJya0U%Y+V0HMXsJJ z-;%!e)xi2?L>Zk$F+|UZkNf(TUrC}`O4M?qk>FcExUH`RC=Oh8thBs3njn)+?GRtW+ARB@Nl)GwX6avR8L1-=3=jRvyONLA~YYdeeP z!-ArsDo4WR^fZIWHQ@M^BY*vjDe}w_yTspYrh)u`cgbJ1W;cxPw zKtW$S+Z>WI6W7+4sWe|da8uU11bh@L&R1CRJX;@a-B1G@>lDHNrn*37EVz?O5M>VE z&)JahDw1Eqt^uXW#tBy8q!cWyC5L~3o=ZOjSdp<~w&ShK=+p9zBP)VVY%`tj(Lcp< zg}^CLhRua>uP#NaHzRye8B@?{f37b%{Q#8lF7~MNoCL~nC-;rBqkM7GC&$F9PZuen zd(}>iASYpT)dj#g)4;9r-aD;0mX0Hq8h1;2L5V8p{Bh{l^(LgGeeQbKc1ZqxtSdamW&6F@sL7!J$q^ASG#W95 zqu;u<1mH|?S7o=pOXf(vKT9KD+<5FuOqo99iqynL%HT>4T%8RM&RX8awF%X2y+s4= z#Sf@6fp(M=;{dJ$C)Loqf@m)^my}Vl>uAR+Mv#9;w5a|`Un^TpVHa=J>S`*Ysr#X@H}ejBlIq@5T8R9qk28Fx#%8gc4nBSu` zei=<>c-MEkl%}h&z5ZyYQ)mRoy0obDqJ?5ER#z*`3h>>~BF(!D?o-3!9?d~Nvx-mz zgIPmEB>aANiTF+4xz{9te9$$Q#loK8L&raJB-CV6ofw%L4nklgWsZI1^wBcP#GGgY#`3b6kwu=<-BaYud8 zjgC{8hgO`aS;My05+)qMp!bu|>B%bDY#L=bjEe;jk*f+%WBl;f_sF#LeP0h~uW3No zMFN^O;Sf*6QLD0EXB*DV{5>)^9|A!T|G_2VltOhbbqQAii14QUTV+?hff|`Wms%@m zR5MAWwQ7bD@$#Zs0p;M{a53B(%~ynKs+uXBRddy|E~-71DC~H|8CfA+_kDlcm1pr? z81el)Iy976>mr=U`9Vc0QIMzGn#gvt7Xr2~?-oaQovKSfQ(XcLfo8|7dQnZnGH6i% z5NiN6Lr=99xmnZWzrSaSD_7lg5yE(XCAB}DH_?0Z^a^wHruKr0==I!{xcb&J9!fxUsH2^k=FXq>UqYII?hc3v^iu-knZ5$a;dU(k4b;EFr& zj;2D?DP6sHc-?(8~_F}bia(7xdZ4b`ld2z9s^ISPWuyTMB;oP zWoa}*s{-|VS+4KPkJ`dvoFP}-D1UFvi_6bEL3Yn_qdFnA?clNz2V6ckPQ3J=pz7gN z_b_;-^;>bldypDxF&?yD_hoLGTOIp_%=f23c!pL#j%%6-|S&jo`1A-@k1j zL)eykjx#=Dgh?B-H5bqG7LY#3=McdeIO$eHDZ)6z@8oh2hqF4v)fc0g=)z0`e{oQ?CpA zZMo@Z1e`2M%8ZsTWSyVv2*F{sWR~trIQ*QE)IWuSS-WqR7qCOkX;ou~Fm|BKgwD4` zAfl3k8D|dXZl(h{VyHV&xVg=3O8RQmgaaS#K?z)DqhfGwjLf<8GF&H8ai-~X*9rze z@AHcrCaO-woK?JGC1Xhg3;@C4MjHOmAN4&+585ZaK(qH=9+Rd|YBad4D4p*2emeD= zxRr_~Rz*X0o!&Qfr=M!5(LjHa9sWF7!^J!Lx)ZFUs<( zb*pT-)ggj%d|F}NtI3p;J^|9L0*$|8PJMKJs&SKRwAMfrN#P#5UV9XO+PPPwIchXm z?}M%M+z@%02^ZhI?P=yvs#0+Ed!ThI0UO`_dNQ5(Dk3?5X4j&hVZiM>D+jVpD+EZIFV*fqR7(sUgT=b`Rlt zlZW4V>-yE|oFXPBcw(cEpqQGxJS?C~q^N0f&2wp_ueTM{*RUw;@2o)(N>QGq47X&E zeY?MA3unYe*7<|lN`hny%T8@$jpkL^Uvh*dKCi2o-`}$#wWZuO;HbSy^zl#Hwc(YC zTDA&zFX;n1iC1frlRxP%RlkFd_YA<@$Ie2M?lO4tIaQ~ik5Iakp&JA*1?>!+;K8)6 z0885~?+Wy0tU|RWwe&>9$-)C-Na&okZ0rpLO^N zX&PMzeRqjU1R>^3l@ORv)5p}rnl&}3tpFzR`&}yofz zqW1v&$4JFjNLT+JH{I9>@*LI!n*+|V9RA|*L^`$g9h%M6pfRx6XK|9b)QZvErz=b! zB_gEUvDNThVnkFb$ZE*Ny*`_;bUjJ4_YKpkkmy~p>C{v$w<}%EHYmbMSKhYm8 zC|=SC{z2XMa%5c!x1C_-4g;45v22#@x8bTn((k|NabMDtsKFb6q~zToVJequ=BdXz zF%C%~)?ZsGsDJA-)x6CwC~z~F_6)f7G2o9%9~ybpQuq1LuKT1|%PAA`7GPu>5LU@O zug_NBdp-~-*9PYQe3rCc9z_DgavKt=@{7^lS>h#&ae)r=;~hqHSzs_ymi?}<%A%fTO#!qQ2H=??2%FgR3<+EJktR%C*&*NCSF3^b zx3=)Nq5F)RrhsaF4j>}=(8O~rqpZS~fg2}Bn*m5LyNimMKmI3aF-BdmbpJq&9MJv~ z(*S*WZOreq0#i1)L!$c0sKBdD84Am}4%(@_^D*~$L4SJVHsyK(;=C884WC*ZQcDcO zI%(BX>4RFSroEiq%_q0>K28#8-9O$w5%CX3V?Qg@Shua)5n&sR#}ZohvV2#KNBsA} z6|3XKkKDC7N|(kPE?ZoKnn>ba%S}2Tdsd+jw&uI_h_hI;vOPuFlO>rVXs=n3D)CFN z+#lAtKRrppIh|SD$uL`4ZmC>pXqQ7@YKv0KTUd~)U^3mGFuwP*np)wo@014J*75g# zbOk^jC1B9IQ?9tGz@f8=HQ;cb4P1wypHmm2XM?f&E@v(|XfV@(f+RX_+NN57dgS(I z?2=EP8qLbjGyrJ^zWKEoDfu(ab#)sz^q|LGK{I7(sVB|WzQ83V*jx(QgQ>=~BBxx> zGivpY(sYd@N0y^;zY)b{^Cu>*^=Noku#Np?L-KU*pYpgGAfQ<3?^?qfpC{e74<{{! zuwf!j_CW~BqCX%QeD>VXd1~2bWqBcDEbeXofCK<&Ko2jx>#Gz6K9_MK%=nW35_8#W zpxaWx*3{9ZTWr+u=!T4?1!~f(-=q%!^VML)tcn!>$rtPPp`0Cn(?vVfzLq@QJr9*| z3Ruw8Ev|W~tMg;+lVTsgmOj(f1%oz|JHNLQ=Fe()G;G92jJxlcK6NY`B?gi{bfvUa z0FSgN9xXx56?~ASr4AT|*o(muCV&LfReM+k;^ZI^?NDhh4^KctNrjk`RG-YWQ5s$NVaEotc|?E8Hfm}XA~|1->&2SP zO?jVisyAZ8Y$xNOn6>oAZ4u(O64H8ia?T7+k|QSU3YB{lxeu_DjF?7kb3g@`5pODI zDLVP%3U}+hdKjZ7lO#g^u;t3C7j-rPK}5)Chy-xy;71|7@+AJqdu;s2XHXLi=PBhd z!68>}KF7W_1`_*dvAF`eis4jk00SDi>S(#GHtmgGIid-I??Mw7q|#+E&S0WSck@okQv^e3ag$U&cjR-&)Q7pDZZe~=~6g%eL7#JZRA?p*cDv#$T-l zuaJQzsniVv9sV*!9aFbQ=OsaxR_3RV`dFk0sAZ0eiKQ*!+h)yMRWA5wz8c8nxAnbU z=LUBLjYzH=W**<0Ap1G-B}c2POvH&j>H;&WH?5NZX!0tz?G>Y}S1dUv=sw<^Du%8n zuGm6wb#^YT94vfPEU7FDFKVIk2cLD^kA&H#KP<+(4g$!3(?161tD|SMqR%4C48n#Fv`^OX3v zvp3Wu0KC})9csQhfSaB0KiqD2by$1XxjI&A)jdHqTXd@qC?Kjh1O-a~iWPK=xwMVR z5p_zq6PT~lDy4hRgtDy;=XkF@H{^V)#|wg7G(V7UsB;l25G2i|Y#MK6v6gJkcj$)aT~5*{ ztp3VCi$6nIkDc~L*YE#O^#s~JdakmX`(Wh|&gkkQgZ?jmRaS=b#;a{40 zl?1-t9{|m(GvHst{8!^7q*L?$^eIH&p~ElPw5y|?$~9BMeM|Zj`-k|aeihx?Q;`JZ z7e}<>mR0u9_i!ZD8_5;yxmSJ>>JTOG1XKMUa6#T>T5hd^l-<6ByQjW;mlvSZ#PukV zhU-{<5r*Bm8F~uyb<%6WF+jksna`7rY;PnzG7=yJerPo#Dg9~Eo4CH@(yTIs@a0!L z1rucbh7(DxX09|-6O?+pHtjG)x?Bfbf%hMRgv0XS_e#~?g?Q6{c-#J)c^>pPZ8OV8 zlA!$0GotvI9J0Yqi=#}x_x3M`!LlCF;C&B8g#SrPp@!CRdQRKqOK<)NFUwZ|4@k26 zWs+>24o#xcJmz4W_!%)0`9QBK0}-XDjski>q{ZD@PYa(v^Xj)xC_)8^h`&cpIBINt zZv9WSAY*6Z5Neu+lt( z=t%~uyGy6E*1z{uwuazjuBO!RLP)lmK@4t=g_ZFj_iiz8W6tt$zCqnzLo0XcTr$8A zp6cnvfv;`D@%iS}=dVhBF9f(d$llg04$gB6HB&UR|@!#&^{6V;Aax zEYf${s)Pf$2v><|zw%lD4LW5=wlwTxGZf^rCH+YR&(o^hFE+cZtgPtD#QdrMvH5Mi zVPUk~n)v})?#ukUMT{mQR~NKS1=Ny9hr7MFXjN9>elVb!KvN(!Uvp@Q9o!i}4nlWF zs-H#F1up}tV_N0yD>p@2z(4~a}hQ{gXmzDs~-&nS!5ddK+DnLtT zH)H!_m!5C=MXNw>!TgAkEemqY67i?4Q!8SIsYCML10d_)U#@66efkaTid2z3U$91& zewo$#JAQ3cNIR78f~Co;%tUI}%$bl-tf?Pz8V-|rybiJqyW2%|Lo(O4H+_jojg!{R66A`1gN@DF_gM7{Ey zoYM8O&Kf}Jn+D$VU%GU-Uf$bwujdHp^2D_#L)p>b}1vgpnK?0ci5d`Zb9v(X{MP%AYdK8ZxpVK?dw=TW)duqtpu3XIk51(orik4EcKwR? zep%16=Xh1G=l3LYDpPZUF5q2dLd--fwtUrQ60j(6M2}l#Sr? zpntM99tYwtOaD{zR1c0GDpA~yDxehXfn*XK9LE(K{fw~qAW-0s{lEZ*lFG+>kl|+DjT{jD2e=-JHYOk}@W35+#yXvPmGZXeT>LA_^ zv!ehX?`rRj@UR?aT>~)fk6%6&0`317eF+zL>2fr=_H-IK2 z=UO}8(dJ=aFZ#9yiYnhK2+ncMpl4;u-Dh;}6k7lKIr_(~2?ub~%IPU3l!FyyD5w@H zu-#iA1uP>>`K!0r={;+|G3mjz>{Z4?F;k2 zs7{D({M_jSaKFn~y~qO>t;fJeM06W#1n(T+(ylRub$5P}3!DJj-TBX6i6R!0uES9= z;LE#ER=z779;Lnc5}^n1H=FKSAj6P}z9>WZxknBL<017QQfbWdUZ#~^N-&33=(i6< z!eZJxz}>leH;RsmNgR{E0rbsYgS8`oMzg^xU@u_ICTfGTVdo$$84YH#5TYXWmz4cYc6g38lOk zSa%sBD#F6O*56Wdf)n^5G^mE86TX-?4OD|s z)myky)J+7wyHfz|UI(-(A|^c0^+TF$CMAF6GOHU<7_}%b;_yhwPCHZ>rY_t9&$BJo zRxN)0<(h}cf+cri|3as5k4r(#zAnRn6VWw^L0fSC)&bvHV!eF=EfbJjs1m5qgu0)~ z(2b{kAnYOJse$N`;}g?_=Vk|PKo(UF)&c(RUoPco#zRFJCzQt%)Zz9d2sdYR_s_bV z-DIGi8lME`cIm9kMSd}Zg~ted%g!gKTN<3F;#2lF+XVbK#rVGS^H*(Q0D0khFo`K# zBV>`_>}+hBhyyJeouLPcp#);O=z;3Y6U-fR?_3g1B99zVF-%M-v1Fcvr)x#^$VJL6 zyu(M8P2{TOIhxsHLP%V#2c6=f?`h+AWzSEFGlk5l7JrCQ^VfcX@XV&G`g1UCIhM+z z5$c^6EU#km%lE-IHPUp*E9t+#q^nkQV*IlHC^82r{+ev)E1`$K)XBA_N)YmqC}X&rZBbqy3|>dGt>n=aXY=cOL*WmjPlW>FWndUom{^N`S~z*)iPv z==nC_qVR^3e&AcGoq3G6F`2Oq|D2@>;lT~G5r6q)W9NlGD@y=4`4yh)*jbH2R z3l{WI2%LApzu4Di(^Oc#ipI-JrGe_s933??mHi-@nn@{W4Yl)8+}SLJyuo>En3J?; z$}$X=u{*M3TQ+UCye2EJH~%P|qsbDK_^$f*voF=({EI{K=n@FRE33RS?XjDp@J zM9J>$V_)|IMj6f8^E)HHT!AYCqS6zjYjpcK$Fvb)N1j{WOl}s(KHEF;^oO-Q&U4Pc zH?q_Bv5rsZtn-XqvtoM92K&zW2$Ouzj=tw<$Q(7Tzuo`jbGE^-c{E?yR@-$-k&yB% z#k_vHdvimeCjqVD>;5}4f!7YOd8K^&qC~BzY3@G_#dG$=y&)x7J#SxSM;p}y!!ssq z>KaQ&iv}-wVlCkWp_&6t%e?FM8kju$=jxeM!7Wxo2Ru*k@0SCx`AZHa-O?~KfuyS_ z0a;{HLMa?U6R8Tij7c$$0H(vHP+57$qdC+*SQ_nQ;{lf9PzDs`Y#P(<$#TnaFjMIXLh0d-YyjwV+{S&t zGFW<9@oqQNlCC9MJ9@X9b&#Hc?giCfm?Ecr$7MlTOg1IQgzLte(3Qvde*(1cOp;;6 z1z@i=liDk$fCg4LI%)(c0Umg54QJUpB-~Au3z{_`qbgk9(Ts9;Gs7!{B_r@l_ELIa5jAQnN50i|xU}yzo8!`7vR^4~L`>eXlNyv4o`u z@6j!W;$Aatv;O=E!r?0`jS;vq7%xUC&()&P_t$*zM5eu*C1cLkFfzvUwh9Jg$6y%$ zwxUrk73$4o6cc=8!IVY!$-7*3EVR5(c3uJqTJ(m0lFcz=CZ2sLRA$K%zzslwD5m2d1w{eFvh zy(@h_$}Zq2g>!!O3^a^T%d^0stizd zKxPC%no=MSnQkHt3W9G3KUBXN&9qy~=rhX~gsb=lVsP3~zsQR$fr{~m%M9waY`6Hv zpWK31!v>z;D|oYYZhJUPeyM>wN+A>wl<5&SBJ`QHW|mZ{iWZYtsDq{UM@%?AJ+g1l znt~}0hP^V!EWFidsbD)f)g;AbZ=X5yxLd+$Om3IOI7W0}o!=n6twu%&7aF+3{y9JW zy%rq;u}}lJ<~;pB?K>Q)rTFL7C(azCT2Tfrgj=U!GZ+fnh}c;Sg@>Py{EN@i57&Wj z&Gy2yfjtZ~fN z^YJXqF!0V=58-%(*Mjn=#&tn;H{4(2P89l}P&0>`Facz(5Z_HrC{%q#ugRY${@54w| zBvuB;={}HFlvMWLozN3nQJQ%@7+8D%Q=P-PPZ2Y&%sTg~BrL~^MBZAGbTLzIvizB1 zrEw5?-~i~U`IEo-VP;dMcds6^o<^Y0dKE~Uxj~UTdQ%8Z$q03PJ99uN8+a{DfM3*J z)}<(x`))c@`Ux!uF%L^jPlOV!tW%MVN+4586pIdiH)$|i|4~u&FBx;MwA!@8~kAoIDF*H}3Sk8jk`TodzNd|e-xjCN2 zA3OanTOd52V);Yutb?v4iz8Dl(l=$HKE57cCseL}m}04MtTR@uA)^4=5y&Ka(R5)q z_%p?&Kc%&EN01ZqWxUxBB!fHLBWxB(qbc&Yp3Qi*@J9KAtKPkIrj+3PrvyF*IS()0 zi%&Rtr2mys`-@N-1J9-6A3RtI54-o~82Q7srA4TDTdSEbl}m=^hfj5pudw-gmo7BU z<jkfWU)Y%?0;6DA}HzI zkwc_^lxjmW#HsD9(G%qO#RhE-#rjdopB7nEfqJx7OJ)Htxa4-feS0`t)+0R>-GnZ9 zhhC5?XPr7wo)(1{H9O1a~^_Tm;RV4x`v8ID{+?fR{0FIr@G2AO-a(mTbUWU`!D=U zdXzrg{^0A=JSNB&wZDaa%-?h4N?KSUBO>kO`z2hiCEv!eE+~*!d(~pz*NqW1dX*(SE2>YK=$!x&8Kj)W|&)apP=^BJ8Sox3o z&q9KSGuINa{pwgsxZ{1b1Lcb_|B8<&f|#X6nFwgsx^SgbH|qI$Kid9&<9>o zz8D(1(+n!@OPNvD$RBXWexGHwLBVIVTH3f1M<+vRF~2m^U-95kM}siQFj?xTcUSXq zBODM8!J9M&$#7lz>*MS}UWlvZJ9F4{bK@k|-%9ppQ{{Qo?p0~A^j%s}NcB{Zry8!W zxpifSSRJ@9_f@GlEDCxbjJ?&e&)aTIbE#PLQlu2PNo74P-^+F)ZrMIpO}LY+I) z7N4U!Z^y4}i*r#08Tmv6QxBGemKoT>mW*C9-dUgS`PjbKcm~kO=VAP8t6L#H5QvE- z`+}|<`<+C=J#Il}@zuh{xz9_Dh5?ljpPJ#K-4(~wq4Egp5UZPX$vDCyNH*Zej#FSK z%*b*V%(G40N1LCePvpWb6D}TlY>gP_%(Gl;)m#pB8H;fwwF`%tYU97wKBXi~cVHCm zzS7aK_RiY#P2Vs@I+jsXFoRU2oP1HR5)PcV+|Hz-5`PQ(KrSeT9VTBQym#=h%z@#Z z4e93I!OWj$U!^OQ+f9nFN$1Fo^_*BjOTfi{!fR76MloE!sec?qvtuEfO3ah<8jeFY z40u9EN&Y7XFTLYVxxLgaT~QyRdoBlvGQ(!Aq8`paTTpspw3>t`|GZofkC}E$t?|8? zrSb%Cg@EF9Us+;lPF(cDDf3qrhN8z-T;ZN8ycV6#H}DpF%W9Oap9Ve6vNS(;3zAVg zQI}kMiK^d#9p7?Z@F=(MHZirXtK{*GfzLQTU)s>%!tFmO5+r$n&5rTWt^4Hd?o*<1 zc+$9^-d$g;u1hbHwYDtThQZa(b1ddOq{<(-S4G_u|C=z;d0;rL&RjoxWV*1IIJt-9 zVDY;;nMe(xXm2#AzfZp=YMy@d*8AdZ`40rN9lUsOTGW#;-jn%jv1egBn&~`D(rQ(O zdYXLmZj^uy-jbL9=Hep@zf`=y2U=f^9pt=^`j_|^UN|Kt>?k<{<3=TbJ9uP1doYu4 z?-TBLu}Bblk-XhG0n@$s5hUxHPMf;McLke#7NDN_^fB&-vr(QjTFfVfOPExqD1n;Y zxQfAG8vquYtp!stXW*hD!Aw?9`l_y-Z^#ws8aC@)C(@a18#CYX;Q=9jd1nsWuZ z*ESwAVN=|-syleuA8`tyv)$XdaOG>R?|yP>iKu)oxt-fiV z7W&ITJk)$a>1vCq&-;-DaYO>|$sN7pl}(!~sU<58B&=BZ(ZQbt1fS8(C6+}lFcwlS z=-^~9YDJU;+AAy(?O?;_x>G&ZEvd6u%IS^y{wVdm^qsppAFcCM7Hh4e+AXEASjiB1 zyFWs0g6Wi`L*v*5wu1!j<(B=T@QiivSjZU#QagF$wf>h=@7ud61e2t$^5w|K-PuLW zR1_8h{f$MU87#gyg}r+Yj6LtEjA~$%@>Py}W5MR~*T^I)M4MPikzBrP_YQjCjyG0i z{SLd(zd>^>V*PYxg8FPjiM#OXgRyqh4uhLgvtcD{H(c(9aq%~&A7 zg`C$7%#bfSP{kYqy$2WaaME=9essk5*r=zB-iRE6xXu2cy5T&hj@uEZ#PiM_(`5ZH zh8?d%eJ6Wug~x8)>3o)isTDwBO-W)yQLhgI50&HXl z@UFPuGQ@H>9^hj27tLjy$PZiGfRWY596fGVij|A}R70jf_)`^;)|7U#7;7M zyk%>nA}e=i-qcxBuKJFJm6~scy`Ybh_R_Hm?LfbXeqo~CQh9QLc%w8_FFJ{u6b|U?FXQ$FIVFB68%ZdH+Xe?g z63h(e2KRWOTXW>pF6Gp9z^>tFh2gYh;R$ctU^4Jxp1Fx)jL2w(%YmFpOAVw!?bxrj z8C#MibZwu!J4G;8XyHigSNvOvSK_d|a<=Zqi@JRYCXUP{a8?hRF{@8t*}TiJ<5~+B zN=L8pFYQ)e!_mKeqAv<`o2B2GTdK(hlYfu`{8JUD-vJBB{!#9!IGW@y{viXdZ9B^! z{xuZyN|}7mZ_7)e4o8t>!pLK)lNb?Es0fbT!c*dBVd(qkT&35y2&}jPsIBeTOx3j) z;$asztU}-o4-ZtEicD^6rg>qrvno31g#>5O(jy6EXUpf4IrRp(n3dU035 zkfm(guButNxq+B^__@AaQY#%#1*8VHi+=NkHh~hS(PSwU_RJ{I7#+&Wg3WA?sxCw^ zQ1Gt$SN-LMxu9H^>0GRgVyJ#2O%LA+`U?=gD3pJyz??VxD#Ej&L!izk-Wi?Z9PJ6f zSl(-7{FKwoU39%@mZ&QM3UO$RNjr&>jA4eZRywv;obh@s+3UCCew|I4eqW~Q4o&=oFIuIx)J|lWcV2ab=W4m* z{C2l50QqcuY9D>Rn?%UxeEO!lT|W~};HS*s81#G|G)OMMXIkb7P?CAd|FEDsIZzSL zd7#qQ&J?!Q zDJJa64y8H+Q3R!ss{iBD7jz2>i6-xJS^`>!>wJBA0)uZQ`f4EdTY=tVL+1Qs=YL+$ zWp{mtS#i6CuSQF9VI1>iG7bza*AGBQ5REcoZaYTWs1mi|rW+x>lwxiBh*bU&X`(7* zcG**`dBdjsfoHwYsBr>RoOKe3W#*1yO1LtoMykzv-T!fm;Q@BldSj-t3c%ZmRhz&c zG6_obJh1I7{WmZMF$ZFy*nlM8#BJb#Xhm**WjlnuuLsi@AoSlsMwq`(7uVl2+24=+ zf1yQx!4ZEyTM=Io`CmK#wOA3#eQhpQ3S2%AZusw;D&~A4@U+Y=jwerOWA0T7Qd!tx ziEfMNX#Pu3mx*`ht@|q)=QP38Y*Y}ACE6Q zvp?kj96?6TUlaS^AD)53YV@Cb20?4&FE#q# z!;pA_9wg2>_TYbG`9E*>_zlm0?syP85ib5~+DHa%Wo>KnWvzIxPlo?(8^|)r?UL+% znIXocfruV8+j&`hEM+t8xk2H!`-XGu2+U_~|6V&>*S7!pCE&J73Uzt{<(mwm#tVYa zbaZBK+_>>${666-CN@$v%ly2IFBY%OBqruS?`uRvkTkA{WXZiIk(lWiMe*_P;F0Qk zDQqPrh1pCe@-9RKr)0cJ{|jdr6JApr!IX+rIsW{qsL07$>M_Du9Omd)J#BXL_NX`0 zHs7JHJb1c`&HqBHmX}L7jRabgqV5!^5?}7WrS#t)!ZzTY_!)tZy5a+7spy)H0(~^SEc{=sGSm1O4-JGko|RzH(V2d@JBN z*TnVtxTAmn1fKLDJ+%;Z<7Z}UhcemwQ4@iQU<_4suv6nL!cooBquD(f27!5n=g$Ha&qz}jFnqJ)E`}cf<*XX8ckL=hLaRuB~!u~Cz;p*@o z?mo!k!Tld2JVexJXV_P@#* z31(CHzSYNEir=P>VMxHid%**wxk?= zTicgH=(1G7DNZHKqfJzjTGqPQSj?QYm6c60&5z$nY9$*3;A#^VUH|%a?L@v45CnFB zzI!F@xgQDSz)+R|NUyNbBg!8I>lrYF5eR$k+W@+MFe5DocvPn@1fu^YAoXpDod*<8 zBHlCt3wtnd`jyz4nr2?!_lC_gs{v=l+({8BzT_{A+cXM z6)iVM72W@pB`L1-rU%tH5!z(F<9wun=;uz@lDqmh{SR{dbA^_*R{B z^_oksoZae3(K_H}qD>T)0dGqN8NJ;AbrxnaMsoacdEno7g7u{?yL@Z*!@{xAYd`VG zICTJHQ8^fd22X(p%>^=hQ~`I_*prmzOc#hJ#=$2knFp-*um6PPtObLQ1qzna^-v}s z2dKeeVAG}0G8NzLRk#qgjEC@)Q5pjsj58#umINsg6C{$WGXNqdEdz3*N)SwPy}R$Y z5V->KE{wddl>A4eG)52U4027gONT^U^)zTV-e#O~9nPz9Xu2`1DR14reY>=aaCBFV zW9;BJz<`tJ-3HrU`LM);;_qG{#_KKwEUAFEPV+tUs7_GN8Kxu?op1=$v&zYwV?%>N zKu@t2z6FY7dEM!n{W?$|MS=j6@v;KR`AFf3A07NF-*R&&0kN}?U$I1|I&PKwed3Yw zwZ+%F%SLv^^Qag%Q2E~KnSXTE2kI;gs7SyZe$8BdaxOw(y<2KWO%zl;y|FJUZzrJq zHQ>>dEbdJfvHgIV-0(rm94%1rU@BMgg>$8urvbEXjIpoY3c(DdoxQ|l$TZ7ndZp4G zcmAtmTT9vz5F3x874M8p`u6RHZ3Ks$;dH1V339i+ovWJ(Gs9BA8F_&58UM|@zYk`s zgOwNOW2G63a=i4w6?vI=1JqG5h@)HCj+K4sf0^0==)`!q{xncM)1T!~OPnbMZWA}q z_*8+pczUgTJ~|%K@+3V}q#J%=6ihV%=^?Y{#B+n5M~50{idw}) zn5c!fJX)D;JoxAlp`uQCcDM2?W(05Z>Xl2Z~^0};{)waQj!vS?4KFv!#ep4Rf@ z+t%^Ppe<^i2IDrwa>kRqn{5R85&NMGhyu6m0rda(um7%B2oi5?2nB?3 zP8@hRH13UhOIWtuOeR2fgZEs9WTj$vOV~R@LoYLcEYP@3eI49JG>dO5u<#k?4+PUn zzb>g53aJJOAo(B+aO;tblxN-|see*sCZ(;y6_5ORwzW5tB1v-bw@C(>-t; z)ScOGK*f!AwTlL~p`N7rJm4fsJ$8W9s|F-*0RQCJ-uk2@$dZ2jJG1MCK-5e8Y?5?= z*NB2_=)8YW7W0jqKEO&OErY9#H{7D*yRx%#^CLd>Iq!O~s2{?xNN|bXjyqf$6CXs= z0gQ9T7cw;$1Mpj+q^zNP!8G2Yw2--YB0ZIjF}WP5fz4-l}9> z?+O9jKw(kP3I$IgV6|*LK(#^WX|@7*_D%0$p~+;#N;s6q8~$^`~m-g0V`(!@+8+skeX)q>+sUiryn3-Wg*#{-Ne zBuxctGt}$FKA6j6$YYl8k9KBuc04$r194LcuDN+X!fW2G4P?iRgKuC%q>Q;ayaxrY z9J%lFep+gfaE20=ByL_OTpZvi1VnKaXz@q(xzS)`cG;-jiN1!jm*LY^iDg^;OG~dJ zRoZiGC;NMv*R~41X2|BM2ngh?+K}&w-afiEi}{*uKV7>HG3@3YuH4hIOuTn;BrV%y zKyA&~ahy6bub5>E89TVT@g~F8?moGpDrm08d(K{zZS-sfZTYOUNutiy6gRJT{>l2j z_Kmx}5lm<1J^b8%umT?Hg}uJ>5%7bWVXwi>t4IO)F+0N{BZBCoNE$Q0+aGD1;r;Lh zhXkcNs{EgVxq=G=-^*(G!=BX#(1IY(><6=QCtz*0mZy*>+MJAn1@iD`FbdMP0FinG z|BvlN3in_tt-@QhKpUj@@{N+sCz4f6uqb?jE5e$EalNpSggT}$SWuDf;J&w57C$VZ z$$Ptv(+{xMRAMcgr-Jf^vCGz(C10(-6C<_-Wcq(#pgXBGz0zFK;_^7Jsv z!H>@HcBv8}t<*svFsXGX5rjfeANF9v`BBHUh2qb= z)LLv&+p;H{3K6764B@e>8_rMwm1YVEnP{HKOfayrgz!cKroV*$B-dAuL=zVS%3O#a z-DhM`RIHHw3{JKLsliKmwFcd;2u0Xk1HXp$&K}DMu$hL8)RZofwL~H)GqyTGghd3o zlJ(&P>D_w2jzq^;^o1#X3NOv`iOXL#G*!P=xQQLayYw4}7x|U`D9v+Sym%k`g&9DB zvmc2BkV^q$wJWtZi#sl}0oG8cK2CZ^;Jx49XHE{^-Ap{geRKu6ui&Cu%5+rtQsEtP zF4CIW>}X$Zun`%HZmdv?`T=y@+H?Y+Q<>xC&(!C?p1m0Dcv$D4 zG_o(nnKx3-VY@qG5;H1&*sR$ak;&) z4L8>+Fy^6#5tmk*Me`%5*wC@-N*(ZavpfaH^|xN>?yD>6vR5!r3PfLANqQlQ#+H^Y zMBum6729`jT^`&Elp;R}vY^GQ^tucAT0-u)Y+>ud;g0-7o0un_kOFO? z1feAFS=_W7Oq%$+Pco$sm&wmxTE*^vLSc)|SDUgy6F#snI{K0#muSHItB{?+8 z(ui!Z=(~HpBQ?%0JDJz#-RF|k5yh=|+_L9K^~qjRt9!49h(|KDLkPt%UkY-%Dn*E| z0xc*X!Td_SHwL37gOT^vhswUFtr&{&cSXs;6sGB#^KM%_*uF-*4~iD;JL0~DG7#&b zb51hq+XceyWJxycd*yz3@}x*&q;C4}r`it^p6)=g2+5wrJYpLGSIV{K@>ME2Nkavx zy30biPOB@-qH4?{ctTV8;z?M!OI1!u*2AuV+xsk;s!BbV}Ke+m?N- zICw3#lu~FHJfo{UF#FiEkyv!(6a=Al<;@+QHGLqNCEFrScsiEonLX9%i*B&8&;I!8 zW|5$c>{9b*nez147QXr^jxkEB_q(lW0^luDLtbend||4B8EiYpv$Y@aBGUUr>Tk47 z1eWFD=C@V*OC<{m7`_S3<=x}c5*;H^B5~u_-aDOMC$!U9A>F^tOe`2 zYBXB)oApy%Xsv!;3_v}Wy)2^tY8U%VVM;O@Vs{>7f#Kr7?EB*6{ee3(aw%K<=?JSS z0+KhRgpzgca!!Io+D|+P#Yy?AwGl>FL;Ql3XpwZza~@c5A;fV=ynXGN&D&uR*ix2f zH)UE(J3(R0D1ZW*EJ`ioaz;(2dU>LRp2DuUHLyHPkKU+n+ixWPa6GHUFt?(X%tjd^ zdqWtCJ zXe-Hklzg0H=dY0R0IsI{`G}_oEA74_ zs~gxPT2`G~)CAn+CC1{RKN<5YBUzHHqr@lS6WS$MeFR&G%N5myWgvfXEX5Owx-hB# zNmZuCyYrPv>m|&pJel>-NF2ZX+)0}E9DWS{1^)w|L7)Oi(g{>=Em@mj5eOi$TotM_ z?9Qg(=_e_kEHI}|!h1E2I!Z5*5;3oxW$Vwt++(-s;GMTwnA&)gI6^kR;gk*U$ED!3 zP`HJ_QduK1`t@6ym3wn={-j|N7P4n1?OD&uU-bK1oAk97m8E=En=mZ;@LBCezMEc4 zWlRMI{vs(M8vWp?oPPdQ*UrwOv-F%th}4Tz{IzJem;(BqPFde9tXciBFkptlM?c07 zdUE_^IYV!CwCIq)d5=svZRTpfx%{$8$+h3R+_*1OsrI;rP+(PZkpUA}@~U=bop;P8 zBj?y_p6cahb$5NSh3z9}so*&lf$z5Zj!EoEyUSAj3*kJ9=)clN!y7J9$&td}&~kFf z;j)tkJawY-#`^_x?kqr~Qk&}oB2GG~&}eHOs)=VFM!}njdCDw>Tnb!7&81lZN>}Ho z)>D1b@qN!9em#{+>5*pkx=vGu->f4Hj&#Y}rjr z;H-TR>2XzXRXAB3`$F?N>uhw30gB9v_`LCmQ{Pc%^-FIB!c#xZ+Bxd+aS~W?yOlki znr%&u1gdTe6(E0^-I&{5_M+Ht`S=nId{Uf$p?QxG3sSC7PvNQLy!yVJ;BSa7!y3;2 z4I0r9{ak^Cp}!c1++`eA=SQe7*$P~R+IR4`=j2+Wkj7nc{%hy6U*Ryv9Cxw;c#=)u zuhCq%)eryqhnH|)+%0nY`{@_2VR^sr`?hPbnid^g`O;5O|FZwd?XWwQf4$+wGcqW3 zeWGi|R(RUw-g9jUlV&3l zAg9e%g$*d}vey$oYm_<#!#}p` zpSZ7VJzNAFPabPAy=9<%~30e}+h zfUC<9i~!LlZ0=^?EiZzaOF*8CalsMNKuLso!t`^0u=L!h**w{n8mtrAkziE|X%M$Q zSykn+v(&7dIWV|0_5q9yJzYt{oHE}?e@{FcAyq;tL0C9q>EX9R)FD>7CFnSWRck>u zP$d{;o1X(g@xGI|%K6#pB!q7qwP64PQU!jvo*FPTIg+5&rM46ycR(c++Zc^cL^}gU z4jShp1~G?EIvvuW{kq6^+hy0L2b1W!RvC)XvlF5b9Pi5zFS0SZEdXBT*Yg9}^P?CH zuu~^LSJr>l*0T?SxShaR4~Beo6QJ{S#^2@-$6#@n$}dc%d~6^!RjQlYB0260;JEaLAm$Is(4}r&=>@cuw*_Od;Kc#+|*Qo@WjK0v5~t>)xizyAc?3*kfxCKj zUS0Oog+7^e87qL+B)1_Df4Ig^QTw>d-ba&@8Zubf0lVZ6A$F7Zz|Ga$^cfhxTH1*! zKzW^W%Az!i*_s4_N2{WjdIFi)5$_;uB4wZg%K62WHSD}|&7B(N z-T4t8Rv4M!d$OfWAw*-~Zd4`Oq&{;5E|EWWyUTsACLa2LMIfWd#b10M+gM02pyQ~w zR1hHsZ4Lni$blp+PB$nFU5X#Oy2zH$-H*tnU)#|C@&p*j|3r(;!RTXw{xyFc0F)&q zn+f%Szcn1AEr93V_iP~VD3J{^QWnmjw28NXibi)-d-QwdpKt4fni{Uq3>GCI4dqll zPcB-LSjjWln{8f#=r%wy&go~OO(syTMfVjp^-Q8}s}1rfa=S{+_~A@IdKBA+@(Am< zK0spJB;JlAe|_=s0U6ufasa-LLi<`cTJ72hRIT2m&@Q5Ds*F*RgG`ABzUK$NuCj(e z!E}qll8g*8&U>0QD^|8FK^LtA`87?l`wqYvFiwvYWB`A4wDNXPp5dE>@IC&kP~0;l z_vRzUx{_P5j~keHxxRTDDZdOc_ua0=h%|o z0oy(oniruRHpVYf)+$8+ASzsR<0?Eqd<%fVBjq@K?K?5PCyiMr^{>A(4EX2+o0Hkq z(1%M#DdCF~V0xuRi5)=k2#0t=U7A+4OL1Dt`cFW97mgcH^tK4`k(x-g%(`c|znbze zr~kYYS5rx6p8*@g%aykGN0rkE#6NyvCwj3RWZ#=spDH0l!+jznM68{VR^qC7N}QW6 z^UcNI54xBHGaK|-RQl(n5N=acBlGBNwrKxG;O1Mi<+I z`*x|pkJjuf-Sj%Vm&9Xk{Xu?06}PfRXn#Dd&?b6p{wcM*?f6z6OuGDgug^QxnKgIv zvG)~ErDjk3s*Om|b#lv%#0pU14G_Ya&wUAmS)ua`!L!%7=cRfe+ueFM*oC zR$DB$)!;Kf^T@krGxMqvE^>xx{_-sM(1h1mc*GxOTuao$&6UlYoATOu5K#w;1{C~g z?wn>=xe?4Up^}8Y93kA_79$B`4iLiHf-p5pkO7(VAY@g}Az8xJ>SHFIbK;kZxowWO zZ4rcW9~}igr9crOQ)2T&f6~{rIXPC;D^gJ66V~pGu4r3s+r@#f9VFwJ zWBpvOy}jwoXm6bKN$g7Cv#C3wf?u0V@yZA}ZtXK0zHYeK*CZo{*}`ZGJsZpsZ?YJ) zrGJ?5Zsq7`0$DHRtG(=Tzv~%Eoa^rV^iYdH$}Gyg`fC<{n+UVu@TWA2WDh*YXFi~8 zO{j2wd^wbuUgZ?`&wcfNv|xJ`PbjDM(>pfmEqS?u^%077PcEPpy6y5ki;O@2VW%#s z`#?tkap#JEdFWGJu1%p&_vY}uZIa-$D-tsg1dvy8lxCs|WYe&43H1fxSR6O*(l!$N zm3`^F99VT3&1`VgEEp#^lAhme=+{E8-Xy-BrjjOIyS|XVZs3l)@d`;Dafjmx-jb}` zu1WV*#oK8;jSSp*ad#)pI}?r8>8GRvstu&r`8@aq-5*R*nBF`f#OYd)5|=98OucCD z=v22a-^i^A$^XQMWpcg6tTQcvC|%Mf=SUY@-?`?tz8(@u9Z5eh<_E9j7ABnr7nZH_ z)|TW96kFJrqequ~5m&_Zsr$YE!(Bq9q6No}+3)*wKZbUgDIsgMNCGSuOxV|QeT8OJ zNsb*N#DFBV^E+jkZj(R|C$5D2<4@aYHpSWQ@Z|4qYo>+r9(lMe%r{g%e7z?#AxO}$ zhum>6-LwDPSULYn)f*}LYqrK%dU>F*WnQcay>}4R%yOV)7`t$}8dS~6rQSv*nuPFM zakfNY?5{LIE<_>v=c^Oy;OcArbQ54=7NIuHuX${g(y5)0(B|HLOVMNp5*4g{yb1F(cz8eD{Y)&< zTE`tjt-Jmn8=E5Ds``3!rU-Xj1H|KGoljl=RfWa;@(t4#FEJhoQNI|fYM+&z6_@qH zb$nGzV;Vsw07UUpfPFrXL(n1APHmMtr9a?CR}q1h&h!3t_7e`L#@EY_K^-5i+U-}J z1l@xXwY%jUBigfUid~z)*pzlkI8f>jgV8h9}2GDSw=1<{5GMjJ9=dqWgR**i5$Fr ze$?YT0m5+24oKZA3x5=ZKD9j+doPve(!nscQFmV3eBILKqBOHTGK-MD!*GLR`REol z`sg8-?{D{rnvi=W$AyE9*rcxSWAPWJRFPua-WAFnz zOUi1?ePZo1-)n`N{NRg)FH3>)m-35SznS>NFq?Mp8jXHTK{BVuYC(@7Nr$4Mc#!IZ zjlzL;QyX8|#7&v|_+(pPB~*QWh&^mNktl5>op>k7T#+8@Kn2njf+@yeUx>zDw_YK= zh?yDMb4jH1s)$WulqiT<;#DQ2*|EBjHxe<7Ya-JUQl3l>Cq1R)dT3P}?^ncXBAxZk zh^VE*j#}Ik;J2EnEWRuAs?}l;FG+7cfTvA4$1-OmM5IMzMq;%sn6L+Fuh3umMoysF zNjU>dL2je=))N}yc^iK$hF**+#+}l5a!}Y>TkGh=!!tcp^OwbFknY{zyQpzGnCy#UtOt_9-TfhZS|o_09l;73>u=>2!I3$)?XrT z2osm3y~LvKaWDqBfT7YeU0C23wpWwRJ<{_!ZaeYYYfI-bw%jmASP^MARtnosfj|nwh$jwHfRVVGwrG)oHha8 zo&Mc@piTZNkylO+)UO1tfB`_a#=WD8olp~r!Ui;MnT^4>x$X^Z=4>;IDw{qs@;|to&wLmR7C7}Bj{+d+`jh2-+L;)zg2sWr75%F zMum2%5S+m1Vbkq?Erxkrn9aN7JY{l4K@N|Wxv2L|7L6gFx;qb0@y){UNjgH-IRJc` z7#q1i7<+?7<2oxIV9&$#M0tA?uCqseq|qs3t!ju;TeD}Ldn$91`B)PoPc))DI)0nZec z+S4nZivi-2@FiOKO(ud2$e{krel{!`FW?#l4O=G`e+weOhq1<~UQc^T)u}G_O=k!Z1r!ih}F__uGAYMOW9FLhl zb!C<70aNXi(7bx#_P?pu(2Bt#A%RYHGXuB@&A~}&Z|$@^*Xog%&$Rui4V#UDK5 z;%VR?K!p>6jcdE(`@VC(v`SdR)=lMUVyI~RKd z7|t2&7?E6TSpN56NJzRO4c_LUomAhn8J`xF4b8Lj?s|RI#Qu-;4t~AY%cuZE3L>G@ z*nd7ZL;RNBNW_F&BfMhQ?7S`OHII~iIt>M8W5}%R5#s-B4w(7qYM9;Z+`k$3(0AWg zH+@+G)HN6Hhv>csMJZe$&n_ayMjCUeaMrXCttRLtiWqYcLgEt2_}b!v7)NyDg&Pn) zT)A;rkZ}XnSw~~blM5Qq7RO~;}=U86J(wAvI-Hl0``yAc%Se0)P zlC>P(bD;(uuv*iFkOo8s>rdBuPXhYX9%F)vb_HTw zYJ;fVgxH0o1j@-V_~W6aZFJcUI0DBys-ckj=&sopyKh2WT8@A)HLdv(qg_P=U#WJ= zc+$gifGv&hou~q{A?WiNZalZmEdC_EKM@&dUjFUM)BKD|E0zV8Nt0ILqBeW%yJ_(V+#fT4n95NYsy1KL9k zXfS?)BysbU8-HC@znIQPk$lrMHJ^wDN80;ZZ2_@ryuh}27 z15tgq%G!WfxC4qweFbfhmyDRx0mJwNWCHStfBs76BB=)20ScUxz8Xm7&To!WtwgQU z1OP<0bx?~-#GiT{LAcXmI?>c0faN5-lCm1)Dg!k8@UP%)pox=K9S7BT0>FYVEjtIa zG&M~ErAQC%%t%dbLq2`SaAyEcCQp(MH=Z7VF}RS=G#?5oS!jKu6$;J;${dje?4_e^lPA(C1bixg11}!6YWt4J|(}hQbOI5vFpg|A}sUrc}+y70kr)P==^M_QQC{b zb|fexmM`WvuTmUHsSP)D)PfCn+M>abMqdW1Y9(j`!9yNb-GOFTXAESnuL5G)z@7mr z$_TI0Njqy%2SeZDNPBfZV~o$rfr6LUcxRD$fC0zjOQ#}A+BOfCh?CJ)n4wCUXM2v) zOsl`Y;hW+=5ibbk2z1{EAvGgKK2{IrkVzQEujM)^GrZ_kx@JYGOsFg6ZK|a zS5GzW)qE?MZ0VFXYsq;K+VI|YCH&3-ka>MLM2-)FO%cnX%`Wr)Tv{oGa!$5myC=+G z;?+sb?UuiuKKtp_`4-6>DVjgSH#@>g#}AYPuNZVy^FhY-I)Kx1i>Hz$-Ruk`Ftw?J zVG?;D)a+Fxmph1&t`c`%O60-EHhp}{*bg?Cj7b!8%)l&vj^SNTGEWtH^O%Q8lquCe z)L%5ifflh7ASA|Q-}{geq7JIk>w-|mwVV3U z+Xp=cg{+dTg@!a`1V5>qTRMwG61_TJitYvEl**bE0F3MC4lN;?PtH)`E6&oxhP}Qz zq2@nb5w_*3Gjv!BTkB-y<cCrls20_O3;%3cG<0%qRj&*sIhMxfiVcbp-oNSem2_tWS_!BmQ1!|E1yW)-It{3fCN`d`C!FE2_Os_}{67eP(L|D)BPEqOZ7U`1ihCxb^ZjdhNk_JIRy1Rs-yAeG5_W!)+IWM2| zk{R#Zd#&}W8f1*r8uuUTMQ!puwNH{mILqG|MU`Ba_e~5Ymg(}SDHJ)Myc~*f;)-!# zkM|xUr+xwNY8q(cFpG#_Y6io$r@3?}^oL+|cOr>N-39o&(uGFiE!VrjG;Mpkx*G9} zUQtrRHYN5|Arz9ZJ3h1(d;*T<7*O16I+er+0Lo~C0nM8rp#lAY1aq_d6nEJU~8z^0Or)X&0!pL zTVU)NmM+ueH$&cuV=S8RmJV-2suiq)rFw(^H2A&Qt%2j{$+fr4n*5EU>c4)3rwY^T zZ0XfHjp3(K_r_t^)xqr-ZR$mU0!iPD_5YH>Z+<|R^H1QP{g^zO9 zo>Vzb%t+AF1vXqP-HcPc*nitUMX4o5n-*5eV#M0$;4<11_?^0w0!Qm`vW^b6{A0)! zSJON6MrOc63ybg$7VJO@<=+lSYr-cb{`b46(t+X0greo+k>aM6;(BGU z?6ip8y=ffr_MQ2VO*a`H!-Q0*^B`2^ zTpNt9E+Ao6C&*^tQ22km;;0DzGZy%tGTK>eR%-f%o8Dc_Z2$}K()!=(n(?0Y|38!a z|H@k|2tddr;tt_=*9%br|0TPEH}zH8Xe-}Fk|_jUkkiys0UBuzq+D8k1YIfP=Z|c^ zYB6rmr-n;b3dJeM)7T*E8S-Rj@BDWHBTY7-pm=&&V-8#i_dtwJTO(+mdO7yOAPp5{ zHv@%^xIxLsK%r9%MJKF|pMQq}p1^V%c3HSL4RMeG1DKQss-JOHlL@CfVc52{nP{L+IndM)KQn0g-2;nayJB_EPQi|F`cCW zJ6LA8ncr2v;VKyd;tu|2E@s;c9;7|Uxe!D#Ej}+PT?J6iXFhJsEunrDiAn?uxCPGA zN{1@#xvUIz(w%yg$9E-k+!V00>FedABZE`azTZ`7eo>nO|L(kgyr!Yg1mG|Y0DbS% z=%4@k6i_zUd8=hYF@Yc$;|T~f{{`H1ya%5kipQ*2U678e1RhC zNEIO|2rtesBY~I=SE4 zD+j>QcZJT}0cOA191O_sK#?&^f`Lo^wmcWe7E&QgNl3}C zq{iE0(Bjz$GLo9W1~zqE^+UX&9XY`e{sHs`ZQlUmx&Sb3uj+&o_FjsJNAvD$Q3;qO z#kqnUonv79OhvQaKD!SAUXVV3y7#wH2FD;1ubwr));6AcLZmF=6+1|-*3JK3DGWe+ zqvr(0g*%`|vFUvcPL&3SPKzi5w3Qn`X~R9$019tDg`Z*8fA`(moFMMup7<+i{RHUW49tk1x26HmdCrxM#Dx#7-rCrDt|^ zS4Kr)($r4BrRnva60ZN;*S=$X8xtOLH?%=#AM#yYa|+FI##Y`rLmO$f*}JwpAH!M% z0odY%Zoe8nGaEW-ftF_hvh@LL#Vp}9XhNhM9ZI?v5@>#}Oxu9)A4f=m+BiFyA^a^P z^+|&FKYLyP`LB`vqXH0eLKJNYv{iytj6jstPY6)QzsSZ@CSZDoRtoJNQqM~#7=yS< zi$S!ZI3$K7loA1;prGG~G^q;+O3AqaY~BH%2N4p%DElD-NP*)3BU4h;lEM;K5&~Rg zIYw*`klI};M^f=B$WvK#Tg3u+%9??j?hHY^lxw?-FrJD(DZqkt2Y}a&yPP7@-Cbo9 zPN2clO>eOX?cZej+Pn&BYy|PHfTtETPDMcOi-Oc5-VNc;YQBTRG(wpNk!}NlxNg=x z?P3D9;k6r}O0n24=%&lh@YqO(!k^fYHvm3Sdp&w5^k^wT=wBCHIx{Hx*E`}13qId6 zQnfpdxSHf!JwPNb^?eNm=Uzb35H?O?-FbVCiuv|*JR=!GYmcJ&=m+CVrU3uD)X*LF z3)W2@h#2DDoU)`{c9ci6zhT7DB3`AteqkvYq%y%LZw4x>?F6;cX zaW*C`))XM1bc>V{DT^F=y zClB=@y-yO>e2GV^cWC9lG|}&rQ^0VX{n>|`?F(=CLzV5*fWIHp`rSJX9{9@xzB%+K z67EYkzLWX)Pr@VP)4Yy)F1~-pmQJRA)A{%}Qx*fH$CmLsg}r z>jO3cN$kjbNQ8aiT*%vO?4pHWt@mZjXfe_#B^-Vr##Zl56Lh%mngAIIt&hq{}3K1Fx@k2X_~>iinL zlUzba@(&2odBMT#+_<4#4ac$#t1UnPfRVF{X+XRl}iyJ z)Y}11=&mSqE<;-5dA>53g=)k=t2Nv7xLI3wN!8yCYws0Os+)%O)mRD^I6))g0sY8V zdR;l5HtiMZqs3dE=L`Z%stjI3wcRG zvLhH)?`wF#;~R)g&Nfz3ek(S`);-qB7Eh7R!2A@(!O9v<2h3p_y}2-p_!dH_D?`PE7%Yp}Q%YA{#@NaQjNDZGKb?}j8 zZr)8pEb}>elv{OK_2fzSP3=s-j!H-fcAW&=HRRqoxC1)rKmqjBzVjLit}XRS8oCej zs1WNcBXBrC-B7^kkUs#9z?_;oPcM5P^ndf`c|7U?!$}Il?|-rJ(KMQEV-wpLkBd1w zDHFF|qMJmIoUR#<9wR3vrc3AE2sW=w>4yr>6C)p~I~B2UW~ri!dyAYLwv}l3flv1Q zm9{(bh4rm3e7<^EFm-5Zpw1YcNU`=9A9LrRp zS;FY&kLj$B#EFLS^>m+#DK9-PDkf#?zW$|j&rZ4`ZW*2@@@{+8#7Y`y-a`qe7U>si zlQ0TMtQ&Q~%Ha<*CWlvIB`ylj*~`|}b)BW0-y>Z&-j~hbHUXFc{9JMCOFBvxY!YAV z_Y?G%vB-^ep%% zW5yjldY|X21DFK~N#+YJv9IuBU@$$ZvW)mH^VeO(#oR z*~83-8Il}F^>lCcNU23qjSZx#O@^=o>a$z@52-$cyoPxS07dNFUz1YD6J{f-+ zR(r`&mH$ct5Kd}mnbgP#w*|B_{jDT9Nb^B=6PFaatj%Po3>+2QQ4Cdgz)R)%q4{~GYs9o?9bPcUc9pxnHyMq zUzb|>_kQA5VhuYp)l)nL3L%@Ck(Vy^MFDI|iyEJ467d${)kDV@-al!;c&YA~EdR&` zP$oB<_yx&M^BV;mMC=;_!c!j#eFdX-SE_^6)&6{;zVm>crycr_8|QuLe#rQaR2`Of zdcb2VQ0T#&=cMY;HA!ikk_9{=eW)akSZtBFNtrvqfV+g3<_LtLx_Mo7KE8ruFn=Ie zMNj|jF@SFTv9YyzC(G}X+OG+AG*Tr!bb!mTC<(V&yCS;2`}{ zPp%UMzxmIbR75#Hd#l+ZcPUwNB3{P^AXQtZk#~4o2?hCq%E?jgD$CZr{5#3jp#(vW zBHce`A&mz~4c%>d_FZ6Bk5#$WWyml-*&6-bk(>^>6vMUbdxB)H3J2RFb7gE_J7EoB zg--W0bARH(UN+Yb#B7ik$9}^KWju8=kGtKI6*-MS&D;99`Wq^#_+khP3fEB8Q7J93vx+;E$aV zLI)E4fAk`sK}?f+;6|%MY4j`+V@w8#jWk)3jgrDxyk{Ej7zX01!0t#}L%QDvC(*iF z35}c%UEU-mWn_Rdvi5Z5c}cW?1&|#BBabFauk1f7vQJoCrI)6m|fZ#d_3QWmcZvM$hkS;IEx~ z&kZZbvXk1VtXC_ekv@3{iWZ1c1+#@3H!#)0Pn`b2%|ps}9owhE29_>Mg#9pw=R9pScSe_75(&6W_ysrh-Cgu-sju*ptn7{6DjZ9beXjwCwuk!W4 zmL)`^M^Pfic6Wpt+>G|?Qre^AL$;b$az)XhcZ;-nCBHs&sP~!0I{1+KOC&xo-v}|g z!+e(ja<^o!A@*leX8aHb=MR`6R39Oe1mFftch%ry*^Q%CVqYktq4uEoJA_UUtOYpG zp>`8(|0ex&_>m?6*%(`y6%9GseN?sO^SKkH@74AA2F_78xWh&_p2 zq~D*w$FE`Up|tGk=C>|$5Q_?pWP68{&jJgr7|KXKkMzKZyk#V%gOk>Wv|H^1QZmm` znCE*D5>qTAIxI1`RHU?QrA@ca=Vt<&qNk#>WY(OsG@p`NM|F&fLdilJdOO)0=2tTY zd{(O{1C1i{zSLvnE!LP>-}}T&6okV|k^#@cI*E+e{wct}evfre=!HRf=!x8FQI~nw z1?Kew1Aa(@A@>7sr8+E?+b3#>o}O+0D00u;)w1m#BLxA>IE@q)ENTLHVEj;ECKI?& zWr$>;7l9G#O*fAF!1@L28p{!1O^rqBPc`%UnG?Rje6u#*DzzEXS8;=N^xxLg`fPqQ zY)rm+Jb5tN!<~EYEBE zxBmTetfKW{VI4#!)Imy%Rww!FTrK$Gm!`gyPjk-?rGLHAZRd7J{Z(wFwYTzadF`Ra zQOJO<`bNA5AmE-Xt}62cv0bGz|Jh}H&i>S{Jnl(=a{uy zbqyt60{Yi@b^e-6l3PrN^fUfpCgST1QVfr}?;G=$_vLRPq}GC5i{mIK%EOz$>+)#u zLbAwcA;{f7o&Xp%KB$lnY=>)ocZNkigUpn0%Fw5JP4KN-)qdRJDmEV)ccz_)Dr&ZUD7!;@| z9HwVn;^fLiZu7u0YJ;T*q?hD~0Afr&Mcpd)xk_x?fx0@`_sXc~qcp5~OGntA%o2f! zSOfk~Q+x#P7;(KCS##2^xy;OS>9AUxpy5zC8o>qiUu6!$1_vwG1`|{FADZtg&(5uK zjgqIand39XaC0%PAy-~Q&bj8g7%s2fOL~_-vR%T0g@Z-AX+YdBNOj)X82&=-To>Ah zaU6PvvAD_U-`NuU{?Fr|mKV%@vV63?0uQp8?{|B|23_>P?ku{_0Hs>0H~0XB`MjdI z6CgM_)M}cVsjqtag$#)D!#&%z0?=_PPCIlexTS@cui>f3N0@VjGAda&6wjNgg z=^~F|3Hr$8@N9X2JV7rF@pXt)6v6((Oe{Q}+TJdEyo%04R4=H$wb|z#g-TybL_VR; zH@ZKZm?i-9AxtlxJjB}Wth|y4Y_OS zUCqAk2DF~k4(-TKvFXoI%cp;thojS=uwx*XaU6hHUHI{6s1-Si64`9@FNuemFDiP} zeKgmt5GA!r{CT4T6bm){Td-EF*AgQUygwV{JhRLAmyd5Cu0R@-Whc3Ps7J(ru3Kr- zkIx!y62T?}%P8$>+;God#;b@a>s<E`O z$L~erSkJkrf*CYSCGKNOs`J#~j^St8)wllOiI}GJ3Jy^)_<`2Szr!_on?CnY^`xHxob@<_TQwPv3ePonh# z6VXcdKD@*Y()BRkpa@mTj|CsUsXb#?#AUiW>Pc*jhX(B+&Ou}7lIFM*Xf*TuUVyJDID5{74BtiFzTHWu@Sdm{eT8oK^V%cpXMuc%N;$;d! z#9JxHE8XrKY|Yf4Qco}}fjHgeV#(02HLRzzyEDp#;d1NL=8hb(A!>fd*VAa1>E<*R zow~t^^G@&Jn^$jTUPt01!&v5zE2xHXa9gb{4oB{a9Af)no&{&so6#pU2@bysI)oU( zM5C$f9ce-k4*GtQURQ(%^=dM0>g)I`6dC_6=|sLhv-fW`RyxtA6Xit=c}}VwZ5J0g z>oGbX5h_*F_Pk*#dux9LH$66QE5tO5jdmfmgz&C#iQn2_4$7<_N4gQ3?Z+&#Gb}Q$|Oc|V`R3V^okj6^+`IDZM?p+2p~(CPCCPjyrbQ503ABB|0z;?%b7sL+#6RKzxU0cfmWm~-8UV!A>Hknui{%DMzF6QQnb5yMQ%?bbz1Pz( zL`9_Pl>Q&^64WR5&%f{=>rj+GWStfNSOLMs|MLl{BDK|b6ya89^4B_{v%7f`97AD0 z20%;ne}83!ed9w!E@|U-T|xsUPrm`|?LXb%|9)OW%}6|P!8*8u634(@>wZ_G-3*K+ zQrvj4WeU;1ZcY$oC8&V&|NVd9YirD7brKS5-uGVVGLm41oKxh){`v&d+;QkHD>Oon z`JUK`$)$#EQtHZ*nybmt30{>d^-?-T&H?0Q0dzJ*y&KDS*d0E+XNw@|4(DIkoXlPr@Kh?;6t3q1 zR>W$}iHfG%4S*;_>eFK@FI&qmu>r7#WfDMj;M4l1o7+&nhW^o08c4BUf>7DmoEZjUQUp=6tJ=SU`k&R ztOJJ0JKqZ`hb3?t7hAk~W%y@x=4I=)nmtbDp#Z6TkQvnL?Cst&F)<;X>1ZwPWhzZN z`oLBMc$4fndGkP?LWEpbzev^qtm6i0*#7ZExuQQbPN-I!*2}q~B~s0!oy<$KR*hsu zH=c1OgD6XNwRaaUc*yAa`yQ|)DXXe}mXAQu<`3cganK)e?67OI)a!HE!2krGzVgNc zC@;U9Go?xkk_3ue+BP!?DN@XMJ5%w>&tmgtb_TdJ^EUHt6; z?^06U81SV*fU&?MSsA`A&Mja`TE7PCdkFl*_JB3j)INYUYu`JF=Hj*Lq4j~Gd&jXy zh0<@&@hgz24Me=w^$<5~+Y8WHwC2I|S{E_+Ll~lm@NCS3@g_61WB3u1OZV-Aj^P-~ ze?m%)LL6F@J+R?Hwpvf`h81Z1{#51}pVb1U^X z;aWKF{oN zs_{@<3Jvr1pBK$+giMXX=hIySKxMk!FMJpYS!8)(=pi7ZuDVQs)1_AfJSQcd#uBRy zP#G;-c>Dn6l?C6n`$ew)=xmy~!b}Z+AcaqN{S{>Cu}n#R1;O{X($a334x0%;r?g)Hjt8OGuf8SX+T@OKuH4ldn zgp;I?#oD+1rv7WjTSv%<4;UQQc+cbHEJGSsvI>X1wlNKFJg7zU;%UfGWxW_QpE86d zZ)1Fj`M*uZOyx#?LsxQe+*pqvOy2bx14OV!vF8(7+nG z8-{j}6wE-VSFl=R_np7k9O>%D7kewIwkji-gG5h`LMwr{iX%ZvKLnyL?GdzD-T)Ba z2ZY1Q69V%j{9T~f;ugm&X@JzeqcHSJ*l)4`oE7_IvxG`(WRo7?ak$-0j8ESMkaxQT z*|cw9pwR?m!pXzISmDc1XqyiCs>XwI*%pFaAf&=2)P=DWQe1qPJ_hC%ItKfsBk(F% z;pL$C+tt>5x#U%d0@@z|>vFM_l;T=_8BJ+<)e}QwZ$@{nlnJs-e|CS`t6y3SmI)@? zi2O2$B?0#_rSw4lNt0c7h!M50`HdW#AI1uM86)+tq zQjYN>509T@IC)fiEP z#aVoYG~k1{D~+@t9BngjU9#n7W2}+P=%tRDVT~XmDz47l?mHM^EOb99z23~MK^<;w zjn)ftl>-(Bxh?nR*MNhX?fPfZ?2q0*d1T1+0Fza&bVJAB{uf;Do5OhmWc!8{jGMG0 zehHy9e%c>&^w+JC6=dfR@exk)B5pOvIr*55pS;!=TbAEY(7$8_IjF9Y?O{x8oqr&= zr%b}_AKai=lGPo4iRD-GKr$K9#lY=nrvR=EF9gcmsH8rPh*e;p?-w#Givl{PkDkMxs@B+$NrAR!MH7CDUeWi^v^;o5i zF@h=tIEHa^2>L8UVAGJ(`svZ^-%e85rTdj*h%^Uj70tb$QPdUZ-r6We47C(dX^#-x z1&i)Aj2FfM8-tPAHLjBoqRS!&pwgiSY=P&F*AWvI4LgH9cAPk)ZFh&Vq?)hu7MQM{ zW%{#+VWGhpu=2qWwUEk`xVIM8Dum_j@BeZJ{=pxeQUcfHX@%ld^FVJgvoku&WGnmy zoJXtGQDkblQJMN^5uJbF{paRIflp0Nt(o{Se1>kb#Kx1ls`TV1dOo6nx2LEwha2m1 zVWuL}C*{u44OU`%`aKB5Ul#>1|M?fV$nT?dWjDp?jczZxBp!wVQTzRc`YXr>Ebt+b zmAzk#klQt7!soOYvECPw-Dra{S{S2SFCv($;8bD5LzQr8KFKUeW+}BGpsblHMQU_g ze)xFB6aI6N{2U_O5@hn^l#3wcFkQUPM4E-s&Fd0e4+$K3pIb1j!eGf$pwy8)`My=`n)3GHg^eLKeeGWxz-rsPjC=Q-RCi@v;_)x;Q)%t{sZ(q* z`TwH5u}0!MhzVp%Ao=n9X(xYu#qqI*EFBig3JTu$9v4eXEDtSOy63btkEIFNC?BKq zNTlScJV2whrwf4xUiPGUYY8E*-&8q%&ZTasqxUrV`FkhrDeIFM$x5_O_aYejvRP(S z%H7=Lu$d?e?zzHs8yMI2W%;!nLmu}d8?ph#hY4kZ$#@u|1<*jhSMo#V6{& zB``E!RN!f`ua=X?+7x`0SU=SBCZGH5qe7FRj@4O6>i;uS2qYH073lq)J+Ot-kmh+b zhrr#5$FLiN+tW2Tom?UN;o`49+4&UgEM*xi!fY`Us*y^4d{yO+2M;j*D@n`D6(9bU zG?V!Jv(G2bJHPXN)bpTS=Xyn@a3RMQ~`y>jn16f!TSc>!PQU?S_n za5_`qT71T}){Ar1mz8JPprr+N$=)4K#J86*?lF zd16seXO^>OpQtWN?xh#az7xqFXcT*B)5nM{{%IS9rO!^~kVDDK2Aet*DK+>+zMeZN z&tIA6<#(z1Hw8S?apCbk z`YyZ932!x8oru*9CvS3<#5h=%gt2OveIl8uWcF}EPc*{tuy;d>3numif0Pq$xD2`M zo(kjlpBl)SYrHEGs2zO0w;W!lT5H@wVl3jNKXdYNm*do6bSHPY2H4>CIkiqPD}4*x z)_SUzsUX{CaJA{8_}2?Zt=#nt38^H1yx`3QJ{eL;{WdivGLn2hkkc&VUX}C}6k8Ve zSz$RBeey*glQ@%#B34p)`f2vPYOQ=c(>G=`sd0=X5zO(7%)$jO^(Paa+di5^dMdj5 z#q_Cr13XG>6~(J$#Yn{|g`Urd_u86}J{F7Rqk8q3sKm4j*rfzM?ORo?ODLC~U?b+k zp86`5#W~_^U!zB5_FK^Ia$a@g^K6|etrVhkuV!c#`$ie2f+#iJ<{l9lMU;CLdnLCP ztTfkT2q-A^Vl}xHtCXA*5nGJ@31`UIRQIm^yL+qT^`XyZoP(ROB2J&-mEUBEFu$3n z)!$i#DT7zGD0;DMusqiTB8yRi_R2)lVVHMXNOc(M@_H4(O{LlI zEIN@LoO{sdnWU~8ty6w7<|U5MAjOU@<~66%-P^G=#P(+D;=Hj}Mm#814ZF!b(TumY zExGwJMfXRrbYCRmuBGUC<9W&Lj?aU>i5YpusRkutQzWqy)tVX`-&5_ZQ+sunA3hd6 zW><)=u7HjH{hAx``3BI@-e>1 z8pLQ^(btkwAo%^5kXHd*B8Q%*CCXO~~H!ZLoM>?s=FNfPUt(HD_t3v3J{q8Z7^ibXVJ zdWSuVK5QN5`sYRG?F_xM-I`S&;92(5j!< zd!ld8LTuH2bZ*9YwyfpGUL-E!U?*&y;SKHAW>38yBJo^MzpZYa(kE;|#Gw~osG64S zXP6(Di+FxzMAFo+s1#`8L9cN+7(bGA6GqQdZS~;H_S^*qgB!)=FuoN zo*&0(E-$-uX{1(y^!BzaIn}apY0z}2sgby;P~1a$$e*KnjkF3?m`k_n1+=^v;_JPb zfSui=-2K~vT-QJ8v)jrbGbek{gB1qdI5Qz6^!3Lj{5gm>s@ z)Eyf^JABgWW+mH(XWzJ$z4e-tjZ`FNRhpwH{x_?|@sYC(`d9VhYMR$Bh6miYUg`%CDfaizuE29_;W>_%O#N2fOnrzC39MkBArj~HeoI}i zg2ZrHdC>1v{Hn37D>1Y0@31i}E#`Mi-#uwMy~0V|?&!_JrJ;O}nf(yLzRPAdTgm}!4JkyKx)zQQGAr%h(ES#A%Aj}n~+r=-y%`>{vt;2gq#VGg1NM0!S#mRVR26;cr&-)+VzLXRW zH0%GoYMYm5#$&rt?|Mlsx!ZtRUs*45&8f4hxw*sD4GPw9GqQ2BwdGqQ&-HuX7_QZxwPOyI4UB!J~(xH!QwX_F+6n%J~ z6L)5@F7B$}mv~vp%WPtQsjfmq$1FLAi4>>0o2Oslhz0JM$5(Kz!H&5e^Ga4CprY6C zZu)$|(urdJYstc0=9KWN=2C@$fS~-0Beqot#ut}Ts2cUk8Oz$@Lbo69Pa|j1I3uYw zB_!(egm~J`Z|t&6AvI^+$V&z-&$&GGp8yhWi9&)NFYQ)ZsQrn(-@wjfKJ)e_t7p+Z=!5RcA{m1wh2QX z_22R4hqW6>9X$<6+xNVb{~E2UqhV(%lD-{sHo&UJGY95X>*55pmIYZ60^eZh4Gwh2#y53Z<%faT_lpbE%x1%@&WMau zYm;qAjIvL_+IP9-ZsjH~9J-~|#B+9C3+8Ay@6pf3w>p7S*xHHJPE5N&SKgei8k;Bf zWIkp*`j*Si^99Lt1M7d6c=(hEfm8Rk{}|)#ht>CAT`6!KWga$G=9s7#FTD)1&($(f zo+m8l3Ncc27Wjm%+h9>7{C5)VfQq+ZA5mb#ul`B>;?%mlF(0!%14K=~wcG`s36+p0 z5=`SXZswLiMte`XDI+c6tcvQp^gxH=Sf*Jwk!^PI2}fz+jqS^0(6e}c^TZ~3?sNbe z-%}&KCSj3h&LY}dQ?d&u1tpf-o{~?q0wZSzd9l+4t=JY8)2rA7!9>mMm8P5Bt2uXw zHrhtR#dt!9N8(MOP=uZWo%TK&~7M;8ChOpS7L?s_EFg2~~DQWP!5}B?d0s8d+>= zy)38n*56>Sa2|T)P`@J9QrpO?!g#`LZF3j;21OS8daG%qVO+_>)t13%qPcDroCO&oAuZhy(+9Gd{ze=-Zy1YA!EjOH^D46<9&POza z)jBm6D+6_f+KJ5R)u9e|SLKT9kbGvndjFU&rgZ&`8U@Kny3Z#?nkI+thE=eersv5> zm|_203j*AfDJ@C8e3zV<^|x;88V0S8$rJKJRcHjucC@`6#R`?$t)venWu}tW&Bxov zR3;{Xylxtj$Mn_hV=L3j?J$}uJajh3O1Jp!bvfMC4+I~(ZFZqz(I{ToeWi@;g;tGQ z8FtoQZD&gH<%>lZ5+4`B5<;rLeVDETwky6zc5jR6p||d-Q;21{v&(GLED+u7#mcf) zf@?fp04~NGh+}?`X6-S2qG2kUxb0yD4EcZR4Mg*Akl9l|ksopchHdZS5mi08Vo3s$ zbAXm^3ry0_Z&LB1w;qBy(p}}!4PYhCLXiM{h)&>=GAgL0s>&5c<(dR3>eVxJ)yp%^ zCyG)8Hbw=s!K|f7^sDRc^vIAYP{$r_7*`>(XXylDg75?impuxv9#Tp6_KS$_h} ziXNKPJi3phMI5L1ek&yLDo(Whw;%kc`WJO1g_gyho%IZlReuE`##Bb;q3AAWRwN>O zRpS@tzp8qvZQbMB6w(De5_I%;D}DTSMm<=*?X1+t#!iQ0+FIMkT#YW$T)3#H2o@^< z!S#XXWJ;mD;O+au=qKaPLK8~X?g=l4WP_>q-7u%$oj3l6yk|j*>16Qz_~NZ?O~XBN zpeOQe+5SbZ);3W|^w=9*5^n=_dlwPQY+NBTSF<4*Wd8IaV9U0QpCG`N{yDnne);f5 zO4YrIy2V7pVoc)`+fyTMptXlGDE??_)Gid9)fl2*jdnELfzA|S3yoxrNUJ!Ees@^T1nwIN+Ab1KA$eYw@R z2E1G9A)&U{(iG3iT^o4y&H+>3^JciXCajT|9p?<_?MGU8*Vlp8jyA*{VGd24ITL!b*-*?VdpXGNHkq^;Hc&!5g?vk zf|AyFK}7y$!c$FJ7r(#hE3jQS8&oEuv@1N{o125=U1N}Lsm(RY*afPWwOZqD^QixH z#pFS1u-GR@U1cbu*Bt7ZQGEt&Kw5Mw(*tP{`cK!U+y36#Lva@NUt9y?Z}a3T4@-;p zYNBnxXkKLVN;W<48EvjnYH>jEcA=I0X2Uhz8E{EDqm4re^7Oq1T>c$V__nvpq5$a# zPqAaRsns0hSzzFOByC8-_(Gv~9N!h&jtYP@u0S<^b!ADRPC6M=n8OnX#dTy(o&*GH z>c~ueNult32{S9)(yNgRsB?(A+FUF~g>v5p5b^v4b*U3{DU64Oi`OT$1EtIY3tbSs z1L0D3?btAk-`u2Nwxth{0bd`>YYv?m?62*93 zcYxpH`SI02_74!Vum{xvoD*Er0GeplBidE|cmSTt^W{${?^lwgXO6HPp!A#5Jz_Mq zWuWp2yc*f;J&QFEg&^yw|!s!M%`SiFBoUMCZcYH6P>Z-OIC)}J+wY= zS+`$#r2@A|X?fP_v?^Z}vu-A<%@w~|J`m86RVCIDW_lOonKSi`V^GpC+Ii?S@{21` z{6OMeCmNENlX|b>3u!!AAwPVu^J{$?A8QK~9m|(LxdKw7&N&V|wWxD&Oc$j>${%I9 zzS2A={-KO%-X}+jjvTLFfRt2qSd&q8jDmiqR;DV>hkkSVNasiFn75}=Pg)Nhp;AXL z%t6KDGDJV%6?*!(P3Yk3{!WklBG00#%Q)h~GYLYboJc8jn9Ja`vf@mHm4N@Z0U z9I`*x*YpVCw@iBCc2*MKza5G3{XK?+#ARNKy<5~&hItx0$U43yt*0@tF@kA6oxh#c zPMa+bPI^3Q7FompmH0zkt8WUnozLNU;sF$9Z_SFx%ZojW$3mb~nkhnbuVeIy&iClu zYPgymj4pjy&%e{0~`@h}^xy`k$=)mxZNsbBh!0vuV_AuC5J>Qt`>o9Xz z;ee@&QnvL3XS!-DbEAYBy4Hk?dM&q)Pm02YQVDbO@LeXOSg}~LmDO9X4NZS}Gf@83 zV{*lFkZ#V8)4_`g3ntEm)XExp5ZUJi#Axa+7U}~~u7z-Wu@MIn(ttKAC#kS|cR+(# zn1yk(LU+H)$+d<Ol$yJ1!n{8?{;$aTfHmxb?ic2_iAo+CG@W*fFSyYxL4t3Zn#S zf0AbC5|xLYg#J_yW$q;LW0-t4(z1W@WsaurrizxhS!1h6NL&TTkE_ zH;Ae%cx-aN&J5#ZtVU!ytXNOkiJHJ*ajmCT{qns77&y{5a$lLYmen-0G+SDqzbdjSI?KB` zygl#}O)h!U``VmE!%e}mNwEpP>FbP9O5uQ*x;zP$q`HpenUPsb(d>~VzVPo8%NZ{s zFl{+e(e*7ZrWkjo_LR30z<1G!&CB{?AXBT&=!qNm>2-ktlK%$l6qDVyq^KMzc^JO- z*#}(%>Vno1x&AJ#%5U)_OsxL-5eAOPNN0Ig1rQh}X~u}c#yl@d}JcNB$xZ9i1kw!}U!tj4YVR?z;*^Nad<5*UxMzuIeg z_Axh-+qNK*e}q##K;8bhL)$!k{bajFV()v13$ecn17)Ac7=JNU1E>s!6M;MB_AAMB zU$kv`O$2eds&;sj0y^77#4fOJZJsR}G@Q_jzseu1=)C&<#r0H!omtZBT^BuE@(?; z|BCt1y|i6#0o7|(lX5yD$a8jRTCry?**tCS2BpG^9!w{+2*&SHQFN?=6o*oB z6lW>4QZG$phXxAIK1GX15iTtuui!+f?kA^LLoKt%KgEvSN>PjF)+=CKp30LY71h*N zbPu(^pDyTj-mcwRcOOT2S-C@g;l{auyqKqSY6~KxtEhOL@#BwJ7nKUT#EvF?eS&0k zHX4WG)j!Duo=l_^4zqHzApJhFR3VS|9keMZQpM%TY|~$8@)KofUm;YMvNATkoej2j zh|ExmPvAg!%w?sdNW@rb%`UI)Uo4S|w;f(}1)#*Jx8-)*PSiP5$AEG~52nRhX1QH{ zBY#&1IjbaV*bNFk{hpnRpkTlQ>l_l2hEE?mWVBk*~ z))7W{K{U^(qzWz>_}%tmdC~0{a_;BJphZ1Xe>ffS0&#p{LV@B<-phnY2rF&fH8R$R zz7_mLmE(=nH}sXNX0c^}i2?Le@nBq_Li6D`&W|7edR*|or;vYf%65vNb$*}iSeQc5 zH#)kL*Bi^)Ur+hpM=U;6IhGAe`ec)7x*ELSx&Dzi)n6|!QY-td|J}}$@fD2!e$Aiw z>!P|Nli=uoe?_Y3oS+lXR8DRrHQ)YJ2EF;G_M5y#=CeRuwN+DP;U)KP zvlAEkq33HivbZkc9RG8qCHy&-=ruMooXJ)OI<;2nrgLFB|MM*ax|N(qt2WIO^;UZj zCI+mA9OM0`0R1mPj+;$}+9UU0yIr;eWcKoSU7WHk0>L@@at)BsXD`LhA732P)**_`e?#vi48f3}* z?-GC+@cS(`wx-e_g5L|xWyl_8OGa(?q(t@}(SnFH(7Skb7VK;TmS;7%5>JyOAnpSX zkg&bA(}tQVVaN*0ax^#hv4)1mhN;;;E+yY^FF3HgL`5KwPqYPw@U6FC3Gq&cstFQS zz0H0+AW)nGNq4q@j&a^@Y-wq^?pg&Z#F>k>zdoXdESjO|oSV5`7yGWz()V-))K~jA z`ZX4x%x9?pui?Dqz-Q>9q_}IXC+fsD&~a>LzowK$LyiZyl9*vmi zHS&Bs74**A<3BfkC4c?}?S@Fhe|BpxsOtus(uEBOLswVXH85)$k0pZMBA$F>mMiED z*oa_y-d83rJU=PpEO(OO{>AZ;(!>}@CwuI4)Ep`?TVrYZZ<(rIr1tnt z05L{OL9Vnx`JNpBxDE5$Ax8M^VCN(@Cmc+ku-J7ewFasZ}w*`=U#*;$k zCg3%8(_9K@O|F14G7EZ`gMKKz37@)8L)Ug4`4i;ing}`M7-UQWCI{qq#F_YIyAiSx z!#n|G>9ySPU9dPDi_!o0f6uH3bh{owl?Zn z26cbBoO=48%tQOW+>+N=anDNZ&v$32g+70eAoa?UQ=SGSlOj$B(qJFO457w8S8&5C zLHd9pkNu)hrebCg=OrLDX(WeV078T(PiA!XTCLr}Y>;2h{qM}^RHV#WAAhYgb}1qY znNdE^dbl}TQ0Avlt5n^O&n~>DhR>ewIsD9d`4x7fLs3@w)$q ztuGIUvVHs4f+F-hB}I68O14lE*-0wNZtPpxmmx927>cJEU55LkEU^i}DLf0dsHTKOe6niXQxN z&BbQ=f|f@r*kdI_7bAo%lB8|D0O-VEE`)KyfilpO5vW5UxMT$6-mx=% zQ+@f&h?$**jMTwhNHjhOLMDDKUl96n8{t4VX;OvJVZ8QfCe!hbz>;KbwpyvBS6}&? za{Zy?M~`JUj{DCISC@5qTEqPSooqvXwofZ9=pe`6=oRk&TyJe%VtvuG8;SF$6jb%u z(H}#+4Nq9QC*TYx%d=GYvv!_tTr{J2Pv+#9nF&P`?rcd_0y@!LH4vZe>0DDsB_V4cz>;`piR=4HHtI(T3 z;8ORAgW->9S4Pym|I5zNm^rpO)BC2{S=@0MTyD%<{J$IWw_e%Ds<0%fbA$_H{(2LJ zg62IF7YXPsu+tF+?3{_fJ3|-IcC=8+S=gm)r^g>#z;XE3`*mG}Ia;gDl*;JH5Eb-w zlO|I;D)qX~$ufVR4ND)_W7XgP%shqGcEH|J3`=L`wKoJFTJa4&^ZPfCcs&z{gPf`# z|Fa9@Y#gxpVw?6l>F)<$ys)+?3S@!^qBV-Z3E|iAv&`agE`Pi(izxOJK-U6s4QyN;?0Wg3EYnhVxK9r^D0|1J#5+hMbI? zX(S$+yCMa6yOmJ7<^rc-8W@n0*Xq|)DGm`_+hbke9-#xUrvi+9L@l#-fo%~Snw9Ro z1{QXb;0mkhCdwTFpArH2IFJF8)N6NpWA`C9KqzvlBUc7&ddZbO3uB*HGUdVK%}3F! z8D?mxS$k0|laEI!IxB1a=LC#VBE-&a{&c57p|N;-s0sh+)u_(%q1Q;byZP?+QGwwG zYh&EKIS|we8K9l_i}qPx{6VbyqpNP3=M&^@DVh<219m?xP_^JWP@HCv@t{py=oM$! zxmiz8aa1NsSzAS&kn#eYK;7H>9GZ=Dg)~)J8kawGo?En0!E7YgjRestahwbcBZ!gM`;jj98P0v1jo@c_a;*)ROqTaI5|E}1S)E=9c# zmjkT7B6H7cD}9wd_JB#RAMLd6M|;-6f(Ba_(@3_cMU^4gtH8xN7o{u?DmM8($4?&`^jrW3O z)&2J%D(*8kFioKV^Y)NUFjuPpgG;NH7(qIqQr}{_{i>I)*upXBnzniqL@Puhq$XXk zya%BDk4z?V(u6$2Q5;G|l=owd7rL1k}Z!oqO>d^h0MPEm{C zpBN4sN`Q%72rhoB%^a{eC}rh5VG+qK7#H^z-95WwD(q2W2SY`23AhM{EIw{f;MYR! zqV`*$mJ|-Klw>U?AIOwul94>tt|J|4X>fHJRl$^2j>(z#9ziO$6&DnQzuZ;o+HNb@ zvwF{59e-qJ@;-HGohVGHSB85>Y#}Nrdn+{@w2(xOf5aLeKn6#kly!w>F-e$S7Dl;0 zl*a)!Qa7-cEWakgKMPq7QoWVgoaW> zpt^63{LayneKXdnO=ZPclMKZGjU7wTcXgD|u(9Vj!ox53mHj`XKi6tj=8IMPTsR?6 zZ0V8Z>0x5)4MFpBB3u%W9u9o+2}TU};2sF9%$UmbA^Mufo1;`II{oh2{pYlkpTEze zbRAoSG+~ga**P~2SA3oD6fNPc^fhycruU7w73877;k+^?nUl%%s5rRwK+d=_6LjnySxv?C)~&NmUs6L|1NQ z1a}f0TdPTN;oczDy7HL*)utGwSuw1!0vjFhr+cN(w)Z}MGWijH;K(cvtjPk zfnk!8xUGCuUOTXub87;-g9`z5Z1SM40m`NtNr9??5NZmZmV zfO$cAw~lIkV?>=q)V3t7j;jKZ%;@9C;m#Z0yP#^DO*4v*k3T1(IQ#k9<3?%QnsXu* z*%LDsGcgx>T8iiO-gLB^G#Uwp9o&%R)^5sxIPN(9i-|I@*C>$8{igb@ogbIGm8fkR zzPOJ$0a*R_phDl^zeDPCF_x1&fxORLc|ZL16)dnG>YLrMbt!0cpTyTBtP)14!B8N? zYT?5h{bgUJ2m?61AD@C@7CGhOdlcW2lr;3QE=*{yWAaa;YrHAu#gb%sM+eK2&k19O+ z(KxNx3(jX|-)W~w{=l@>m&=u=y3Jn{g}(p>u8&B%o*`kJ{P#+5#R6? z+%p!cGw)UHQ`-?a__Tx(P#KvCB3_(?F+*sUK{;yw;GDR^7N4r@=jr@sQ&*KgaKuRVoM0s)YpB+gOjQOj?Ik}@EByCRU~MFQ{{1z1SV z0dJ~j=@`jh{R=m`+@`7FnkfQ8BN*HR04uEeQ10Tv83dN}J5vx6g${VE$}>Aj9))g1 zLjqW@lUhynE4@eNpy{0~pLy9%KQ=U>U5WuW0%~a9^bA5mbRZkDlywaN zUPgiP4kf@!j{Zik^@4h<=D)}#NKvQTt6ayvz9IBF{lsb(&H?49Vb7L!^{u~+{ zg0QmpG6Og;2HQ#xS_g*YLr_*bVAbp3ev+=SedZt2P;ioesk62%p8~E1u=2f;wn4uWzBHo%up2bd(rL3l|3h#3o7oCjbL(;3oQbfhV7ErIJ~QVV(q)xkjE zk~Rb|5pST$z@UJANWC;TL0=Ev!6-^U=2jf@&=hoKuAEX0_Loh__NW3orcFXecRVAp z6VxVmb(5qSbBacJ9(=a`uMGwU;zV@4M(ct?g&_ZnBH#uobkX#_{^MGx(F4JNa{w%; zfXw>>fMi0{Iwi?dSzJnraTiSH)zsl+IS5CRG*gvjKIju?d#ncD*}*a|Js8d2-4j0< z0K7^?{icWp>N~4W#S*Oj=kL6A0J^exo>^19w0j?rU*T?CDcVsdEP<^&p=B5VH|KIn zAqfHrkYWJF)HY>u<2~TaP6>g}xHKy`xH1YCgfBoJ&|S(Jzbe0o>-XjTY?3el!AsPN zZD2aL$ms2Je~PX{Z-bfIs;26+M8vNJ3voim&I^JK3|z&L#qyf!u*0NaK(R@#@Enj9 zk7mq|P^LOFGTNndlm7u(_0c~T^L(M0$*^8pn~^1LBP9EOS=-f9f-YWZ4FEha2XVk z3`kqyU=~coK&f?9%nDFiYy^YM2$(TgHqGMMV9l)HYbm+Fklq5RV`mrcg9|@6TexFmFEYu&X_>2n(wjQVLgWa8rI6<>VahomPPA&%X0 zr;y;A(zq;-m^H-)r*Z+XojLnw-!a%ykvQnamrd}QY~jHGWIlcH-SP2^3S&gO2Yu4h8qOm#e6S7S)Fm^ccI=9vWA1~bk(?ionlJ*_*G=_*Rthy zv(Ccm|J4B6uuOa<;L9b$LZ5NXk4b?>3$je)87#a7vI|Au4!#7{#UK=p_y6tvKnxxA z$~pr|5-oBjCISqc6D;W?amK&Dm-wUKp1(Xcl|ou@s-nMqj>zS5Ae=4e=`h$%P@?Y-7b{D2)5+AKz>>Z@c1tn{lvsXvi;tsIi&H01CMGb zec&AO<=PO~itT0QO#I&go7)zEY#d8l`4b7o4ZpBrXJCa~@=c!7jxmw8s~cQHbtqB? zKR)5yIbA*qg+c<-!8>UGum1*N7M<%HFhzunFmmov#tG+RV8_R&&*Tl54lDxqBuUGZ zGJtOlLMVOz4TPOiK>b#DjpEFQH(tNUTX(S8c2PpA!RJIu6-EO;0aa8Ym}#wsg1Qn+ z=K;Y71EDN=0%$eBAu6n2Pu&OB+4c5)hJ5Dseoq+m%I7Wma$+6t*CW3)H_yxrFV9_7 zbrWb!WS`ma3i5R}sO&$tAVsnEek&7O`R%fW5n{{a=tXX3b>HFQ3rf2aGGSg@^HtSc z^8@|fd!4jUJD6756_c(UfYk?p`XjLAz^{gq7I6;%-~phc+`=Vab`37^0qtNV)PE2c zKnxR)KX9&16|&*{sOD2MbAF5pI3jg6njb-zI6N@GggjXV{kt?rM^oJPz{-jCIksPg z+`#k$F08?Z2IUJH)B<>U1B5A4VKDRfeYuSpXm^hd@bI_@c)e;s<49avMh^L1D9w=% z5f9sNru}rNr(CGW?M+SsHNc(RS^cI-pg7#fbpQRZts3$wWUXiI4}7S)oon7AB_&yT zDMLS+lXh~*#N{0>%{0x-RL}U@m#lw|d;x7&2k2Aasq*lYBpHj6#+J>TTQFY=K#*3* zt`s~R9({$hxB-c6TR1o*G}b`_v%U@GLjs;6(@RlD`0_!c6;{JC=eq)Foi~M1PLSHL zGje4INcvHB{ehS_^`qC;SP|J)-;>-0k%Dd zmk@U+JScXD)g^)M@+klXZ&~_nlr3b5wBE+#UM+_GYAdN@DGqC@ie#dQ!RbOPD0WTn z?Zqbjt3M7Z&*o$C!tBrwXAGl3+JG z&9$3FT5awM2K&IR?YD2sR)+8m;*1031wd(r_l0x6;dz#--X7u>R>P ziZkKX)7m0M_iP*J4HzgU)6_B zh>qSnyL*vT;}zt9yY>k&=%h?5rJY)OnL@T49f&vZm$}?3my08K}N7PL`~*Dq1`dvlqcYj#OeG1r0t` zGmnw`Om3v19nmUZ)eL9ra7~|Qg_J5TysITIn^*^Vs2lYEqm?kG0Sj2M)wN>QXL_4z zGeLo`H+8QjiNB{@zeWjeTadE$zq;WPbpEX!jojHZZIPn5>S@KC!cCVOrd$=DzEG@& z#uvOk3C8H&UcpQ)p}UVL(Y-y(I&LuAL=MmyV_u%v-3R%EknR)m6A@pC_>A3-2-we*LMr-=es8(`&lQmP|)E5EWX0ne@80 z;JIKIac2c8~3gxucUa%?P|=qKDksUJ?EtufH0*;uu&Z6~N6p7BzaZj-uvoBr4x z=@(2@a;N)x4i;&7l}At#CDikNzzol5#*(@BwsgwB-6R!^9kTZOqz5-Fw|f;TClp9l z#s6ToocD9tiLL3uI!5lDa!k!wqZ*xe~cy+n*$nt!jpqcVCgItJh0nI2u-kWp0Klp6|)t-_8MiSZppAyfJJ zU3o6^f1Km4J8r4HVJs=x$@)hdaUx)F{G{RO5{?LbYs{zeaPGU5K&8r#Do`L4zNT*N z3S2woDOsEP02T`qc;K<;{o~_$6I#hb5)NcJON?GYV`y}JzztoGMtRt!IXkTtu%yUt z^|d~pch)=pkBKNJSL^hjBQyX7k3jnS4D%_6>A|xZHKTl{25Tntix78Uci~oAUVh~8 z#tK!`voAwd76fH`Wq_Dz419jLT5ZHo@5h+vqp667Q*K^@@Xk@Y z!UfjU(S{$lwwRbTu==%7rZGHtAJOKtxA5KUhTxK!5e!dx538ToL`0~wE!%=C)<*Vg zQl(AV8>uvP3WX}bTrrYI4s*)y)<+eN@f4Sk1QlK~w+?uA=caMhF0xv0j`$V++FF=PV`wu)dg<0J>#$VeL4k%5X>m)EJYR#Cz3Rg^sGa4pI<&8LP(g(o zrRg?bhwYS#RdILs)4f@E>ixZD`N@i~^=h}BiW(l=x7dbOY__BSo_ut!eRM8+Ix}|q zkanoLQ?DboG(a_ytZY8#&g-0mpRDE?8do%#+ewXCnh4oh7L?y;ri6?L%Ivkf#D+Di zJxgpgQ%82x2W~&0)lBGg%fFeMP`+tpq(hv3r!*Mn<7&}x7F&`?Cq>UUx#JQ3VdonH zqfi^|j8L^y5h|P9@&D;d>UYMiqWF&|8F}5D7$0Bt_YMkpCsZEoM2uJs!Pm(;8a#gd z;swslutZ8VUfc6aGP3$iJLW|J{yFJ6K4}#fp0#r$V;#mO7zTUa_=0>M17EzYsqZ}I zj7(00d^Zvktvb_HQ5kIyh?NE-%5eMC%=G-Cy3KW1q^nC82q(8HXVmxU!eBVYj|FgHb`WI!1PK_ZZDhs&dyEM z-V%BZfk4+7@nmI$$0Zhd4R&UfVeJXMfIPcmiKy2E=#;wL1JdV$Sxi>7-s5nfjWCfP z0GK@;SeX?4ISBA8w-+_@fm!esqJqlh`6$udckyO94#T2*S7nl|&0VD^a&4yEYw?byzu@Ma!^A@EMe*lM3@w&Pxc`Mu=lTBRvaCIsHDvJ}Lcj=HV6|P8 zd$w>4UcA&ykW`6ao1YxWeZcHNu=lT zO#ngMJjhJB3d4Fyi2rCC4+hW=9rB|F{LBtyC8+=0W=A&~2=LL*3h->z&b7u-fd=8b zlLam=@q%k@%?IF9kp|~QU}>OG{fsaw{ydogBaHGP=a#H57aMM=R& zy&;E`;8hdPNx6Oo$eEAd>iB8YZbeg4R_pcLzUrIkr_rt_b1}mQ+rmA23sNiG(m40K zI4jURL-{Od>agygGI)AErWy^IaeC8N^1jDR+SUfy78qF4+@SKFWKCC<+E-s9W{3F5G z-CK%W=ggDltWBy0%q#sPc_^xu)w5Lt*F_mZSsdv}=L9Wj zzJ!|)X`R}kIv##K_@Nanq8Pb-Ac_3_^4{c{@sxp}E7&F5@|@p9gwgk@I$vw+8<=MX zu=lSlW)0aR4Sh$>Y?1i|Rx5R~(Jnv4Pp%JX?M-NYeaEpiO%kS5+SkFgI>S`N3sg|x zX0ZMRos_hts%_p)Ws-K}zqf>yJ%L*da~8SIgcXcLLcGb zZ7m;R{eGLu>UCa$`#n0#6{pVC&D?S5kKz~YZ0bu*6-4At#0RDI3X0hY$u zOq}^})ym7&mVjwqg%i1-f~{Z^%ge_0xuI9vP+M7Uc0^)P;RU-BSiZFwj5E48(|Kmu zcj7#G$kq0Hi`${#%llKL!9X>ibJxWo$kH!j7vFD?p+A-7N?w*l{@C^pu-Hi`hx^dM z^Fm3rR(a@Q%x}VGMdPLgZ3WIcWtZkh*D?jT%9ce|;JbZGqldK-{z7ylH(rl>BaNNC z-_Ak*wYjpL%0g;zT6Xw5G_KB#WUiwlc%0XgGCgdCw{EFVUA^ls7!}9bxZL8({iwze zTZP*6o_#=!mxQGr`MUR`uTHMym~OTjM8|AZVK9!b5vCRFNKPMa{E6lmIa}>?UJ`j?H;_`;m_ACd}- z-p!)@NDl)UVf4hmWEaVsMVFb0hv%WwWMM(WOl|HpOIeBdA&#md`6|8Mv=@em6p?P_ znq3%a$l4rM2hYo~W+KzU+U*wWa$mXKYRP*C`(4D9pQKo1S2Q~#Twuq(PD8n;X3{E2 z;|zGoMC^O@pq_yBiG!5YK~GeDm9wWn2q^;J%bQ`nmyKPp%=h-Q9>ADTSl-1{bevc!?Rjdq_z%3C%GzqqJA}J+LHMY?!P z*z;6(>3~fGmP?U(Mj&iYM4k~HCc@c{`P(zBZCpTQ`Ei?{$B6F%fq)+?c+`VBq1v>W zElUhVgTfrh-m!_?nL7pTu~-=?GQLtO+tJM)EY&Dly=S@Jy}>8hD(k1bLdT2nqg^)l z%c=9O)>uN=Y|F@I7T-=4KoDLc1eea*&&Qr^O=2I(c+aOY51;?lS&f37a-LryC6W!u zUu$^>%G%h>WB_DW0Xft_kzc}c1csq>Ps6A(Tq(Cuu^XLucsBFVw>Z-Cp;PP0U-v!*bndjgQ%J=#soioxLJQuI>0OeBr~s3 zs?T{5#T-tTmSf$1>k2-q4qzXBPCZ+bc(vcKHz)KZYwvl`2j<;uk*+Wh-Ub9j=?c(i zxOyT+hRZnAcPZ}mBjN5DWY^g3rr^0yypQ@sc3z08`vA(pUvO;ms$t^K)it4t8!NX)rIFZM-{F0bZh*%x@ZneTUm=GIZa|mhP^D;h}tp zIX5j3$rV8BX_mhrStjWs+Tc{I0jP+j5Tnzo(mO5wIQ|R2XPiY8t9~19t6N{hf9_5F&F$9n z!(p$@jZl;0`^;*lYn^oZ9dn-KJ%q2#L^Lp&)4&4B_U1b`xbj+gQfO)N!U|Dzg%#|cXwC+)%1}VZTQ&O_ts}9b=j{5<_bEgavQrCObm%NLu11@ogK6dN zy_%uL1JR%Ekd6m9Ajs z+)OV}ZB194z4=u(qWqS5E*MXh>k;fE{jUH~sEFS}pS! z>6E^UYm1-TE|l(E48$e|`X2M8VQ}t*!|AOIX)6 zj&AR8jNStS4BMf3d*YP=JgCK7-q=_51J+z(FvFfNjh1EkX578? zAU7v#)kh`tyz)}FI{EwSy_lHZ*4tKCcW2$Hn*vk#I}2wY<~+aCq+~t!$H=FSQSQ;< z!!k(M6EyBo1xqU@0+Z0?5)*VjSs`_kJYom8w2TH6ea{DJnp>MYZ{=olor(tIg2o12 zd3Ite8KO$fI1x@zdeI#`bLQ>edxCKcqU^zbq|TIGu$u?H0@a=Mq(Vn^t}hXt51BbY z3LGd;lMdW>SC&EOW)RB_XRBO|m*FVjKpdgE*m%G$6iTcF8X*Fh>yfbb>SOxH7me{F zz|`nx+NqcPSK5e@Su4OBtnErZKXw?fiRwoI@4*|8V5{L(cefk!k=uigu^Tl|x^j|u zU>-~A+`k0T&FbMGYMQ)j$7z`X-|}w|K{Zxo`B_p9Wl|YfC+v{RZ72|Xf%wj^iU){H z0T+@%FYf?T-(SKYJs_J}c-__#jj_^pMhyHWnK~L{Hz1BhL~gHT{22#p;Ygw2e0CT+ z7ymDT^TXbo>Rw}Yhz3_OmkMM4gIxZuV!1ZWv|`|yM_Szy1x}}6M|E#!;d+`$Xxr^i zMY=*~8CMHhhL>^1Nb0hx7vXNCRbHs_P^EpnD+IE_ccQ3-lts{2dz*|^4Y;8GRKKi; z+|s1&5_$`@uy^G*ss-R(GkfK5>Ck3;^-q|jH{ofeuX#b^S~Wf8(iihYCUPHc6XS~cxLC$1L} zUA<%4DKX5%Rl>IbV(+Yb!pzJlu`{C%tU+Z){;0jxjfL}J)1nIM{E_WCX#e$Z--Q>dd+=Cm3EHXIEM5(*6~8Am#VeRAU7|=k|ld zhQZ;{4Zp9_QT=`A?#zsDFh8;q;;Ogp_C~mwla~itdK*USWuGy=EZ{4}u`Rv2^{MVM zyH1X&s>~q3p#u-8uKI$H@Mv(>nuhX^T^qJc*&#cEm*kdr54#;|eKLw|u;pR&D)&{p zO&uR=Ihv$BR-3|&4;%$@X}L*sf#1KI3o);i!|8Llha?)N2ZMcFt{iU4!3V1I`5$ z;8R?uRDjNtkkngd{c-$lG7#m;n)iRudE*6965Z)0$(}=~scdv1dKn`Tk2V9$IvQzE z%cE+o?esA4btZ5W1{1iJ(~x42d}Eny%)bNhoUzPzh~c^Vgg^2h+2z+`>?~iu@m|o! zVsL-+4w~eNrI=~Rx&3QIH1dEbr3M*I>4*cbu%{a)YNgapx|yQbbU9AUQLjmvA)lJl zq12Z6MST!GFc_54K&KYN`zGkQEjEtYQk22r!`MwFLc^H{^L;<*K7=s4{UtqI zarV|(1(N#(0TrucyiHAC?!eKblGpn!VHKvyfnd7tZ(MW1c`XN1o(p93p0HpHOeP0C zg+@sjp67Vve8AB|YXK~S1%+)&&Ta8+h}H4D1L~G!{m1Vh9XWpZ0kN&|9>SDynR4dl ztA%&H{&qtOA8_sS)l{K{KCM$xkmLY&FbjzuP}OVdf8~D4O|Xb^)IMwn(bRYu?AO(x z<6gm2O$p}ot$kn~-h2D-+!a_Fkx2OTGVI&iyR^iFA!pWb?~3=ohs2gJj$H9a30xn= zvu~$4{p7?Kq$w7S<>(ey%E2c4)vWPeGnD)kIDXAW&;WKnV)YVC+Z^UvhNI>@ia{Tc zbU0U&=*1#?@Tc0|wx2pcB_6pC`H@0DhGMeOwNNl#QzLcuHq1#FFsPlRa|^$Cxn#=< z_x8KfJRpjQu5|Th5JGI60jOVkL#NZh6=d%2ptZh!;-FD;CrZP$XO`NN%jT9^(pMS$*XU(W^o2bz)|SbF zr4Enj)o!op4fo#{ol5Iu?G6?4PF=lj#?BF*PaOmjm0*EjjyMqE{RO0fALH>x0eNw2 ziVI{zo`j)V6=9Dk!m7F8*#%S^#G_2#dX&T?C{$Q|3dm+v4y^+<7_zfqc2y%yIN$Fj z6tIM0qTA*GncE2{aGnU$fV15S$|IRJYb|!z_f@?-zXTtLtWfJKc1`) zLm(~?0%osj5rITxHaXN|L61UJc4d6TtQg-s1*G$jGIJvURj6<|*#CmmS%r zDE!o`QGeoMQKnWB3 zeysG-@UP;UnB(!bc`Jv6pKZ)X$P!Hvm4tfb#S)Rqdf^&K8EHh^*b`c?CEP zF9@0oX2`{lntO=*fBazI-E?-5r32fkVUNk4qBEa)jxy)>p(1Vntcxq;|jL#^k^HJj)N^0N;g< zZ-x^GnSI-UBJlf=yzXoCnt#?LL}vb~qoq-@LJ#$<&s!N)Q`7v2=;gqb>)^MCFPy&o zvNBrWkiSm<=ogI&*zZ3rrU$={#3VL0=6HK;$h5^TfdW_?Lc#y~ z9|V^4z0nRN1HG6I`-M0|e?!#GcA9HWbN(!NH=jeg|Nie2eKd)ws;ld3BPLmC2U(sM z%VVw_FClXK%GBJgdBw3~{vI{Oq-_<;W~}~`x>~~d``t4G-2U@cZciaHU=iC=)*kQ7 zo}_rHv{TOV!f}Vqu6E8xC@792k(-JS`G#kPLgYX8kOj21uLm$aLpCm#?)E#L=KW9JBf@u2h^JM6NHOLqh8svPe#5#2R zvx$RDd`^;c%iXWvTDC}a|4eF%B^l0BwgBD$9gkd|{Fegz2Q=!0j+FP~S%3FWN*PHa z&F1M71w|?qugy;=q?;RPZ_1E;RHjcZIds=0-fMOr!Z`zQ6=G(ZJn454{Qc*J7X40h z1xYrD`$l`;?MxEA{V#r%zf&bK2&i#TpQJODhyX4CgFx|?*xrm=`woikiusXRQNQ1R z@5UjKZJmDC{2jMf^F9K{g7m|F&Uqlz>j6G_uiKj;ZY-d(K9H^S_{!IKM1C~T(UXEN z0&C@Y@k}bH#?1YvxQ4trJy2Ws)V`zmyY;dH6d&n|Vcv!Rkw*dq8Z-XlnFez&p zF@M{(Ddgr%9?D9y1BfFAnWIRb28Efe%Rsm%DJ4|_$a*3ozhB8>=(cqA>PMp7AM6jV zUCg`1>T9{G?!4lhGp;7xF&EWxM(nuR;m28lU%y2S?77$OOY$1i^9B{5!L%gYaF zy?L?heg0}z^1kEOtxFsp!vP5r5+}gs8(cfn6Os{-!K3;65ug|Jf=u@T%XX;HvMgkX z-?I?>EZ{AqB!TQIJjdz@3kz=y)Iby6@o@2b6C|YDtecO(tqO%KYK~DMP9Z0s^r~~# z_wP{FH6Z+j8t?B*+qG>^zAT!2;L+^=krdLlNY#F#k`iWNfBMqRxzJaV)3KDJ3)a1N zPv?q^m45;HVfZW2M+2wD4kZ-pql35*3Bd1lF-QOGe_BIS80etfaB@Jn4*(U&Bf}Ml z?6K^-z}LV9@Km#bk|V4vbZ0Z@m7!cwW?FbOIRoAB6kP;}>bRjY*R4zCUcgd9XwKyO zBJg`&Tn6QSe@y^=mPRzXDd-GBDwY_4FIzgzRgqNT-uop<(69ZH&`%T6IK=q`s#){{ zTTMV*SN-dxTN=<{wYIhO`L^L`k{1E(vc>6N`MjicKe}qRf)s(|Ra>5RC_NwI{^kTa z{&sN9QC4hEw|;AfWVSxd9g_MNefaPJ7ip0Id5Pq?w!i(+r0C4;Apq=G^W^8QTRsHc zNFp|y4(ve++B(GKu<&!I4Uz-f__u+BLtO~a&)Z1@hy7xpfoe++nmQ%31rj$QevnYU zH82uE4FR}f9_Gpi6coU#A=)P?=jC#k4SY{x~lq zCSgo=&`Ee-^VQCVA)hqKi;vxv5g*7zj5qlgPIjHT`ISs`S>Vn33np)v(`Z}89bNWV zjg@$fWEGGc?sH~Zv@M=<TT-Kro%w_6jf1g+3>E?tcV3+EaeTP7HqCMa5E8tb4Da&Uf z{|+|{+${?YFBaDPl?=F5WrgS#G`jdHC~*}nK_T3egBi_5fl@CthJ14{15HB{G+<47 z?0h2j-woqo`7HO;Hb~|dOKUvAFj0d(V`=cM;K=E#7scI`I)LzIuL<}~$e}Q%BW+|C z+H-Qg$xXg9oDh9}R6kv*;?=c%rGJg#{BKwXZ-6a-3O+Z9X+tw`$@kGuF-IEeQCq^%5v*NI@+wz;Vg?lr+uU?AvjcMO_^qA>7m z_gVjImTnLh_RAuI{UpL>3E14OFNf??IPAQU=p1qPZrU$WN3SYC(CCmB(2)x$UXvy{ zvrvmo?D8IcVm_V=?CR@}UH@c5f5iBi$JU`=AVFD5ffQN9z8k=-W6OR_pXhHpTcy<> zUe?D<-yc1DxKjT3g*$7@U6JWZcj9nPi7NSQCnc!K4T z!A+7M3-MdH00)ao(15k2so(X11n(1|B9XI&O2J@LasTf}r<=|ldg!a}xdqie zC_j2egtqPS2P16{qO=LMtGzPCR%X){!2NA^d z>a_MW&0${H+Ic1A<*#Q|HBN=HkV-4+I~Kq_v=$^t1@{9}IFNCAt4CL62#AFo0TTi+ zMS-$8X!OWSB|qhQJs` zM(XCAq_i~pg02iP=F_Xx*tMdd!^8+f>G#8xoruIzFE;Y5Mw(v!+R&t6-!azacyV(R zLjxYK|3gIs@Y>FMFs_)3!9@@Qjpg;VZ^^*> zc6B8#)a{#bmu{y$8it8~0RZOohZSqQYuS>X9YI<7&IJE{1-t4y)u~!>W_S+v*tBlwjj)vNhN;&A@EaYsibSo5I01lZekym!&FznY^HxrWz zO!7#B;%sn8>h<#-*iLrq5_2U=fM(BEmQTI205ut68f|9j)z*Oo>IaoZDE#@nuBUKd^MtP5c5y&xZ;1$JPN~_jwcgV{_*~5g*?&~VCXQN=V5VsOPDcaU(!Wflv z{`thSQ*IlKT<#sY!(BjSr{Xz z9GqyA86$nX-Gu8oaOm#EbQS{}h}Irw_nq%qQPRr+uO@+Vw&cgBNb_2FB97UivT}z4 zs;oI^24P%N2svSt_Q_|Whtc9mAhdR~+zZgV(-qejKk;mZoEh2y75vk)z#tFUUB<~$ zfV)!IPr<%RPn9m3Jd%ko;P6Nxe-r?|VdlChkY_aqmV%|(0uNyW3Yt6%LC>0M6_a@} zm#<|S?$d;gz+6UKgu}c%3rn9a7?@r$OLNUvOoH5cE5G5BlMrBO4*JW;(tjhx#pwJP z`1%*9z)kF=`cw4UE--0Wn}aUx*H{Dq6M(AvNyM!{6$6dfM2fr^E`~4yiBfYl{QkE`jzFEhra&^Iul?*6Pwz?DAh) zIHqB{sBi!yQIV(MHFz=Xh8ep6nw*mBC0~)8hkvDIP>~hJ162G>i6Zn3WsvFTqys~T zW8`&4TcqgoV9O^HIQOjo(|iDybYF z|JiXzEKhhwj&!u`<5g8D{wK>94_e85EwYCXRDkc9g`cv$u>uFZb$V$MBr+U|=St$9 zW!=1MW9joGJUC1LB0sR8?aec&9Iy{l^|`1&Ubwc~d1+f?y2s>HXO-kq zPBdB;DI+xgQtdwmK99&A_rKWHXTC$zt@82zX?VM@7y|~>eJoB;jpZA^W%>Uj?7hR9 z%G&i|iddq8jv#{4#zsdG5P?udR9YyZ6L28XK}rxp2LVMXN)wb`0-<+AnmUSr(nIK= zh>*|}5=uh()<)-j=Umr0-*5lnr7^o^@3o(GKX-vP&miUUsue{!uL!6C{FYb*a_j@g z{nV%O5yqeMR*%@u1@wWolx?ZIeFhv$x%oY{4)VEy%wr&}D^z+Y{vSLIu<1;aL++C% zWSbK9Q@N6h?8TIK9fo>(cYwG)76j3m-MaP8Ap_K1Ryj+a>LNly72rXf)KT2Dh2|wz znymXIzQ3x-yfypngC{G-kBs;0>s21D)9CByuHKE6cREb3`ecN+&Cq8vp0p+ zOrM!1cYOzTUoSDnsAehdr0evDMN=m^>mEiJ^;Z5dX+%HxwzeY5cC31#{QF;{MdNR6 zT{nlJ84l=*-~NvHm>^^Tp~l+Zx!ZvD$OUNOG{19C-ny8Upe4rl@6NeHT7(a9CvztC z&w&lm9F045kona(_DNX&dG3S~J_E?ukQ`l1%P5X>yoK&=0XUT@}>AiAxaW!?WT&EgO9* z>@H7$atN}8qkmxwnj;8f+!Qyzo$v!%@p!QnprZb)K-WcHK!do$HDJ6&1bW?Xt3Z7C z17jFq(AEGrJdm)_8|deUz&vbmeHYZawb1ZKOcqd!7y=UKMY`dKq}pCA$n~fysU7L) zz7O7?YoIV3?L=}=%HOe8fil5|qX)<(hauM>AhK&Z@Vt5ls2PFo&2@DfjM}tX@W!Fj zG?PRtE~fju1=w}qHZ_XNWI5kQ1kvibaYPzhM%Y>oBN6TpmrSZ36Ddff)hn*zrVpA`Lp z-dtJ+1<3TeRasUgGk8tkv8uZ)cp6crrij({%20d#0E%?T&<&WYxdxo;woqe0=ajgN zi*TH40gw^rzbqvZjknwP5WlF$aMou$w{L%~Xf_Z*WmZHWPJSRc^a>_K3iM!AT(|9c znEa5@i|aMo;$jt+ik1>Wri$!C5F`Q297Tm5)wqlfhF$5PK>|!$;HA(K0H%-NJre*- z9M)S$z?*T3QH-U6{bd7ajRw35y|Uc68Urw3znu#`+e)B^0m)1Q7g%}ZhS&msF#AC~ zZvcS&5S3V51;tfz07#|`_K$F((X};YbQ-<}t3&g>PTeFrvht3ONi$t(eI9|F>#X=GvL={ zH0OB@5a#BXP}^)#MRYItI%E~!npephb`M;bd?ZHZjAjBCj-jAbjeX#-m&Kt`d`AgW zfWWkcCnT|Mt%slH9eC%DJer;11$TGri41_`*v=8V10vSL;TC|KRCItRQD~UPQ*-YNxz4K!evIO}MeR?*eI44G~qlz_*QWFuWJsvBX7PiI} z>eo>ZRW^3F95Z<2y(u2(B`Amh$2{Ib+5T*nzC7dQcON81B~`K>0#~AZt02F1>P=Yu ztJFf%c#vbOkRqbheFj^re?p*RgJ{wn()s#q=?4!Gp+l?EPffR@Y5`>2=*eGH!_#t4 z4!)5W>I<4U;d)Cdhv8A6`hdhkcT?|!Sx-F5{yKfsNZJHPa&EQp-LFiZpOYX~SOvr% zW*3uyJwP*G#(UuTiAS%FSwr}fadHV8^8Qg%=V5v&ziEN+X~Bni#qzx-Y__Jt#u&@A zWPFz689ZJ1A#QD9;N*)?XV6xwPZkjYYUR>Ztro<2qm+BYUFW?Td+u5aoVcJ>&{z8s z>(T^R5!-By&o^WdF3wMO+#Erto+++&%&Yt zj}=zVT{XC;xj0o=QjIu|9Qg2}_OPibEzfv95DZR=8L=^WqOXB}>qg@mP@kKR03edY zg-JEqKLLCb170x&!tM&Sa!Sr7AaPwHhBXGW1y*^9Z}L}Jy(Y-#REIrCz$ty#Yo{T` z%kB*RLjz#aZAh~88Q5_#T`xNhwIN(C*1JWQHVf6@Re>S?~M}N)Sj_|aEUy%B`@oZeF zOp#R;$IuZ*W0tR8%~&3$MRWQQl|g@Y24rSsKCc^nBH+FxirzY-V;9JJ`eK9ggPWvl+Dhqo7wfba>1~Gd{98VFj~b`q?EwA149x{c|5 zPWag?JjU~pL_?*!cdZP@m(|$d14P1V5sFXRDaS8|&jI81wh~x`8p_l#eS5dpCiE;@ z*2suXYm9~mxEfuZllO1-F!ffnG9_TbE?HIkkQU~yz*#Hbt2F(Yk>qp6g*}=utbXkN z!>6a&TMw%!L{G1Hh=^HoM+dChruk%`(V8WVqDx+9d4-+F>bn=lJOjye`-c%y*pb5A zF~zy7?~@GKO_yVxTshD4q2Cq4!UOZR!JMnEj?Q^gq?35pwFwnr{|RH{3= z1@Q7!B9d*Ib{;k0S<*q#446x~|Lx<`z)8cJR~H&|@vLbs?`|wJTJE~^Hg(}OBlk6k z!)+gzN(6Lshu(PfR9Z-v5G2>F+&Xz=;5xxL@k>766^>JVvEhzxCK)*F#I>nl<~=K# zZVl?kGCm7Je{;GMagS+o`U{GG*2G#r zg{0UrwjY$u*=mV?zG-+hj zS(tDHkpgl_eqyK=`BbSAqB=1McyJgtr#D=clVH)NE)=fA5C$vwEa`$^4R{}?(jmP*W4dlwY>ujmmU>JNX zKPa*EQRow_tl$nLVN`yslbfPkl2J@-0s;VC)=Z0=T3E;MA&i5-B1fVFLyQI%`y0MJF6Dj*N{V| z`_>RVJ9jfL45!a=uNyx`4(=|Y+7&3rfKS5o&kT!7m-1i`)u&v7yNfz%AY#8N%R+@FwV zCUA6>nD`@cDdY8xuu$wX)7&EwQo{DFjPq|=)kU-o+n1(<>NdCo}_sZC<`(ty+K4M<2`Ft0UHo3K4JS3uF zSp~{q7%(um8#xvXTQ9BW*$)#nmsz$CvI3{M3!Fob6&b47*mZ{e3uQLSzmkmapb;dZ zRW$Vy&LOer{t$4AlQs1uFaA=%}6%Kw{GhICye1s8=I9 zS)?^tXI8ha!WfnABqD)2SyzGcu{NzlnV^hLj!YVuTA+-K968Wv8SgQic6Kz}8i|+A_*n_POLD93veJ$yIC~svgX|Z9 zwK^aW`YsoX4p?sU5T)ma99PbtE;oFQAzNz(tuhltdn?}h3I^l-@sWo~^HYGL4SmG-Gz__geYL##qZmC61q0p&c+9Y7lQ8(3OmTTWy zFj6}p^t_`Jf)NgvooBpeXwF1d{I=dL@z%-YL?7*kes{!U91F6HB(3YoR#0-(dxu9dWp@|@0TelIgTG1>W0@I zhcxW2jb(|V_3FC*^5$RxH9GjT!_t4nHPVbBhd z;|BqWJaIx*I!GHUEGf^Hv_g!#t3(+d>&Z;F=cCbIb`==KX`4E8CmADjy|&5rxfQWl zPQB8QVzRi@DB7Mk7~3x~-Z9W$Mp3ru^Sce_BE$DHh`bnyHIOz$vx~ei;kohY<+UrO zz^ArH0q{|bJn_%^Pnj|(Lmu!y*H1ANJCo0Rg-o`gp63;6Um5^P8KjKglUaYNB8gVEv6Div>N9TUqL$+gB8W4V!IpN@$= zAv)OdE}Y*`cW&OavvB3zW9VJ1J1aE zMaE^erFS8hCYSmz5$zUK_A?z(8F??dSBsZsQtZ+lKz&)wbFY>0mIKI|EQT>w@a_DF z1o;xXw&J+bWlq3;fsG6?m-j(d=qZjPAgI7(Ml-sZQVb}epUIEMI)Imi>S~CwTR_K(Tw4UcKsTpeHucnegQvq$&hPrgL0u1t_`a zockFHiW;`!RsaKO7)0$Yg9{Axv7jC}xWIiu@1RaJlmoH=z?NFbaV}3JNwFVvdlEkp zAScwCzCL~PMjv;5eI!kB{>m!o%hlbAzc6f|3r|ca#gb`(z;nJ(5E$BD#mW@Y&WmV? zy#DiG|IY@}Cf;hu5QQpz>tSxzFoaV}y5VbTT4}M`t0;92J^f*?|8WACxJ0B#0cLYz ze~A;(7B7sHPua7cgR}gsgmBm0=1E`z_8Rn>gd%1ka+fnGp(!@qzzHl_D1I5_uTVXSz7Tf` zMKw^m5NA(z7r?gySH$-pVkZjDN`qR+;yWl^D?gnV}e(1r0>ley2kTq}!8EyQYERb~QB2EZY$0mKQ*~;QPFz5n~#vSgI}=N6F1y$1QRHB-%m9tz9dM{|>#XJ5|>6StAx*)fGqdc%9%3d{oV+S{P3 zmSsrFyrZ)eUEK2{2H>#}_9SUBL;iiQU36@YN`^aka>C!MBo!z&aXzBrdVFH1)Zb_% zE)0?0sUEBN<2(4Y1vUdT<~K9e3N4y&)e8907hVE)dgcS#4CvzSjB; zlaj{g`R6o+(^p+BD$y_9^JfG=$WE3b5f~btR4VNfv8Za^H;0PY&I`kagl57*0|4}d z1jV(dp0&ZcK_^iD#M(X@zT9?ZI>cbnxO38XmWSTAgysr80k=-x8Gv-nt{bYkvwzeO zH$GkI-k__{5AKcM@n4TUjBiY{HspFHYkhD6dFA@9@hk09qN(cw`+f?q<0S6;zhO>2 zyX_d%em?p7?uW{uRp9KE|d?49Wtq4Z*|OZ+KWxhAv*9xr5yCF0gnTd%!e& z5SeI>zK0W?C9rFxa`+2gx-nW?a;I9+&WaZj6c* zoC2MK+`ZSM_SsYYl3wdYdsjfISw{?PS)8 zu?TR*jY#Q?CLp`5$4i$0cZKK12ej>Q4 zMQl@M{NtEDKjnQu&E##4`<4^w7I2OJULsI$^B(V~3bW3MN>dOhp&8gSvUf z)CDqq-?}0$=!Jq+7m`ayRt|e9AmurbSB|Cxfvo3|dq^Wwng#V*0X@6BQ@KrRHvl=M zIh!e59>U~crtzkHWK15-j&Jw@MVT&6=abAJXZU4=+GmG3#W@X$fDgvWpGzI<8tx(& z#jX?6c6xPGS}v90iL~Q7zzI!V)&04m>GH!q%V~l z>+%j1ZHJu-&PQ);{7iIVgTxQBMLDf$_*$}ND3cF9<}|)3ig)B4X9$#H-NH{w4nq18 zIFBKFd&8Ook;JljeD*mS+KS+>&f{UvM~b;ddED5noXbJ`Gv^3QtUoz2wRs_Psn1I8 zomaEOUkt|tKPGPX-VD0>cG5oDi6;MS$1fdaoFPO)JJxAusFSQswG&uyYd;gz3#QUT z&BF!fYQWr%ZfEgP=R`n+V~SidnOK%#eE1|l2*JzvJM*BeONq7RVAKq|{97;qq?QgTV@}M^#73Vlh!KnpDrMI>DK%9#H zGo@gIn=ZQ(1+N~0p0#-YSv(VFFGT@nFjy-`LAa5gEuTrm;1(`nte@shrO5gA_&#Vw zWiI@Hn6Y7g*%vObCv>dcE$aU6od2qFy#KRtLESoVFRbkf3Zv$Xwd_zOx|57QWX*A} zB2@;dLghHESJn4os~B&!-&fpJw4-9}#}{gd*M~zgf_8ip`Piv1vlz0{rM;HJ8YTcg zGc`2vkD+PJ%RT-=eyS}4krT^OgZ>R8S{O=C@r}Q!h!U%bRrvKtE!g%*X|Q=}bNz!z zEHbYznTv&sdR}$$QsEt!fTz7-4R{7(5|&TvYGBv9?Ug)wCwxDGC*b53$hD*Ln(2Z- zz~zrBrY z@~G96;ku{pSzv~;_$Iw#2^A4tmTDg}JMZ(7{7!cvRdiTL)mA*l%aL*0;9m1OQiAhD zB-u2C9V;nXMP58d3z1A)gho4qJ(RtwT!D7|iQ1AF&w63neb-XX+YHpJLb@+5 zk3ZX=TSe}xX~B=}qunn)Fnx)Y!k?DsSmVfcb3Q6GjO_CjkfFkYHhW|oi^MlTq}f6m zTF_k0hI$t#aa=A2+fUfUYf-{ATxRAp-$SMS(XgUu#ek3=Me6+v+D%hJ=J&vS<<_H3 z&a(lBHpiC(XRWwtYm4(%``%tFjBM(uh!Bjw^tH(6na8c^YGWDVO=ttm*HaT29U|qry~IdrqSuzIW0^c((?cs{l+HpKQr;uz%i( zuftoju8w%Bx*LRf_()suT=D;Kpd;Pb)P~RajW&9OgehFA>Gcd=iWHo`fU%As=Z%Rl9u*M=f{{<{_XN{^I?`eL%zv5p+EX%Jjd^a^Lr#&G*7 zYq5?b`#`_*_9k8S1_LU=KAx&q%omuUcW%8btj>QPk@dLn9)g{cnh;1uc$u^7gHPg4;qSQ&XMimu88KA?eZ7@}k@fY$@NuZ#nk<0boKy5m~q>)?v@{M2L%&awXl`0Xu*4Xf|#-gn8 z*M|)Fx)mlrfdb=4|EPEn<{}r%XI0?XH=wr6 z+398a;0mRl9A^$PK2IjA+kB2a4W3NDF5Lhn2JX9k-`b(Dj#A6`LOhmAMxVH-qxBu% zCz)A`_k%=}8o<}dy#i|?v2tgGLb~OkleI2vq(s;bU}DI;yoY!1_|Ncx_>k!7WtrYg zcS6?3HjuBW$T}JH)~i|aXtv9=`^(44sFNT5=rA_3*6l~5^Io-10H*fRE2Y(K2K(n9 zU<-`VA+5aR$AB~w%n|mv!3n!&3le;9h`;hjt@j)H3}A7 zbs7}i0|A&A2EUo2C_BLohJ1x(&40yK%1F`3?7-&8Bjz=xL+Q-H zAF>i=0RnK&Q#(2EhY6VK28dRl-^05K#PDC43X-8oxo!~6V9zxH%%7@3#U*U2KLlD* zq@y6ok|_xX@utCaugA93K@Rl37NPi?!xD`>U9!_h=0R<<4Z;?+lTsG}qetW$YVRga z#f@FPQFfsSUlktVl{jK?Etn8n*%`R{VsZ%VV&)E7Kio9(}Rw*(nS%9Vmn1!LsLhZ{zJF`L0 z_9%9({k0ty1DV10 zva?fQj*gT}ly6>(S8T35M3)7m=%xiwZcvJEmL!*s?D0WOY7FUgL9eu zAc7$p7OVquLWo2U7G**b>IcBmH>K?n`*U)`G8&eY+cJO|{$wQOXHDQx8}xF6&_GDa z90*`8nzYaqM3iPQkv^WnjJVh#H4ijjlAPPeg~V%*$_!;z-d==~W2gP0EMs}#o}_Ro zZ=oI%#ehpN9a0A#1b)lf*>9hi7MPvCoX^zkY-5DTHI<->f4PJ$@H5Npi%XRw4yK^%l@%Of%Xy%Te ztGz#~V@oYx0}Y-8be`4(L$0BI&i7HUfIFgA-hvX*%?U{~lhxjlpEsU%=tw=2$oVNV zqI=4NUSW??d%AO&E9lO{4nPhje^g16_9FG27b+yz5K*hbwc!a#N!Z#9{J1cE0e~pf z6~zFWPwOQ^Aws5&5Cg=N83GUCVu&)D8ZIisa)RTgCLN3=!bN@g%(f$up%1d3G(ZMM zIKl;M5TMiubCE4&vV*h(5^O-uxj2k5m0NZW)>gU2a^U>wg8t1~DB1gQGH&KDKD?bdN%nzEi1IiQG)D?h3 zElmMU0BI64eKrYEAXP5bJjO9t*vIOc`jD^x2_3#;F8;0vxchK+n?kR3kossq^Gr1L z55a$lP&SvF%CpW1#dKhy%x^e|ZA6t?r%0|FHc6i`Cip_BI-SgDw)~=%{T=%T6#Km_ zAxjaGbEb}|pNh9>oZ@nM44(E~zWLi|Y~?A;E`_$W(WX@T=?~C(1fp*xxXh1Nm0kZz_*_Ms3hxtG@ ze208D!rK}HFxqLZaK{%Ci;#rV@ii$%(MuajeC*bg$q`5R$_Uok_x|aIyEG53W{01D zFQyd0u)x`|!_Y7j%D~Y16=PfU%#39%yDX#K5IHl021Ub~rgQV=rH9Q!cZrNWl-#1w zKp?1`b;iQ+MWx8unrXF$^e1>lE3@(hy88+O;h9K)ZL zZi9qxfLT^k6BtyRt@Nmt)W9NgE#PLcFsYr)w6}sSkxa;3#%Wf$e&a_C;l@ND&1|E+ zvTp)UaeucOk5PdFCGfRv`ybtx*tcCe_b>MaEmsE7+3y+lNWN4v;f)Cb(PA4vF`_(H zUZ2U0*VmP8s7%E`0cKga+PlO4V6T9Z;tjCs48(PIX*Lx1c*WsVjV7q)5;(<#(oSp7 z>Rf12DIGD$Uelo;>^aEV1n_)K)>s8}I71Lzyivz)g!eiMxt zdoIOa(ACNJXgHrHWJ@IzI{hA&Ji@<%@|@C<-VjG*=e3)&T#Q^F!q4l=@>3xHh$_N9 z|6D7LYfTI_C2t!AY4#0}BD$`cr^k*g7|^|>F*3(THabBCx(4cY`-3b@i2)`Bo=14B z*3YL26wVI>yg1XeWR_3IbzL#Ed?lWe(D``6Mqo-9``IgvJ0HE2!;US3%1~v4AG0F6 z_8=R4ZCU31`SU+zH`8;>EZ@IZS1fZ#%xhr|$DG%SMECCcpGji5BPV*;e{=*0`Z20K z;%1<;JJak6XYwngvQIr`_B*lI=o$2#4ou{U*e$A^ARwyvm z`)2A*Vz2_%RI z`$Ltye!BJTy*hr2vaGk5B~(rXkg8K}wtD5h0{AvOLn)~FbIKK*bw0}7{o|B)r2`P8 z=UtO-GKQ)>Ycy>&F{m227SZp&`12;m za$L2_MwFco~dJ zRT+~nWw>!6;wkiq^*yT!sr3EpQKuKPJ>b#P9woR5SIyP+DDws&Us~)8Xp($!>DMm+ z&803_mDMYIs+843N0nN6fgs{~aENgIbR?#K^lz>p*oaUtr>+PyVl`y2`~LHK1x~5G z*8})&n3sjf>Cq13BwZ6+kF1!LsrXjipU4{mIRcxy|Cwgm&HNK*#vpYwjM>k{vj&klOM`2j-!Eg9%-{_I+@xGHnm@MUPbQW{ySM*`|@-A~UF z@Ji1@|MViTO1^D;05-ZLZ*c0IXfonLbD6E#*dGk?C(AwH4LN0$Au#E;H~#j>l6kZZS|vUtOIc%{ zvUUwztkg{ zH8+ZlW(aPb{s)mBhyM6IPp>gPbAa%5!nG)e-%8XhRwm)sy1|LUtP6=nW+kD1q$3TN z1gpf;Sxx=lzZc8S%40*yk9<7po@B*n_I=)dJdW#T+%a&E78#0@dmVYR?Q$a8?_ie=q^={4u z<`zOCTOeT)6bIGw)v+F^>g9uUL$>WvPnRd=UnUuw6G0pyAR9EC_oUV}IX2u}8KdB9Ik zyVMK~riV#|+`wtg&<|+hz*+T{TFQ$62iv~BJ`hD`*1Ns~RNB?OIT{zCT{6XDKcA)t z76Z9P`r`00tP&Fg!d(3@XLkW2=oFb^?=!!5q-PwwPR$;38- zC-S6jWCbruil?^ybDNsFQ~}U*Kk{C>rbOLlHj##2+86fH<{6AmA}^0av%SpZSo|1B zZ28VhF##`vCLnN-3EJ_On1px|0GRwiW&dU%t&`XZNz6ctLs87k7rY)by^9Up0?k%vlG-e)O`ik7iAsLt zQ!w%H05FYz)BOr~5Hwp5^BvNPVlL8(Q?J;?Q0Ix=P&Z!!4Eo{5X-H+mQK*dxISX2{)N)GZ4y zpcUg0^CkfHV`t+o1@EoB%+S+U?mp4Q&AeU*zM3fqu*jt4G3tmTk7W?8DK5`1$4>;3 zc~B|5262vK_3o(BBR7xA0mpsf3ZSn1M4Rc-5kK^1B>^Xe4#*?^rUg4Co;|f#m+GLB ziy+6a?8qlF%HF&eLkh@rN&;fCs*YE<<;c>j`|79@Y|G}Boq*ppRpy%-dY?qpUVaZ* zUID0KZZu+Q5wv5zjFQjPq{rmZvsx?Wwlj%h7RgK75YKtk=`}QU`^gB5zB0wK6v>R$Ruo}h z^Voss-Y1igK9|Iej~c7Aj2*Cit`j@`#?D~%XC@0bU@Nqx`)n2ZoM$LH7m(GfCIZNN z`uEgSl0nm&_`NI5{5(S*!(%4<4F8o!5W2>DN$=iumBTE5RY&KmGfJ|YNBPchu;~l& zR|0>+$(E$TMMaia#chfKTb$r2U?<7OCKmwW&wax@Mo=NrEOpgxX?7;MK=H*H$RLZ8MBT zNs&|EYH=xY{RY8>!;l6H#R5i^RYX_$`DaWH8CjOj>IJ8NC~so@~o3EFEwC zR8)vRXa`_}qz12DFBGkq=u|SV&!EL9$D)KeDyOli8v-BC`4N_Z51$Vd+yFi8Ng7>a zS%~GW*Mx9?B@}DQ+&^Mf-!js|ownB7A{5kNK{~>m$D_mgTd}aTz%F5?R_hyfF9ZtX4YTjE%;3MQrl8UK6>{}l8 z6O~;F45m^VFlE8#ej3e4%B zV3}lRKayfT61i3seCOC&4se|t$(Qj`s_s_av#LGtm|be1k?D?^?wc#UUb5*Zk+fKp zW=RqKOlX*C_)-%}-lZr&_#=np?n)D<7vEbyfu4W?zjKBHN6Ts+*f?4NN_6V=H@)}9 zzw8Jdr(ehArD0)B)MD|XNzSZw zHBh+oVsof!93#cY>XG|%yVT37utP|K8Ox!nkhT#RF|Et%NSWVSD%DM6H-e&QKpiE= z6b5mEWDgfJ;}8Uc@@i7x_d_;`@58!{^i9pMqY?P6eNk*LJ35Na48%Z{uaP=-zhP3+ zhYl}!huVlQU|gs4eQo#55t80e{%dwy`fP_7W&sW5y!5-VMSIM5>Sh} zNckeaIYRT>&3z-0_#V8Y!;prF#+wR%#dGn*}1Y!FYg z*{EX%Dzbu|c&Oegdg**m6AUR=*vs|IZ8|b<8*?0C|8(dIUvIzfBKueQI?<89Ix>@>Z1muNX?;wRn}SFxl}{UHFNQXSmAEt0F%mD zoHeQdTfDf^G^o-{KRp-tfK`d9iL-Cnyk`DXOkylkAU=s_!ock6>DGa31qwaQczFr2 z?q&^)+se2+0|Vp-HtRmimb=`&2qKkW4o>}WI7<& zO7V&XT}Knf^JT^dzNauz*$v zCi#+L{h2Xr)oeUgZ3OQ-0ebnViXsE;p-G5I;<6PfEy zhlf2mSe$_P*&po~sSQcm6fhD4u2vs!^j{;dN`0D_!?=v-sK&m^a%tqBY%*>;7TyKl zM)k;$!1J<}(tH*60DmHu5}=m?y3q+Fe}a2BijzlUl7;=?prV%xceO;5)j6-Zti|de z7H&0pAK2eQAMsR7nU@kq^r&2O%+uytsc|-!qU{JUKIhN(){xI6rf|h1<{yDK)nQ7p z0RtBRIgWUJ_SBNQtOiRfGM~y}LZo4rzx!nfG9!Sl3^ki8db5dl_{nw9Vaw{eGKhK| zA2dO3OTAR;+K{i(H|;<3EKS_3(JMw{q!DLclzNhvw^9{YN1tk&FO)4Vt+enR+i)ca zi1)uU!oG?rsyuY!!U&kON=&~aan9#&Y8L@{q#a6BB6AP3r>N&qOU|c~? z=rUbu`*m;A!Fk7qo+iLd_6cfUiLGtF(6m*h+VqtsOH=5}bl4u35bx41YV48KdR+Sy zQL6IR;}H5Qoxl8mwYtEb;Ru8M6411i$u3>W(bCKgHR{b2U+7mWvVW?B2xG+yT>Wk7 zX#M`|FXRNg3e{e@adcV7(*6;cM>&mznr3D5DU_^U{V!P0pE>Hcf)|04waUk->j>}_;;tiPT9k71+(RBl#MS9wlK2#<3RCPzCwDp23PQV(1n!}JbUQ~ zYOhBKCZ7}JxC)$O^(}tgkmfn4RDl){a>VS{X*(Ofum{#bpIDS+dxqo3jEp|-&B%e1 zkt#u4Qp5W94{WdR$dl4}ckx#&5?H|~UGDm2-GYGjbTF zT@C~~IjgIWskvs=uK%UpOCAyD{ZtnlX<_<@F=hL`irTMr7^HI)#eV&@=g5D5n15ZD z>;E}~2t4r1REzE@RCis-LFdZ1Jd`Jzc)v%Ks&6L!h8MLhYfp?=d=j~uOOTSt5)|#M z(;2+oK2)@zS2u#f{Js;}D?RXEv52x_$yVdz+k1c?xKL|=U{xk4=dAaLLw$M-AsxO4 zJ%C^44(PT2^Cgx4-nhqp0i9q?E;FH!rk+yN*dR*slnWH01uZp53lz$cV?u2)YCc7V z5^;JR_()Nn{I+Zv7y*a%`=EO+{R8g&AGhiMel?;VM{%DV?O&@o9ra8gHD>4c))4>o zRQ-$qP!R{mOwAeL|N9$Ee!vFlwQu;hN(#O-IbEmh_jaVv!zM`|q~Evo^{o!0F@%Fi z_f9;Q2hv?Ewd_nm)bI1yL!;Ey&$H}z-2D9{c51pei$YTRTXCgwuqiQl`xk#!d7#P2 zjm&|!mwZ4A9$JF2BBh_oi|kfV4Gk`?h(vRigNKO{zwcT^P-j_lG51l?nElZ?D$xfc z-gN$2i(%mO(J#L*@aKR10>Y&W3)$%eo8(3Rvq#)7{`LW7;I?u{+i^avKN~Iks7!VD zyh;Pv(7XBR{g2cHr(XXVWgPdf%_@f>IDm2jTI?hK*Pr;$e{KOOwg2_U0^W%U7vq0z zf+6+rf9!hh7NA0!z_?}WI837Wn+dYb+Y-fug>UEo`f6~#(BI`N?<-f{xAax6s${Jc zFJS*;iTm#p{9pH9P+w4RvZ5)nNY!P#c3bGsfZ9+QD}VB+HF+ccTb_sJlJf>ObZFxm^4Id$}I| zLvi`{Xpm)L-90)W(k^dE{D_*7>s`~=)8jpgp6GPd)2m=pX)IQ_W@N~lbRPcW_bphE zmY7t5i@DbK&u^Joc=)ntbXEVbkFlA_)sJ0|qgEErPBv8b{WJbDkU>M>*YW&&&;R?A zmt<~Tc5{~_@w%G3BxtHuAPo~ha&Vu$V03?lVw!9Xjz%gRFen-&fW=RN$bep(7cW^?R_9x(OVSg&x|e_a6Jn|NGd2 zXYg-A;OJRXJ;J!pNh^v~X+V?xtFq)HuzmlWdfknK3+It;23&sb1PjUa-}l)}o*tl! z{Q8GU1=#mtE-d+{k?MXcN>F>HLYkf6>+Y&84%q`qe&Jd8tb*Sp)w0OQ7=uABFHJPjeb;!5($K_0(<{ttZNrGWXauuH%5h{V7M zr|bG>*ZUf8H@+_FT#00x6yD*m$Zhja{=>$qJu#yeMdc$Wdq|{yUc2!y-YW1=^6x!B zTM;UB6Iu7XvyGq$jO`kQU0M6@0UW%hR{K2K?#jv(qr!sFxqt230{-&<_eehuDq-E{ z(Ek5gC2pSCZsGW^GlO>F5plh3B9QOy(ib&uhKz4b2rgPd#B95IV6}LOE_v}`U_sb_ zkMfu#XV=i#|8DKuku00o|NeK}l?O5K|882f>zLgCJPnXI{qL2pW!bP<5ytUSw8XLN z`nJWi1K~w%d>>os zl!bSv!C}4bhke~j`yV&L*}|aCcbiz4qUejZSzl!#aTaQDNx<0GKh8@ru{Kc70&qCK zp4&(<`3HI|ZQv1B-NFHR8^boZP>0*pABPUWEAb>_{k#?a+vQ{ z?9u=F0UmtHUl$br*PZ>Xq3Zvm>^-BJ+@h{g6#)xPQ2~J?{^C1oTd-t=svdD2>19kp#6d9SPDYnpsG%9 z`N*~-1W&Yz6W%1!$y_r81(F16lB z8;K(3tK=4z(f!3g>wAmFNsHfPZo2F7e<+G|Shp?iX$A~gn7L9w)YkB~O&UgWd;Ea#?k`6T zAFiO%wJql{wuChy|0uZFf7Vp$p_2df2Yvr9&1}0xMB#`Vg6j=3n}4vJ^FsZfr(Kfz zPyn+5Cc&;r9NUNbS1Jp?W!Cyudek)H=#Xhi5^R)i!Iu@izmwC5@cIw*QTWKiHj{wi zi(dpLF32i4SXNAkC9Or;R@bV>&EPAmJ8jX`_gzg#w7$vyU(%P_(0?M>d zXUtc=$9wwc^KH*W{I7~;f|;yCVw|)-46X!WoB~CEV1oaK*^U20p-akp37@)lHfI=X zZ7ScQ4s$IS{ZQ!G4Una2ymQze?40*4{d{Wr`Kn|q?<1(m%QOpGz5J>)nwZp>$Tu8m zFO|Mpjk>n}8J7m$DMRS>%#DM?`6Lev?@5!yle+LsHX6tsIp5|FUh9>{$vz^18?}FE z);t>)XbU)cby+$bG9l%jVY;3B&e*9*RGC+9a_;*!|EK7sO~F5r7(%W}_LKYN{~DAk z>I6qPnoHdMz`H#Iv0SRv^X)v+OAS!ED|I~3e%#%(vOQ|=J-E7 zGHFyO{vE&Gr9i&u9QAnr>7S0nd}wze%|AXe7q{crjZ!vQWW3svV?!1mF}mfz{RgSp zRQEKEnY2?)PsH5+X3_tzEP(eyT}=4i$^vnux}|eZDRlwsOYv>zC0*M_ycDV6!}Fuk z`af9Z#7dHmHcg@TB-*B_rT;te5+{$*>|c7zhViVgcf4~b zHFoh`MhJ7(U< zh111N0NnRS0JAx{RjEol+yHd{OneI zSJkz)CBwpf#&beyauIyG`HzQ$>{|i1M;)^`!TFY&U%zQ2S*~u-TL-=J71t5Eq>!rk zpDo%RPHVC*VDWcGqXt^AH07u(ujHZPK_Hh17TbVUkNyw0}P>i@r0# z>H(l`@){*a{)FSPRz+^6ExdmFSWSM1S$Lhdba)Jr^1j_oK2}0 zD8;v$6zj9o^=CeD*?VQ$Fz~f_YA0%R&h#dw;@9<{%7-3BX@m8lF#{`qg4_Cft}^|M znJizdlmTgLWQOu`QJ1pTAGnQr-k@ldgfwF8mBCaRh5nUNJb3QbH&ba!zN4n@>3xd{ zF<~o0{1DNc@(~ICE7y>^&nijUpq$7RAwA>wSgvILjm+uAzp;Pac8+=u1_7uJKDRgGOiz|?z6&N?Y0?oC2bbKfm`5y?P+^kARC7>z( zc7sQz34+w)61F0w4)00f6L_6`@`FEprm@i@>(+0z@{gU`8EyAh7)#hcOSV66HTT+} zA+d-3rI5n6Qq1FJ*4OWSkvX{2SZUN~yE}8uG~iIIYioesVLxx1VO@IfuLRDTch!X= zcaE+{p79hF6zy#(nAcUmB(%n50|pnpeUa)d`=T@LTxmv;L56?oi8) zxc0YKWyQR1v$<8PZ0){?TI~0)L0#RlFtm<;`(Y_iby{rP2_lLMhdNdUGYQYLbgShs zudk2Wd&Z+h9n;9F=IgIS-5=dOTYfJ9_#+9LKm4E$9!ksNsB7@uKc~j_MKE)L?6j+EqFntum!nT1%R9qyz`{RBuT|&*_ z7^_!Fqf48)+j>ohCpDntE+qQBzNzF|jq)Fy7za1(Eu09@SH~nn!#Y!-?+J6vXZ3gO zJeoWAKQQvcQjH`9lbkX*8Y|j5X18mhA3t$m8Zg57g_$QWw&4HWlEHNX+q@La1KvkR zevsTnC{9!49Sq9Sod_~+$FAv&1zZme09Z0?m;FI*rNvSX*Lfd5X(X+SJhVu3rr;LG z=y=WU(@x}+G0C6z-G&o&uT{jOF3h0~**q~{rT>^t8e;CP3y>2C`-ZvXa6mHFJ?%7hIP91Sk#(mtZ z`uUo@4=M2l4a1|IERJTGLd&hrR)E$mMounACQ{2SD{?N%R{Ct)ZvQl@{aJaov`4wi zDjWOpU%P3}$Sq8?PgI#_0Er>ei~Rgujdr?~-S1hT&~S$oZ+U}WN*|02iEuWGyRK>C zd(Krta`)WfMwq(n-TeOy8O7g79ipl(drnZ3j&b^qbc*i9`5FCG7dCf7C3kTI)6^1yTv>iP-X9j{M0bG)uw%BZ>Bv!JZ7qnwm;wkt&%NVip%BUf-xOiwP3 zsseZg8uY$r|2K3;xy`#EpvhM_a~h7!y%KHRcpN?wwNl*TyKRA5rqqi9crw&XMPI2_b|U#U+R8>%0)}eNWk$(nxoQh zZvTF6G0V-HmcM29Dtp0o3#N5h2eSevvx~J;5YW-jJZ?`+%!GGG5b)vV^*# zLA{WQFM3^b^qOA_{5|F_7;GyIvMF%^^PZTdPdwcAi$K73GL+k>Or_ff%JSyV%E@*w z`SmRQ$k^nh_t6LwlRq9%s68kVAPf6P?7f&qj_K_O>0NRdiO((NwGxpn3g{vz#T+Lf z4iXm6CP-<81~%-*{Sv|-G%x0|RL2P$3fKa(k;_K2NiH{O;_qwN!y1VhU zllb5SdFSt(^jjA9HztAH?-*A{dtsho;6wzu0^uM(CnBG5ut9&ivFK1wa!@90)ht2l z?6z~$4s)&64!8J;Tl}1&G|8Z1+}U<4>*L2XfLd75o^MfsyV6Rxd(M79ojt^4nj#&$dDB zNd#t}VU@>Z+;=qGSG@dMydMdi85|_xKq6pUB8hmiPIO8=JzA1bINeq#k~v%7o)9R~ zE4O9b)Gjex84Bts^67SPAODxim9Tu5de5KY(=mrXiw=Ln4SE&X`s19}kH?CRO}5R? zK5hD@rEFK8A%NYo_hn%F6SUKWJ&cSFF!jH%u~&1DRPUp)b|Lh>EvM3HIygugGF3Nm zGz>X5G+i>S`DGe3XfUl?$wRX=mB?6#Pe`JkL`uMEx1J_>v!QQ#tVhnRO`WVMw>79W zb=3%*ZV8OoHJ!a*JrX@LIm15588p1aS~tV$jtMkkK}<+HKRq6@Jbt-7b$UE?c;x$$ zc+^95gv~I)(hn9aPZk#K0!VgAdgL-a2iUwiOl4%IuL+phqwYVb4w9Y6zz7)kr+f_& zWyF0ru}{!@ayxZo_@i{sjL-y|_BGGxu9Phe$Mr$CVv6QUujT2mS07Ff(oRQfHi89e z45_95Ukv$t3nZIsUwBV{PNbZn)qgiva!AgqJ9nwVjTm!u_BI7Qi-AWo94A&`a(o*} znME%H{r&IKTZoYU!PV`M-X+V^UCSg1XsN`&E}JACw&xXKM4SkHl9C&6p0U-wr)kbb z;pP$3MquSUzLJJ4(+X8?72KBdF*v9rlIs>1yOXTCX-@x9BCCSmilMZGJH$O0aWABN zxADp0Kk~E4aWccPv-w(b><;804wBo$sUaPu?|i4pu^MsIh8To3bMF3Q^(uJE{ZE96LRJCj>ZbKqndaiWpaeW8m=#k5DJc0eXqg#MszN2~tD)al*>$kqeS z-J|^m`lE)$*djk0V;LG-zuAvr;Ox5DwS6?>&QQ8>>e3RlCS!x@C$}mN)?5`m;R+ z$2G>SvBc$zg}Cv*u(k&QT9 ztN`xloy_Yc1{~%D*vrpy$t#VFTs)j%@$>^xYjss=jbJ2Mr;aD4%-p0mRdBo6&@=xERJH&y&h|Yv`HalKE0!`JYMnP) z2kj)E3wiuIbI~@m!O5}3$!Xp2c`s05Kk(7n+VgpzoDVs~X*cJ_$0^fk8go%gH+bbL zTT)`u&Nx~hCgOT|qlJs@tYcjB)>#!BWQYqf^-yXfkxzS&Wj3n&sYf=vZsEPW0h((k zu=ICl)g6F9HRDrwq;d}&*%Ni+2J%h9n!9=y$SKcJe2z9=-t%g4x*+rBiFAz}eW37^ zcfwEKm(zKVoM&fVZSJIayS!;vc9a*_KSw}mv4Dmqor0TPx-|LeWNy&}d^65Wi) zcNheSExKUGyyck0S0{pu-Z=UG5Vxo%q5I~Dt9(<2<$?3QDmv4-tpw0ez z=T06P6nJZ@sG3T?HiGHFYz8Aw!uOA_Ugqky1=j5McfQ<=$&pp%AReJ0WYpqW%Qn|A z^IP7rR``)RQu$eu*BuKzhd{o6|Rdbr){$OR7RM(wtL6lnN90Qk?A;=RDC zf0N}(nFa$l%dPvC`tIwTbf6RI0=M&Z7xy2#ZfKk}A|aIxSxyA+U2STlvdiclrtq!@ zu))s3IAm)NdQXpftKhGql{r{7eFifCLp}Xk_=_7nJ}JO=0EWHbEN_#iN%X&g3+LD| zlgws3Mm1C=U){@#UrO$13qt9MBWIa-%F++|O)<-Dv2KyHgRVw(!I?p2z#b)IE46+N6q{X;T;KW%U0NM%&dZ*b4P5uGWs-mJac^?&=38$B zhwaXSo=)oRGv9JmCN%7n#Mkn+h#%e-Y4Pu)_K%WRrB0)tl)E&!Yd%%(Gd>5%nTZ+q z3LXK?m>-|$p4RV&*T2R4H8G}slYdZD+Ip76QFW6ph%7p?gXimtZrtWxh`A1qoVSw!{U$?hyaYAEQK@L>Du2#73$SeVA>@<<>DIE?`obWCKps;W5MEFWSdRGS zi&Kg>bD{nn@)U*u*LgN!lH+bU4cyONQ4Ll>^ai}=*EiF)lb7D^|C1o%qa`WR4!)-4 z9>03(wK13@Uw&|=%`P5l@@I7#jdB3CrB>O1R9*7+S?06I4V&d2v=fQH??)+x9d<~$1R&tG4i5*Tw-}r$SxUDZ`dj${^cbVcXIc4Wi#h~O=mO) zZpF%od-1)wHCW>L)|!(<{OYubdWsAwDjR>-Scx|+ras0)r{2+G^OKR7!dCMsWV`-! zWJldrXn$H2cCwvT)Y=d#Y_Z?OBwl}*SO3$Otyv#l>t+r9pV{Il>IXGBH8Pc~XuSH7 zrQTclvu^H%J}q2;LivYold9_G{9#ket^;O{#MlMej{-6zzuLJP93c?cdt{m-Ym63f z&~I<5tc8#ExuoIp~do27*+bE8y~*w8^$V`{}_H^VT$;`6P&atuL+fV z6>H}E{cPw(d9kUGAa;T-^w-=^v1t;y?!PHykG^Z-+Q4W!UKXytJo z|9|6{(j}=lb@|n6|C|`Kcs80TKCGFg=DV}VL!}68T^-dKx}wAzZ2oeh-M{*~pa0P8_TQ8H;j_c4@bdtB zy7@fO6Fx1}U){(_wY2q^E+Fg0p=Y0aZ90dj}@n$$Qle%-4-hCQ|t4f_)K{tU?`V=isgO?A{1ACMr`ZiX zNh1!+E(#%r_ZH2~uw3OQ%ms3A2gHEW7q2zW!bps@WXc2+NA<33UFss*!)?~;vMk{3 zWELBAyfRueC%+r z7&wg){m%uQT7gAVb)!kOxgDVjo67FllV`ed*d590yx6!o>_yAxsv^w5koeg{@WAEfo42c-318jx`n|r;pH5d?rK_s#>A*?Ef3X$W z;pOYWR;R;j**lte9)d{0<)_FEIfe#~vN~>PKDmX_Xz0sQGc#2bsUDZ>)X6XU1fDCycPJ+sb3J$?=R}>l zHl?T+9(#^#hZW&UCZ=q?$_J^_J$sbW;+*n2#zWM~7!Q}~E4+CmB-E*M%k30islN@`@OIZWEva~kO zZu$C-MsW|(6Fn{)*Vm34kJEXkEYDPEuU--Om{lk(3D2-zM-O>sZw5TPQl(7EBUlCOa8ouxNR1JS|6(r&_{lD ztw@ELf=*QioeD=<{FiS0ke}PATOr;{y6p+zStEz8-p;dX0p|hF4TT~Z8l|m$74BNV zeAz!j=H|6oPnUiTk#xq^%LHcp64)D;>ti~=>fjhb3i=h} zj`YPXxHL&L|6uxJ%XQs4NgzG-1HDkQNte(!?mLYuIR~P;SKw4WI^ROyzhnL3e(za$ zx{C4DkYD;Vw$%OC+w68{wCYiCY1#UrfnWWYG%B`U$lCII&-=rUSI5sFGW(#vf6B-#3f+EY??hNyY2etmsKa_LXl+JILbW@yp+?VsorBZvSBNh}$Mh-9-tc zqujK-O0r8MqgTGwnWTvI4YOUrWreQHGKu;|1o1)V*u2en3I=nO{dHDXBqIg0)1e@m zZ`!VO>Qa1<$;EXJlD!Cz%G6wJpX&%AhNyLoHBj>>Tp?_*yplVpR9prqsuO*-_`@EH zAhz|l#=(Mh2DG)fR_!tQ`e_Rl($3A14sv-4%LA+Y{h&N+kvs|8z>rD9$!v2YlL@J1 zqaRwQoUie|DJHyuHOr98G0y(aO~>e`G2Wog+l-AK<$O8sH-lh0zfcaJh+ZCo!@uK} zln?q4;&g03mlz%rFZAgaLJ~1sqmVby_ChPo<<(H%8vYi&^2+Gdfh@;P!7tV_(5mAQ zWOn^IWG?Iy+519RDPU9!dtV(3$&az@yn}JAFsDZ_zLWi~M|eS9poqZ7(x^Ju1Hq7& z)DA%I$mOvH7hB%B!&_h%``OKWgleo^mNsBvcmyqIufgpJU z41bSMu6)jYkIZrQeb4ZoOq(CzXh}myGas@fe>N(%!sMn1^Mr(G-pCcIR^g5})#M1- zOS7^kvlm2mHlEX%V*2d}3#GgLuFH^Z|HNmJG@HeCTp#r|ETZZZqxp2fOT0@n$;wYVv@rtM3)KTpBXxtPoXdY@o4J z5i4Adm;7ylf$&a)@o{KR_4#q;yT3{d6wPh->wp4fL>;e$fr=4_n{i;*+r@nAhQnoC zk~&Zqw(LPPtGhD~U7tg*t0&tMHfcDOK;|4&g)5|>#!rkFagT~PCv=6nPnMrO63|Z7 z6rp!eJiI5a-~9=$9=#B%@Ffd1S_EQbSw3m zwZWa+bVMVbv*P8Z-Ecr!PL7R%n$u_J?YGk==e3p*?{+b*%&U(H5Y=a`CK7yVAmJ;l zfzOwIFuDgt(}52>wKh*O{hzRPy#DAN{YJr5Mv>xj=+&+xX3$|Qk7;_QgB17MqJ|6_dDhxF7AcMjB`Ro5 z`?IF2=LEQzEf;0e#w!Hv4Uj`fTNO6U01@Sw-D0plJ^*A{NQ4-yI7zVTnX_xHAuz4u z-oqXQ_7U_-a-AHO4ylKKePQ`6jbAnUOgWw})adMqFryMM2 zhGtVS`4GF^lS6f#Cobj6YRUO4=3I@QANe~)!+Z-k4ELmC)&iu59f|A9Ns%8xyhWHq zgqVPxx)(tQ$vLKWV)Autl84$7+zDqv0%R;ChtV}5Wd)+NV4s68@{@)ss*YbLqb5Ay zwQOZCi|i(mK804+nGZM5?y@}Kf>^HH02AX_qsVi7;8-KS+V*z$wN6Hb4!@_a!q@C4@sAUrq-_1B6P*7(t?sp>bY9+j3S1B>Dq7K;G>A8a+ z&DA#dT3(bsM_-*ekN-?E(G95Ovur)V%mH-?vGHDKEnp1F=dvOhucCd5pD^#*C(d8- z@gt9-enuW#|1n6Ohu1#KeO>z@^UlQFcA4@ZVM*NcJ6)T+zxEvLp3c=Oz}XZud2F+G zDs{-PpJL%&c~s_Zwtd+hN^m^trf+ysp}yiW{juv-jgww@Xow-?nJ}Aavc74KJ7EZo zV}2$MK79vBL4Bj%!rel@Dz9_G1w7i61a!vD!qx4EHg7l5k~w!SqrL-BI*!^4VPfzx zwNMQ-hteglT}fDkc5`AW(%D|u0VgkSy5(LmVI8p=O@+z0^>Zv2WogcY0o|ONQ7+-VJvcx-90)UtCR$J{+RNs{vv1VL7#PCZ)2h}gsB z{8X7n4-=eh9M7fKyya-=V>B!ufAOqn&2KFJR*=i<*7M9hvfaBnUW;A}z(TPfiqok+@6oi|qtYLaW|MKsTKO?G zyC_9E!UlOWJ!U08-XXlmE|eK_W`7rIuD`V_>=K}2ZjttDC|N#i3sza^WKfNS+k~J3 zGo+O2JWYY`1!t9S)}oqRZ<`<~DJGDkI;Dql@_I5VAe!@NpQp>A>(n;+&b@#KO0xuhx0W%n{_tcS8*M4qW11~BqQf%*Y-i}#&5O@{U$Xdm zAb~*_fR6Rfht|C)^Gm2)x;}6(LZRs%NKDB59`D3EBDlJIZ4vZj05a)YiM{gCdGLUX zb&efMVlM2?46G0-oX3`Wf7X)uJjPWoZqPdxh1zhE_s>a{BGt(ACCLJ0V`sZh+rs-@ zzVSyF!Y|DUm>XB|cA6!1S#_fte)`KaqFCN^*BzXc-*P}ii$yhO@pt;?r3u={NfrPF zatL=c9WMAx*;kDeAL^UN?36yM-vw?;BeVLe_@}?xc80|AT<60oy&%?#GSYxkQ9~p?(y#OXZ{o#v(pbXSDvQ)Dz#CQA%(H zT|+ZJPx)BZt;#3xdGG>0*!TJb?&|Wh9=6*ytH+M0e{v22{ zV2GzrU5tmMC3EKs{N}rw+~bsIn1+gC_4ub?pPHP5{Zdr9TI~$$99E=OtHAiSetznv zG4z2C(Yf$)293F($z!LBHNdT~7RjuzzbC;7N!*6b;(> z_V(f6LyyBcAtXfAI{$|07Y!NDZ?vVkQsDe<*MOzjIZ@$4$OM>OaTMff*%EhUEMhGY zR@kWzp5Nb9t%TumJUE0lIs?BTe5({M4VPox(~7)SkY!@ThDOTq7DWREPz!XDj6Y&! zh}!i|$(K9!Bjk#>T0?DSvC3>4yJn&~C0(wBQC?ZeA#28E${&QP>-mh1Epp6c0*U&aUTlG@D#>2oR1W~s$}T*wuQKkmH%&fZgC3k` zwhD_+(nCVH-xMDAz871&B=~*g&2C)*cG3jgJ|i6TbP#c0Y;B%_zkc0WnSXFfPS<#< z-=AN>!hH{O;zD&tq30qh?z{g}-7D;wYb(Gq=oglLqB~AUH*U<1dZwHWY9+-~@YA7m zd*%kbcB_K2+{$5LOXbCzo1@HKnqw5T;(UA)mX4=75awD7%e!glB*TYsKO|p<6o-?) zS*lH}JInfhtstsC<435QtzrWUHTGaxnTKs2NsD;A{y~H!Sv6j(^U~xj*VF%xreAj&Gfp?Xz_p*>N+^ zk6hs3*ggI>PK}dzLms(0l2;hOQi|6{VB$1AMAQfLV=&caIT#C>xc!qw>3vRJEv0x$ zcBmV9@eD<|>OFaD)!Wl$%?hvXM%3_wrQ^OR6y%lCcfxT1(GjKVCaxF@+3arbyDn9wZ z4fm<5{M76$dn#PVWg`77@@>?m##TxO0sMhE@5g3^D8#UJALo?vr^cQl?jOh}XG{68 z@!MxwIqUww+hG**w%u{G+#N5dgXVnQ2|AQEuBKM3Ar)Cw(Pldl zHr;1pY+mj4)q|#ALEJaRJPAw7z1oFWJuyQh2(SlU%g>Mxi{f_hw=z5U^%6^}%}=p<#Yv;0MDQ+E?Gv10M85VXX-(OIcSKlZ!CGVpavY9e;9d#k;H3T%A;v z7(&|Zdg4C)P#Z(Dp@a5h7C&;bdhr;>#^^*`SH4Ll%!bEqC+SceFa$iypuv83Qs72& zkves?*7kpnvOTfP-1R8io3yG?HLvOQeoBbRJWi%QG(gQT$Y06n`~5NyNmt4~%o$fy z$G3PFsE=p+DS--jS~V9yQ#itT?ABG-i7UByC|YjW`~rhch8`i3Y_-tzO@?X#zljdh zSY@e8?&YIix70imADoJ=HEn0YrSTaf?=MMG7FbA;9cS*ibEhwqmYw*r4tI+#W5bD9 zWhxQzi1jzEJH=9@s8clxZuZqucPSNgk6i9~#NfIh7yXJ5B1ie?ngw)cxFaKZK`}Ys zp;{vxu?W!d$hHuLTVLr!e#@urSSO3ASbD&eAR}@GGGMnWNII+?n;Wi5+7GpKI|4l? zoIg6b>Yr3|YQ{)K5Oo)lt*w{5Ii27@?VaK7cWy<9p6p!3kpt4TWU9T%|Kk$h&K9uIw#)G+a+c^K`)^jILLMVx?@zq74?$y)#gB}B$gI~rdaCM@Fp`(@^o`Wo6{kGZS0I;C>)5A&8u^sC}h%()@4TTcK4e-^|(j+;*NK) zjAuS;^O8FJ83h`F@qvYQ+UCf+qhk3OZjipULDIn;P+iJcxZQCuV>&tX-TLO5AoAYr zYrnTfQ?Jk36K?>Vu8~AoQKY8pii4)S5uSwBpP|W1RjS$&c?@6h%PUV<%U@TU|0-GW z)ZT!_7vI&`E-4{Mk^0oI0t-yK*b{A)#{&~insO$vs|kpG9mhAr53x0r zHc`VPn~&I`m|lBEvQK>YqQDRlvjTDlhi_R>qcZq(crE=+BRC5bzI!P7lG(>Tw2<^L zb?@DBdY&aBnvvK9*^ZbDp^0g26Dl^vr=L2-^)VJzwEoqV*ysJysceCUJJbmms2tzC z@&5p%2mTJp7^UGDY4DX%)|>6W-|sHhwG+PkW+L7KqP^#Q8)Escm1~Td1LS8sS+n~H zDPR~S)Du=3$i`-02Tx(L>KZI{=9f0mEBT?U>%_1&ce}slh0Uf0<~`d=zoGMRdaf2N zx5rAnPc2|R)*A%%0v$$BX6-Otac2Ow)$`ZEDbm)S%_)LCfByheHp>jxjh1N@HXFZ@ zJFXi_z!_TmnRXW2LLYm9Ri>g~N8%J-d!D90`3BwgGi@<3Y%Pkdjsl%_5{y}-r@KYX zV{f1p{&)kz&MQ;55DKwS}C?|#Z z^i-_jUb4N*1DI}brjNaGiVH`hZ~Ts0eqbt;d=QFLJ8`7B+1|f9mTUIF40w|TMpI%~ zjN%Sk^mR+LSTeR4(j9PSw*l>%=$BcmNAFMtuw?;FCnX^2CylSaCz|bQvU;Xq)8}re zChiGh=CnYu-smJtX7m9=8VdId{MDp`aHA;2jW8>-sFxcV&N*+W7@*}bj8LxNxzu=( z^|4gywc21 zaHrYj8ReRlaA*ICihP^2mO$+pfS#DGcpNw0?w>H8l&f3=R(7h|UR+Fgqq;N6>+;5mM6MYjYQ};c^r?;p?8t%so7&V=ykO_9bXHp_^@0 zocpFAbWAFb)$UFa0h1~k_!}s^J>{O_Zm9Q&WX!)c>MK!?0X6F0mS#-xJ@z$!avN^r zQ68-xF9E4FAuOj9kGeL*=K_1<`5V|E#_EYn=<^Khz6rlp+23)@xkt^}-Lu3fTL+2*1P&{0BeU6ioOeOswl3 z;?~@g3;7(0C+{mK0kh_D?2jE>QgO7q`<_2>wNrp$Bt3k>uniGdW+uz=%wI>wf+r=3 z#4pB*mC}Y?VJa4MF5|?NL+RI*Fq?El7Gd zuVUe7BB?Ye6$X4V=|>ZJ?$;xm5dKyN2}45zoltjeuf{IKI_!)M%+MLN15q zwFDBA4t$2dtS4*azT)YrN^MSXVMnvZEx=6RW{Bdt#t5Yy%;9i)E&qko5b60;lRc}T zQsJw@qRI4bSWl^e6ic^JiKMH`OzuG`xJ{zChs(H>gdV05sQmfRr5+w(ECo=P@wIeB z-EKk=HInZg?5Cnro`>EXtSb+2kAGWR_bv!^BY#fPYd;kf_SF~Qlo?95_8w1xscXYNr7zR9kYw6h%(ixq?lXpOr=OPnwD!y2D0k6*d_8_8`ok24pKKfGz?MzCT?hP9>!>H`HV?l!~acDm=X zhoE6%>`;H1)VOk-DPS3q(nlZn*>{Seo*-@>P#xUx>U)%^LlFJv$>*e{N zypnNZ7|oo59vx_WqOK=hG)H;5xwi_T4RYtACZaLNX6?9uek@eZ?CjG*ZYD~_99gjb ztGuO>g~8FzDiSoc%2pMng?46Y=^f?X77~qf{d%}dX%YzY9}_ZHYJE|Ffhmq$-?}^2 zZ!H(mzNsv-bEgQ(TMINDt?PTi?{c39&hEEK%fl{>xm0h+o|m-b(SDiKzp)Ri)-fP+ zMJ)dW7TsV;IH9(wyp|``9W)k(JOLF4?kZ>U#Swao6$;zAiOoNhGxI;G7+trWA=j4s z$L^5Y4Y}j_kB=@>j9e~;m7sWCAH5MUR%Tl z>L>+$xEZdIF;F*JH^w9SaTCb8c8_fG0ThkDHmoi~I!99Fox#eiakaj7mgTU9cOyWM zD^}SzYzd96axXt8;9Zk}z3ftR@08pK+!-%Dqw0Y4@CR)4JyS?K5qkA+qvHo)X|+uc znf0nb{<9C8X6j1P_jLA(%YnGD0p3CmIKFB*6Tfx#T|UHt@hFBJdMBlF$pzqv`BDJ< zc;q}g`lQ%H4^t(DcP9b1G;RZa>EH+4JqVm>ydJEU*kAn@KV+NOF$w9)ymieSY>2dt z-B_33vC_e$qR`hJP?SCOkRPRfPm0i?-W&p*@Yr(Wkx{k_>T@Jdmd<2wFyjxo^^z+y z_k6?3_lQDmuQ-FM_BFe|tTl0ciBoDr|Ms18)j7n`;)_GIo{s1axXAd*Aq%{pU%J3aUX%WKSqpA-;Qc@!*(n zPX)ew=4UEj=5GB6ZVoz+nY8kkE7F3GiVxa$UK2l4jy0}`F_JGGVMHj?0gQ_nvXlMM zxt6qE`lSD<{kKbn6aTne0n*&}h_8a7kI$(cABK&-I`VYF&-?Ik5NaEoOALCTXQaLv9s1M(TAEQ8{y+ErB#OkUdYzRWV* zUhKE{tyz2-XNkz+w7!m=#;ycNYR)n(zQ7f<`mR}7UTy>&c!sg))cZ&`jf&7Z;&O3D zt7W~w(^J3k zurMx`ono_E3woKKf1U8zZ1BGD0CH(ft;Lx0g>@XT*M1Ns%8VBzzhM`~Zdo7t%dk60 zJ2oWx%h2|5P)DDLbF3#J;%!*tg#Ok4FY9f)tatQ@S<9inzlDl;?jK9$OM>cdAEajVj#AZOi{ zgF!YiP^B`B4CN}edu8AA9lm@V_Qk@Puc_f=%{Ur4Q9*Y`XnVRL{XRKR^<)t?gM0$LeC$mZVverlpvPc`ymlX|2g#>?huQt020#4Q|*m)kO1p#4`Q?w>0WDZWyuED zUX9Z$_S;I7?UfT_ZJ3;39rP>OM|0@+XvnwZaGAOEaH%m~Ighpj(kB;_ZyVlDk6dJ+ zX18^J&3gPU3>2re{H1#5_CWK*{SPSZx#_4@80?Uj)Hx9U9LZu48(}c){CFy_n7tpbcNo05aYw zuWEvfqxBEg9yOedV{dc&0^Ie^>@X9t%Lfvr0a^sL5=7#qi7%`Pn{YWnJ4fBp&Rt{% zVkySm|7WUG=kvIjJ6`#LZ>t@&f4<%8|Qm3DVYb4#Fr0X^ph`s;eW$ z)6RVQN}t?Aocld%EwQbfKkoUKq4?s5u-1fz%DyRLtt+p2e%)^b_Jzlj)2Hy-vI6c)sp;eNfS}=^4CHEjZl?w!^vB5k#yxZDyaFIZHrvz3v&sm_10}l_7%gLp};A8(F5KH?M59FZuq7Z*}&L)853ryx8^XSW-sM&QL zlCQnC>0GC6H*S@C{lxXtW?$>ah6feeN_`!i>=c-I(!)X-!n5-oqr3{xBg6W(8%|XD z0L_8$tW6C~)9)W>giJ3jXiS>)Gt4My;QC)X4tO%RZS6aDh&>$rOmiL@ORwf(dUw*M z=Lx|r6^?S@m3Z*HH<^*Tqp{AQgBHC&y{zdUonJogYH}%k8<-5FKYxk$orhFf8>B_R zWbs|%vthhEwb{rabCP=dEo8yYUFErPsXl2^Hi7$x7yRU{Lk~(#<}HLpI@|by5H+u` z%9t(Q`j<9?6nww@S>_Eptm-`ibAL1{^zfyO+2|(qhbEr~W(`kGG@4Kjc7&TarGrXx z2|fylDt2gcw!zxwp%+L(No|OeP@vuk2#NYK`Z{fEHJ-vZX`Sj(FDrxT!mB}K=&^Uc zuro6!3$d{mXA+jpN*3 zX*fkDjqv3uEC2Ymip2{Phj>44dJ<3w*W3)cmR-?;J0F|rzATkjRrx%Tkh3twWbylM zzCMePSA%5w<*%P_{HQYPIpZ=moX*W1+EU5A8ln#2Y;)+5?X=sh?ljfU2%4EzPQ&7q2(D(wfX0+kn3)XD`Z!f+ zvj&Qt&9fU<*V?@6fu&oZq4f~sG~b#HRNQD?C=WUixalkHuDKS*#Sb&^KT>(gp>-W` z^+Ijfdp=Gdi_BLOBmhFzpEn(TAv~a|+I7lZyosD@( z$EaD81)Z=VN6hA$^sSgpR4a;N6%68|c=0G;*aj0a7bFKAFL~gxd+VD;OE!@?5 z@zNK`gA**u?J^Ks#z`rfuIKMwnNE6MEdB8Pu7*1E-4XxX4}5X9oiKkJSpoMZN|9L_ z$(zTArIu5hV7~J^V(M#UF3+j`JNB+&vlLFaG`3xVnqVZA0eZ-+uXTe2-a}uE-mh)k~$%T%VGY83-J7hYBT1wQ}CzgoYhD609;1I^l$LyRte<^l_! z-2MDKTyIkr7U}9GTnlkRe45B30}~p@J_xp8jG}4;zdsi9?5Snxb8(%rl_9O)7<*d( ziFGF?*gJF+N&hOmqkRP5ce=&Q{dEdhzx{mB9j76N~vWMJvWo20JEw zWA&<~()(;a-Zz*!Wk{DU_ghmZ_85)!jiuh8Wbf0?1RNh0fT4u`;SBYBV`;EC?dL+DKvnT|@% z2#W&dCzvu-S;|oFw5981gNQGt{%W^#oojE zDvOd@?=k6^6MXNFO*0JZg7V)nBYZy_DRIHGR#eUD{aYS`O2NuIaX;tEKSC5PLj;`= z_dE#YU=rl}_v3S0cWBz#p&)GMol|DnqLrAjJUsTXPVi7_QR_U-*S%b&4<}&o*mmZ4-}K6-`o&L-i(-OsC?4A~+RvQ?*Ozo`rQsng-<)A$ z_MhX}gB@MJQT5X%_WMe8I(yuV{l)->Jn zT0NI{>|!a34UB%A)rqCW!J!u&dzi6jW9-J{ckDT+cRZql4yrz>@pAO~6iZb$2XV-t z9YMrmOzYcmtl8GuOmqHD0o%KOK&jgj@h;1bm9;p}F^PJ7J58}W%3>yx*iu0y|L zb?mog7f=$Gs^u&$VaO6~oeJm|bk5M2>Ukg6EmQhrwu%mvE^!Rm^${4ePu8zXq@BIJ z->hxZ+rVR$y?qPqT{fGoZ?7ZhIQH<1^%&>){$n7cw~Z^QwC^7F{1B~p1fJKVa}nG+ z_Db7vPFu&|I(E^R>NcJ_2A#BDUN&D(>-7Ad=Xi#$r>{4#LD-)rpt);(mi+*=_Hmon z>Dtk^59C6A^Y;7?X(epG>*G3j-5_1h+gOhC9V3VFzUel?V}>wif%^6;#rtvFw+)tQ zzWy=z6dbqNrtVvNx#qCt_;P-YS#j(QQ0$wY?{eEF8P$2Uz#q&;GsPA_Mj6ii;krAP zPaS*VJR?ayCg*Ku)L{j6ALuytP%k93wIIhFOQLhm>M2Rg1Cc_#xF}{*&C-dtxHP>R zZ%bXY>!1^M$DRaR#}vpfD&x|^F1cgRzyOf6Fk{c0b_iQMuyrewI{fUBUCAFDd${<# z+({eABRk-_kK*=8jhB!i2}{*-HkUADiMCD!^b0y?XiW9IKh>#XwrZVh8F38R^$-|y?5TcIw=+kEF+iD)F^NNnWq3bfk^O~gjADE6k?~{|Zi+JBoX`NmM zGNr9Lhqv2x#QP8FkLMw@x3w)3HD zH$>)CG~P;ch}#9nq?&^X$K?<_h;Ns)KwkJX+bL(>z71Vb4&!!8d0c;azsHobe0Cld z2~MU_XjVVoige8D`(dHJBOH6vZRxg{0>&8}q&^PamRq?V2;0NYGqUg_Nb3ij1nROr zY2Q|I?9Bx_nus+rhnoxHSFAhx! z%V&-{ckF@a+|yNEY94juMaNz%$&5X7@R?(8y%~GuN2pTc<+%N{trZ=6YckD_$Fp{O z%LDzfZ}g9T^}Zo~*|D;w^LK5Q9V4)}%UFC{U02YTagM#K+273GakOJ!iH>9=JUB@&kI!}6AAiMCFK>(xI_ ztG3nizQ1F5Y@aOeuw&2geR0e_S-mb%=i0p@o-WIRW6wTp;z)b?*bvf=A?limTm_wla6z1%Q{cWV@`e1|pIW;98f5_Xd&zvX#X^3~Z&Y)k1 zb&TW0`+YjBEw}AB{f0qZzHME1EUk^$H!P2BYnXZa+gw|CzrFk++#cA6r7f&Zz-F{d zJH2a3*E_8+tPfm69%7m;(N5a_Sa1%Ee^9qN(5~1?oqZDabEGWVb92$xVI`$94fjD^ zN!m4^v6nu23p`wv%A>k{M_;Tf#&so)cRZM;r_6TjVIBW=yIUHzzj}H;yBX6w7yuz2 zVval$s|R^y69&hhl|?x?_AZ;~7*Hk*riI4J+GFHA$E0s7*nEoJv8^D>?$=-$J3_CTV=w+* z9cW*U`ypi8V60Mczzs#ISbBZo^^U2(dR>w@_I!WpI`(k2VoyXwq`ie5d(yi6LKnzT ze~=I9wy)~7REvkPbV>Cd!t_Ci?gpvRfKK9uY3_f=bW7?Y<2%Js)!N>_K$P#>v= zo>TDr=(D$%>zwO?GRKaIy9s;5y+(bUOx;$tFD!woBtOIVQTcZ7w;#La`;14l3D*ed z?tMC}U)*l!oA>XOOw(@|)CHXZ!oJ~5jFmy43_EvFtqyBV{*_LbeZoh7U*B7TzcPNL4Nqu%~FkhhjUw3#DO{#Wn3 zEzR8)i38ZN{DnG4GOcgNv1VIqb3OXzV*%p&c-Y19d;sw{sbGA0Tx9LO-Ob}lDUXNf1nuH+dAxvXq)+C|C?7NWXdw$0!?+;H z#+chs+rsu5+m>;Cj$1XI=jdm)D%F|B^lOIwChCXn5gv(cbNDurzS@45*NJs(r&?QM z@YX_GzMAe+L-_vIssMe!F*>D0J=7eq#bDJjF~5i|gFL8sWPe-6*xl;X-%+~P$aRZ1 z6h=w+ISrwV_p4Or6s(T=Hh?Xrwlk+s0Bw~2UVo7(}l8q>ej&V8E?gS;YRkB^|eu7L~@^H|@!Ux*vi9`fmd zM%PY@>vVVw=GfOoC)iKPY>>P@!tgpFo)_0!vMAa^6NJ=Wh?6m-&Z|-x!A?T*KcwTB zu6c1qJ^TlRBKq;G@;+WWSLn4n{^!?l3i=?Dmqpb6wggPqNWPw-2DEhB-O`FH3JUo# zb*<||v|E#|BhG$?^LTvhPbrTt`*FHx*FhfMyzc(!*t3lk4dvlIX4C1qu4=re*&u)u z4~{o?!gU2$I9Uc~Uz^9nqC`0!_20I*4$?ldp=9X920LfcQP3}U0urzDc;@}ZKV^-s zIjh+5idegE_4W{t@#_aztoup9%lP<4o;+W7>|X_U^Eg?PtFt58^)ZMg#+8r36r{y` zHu8Ktjt9r@e%*&~ovhUe>T$j81RBBl1Z8pxI`=N}R-(ON?^7EN+Qr-qBbYwCP{7B5 zH|XJlW8g@~-rAgge?$EkS9OO#9Ut$h?aA77$9PWM7v^mv-^QojzH{}Pi#w%{ZPV(g zZvz}>rnZCpkg1@JdOO48~AJ)1*f{v-c3D=L;h%BxqTD%Z% z;%VijF4}dFhi5zXe2Et=+H5)-M3g&ar*h*hl8-H~G7{JBLlOosXlbHkZ6puQ#-M-@fCpuHnaJEZ(P6 z<93^_-&s5Ma^x6>oDT%Y-V}~??9DxHp&X<<^A4gBSzK4TWuh*nIz8;m7}kZ7=W{gw z&08-M+sH-@<2f~yhuyKK;SfL?#2tISUYfO+=EthtSatKLgAa+Kl;ug&wFc1x?}R}Z zAb<{u;>a)mtb@`ioZPa`qMnlz4iMAY3XdIYU*@q&-vVp4wKi*at!_)i+gLWiy6rP2 zz{d-A+&rJBT^)DxI9Zdcvm@HYI0`3*>KJsfd^+-Q5i(J2>*vHm>e$1(z*0Q`>T$i} zNzrPT-=bqLEg!g+j=gfcYM!0*TBJ<5)LeWVczYhEWA8AqRXAo(o}rQMf7jcFYah4r zi`%Os?hDhmuW#Em_7T)CGF8U`%8zgf8^6X~V%%CC_3e%0Ou3!wGe(vGeGJEulyrNx zH}^}}z02KY%PzfhSZOTjPt68CS)-O-BD6DQe{7Ezka3vGp{+^!VH~Z2+0Bk6CH=5} zhhT?OIrf0-V!GLKhCSNtH3ex~#>6U)J+--9!&$^QBr`ssYHdv_*)r}}0-ePUSM1nJ z!%;L^bhGQyAbC|o+o`27jZ7> zOgxvkj=UylGtU#t#`3zZ@7~j(fB^Kxz{mdwu48XaR*+WIX}Sr1^JDKQq@UAz>U#K05bOgG$K#+*h*!NN zUXQmq-iL%E9DAqQZg#Hd6Ha|)CfoK9J`TJ=57V)Cm}sl%TgFZGRa`Tih7x0W zI4*UOy|?4Fn{4T%JdMLR>dq(=0^@}W}-O2SN?ho^}@u``s z-!SP7Mi;*i@OC5hGBvD@p<>q7a{aoVcptNK)M}2s5V#A8W73!X>i|tD8o_Kn{rr!* zT*iVnT*lGfBTsJ2uRGQ_fBSVrsU2KLgkXpBcI^3h(JY;DCE-bqJ(LyQC9cF{q&;i6*?xWQ7x#W-E) zI(9(ZY#U~4J*S*{rT;7(4``D#suS1{>m$A%k z>-y2(Zs{0AnhQwnba?yNA-R%A+MQ-W4k_+Yi#MuS0owTjHi7B>U(-z*wn);sykmT&U4%z7NNf3L-$ZN;+X{Tq%wIDMw}Pby1`oYb*b&+o@aZQc8BE85i5?Kb;( zdj*cYaqXcExxNgKuD1O<*iWR#{r;HokFqWyqO-nH+r@kq*Uf|3z&_KrlqQC?;4S}MQv)=ruK*xd*v-fOYz#9s=as2P_)t_)QpI& zV$Tqp|C4@yegF68V+7Ch-1oW9xz2U2bH|5Q>IxKOjAWNCU7}D@l+(I&=_>Kkr7Mm% zuLD2%hP$T>e7OwMQh0HxqMvyMcyZPCx!Utfm#SmPPc5ziuWz|38o(}HqU<7lTt>MR zSzWqxk*_58T*t?Bed?Myv+g`w$$W6snoKhMqJP)X~!$S2$o?!I1UqA{;r_L^DmQ7H2^S6X6w08kzPA)aP*= zZS1^*R(q|ST;B75;L777+(+FmSuqpMMmJMAz{6nLf>PR%FxBZ2a9r}b6-Il||^*-}6QM)Mqd^KU;+ z6;KdUZptQ<{9X8udF9JsjQd=QzuLk0-4NtQOX~yhkw+N4qFM|sO2XGa)b+CN!#V9n zco}MK(G-*1R^bzUVR&9|*jRRAd-20PdP6kTXoyg1HazHWvB87!@MAguQ3Hu8 zPa1a>pt66(9uH+y{5Wj0Nsop|?$|#Dh-~ zKc_3$1MtatlO;_ucWW;Pj<|l$u^L&>85kzK6BImP9KBZYMMX;v7&6ah@HM3^TtK+itc7O-Y_hL`TDOmA5pEeVjI! zin>`M8~1F>Y-*qb&eb=U8k>H2fj^K8Dk3U+W@)0Q+N{c$d?679x2ffgjIlMs|28U1 z;U{zcQ=9~uSn%8Wsb(*w^%%#3Ghb}?Tmhjxn_*pWroLS7-bT1T+76OyEqc$1on<|A zpps1WGsd|Fm>>C!>>T~eI~T3L<~s}1uKDkHuKKV>@*yE^P0{62_uN(nUZbsWSyOL# z5&9r9q`F+*=AJLDeX4`IFh3hQTn49g(}Xjs7jk;uiJTqy27v!JyI&N< zP6*@p>$^@@uQ;}No~^MA7b`Fl46 z($u)k#j-As;-BqTGxq5SJ6XyyDGwU-C@ zXlU#W$j(9(^YVvmKRshSezw{w%IAH0Hawdhzqh+epay9ef_7G%$PD;xtCA^&#$WxO zRybjKN|-?P7%n;EQ{g+!CRO>g?q>G}T=XXK6!W9k8A3SP!Sv_9tkiXyZbOA`U8pB{J@(-BtSJ2soDE z=NA`>wDwP$AE$F>Hp~}oxOof^vbSEwC)0^h1#!uIP3GjX%bd9CUu+k28Y3)@CVcQ@ zhJ(ekHfTNyZSa?p@us*OaImgdKe-K_45H^Bj||Q?Z{guhGlcT{|Ff{=Z+w_>N-xJ3 z%BtYan&woMyx+L=3?TtHeYNLSKBJ9wVEKBDGyhVXxTi!k)Pq9ltaNZd34j)ZoV&Z> z`DNoCQFmjat=&fUpEWPF2=qhyXDXOtAZ+%mDe8v&$pLE#Je7w{LMt&#PViq%M>~ty zg0rI?-%(K4j#F>>>nFer5I@-$j`V0Lp8ISW1UJ?71rA7yg-Syzl2B}3tV8b57?=^^ zQlXwjk~fdjA=Bwnun0XFr(30N{Hq$V60`WMl0Ygmh~vS-jdDq_^!=7_roH6ju-;}{5NMZFxh91 zuYn!(S;qsTj1B=4V{9Km zI0Gu3q}rr0zJ29B*~4^3Dl*Z{F98_gc={_w*r57b>6{YbXB-4~+@zZ+^|*;uMTJng z0Aa&4LNXt1?Y;;B!<+xLZo=;@cWW1~_YTES+y{I=XTp(&4{O~-Wv50I4B#b(956dU ze4N!nkhg(JRwP|k9~0ol5I-NJK3HM&$dnP0t6JJNRKi)AA_Dm(O*!WbYft`tI^aH8 z%XKR&9_({FhP;y2%(QpyMMQxQWJ@j$WO6Yy@~b81p8d)mgI$)#^lzJn1-YQ-UN;A- z`t>0`e&WXo9lUp1_)d3MqCg_lmK~6a+8n>ZoB%&O+~r zVo}?_sz9wvcQq)xcS982bBEe z+T7q-{I~!BUFPA`i)qmi6u3aelxBT=@S|s3ch{w#Xj^HpiT6Ie;fNKei?%CB?k)Y6 zS%;}8wyUf&T3)SWUd$64Bkxu3?-FW8uLYSLP@{=$w&QD`abU+#R4A$xuC@Cx?!I1a zrPEN6N%C`v>fOloe^f?TvIaHe>JrYZm14G0n#(nfan`-?5}xXB*Me;i}Dt{^t|;g%UEiw|AR*a`9H_UnbRO2?Jca0AM zBtPAh1HC)kpM`;v`aE~9@kkdQpJVhI$vLV6w`VkDHLK{8*Cu{*hvwdWRH>q+-Lj(= z=(<=7 z^A{HBBAcvJgj99@UgH5X{EIIJ!nW!5!!{|bfqp5JEVxuJ7O505?s~*p-R-#(wR;fF z9naeA?~}O1H#-CWz`v?^oH}w5p4cc$7C5u8Y)zv0l!hg2Lf4^Qyc?5ihJ4Y7ZhZ%Y zuVz_m%p)5PiQQvy{bn1}DhsFY2A3W~Of^IbPsvMrL@vXeO z=ZK4og#S8YuP{oU=#h>{jf>`5TYeqx&eHNoZK;v2rU6@DN2pwrh_Z~`TK$etKYiAX z`B5)DBT!tRHg*PxdZlzI#0}EN=U6bZD zY5l5tj#toblC~b(#E9oU$})=>KrcK5X9e{ca|XPFL^}=d9I4kgdtsGVztMc4 zz;z2uR;1yd@>t6e`=IH{&@jJm31 z@_NPr_f8-3;ZB_#n%hW3s7@qdat&Kph^~sO;ms@TauXkL>kT|A$aEq=tCsHB7;!O; zd55HXbsnAj;G12C_kwUa+9!6-uvfJUkJyG>_XtXP=gR~RHa4~zHpiJ_*_R){C^cKk zxd2WXQ#);d=LVSBBd)dOd8Q<I7{{wOyecrFY%S2O2|TBgImvo1cL=Q4S_yjK#>?x5P8 z??Qpp1ely#-UgbgZ5tmP4(~DGQ3ntK7w}G9>e10s!Bow+0_{?v+5oJ3?Jlc-x-9=Q zOob71t1~n>!xbKhnhAg7Viad3H9r@S8Io2Sba&W88pi$Wu+eg&F53E36{D)s%;ynR z_$@}hE&E2)8R4_Dezizml1GKZfhpqwCpzNXz#w*}-R~POks#^CH<97VNb>fZG*AZsSw`ILs|;HWoXXTPLmUQbW&YKRJh?_ACJmlCH0YiH?z zig?P2NwF!9-4!CHm?BHX^?f;Q_znqX%jR2*(k0<;L#u*y~E}p~++1vkpGndDHJ~ z*>AVg?JAuO+L{_h6nODs@U{&n;B_jtIHhcIpm3WHu7x}L)vsClmDL%}e-Rq9cMxN2 zPW9bi#N-yQzDDd+p>vDpw+ZDQh(y-OfNY~AmA1wAmGR=^jt)Lo?kX4y*hZ)B%o7SQ z8ro{s2He_OdJgya44wQcCb3Q}9j>+L&mB_-QOwqo-dn8TmS&1@XXkz?0%lOB zAcXj=k1}O%BX9P_CJTk}GIB#Y>jLlSg7XY;mUz9XvE;tUfsw;eg#&*$+3#IRhmvkE z8B?2}w+DDY;_>ilC7=BC9R~+Y4cvNc|LLg9Wgw^6XE`(aNBSNi>H9Fl5Dn&O#)}e@ z!r~^6bq@TmTy>?}2Pvo;xY5K{=EMAM`+am*oyX_#O7SeFfJiI$qj4EYVWrCTMz4-qS|hQR#wQ{-Xzlxwi1)M@sxG|`uo;wG3? z$BF%Qi>G2!-DL>0-7q4s#essno2K)dtaw3c_JHZhhI2RgL`#IOc@5Em#v3^^sxiRU zqP$b!roPpIePIIsQg3DJ8#AMCeJjkjFZO@^a7c)52{zX$ea%m6=G3wLKE|jT!Ok`m z&~#!o#UH6C`##*CTe&Fwv?B|u!S7_t53?IjNP1NX;6eQHCiigAPJnh+iAXP=U!XUM zM-+TqVXsw=OJT5vs`)l-+RPQ;Qr@<#+e~=bWs8q=o!{6p;`KOLa5;aow6vhSc%*C2 zwbHQl+#lzLKUix|GO0AXsIS#aM6MzH$^5|fPlYbbhtUy=hEotKGWooGS4<7${rfV- zA;`tu?GK89gf1i<$B69tNa?#xp9S!*W>a@2%5UJMa}iX^5ll^9Aj=TZ$79y`)58Qx zr>$q`psb?O7Z+`!nhy06>_V_Wdkhgo2w6q4i`zr^;F4_)-q|Pys=gy_$-AB-M zUw{@#{}|yK&K0z57+8B>mf{h@#+zBOv#OIMyBZ?nsJ5W}&DS;L{ay9!)YY_sfj9$| z!h0ejwpchyq+epUbmn-}-Ke>BMgRU-zN{47PVwr;{Bpxo9qW7p5l4jmFp2Ova|{=I zrmjrql&6kWq_2oL1P8 zInHMBCkRHQeE(2DYq$4|&X#VnuP9a@(5SlOH*6-`d5vj0k$8f_hq~KpYufpaW_dP~ zTekzfgjRe+=uV^EWDCN;3Oi!^^U%$DD^ZOvj7r~j-3jOuP?`ZmU67^O3;6I5D_2(T zQ%FKn#`FbxsmB=DBeyIvkq~$*!TR1-CFIqpk%!@v6VM)`ZNLCkdZag!jP1+?_ zIi7Mo5k-KJjl%ul(IkA!Zk*$1iP6E@iuQ~hOB6Gm8lb9(C@8fzI3Bl5PN_HNG;LqJ z?iI6&cWF{A!0-K_j&<6DLE@tosx=iI`q#08I-c5vs->FGOX99eDqtmhUTbzDON3wS zk50@t?G%O+glz9g@+&F1?s%9q|ID-L7g*q)NI9TSOdF6Yw@GqBAQ^nOa|&da)aU#> zZxU}}ik#P!C%*yPzq6d&nk&iIYa(i_ymgdmcXeyzyjO%^z^bsVkZ6iQuy)DAPoQ-p zDgLY{F5T|kqmItni5F@2Xbh@HFazK6W=?pyIu#|I&!x}WVmrAeX=CZGv@@MOC-a<4 zvFv-yju26TUKT1FhF@t>e>l07aZGveJ-%P!D0%b+7!~T@ejny6Qn+axy;#EopGAYTgW<<~Gd^LSAc~jS;D@qW@yU?2d*&hDs%12it z9+hjubdPm4Eo6?YK4X1aehHur5LO?K*1sjIVNbKwBD^jny#=Rk+!iUVKw94Jw#r{0tpG7hzsT9MW>8+tXPDgxhG_*y+u-CNgk>b9j)p+oMm3wEePuk`RKb8+JOG9DckjUL3u?XxQ$B)b=HySe*0x9FZoj8 z!BqCrKnwxXYZNBrq%pAdfE_H7R|&~7SH?AnhY+!t+Z17QQk!c|Ry-0TW5_yNE5+#FBs*=C{f_qpT*%yvL5P$5dF+^`;iyw)JgP+zwu=em6BLEn2c z_R^>Q1N;7F?J@E|(n`CMIK^9W`oWO4dZj`tRoz5NJm+r>xz(4DKKZ;?=UahOR{%#3 zY#yLBplNCmnJO18v!Px8AYw8X6&ds%2m(dz!jroIeb)#*&(bVL&TCfbTQ(m)lA7X) zd_Qs(BDx=6Yd?Gb^57XaS>P&=B)N(z#SZjD$cSaPh<=N3=it_w(r@;XY`{|*# zralyF4I%FpH%J%!<18ZQt}>GmX{Eia8+Z5BA>pq1`@4}r&XIL@xp%j^1@8AV$62NG zt9?-37*)4_THmKrpfzaa$-$A|@#-GfhB7g+*u3aw*!FBVcr2_}PR{DvC&+F2w7;fq zs%$LVHY%!gY+XL%`>uv6lz7#s4^y6%Bl`^E`<83T#fJTLeDp==?DRXDg&qFyztQr1 zp-F{_$Dh}Urb>KSq|{&{k2lGFaEzIfpA-iHFc+t-o-!9KJ2X17r3Y}lxK*R*Swsnlvawymb7 zrsVCkypLQmpGn{HVD84@Y)1&%?0lDA>c465;xrXltfOU)m+c(By?RBKtW2{k&#@bP z(fOgu%Jj7|e;U2H2=l4$oaFhx%K7VGeAG<4{eV!{}2+mE11|TC53m&L&@l`NEtS=m~+&X8~j;gjEJ>OukDxYm1)}>Qo(0>PYW((!`n}r+Spd4Ia!E}n}^sH|8FHg#M5I;{mYLC*%wYHCJ)vu05;U8bc8V@58z+%;T(HV57H>qk5kjlO*4x*Tli z_+ZN=xmsN%iStA5h*2?1&BLp=^{N(>bYW}c;OV&U%{upX1O{}!apXX!n(L3ww%{&O z6Ni}gb8E3_A4_db8ZWXxH)cckR#|lQr8!nGsOh4BI_ifqbfx9VrQQNVbK4M^7a}i^ zua`0Z%$f29=^Fnf?e{R}KE7!w%aImmb~;aw-|gjZ=p{kt?*Ou2!!=ZHKrH%F1QAX3 zFVbFHvWzWCNQ>)Jk25cFRqMyq-!(uPWFzF6c!o2>sv(1*RbrE}> zKq$sb(c_QkAd{t{h6gBXpjH8eyWO&fPBr`?Jl= zKOko>$?3XxaSFS>y5Gt2(&tlzchzdd_D`z)DxaM0C8@C8gy+!*tWp~%zh?|fT&MG86FF=`UJ#h0&1Ix6OOqOIkXzm zgOoC37E|`a1@bV%B+10_TqrmVLk`N#5t(}S>Cc6Vf|`t5Xz`hk1w%bOJtdO)+ttqF z)sDTnC5?YpHnfgBoR7o<_!t3T^ic>{KUB=(d-qlVyG*0H$p3k_oLt1>sJ>Z!SA-k~ z@xPy?yOEny-$@9emjiD4aFtSzB3(vetk_3G;%Ndzl90j zef_5L(ILb*@oDo9uKyhDy~y44vB6CfC9eF<5-Km>yQ>ljZU5g)-SG`yO{GU*?qU8N zH9FUHeJo`KG{E@(>@{L?cJ)7uVAHB}axX@0E2C z99eg0f}-vVcCI(CRSagRLU9Puz!M^`UsZ$MAe&TQ7Fa4x+jy%%+NSYQ|Um3mU{n7(_f<$wEQIC$kT zxoL$EW_QwQ{C;b7N63xzeT*3-EnROpy@KmS{aR)Fbin!1(qP8C`xf^P;CVJ?>E@b$ zeC#Wi+6}jax=1RE53Sp=cl_t5`%7=jHkUnTc@A1@LN&9WmJ}5UWC!ei!1}F~qk(jE zhxf;?aH~uyZ!sLSA+!9mN^v8F_~g&)_LQUk;kOy?s98@~#jB}eDoi#GZPKAhukZYO zCm4t^B-Vcm2^F35#F$|8ZV@R!GL!2-F2(Uqd@?9uu~ zLAtX8Do|%;$}Tr*eQmRsV@~LT#<3EKU=T3>hm@-*@Q6uw71(+;>ZM9;0}fNF z&(Bn&nQ`Y`fMZ#87>h!>7_^T@f!X4UEJEXwqV62DVQMT4+6JW4aT4K@3HPLeoVqd* zC9cM<3loYQ`YbQb&o*j+Bt8CWP(i?FNQo1B)au+Gbk+ypzJu3+O*hW1j&7jsMwHz_=)R2+w zZ!5^u_OE!2mntRfs>W>rO--B4YwK$@=Z$U)P7f4Ct!LsmRMy?cT6Vgb(13+(aBLsj zf45giek{T-;zuu?_Yulp`3}{W^HoMKEe@a%_I{?GSIt(oxq1C4=r?sK`e3p_SH1aK zjSx<$zQBF}QdhZ6Y4SaXy3V~s4Q(gWwJj_Y0%Ie9Y)T-u+DXH63Ga2>e` zvjBawmpR>*C3I0i#(hNvEjr(QyaNj|PodIv#5K6Mu=@U@@xRSXTrA8E>R}q!9CE|= zr4rBnz_TKOuD+W7ww0ce?2xm8r<>k5+ z($u3IHslZ@Xx%ZS*WqIz5+k-@c#Ys=<=Vj-_m^Y?4^OqeffD@}3-K?`t&clQnRWHJ zn!IM;-gpTcx;X#<;WM1?b&ILsYvfM$Qtc)()U!=uhkyI~ISunyFNM2oOw`xL=)lmn z%Rh6xK+9GkP(IVvaj*B9MpvV+`YZXs0V`6RXnyzmlYE9eupWhve4cP|^$OMXNLnS0 zMOw1HHJJ)-d8W@YW^oKSdJXi~H(WBi4trV4LbQ|<&JT!^swSXfD81&M;F0Ucmudaa zYK2+QQ9g_J&HYJ7_{G@)anRE>mOTHKy>^V(CW#vnzFeV7Nj3|TFf@eK+72L~e?6YN z|C5+S3(<=PY5o6Lxl4y*`{l;5?I8J!>|l1{C%5{SE*c z1Q4PqIuKJ{xYmdC&so=ikZ(i4ZUsx3wI(pJc)vm(jA(1rZyd0A{Cw^uJ>AW+fu&#+ z`R=hXA)2P0$$h!8i&AP_!8H6|zFQ9;K7TwKd5i72kK#$7w9L*|S}#)>tTN&MJ1=!U ztV7mX$!1PcTyA5XgNkyE+OJ9BdKR-KOb~-GF$o)x*M8{NdbXS! z<5byXo9UUv(l2x3#MGv_+OP0L@ypBC2c6{naxmeeO11qdCg;YEu<$y6g%(XecW7EIPT1HJn&#DH-B^rHULJ5PlFF8 zhb#O6+)HHz`;C{h1${s|p_KnvF|9prk&c{OyI5J5@v8P21=7W-*b;`_~^4!>u5+nA>OJ?cH8D@ z@(oLsV2ffduF@)5dxLx~sC=XNjE>G}hCm;MSY01wcV4)%RDwd+E+8Y_pvNCcu~3}q zXWtxtVsQX)X>3W^x10M#L`DTBcwgZ!Q|1n)%8M!Q8ICZ&Pb0H=O7Uc5`(IAX{mJ(K z7)(!Eb5B7@y0c=}{L@#~D+|sOQ72d4n%tjFy(3;lG3Iil`!|)uFKQzSFQ5j+f2GwB zvCVJ`!}=^lK@$y2F8#@o??*WK$4e#jGspCG87m)@B^#Gj6}PNav^aShDKBZ>*#b%d zAxl)W=t6f?Mr~507hoL6}4B0xCwFzx}j^f*^B# zxZnU@AqA)+0RgBiDFEnBc%{5tvi3l9gJ*%d#X<~g6>mpUG-A6o86P`J42c^36;s70 z^Dck)6s|eC0B!RkEJKsCe14bKX~1a^$?*vW@oAr22AP0Hs6y(A8DsV4M13iPs6#^2 z#rX*u0DdP)?@eZ~3Sg={i$osoY;A*7Wd^@;j#>7)T5>=nnsyxKIPUzy7f>gwGg0Xv zd4KumLwo5%NJ;^1)Sa%!!vt>hyPfRxcy;MvXB9ca$NLf7Y;0)xfh;gom^<{kmq!3% zG07A8AWjm`!r^lb_=y&?)6!d8)!jkMocyeHiTC)2P%?Dhdf!4{1 zyrh{V3#EW>HV(7T_c&o^`xu-XX;Vdr$EE1^v9cw9HTn4H>**y4ZX~g1P%gFDA70K% z4}TGi)M7$lDH_OFhcpy*^-^>qrd?`tuV#T}C7iP8m8hJXN5cFQ=@KT{e_1L*e%E1n z)r(~N1#0O@ZZm7QnZ)|m5jf{Tow>#%Z}E);8#6E&pfRl#*BfmVB2DyIs8?>IUEuR$(H)pMI63)eOEqCY4rk#UiS6ogc!w$lb4W z{ha0htvhO2UL2wzPQ?ueGjK>+{7Rp&<8<9`xhN{s>|KkNw`j*&1)!)6CU8Ar&hwa) zTq}$Beg^1rxwiLVM~lh&p=qRwBe3ii2K&9VvdsYm!vYM{>7Uo8rp{)y01zSANwv}s z9S4H)Y+Plhe}Uw#XzaYrA~|y1ROvbejvfGAbwdOVNp*$t8)&l7Mpo|#~0o^!kzc_O_ zN$I^tZ)^@K&N>Ck5A4p&IkACADhP%cAL_zJVRXx+Xc;Bi2e7=7iCC5S7vPd? zQf!N4q{J*GXXfU?L@t!ZJ~)$1HjG( zWjaIsLT*rrX~-Pq0b+_;U7qBo<1I4B`OlGjLY!GXpDFf(&eqKIuh{~;E}KN|HhjeD z1{9cluKIiMJ!gXJ(BXZhA^ls@mbp=4LfVDu^$ViUi;jRQ42KDt3#IP^bZa`^A5s37 zf(N9wT8B?p_1h!xkNvkhZfuy^noh!6T)9{ey z{;^bC%nLA=Tqwmq{_@(S1oh*v^8+QbaZ&QuP(Ph&G~mbB8tcK=bH*!@$~8!mC>;(8 zsv3p^^}>cF-_^Gjb*$j-{tSuX{sSD2TWq2g#<97)h@b{InTxJ{H~gHCO%zNAU{)tF zYfpu5I_PAB=(Z3gUQsdxfFPS^MTCk#GF0(pF|wzpNZ*+03Ep%@vxqaa#4hvfx(P5} zt1tgtM&y}R{PUe^9}vWfTXR?eZ0QBO1nC!Qrr4h*+ACw~GZ#v9oBi?@@yT~>#3H*+ z-rcXhOPTa&K;4Md(jOhG42!IiGufQ1{Q9%ldA|c6nz)nmh_NV0WlQh2&LS^WSRdvd zDS;L9Q%P_C!@H6cMZSL%L&jCSL{is(+pJjvh96h>3k7UGb_c+aahb;wOBpbyDg;oh z!_cCbabG}utBN7veawF77wFD>6*gX513W5YBRgt>;hP4))dB>QnEmIKOa9dh)MEh=NsSzw$@r{}%rRILeos_v&dC>$!?r41|7nHa{M zSMn4(1wL7?l?XS6&S^$5go{`rwoA0$e=p zLh3+7J1L*0D{Z;qC1@DwNL;;hlq8sc zYK3^*8?3d}klM@$Bw~rNn1V+c3lrnHAU;43ix-aP)Icm?gTa#5 zbCxWskj4P(a0Z;FD3W^A8HC+(F(I5@*|2)VR08B8CF{EU`@}R_A7~ z)w{fc1^NexP){5r%PkJ;G!;8ISvLlpzlf*84j2dY7u%eu#WQJ62d4A(K?bC{nA?{4 z7?2dgLG#Z6un&SoA5C$g(nkG=?~~qz5zTrx2ik)?3j!;KZ4_`fsVz2D-uZfXdNoWP}Ab|WHj{v|V3`KOM`2K-q+CC%kFoQV;-M)b{!4ki!?P!rLf406Grtw|B9NzRV;n1*Z06Sn9r>RG9^lhmCU%^bsY4E$ zJyx_}7iZY?f0_WuQhS;46wkj9LUZ{4H4p$$xGDIA_<;2$M}Z{P%3 z$^@P|54V|&Bgmi$%m|cv!BUS2+AH1JN+w`Ax@2L;EYa2N=e1a3k#--!rPVS! zq$N9`e_aO8T#Hj`7x2#rP_GYcUWhGRhY@A=rG;ISuk>wSKI;QIgU-oktMAy+X-Iv+ zD_#>b%&z!rHshP&DNW?}QmWytU-(eVx$2_f*$4i$Q-g(Rx6A@9gQW2HrP@WFQ7zKZ z*G4`E+V`Td(<$)l{n{CZrSu@Fb^UeiM@r5VKN4LUI_azVuecuRvq(E-r4!?{VeZEw^K zt0PKyPSQryKe@!Ki3ystc-1c?D4x!RgWM_E6~fG30)4otvW96tFJmCb$?G!O_{;Yn zv~E{SQ0p4)>t4U0)5iVf((@|bGoHr;G3l`~BA6y*(?uf-^_(ORw%8fUw^3}b?>h~R z>p!J3m76yT%>*9XwHz)-1^R4lS@a*?^t2UD4rU+9Q zze}}mY~+2!V_5e+=~KFpwX#Sd(Qi*tJT~s`y4%c41x6A*MVnn5>r1KEkLBG7nniZZ zcm*C0XP;YvR{AVaEL|tsjs3hbhqG6h-r?sD+Ij?uPOxV*C;T;R3%R&0_rkEy|?VIUC(V9 zTmwbv{P}!}Ecyo3w6oWgAB<^&V!b8ogBO?Ph3IjLb#L?L1nm*QNWYs0WORwYN;PWnr%qzy z7j{wtraTIp^`-VLT1w$|wD5FDH8a-vs_%4gDE=pmU&Jwb zy0>jc>ih_0r-Y_h!C+L=n2CO3o?|=!5JRLksd=y76TW>jsu)wTZg)zHvA$-$#`f_6_=G zfudETBxy zD0k=+{60$VGRcu_{08K^DTsQ9?jxbh`@MZVUf0~+wfJbUWMkK)rrEes^Z;Qg-MTjk zXT9f|{&hd2#RDruz$;{@WKG3kdc(y|#8^cqjRVfgy^d$ZXY7ghk%_$aH!Pax| z?J?5Ug)8V};u)DJ6z@b>*jvE^#f>BKMg@siM7_PV3e*og&qi`ZSfJf%X(C)eKj)qK zc4^Y%qp7VR+tN$gZv@P4J$s?jD7MDfxzRA=d=TTWpnQL`&42vf9)Y)TQMCGs&Sfttp5#fw24819gxXx003uEjq8Fq9+Y86%Ksjd|p6b`c}4! zLZr=)p}vwlx{fue^JnvGk5k*ta}1hPI0;>mF)1LkfDzTeMK{>Cv(!YKulUqOkx97N z=4QaUJpn&;A(#zzi#a;|;jg-->o-B%A6u|Ta*D;alg-)~z*Ca#afot? z`<2bBPo?;og4Ie!=aY<_t7AkveC1}^0!v?L0j&HOU&G7US3lHoulID(f-)&J{b=~T zh7|DTXXT{Ci@Rkga+@ISl#S3Kf=aML*q*d_V`g*=ZY5%tV%Jag2G0EGw+w^IU}#gk zNA6Gcp?h}$*GUmZC0K5lh+T1kXS^vGAY;03ny(;UOI$^6`1tyU`nq)5dbRK7d^M@u zoa&3?=ykeoLLb|o-b9{7c$ngzviA8)oe_M&B5xfp1?piL2 z4wjGX`&t=SYMPLkl(rv6L5Vo4=cQYAoGM9V5cT;7%$gl^W-@I)urg5`Z(#fV5-tq1 zK@{a=DG0b1rKDXAwB(n{B!PsaHM6Ws+(CgFa#-`XNlhNYVzSvw6(&|)J=8}AOO+Oy zNxCke4QA{AHJ@z#{A3G+rhqoih2crE`NC1B+ze1}@9KHyQ+&~YoQ)t-!+}C>JOk() zk;E1O2>{ic?yvZrl;0{5Y6pZbuhWW_O`)Ys?<^L2(!o1S6td?wNO1hTpB?Ug1=@Bz z>?|Ra9NLt~L=EyjJ(7W&7Uw8Tl+5y;^@{j7RK0m9>WO_#alJ)bWCO(?DBj-a!@tLO zf)8KVn0)~j*u>YWKg?m(Dpod96V(T*l4az12MXUc&{jmYU-hWbH()9LgIAE6)b^qM zbO3>fqOA$@Gf!{L_9+Tq>_FeSj5h+QJ$W{AWotj*TgJ{SCySJ$GC-@mHAkzgX&sQygSYuyf1I~No6;)Y)2AV4CABw)3 z4(Hwh+RQ^Kc3n|VKl6>`(vksHL2Q~sFbA=Q-N*51PZIx!ZaH^uBgC`chu3cLzOMS> zCG-3TUnr^fF{G7{7N@iAl3!hK`Qa#~UrHw|Mw6>O?5EA9N2q@seU53mukdFs(mj6y zJzqzu>`yGuNUxGUTN!-&*pnCMThUWy)*7?o`o2Kh+DjS?VP4Gz8uB78B_yGdG|(pT zWfAGnZ>Pq^$h6z)L(;(Y{~KKIFx{rmU@-M2oh0?Vbvpn5E&x7<)^wO!TleH?(P`3Q zU7uVX;C6DwsSOBEn-~5TPl4K8F3&u_Tjwg(?Zv@d$RjlSyGT|?qwoKnZjf93S{h-* zRPy5gW&y1A{wnple2%Hob_(o?E6blr&~UxBA1AWp)^E@?tQh$&CZfBUT%5!v?AWDD z%>Y4#KI%_T;{8Y^aN+=(1~gsQ*&j{=@!%3Iy{jl}^_^PSWBC<~RP6bR-a5Q~`@T7) z^!69Kr{%h(dR|6AQ%TUW`=cQH%a2vV*s>;knwU9&(El67k-({^4@w7iMu9g=7rLb| zu2=Gpj1`!$*BjK`RL)soeS%_Nq*jY^`v9%Z1rX(wNor*Z5Nw!e;h3sQK$jU5b|>}c zR>sPWM1WI@hKBVN(I}F1EOW9hES0GSum|*~=OMqh@%KYxC+vYD&JQ@sgzwLE%nBqL zFg0(G`p`hvxwbczB;uz62asme>4We19@PN?LP_0&>y*rPUXyMLwIsQCbE*aB>lFT! zq^?)6=owhkNr67P0DVM++>A1J`CT-{PR9P2(ZEMm@Pt$IN&%_EElN5qnH1&|b@-nL z*rys=Rp|qoA*k3{#pk**M(`O)u38z&S9wXDOkrXC`h_4Z;q*%k^?>fq6rl00i&&Te z=l!6f=Gz$vC~l&pO4WCkQQ8yEVDG;>v#`ZJ-Prhl~p z?FXbiV`*&K9$wFR~CC;@2!P15{Xfj+1a#UWs^sHFP@AoZkP00;BD4uSe!HHH=J zWakYC<=E9upw-&K010ZPxWV$*S5oUh@4(t0km;)JXWEbyw^)?9f_8s!4kiFd@_PXP zYlD(F-;o5#tm^~b(wL~MYgE_}O9MgX<(UTf|3rAJiwKixWQadrm zSvs9T*d_%y_1I+Snxb&UudO zd0mgkeO*T;cb}I7HDYDW@`nbEvx$OMu?Z(Q5*Qa@`%|Xc;!VYg1bYMYp5j(s&rRhO z_OBv{49t{2V;gb3)<(I9CbraiZ1_onsDL(`SbaGtjD`wV8|e&6&dwBou_>>i3u9lu z{WRXpX~as1@pc7zJ^U#dJ9+dyEm{@7@>AC|vMN|JuZoXgF zb|{Q~F}w=u9JDu+hIuWxUeU}{Ow^_*sP#(s4sf0AKLz)1aKFx*E{>A2B-A-(vL zPZb1pnd{#<(uVMT?rgZ2$99RBQ9a7+-*>mffm?J?3@kJNpJP(*OF74J+8HqN!;?Yy z^$U?N_;_{9B?zq?82BmDoOMWP7~;D_w!raNA(OsC-+lG1GJVB;-o?^acyO_UsHSR#-JY(Q|(h8=?NRs5Q%<2*}eReTT2kP4uRN$?MS4*=ZN9zwAe+UzlL>mlduz z5&F;Y;D@m*w`RU*%60K^-Q1h1+UQcLy#G6|vqXniB2oWksV7(U8j9TDt9nzs#ry!B z(t>xQEu%D~W$e$3TUCj+4eQ`RMijDTv3_a>Y=OBGDgX>%#NL>|2gS#_1x{pEtBuF;SD&#K*a_yEZF zDI<-2d7vO^aiZ3XKk}oAui%Jfu^FIOW4~OzJ!oML%|sAwGh#9U#yLHNnbHKP;XEtR zd#bUCyd4X_C9}`+K-9kpc=>`eK3PDt7MOiMWCR`RO;o$@n&p@e*|&0Ak?Pwq!9zR* zNyv8SUv5zdT3y{;=wgfQxev`~#fuqnu@r)XKl_Kxcz|+Og7Lp*-Fl96M)2dY^{m8&q;E39W&tz?qVpR>9Hb6ZF; zIeE#(A(8onX_1|TfWu$q1$bPSu8eLRsuMt9v08vuNfmfk@L;_2)4hSt@Ye_NAK5r& znbwOtB~1qOoCV-8j}npP@5L*f-(Kcv%USd>OW6=;A?P685L$4xnUtP6u8j^ky^`aM zL~?lZ11%{6%=lj+7+lwLZw{}_e@0yN*VgfZ2as^Il@91Ls?5nR@(*|_u{MP;TR=hr zvm6%=x%pv6o@>L3Pq)D0Iznrqn9d>+63^J5&C73YKlC>GD}YjYW7WL?PxJ;4WIs57 z9*qpl9_WE9z=HbZp-s)6Ny*F2-fg#>yB&1t+W_*na&JGLmpU$5Uk6i%fO^K0kNr6k z6j{x6=Sr{_X4xyboIT=L9jhz@GU27;N=R}?o+bLv1BvI^qMEuMFag|A(FX+&@`R;iCgQ-&^-+FH~Hza(q9JEooVCR{L-1=>1 z{b5(q>*&CYYZeB0rEhY9-`H6@0^L3OrLHC?jo~zauRDN|KREl3>^*(Clt3_3EiWD?#9=$ZfMJrdiQvRj^~+)Hsz3ODo2N zY<#BR@yEyGyi;PtLLiwYx*ge>xL4^5GTq6)8Y05VJ-{$fd#ukaslK-F?R8j4p`T>Y zGj(XId$|1GBBOWM2vfKa)fk;$4Xsd4}W}BmQXMDq(>4H6~6N@{%n$zzL2LC?&vF>jb=RJXXn%7dEWVGT<6XPm_vqK0VRhH z#d!LA?+`>9J#F&|T4zI8o8RMp+nK@+53kX1q9yR7+~v_rMzyC9XAc%fef7pdD&a!^ znP$z;x!>%*0ZcCnGOVh}7glFNrL3VUkJ!xXh3et45=7CBMhC&9Bx5g6Qz5ZLFO=F` zpcHz->)3uj5Fm+G zkplInn#OQ@zKK#5oQL%=^Z|`>GWLi}5@Lg9pjs66xTOWs-06w6TWs!I#=(y!k3nIw z!l%?heUDmn3oh&>TnyqOeu-A`5P#NCUn^Wko!WflJ<|vqM+t*c%ZOhHK$T)c8;#Y1 z&HhW>Bo9&dt2Cw0DQ#miDH_dzkE*`b3aKmAD%z57Gn%_GNDuIOkKyp^%2aKe1 z@)Zs3Rs~5Vib2;nK9{$KP@jL+=XL&7Y;nQS&acv`{5#`!I%fogpE7CYzYTfV?9=IU zQPTdLJCi9sZgqbkTINHOh+`5(z`Nh^rqXG;^PD3@)&o&cR;-jZx!w`p>ZF^Z=o1mq zkLNujpZ_d`ejYW`_FxwAlK{kGhDmhJP*s3yacuXry(A)p`gSC7u{kLGE0gB<#e$i2 zDuY`aL-V*vCYMQXr;#I5!vJQd!p@m}rvXVL#%6y85R?hQQD$aDsiWR9_{aEtKzUCzN72HUCo>tT*VJk6-pEldW3U=sPU_LSS4 z?nbfy?vxH70dvo}Pl}*ESO(17ejlvy3bvCJSz9x`-nT1~r27Tsrt7{|{JWxUhN--B zW5B@6D*EGx-hhm2EC114mvBT--rW8+OUFw6_%y4g)h7JDm-rn=!lRG0M`RfnZfiII zruI12eGPL*o52mzKXR9izP7I3>Cchc8e*AZVm1WD@^LZit!W{vgCb(v!_F9p{(EUu ziH)_eBs~5+X)FO7`Pex_RhM;AP83Q7Os~aylt<;*P(&hBW`7l1 zD{pwI^^~h`R@L##jNFGAYDyY4g2s!*S)W3mcZp0wEFz{TxfUyWqO!nfMckFcW@e=W;LOe=EL{#rbnWv z!yIjD51lIY@R7K7WQfG*p8fn+M~=P)X`!Ub#OtFwzlzvfYzHnczh6dK1TXuq0!J7H z-Yuj0A$DHor!0qztu)b0cfAGzL)OGnB4o1aO?@^5CtRl`r1lq5>DL2RY3hF@>4wHT z=RZ5cc;?-%__0e7%kt&F&sV=7+VEt*$$agn5H0&mVmX08y$~w+3(?%HC#B0+#j-r^ z(4#^>K$S#auLY}nbud~Ka&Z-d$2eu7+e2cEk>+(oEE0hv z1ku)etA1I=154Kr;-3f8)pK0cWcs0pliF`5-KGvJJAB9pF3*liWj_Cz|H*4LUs!!> zg>gOdGY{SIo~}Ejg|y0fzuVyMRBlp!4O1h$4ld52w)}35JQVRIG=^3(!oECa#mh@6CrgDtXW(U*Q_@R223 zDJnHM6rog+lQ-W=Dt>Bz)=^hrFm3s(+7A+UNu{PaV*IaAW^b;?*$GhhH(_8A+p_F^=SnF5Ed;31Fp0JH+vhz~YDW zbE)(7IHgW~u;D#|+o2DWWf@j~cb&a2QOT#u9x-2G8_M-5!tFb!(r3)0Q!kwHxm*?R zq9P>i3p&}BpHJVVtA{JSd_ISZF%BtWA3+|u-{}o5(;&@R+AsLx5JZ;ga;zx4`+~DA zU?#@lVDOIS1^$se)(=y{BAd9CKwd=i0F~O~&$1g)ypOhujw8$}kM@^WiMNwktIqUs z4n?_pm`1b35UoR%Hj&0rR3^D4XsFi1hq$YL~)b zB z|BJeI$HeJ!9$}NUSckJ3GMJa2w=oJ_!PpLieB+pspqZJ(RwofnJLK(y!y5;)rsv3# z&5nb+t@9n&to~r`x31o8ZqnNow_klYTMuG3oRJ5e!D5m_`FpV}q^b$yUP%#?4oRlx z^LdYvL>CsKlE&*CMjYn0ZP4zARyRedhJ%5*!7h?hQZm9B5eKRK9QM_`;C|O#pkLWk zes41YP{Fjh$xu7#b8Z~fD?eHxTdekug3j}U(@RS9jhm!t$M7_n`*OtB2UAN&f8j{I zqt7ft*6~)mAJtxYy6X%nA=wWfFico?i}IK`B)n8T!?9S?G;xjfX*X=2M4;rVJmDY(wuH{K(|kXZBVVvxCI)QU?r(x#YwTK`Z@+z3oyKqoT4h8BwuNoOSrNa1E1KL zDWw9ej95^~*%qR+Rp!?I(GCaT4z*Z)Bx+Kmra!Q)P)ODCXI)S=tGwwq=|79Q{PaV{ zQb-&*@a)RUUVz&~9TYK9UHu80JgAG$y$m8^4L!?>c~(C@?_L19z2K-f)1gksR7y&P zh0FsyETIv-cCRS=f8zrjx?wYveOJi;a4;|YC33Q(4xHLtLTZ_F61IkU-Z&5y|2`^| zhf`AS-tZih&OA+Wt;$;Ec}2j6&yp_*%#K7FlyDP969>d(!sh!CuYerSp^!W4VXIwQ z-ZJH~gV)wuSQkrw71;u#xivgduV$xS;g%31Z*rnEkywZ@mIuiHI6Of(S*c~Ly;LBoaF{jx4j zBiuYe<;_q>BayzJQhqnVFE&0?caU-befRlqM`_+3CevL6$|7bDK)YoL=e^*zDn0Uv z4MeQ#w&kju^V~emX>pC{y}cPyZ(U-1*$LV!dgacds{pd@f2le(qj-40H@{BiETPjlDxgJ%}#%bJ9id#NA zk+rED#fb3q-e_eTp>U(M7FPDH<1uEuRT_2-Z*6?Vt+QTeBm) zpXNbK0g`fN^#DZR_COfAmsiD-nH`D3B~4vBxxPcNl#jny|8|AU>lKceD8(@0w#==Q z-cbfT7sK895oc~i7{(G$CpQ0tt>j1RC=EcswZ=#~>YB!Uhy?zX&ej}ga|)M5MxUul zx3o*`^s2AIijMGL*0kScAs9#9_aR;t3YBsrzmiq6un~)_Tg3b2*r8|+yjR_JXZOUT zcULsy<~*!x5ApcYLAoPSK4yQAXjBMzdx4HM6_Hwr!C? zvl6OmpAi(&5=z|mOrsCd+V9hyqFG~tz#vq1=#qcLHOCaQ1Gi}=b>@d7J0u~ztK*wY z9o(lcYq06H{36a&q|V+MC<%3%k_V#aX3ndPHMpa4>vB)zs0FSvk*j;nV@~YZ&^Q%Q z)>ckYq$~)Y^V(;o(f)Wm?(gTM(-D6iU0CKLS&a0^W@mt7nTf80oT%{kw>#TCdouYw z`Z{}OO7hHuWm41xGSRwnRZvI!>VfVQhNQ}q3~NBN?6FHyzNPnNa-BXu+OnB+`)1ot z>jZC=kn>4tB=O0lP#c>FqxuUAgg!%)NZ z-Y;I}WzQ`~EjKCR#0*jPqp!9=XLB3HC3ZmD=^yvN+6B`!>3Yy@6npkJ1+@_E==Cdi z2WPN7k@u+0IiZ$Yz1ib*x;`QGFR#`YDz6pwZq)qceA|L4W69bP&TO@~iPe?MUgcY) z-whbJI;tsm`L_*QC6t*An8b{nXE8zRp^a={W0JBQ3o7-{i-5I_}ykf=N&5TwU-vp(n_m=HE*I zBR-UC-@pGH>l$@A29WL+qszb=? zteV^_z$h#9{C({!p)|B-q!X{KdXa`|=RV*Wb$0xntfvbuQ|(U%`0|fG5%>c6V9}{? zu{%d9e#dU&q2XRv5Si_?N)+8@4=CDvy1iepXK=&%72m~ZLtI7LVAg3Fb^cy$^N`(S zpa9EtUCPe<#`!w{zb!mbV$eMC2oRjj3{$|FN>Qd`fLR@4l@t@7w@` zpRI8~KBm&xqyGMUHEQV`=%LZvBF=M>nz5mh`z<_oP`C!6Tao7-mJ5T<`*>^uX9apS z0GXVk?Ur&zLb1zf0>}d1E@LnCqodlOo^WF7Oj$ZBH(d)XkRw|EP%9r!zB09k zoWUu546|NC|K(pqeFySS2x%Px9lBNI5T|-Z3@HrxcN8eUvEYq(05p+# z8d=eI%~DAwfwQpea6qkR!f5Bp`r}!QA5KfKgd;5veRtuhUG%R zP9-{YE8+#7f_y1N3u{0OXvptt|I4RyYyIxt;*8w<`v-*Xs%N*kitnGz!Y^fLNlCDD zIEU6rX|#S7GOE|a{%4ZBq+^Xq2jrWLW!AuFAUgj!|Cf?QV(=fJi5r&92d~014Pl&kb88Z1? z>;n{zFO%5|GZR|YLhD{4GVnKrG)5uQH$=cNd2Dw%RS(}_ zN)WJ!$~(%12&9waO=H(h-|CEzoJv6Ip!@tt0WTm4oEQ*AF01^nQeaEHaDWUl1t9xg7tx$F3;UK|+p`L*ZfC321ThD{1s-f3j0onc1zWZPw#nVd0$Hi$s+i$>GS{o6u*_M){uQ3p$dct3+a83 z=P1&omH6k4`JiAWpr)a#-w(Tj|Che(m1P6a?>~nc9SE2D=ZIzfKcBRJKIuUJb0Q}H zpDXJ92{aY=%UNt)w@Lqdsx&m5FW4dm5mSkbgXjLww+N*CFGk~LyXwi<@iyy@NwZ@a zvlQZuR2piw)q30Hz;(3)fupZCXYqCP(HIjIe;QAS6xo?Ytl;F*-f9D7RXbp_7**Jf z|EsnLYYJaq@3$zZ?7qY(36dA1V#342CyRP1gDP1id27T86Ouk}fvC#5#$C3F(7pA0 z)*@(_z6&+i)S%iC^0NJ+Z&lkVswo%j!D|xteP4q#*A~1?;pcxIRT`&-PaO+8K88jz ztgziEKGNzsUAuE=_6xcDYyNGt$Q>H6isLf=`SaLHfMm*9kUnf#rVY5)+AIF=tfG+m zQE4TTfpPL2prr2WtEs(%jUjhqe&C`=W$ZszCytShn9~4XhNQ>0qN@Q)|IMIMKn#$8 zyo=s+A55Jwh%fc}fW&p)g}b}w%_3{SGP1&01Rf=Za)yw3L`_Zp^4g4bTLPK<+jIq1T!*=_E` zho{edNqvj5chbLlEZ04=X+Hj>+}CzSqLQklrv`RAR4B5}zU---96{J_S`%BM-VxkS zYp&%PFD}BMtUgcw-!azSDKMaKB-WXHHt@%fLiHdQQ~FKUjp&}u^P?q@?q79u$t|sjsR0dv*#kxiSe_N(~O{T)uPJF9-*pK3yP!3l;(w>`9*&X*t^xW&(+$bHD$9 zq{JD|lB~x;r-1`{Oj(5NQfKIwJ8)z3+}*{?!Fj^{`tDXZph}QGu_=K&^q;%qPXV(8 zTfjWg(#rO@$IV>|oU0r2bq4y{3>_f++4j`{Siv@fT-#X(*?LESHLRVmIn(}KtN0tH zv>^`2SSslsyhgbKKOMr~{cc`KxL!0oY}gIt(WDlYPCi-gt5g14D=rOcs zKmBKh;&)cz9<;b-weno8;5wk(X@`K?vJ%75dzteIFvO{pS&#iIaxo`szd4w?zq&G1 z4AOHA-S={KxWL~rP5m?5@yDCctkh}jv}gVmBwQH#=RbqoyIxD5YH3~MW+V2`-GY+P z?gPl9XbISFNQKfB-vq=-&2M_mfkyA-Fn_20!oFyKDc4k731td(cDuvdK{;YWDY31J02vftA-Wu*N+3rlA80UaO5__-0|tO=sq0CSG3{iKEZqRY@ zh2Lk}Pzn5f@BnhhZWEpQwM>T=fh(rrqq)_JdhQ=8Iv4PT<*_H z&31SMNzxnVj%%!*01I!$`m5)C^Lyss5zoXr`P0EiYOoej-HgZxtG&>Bp`8Ij@Ag`$ zlbph_Wm&ZDo#MEl6J1a$|98lpISP?9bV5Cl@2W=;lHvVnZEXh?7fbu^ZJ^zY!LN?M z)l&reUo}MiQKqf^ITT>Y>{5ORbc10PoX}iTix?|swEe@GIoN!Qf8_B)hMjCcMx#Zj zln#0@bfu?n$(kAgjetA91`^ZZ;V~UJ$13A53r%3$I)NAPo;$e)Ct5Z9a|V{xDJuYX zpaXPRY%p_CW{OS``DtSnE<_}y&ZK#(%xdQkT$(qOp3LIL$2R9q*a9>3tk zG7@2wX^CE|a07S+V0mFs~7rdV9-?FLO-S{K9>Vxx&>dW@~)h43B$>^ReEXdTz?Ytto1_F}KCHP1`GpYb8JTjsmDw`1;0~h3%oX@MnG=vg25gK zv=KjfMnI~NWicaf^>+B`fh8c2vvTU)LD@TkxA)MoERyS^!zf4HD{A+AKfaD3O=Ff2 z#k%b;W?Fq=N#5tiVRqA0#*Po>rO-~N>sW{lPQV*&OmrU?nC}J&J5ai04CH@yVt_L{ zMo>I}2VFNpkGoljh0fb|{`ORxi`L(FoffqkU}(CV#7X-%|>&L1RZv8EiTQ3|x2#~fFqe=v(W+y_s#pgLfN z09nb9V10Fx=#XH}5{c-5Ub|Jv?pqf}=3^#5%TPWTt8gryv784n@4E*aK{*t$WUqg& zTT5ThnWkOxCO%TS7{V;pZyhS=O$yY?(ME)gsSM2meY`w#P-Tb^+34W40y&p*uzU~i zk?4@f+?%QXA@!t~mn6E4xRhJa21#KDLK#IJ%=k^(v@P=9)^0c~%q3A%k(gQr&d6rT ztDf`W_EE&&T_kwF>mkuvVN=N>tX{)0kbEEsx_9Rmn=DCl>x5Lj23-rY)1Z+FNT+d1 zf+7dqSzLJzh_^%5fllS^dJg~rWmK`YL7a2Hk8qMM2JxhqK%k6e)n=0hu?cgd2jV0v#l^4A5Dg;cDtV}i9FBy zl!&huJyFz+eI$0e<`0~ol^fQbAu8O_MQKD-oK4RnThh(0NaIDgsz?!1NUl$WUykrr z!>*iVuX^-ZPRt`&_Byd?ALM(*a<85%Q#n3V6%Dq!%rOVfYU(P7M~elu zg9r*Rj`5XJZyEW$q_aql2{aXrp|IuW;lZk(Xiclm@7AvosF$ACf5tT+%o*MSH+M-Z z!M?}OaslKjxPs-=iW83B1Hm{$dqDhCeaP4As5=F7@3@>k=%NzR1w2zW6TZvhevX{k z0g7L44;i;23WrSMh9)~!84KV6?+lrxpDFDYjSUr>j{M|Xtg1x74V6!=)C=iHs|AS% z%<+_L7g?=2z6#j=?dIvSf=^ifRoetA^Ccb7ryhLU2^fW{X?NqsVBK!s#n{O`%YdfX zA#0w|TU?`;e}^*aS*L-&-ePsII~^Mu{xU)>^)jVTys$KV{f(ULMB93D4fYkn55Xg` z56O27*2^~CmC!|i{vFv{fw4whG$l)dg0aMEz-&M4krqX!VWy`L?GQSg3a4WH+qE|k z^T2q8&HMAkv&?9fWYU~+T9O|UK5XN69$%iK!DZopFDJQ7e?*g{-qld(dvT@|sF2#v zf3hQS_av7nR5`z)ZP~ecmz_c{Mk%YweQHN!Ts&Y#?&m;`MsCh-6WfU5yV}pjOZCqK zHXKU(UIu3H(KuCvdx=NWx?4)s*pS$7ay1XBUiix_=GNgjAEf3K9}R@zW1t>p(P-`! zvYmW1!WlF|I$q|Uw24SsWq>R$RKc%ahekWetQCgXOzL~?z15L31a@w;5Hkv~##kDA zY4p*C)dP*kgp6pLH^mglQ0JF&R?Z~t6@@{z-QY>%;s z6tY^2><)!TrQlhx`@4fQo3cLUJ^QGoFDR9R#EjVdP}N4iS3KpP0;6NuT2gzN*n7x6 z#b7lNZr1%thO563`GE=dFSJ#ylNI-`Bc6(z zoh^_vTS^k!jjbZh*;DI_^0*})JOY}8_KUn#hDoiP&g|RazY6TYi@V?Zp-#V@dTsM! zi~;saZ6-lV=&B;C?|F(w2D0yDzI%c(OHe$4+C#;Y1)+Xsi zs)|0dO`ptKQX&@{aDA-SBBMH|)FSd?YVM+*l7BT=VeqXI(rm-OQmw>Ql>!fUlxr}D z^ACUm9mLm-mAPz0pmzD)w#^t#HY*%h9&OYtaNf*dg?Uc5eU;u-W4y1GNJ(?E zWW%bCnxd$Z*p|MYPVi*Ruh2mEe6O|X@{Fu+j8r%3j_mZ~b>~lQT091jZ%u?AfrvP! z4yLDYXK7F(Z zvIu4rsVT}NIW>I+?8?iyW9 zVbIXwq=3I~h&C_T#FBEpFi&5)+n%k;5IoU^98!+6Cb4!Heq;txu>*8@22-~g(kDzh z;>|Pb*6nlNcK2Q+!ih+dccT`xgf2?va?;5ij&tz8aImDnPci>E*Fw zWf~N!zl(3L7Lw-BoNkBTfLFd>5z=@`{xvJT-V^8flW?BV(y**yS-m1u4H2=B>c&w} zN4#?>?d*jCN>*Y0h^5qdU3{El_W4$EALZdvHifjD2P4?>4Nomf{kU$oCzqvttkf#P zLc|6$M)GsfPbjVrudyN1^+Y>_Mlxra`tn7*tcg`DIFW#OgAUT>gl-$_31QQ<1NA>4 z^Y-zBRHFNnn3c1SxdtK(L%vu^9=zqy(w1Z==)F4B;U?h5UTc<& zwwnTWR^Q}F=bGorGT(0C5n`YtIoU%rWPSAntKSf+l}=-xG-5o;KN-x-xK z=ZTnn?J@ib#3)|iM^zjX$+_?ABpJAxRB5S1J;N%?$oQ z%H|lW;#$AgURi5@1P=$Wj$mO|nxpZl(DzJ$f=P8jjyYb!R`<&=rlZUTRa+(4TAZNn zye3SQuf#vVLi|Nn?%))@ib1(oNfxwQT(M912yPN$M<6;brQ!ULJNZ`WM$xaM#Mh@n zdESF9_6`gL))HsHeMz0+m9f_%?!Tm8IaRy9YRo@d*DhTpK^x}ZByink10q6}LLT|6 z`myS8$YtMELbLmsqwa~9{VnvPHHskoxi&p3t=bsf4)zASvB3p$OV;2n9#Z!a>rw-o z2p36>#F-QpaCPaoQ&?PVP-&Y3Azt)pWwV)P6(PM|E0gstb}m9geS;{ z*yN5H22RP%hcBZe>}HzeppX)^%jt_RxShiemvld9 zo@MtuMOGHfU=2?To6DUBADJp+VmGd$ler70tgT(!0jexxm1{9&f1nL#D5O? z-=FAy1@_bA{@gAVSxg*atKgSZNS-MNhv&eN=$0)M=Q@muf0}-HmJ#LKHlr}ihZ6c zFQ<%voMvr#W#jKazL_}V+Ery0rQ<`aQ0)T%=d7QYlb!T4X=vvG_>%R3PIVmkF!}n+{98zoS~%;`f`Sj*LKn z(Q%9v_<1bw$OxV2UF8YUX~21c4fQ%mrI_y!UA~6{KuRpg)yE`vyMx@zhrj~$m|sKO z&tHzwve275odAi@4yLHQ3fHGV_S276DBI;F$Y>SQCsVqYvv1?1*k^kPJ^W7=pivZp z3~ZMJfFF)uJD1>31+#|m8h3-f7XsUoTYh%kt{?YUbyp%ICN;Hp*RhCUfBFJ_jO@q*9iVDo;dnV^R&RS+x`?$DO{ zXAR1bpT`}5PJ3iL{>KVrqr%luM8q908yr{UXcvWUgzV0dDuZ_SV^cl)_-LoVYslN? z+2d0!g7grGDAp-5g}S^_@!c2jMFZb!_LrQPbgW7vMIg>rIE8g;5j<3zO7615sCz8f zy=Ev8>QkV5Ab9-4-_gVG`ussFApmX?w0s4CoIt+Vo^AeZNnlJAoFO2RDnP7i3`os= z)7FWXa|7aRjlCz>G_L+1A7F$m$k2KE^YmZj8cRL^;QbR&WQkN@@hNHD5rinpBBV?1wA3Ih$*^Q|HAnjvl?fm%d8|e=;B%;2 zr~z`*FCjzwp^!b(WxP2+J+L4a`N#T~)s8r!&isLHTkfZz3qD^onNiA>7D7R=+j>J> z(2*$L)a$x3^YGryn|FEEWXf%(cR%uTM`SRGIemxP|NnsNtQ|wZo}RclR|rLiIQ_F; z+)FMgx#jj`{*1M_d7>0$ViL`j7mAzs0*LBCB@PCh}`E5am{zQQSdvs6Vmz1 zNQ?pmQ_%9(rBoDa9WX>b$|6M&$kFmR;MNRll2N~j+*64A4P_P` zhVKj-hqe!!*g<40U9q*^uo>Rs&15{H;{g;<#D7&#%N_vL!@SY}+`C3>p(`0u9sbN4 z>Kg1qL2gj9<6=`q_s(||7&h@?YW7*nl9cp(zdXa*qdQ6LMoH-&291Ddzc$dKn#Z-I z_$z_r$W->P1p64?ZGTm_(wUOq(B#8!$%g%(^w zR7e+#+K2uzoclBRb`@-4BKR4`-qInCdm_dJ+J6w>$eMO@R^Y6t?*HNTh&zz0#jUYTc3lD*kLvhzd>o#m$4pvo%@9XHW18hk^Xw=qs zRoFM&IYQFGYOc}ujuOey#PMNLo}_ZNm~}kI&it+FB;hhj_B|KaC|UPyBN=ph_p9(T z)r1njQ}KTXZsv~hP$-1a1WJDEKV#sumChyb07>#_)V*tztnM0f2D2w&3Y{QWn`b1= zbB1jmAjze>uU=5B0R(EuD1h105ZK3@T$;Auf%Nt*N<4DhfKP<|K5(X zd<}MpVI!UsTa{l2ur3xjj!RRLWfuNmM$I!5EEzqKob7uE7oOAUUu-fh6RnEQp1{Mn3 z`iQ@eNr#Ld0KYzQ_+I8&%6M#_OP*CSXc>yy?YIPC}c(%E% z=GOwq1?QRSfn@g}xs9Dw{!5cU2o$ zz^=AudpWE%(f#!l7y&79&Ev41Ta*U3#h4gl^QHpd*%q`OS{+I3jCnW;@GvyuoO<%#knbNR?$SF3Yw$yF2I^j0+Y5RH*QbdJG4hD}6zz_(@^NmHt;TEa zoEi(Tv+vOFX`Pd`1?I!TKcPH_hMzk8cW|ixC_ySksJuA7*6Qi5*jaW!H%GU|*j@vLrTkmYND>~e~w2|UE`?T45 zK6;8$Voc4iZ|CPl(T?POUiOuD#hpmD-@#nXJCGZ6WET_ekl)N#Q=K*@Wo^$92Jh1g zuxZq4LJpiJD?fc}A@%Ta2YdMgB6j(&CnyK971 zBwW_?1@NSYty6oY?0t=<$v$e?t>na0J?DUTcpvWxJet zta_Zjod_HvcRXeElc`1<_+nKLnFy-yXcn zlky_p@KfZFaH#dDirA40&z?TmsaLG`tdKs+rj%mmC+<*{3K^)xh%6m5wSI*82yscd zKxJprT03>~)U1C9HP~h6r_f+Y9Uax;pecR~d?suwqZGi*EduSi6NcsV?39m*>YaJ_ zz>Zgr^m<7W9qeGK&@W#TmKD3K)F5!X<~p{9fARW#LSF=iN@Di2jHM2;mo}w1NY81A zU0YjEWdNM5`O|JY!@Ru|gOt-J$`XYcz-PEy3Jkc|Vl@Zy?kbI7o(|e`I#lmJ3N6~s zSN?KO@?8dh8sLoT`>&;#$4b*Ebyr=ol2K=Pm=v22!cn@JdLIlT9)o>H)PJ4`k{abo zzqkOn5-X^5JXwYVB`>LcY(h~GR< z#bOz!ogyJjPzmY1CV_|aY9>I1fd#qCKl)VY$*2}*+fQyr4|fUN844-0jS##91?!Fs z>FCAAX9p)Z4gYpJ0Qp1FLs&!M^AQ|O4^5twfGFMb66bq@E-n9kISxuptE4Y!8Os3G z=NAHX_8oX_aqcelTd_Z6*M1BhzJ8Wo-|Wy#KLyyk2l0X#nxS=05)->pPd|uue&|=$ z7@YUmUyKGK5igp&$R`x*%IYZ{58uI)jKJfh>=2&0gjmDOzs!x**4%c3wuTrf{6u;4 zho1=;8xsqIKH=65W?k3i0WBt$O9TdgtB=w!)_VzV)GghL3RUe(0{fINK#` zy}q?;8h|j3-Ssg+FyW+;I@vISpb4!GrKH&-YrbY z6nZk}k{>m|j;QmX{lQfU+;wDQyv+@@0>yZ zvApp;mQk{v+f;O{CXq2SxZ2wZW7EA`qWqHKdxP6l#igXDz+>e(JT#@GmF2->&ehwb zzq$am2++|!rlDzoEiKZlPLPnIQ($lo-541}EMx3;7n(fim(eK0(6=`mqn6#&3pEk{ zi0aZXI;VZACm&pQ+iAPD5_xXh6uaHiL8kdfI^{k8)eHFlj0p%9F<*<-F4PPV z#D|l_<-Tdyb^tMP@0B@TO;H#r{JYK8O~0Z#{g~}^by=^L#;8f$HGB zYVB{`g@V?t6ua7*FJmd9xr;*^Rj*yLdg1s=yBsO;8oW{pJp-A~V2Dw>=96f`ZrZS= zt6+nG-?Ue{kMzy8at+NcOUTQZZ#oV4w2w z8V{lwmn^B{g6(SCff&wsrLSJc+h6t? z5B9J*B8aw^b$mXOQN{X#tG>)PEp=42--S^2=RNbd%k?CHiPSK7%bxMJ{*Zv4l+)^edw}bSou^>e0LN9iE5l&K-Neq6an8&4ee)v0P*l&p>$`+UxHVb zFa;gXNsy2M7G!sb#KvK37L1vr5J|OQf2ko?%g}ciK=ZsSK^Hxqg77zkP7iZvIJYZj zXWtThCo(a6xH_NIo?A!J#yj6Bb;sglpzg z?6%>dFxG?5F=vnex^ji++u^)T9s zksHeT#wHI6C^L>NUA>Bp0%8Ku)~K&KCY^%w->dB9%i&~rGa}xp+7fS2HF!{jFh%It zKI{{SZP@O~GDzQc;m#X}~T+_|>7F^JW3?^dq#+ zk6*|>kod=PkCFb8fqZpD0?o>dXx;XLp}So!Nx`w+M!e8S$%EtV`W7jY87~UFosr^4 zt-BZS_zQSq+vE=#bus_dMQv-+fajY|oyZbN;`a zTe(HLvoPmyqg9GARz$r&(^iVW#8baU3Z7O?(C|&D$khS({YUfz0I~)b)ODL5KCF~t zy>@ql&NDU1IH%FMwxl5<9bv{&<6#H>8?L^UMQ&4Fkm>+M|XjH*Yzh0Texqus>-JmhZZ*% zfyXgQZzWrA&7WPo-Zc{6ZKdcpWOA@DsTl|j&B#9n%Um@KL}JlrXN^{7un1jhjzur-ZXZrq-5C`n05GsWFAcdv2Y3ArpzcbQv0=sejOxG-ZzDX{$E3(5Fc zF6QxQ38%U*^DqQ?29HZaE_!&^Fn175GS7kXBu+7t=zRa~n&mck5)*U?Xc2ScfSt+L zFr#@5sH^UfIspnl=6g)r!4)_*2;g03pusAlXaV#5f{CB%G5NfQECyPCyK^_UJMozv zI?TjDXU;$by;G_tjc|_K>{vO$E`3?CYJ5|eU)3gWeBFYoIFvIq>Nn0Zy;??684^rS z7U7rCXQEQ2PcZlWfxgDE%7IJ#!`Rnb`h30557%b2JR*6FY$At357bh4`exDF7t452 z?_wxj>G_Q9tiCm%RpuHz?69ll_vh0(h1l_hJ*YzHE2Gy2VS!|LSP~T59?>CfCY{YC zczaoPZ6u!k>6XcBjh*+x{KSbKt_l$@60_vb-R$P--1==tuCbYdZeX1E0%C~S+t`N; zfeY#Bf0AUfMBS|ZrN$pSg4;#_3hJD2DSL(n*(r}zV|ct%RJD|DKkZRHXxe!|uzS17 z)CKP8U8u4PuyKnN=mH&T*r^U3ptBlI{*rYLga{jJY275*<(I^{4l|4|t9U>zqPa`%wAf_eC6D@W!B`CE#KaEj<0!% z3qMEfwI~nxvc%`W1t|y#=IWm5Cv0eWuXj8t4D*t93;p9{NTM_45u7jB3MdF6I%28D z&Zd>`OUxIGE?c8QOpe=-WN8g>0P)^HruA<<*IMG>QOfWNxP|=AnWjGUd8R*0d}@*W zD{Im(3#L;Voo_|*WYD>!U1fFYmt}%`Xs7(>g^@n{<;BI;)Of1X!qXI;pC>}T%E-Y6 z1;&-zn71ZWKN~iW^IErw%$A@VD*Cr4YOTM!xJ8mZ_w8x>4Hn`=-AaeDwyD{Mdbpo$ z3)#Hl$c>)E&I^b2i;xqH+Pt5Y#3xduPA44Zo}qe=>}B_x-KT@CL#Oi8I*erV_R^na z^>c!hY{}3|^ayAG-}`)QmeqV>CGP$Ar8snm>^=vnRt}9y z69C$joOGxgQ50J51~aZb$d9x0tuA7mhiaM5$yj%qaZf)3QvUqGkVmyz(mZ>P-{>|J zfKA5>Vq5?MS@NT<)uJ7t)#xt zlGv4@t!srXr_#H#R1ldq&J{DZerI(P?EU$pT4PfBSC0F$VWD+pN(9pash&Ffs6nS_ z+Bq`b(yqQOhvuJL4L+5J&-a_rI78PDrk_!Yjo+eZ6&Bmf{k`m8Yc+$B@a5gk$am(f zAeX1#@z9TZ#h2cL+gp2WhkVdB4!Fl@()O6d;a<&io2@D@q{{5sv9iV(26|| zt2e7gX+1ahn=g(%{{-hAE6MNbC+2-~>3gD6I`x!dS@Y4}zf-ZXM-n3+;HT-35L4(7 z`Ql%wEA&IO{5*(GIMP6eURg^&%Ne`Dy#zI?=Q{9BK02MwTFv2vJ<}F<{u1{sE8gwd ziI}0$xc@i52ugB&{Qo&&lmwPxR^8g#YG93dmo(P?T>}gFQvG|-8HVEfHi+?FE0JE# z1mE_4VB9=jXFtW8PHD>k^q71GhOePl0zEuN98|WUZlS?ws`NiQ#ogGF z85~~_X88z|M+->$9KBfsXgp*P81*?s4#ofL4t*lz$9dhdbUAo&+HnnoiXBD4omjxC z)(7O;PI2HvvOye>0PZ4&QCtWjBsrhjrtVnMzkH{3k|W@F561vKE~1f)eT=Nyopp8P55<+3%USc|iD}xHx;TIcg(s105j91ig|!&WAtRLUe-(_~REgHQ6f#tnl)6{d z_9SqgRQg#qLuJ~(Z}D`L1qd`1`j?UehWB=3&xHvnC>?|k^RIzx7`JwF7tjhY!1ZY- z5U|sA0XoC#pDh9cUVsaPc4Yo3I3c&qyolz30`3Y#VTa)Vf#qFnX35mAX^Q$ry0QDn zW2kfa5lDD$%=!#9OXhAuU8rNRf1unSn4<{(Zr}NRoFKq8n+hfwRvjAC<^*0frHQ!a z&j1RkKs0t08U{r{^UDCxnlcT5rJ;BoQO+kIA4&Qta8LUdhgZ$>Ao|ptM5?bymRrZ) zyXBmn7<+h5&$sU5_}(eJH(mZlHzuTwQhK^vkmis1Lu)QoAbb!^P2rhF|BUc4uSxkE z(#B^`y?u_2{`0-b8)yO)>x!RTx-qi}pE2tQb4(LMCcoaeH72Mo;2)$;%-eV;Jwvhg z0G#c|fc}&o^xs0tN^htM2}Fug4cN{Q$N(3ETrKdHWIzk4o4^9VORS*wB#^qND_CL` zC$`BU0F(h}F*O>vF+^><0l0K_)U^@Rc@02Q)}+1#srZ6En_BuysNR$Q(OTCfTA4kI z8lUkgM4SA?GoaH4jh;@;8*KDCmCc)okxaqtZqg+H zq5)m=i$QTJ5bovpku<&p$#E|J?db1 z1#LLvblJYUqUbjPm#CQ!Mj&3Up%DHx9$Fpp&MTq-JaOco_zcZ7yQChLeO zb%}$pCUCt6oC6N&4J9Y?ys5>M&X%sPtf%r&<#WuD%Ns!D{kC}2;IotC73e%mpU^er zNl#D|w~(lw_Ns)0pO6e`4FIB__TEx{fLslePb`=rAFcQ^jc@H$K&kXvY!|5d>qFz$ zUT_3IhH0y0TXL)Q+C#E_A&7GH2HIl>b}C?+wX7ST9`)R2?>Pm;Rj-J2E7*Wbh^4A- zuc5J#>8I^LE1_U-`67UY@rg!#npQ9beYaJC4dT#7nGSgq^xb-VmwIUY6f);Dfh~2X z9u4VWD(Xg6Mm{_Zi!nlm)yGR;H3C7(mG+L<4#;F|Ydm>GC={&tFu1qIa~0Wz;+ zdS`#uNPQ-bTZGwYsPziuA3Li4ah0;m2mJ`IbG+Js4VnKy;LoacFEAflSG#3*TPFyS zo%e_E;fSycI#c(2G`lSg5^f7O`m||M?5fNOXmxArE#PodZv)oD?(0ww{m)+qw`OWl zd?7$TJnNyA6hQA=XD&zYi&V7#lLESd5sgruD`+08UA=o8n^AZ6WguIc6#k7A3Xgl@ z1YnBz8Hd_|ZRX!sZ6p)eWp<*zCl0kBu93dsoTK+M_=;t;`;QIeLWqmdkKDJE0m3(! zZa7hJP%w$=NZ&67rCEv8m^UXV3ClPtzYFOrmLaN)TmD5;%lrnSklGR&b}$=0hvS=xNx5R!T3xzCH|&?m-XIYZFM*{ zhMMdmI2och#!BWe;x;D}&FCe4>e}$}`b3ixCktc00u{`hcUW19@#{2i5c%wgAt2hA zViqWRm3hzefv9I4?i1Svz}tTxT-66F!25|=JT{$sFMkK5T}GpU)1@EWGVhL4Ln%@$ z)H6bj48znI)CWJBW=-x)0qfF^#|lO2jW>U~&yldkssxJVgLIrlqZ|hMD%ZFrD8_iG zw!6LuHsoC#b`N{%(UjoMjgVu)+G)K(?9TgJF2)qaZcGe_n^ZLii;Zz2o;q*WMErnP zuK}BcImcQ~1Hn-1I4FsH?_Xs66)e@l1_HkDc|du5%QltjPTXas{tau&J^@a=7Gq|# z(wn}a8*$`&p{G6dk9{-4LZGa0(#z|k66u-Yp`HMU?fonr-^iw*a9>*hgtgzU%)G7J z$P#l%DJR(S0P|2)yYrs>zJz$=sBe8johixPd7E?yO75s(u|5;XYnV!TZ`dyzvR>t*7!%ky4mS_nRk|%eSQS)1bY|3GV7pv_G2D>aK#Yr^MJA; zkuKIQ_1Ray#mue=ZgEN~-tyXQn&>Dj887W14-fMRzeBQCPdFpOwoS~!*y9I~s1XAe zER!l}tcQa+TN>e86H2K+&S%U*Nf8Zc?#7L7-y1}%Gfhvz2b~G}j2gH0QDH8CGs;=u z9m-Q+!(^T!neye8I0aU%T1>{dlG+<5Id_sC=GYJPpno^E0!rY-8oRp3_g8H%EwxD;is$i;~IEg9uUT_hU$JxXO|4n>m;SVV}v*C0+fg896L ztXho|#$1%}_k5x{z%q~=oW$c^qfSb(aS)g&0uGKOnrcOrhnm?4Uz@0#BWwLuuh6AdJQxS+l9Ce>(?008 zBd_xjY`i*itdE57D~U3j98o0r;TydG<% zdZL`X)?10lcagqE2oV&J_23HxTzL7K601zQUR-QukQEh{Qa&e`4Y3f%$;5H~_5~(m zMzh2;H4dUK5LNH*XI2BAQY!tY!WV@yX_dbd!c~^LO-4^P+*Y`XRAn3cm*|d!jOllq zIqpwmt6|-!6NikdZB`)bs~+v zRPm|^_sZ>B4v{>)Wm+sN?-*IQ#VIw!oZgbW&jiFx1;EM4U(ktyFpA{eJV^nHzZzI? z?P7C%m~F%dlwfU8zN~IhQE;Hjizcqz3o>pTb4&W zX^F*KZ0}1EDv6{V1P_0>%y;bYvmuV-8c4({@Zl_alz?cNq=w4_B3(lq$Gq-s3MuRD zCgC5tQGLfhGx!R*@TPW|ijIHvO!<=;X*@+o@!C|8dK!N%=e6z@Eg)5h9D0Q0qgPpG zJ$B!@C&>QrsoX79mkoqZ<2Y`MH!#`z`~3HZc)(tSZaM=>wCK$!;CL z-wY)>hjS&O;&sc49JR1ZL884uXhbtvxREw#aD$eOSz3erS-~6E(;atXS1OfzJATny z*RGs*?*hB-21lo&p-&hg4po5dJu&zliUKNZjpq1O(GSqIXiDEtzLw=RI#gfVav%=5H;Ji z=nNJ4ylCaW7SZ$R#m!VEkg=7opm1vQ>*(3|n7q?$AyaQ5X0%@(_GsXwlsDaRUp(4Di)UNhIPERk`1 z+C%Je^CdN{4;Rk$I}(hp)*7Zc;0Wy(y?7{C9P z@0CzNG=C@z>%DloatL7Z0eO%ipMGzDHHC5I$-Zhsn5nx5xtvk1*3=#6#XV$NcnRNA zBi;2c1M;JOCp@;d4;#Iab7Fy_t5;tR7tn`OhmmJ~PVJ5FOOQN)?Q_|4uN!u3OJ{~D zMD)VE62C6b*$nbeq}?h>5%an8dwvo((CDCtPB?#;91Ov>x2mx5=$1Q{Lx>$TmOb&l z2zHXO7Mx|tY&_I6_$dm~{v3|U5be#% zRrzMT_d&4<$Apr|SM=R}vkqf=pU?BJqDlK2Y4g*$x4zBEM;BONP~cd}m({(%SKAhl zB|+jbta=^m$SEz}V`byCOc5NXc6>lNO1V8^MSqGlL*e(QSvU_n9E_t2uitc?7wT|K z!K*eAiMLou`gP$m9%sNOUOnA|QmD74@M8y`0Ij=b*9VP?;mx6M-(fO6T=V6ud9zOK zWL%}mgG)rp>vf@r+W~kkyG>G;n-}LAA5t8L6L{Bi3|fP!2u*{H0gH#=G5MPXt}9# zcp|=+Y6}j9<~}aiT@NkR`a>d%ta$~ZuWCeGpXXs$X9?!`D;s=ojhDKb_M@CHJnOrC zihq51wskJ=cl8cQoi;WA}hPedLMub-g7LAS;nr)UYX`?i=!)!{0Y z-9_A`;{86-iESFdTJNSuJAXa8OILKuHYM)M8kkSq>I4;h?J`3dsUcXB(^@>*{!pQTyiWUpz<5m=w6TcPoAvG*8GXbTz{`Hu0Pl1h))o5$W zh`Ou3QmMMf6+p$slK6>O9!A!*epQSJT5ZND=Yr9^Nj&7h-utuqj!&6$+6qTQ z6LE^hA0Xe&GKb~XZGdHEK#4Kxr0^-L{P=rCpVEr#yLHW#CL<0 zDdd>!m;^o1n;l^{L7*w!BXj!Tf-R%mR9*lfpbc7`RbGzZ6-DOoQ%OBvMh8)mM_etzZO$q(f!Y%yc(U9A&zqN)dB@v}vpJju) z{rBFqfK^cdF`yNayfZ4qR(%b`c(coKGJ>4b*hR;sSn7uF>yW}tKTMwjv(qIPEQ0H! z`NZ_mmpb}XCmNvO=|rsz&nD_LpKs)iR~e#TODo*JU381+@laU(<0Kj<2F~-8ay3W= z#_TR5%Xv@5#L^yTsZu@?KR_k|5=|vz{fZBeBc1yA1qfX2>Az~;eaPI!lNtH}gpXQu{>=M!ynQfgdF)$W3G6zy$jpO3h^4{%@V#?~#;ss`TObt@hMfG4?{-#em0{ z2O;4_lHKE8^Wd!Q21jj&jL-3QU)Y=b`U~8$nj9*QjV3c|0~vaif{cM-O`H5Wno&#Z zG*Jy{Mqn`!ZJM1gBSeIcH~6!IPfk>Yf5hZwmI1}devfzuGtuJ>rY>xi+arUtEST}G zI5A;xL3~-4eIVbT&CA}=3zXs;Sjz3~YuE_h5LzZ+*~)1mR>_#9 zBnkwWz&qH5fXL8Dk{VN$zlUuVF>wu3t+Sv&li6R&1_N zCsW&06PKpn_g})s^V7}bLK7i2zGXtCCbeLRnRRWi=h4Of`A>J0PKG!h9pi_G)8{)% z*c#EA`h64y{u)O@MU}s(Qag3uY@OCIsK1khmViChMcpM~l%E#j+2eNcmu&Hd?P z9K2_OaM1`dM!Pu!^Gl<-6V0S>)BS@F=6+jGVUVEq`-uczJ7!T-r2T#Us6CL+TCN+* zMvt(8npH-01`bH2&mubX|LKnenc6Xc0)Et_?sHEYeU^>O5*H6XOz12mc{EQavc#Qr z2R6Dvdb+DaiUMRa- zMM7QROe68PJR%IoNs2Ry-KOkyFUB&{`52m4nG2%T#7?LQmrvm9(kJ^y9;P#Ux!V4Y z&xdBSUZQSQNjwBVyvc2P>|ctS1%tDRkB2`yo6db_W_&x0NsdcSz4`Wypn8$Ha+|wt z8|v}CLJwV->=h7K#aeW}CEhmNtGDMHbF0pNt$&`vJs0G>QJm)Zo|=o(>~I8+U@2F# zD^6j`wbxOtTbPb`4KLR5pw%l9A7m-GxUKc~wk&`+LcMVBL;`omW>LWlRYIw#tudK~ z@=v`DmsRIQXN^ua6p~N|BzBobowj^@px>(MoC@G&MlVKg5VQU*^HBwqSb(kf+1*sM z+aN5DF`gR3tUaDi29m`{(hHb8DG>wD7EA(0;=}8A;^XCY*Q{j~g-04b&r@$F>4qz% z#fLp+E&gEFbb;i_ko1iM(MToR?#3G7^~H{17k;ia0pa{vRom19{KXTWEBh}5kQq8t ziMH2Iz#MFT_U8v*Z>-R4sogRyt2uqqkxe7rk~ zWE+%@$hoH!SGV+wKi;kpa;o^NHT1qx@%$L`;wnt8Vys+`uf-@TdXb<>$|hIBB>jWk zPTgC*Cqu8B_IG$DzIWP>ber*yuN{J;1oRk9+-^Yg7AwU&!NY3-k%MIrU|#8m-Q{|E zD<_Bd;mw@ah)Gjjsn3r{=0@Qc1an6Fx>|A?DYr+PSdt8wBqDa!!^b3&@#4+V-sW>Y z8|O($tZeWbG5B*VVPaa;TVrn(o3ftK{g-eA z6Za>tkTojGJDvML>9hZXflC{3ADT=Ols`xM9Gf>CY;DVJ zY#1x(@%%VeI%0Oh+5!qAbr-+5^=%(QKeF%ublHi~m7C7fz6P#KDWSp$pTL3VALBGF zvMeA5`!V+GY}s4uT54Eb{oq)5<3?{)U0vO*2D5oDoqdJ*e<;T*2arGl7F~5=5Vn&# z0~nuzCfn8y2d|}i|2@b=G6U%3g0f-@&@wo1o=rc?S&~T^Yl-NU+6614=#Z;Ld9}r} zccNxrFlv8tl`FV?G6Zc_10bx7Q4O*R8;v#jLJrsW6_EUxz9AFzGr;4)y&RK#Q(aI5(R(1q3)BsOWh7H#^ zwMyK#c+R3#p?Meiu{`-mn`f(iuQLfYfv516nq`kDlaPb{pxe9#aQIn}0V2$XOUJY) zlj5+nAnp-B)ti9z$$#4;vkw$4_5;|E{@=^Z{FguZ-YSTW3>xu3*`<9yAQc^%r9n@L zjqSl#d#!P$>G4`?^;(CSN40Q~ENC8&f?*r+MnKFt0kEdPK^k5Ih7 zw6v5UIUKwEFLgsF%E@;{>?ytY?wbGAjNV$zSYYS(&9E2S|K(8C-b&>FcH3<&%{o29 zsk!%oe$5>~E)lYX5bst(y-bH7G+se9=DBm*@jn59>Mz$N4N^5<@RM*qJneW5tcVSTV_)A=4S-{Tc?wmg=A#W@ zK&~5;Y8dMX3>3eD62pMxy5A{sGjcn0ju-O}Ab8BD+&J=nY<(Ppd{2VH4|arryr!iC zV$QQZ&pEZg;nu$;0W;)(k`0|_>kbK18Urnv%L0_m)^}|KQ5)>^6*YW!aAv_%)KYkj z9nQL4QDC&$d8FbGEXV^=@t4368s85o^7zeAfZY{_GL(!{DBmXD{C)Y+QKe!aO`wN2 znt2SEQ0J57Ak<{%m7MBH@H>LmL7=9zLLRhxU+#5WFG_%)^twr2xfzs?ecChvk-X6- zK3^bP*pe{ohSvf(Ee%RQ?zGz+Z4I4x^wWQ!fWDOta8hLAQCk(e00XDR)NMeu+?Mci zfpe+{47E6FsqB8iCKrsR0#YSbsL-!>dE{PYyXw;5b{|CZtgEnG8U>md&o%)!1WyClsgeqe{NCc^E-oRtJiISkDqqDaZ)1F`=*Nw?U5wYMFCe4h z+&7TnV-NHa=BF7u1L?3n-6T#Ld^^AU`%%zZB`2wb<6%gh7OyArDs6hCgawnh1f_m zBJ6#ZxM#x{j-SpS*3>{CQQk&|`TvHA>bCOsgjgu-_k4WGU{vB4F{3mnJtf@}Z68$g z0V84P^qKh1%Xq4(adh!r3if(8?*wlVmoTRD54>t+jdUH=tQmB%I#twal@he+Utp)IwqzhbQ9xDEF32$bfjI`4#rN zap&V+Tws6NUWi$oXy5$#T3d_76%f13ej%BO`|Jz#eu=6)`|F1#Hh7zb%}c8Dk^UeI zI-`p3tcsN=Jm|cQ$!N}!M+cfw1q_Maostd8GE^(2ZmV>+gTYPcEArOIYA1|5?!5$i zEC%@y7rm%q|H-r=>-g2L5?Bhe#~>jpxuEV5%|*z=>!C$|41LXPG;`n}uiE>3K~0>; zwp@4a3Ms_0o;Z!`=PO$9aTb>gH#Ie#=FEFhwXl86_<9S*cCtt>r0^JbJNJpU25L9^ z`XW2c)ARL7=FS7(Wi)?+z)pXffVFw#lK0|i<@vX!Pv2Hdx%v-H>%KWVVPIfTp!-a( ze$i+kBmfZunwerrs;M?EGUPW9q1wY*>XWeI_xbzxah!=*hQKLHnjKcF zmZQzGl1WWc%-x0s&n|0Lzju>cP|MOXxl8AgVZHUZHusSC^)Ar^d5%I2ZnT@$_F=C{ z$I-DGvo`u=bz7BXgP1m^C4qd=FfyswK6}?xQ##uG`Gg}%bT|heE@>5?MAJ~7&WB?j z95QmWRaj@)8Elj4$8CSUR@LiUL7H2FpExlO^7dB z%N}VV^LJ*hu#N>va;#xPWIftX7_S^KF?7jEQ~FrpUNCKje%Mj;Cn?0=^2+lbEDQ|& zgfh;2u1csu+_^Yq*G&~}l4r-M7A)g~5sBK}B68GzS3-0@oQTNaQ{EE-vQL;9#d1k1 z&xwNM!V~4F!<{!1)M?c7S0gRT(+UUW)eLET5Fa=*-SS*GTQbR1sUMa7Qtlb4A1{cb zPZzferQx9^+NO3Yd;2LJy(xnDrP9Zw)aA{|z5AvHu)j%o=}cYMXF(WpGKZP4`o_A~%;AsG*Wsf?C>`1I9`^~W+{>aH?>v}HQWoL6d_LlLVy%fRQO;a;P1gUkM7o+L=wagMpKio&puIO*3JniA0%&Z$0EvfeOGF?H@pp67g z*IN|^RB5)0GOmj^P-Wyo@E4ZSdTp2PL~17aVv#i3|P zkd>H>)Z5|3CC7~7KX$2-JqC_vH|&`jHmv!LpQjbAjf=4#hqC)*r;~2g67biNrym^n(a5wcdu=~Q+#%wo+$|?MJH>90|C(BJGF}`eapATrEX-R@$ zhW^5F7(pk0df9W1;w~nBYYzxcyoBKxZx))G8`xx*D&eXi?rWJk*)^0MBY25}-=HwY z_Arl;?3==BYRdIFic2)%s+;AiYAt(tM<(MJTAi9+*F;$Gx5f4aJ9Hcc!&?zcz6d|9rPoAq?t?zraWncW zB19}6gFb5hY+{P2c@H%Yp0O#x%#@R{FNAcrWT&2~*-hl+hp_SF0LSU#SG}Gg6Y}Gd z(w~ZL#ca5$GyWk*dp^hPB`WHSrbA=Ce8v2jDbz&y<9k^lY{s<4bJ*k(!{rpwxV2hK zL&~}SU9hQo!YPRh5>EZGExU}W61>EBN3bb^+RZ-AIgbF;^Wi2~c$a4ETE&#^wU1bG zh%;Q@>iWT^k(}G6OP2eU-K$FECTiV{AdqaUWiVgb*-oWub@L1*O1K4^R@1NUd7&1A ziBZJ)x%F51`e|^w*!achINzInRk_i=l{J|@IQ*d5OX><#6BV#shE!;kc^zf`{OQ#; z*>-s1E{?9)JO5}PXSs_irrC*a$JC|5sfz>23ICp_6=RJ9Kej3v*= z*NOybH+fw2$Ft4fZJUdINZ|=I;sK?b#6$2yTSoOE<&GY~1-A`rS@SfhJ(s=3Tc}KT z=e!}~0dSCRr_aw3g$HjU%jzdQ2WqEkDK0BGg39?ED&iM2upEuWNiRM~K0B)|-7;LV zw_(0)6)N8FNe`&%s#gmzd{j9UHz#vRal{ILXC|%*vQcb+_3Roh{*pHI5 zLy(>1TX@%4i!N1}?NtbLZ1vc^UBki04{$8pY1pA?+38e1IVyJzavhg4oI5EH{NuvVJChD zn?U^b5d@6ie=g^?!56pZzvpXmtnntj5z2GoIIA7gN4@sis{DN7?SEJ;WoLlh$%IkC zBsf~N>j6y2D+P`b?{&aJHi@&tGtJ$x+?8{6mvM8V=$#T{wXWB+HvIYfFps2>;MIgE z6Sl<(<^pB(`PD4nQy5Fm*LYWf7;iV^9H?&E+cK0}8l>4WYDi5WUmAHmZ%0M{s5E}z zYS_Mg_g4;+RffZKpVWnWyIQt^=lv#BX=?z;KmS|IWJi=Aq_A`paj5mB$c{U9S_pK< zY`&ixEqLh&a;BwbXMo@lUOr}#bXx8mo<6reoqiOX+x5ZyM~Ru@()i8IL&c&?5ZL$^ z;|ZOcRHdwcRH-ymT}(jx5qa5*qo(qv$IayV#iO6msOMujda>=Le0-)xf9{Cqn2>cfKN7%|t2E4lYvq4#x9NQ~ zXLIRMsog^+WOarS&6`%1_K_(=MYvfQIIE9KwKqK#FC)W8YixM?kY3&?BPVTh%4NHSSpRvFlNXmekrJ8CffFm5Gf9DEESPh} z0($#Q_D;7V=d4bsZ1_=!U%)Be?W!1*fVcxR> z7z}BR3(j>I7vK zSZ7b%(*<*i_Z18J%f?>7oHeC|WIrN41B7BM<2Rm9EIyOh7okNji@h!N@s6}XdGOnp z>QIC1!yk40OQWQYO9~JV;p8SjqG@taLc~wne5X5-{1hP{+f{U#1Hw%5k#XO(spe#) z)j@hZ0#+o>s^I6^y`oru!Vm-1+~Rze*rk-2AMjj#sM*FN}ZY z9v{mk2Cq4due@41q%B$X^mD1^5%pv-dnNHGS_tkFCgwl+-&c&F+P1__4GDhAYqB@s zRKUr^QLv_2reC@CdE~eHXy2b-?_G){!z4#G6#h26+#vG(RkR@`<`i3;lY{s(uAtjJ z=D9OFfn5W@!MihbkYRNjB!Yc}+^tSP+5XZ|LL>q!Z8#;JYm2(8Ou1@(I^5<94y=bJ zHnn^z1xS;J(G%MGr9ays4yC)%JVnBcr``Dv?FYLZ*8$hw!-YT9!Mb$+*dsI~Noiqn7?%JeH}Pweer7`0`Ozzs zCQ+p-cY_I$Bk|VcS(IdoQBIDrUy8BE)|gZ^1LBO8=Nat@upTu01HFJ-2`nVX4#3CI zJBi}#*On>KbcG?Rm}ri|*BZY`y_Dp`vCVs-E{IhY@)%Bru+!*jm@IZ9?))Gp-R4}! z>!GulI;4)aSxPqC*-B=um&FnSEtO(tG}GV!#`RGR#Wx{ZaA zTluYHB;PbY55+6~ts|zdz%+m{p-f)T_IZ5zFOOMvRDC8x)lUQ>S!pb4TxTWz8QO=& zl!1VTxMDV>!~KY<_9Di>%fPPL2MTltB z@M_13;1xI+veo7XX%4&^ySE^8;S8tqYeOIGv-Wt%i&91`=yl406QQW?A7XqDi`;d zSk+EFt+PCIE76}O3-3G)&D#OHMgzd?P{(SK7R^eRgP!fHQ&&(Aa6z0H0@dyU?OB+U z<1_=p?FXdde-gFr7CzgxDb7yi+R>$|qF=_|;_uqi^F7(#Kz;-g2Jh2{Glv>m6j?ni zkqlZ#662RKCi2xOZGoVO2Ba@;%&jZk!l-HH_|F#i>@(5Q*KxrDI7W%4^XjGDuJIQ4(Za*~)YH4=o!?nTB=v_zegbpZ|qI^94MfE{Xr$;Cuc zq%ZC?>)I8UJNtWNLPsz3gxSr!^GXv{mKE?7Tk8Xh{h>0k|8m(UZeJ;)-|kXS{QZ*t zW~=)%A2qm3xYU%6T50>S6za)6f(&UTVO@}{2Z{ctJ1pT#{m@E<8;v?CJD?TB9t z0HV*zfa5lUJc39F%I!^G8?pI`QhHPR)&rCwHl-gTsW5^B7u46Ueq zllJS`mG%7~YY$pHUzV8*SoQC_sGPo9x!TPQCHs4dkR8^IpyAd%yUb{gACb&&_SW3G zu|OV$CB5Kj1a#Dps_T3m?enbhYDoIJwGH64i_&!l2#%mJGzBT5xINXQ1L059hL7vr zymSG~oCfYp%~KCvGXYP)CujdH!>gGq5YuR2J4K06gPMc(=OxW8Z@hfvj*KHoZGES~ z`!nac!(xPJWU}6aAINgV1Lt~l8*%(dmDIPqY_5Y}$37%^rtl=xRC$zB&3KQAd!`NT zPO*iJzt=cv>KZq<{iLlAa%bc9$@8Vd)rK%U3NHY#CKOF11ehH;7PL={*BhoCi4na{ zM~m4><(X+95ka+U)JB#E~g*5G~hQBflaivj@;2revwg7O0qf%m8wm(HzkEK}r6JlIloL?~< z6IX2V!K1=`;gaeAAewVnP1x-aY0h~usCyS+NGFG5ZsSJ!ruk7Q zjR4><@HaWdCj#v~i}40d>rApbdBtucLb#4Ak~j11%Q5GH^F_>5_fICuX)?$re_6BO zn07PcIo!QD0y3xYZMALot-LVZ-}FbQ>;bFd{%J}vUjckziVm5#l|RHmqJ@tk<7VtL zv*$RyKXBoh%4W$tQa!b4sfO~;@b4e&={qP0^T@vpIXbDU3EcU>T<6=YtX(w!lkB*# zXeG~Sa9l3~QAcoWkb)%v>u5h3Ro(g2$9s4_NpSoUWL2I(8LB0dM8_ByHl#$}Oo11! zO!)I^_s-_K>BszyIKR#ZCGQ9J( zrD7(SQ()%t3~ZN^!HvxC{CR|0X>1nO-oo?9dj(1&(6rAHpQv_95XZ|vWPT|1t{S+k z0;{;jK7nu=ra2&__Y>Ryf-M&bk38RQdQv|C@c}xJ-BS4RF2G)Bgmat9KBCcmt4$N*R0nc;gdhq z0vnXS78!+&Nyecxbzr6_=BF4KiJ#am6tyY!caMF+)-Pi?`sA7@&}t}qr;j<{Ebr8U zq4f3+P>Aiqz4PCx)>ZIv(fqav5`Y&+%gMl}I5D(ovQM<(Cb=5w17b8?oZDk5HhC(~ z`Zknl+w8^^1EW$i;;`vRq@&7T=*1vM)V zQkq1Bvv|0NEgcRo$=~G3bY$L;L5{4KV|d;Ya25J9VFYPSnbnl6H88ESh8o)pM|&Ad zsvp1lawb~OjvJ~F3f{5}-7$7W(389vwqxTGDRY_^wpuO)Q56qh-EeJn(q@+~LiV$O z{F)Mt(whz36xsqt;hJ=bvTCp8OAGy}#c+KV+`klFcDatCPWMdrVZ@rLC3l{gzob|! z=OXR)4tEk&@Mp3QB5Kof922EMeBf483z{&}Q!^afQ$O6eVFwSVMO{jrwU9$;;pdsS(Z|Nx=7TSNsvm zjv{=pN_bJe4MqjzuJ~M*=7MS>CA_5FGzgy}bY~zPzR966f#Dpim=5poCyiaKZaCW0-u(c@4~UfaS{QBOxa-z^X049&{lVktm41x zw3Q*^q(1K@*a{X^THCFnCc{X3#U$A!Cjz9Pw?%;~vl$6RHy&(&N}LR7yWb~(@YsS5hhz zzf~flRF~?X&2<-iYJu}}@dlklYr0}$3=~7bPn>DyPP7UHgyOlZBr+r(D9{=ko)k8 z4R1s$m7_M5ek>a^WN>S*kmVjXHJaALOvGE!I8GbXy!^5N9A@o2sy*W}baY4ehHaX~ zs`sVfNu{S{`&tD*;sAS`k+pd-(GO2@rX%6bRA<@ztRCuK8Ym?>w1>QhKvA`IfQFOB z>W^&4tfgjfL7TVf7$xP{G*XC-%EfZL{o(p4{v+?Qw-xC~I}A!j?a(0H6^WzvLQZ6! zhF<6!YB6%nq7ucC2^n@&rU#k%S0fCJ^!&EiD0+O~5w*vQzrOHZV39%_a=7I}(ARjP zwREQK4Sm-d+DIRg-Pe#m+-Qlb}e~yJQ`2av4<#P?- zzQo|rL>To&7$a_FfRvFD3N_64>Gn5?e2=~z{Uiu+=N%wDRzMaUs&uGpvgYE&r;2+z zm4l@@x2DWI9f2_sCd9b;7IDt@EHm{1OwDaj{^)rCFc5|L0MJ;4VcYvBEqU_$to+z^ zRr|6HXTIG`k$wO^VCY|#H7|q6=)W~IU^tG!_jNtb4v|R0DK1H0`E3|0CqY{GKKp~! z7w}XJU1yZmHR#Y%PX?>Ks#guI-XIOrHz6E9ANR7IJOrQk^J!Y>c^DY~$JUz%Lb<>H z!^$b==t!MN5l){Lg=n#4Z`C3c#=eHELuF^iRH9PSNho4Mwy}(LWEm4BAzNcCGnOdE zzK>zX@LYGD&-eR#p654zb(mxBxtI6*x?b07d4IY6^KS}X$0tM<>wJFwh|lyN-Ay61 z6x&nhrGEXS;4&?-ExhK)y`!JQrOr}&3lxr!?Ks!I0@yF`>mBqo&wNqcEXBB6&hhCt z>~J^7c!FA)FC+A3ifF-q2)zUB2zlAlTuqI8$L}ir`cJ;xzoM>;&eLK;Zlz1nf`9$+ zJ;5Zd)r_!T(L(-I+P9HL-nEVvvsSmuAp@4advt8w-94)T&p=-9|H%?^=CYlIbyV4iN}dgrm}nr(uFktlbb(Zcn7a4kF`0RQ@HHH9p*w z`hFf7Hqur}_5B{d{=!v4XLlqSGKFedw7H&$Te~ON@s=XKSEPZ^kO9-keQUBGtzc+7 z*{nKHf<$ovALsgxgI8t?X5Pja%(F|b*9n3kIocDN@k`0SFpWD#sQH?Hu)y$uCcrOm z44&9aq@5~Aya#Y?cwj8f;t|V7#G%}4ngq6s{?Z{Wa4V_QT5LP3t%Ig3RSreUO(N1d zptAPnuyvpA8Dk)gumliR@_?04{pZiazw^~3bP)G?eTy`UBzk{?@_0yx=jtxQ;{nqc z_;Ui7>TED6mjire>Unzik+RxcGH_rK_TJ0dLIeQ1(?e|&i$jQxJynvdH5?5}N z2G)XyosLJR`~V61e9l6BlmwIeQ_=}S+W~*b2kZtSi6{KYJb?$IqN23c81VP_Pz`43 z+!gmq*-4YrG6<7h5~QfMm)_Owv|7FeRFLsl{|8dRS=cELyM^zBvDuDnW1iY+NS#D_ z@FghsIk!3V$a_~Ly@88nib4(7Bq#5%m2lmHXh5;xB+&5J&o7(~6e4y}KQ=hXc1K20 z(QMWRSC3Ay#Ql}>J!l7Q-md#tj7hKW?m59G4C~FI)w1Ss|wGfkxMYP~2#MIwTk;i41A%yYou^{4GY9< zcQPZ-Zc4b@pZN6GQ+DSoE%n;1v!~2Lj5gU%n0$wfv9C?0Gotgr=TZ(bF;^}Xs!1Ts zBJSK==-VA-Uf(?9JLaBmLz=(VYHa79B67kCC6>&n4DgC$C9U>kcmx7C z%GLnqs{JV#WQ;e~D53t|NvJUKdqdbF%8`Vu&IbCshFhGfU+c=%VAI5Fx1_=6056_e zpf4Jb=vj2BD8nJ;jA5+Lc+)g+PS~eaJ0?{mvz$L1*HYAU*I{{xxem-)Z}KMm*>Q~a z!!%m_%f7`68K;kR{Aq^50veP{)Z+q2$piJnnG6>eEE1sJr#>y2-oN{y{?)oksS{*NrA zo47$l+yoKB7wvh^y*-T;Ct6N^s0P!0?u%(T#mV@c_b7MJzp6k~%V+Mi|6!*PY%3nvX9>AZew2hVM^EsGcr`|EF_$bI~>%?RHd1X#X4lZFCTq$CR>+aUtT_ru-Hn;!&hyCdax9 z9WMyOe9Flm3@6J+|I6yZvm{Grc3S%CYhFj&fO}vSix@2Q}UyzuH9OYDmvWj@#QV{`(ddKWz$ZqsK~^sg6P*1G0DZ_f&wnh3BwL&9nWfA z8&1|Gqc3NbfzLzGO9(jsjYwyJ00z6r$OR5z*X|V+6TzKZSm#g=1rMDaxGVmTnZ7SI zC7JLW4v(KI9Mg zzq^~V^IP?}CM#lw4r&UDzixECwk6Y?Nb=GD$p3m{LB-=Rvb6%oF6eIdm)p*gxnDBp zJ4A>caicGHN;0JPrEJ{x>y4+r=b4KB``6zm+=JTf=j+hv|NY4S{P#F6U-b&AyNB6T z^&hQ==l!ofCJbg`=-AlUhN=~ed1ie;f3g?zH4m#}pyz7R3l1MT z78~2F%dK11JxK@GFdJPX6>KWH5rcPNBN+hH-Nu2?V|Tb>1RX>!aL_ z?gEEuLp@C>Zb(op;iVuE!RZKJiPjll3^ab45$8+KZ{AVr&lYp>Reu~OUl!GA*QM?^ zY3~>zd{y4f-7B}Aqy#~Aiks|q59~Vpwj8RaDvKSLsck7|(O}2fU^EFVs(J6-RNW}? zVXjJiLVq&ilm*RWnI(+KkXIY0QSseuCgtMDMC{fdUwf?02VT}+XN)u_d9(Z2cUsFn zLbc@qDI@2LI!=Z}Nqdn){7V&gDiqEa9cKhEk4%2Iz%$;jX-%c8>J&8?Cw6urZbu_n zJQ_?ONZ>xpfRct>I}W}ttc(InVu4e`^8+e3zU=y0Qo?%{RuDWGylI@YLY7JZDm$u{ z+5?ljv1VVEwK5IGN=ndsDy`EW9}kvNu*WQ6QF8%crNONhyz9G%9QPWq3tQ1c7xvsb z$2vx{vpRo1oK1~GjrP)J+7V1#$6HIaN;=mXHiC!fcY_gdQV>rQa1XJHmeP8zd`vlw z)@tmN^&wc9C8_H58j00gD{dD@0N?isk0~ek_|V}4YJ3UT|E;iAFr@XWMa<|yxm-iT z(ysc2(k`0(gZiV=_lHT|{E23`;9GCHY)7y6u79y2tt(h{n|@F!nOJAtGgVoU7rlST zz8oHvDJ0?Lx13+fF3&l?B23*>0O&11q-3MKyjV|o2XeVK~Q>y ze2(vz8twI~rW?-aEg-?MT99B-Jq-yOL-F9op+Aa4+@AQ zc5q!IA(|zqG$w%>nQ$pmcE^W{We)qlA(7$`RWv+)Sp#MBNLL7D-0S-}V`2v(52B_X zCERt_GS%i`Hi;{pP07(U>U$AQ_| zl{k0fHWJLE_Uri_zKHZc&(lx7+0uk~C?o}qyy|~34_APg|HI>9!q-n0&Pd+G25m`AV^Gl(jbAA59 z1NiwM=Ud?eemtertb`iEUo*cbVnS>ai})$ABY{vHqi4U>z(!lAEsWx1ulxYY`AqXI zS_i4{Ost(_mYr(2Hh}h-Y$0>I-ye0R;qyHG-pVSUWjVdUmU!!da6;yMn9I+bqSHFO z{@LgJE$g9*CElFE!pJF#QNmE%UlfH+vf~&mHUE|}HW1k$gzeuHUhX$j*Q?m%$@%sem^aw}KG2THFhGg92u*x@EW}@kt2fl`tISJhUReMZb z@al&$M*BT}8z?6z_}uvL`gc-s~R0`>T` ztlgC^KA?2x&c;I2C0<9iw^o!?V*YD6UC|Km)LP=1<(mJK8}-22Ob6@U$uJrBCPLh> z_GS0rw;i91HkoG{BoPD_KO2drwpb{=JG0z#iqYQsYO{U&9ho(k(WC&(ee3YbBk6ao zS^$T`Q+_TDUUo?7*Ws8BA=OGm$p1iv`lO_yHse7=|ba zI8be?6d0%5*`-3h!C^ry=8g2(NT*h9!$44nkl{hZu?v}rLf3txp�VBg%#RWmVIs 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 z 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 b$TSZ}R_T86|6uw9(qLt7f2-+_KOg^J;xK+u literal 0 HcmV?d00001 diff --git a/docs/images/gui/gui-plugin-edit.png b/docs/images/gui/gui-plugin-edit.png new file mode 100644 index 0000000000000000000000000000000000000000..6847beb4792bbccde6efd7565b9b9cc5bd57e78e GIT binary patch literal 118708 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

p)Tvf^>(xsH9Q;-dEoe-?sb9X5UmN{x&Y=vJVx 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 literal 0 HcmV?d00001 diff --git a/docs/images/gui/gui-plugins-list-disabled.png b/docs/images/gui/gui-plugins-list-disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..6484898a52e9ce330f215c14b69b761c00a8dda0 GIT binary patch literal 91464 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 z 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 Pdz)Ri{u_1e&ZGYWhihFJ literal 0 HcmV?d00001 diff --git a/docs/images/gui/gui-plugins-list-enabled.png b/docs/images/gui/gui-plugins-list-enabled.png new file mode 100644 index 0000000000000000000000000000000000000000..64f4ee6be4940664d4a1845cb75bd4964790272a GIT binary patch literal 103446 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

FX9gL@ch)%|0rao=*x-iN`qk9g01_DV9Hlg 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%0A3%*w1w9_eLP> zOGoAl_8o6;wU8V~tnKWYd^V;CzO@_07I#H1g^DA0Tb6sfti!){6mMNTx(!zeFGDvfM)zFNB;J$-TiT`-w{Q6c*!a9b?lsWxA z_mpQQvdK#2%FgjV%JF+N!{jM-d+!KTvR8J6e(qo)a!qknudX{^VTkorqXTSF`*66f z(6<(v@k#pR8LJ!-2|Z2;vlnmHTV*5dzgth5I&Avjw)|X^>ZBV|D@=@cJ1GAB!ey3} zkYn9-zz|N!ctg36-cU==34B@r%<0+kU)EXoA-ox~ZinRXjg-g{F$=F2|Je$D7~(hL zYW7&V`~J!^!7jtPB;UlP-DDJE9`bLlYz;w{1o{+(z&kbT68OMC%|R@h<@L0_KJ7h3 zgxm(CD}a4y+RK-ANg!v?tNa{uZ(cy;M|%IPtSImMfB^;E{3=JF%(Vr8Z4>aq{^U@`ELeKyw&+Z1@!#iniCWuTC*Ot-wKwE5WB;h|zuPc92uASCeWVs~ z(_9AN4ms@k{m95=jFR;Q$gyOVl}-{+o})aYRqO%fJ&B?kYW^&8ZhRwaSlG&iI|cI> z-Z4K3dO1zYwG4_*6Wi}!|0DGiU=Ha?SI1bI(o_xMS}b26Oif<#BX$C7{C)kz87qK@ z8(Vm~nz_iqhBLb2BMvK>-Am@p_*2O2kDvG408C}oL{Uf%INW-|wO!#d^5>&^w);mFW1r>hQA>~}h4XjkbfS6r4Z#DE7 zzYa*|C>W04zoL^$pW1~<2Vr}u(h)bS*vG&_@Nm?Vmh(uh3q4Sbk;eKyEf|uAJ;)@p z)f%5G2BgO1v&@GP^HbXN`)z%tpO~$4!G$|+l=nKn{nOh01c%HMIF>xxj@FJ(&l(Xd zbwoR@2pSuj{SQCh&b3bNgay$ON!q;$K5{n~So@K9y~5DceEt7{picD={bQo&@QU6a zuK9$e>rfS5@(wV0&&a1#XZ4LP4SHc=?T%?Zy*mepRa|&o&Pi4Q&)Wr-W9^mkP9N#P zx1$#-&5hr0!)<}QUD{|D zFB^}ErKvAzWY;1O3nr8N8*b^Hy;!8FPC5uwKNrGl0Fl3DXZD7FEYFLpThp?Q9NftH zxRX?q*I`|_06sc3t;t+&=u5?eo%qlnN)*=H#!U8VM?N1+P zDqV3Nl^Ow^w=}-l+<Gb!K5$f5) zD-mn0o6+Z5a#^;Ll-fvLzJe9CZ&^JjZ^N0kQ0x+e|y(IP)U*H{|^Gj{kis>eKGxYNyhezDC?ZqYpb6Fr<%IM`uZUUVF+HFU!FcE$RWx5YbTC zo-@0aIrc=p#;!NhGd3q6``}0NklafC>>A0!?S#zyKxE(*t9Xvm)?C_{QT1>qf`t%~1iCAGII+xf5EhL_G;#6C#8>9OSG@|OHHN3$d} z_USHP75+rgC$-&SPn4Uj+NkTO?jt)Qqg8QWgnNXn`%<1 z9(w9nkfGUd*=MJIr+F)H(V?i^yAE}Ur8s;w?5G?SLa?I|5kfou+uUKb@cYsT2ELv6 zh^N!lNSZCZ-IYCDwQ1Ye&)j{g)0LJsh&{9aYwPmv6b17O6*}!+#D_M>BoD;G#E%5t z7-T2Gp6++!@1wtN&sKONr)&9!fh<}Mj1p5@$xWzoZp&JO#r0ExIkGDdGSLv|V;fWF zOB=TKameleAT;ux+rBlUtj$GQiOP#*@76Hg zI0u^KwlX+xyQ!BMBNJW9Gt}%aE;qSX9yn2NtYwr}kGGQUGx<|b{wKfj5xOYO>SO!S z21OgkSDB|hw;sYQJ|+Qw&o>?sigjSdfdv|4?qzL=1$&Xj15GzI_R@i>->6=LmnTx1eKs~Sn}QD;cMnY-RwPuA%eN?I>m^~f98@=vu~aFnx& z3Z0HNDn-Hamig`@>hnQ8+2-sRnV@FW`@;sAOO3oa2q>edH&cugLu2Szqy!SNQV9X% zx_`V-%HE}Fm5D}6z!gT%!c6C1xSIqZyY#kEDd1q6{>TGa7T-0V^hF3+Q6TK*gw!es zZ@}J7=0hP4Og~@Y{dtmeqSr(t#&xTJw=H8ei20b4aXG*t!(10)k(GBVb4*b;?DOT4 zh-xM*ZAg1F6<5Yn8R#*^Uw*SNO9?eQU6{AgB-<~&Ma=o&l6!yQD64j(uZbSMsYxCj z#QmO)QEMO1AO`FRPShvbw!XDt4GVvXtB@^ma2VpUswnMs`+Ro^;1w6u5g~rC$@r6d znMt6r%5XX)wWfY*$oS|YW<28?|H`JHtg~~^cQ#tPk8u@E4cdHHJ72-100taQk zF;&EPRp!h%UYN<7)i}Hn7ZsZUPRdv&ufT>EWA+qt5c<}4J40PdtEQ-!>bhze1R0C- z@lodO{b0{~)?V`Lr=5;)E8QO|)p*m$<1MBlphcRqFe!b6hTXGn%Z)3 zv$(QGIkU%T729;UI9F9o%6-PQweaX%rci-%VpW*s`fh3!($6H`mcLWTle<~p%0d^C z!tHp11G3c32ezL`@F9UtA`8&Lv)t!dp?znoBiHYnV;-?cQB`VNcRb^;S;}oZ40u%C=>G)CNv~`q4ah)~B)*?#>l+Jl%0oudu$UEhw+eXHg1Hs1fzD&;r}%A6_Rz z285SL_~(hbPSMsCs$JEJnw}*JnyB=UnRK7`%bO>dT2T-c?A<$r^f3!mgaZJm=1{k+ z!2_j$UhwBg2F9rKA1Lg!%;LYmqxp-d@Tq-6k43O&{x9Pu3Cj2TNx&7%rM@#pIGc_K zuYm8uyfH3o^-dV~Lg(V~na$+EP1fJQc#NrKT0%951p4mzmIJ@Gm;tQgCwkV=eNPpfR!I9~^T&rMl~AL1D7))f=qx z#a{KPg`WQ6@&ciRE+ zp{p`>eKq1--#WU|5H9s$VUM;_@|b%2SG4VdF$wfaL-+C^;;14&dsL`5iN}613mZM| zU8zwmIr;O_l&YwNy#L-s;G4br6Br;XR93`0OnM$HZa;lyHTciy69`eqmueWaE}`6W z4(jdZL_5wP+u0%g){(m=il0Qo`^KoYi-FC)ed*fk#&Av6DyJ0l6QbheJY*jbcG?rF z!mTEa0`7cYcx^BaA{bNa@H_e2bqaKP0K3E^i}3Lf|IQYqw>0nN>4og2@W28(4p8&b zLOolX+t2EtF{8*Aw$8wjE`HNX_!Z?ksp%v^J)c>nWi?>J9lJQVL}`V14Kv4OSo(p$ zH-32H+F4S1h?GYXw1tmYd1h&0$ErzB4ia~SU4F0HK!vV;2igEPN<1CQ53XRkqN*sb zdIOfRSKA&Dt-Wu2-wn5ru5757+F5zYY@Z*$b?=hhX20n@Chg$oyzkEEsMR-}wg&f_ z#?;EfI0l_v%Q;5QM3}85-V-z~72XzpcVRYgRQ#~1ARMrzo=%8*Hdfc*JLEk8{PVl1 zKllcxPyvwIkqcM1!9pS(7~Y6!M_5`t1zu-BR0v~eayvm2Yuv34n0VOI~F(pU2Hse%nu zNeSl@`i9l$9f8|e-zP^eqTH`aT1I_>>s@~=spzR&&-P%v%1r8yh6r5B_t z%rGp;#x{3z*=+tF_5J!lNKobP|l)}CDfF1b#i2q%iKoE%c%fTO( zF2B-8Akco%J#9_%Fh|NXXU^Hq%>DgIhxr`&o=a@3F~+x9WAv}yEMfa{Vvge8w%v$=9S$e2(yY>)mC0xc7npVs zF1dJ@u~1gt((x-!Tq35WrNvpM_~QkXXM^KZ)309-RSdp?$r~xB>t?JbXp<+^LM`Ap z!C*dlO)12`Lv>vtRkTR6+{iZ+<#)oeL(!NCUDJs{@LX|sa)yWKJ(QF3F7b$yt`vSH zXw6CGHE%Fgm=z>uwYDt}qUyM!L%YYUi1o-OG{S<5baR&TJhTy+P^?W3hCiZPhD=SS zFK<|Fu^i4f>cv3b@vo&22Qqrd`a!~<94eyo%Qu?dI=Ca}#3u)r`0Lt=iH~s?R1)}S zbl>J`y%k0%7r-@Z?WbXm1>X8wlTwk>zyyPgt-{pG(B&Q)hjPX zgdu!?erTfWly!d^4i`4XDCrXs%h@^4p|`ears27X@bf5d%!*mCqZN{@AJU>tw{RD| z=c-=+DqBJ&Z%jwnEVz41ujCO{!x*uQ!O(euQ{}IP8i^?tZ8YaDST9Dvs|M;UrAvt- za%PZmeN?U;(J5+lt1Pbq^^v}Z39lKxLdwJb4*mvAC==5t*bSGTSUa&q+)A`{8VPI! zzs)^wH#%Q;vAJHOxRbw*v+z;x$-u_I+0PmT7w2h zK@oUT57+(qoVtIfiB%*a3!y!4e}rrrHgjRprP-39Wm%&^`FLhdRZEtO>Yus)$Bsk4#tOSh*m@g;5A`DxnJHgnXPI1fpjmeg$c z)+1N+dD5iB#5EoA7u0iv8%7Y)2`OKNClxEyDF?o}FXGsVi*sGj(|AxHm&{o3j;DKD9{GQl|U&}SX zBXSIe4?;z!nPq3Y@PmT5IydZ_@<-TAN?77n#emw|o@ieoC4>1e-|R;?CIPBMb;wR z-;M;93uyFIp6qKbwTlUsJIsrAyn|LKgf=F=sMeq2z0n#c7`;%Y1$h6mu!;CyO;HY)+MDCBVgWVtI@%yWh zZ#*2r)P|HVmdjKk!R04MD=6(ZTO50%9SXwpxg{DQ)7*Dm@mcpnrnHrY5ux;i5|hKE zw4s2))lUJ2c2Z2#-k zhNKqkmM)369oO&Vu*mdk6}_f(xvCCM<`ccv7j){v$H~j0R!`s+yK7 zou)JZ<`2GAm82ZnH0o+>)V1N1Bx@ojwMT<2wzX*!yA>DPWHk)dG7o`%7ufGMpMT7m z-%a^SPZzc)rBy#AYeu&o&Hc5}b-!V@=@I$t6E#><_3WO+Ft9fO9;Xxv5=wO1C_y0+Zb;6`wF3s>=u8SWmjEIf+ zuHEAI{d$x{%#?XHl#@kHI3EfumU|Ju*CCaQ-hp(wnbp)|WZ8uZ{(gFIqBhv93b;!> zlwkpi8@p?UZcuipMW0nL$N*x%fC;*do-dw5t&&0)@Q~k`9xh?kESa0W?FDJ>3Z;njM%X{ znW5|NFy9ntT}RQgi)v0lhM>VZLlT#WsDP0ydnaYK^hEdmt(YE#x#{SW zF&;h3_prFd9hjqOqpDC`Lm4#xOYJKh_DZuSl2Lv>O4X!mdr+3C2p>2itAc4=*6z}i znBKl5SR`*Ipuw=n#V`1@=ZlvP?(CHqIn3zI)NgjrM|OLw)J`chdEVfb#e_p|dPy-` zJ~kNw+f|35bJd=h&WKRT_p;1GS3{fJ z?@PbGucsICd0n(%eJc7BcuHaHH*HvG^ls3U~=mf3#M4&J>h+ z+S;B`@b##VS+>|!l2iB;`H~_KiJhifGv$Axo9FAb9ty13o?dp+nFuf{-ymB%Lc&+b z8}2P?=&dZMma#x%5V~U9R&EYw>7>3*-K8%(Ht!#KiNO%nS<2yR&}S!R^B%qZiYIQi z8gXukUXKi2Qiu#$x+a94@o5TL^id8WdBk~}=$z=k=#?(bYEQ=F3Qvw#+JMIX6=1=RVTl>C${9stGE9|Jjq)%J<#yQ>f~m znq}py8D0U47H`)EsDNviYS{q7C6l(t2BErjXlng?9^WO)A8%Cw)Qtb_M`-t_ls5PE9w=Z%_e zdxx+YgF;egeuj2$G^CCDW8;0L70%yp(^f;4|ADLB3f@yzLLdXa+%bE^Wq|&mdSrp+ zhV~9w}qjEI7(t>R0`RcNduQ#UV52J4>u2x%1)9zykC%=eIvKJOm zAsEj~0i(A}qa#(INjCLDEU zO4DRl&P^=pNYQ9I}vd<$dGr7N~9fUgna?sCdBAXZ&*Id&mTj0leY}j zc=Z%u*EMS~8O@Z{RtRrq!=dKaC8p*j;Fg>>DqRFa8+LT~j~)zRIpX zTV}~NdoDrPSK`&Jjjy74Oe&IWUspWD>u!JPoRa|Tg87~* zJ4g<{LYVS%q^}BWU}&9h=BLB`#=&y^R|1)XQ~nrpp%!kl>D1w1bS^`>IS203s&!>v zzg53LqMmD^%As}4CxSHS=27v@tv8QR?7yN`Q_?Vg-EXMxx~H+TtpeC5c@h8R?AD-J z-#zzSw{1N_+f6?6!wsIhX@dL5FL!P(<8VHcXbHy%>Y^(=FREy_S9Jf3&+!dxSKf83 ze&ZxOV;jHwSwAPJU>7$xI6kj^GS#l}p~Y_C<}2YZ?EX(K?dhBbmET5Z?LBtmU@)Gc z=SBsi$RM#A$>vJ$dU$bgw&j~5BeYsbw!XaY-aqoh_DsbvBIlO9h7q~;)w=<;E#958e~ix>LJ2QSfOZy!rFU=k87M^lOW$JUGwiJguMZPbeS7UjVEi#RL6X`JKN-CSE zVoh4wT9JzcuBkvZvM;a7&S0B`p-*-*Fi2u`*?P}vc0J_nY3R(&5Igowdzf4%d|Ek! zyFt=>*?ph75;9XCwYVPU6dJuB;uTvVf%=s-qwJLxIbo489&Yf(AQ;lbtJ@Vyyv^uz zl6&&Ihxw5A!dp_w1*sde*yAmr;dne)%xaXVF65#)yI#~Xz7e(ggg?6dL&mF#h}~$8 zJSR%e-d>nSAKwelnJR9oHXH;R3cGYjPIHSP{!eBM-e1gWVUUG+`T4Jh zoS%@24>V~z2EL5qhO+Od&yJS7sY3-8dNt?^gA`nK`*lzJ%yqD}^JMDT>q`b7CU9hb z`PmoJu~#Zi{di~MqMKFZbfrEs1m5UVZa-qn1OEWaO%_*9Z+NMa5lkdOF6La9@jR73 z@F~i(b0{!P)jG{v2Q|z0eRI~4_3njEqPR1fR>wvLGr@NCnR|pQ`kG|R;?m8Qr87#`BkyCW zBFf{wg%or-$L3ju!q(+{-&&hUiz3(*ySu$5e6fnw-Aw;r^DX6vw+;2d2wDZhama6< z+6OB>_XLMF5TGIrtTHr=(<60E-R?> z9-9Em85C$sW&1LWx@o z^X6U~LnM;6-A-FZdE^ZR9N?AIp&E1>Qzb+NopSYz}%-t5hwSSj$*_G-_Xswu#jfU*qS{1(gGSV#a zEyRpL&+COIyt9vnC_ur%+dO^LL7U*wj7Zb1FvqPIybIHFqM7HD&$QvnTMkPAeNz}3`NKyTmpiJFTE*kBVRp;kt*hsq{ z*rQ;T89>bOWQ)+z(Mf9?*Gf6aLw8@%ahA)RTRU`<1aKDzJUJ?P0;*)1vVFQWLkB_n$ zr>qKwfz$+@1B#Z-mF{K$(kwcOuhU(|ZWIj4-UfaCV2;6yZM$=#9WH4Z^BqvAmz>8y zpeI2UmiZCg+SDTA0VDU4h}^$p@s7e88%yOe;suoEw)=VLz=w#p9Ml5Vz_hedF_^Ro zuHyOFh9>;zHYW)5l~trHb?PUR`2`;b0yVx=CmIK1xJWFZC;Elx!|NICzo%5o)wiYS z`(xkS&ww7=F?Quom-2*jCh?}yIXdMCQje+K#nA!5ot>QmV>%cC0fEf${6TrFLEFso z0pwe-&eEu()060jh=2083hrEwnUOJdH})a0s|D`!4T^`HhMtVfPvH> zhB-DQR!U<8f-hVBeGT;SnZD}ZAH{lq>$>(g{!;$Uo$;t3dG7w`r@e<>o-a|~UI}pm zy#HW)cU6tpygJ<+%9sAw%F>+#D7ddlca9I4+t&Mn4V4!!sHpq@)Y#a_-=7-zdseY} zhhU5afmDIu1wImO{w+uPK@V!9L@nbL(W9-Qq2abegGcsiNLkYo3fJcsy&<$gcG{oZ z+p0&`jU3^Zc+bMd@qWZmrQdh%+0lav1+b8PJ%7M7uUg%?R#h5iJc?2{o*Yni;YC2% z3=xIUM}m=Rzo<3&jJ_y34zH+$kE+QX$W_6%>@(>EPW7D_2T_5XLw^qxVkVDMS#J0u z5w^=@Zlkk_`A{|M7ek7Zmh|5*a`atBgf?4OAhOm{q8Rg!ThD}iuhvgJPU>H7UMNV4 zRrqrZ(DzeLP)6PE=7>?hYlg$A!4C3h*}KIbAL6cL2?A>L&!MCg(EZv=xcU^d4H$>2 z3pU7BRB&ht`n18du#0eA|J@xjy^Y?7WX~&wP^{%2etqtIcBGHH2qc~zm_4zI*Z1|;_TQ-x!Iq;-{^{X(q~n| zR$&vLToS^!r$ePbdb0jGs+dZpW9uYpoPtL~jm?Uk(9t2esBVQ_U)YOMQ{m`hCybu| zYpEaKQ7^tWc~mY~t6}#n-%LE?5z5MEZkB`?zuKpx_vO#)CSM1_OUQq#2ig1jUoHK? zJk_|O7qy1C-&xAw=!&1V>nD8P-%*A~Ug4UNv@RR%U5VaqRy3u{?yt)d#_as+$Ol0k z1bRIYK_$6%>Ajb8eX;cLN}4aG_7w`FHmPKD?5f@G-x{a6e%}OaP4(%B0bn)u*SArt zoB=#?FeX0Q)%zCv;oDH2VB$dT(l}Szp9koHU!HE}^hR14a!~g!f0^&iz`5!?(D@?B zcjo%{#938C0oR<$6l->@Ki5m4MAT;G1u_ySrN)zCmyUu0+$|@@?EF z7hRmmIS8=%55}X5;(?x&L!HqDl$lms$d}@Av!*G?;I!NLajm6BfzaVmfh!5*uLynv z3nD=n+N#de3sa*~PK7iFnf?61x6nP)8rk2ICJoSpzsI}QnKpXP>{DJfguXQqJSlp{e+oUGu-E z|0tJ7Nbx;=xp}ZJOF_Ccv2nSH?6U^EtJ!zD2_|t6j{h87W6b2#jLJ$2|K&8vOP8MW zY0QqG&V>GJ^}iUUF|gC6&U`=^$Q8Og5AQodnrOcs3rM(w0Xs7jHpgT4SU5A{?+Y5~ z-GjZo^6RBShuz$;9{>3tj4RUy}bh;}?I%cZh`5y^@q&ZrDLB z2!~EsRlyu@Xq5a)pH?{VLjXHY})3cOZpYNQQ_v2Q$fKwySD+jXGyMug4qYfTwuJmezbuLgI!59NBy^E zUPteShl_s`aJhaPT$!5caYncyiEE)#j_&o=D`O|>@5ewrAA>jT{`S$C+u__J$NsyGvg*u;#lsZL&Ocu_|9fNV?}Y3jsT)MrAYTXb zfW6(G2A2fA#jIhr`7{w})c+3mb=^hsapL{?hnkI9iJjE-k;j)ojfKFzC#?6%Dq)?V zt(idZX#Dj;y6;-oS+jZk3l5$$?|}`&sW2sIRd;^I5#}?T z6bIG!Tf%o{6Z(Zir%dKGvZE_bFbcJh!LqHJZ+96hP|^Puz|C@j(Ge|wa}C6u@(i)V zVw{c8!`5w4;Xt&muD<$SdIavE61d)dF?(Y`W$@)`d5Lw=H9?f~Gk&`~{@&h-%xb3_;qtJ8RZ|2r`!7VWK>v7%pBSz}eIgqUh zw_jfq39DcD5dIM5gt2@mb9XDa7K~H0{CYKJfCWr$~Zq3q2!4GjE};!LA8c+lG1 z9N#ZpNkknU(OUa>?_F%si!(~TKBCDLliCP5VU>{Uc;;69QXCX~cWfnNAbg`=3Fi%Z z><|#}=?b6{xx7xOY>m70TUa@`LIrJWJsxEEHOz91AYFT%lZNr>!=k6z$Di>Ed&T)= zZIQ9E)^;@MleSni6l#YkZuH`y96fvEhrNG0yW98I{}u-l2Nd7mc>MdWeE9mKk=Dp< z=Zg1+xyln{V=)AfB|_{R<}%&$Jn8H~GHp8#1A8P(-dk4LW)>S{PX_Xds@<~wqpmb+ zA8WQQe73u{_$ByF?&MqIea~F0b@TP4rKm)cT!M>EyV??&6}lYL+F;$4Tvdac3^X^4 zk#$8~qKn^uCt;wEaBLkG8O7DOr(DTa^5gXymbO7V*`a){MQ$%EG>5M6dOexi0s+b3CHl(w(cVBDm63NJII99#ZY27&qIeLI zTNCbcq(W-6xDivaR zu`z?UHeP~lD~sDE2^cFU9@L-B_eg4g*Ki|(<7n4dLAu$*wkNxN=nuML)Q`YObkZM{U!j%p7=e^}KtXd5t+)tO;H)kH`| zJz{g=`%w*7j&j+{)K5jl?OtjnZlBc@$SUN@n!b2JN00hMulp6=FGx5&ETXNsWAxE> z!(4I=(Sl!1{q?MeP%G-4p>p>YuIQ+QgA8zOs2Nk(WBG8R$ao46p# zWr=VE8v?f;IHV&uf&IJdq+(%?=KfVdYBNtQsqmdmG0OdzT++V{4<)DNTfFT%1nw=e z*q(n_Zc`rnsD}_R#n!YBn<+68#aX2(*M=Hw!5ax#&fs6sv{Bh~Rv)gj&sn1)oI^K{ zLFdz~CY?VdBYx&*agqLYh*8X^%?xWbZw;ZW>;vL?Hg7grHMwCVM|91wD;Ze}lCT9c z<5e0NQBPF{qAuh>?YgzCz4AL`_cdxG8(-Vb+j=MXGlJO~1esJi8+J(U4Xabit@Y>O zv+;uZ^sS}-BNua=$y5i^pN4pLh?T%kYzy-DsJmyd%izLZZhP?M*;*OB z|M{v% z(U*90OU~?ZF6oLwM%6~8tBKmGu!``UEJXQW6{kqp=e=1zXmz`4@XM7kX%LLlJAZ5i zkH)(lxyjtcEVjlTg)%qt!A{o7D7mDN<*Cy?Cf}&-ML)j+BHSk=h1>83*P#VvhX6*j zj)~aX&-OSJR5sTNCw&9v49k+Bj}kM+yqkdx(RB+5_^6fk)!i6}u*b?L_5AttT=V#(@s5^Nx?Z#oN#Px!r>B z<2Q`r+cu;)x*y&b)$e6rsVL#=f3Yn*z1{5%C&coePRJ$6X%Q$j$fuPY}|upCi)jj*lj8MBC2-rkmz960IL zpOuMbEcvZ{j$g1v`A*wpfLqbotoSQMC3Q!R|a=9Lm+7TKzk=- z@N%=`>Fw#@k$7&wNeE_Yn&^H~*9PU28NSlGJ*@Wqz*d9Awo}zQpNZJc1&H^1KFl)0Zw$~}>G|ohwTv*5MCf{1Wn>}6ZhUlS; zXtf|QIS4ef$^Nw-i&C&NB7t%Vu%p#P8l`41HtY8$41QcLExyp(STR@huJJLkp}GWd za*ZD1d>SDVSMQgiBzQE20^?_yzo8aPZBj zzQt*8l8>4`-vHJzvP$@pR!oyabI8QYO#iW$A{`F-kp;|+AboW+BP>~D(;?(_7MrEp z>d*GAGn=mwF69$2bz3(J$?*>`$CIsU>ela{!_TKJJ!vX#yC^_u0+L5xzY^ZZt?&JV zBwE(qLSYI;>}J7BRfRHasAkN%AC!(m*+(oEt$of}pch}ZR4@p|l_KudFVCf@qq=wz z6^ZpT5xt5VR(C4B$M(Mo`Hka`ZBJv=kCq-m50qicKrmWs6p)XW{wnelk~`f-`%CIQ z$c-o&XCm_c)SaO>N`ecf##j5;m(N}yqpJ^WLP=hPvh{uaINgO0>N%Pv618WJgaF17 zWmB2;SyBiLB=W5X%-IA{6#cSe%eW%MBG3U`v7|kr&~-NOrRM~)$Oic+vA>)SFa^rt zoMet7<6O7}-Rq=-LMR4l)MfHRpLpDrgJe5eGE<Rsq4>I9HP;JLR~MI4!A>5HRfg|8AYAMb|Qoam>o_tFNrN80VpCaq67OdOeNf1&5DxBbeh>`OS9 zEExJ=(y1s&4et~}w=|$A_l(4S66sK|tunHaBOP_(``zYWZF17bY*oL1do`0O69xCk9mX+2IbieWFq2S7!x!a5M2tcSzURK?&*C1MG7@-|zp=S?n z2PF39!q9p4*jn2{C9m>d=lr9^jLlRvI9b}bSUO6dn1*F>s`?0w;0vfqc?c~C_c5d=RWii!Gzt*oxA1?=o5m~g z!z6aT9Py%xx$_fEzeYmf9@p<@2=mX#FixnRtY8*pIC7;wOr70WEXkiFph)%ibs*M~ ztHnORRmF0_C5=lYqD=MzD;n{@assm@kcfs1iX_g+fV{Vq7xwE2ZXc_FwdY?sug}J^ zb6TgH#(1$l?vgUm_pxR`x!1SCa0hdF7Xf8>H|ndh9@dy{23Qps9pMzcTVzx19CMIC z`vA-EpvLEqK`Sk5VZ=Aj9nZI3?YcrpV+wlTwzWTd^t-c5u(+n!{W$xttfpB~;Z+|m z44xDeNHtUtwL_F`VWR>9e4JnYdV4PN<+$RcwQjfkhFZQm9KFmD**6IgH8wzv399_6 ziuLs(rI%(p8c2RFCIrob3TaJd>LFaKlKjRDQoN`3B;e^My!DGDVRGCU!F<;6p2LUa zGVi>%3^e=mq>&hhP`v9iUZICBa$cT`FVYK1d#-3;1@DC~i5Asb3hj!I_9-G7YCX!o z+cl&z@f?IH^I)#G@p?YEod>c_Mk#dJ{y?0w{s$yV6^1^v-C$p+y=NZ4 z(&As{G-@6)17HF``_{fC#Lr-$%lXZ=f~^rnKayWWf0=&!$|_{D-T_q8h$!meoy3R^Y0nV;6Wa@lgV!S z&tN}7`s?=&wLElcTaAEDB$ij`cfj(RpK}$Ca$HR0+7#zy^@?m3`tOWq?*B9T&j0El z`=%7pePy@v9_l|9rXdIP9Ao$es<{RKz7>SAxMz$YErnMkXo&uO>2>)@IqJ#rivN)& zaL(4LPKXwEigxxk01NXTtTL3fa#{~x7@OeGCBW?CXPy(9!`Jz z_E%Uy@EZ!V{TArZnd=_T12>6h4^k@tLk|Z6hJJiEa8MNsui(jW2+3Roz-6_$i7UCt zr0|X#08fg6nnh`u1HXj+Rlt97rT;m9qs+Yx+`gZC5?ZuHU zxL#Tt{mU~R?)10bKq6ArJ(pIM@#eg47Jzj#3knKg5`-7h@T))u=SlCmboXY^!Vl*% z(g7ULQt}hIEIwB2X?9>c59wh7ffzT5k0m3}ZBu~BMzx>nmfW$PVEj@27cy+#RM~!f zG6Rs#Dz&nPM%1ZhGQUER~*XulBy>s7Q z#fcxxv_|kp_0kNh6H+F@$8~sOyqRZ(yV*Bw>%0U;|4~6Sl^@zt4xxC6YD4$w`0Or` z$Ue1d(U9>6C`p*eWAAcg!ea$L4pLGg|HVmIZayP;^{1xY*{VNOp!;I}*->p3=FVLI zRAZpQf8H7Z++18zt0hOTyMmhFhjI?c^!b!q!yIdMZOD~!If6N0EjM7rEj?RS5^GOb z*!splxq#gY22Sf3JP9^vQWPH9e`Op}I93+4I4oZJijlmq6Vdu5fv+BSGa|Pszlkqb zC6w2z5CHar2c#TO{}PTbk}&i8 z5<|D2pJPID3n^;Te>RSW>i1Cqo!!J7p)7u|n79P+0L25HAHLeo*>B|pag}LV?bwNH zLQdRC5dmkvm%KEqM8&j_Y7P50_Iw1EO7zdUibzUd)u)p*ZTO~4VvYpx?M66nv2kW$ z*=h$W0&4-&ZOvZ@`^YNl>!fR=T8>qaEIl%sCZZZ~DDWDfZskL#0urtOaDmUurJzkV zIjc$PpN}i#x8izz4FPkp3fFpa%tlNgvbZ{LcebZJ9|KwB*m*H6=It`zisFhi*GiHK z3h-oMuX_b~+jNFP4Gq?VmuNGdjy4@kKMa5pzy~I9oNV?49=b9!-?qW^dNG2UbpTj| zcKE@C1=JpiAWc<<2uZDUW~mTgdTAEs{vP>0Sj|#~;xD{WZ)HneZj+#~0^54dO>Va$S`c0>{A)`mX9eW~-M~&KVaDol%4j(+`zP8sr^uzc13FfZ zSS(1AK~gmwj7JOw_e}jMfM2QISO+RNs%?g5;OytD!+IGqbsIfnBkmJ?J&Vn?AxpL! z&3MMZEnp=R@*-L1jzv(qd^o`XDzEhIB<04uE3S zAEfa@K$@a%zVIH9`gosohm#C9a_IdSb%spo(>QQMN>GljNch(%+G2?!ePD>G+%|xy zbhp|*aA4kJ-eZx)bV}@-g3t3n(c$lAFU8B9D=kUDOdh64hNWMI5k0caReq#Ih*^Dp zfQ360yAS4t*zn~>SX+piZO=p^cq%<}S)o0kh@UZ>KF4nJH8bOOI^wyP*p`7}(nS@X z&@2Sy!crmU%*{Z;_|K=_O@yq5#)LiKIrb{1m=z}#LV^vv)LH`OzJ={S}(sx-dxjt*%n)GfreYvk{bxgHI?X<8FwghBx&Vtc8LTzDe;ZTj}OCAj#Lj{kq zW#Ld8gBJqF%#6)gKKCHGgQpK@&AvbEC?M(=+b*8@aT_fQpQwt>%T@$)jeXt0A^{-A z6&JQc&RvC=oT}Z&RpM{C`uxexw>4E&jTiql6yNf)J=zg>cbay@jM*u%ye!^DSxtB2Gs-fz^XI(Lu zvJFQEQ$^N;SA<=;cKc6Ya+VHwm5rKmde)dkL4rGkH|)u`508Ny3d952%mN+e{0?af znFR|gh+?+yvye6h)$ymU&>?l6Hzz=RG?Ay+i*&HrNF-GuL*E>xkJg-h^B4lq%S28^ zD&HY4V_J~GvXXX;KF1iaAcHKgWEs^8jq3>;eEo;ws-g}jQ zb~qQg1+cNuJ#T9KN(E~smm(>8u@$Ro`t$ff@;Jst!n}vv8PUpgI{D~+T`Ck-QMRg8 z$m$qz%;Mgbv7<28dkBMy4RK_U%XWS0eW!f_ZB?H%dz*}?x>Cy;D_)2mKI-%ijujP` z-;3BZUfHrbH2`2ZN=r>Jc`WRhM*A&=MvDUX zsPy8&5?PZ~8yiQOqm&roMOLts@jR;oEvNaiUDrNbrz&H0duS*CGo^4`L6Vw1twQ6i z5N*@G?%x^1#z|?3*4k9v+Qk;?Yq$EkPL$syS0LKXR3`E$p3p%n_1qm*gO}PMSRb26 z(Rru{UEH&JgwNN9Lw-5YU7Hc3X)J#g?4@_d`~Ao-vXTJz06{$!HRo4_(Q6Gx<0@G> zBQzL5eFJ>VAm$dew@9MGOR9p?zdm222a}PiOvkPbsL_4woH9!{q}aM#k1b6#j<{iW zPu?&NfdTHWxYp}o1=$wBF;y9*Soo7Cj(QeI;g}#(X9|Qs>_gY@K8^jw z<;9p2-M?ftYClR9ENPcd?asLL86H>))q@%fCDIH@&4`tea*K-CtQV&)TRUfu(a(2~ zcDTxS``6NP32oUl8B)Y_YZy>z2IuF@9`~A7kUX}C76~A4W`9_z>tzig{!k#R?X;sx z@z2b6w{CQ}Sgxv{LH0(BW1gJidi0$2 z*cr`{S0~Ch;&odL?wpq6I++(ZaPuC5yVRg(*wpD=avK|STf<Ql*I5F#t{}-OE$VJv)%m{%htLh(B+hvW2tj>!-Cp{@^& zc%(WoqSd#j0yTLK7Uo&o8e*TKRszqbL1URa9}C=dp%XxmPzDU{%o-VTQ+Ra5J7D5Q zLEEN1aosC|fSe5S<#iRR9OCONLJ zWlq=F1v<_fC`VK&w~Zyxzm6!YR+7&I`C`?rTsLh`-uR)_w*0R#KjsL@m5qH?!Z61} z7X}CVuueX=vSOj8MOSR{arYb^)zF}EW|~854lWmla=mRl=p8NOJ!4~9;GE5BP^1H( z*N=|+mH3Y>w;Vo7&&yT^eQ~y>0|6;CJ*%<2;Ypsy48}X#oHe3gDRu4bW!Obqgo`wN zfNx6j2MWN%T+}jSjyGEdCx7q@!j5uoYLkN;9jq)wlBu?-R9y|eyOkCdmiIWnmvZsE z|FB{YDTdzt0Xp3yF$xnt^rz+vYk8a*@yR()aH>bTw{oLV*=j|We9X6P+>O?mE?{gL zFaiA(-YnOavkdemWnQ$B|J#~C)qUnlO5>aQ&lG>x*E9x<>tV`&zh}ziYy9!!Z zrK+D`A_m~UzdfmZlV4eOB{THz|7j2;y`!^Pv7?GsKwtcDt>=h@^?%kIsRh((E=S5@ zY29Qz8r=v40E6i0HqYs%x<(qQq93Rj#MLQaD{bU~`s8otGQvTn6!D*1Ue(clxhLk> z`NUDtoh%?Im5;HG(SV zo*SH=YxT~KeaA6+*R{FQd1{xK@0WGm^I-3jr-=kT zY#4o~w`e~}b}+mC$JC8^iO9YnQ;n6Dje&||LAa*iiUVf^zLVqOh<)>Yb@oetsYziU zWuhi7%UWZ#INMh)MV;9&(FSSW9jkA1^=)2KY&IlLqrll!p$BDR!eGAW<9j?JPze8a8TVP&S(0BF$xI|nRH*0~K8=!B>c52QVDRE_)8 z<*R_&IWh*y)QVYk?M-LbYc%R#0PK;~qWT$F$YQ7oI+4D;!sm4`w=kL;(2#q;y5?@f zEh=98QeqkOGVd+wCBuPVJhSZvo@qDhgPSU9>=hz~Kfv$lA6e(mLweTU~ zEyt#IS9Q!?BjD@7l9w;nLN+~fp&|nYC@xw-Iw?GXP(!IdO9PwdfL@53Za~$P z0B>GKW=2Ez=0tnt{$cZl8lDLAOis4G2SkhD!dJ#{>fs8vO}BqjH3Ae( z8f^J?HHsVd)_cL9?@d(y>45~Ar(U@Mt%mKVg69C|`-x#a_6%VESqB|?gabnx(|B6m zZM^N84u0to9^zDvi?;|xYn1`rY$&H&=u)BB(z1hoSkP~z6eqPH)+Lj&GnZ0-eoPJM z-*D@EA)r}xU~6A-ZkC^$h^vl$`U|xSRErJJ(TpBf!Cg8O*w=qrYyj|G7xzd%>v}hQ zyS|JP-3(?xPRZDN0NC%?WtItDHf&$c%@K~Wv~mgov;W9p7^V=-;n1|8g1}3>YN+yY zqQ2RM?r%d6I%-g4#!4W#EGh6~b~J*TXAfmi*9!W6uWr6~Nq(^DcW}8k5kK)zTH*dx z)I_ECHd(OD8sTcylU{)>aa`1oik|ysK*%h~uiIFJ9e2?^U@*mSo0@MN%|JhvTT< zNzA?+__E@|nvjz(lM3GWE08EaRkaT&Fh9y>c-a)Otw^x$*?^~z=NM}F$}MX9HUPE8Av)yH>L=9pi45P7WMK72lHt6m#haQOjG&iP48G+&cZ#B@XzfHM?; zkXKJu@IuLf>e`_DOjKsuavGcr1WhHN(wjFeW4BOKh#jl_}78u8v7vWZPpYGGGB&Q;raFM_& z*TXP!RXvA7+5Zvt-eFCpUB7Sufe}SU0g)!mI2MYaQ~_xg91G1*r7A@_(xoHV5EPUq z3BC6!z1vVA)X|YknHU2eXq5C?Zy_UF->!Px)@-d zVJRFM$nvmjRYcRu$8*rOtbg>6EAdW}=a7Q*G-tgJXdrCb4HR?wi|-E3E!U`(=Fq)d z9~|8_FYBspn(90|UOAIjtaxTF|I2G!p1?7+#$H!p0JbV-H1DZVZC+|17yS4l4r<;K zW!s%e-|dqbng1%DGf3Z7ywHWWtNgru@uB~+RZ;%3$8!dfKz$}}YSH#m2tsRLF|N(H zU0PL8Y?k$0>GMqOP3}bi((uYyx~I$LTp9gcf)L3~Ex#+hcfFp5={-%Hde*Oggbn*$ z`cK7)FIk$MzGYeI-d*)gcyrOt)4?TgMEQ7@iesf}4Vpn(X*>=c-MXNAyeVfd{ZhDk za(!~qQn*?Am-m$}GyWx-$@Nnc1pM60aRAD`(JykX8gz!l7xjl<78vMZ?O44qde*G2 zvPE-evjb$ToHw+Kwdaxrg=rry#X7&0#~~>w_Q2HTjuIvBD-mWHIrRkqFE`i>yy1&W z&F;-kBQuOh-O2|9)o}IJkhNOEb?1DsPa#Fy|7=wj5nv^>?#VOY6#S4Oq^m%Y2{>L= zp#r0W*-xA-e!CVOp-O$$05a0bh)S(PJhhE0p^JMr49vl0Ju7vglD!h` z5a_?Tv7NbdRoqD1$Oe~XnLBeOUxM0*;YZcXGN03YI98=_aeGFs*pqhFTUrT(D05y! z@vOat{!c2ktx7jz7rYu#GbPcm+{Rt2-G8Zfd&sG+-;T+2eyGCy^Q+^ol%|qIQoIjNqKbPvFWpyedJL;N|c1O9#b*e#4QC4AR=rRHS1al)*J^kY7h#DSM4ug zpo7j)LdRs5SG1`KTj!}xY$*d_9Kc=F4@r{6Cs_P$`np2fv(Ku@sEV%T9=nT`bWSa` zUWt`iRQHuQYX7XLAJ#oz*chn!+f#eff{k|!D{M96=l#YL6CTL3^ieLtjkGT(<;g#N z_biHklNWnd$5kR^I7vwN1VLzYdRp=*zfAM;D zkt`^$?sub+Xdj|fDT`!l8Aa0bpjT&Ks8i8(0TQR+*r?S%w$FV$Kd@?3^m_;0um{CA zayRC&60vliYW3>9soP88QW;J&9;{v(Uu=JV`ydgT$@>LnP22o2xU_=mlo;pFntu9I z=2>Y^rf>~Z@BxVqO^0N^87_{qT9fVD9=28+qabGqH@cp7Q^_pr2pPc+3esmba!X;& ziJ$MVr!tMpdf|hAsuZrJgu_Kwp2*E?Uq7jgGrdy2oOv}{WbiGym(N6nJYac-VR!(}1F!j+{TIUr&jKC+LCtaF(aJfiVP zQiu~78S>k27vAsOQK+4JOH(M}q;&kgsle^87n4n{#OkqobL^GiPdLfoW%YjOM@?rT z3H$p#+Y7R7DdlKe2G`8bx&EdhvMV~KYWbyW52UzmRa3e|N6JfV8((lwEcY7i@;{&& zCxG}qK-}eGzwe%~1F|*r>d~*^pfRDjQ&=X2*lXJHyF@4#|57GJ!vOtp(;vUJ?-7#xv zkvZfb|V4K8qZL) z=dNG+9sZ^THOrH!s7aa9k#U_Z=vBN+u1~xOX8_|jfd2FEg@#->j_A>y?bUH=4+a}P zH42ySG;w1Q7_I0ap~_ErgH>+%j7m}$AMX#`nc_QRwxjsb3kytZ_1d=}*I@uQ@6LE3Jv0n~MKdlf zpkj&kA#e1%Kt(F1Qp(-@9;Vu&eudj?=JMug&^_gOLK~nAgDL^sOJluh3W@%qN$bcB z1zPvvJbTCss8NFJ2cs~@3+nY_e1E26qNsJ7Hhv*}w>iGT<~;2y7M89$S1;(i)1ZYb zHKg#_$mTckfc|JfzghH+en((A%N9{{*W4704Fx5gT=6nc8AT1v05uGnIj}nn$UV#M zJsT02&Ku+OrJl;O&PA1V$(c&2yMj!d)nyN05V7K_!u%$=6{d&K-AFMPbSb|qA)!77DEm^w%MT=n@fzhOG7Qxfo0(tYUbq22 zJ&;~P@{D?yo|`vA^$CVEyNB^2;izA5D(@@0Mm4%<2r77*Dk#3Z zAmO=UL#fj>Xxan0*Q^KaPvVaLOi!*Y9p3saj_<9agag4p;vhoQm9n?@u=ZiKVv44T z`?ZwyG~)m@#j{5-xY;xsX*uaFw@tptR)`I=sXH?~39`tKA<0SG`zTmaNdWr%@AS2C z9wffO?@OQ+C}_4C$h47IzRWfb=zj_KduzYv$FE23DJguwrT--5$t!ASQoInmxUE5* zPYt27OM~6Pr}$fw#h__^_pp&N>p#ouGq2P`+ia5?t_<k`_8Pdw+WLD=MBk;;`?wL(RsSb=zbA5%CK3@E#4hce zVIUVCel=s}p0XieA(k}WN#j@*sShgRzj6$p7=|cich@sp4QxEO_j(rt_>=4ADXYI_ zKzA>5q2Lnjzz|u}H=xOiD5V+Bs2giu%ptDL{s`n1kE6vbU)89XaqRvWS14=t$-6s$ zNn06WeGP}+#p`M7>z5kNG?9XuPY)i-?{WwR{S#WY#j51}hEf#Tz!%`3io>43)A_dZ z9*`3k(EJlo8BA8ejB%DrJ(X|J@|yRz8An!sRy<$&th7NowS(2O?9prS{$Wi6*|RPd z55L@_GyI!$QB^sjdr52ctXFGRyGyqHMRE5P{4eSZ&n6u()+o%q7Wa%~7jgb~T2w#I z`#@)L%(DrDy}gfmtNy-q@~c^6cU7h-D1@eIr`d}(+FoKve2l?g>6ZYsH$k{86w14s zv5Va<3i%?H^Y^|e@3bcx$8FFDgIbJJD5CeJm4YgZj)s#FcNzG3I<_l)c11*LRU#Sd zr!mAB5#o|C2ZG(WjVX!{h?x3IvF4D;FMn!`_z$3j@W7RSHV-nRmb@d3As!dchKj3T z*Bk>y_Im=Lv4R|+N}@2uKy8<8LX zv5enE+cbJV11Xc;UN_8$@+z><|3Av_f7RsgbHYRHJh4eBDK_9IbXO+3^9l7}*vw|0 z1vU3r8|ZpFRG8^iODnSp>e!@>oEe^H;+0$mU{O%7R8XhM8x|*XSoB;OFfNIJYNqKH zn^V+1aQ`S&5^%E3``M-6gU2QsA|N|0Yft0cj_b1-E=hjW1j zQs|hIh?xyH#c0W5YbvF$;kx%WaJd{i34?d^#Cv8{F*E~8jRlL&z5%ejfkJut+5E{) zyuSS4Ty<>SRKC-(p-61yENM&Kfyx*VN~HR2xEYPaMyvu_d#(qXVV!m=Yqe-)m+_lh zi*0x|!}|^AH)o5@5u~}b_A?Ez%LXmV0h;P_WtX2j-2CjB^`n`swWw?+Rc>3(52HeJ z(H`?}lNc~5-_YVil?I&;o%mVg@wKlbm&R&8jxJh(=gg6s1FNQHJo90v20_^Y705JH zqhJ36&>V5Jgv<7TdE&@IncZw(;RIBX(nXhPq?IAqxWqPcyXikl7?rI3$+$V7D!Z)F zm983}(DNEZJ0i95X&~#pQ2>zR=p9;h?L2);II*A{>H?IMf_^&j;R~RvX~605-t3)d z6}RatFs3ju33=^XhZR2d*ZI~VBUS}37O`vTx zZ9MaC;Eg47%ZE1r;l;g_fO6%u^|KL=;6!t{SW4ridhHENA3L1>(Uz#7k7h+{PrzE| zW;&PGN4yr5@dm3POvWW%eDaYd9*X&!1~BA$wpPO_NrQGQ4SEjOSXHWujNX!aUoUKK zT&8)B8XG~~qW5vu&`7OF4{aPgrqt)ThK79rRCfx?!P3qt zW)Uj8@@u@=b{KV1vN!)!cg`OLza>^f!*rBBccD8w%7C&wWN@MZGzDP6{tn%U2Wck? zo6dV8akVsUs!+xELxZL($Rwg{VX&xYmbe~m@wsR&d~dq(*Kj_p4f7E2roy-DXiEsR z11LHzJ?Tr#L;PPd4BHj66$Rkf8o(InByA?zmZ&tyntcU6TU3X+*rf4*XPt8_Dasxk zY7*J%mvucp&x@jS#!E(BuTFF1X;ILl6%+1B+6`F*=p^|}BGDawB&$DX-^$f<`;@6p z#q)UGlo$f;#C)P9+60R7dS%LtCa-euIJLQ4_quN2?fK=00ak(jVP7}L_Opxp<8xFQ z><>1R+^1)@XU_9md1N<8Xb`{mF1!4JK6>{=2=*G`>F(SIi9*0F6VWw&zpGmc5hSvr zC`*F$x2MOzbT}wvP)Rf=J)*Jfgwy_zpluDR^urA?g}Sayq1Y}hb=Mf`$QN{VY99^G z8BaRppHwdpy0&(oV zh3_x%$1;p+^d*JcD=Zc_!}<|P6wDQ-wnWs+1IK7BfJt=9GqZFyxkx?u}qt5<>FGnjoEG1T%yYUGRGWY7wxpaK$7*%B(@qxTN?5Zz}D6TbmSVGdWw3tX< z+Se92bk-E+ng)%^MYk_a9X;@lDbUCJJ`mGFLV7&`$$I&%#9^=M@I1&gTLv0NqRTGk z_UaXP4~1S?hU*&f(*vcp*2T#Z_9I-xgxaaDbjX3hAK>WMB3JYMNDr2{GP=(!dY#z&!xb!P(qtx5lva;g6SbHF++ zz$bQXMqJpgpS3ZUt!{)6mKk+C+>%)5yJMW$a`j}aDGgJ<68B7%y3BIrmb%IV4Zh?X z$MQF*WYsbJTz_J}2&3`*-&~_QxufmOv>0oFjI@I`5zCRM64IzdtVCg;zXBk@lpN{k z$&Dv8bNPNeFj=BY&vVam(NllprW3!+Od{hfOuN8r_r6u2>m(;l7R z6esUniyAujvFDQC*e^E`Jpm-O>3MEZvu}$}bQX<9rchI z-(z*&b|$_Kj%w@tCBs>EXe%HvvdYa0^@-v@fwI^?ey#-CPppFSL+?pV`J0#n> zQ#$s_gK7Xt->%&50nsX+{W!vhWdT~3y#Zw07~xMB*?AYzYmR#gAe;G{+mnD3ml(XZ z#L@k-?!`DZtm*5!CxhP01H|}f!>WvmwE3XYgR#xNvC>cI^wmUFYE)tunuY7$%n(;~ zVY&adN%D|~@o>%hLbS6)9f8gY*EESyFzAZEvQdnnjFNzfvW!Et?}@rK(gSWwN|gz* zN`Gkbm@sT04OnSgUdBOw*6N!Is@!AN&K*!$`(B=ARX~~zo0^`P`*xnRJp;QB z`Q3>dqBk|wCt|7ZCMhIF4%Y^;370NsIUTcphWp!!la+Rfg%_#O2xtIZ#eSKG&YP+9 z#d2zzljSfXkpg0@I%odjDA8zCD^ST-4>*0QKIKbdxlfDpja=<^p)KiQ>y9t91Nl%& zTw^y*-{ftZ%l1pGqR$u|f?yHSGis0R_(J$npOj5WW_!)P*Q`(ACGLxxw;bX8qW8u^ zy~2Y~2PXWVwdLV^-QMm!`5Nitgk%^L_{MHD@ZE*6TjFUdCjO08cN3TQQip^BYe|jB zfU}AjHGhy=^6kn+e%^CFTlYN;X={mhEYM?dG3 z#DPVcKjW-9WD7_**#Swo62nnJR4-7bssdGOw~)Y*bj5R9!Dq|_Ge&HWS#{>*G{D#@ z`*nusGBdE{iLAdx53scK}* z!S#W-`j4v(H=`^ggQ;&al%6`0o+Kzrt9QBa4{VqQ1V&bn7qb?<=90~;nglh%bZGx( z{oW~;*)A?1)0X}PC$f|@wN+`@yQHHiPRr*#&`bNBi*{Q-O!wzPvBjSlSPE;3a#?Jg zSO4vQ&Y&c{?cKTjFl5&rA1Z|IwS63XXHsvRG%K&Df2qgz;#<~38eMfoY1(w6d`uBh zUwu(4kU@p-k12mQexJn)Wi`z$dgb>L32WHVJ3h>}+_-U%+Y&Lc{{C`in2m;aXK4TI z&X2`$O~3XrPp~kO_dCh3z20!$P5Fg?(DsamQhb%XhqBdCuH#<2-WfmVzcZqav;6am z#>nbKcIztZdnc+CWD}zjhyfWI4(g`XA7?JpP7{fm1T}I(Lfhz-2WQRR=Yo1!NI)lT zBE1!4&^8JL`F#J!Rzf*rUgT&#R>Cds0HANLjSpP3=G zXT){2eyFp%zVP>J>R~nr*^w^EHJpSJHd|zv?(6$*%bU_KW2~4JYXlFnp6SFK^eIp! zS7ZieS|82w3opI;h^|0QDSjgtEANG?d~nkFfZ6BR``-c)3+P%yeOstL%|<*{d$Zg; zQz$^0Je`SpM^|6*%w>GeGk=Gs*Ik7!!QeHChIzQpFC|hlpGqE-XMeV31tpDH{mH~= zc(D5}-tftMBjQ1=+m|hPxO>&Vs4Z6-8kB7;I8j}gn=wouG-3}z3nB*N^8A5_V<;|| zzde0_c~++nhuQ^Q%ceanl70_yV^*=aj>j}kucoTAwCQ4Bu-h@rTFBF`gnvz+$a_&V z{wBZZPi*1?DY3OjbQAx6jBTz_Qme9&0JD8yX@0;D?wB9HC+K|4LuQRin*u?r(Kofev@g{QWhTU>I~~ zEnftH*Uz4xN>)6%+u}{r{-rA&%9p4repovjJ!>*dU%AfmgEAFjDpVczv$5U7%9oC- zmdNP~Z&z!S&X#-NMw4zf6?E~k0K((M&{PA9g2WdsZjac!o%57);deu8pQ5apjAlA7 zmaofhx-+%mruofyKMpf(@(;}@!;^#ylwVgtA|v%iO-6!2(VOrf?hUch%G{^JGU<88Oq~=|o#zO6Nv253*Zo)C-vc9}B0;~zwPU2c z`;=syG?nQisPxbb%Ju|vh-c|bfj-+7&c*ctvpcZJNdRLh@_!K4Pb#SMeFRYoIAl2h zY!3&J==*fIg+vkH2}t2t0KS+?^DMr9dWl>mC|hR&rXUGGWSM#>9~z@@|&}wgZkRK_c^4r`#m`hu0CW|DNwc)zpn&cw{4BhXzj2>F)QQS z#M;eE1a}v1J08aB#4*b4siytwn2AmIY11L93^NWY$!Gpffw<9)H$E^5O8k+&y-Kvv zBLCzIQ@#A)yjw6z9g%3z49$nDzmz@y)S@bR!*UaB!KdXYr6?gUMM_*~{hb&_BZ}Jg zv&c_(NgZDxiYlh$7u z8#1*Px@1Y3fA#-hK0usaDaSa5Z6tN^GJi&TyVnA$in7ah*)G=|=lF4f-9?@FP^+=q zm0_Zy;q0Y;@6)&56kr*{zwS&NPoC?hj~ChA8j~VbQAVav&-oblboTS61-1J@4r~pt{YML;j*ul>ZkqM9H(3kyu;|; zKSxRy!Ux=`T!E@hcb)PFDMl11)IvXL_DG!}!Sq9Szj^V`L)mW4ffE7;!V{?^f_2O9 z6;@c1_Iv7MD)=HEh*19Slj;8O-6H%yh_32`?!^?(0M=-xwg%fEgR50@u6XC;-^ z&}K14I0u$6_W14|EauhqxJ>O=Z2N6y{B4C^U->}+S1_1!8t`}5uCcTQF$GgdB$&1@ zYFH)&XZ+>-`>lz&DT+0(ULE>R3Xtgwelyb5@qQ1VI!{f+prOZ2_3;Elt0aHd&kVay z_-UlF5Sk}iw2 zw>}XCWUmHZo6b3HCv`Q^NEQ=k^T*WZ2R*TKC6tn+)@yY#LtC@(yF8|#2+}4^yLO*< zq=(3_PwdJZfGqNBN7#CI4=<>JX-xeL1w;A7x?b2)nelC)Ewb}Y%0q~v=;0bE31aUk z%eNdT7t~n}aB4d%U$gesn6F|1T;S_FLN%v!t_^p1d*H8ZAD1Qj9A;tJ<5Xe|X0r*l zy0y!@Aw=-D?(U>u;YuA>zu;eUCME%f7Fn+*S#yJJnLjW_-j>{dO_!VM0_L-U`0Z-< zuP5at>Z*S+ERvntL{ztCRtX`(=i2fFPW>zUvz%AH?pE%0iIE|0`|MchpdCt0+8b%5 zY5;;l^eegqO_X7Z-qI$&kc^5w(=&8t@~BfozK^XCnUFg{+}V0mu~@<8e>$uF_%ZE^ z;%+i2v+^;NU*GXsXF?pONE!d}=rsvL*qa7ZzRh_OzvHvDx7g<0)S9`N-c92mvRIt}ZWRms-Xv@#;Y%lECAx~n?Swx{uT#?^+X^`%?y*@d(0oX_5 zOzjSN0T1kNpT}3p!{|^QRgXt|q%!$i9oCDW##r`bSz^0*>*}jZjc==k=?9%qZprO6 zG}->~k#i#0XsfejeKTkxO54y*y7Yj*$?=b~GWXl{ohAjHT6ebc*S}<^PK;zq_W4?e z>cxAvdM(!ZShjAA@4PN0=)K0P$cvXI9q{?~n2E;@41pJ%05N^-yB_6x7Addf$@^=!D zuoKgBo0E8S`9tYm``ZqMQVvsURUVCTHLke$D0{=LSy9pQD@;{6qwfc{b zo=8OSar4eHM_2i6U8kFqecRrIB~g`qkxfhU#16s1g0ni$)~$x6yOIqyA&6Fa(5>%8 z1%v?h##|-Uzsh+M$M$0^r>kt&y|jYz z6k9UW<))1)Ff#i&50>5y;o}#QNTxsVhZXfTe9&5df3;J(YQ6!5)R{#5EYV9IS*=Bi+3B@~vjlr|Z}CbV-%F}UA1AS3{>>k) zTY0>`y02C8Nl8d+sm#5Cb}x;^uRBdr+BvG<3Inl ztBDK>GVwki5za!7wPhRpIG6mNRryg+%m05>&Xt?4G|oG}9OyO=ZSukcviWK)InAK? zu=(=DY4cw>Qs#ZPpG1%9J4HkA2M@a0S5CU6Df-m9 zf4ed&$#dGk+-KuS<7{EdX0{u>QzjmAw2=I|&b!rU`xo0HVAz9-XFOJBEn!mx>*A%O z9#emD&W4%1kou_az1;0;w47b$IDQTE&a1C?R(H1JxqrH@&JVjL1b1g^!=*5~Q$kf^ z1ny3A=#UV%uyO<~Dt@?%u~wOuFMkEhiE8J3|1jC@*rd!%sLEVhw3CrsNV6M>nn;NA zceR)1xIpmLuhbjHMNdr!P@SM;JiXx)bUAeY;U5Sun?KpN~aI5#TF!?Xr?wsiUQW=XTy)0?Pnx>3s+s4kB zP57DtxLXM;ZWU#v9LJG5oHlC7SX*Br&0wma+G8_pC8t+d1TpW=u3u}SU9t+o z5l%2n1#a}A@wO7ubBZqfP}#A#@LQGy11W|*y<=s$L4Tq-&6?8let&U`fy8&bowYIl zx)z4$mK#+a%i17fU@~)jVb5;!M2vm2nmO5X8B}lrhThdG-lWrFlFE3y5PI0wtTTOA za}+I@yg|f@c3C4s66*x=*fQ^AN_AbY>z*4SzlBsT8=m^v!3=(owF@a zt&|7v*!S#c+y68@p@IK3>0VV+y-c(#Df|FbA95?ey40svQ}~@ z)cA0f=iDE9Sb=Po=gABE0lT@lJIr`PG{Z-h57hQY*MpTZC$|52>D!_0%B zNWcK9|MG-F>hB{p0R0kAL-bH9heBo4UlSD>>nDNpHgE zxx)W&rKzGAJKn&JFY*6W@5e-sY1eH;NP)g4AoVpufTBh3;sEG^fdqUU&HFhe1b00*2`ie#>$a@@ zhf9wa!eyjDXWf|+XbBu&0ZTEAi^aF!(G`RC5eyBKk_pb(eUX%}lHr}05SL`1b+hng zdFM?{p?m4Cnzr|BU{cJ1!3Wk*YWx|AzY8)^L_L-)M4@wIppsEf8-9J(_<5F9AM2zSD}f`hk2xLJSio&w@;jjejg{;m)e8c#O-?Pyg`M$TO6G}113o6Ub-3KYq)MgnJ_HZ9Adamp z40#c}mO8GOzkhnrRd+I8+&Xd{G)}XW(S>($$YGM({6}cR&&hwsAD?P?_y4`fS*_0( z*6Zu??dyqPZH&#aF(~9aFhM3?ljJ`=ko>;YD)v<0m5)dLOaT#86Y@)6H>HwYpLeUX z#$@%r;mbYFl}60+{LJ{pcO`TU?Mb{uco<)V49 zA53mF5M@OlrytUsTw$)0yGGpD;(e=?wRO?lqnz1_W8cz;@_Hjn@8fKYm|DQG2vHsp9fj04I7nbkYFEU22LHb^3tNO`)? z*`e)^e988pfnePj3WG5l2VIQz9~+rh=jtEOD1(7$)Q%J8oN5mA{-h!U%q%i^j^EdCRfJ$P=Eg|V-7tnHf7kLO=W92 z@2R}-SF=D!GohWD)t`RPDj%zI^3BQa5%*Mq>p|4_g`TpF3LjxkPWeYz>)9O_r4M$K z5<~~*tzT^PnCT?BC8DV$S9=)5RHuS%RDAL|^a0y1FEldH&jX)p6DeeHE0J;(ek2Ck z<)!**wx2Y%h3h(Bgg!j!a(vP(>VACBJZ0sE7Ahk}wdTH9-c2FKGg@!B^01F2-f%Yl z2VCIKc1{RRvFR}&HB{&rhZz4ZFlx-2`$->)R`eJ32KSWd{~f=t?@@qv;keGnxVfjo z8oO_eN#U129C5wmpM>C=&ZHfFx>=2wzG^~WkUr%yfvZylz00$iW5QZ2DFXY5e9ZY< z8%5NZ=}u6zfUFlDejDkxyh$JYof$?f-L(0c23XmmUcX8>GU!~!kUaIfqbPvs?ANuX zYZnUl*TA1s9tjf1Ve`8&ruDsb%Bsy66SL_% z^O|zi@p>OPdj3eu2g(ZkCE)=e?e!l&GcepSxls;ftFH*J)E!ti!uoUac(veF{3!oa zWX2^Y^2}v6&%}d|9?I_?8?TGvrCibnrr8e@nhO)m$|jowRHE0|ny;BPT=@M8(T66w zLMfW1`ab-hN&j?s{k`sfVpJpZ{gs;Mv_G4=g7`|Y5jtANu=8KO&hDSU4`7)Vx9>}Y z@GZ9E{5smYlsXB!)M{Rs!IQ1I5X6 zwLp|m0#rb|YFUtG9tTUT6Y$pq>fwhBx)99`?E$l(;>yA(?EW`s4zmF#5gob$IZP}g zU1c4#RHk952Y|T`2Np5}p^^_?;kpfUy1_h;5X!cab|IM}u-|tR!TmDlE0lh@z9E3F z$Zl1eE;=8SHRG+BwvfBTBlzL*rO`5bqwfn@GG%$ny(#Vym-7E&L!1F5OZ}zu%pu=k z^`f)9vyqY4&&$y1sOU{v7=R6|1?WC7wb;&9kE{Y$K}P}T6y`t4=s4HQDJ#*=zIEAl zbSR{&ecHXgh*JDVx-I+j-4<`(;7v}~lZ zp%SD0E~fdo_Z^eGO|QTaNuZ23Ia8LKs6A2j2Yv+Y13-YRJUg0e3_>V4)O*Bzl6w(3 z-*`u$=pD`PbrsOcIRjd-aD&<}{`+harja7KxiS)25>e+MBolU0RU%tEUjim9eKnIC zpdV;K;mvI`d3uWi+IeE2J+LXzwCfpZQL>2kPAfaB7W-O4W5J7;8diOcJDXLo0(S4Fmx@%(1Aq}LQ_L|NX&<_eB>=3#rX zqLkzJPYEAiL*P<0G?aaQapxCXL|5egCB|Q2po8FoP1u;A<+^Z@4+pf^B4Q_zoe9+Q`#-oM+OTWm z@DcPa)#Skl5n3D`eR3Ofm?OA>HXuAA-48B}A!cDx)#J*$A=Pt?_A&XoS{A8GEW&_D(rz>bZy^nrhKBQv#b6D$-d3k zXvy&LXnKaRAUcuXse!0;96)&5W;tHkB})q08Q489U67)yc#)F5jMh6gBdA+1UV%?U%Id+Ff$e;CVWG=TxmC=?;yx<=AEJYIICw2QiK^kQ4R1qb?!d0 zmCtlpe{uogH*bc>(qUmkE@EW*!KcHU9^IPS{nE5cZxyREq|q^bqIIWdSRG8~mR)+t zna=x26BxhpmKwB~?7Qr7w?k-Q)$jxQq{ujPBk4t0hEy8v z9Y5xc57}dLkomH*bGMi_LH6Cnh?B`C0yJv64}5&R~KwqovYa+l#B1kerKJtxcd@tUERVPWwPG5wQ1vE?2=Cx^Fd zy|md4|Js(WBQkgn-IA8@^H5fd2cGuC{;5D*1^9Z?t^vP(7O1^gv5XYsJ7(zr4$&@M z>Qz$6KX0aVs2yF+YCIA9Nb6Kp_iBvZn+-p@?lN7_kVutqbNbWDZvE6dK8qFRH=c}g z?)uC6YXLe(Pyt#$$2<_RxfGJ|pN=CM_OJL|zy7^yc>8C=q23rR?%FDMaxsj`sq1kb&oI~L4>FiT^ zhvObklFb}kk~|uX7XBd+vGu33h;#;FZU(zYf;sewiGD`^QhUhOx=Ta^sdL(z&+s>Q z|2>?4Ia9l&fp`2W`Vx(^FF3N16^v1O}A`M*RBC z<6v$orXBV3$VL)qST0M4+$UodZ8ykY=;hDIuYXqEb-QD%gyg01LRwkJ_`CAGIwT5j z`cPp=Jp(1y1Q5o0FT$LN^Y`~pkRWS;AQ{&;W!!rB6g2Z8?UJjJr=s64<-@Iw0&^QXe z4jkq_<)G{C<7!wgbiVst0e-^;=Z5tii9gKB+P@Ack95R`pu_lSN#(Le>i_E0H3^WQ zh_acv1V0S94%(V;Z>>WP!L7QxZAVeY5|n^J`?_I4{qi$9A{4zi%Dn`e%r!OUZ>F9ZjfJc>)ruh^UNNv-uZp;-81^AZ4 zq{%Ddmt30pJzDpvzFXWbDwkHT8OwJ&ptz`4XR)b6<=1rT^#uK!x6Og4%2#R@yxZtL zAsRKWc7tTBe8a5HeOuahJ0tmp2*mwSVhI@~;GeQruJqa#gCHAaVl1NMx$!gP1N?n! zXwEk0A}z-_cV2C{WrC8f@K?`4*wP}@(m}dEyTd7m=m$Fu%^Zzsenz@Oi@Eu;^SUst z)A(cKBufLChqsj?Z`XYa8I8S>^2QDZb(%_dWEsVAFWJE6J} zVE6UG*;lmR4&`F&l?ivadrK5Fxz63=eH}En5I+O;shH~X)vy!MXd0YAKNql*=(@&y zpP>>_zgpJkApRWPN})JV?n#Xc%Kj8x6l;G(ZO@P(C3J@Ez*1(uYKJlF3weu=FzPku zc2$pV^>kEh)&6;C??M?MUW2f>+IL6AQF~bJGOG&LqCLLSE0@joJ}G$4!w9)jtm;U9cwpAh9!vVWQbw?K)vvIyZr@HPnxbN@K~Hg_5$t|FtNJ0|X{dUzjj)N8+T%^CNE(Nsv7~_< zndvJnUW|g6$TaWZ)^3JUMpXhDGRVB68%6Dq4|og)y@xHzZ{JEkXoF>(3e1~^=Hj0O z$^v%kq!Cj&z-zSM|FM#ZFm1BW9xpny)nh&_40gK>xDY!~rqdst?3MeVb4COq4T3kINIPJ>+qFi5o zLoCsmQG%Gs{2*Mu4N0kz)t59u#j7V~Mf~)_D*O{N{LWV`$Q47_WLLSvSVHNBbFeel zNlq@`t)J$wU{Ap1BntL{n`-zW7;IZ0H%10|O^$mTq4Uja(9~EuoAcZ|uujN|v^bMA zk-U<)e%dc4{cAlvu#9vExO9Y0rL~Nq{dks}esy~I9&zZ9fb?zaWxKA;#Lb-5 zuaLc1gx%S;tc3<|?l0Kb8aFxhp4G4!M3Mcu!gx4g!-1YBSeH3yTjl$SKIpia~J{qHw5?%*@*9_wm?f=f|8uK2IR;~*hRDh!!oRDl4uH_ z?2%^s#|r{u=Qi}6hh2L6d$Yg@8{-95lnI!_@_Jo^lO{wZcNzj6!nM2&R#Ttj;UzTqqKeT zVcPn7VcyJ^w8P$g+r)gg%Sn;LM8ZBaA`d`llQEjUY0d*(P)d~qS5tqUllLr$y zAK_+59;nSkQchTk@6L2{X*Vd6gIeLcTF(%ThG3~bM3>`uwWv2^^`heW8Xg2!4{P|IkC(-uq-)DcQm!%bW#(aB--PsT^ataSWYsNXVg3#u;WlEikE6;*&&fYcKT+fp+8#(+P*qHW z#Ji{}A?3+7NG)1?&(or)>?=?;p&vRWzOnEG_nkz(xA35iG5nwl*RY;=v6kp4OnC!2 zvSqOw;*feJ++4#pPoXvaZeRrn_RH-vK^_IvegkO}Squem9q=91z#7k@pxni;qx_9z zb3W!9i$VoyiUQ#VlP|_sw3VVPmh^LhfNYy9N^UqJ?KW>C`rZp74T?6)$7b{?Gk~_J z8g07%iD)I8?(M)lsi=QEo~tiB@%Q@bHx0tzS&h}s^o~a$U*^8P5LCWcvHDItzo&w@ zB+buDnU$X0YEboE3ETN&0{czg<}%?V?wNT>j0K*6m(V`KeMrGhedjLlIr}2Bgh}Uo zigoTnjGgP3eYAHG2_Jj~;-k&|c~1@p zOz3KY)-c^l-#p?Y&m|tMS@+aq)MbGf zzD`+!mp`O?<|f(7ZLgVdwlLT5kmOapSEknnv>SA6lXI@23uak+E&2)+wg zm@7?n!SzbZoE~=jne>5Dxz9o6_Sa#EBWe&GUYTj|g{JQprhSk#pn5A^C3aR0)X;xe z@!uT1&!BKzrW7~L|Ko^VT=tCC8P&$sO=zL066#-32hY2e)?&}7kU1YOXuh70rOq4Z zSy)wzr!^bPT+c1~>)opwN-sh>xf6ZCu8J%nYH=^;cxc=ri%pl9y!_$&i!<+;epO@| zEj+t)x+)VN^+vKzGgRoA?@dgGHAt9Cbv?V;*AyPq17E4`)Zg?X_e8upd2X7C-Yn=c zZ79np?2kY{Lv}N{v_m#svR#`;x8G^IRF0}s$zEOYL1#`OEu%zkS^u6e*X7?kDyWI> z4e&5iHgCoaKBjSrVBNd$dlpYHhkokHz293poO@%H3f6MU!*3wWc}Y*%z=6o6t!%A1 zDQ&kpY2Y-R!IwNrok4);8}zD-eYyZWP~Xej{Ts|VF^Fy1n%Zr}Fn%oxzvEso3Xxa& zGA0LRv&tpv3gvz^Z8SS=ov7UQP`=(9dGumqmwsj^yX5mkUE$p~5;4$Sb>0@!pS)wP zKDg1Pu|9Yb_C0zvHK$u+9Bco}_`Jn`jUjH9{)V|WMJ z2mx||`bJMyz>dMQM0QEL7^#WZCbJ7)U?d`-YL|1BhkUUKg-1fb2@5ersaFEFk&vl_ ztqd0-Osj9*#$m{z5m~7RRLsBzYYp4l%F|jOa$1O*lHFHF>1RTPpdHUJ2Oxr+Z4W^Ti0MP%Fp)5?x*cigY zBbchv3sICM;&wylG6+Y3K`3mfMvmhN@DA^@3UgZM)w8c*U8i)x3PN8q=@n|)ldmvw z^3I^92%81~>;&|IIRz^HhG0n+1-||dt0t`ZFZgHMQU{SHe&SE)akz=$y+G*UpSwX% z&={c3$RTzH@r}R$y=jIHM;R{K| zU)vKe5)u>xZlFeI0N1h11=}^O$RQVV&pMTKY#sDh$ktH0keOe!+C1Hpi?11Nnhmy>5%7%dr}~n=bb-B?00Azyd*flEm#81>nd>WjHw3|pE0Ukp z+pW>=lVx>eI4BeXX-s3-G_W{5w6oQ=V}tzL4Ey%kCZL_EBmnhfEIl&;iHm2z==^48 z*|QS5R=jPRUpJslcS91BmMhbM0!2Gb@%L3ZR!Yz^E|B2@+4{WQiqn{l$%R8)yp>Pvwo_-kL7i?wZ^{D3kr031idc(yM^b zw4jE$cN7wNO0VY4KPl}fnW|0S@LA8--CTeW?k$ia6wlmruS1u-f=J@J?e1b<;Iwy* z=^6wMPbq9m*p1}6ExyRs*6jDPAx|jdTcGW;pQQSJ!t7yYNEz5zBkMK{Opy9uiNNA(wiO_&6gV++v_BK>eYj za&T*|@hIwzC?581Nzk+`LLeYMfSzgKOIYhFiVr||S2gkpeM&J;VGybXDTG8pH-wE2 z%Ev_3fwc6?R3`iR`}e+=@O3Hcx_S(wP19sUMA%tRX<`-tWGZP8rloP2lU9L%n^`ip z{`#tNLkSY@z&4blXqPY)|Kn<)Jf%}954(WmtfTD>Q06SBjWdFY@MBgov_sDFtTB2O zj*xM6)&O?MRvi7YnL4A5H6OO;zPPHlIqiLL)d#iJE8+si=Uiv|)^;zs*6F)3c6vLf zgd$u2R&Q4CN+H;j{q$b@{?M3K%L~^lV8w*`F#He%CpPkH2nkFr0Ycu7;&l3^;a+5t z8HVj#8MFBO{ugQQ0o7C%wGD#~I)aL$6cs6sjSdz-N+>EeItjfhg3@~n(p3~hY;*`n z3kg!BBy|UEi$re*gd9wPq~`xc8oW&OZC>^6Y1iDlJHl zoz~BGw(l)$6+cHTB3wccQncQ_L(}y@eG76Q1-h-qXHKqvIlUS`4Oo5;SZr6>-AtS3 z^*C2N0$>t#yNwxpo=KyOBUk@Hv?v{W$mmyOYt7%CAXNVs!q=<*FqG7>jlb|ub<%Y7 zbisv9mQun*XT=jfKk^v0C+ALJ<7sD<%IrH*Cd{d^Ng4iq!*z{8f|9@|c z>Ll_V+w8##kdM~)GcCc;HZb$NXSqMut+jI6(PuHoA6-2)iH**R$xQ1RFmWC4u`8Xu z9{WArFOI1g6K%tvpLt=^JS|K*gC*cZ9Y>!uNfXH$E`gLegLOt72=g{rT%a%N)I+7GcKaY>U3?AMge z>dAk&05BdYXIjR2*l&7K+ZFHlbGTttxR?kFwHTTj~(`n0jQhs|N zTz%otFgsoP0WyagHKXX2CPBu=w4O5N^vd zD#nIfNcL(*J6qq=Wj_ReX$e66vph)wiuFYkoX`OUx|m!t{yn-%zOWIWK;DFbR*kb2f%T!IeR^KhP!t>_z32Fzcx#u;QX_4!ogo*Qf z%<6m$8>?q>-=6kmr}0(bCCA3z7hM&4*+51v7!(217@|F%vJdrXX323%E`4!AvdtoD zT;isJ1VcKP=Z}9%n%u!ojfIG*rq-#7AGt$wG7?{P2m~qz+VOsK`En+*>ue#mvnc#P z+Q)N-;)wy4$MdgDDDJ*j*s2>3a(fqi_EY@P`->3cVn(1}(X4g5-wELd82^(5-^3D; zC4==5d?bDbVltqPjIZ{trOM8`0De+zCt|m6DR58R5)j*4=B4scY6Ku# zl5*t@Kqe(dT>nZmJ^I~ea$z?}((Srt6%q5)hsnsqMI-qgf3lL|$FtNd$$FReSz>SJ zfv)fHi@FSPmyqw}9Ii&a$QPv%>KEIsJBmg71Q;$JDcd$M{!u8~ z{di3&p_6|Pr5C~MYiFh-Vi*5_+))IP=BR+&(Pe}}nc-G1SOv~r`)rZ80&Syuf^+h$ z)msAwSaR;sEl<7t%a}6k2E9Zb!$5vsnq7PbyZOsPhzXRsI(XN&+{N1xbhrLdzRLBy z;jGnS#{L`B>lu1;P*-Lv^LkkLLX*VkWsLQP%G}w4(^&zVsr-n$l1Xq(@kxsjCJd2cecc1u89=DjWkR2oU z$xeg%{}E36jc5HM#`d2-1c;9_XU@D*oSU2bmo_}Vp72ZQ?5z3lSLvn+zvFR$AWm-x zlvs*yrI$aroKk-&o$uMMpxnzWzOusV%SOt#G=oTHUR)mh z8<6E3BGl&F5A!Ejy$h2Rbtx9N}+TJBIXeoLI-i0t^p- ze3&hJtO#i&qy`E-N$kB(_cDZK0Z=P|Sfm&TDrFw}R06DC2&^BN8f;fI%(iuogY&Rc z*cZSR^AyUU(6`&lV2Fq~;NZ=LT0jNV20JL%B+NK%#4BIk=MI92oRS_Bu+AuY%n1q?;$V_H6YLe8x zPU(%@*w>)y)|%G?Z~8I7ln)mN`T{M>J-Tx)&LLn0SXnk~-xkK(8zFd{Y3O;NO)o|o zIz5Y)cF!srPm8r_mAhgHnk#)|I}eqNK*WMup#~_>2{H6-BP;=v1CW4w;akv20l5Tz zs%p`|d5u~lsJP=g>cSKVlO1_~egtyrNy5NXJpo!WL)cR&cnRhDz~h1cwbg??UZ-Og zbTpdUu7xf*BX{DhQtDIgAM;ALU7kNp+DCVKD0jX}&+AL!fKZ)DIY_SAsH-0H1kF%0 zR+`+)!E0u}#m5W)<7L>b2ZWP6+70dlz54OQmSnpo_b8N+=iJpg&(jk`Aeg%lMt#H( zGN#X%0ZW$aYYkNSyAGU=0_qqHqHKbihE?u|TFUpCP=E4uE%nX#bgFI+$!%0;xi25s zN;?w@q^95^tD7Qmya3Gq9RDKF zU%RIm5Gr+#Lv%=JfT_X3C856XAjBJxdK7>Sz~xsQF%#2xJ-kO-XOv#umkKC%FM%RT zQIJf%068j+z}~HA_}+ARX#ZAa5-{p|{6z`A{Sc=`r!6q3WENVkS}gzk?&fPdZ@;-{ zzttyeg43sNdqRZlH$QsMq77hUPcEtw7QQ;pBmIb_Bw52=Dc|D)XVO3e@3((ZV#4g< z`sKQ%U00?`!0rLBW2pMXh1sf0@=vmq0`Kv@WLMPl0o6r$fu>M` zZ99iTxUnOtUz(foiXSe(y#7y7J!|WeVQPTT;1O1)JWHGPoMP9opDcKQvSFtiNBhd( zWZ8l#MuXa62sW_Gr+)C%cDCdYJ1MxX%rGwFtsG!~59z7Bg=@mJ%ToRw9u7BtAi5P% z1d&tCTWbV3))X<@{uig>A%T#80nB$Au7BHIP{9B7U_Bu6*t*oKm0ckA-&xdBMVw8?-viP?r zI1*U~0HZb`23fiRoQFR*G@l~T26t1T-@l4F>k!LSDH)@a*yK2G?q8fc*ilr8k_&CL zuIK_iLf@Bzx@fk#-|GZj(frR9eJtFh3wB!a^z0;4@|5PmHQ=ep%f zf}b>kL{X7~XwvO}HAtY#rTo8uUZoT;ZeGb@N%lP?1rn8Cq0SmHS4V*tA~vvGD-n>| zRVfGp@;FPN9Mrd_)b*Sk|FX8;zWd)+&RDJKYE+77$F(&MG0>@3KmrUErl$e7Rv{En zt3Us6iZdIK@}x_;fVKqp_v}Av3bElt0+K9KP!V(saJ5&nZQKoPW@fZ{QMx#S1{y)DAKiiQ-Vg!cC6B31X!h4Fa4h7xTBsKtcDl6wu=r zt{zC%1$HsuF^iRi4EEgVy#7d#+=vF=dzTc{^!S*#P1HC5NB{u9k|sS4LPykgEj9W! z7uW+Rz*K2~GjR5C^O%-dN*DsTvy4{!ZTIC99s{&jB!^G`reFc6oZj&Kkp~^9iN$k^ z+`a70@y>d#FStxWA#UKABMBl691+58_A%JX0gS zn{c>B-M+mye}LK{32FD~OCS+aP;s$7Yqh~-<1XOzw(eAmzEIP_DdW)sWtSlSF`1v0 zyWL2DMYapz7C&yN^2sOLy1W2f0Wnbfx#{e*cOYFAnoR<}k0)?yC#4Ya(@SR}4ex@k z1qUF-#^F%{67rMOa46257#Tq+hhhg1+0N|J13Gq0wnKyPa9 z&1!hJ9pjicn1n?ih#m=wA>p27J=*kplP;&j;;{yzH%X&vFbi;G>dhd^e{L;vlAl;*1d;h&AZ@o(! z@H8LS?nWk%&RVKxOSyc@O&ZpHMrEcWqjED0O>Z_{CjQl~L3eBSB`B#svXw}&$`x4U zgdb8rrW2#1em(lmw22Z(6F%q^5Py^l~W2FaC~Lwhm~S0`$#d^Xs7! z6Evu_i2C9sv<*dWXXK1^Dg1d!DH`5x_Kj#{&zr)f=g4Z0aLSZNs@1IXbI-ELW! z>`lGH-VrWS(>+xQXBZZiw{S%_&)!j2V?O>2fOnZdF5vQ@vw|dpA!vId6-!N>BG_nt zWEY!oS*Lro>w<9Qx%$KKL)TB?uzF*VQ`h;pIS8vkr@f?3k4U&6?CMKhx<{=bR=uW&y)CZ_X6Jl$LMmc)71E#Ta%c9FpK)1KGv|Nda-ipF zUr~@Y_q6N>B_eW`jJsA8PSsHtL6Z-tu!wI4#11EO)5q_5uo$HT?MZH-x+f2DK{3P) zC`TbI(3LiM8DyFgq!?`b*1}bff36nGWR0YbUjIQXur%Eti5c~^1FQ5l6FpLLFyRb% zKi!uf6aN2V>Ewl5W}=yr9$}89UN0$|%x#$boW{5(6U1D_=UL>&2G#mz0s8tLXai&) zsC?X2s}7>x08%7KnF^Ug1 z84b>uM-OJRH=eWg;Yi!7r2`03c}W??01auMbYWfGuJ`kd87FAiuq55;$IjTo6%0t| zoDqE>#hP$t*Otln`c~I^2P^bGa7CvAkkR%(=?<1X?2Hq~^1?-Wah>KfiniIWl^@%> z2)dUJ=5DOf{^<1e-o2Kp<9Ic;DO&-eiPRo`_qh2Z!Q=LavT@1oUq=9Id1!(UMjtFr#s2j{MPGgS~P)%DjE)ZN`34J+|VaGuUv8eO?L(%;uDE-mdfi@&bDND3TcqhTTJ z@tqll!~DdzCOV^fz?=OvxY}7!&cS_U!g3Q>68FM~8nV1G%8=hF5ZGf_sN5CtLT+Q3 zAKyWYz~S*Bzyc}k>h8uk#pE{*EQb#8LT!NuwX%^~v!D(iUs%mKt=y(kTLJa!nBJ5!Cfw6s)7DPi634N%L(xI~lREWSE zXIw04+ixMr^Ib-WxNThyTVMjrh)^LZ*dnxpHnapGy+Bw}-YcJHgJEIRo za=!g!t;1C{Pu%e#=VcA2jzlYxx{4R(ltzkXBN;LeO`k^LhQ*$vt7SMkzNk$o1wSY> zPz4PeyKCAv7A{5Z{bs5u{{w2HmR37a!pU%iYd)&3(6MZKEMyhJezJqJ!EW~R4cRsZ zo`vJ74=L$z^})Sz%L=ovBTGMJCL60U^78&nG_6weU2Pu(;Z%3h>pI!R4>iPWA6NWW z5PWrsWQS*C0AXZ>M>R*k`Y?~8|CzpE49WniayLRD6JS41GV>WP=0>s(053V%t8c=% z#@5c6!(BGZ;jsV<BwOwz5!lyv)NP;?=QsaBMHmbI+=coWqXDii~z^D)L>ds z1o!cVMb5B+)LO+UZNxk)U=r``|np4Eo!~GUrDmD!Mb*;znia&Uj-w?Z=KW z!20J=ONEIC7Du3j9TQvMl%0EqQ}Cd$dr=5e8gC-n*bNwzWOxEU3d}XTi1)aXkqA~1 zAN|BWAjw#dn>u!*&(pgj52f{R(u_M}#09Q)Io_9nK(K)JPZ7$xV_xlT_8c}SttYm> zr9kc(=`|8xV|$CfN*LqjhL-O8!hm9Fkp{j`$T^ws`)W>c&Hmjv|JClkiqYPsk^yS3 z3_~8v-&)L9DG06d7vrqi9*<*1wB3kaWU1xeQQ6TfPKHV2jk!p6z}^sP)GV8fRA0&8 zm@rNHTJuaiZ%8KE(`OYa77gq57R3{(Z+Q7QJ6@+F6&dE!UP0Qrl1+1zB@GrQzY zxm~#<0-#{`dy^p&~eN`-c1h%i}i4V_h>F-CVCX1wsUd@^Gx z%(#Lup~Sb|$()(-ttOCr{o9}dGZ+K1KjR}bzr^5L_d*T@AugTs{K5Z8i8f(%0{f*mW;XP068fKirDtzoI?pbIH0 zAQq%Tu_cXKFb>!K7Ok#7-z2PmVmP1(Gh$uV^izBPl`RKRspd*NCu(8dDW&ZezdIaO zs_L9y9-&3o(c_=x$na8eGZ#BVm<{Q9+!Sp$FNVFr8CEZN{0ZI#G7r~MXP@bEmsY<$ zBGM@_zPmte^)6AFF2`oE#GLG%LK2_xp78>UsL7*dvM{Ao-a6$!JxYgtNYQSaie2Rt zW;n$1zE!4A|7g3lp=v4j5AU(7+)l2TZ*Qqwy`a<59SMYkvMVMrM8j0*AlKu;;?4_q za;*NIx`DUE3h^zczUz7P!Q1iyJ|@_1JRg^WdeRZ$aw3ME+^#)nju2s#8W-2%Okhx` z`1x3*(45_9?+Dn`j|@!k?*yJ&tQzi>GG;xHDYTe!}!_KNEX zey@be7^+VKypR)^(XooaGJlV7sxlw^i!OE$G!MgZ zly6Y7MNmtnqpZ!|)6Pb1g#kRsM$Md)H<*vBtDP0fCLN(j8{f&P(&Z6dQZ$(#{C1#? zMP5S$YtTp5o?g@lH4wd7HixLYV?<`to;ZJBft}KHLCSY_g)B8{ggD4h|6ZuOCE&qX z;&n~iTMeHY#*Y^FydE)_kxDjpV!VP0itDn?BrBv(i4-4v?$fN^-h)T}h?ebCAV*z| z0o`ljuTs-YZ<3zw9_a|ttjJ^U@HAXJG2zcH7gzmA;f{u+)*i~pMa&RcN*jEisnK;R zJWtjm>=s>)Dx+JvP6rh@(JSok4E_tr)bn1UZ7t>{gJL60`=fM}~EGCdhf#}_p;)YV4?d^Yu1 zUbgN=05!HOGvUusY)-I~*7~W$?4L92L?Uiv3UyE6tdtnv92M>m`vQx6`|jHa-?k;GrxV?}WnK1qSaf{HCJ4Mv zrG?gGh8VMtFm%43WMe@v!>Ap1Z@Oa4Is(Hha!UPgRqzS1_cn<{S7I z#MNi4M{x!{DKdjXQytINZqS{0u=>i2f!k#*5%nC4e%Lc(J|5z^tO!dRI0~1SS8E~^ z>#M*S_) z85JvPG3U1g@)JfD|C{05N?#^qhaZNk#zmW- zxX!h^DTSA~X2wBPo*`)JxTP?2<;Rqu<6}3LmaN9y7S0$~1f?1L1NTP>BM+w`1;!Vf zg^^T*$SKOfHv1o8? zEs|~GgQTmW$Px@b67{(_UFFk)I#U62CP~r%O8Cjn2$o7sNtbn`91(6f;~3^ucy71v zk5k-^^cn>#%SSsLIX@9%HR!crrB_Kgs6a`wBzh<;QV-$Auz)CEmY93qp;e-8|G7|A zE0RBUr2GDsA>VK@fwF%oY%*_dEl{?Cf4n5PT0)xrxW4IN`GO*DEU7vr|&v zZ41S@R7rEkd-8?M=6kP-){jrws*rYreB?!3#yLXw1sUGmmzT9y68FRYVS(=UGcN(I zKlXgsUUTSw!FAN;VgNE&s`&G%r5$eCkQO*C>Kzr;l!&6r8pjnsT3UV5!0%P=6FxXT z7iO|W$pj+I+T!W#dwx_oM(-WM&JsikhO(%G0bX=D%v%<}8Zdi807opYUN0*MHhHIJ zX1!?!0iSRz$S`W;Y(HS<5Xe9J(=j~5Ftu=4$X-u{=rhX>JpI8V3A!pmP)RL&#iDgZ z$MSjoqHz^_{q^2vs-c)zKZv|d*5^O7a||(HLB~1W79%zr%^*7!DAI%@sGpBMnjgPC zx(XQ)j-b=xfXcw^3wJ@(Ldv-!NASTsZ$qqC93{xQ<&PRt1Y@{|77gfv%9MnQQkK6n z`|3hol~$9JH!z)q%7HBkhWqZEFlKZC)S_z)pOEERuH4J!4*VS~CWd_Xrgt2YjHxNe za?M<6?^RUOEgzsxkIpR(0$)wlal&Arco~?!hQl@o3pf}~1j(wbLUekD`r+UxSgQd{ zD1la1V$)8MB`m=6x-tuueyL;}9844j1I^)EPGS63S<)7sh3H)$k#L8-AC%wnDmpW! zZ0)@a#4D3l^i8f(c9oE|vdMGV9yJ2R#y*Szy+5Bw2m*y-P)+q`R>fcb7kVgcF!pcIyyGZe%}NUbDAaxvZ7NP*XBA;Ao7tsc4F~NOF)q zJJFw*feab%eT+ae7Wa;b<1H!g37#57T59|ywc%M0hXqPj^gGu9;OwCz;Iv1;ZKbq{ zD{CMJ$zRtQK6h=tz?NuvbC|eN+!|^}z*e-BO}l0ff=m& z>p3fSqP@TYl-IL@iZRdd_IsVzOPLGK5mh`6=0ATSnO0h81Q}>%Hb%&mGxM_EckKUi zr7*#x*k2g56=N(92w5V%N=^*)yS0;sMGY50n3NB-h(;Gp?Yq z%-3{$DLNUQ3AXsJ93aawm|;T2#%3EEF&^ei{3*9NFPE9=ki!`cX9)wXviopaI&2HAp59-Sj0bC3Kf_@e@tE>U z4=EWYQ^=^tER}6V8h!lV1i~KvIMs)P7Pi=OrEh?0gina9SRb}q{U^-h5!2sBAQY9g zf6I4|z?arE!VRF<$a#A+eA$$3-v`-I$!5oPDYmI0;GpL-8Dh#mjRbA{WZujHS+Mf& zSx211yn3rM@6V91B_P$`P3%qb_-e*A)DuUR{wl!??Cd_FOewB-op|X23N#d^?A=p3 zozaV!iTOr*pS%VM{uyYlJhN{LEtzamOy=FVjH|I`!~~=2lMKP&ByWQH;YOmV{UB=M zUY9Z@OV$W;s@_}<+jN*h)MYqhoUJ-vW&OjzlkHwM!CFBvs2I*FViGQ_v{m!>HEM@; z`$!o*2!DtkSS@8o)?Q4QmU>|moPHoF!>#U&Gkl~w$I4}nASKc$%_^AYPFLwF141Dj|qdGKreuA0)V4%%%`Y==e``5edpt26>`%)Wg zz%3=lRZ-Freu|@W3?d%>8FCE9*Zf(CL_Fv|%%0!sXsJ&ROGdWJ@$i|H)oYezA)3iG zXg~N(b7X6X@sHZfK3#dWX~=aVsfB0HVYpl~?3BwESad;CkcZ%Oae#ei`O0#DH6@P= zWyLNfXC=_H2!%KX&q@@1DP1yJzHVP7V@;AmGwOq$D2pfB?f=12Yhlxb1{Hq?&RDXR z0_J7FE9@phk`SU*G4;S!W%O!;UUbTY)bhY4)g&@oszJjD1A8E8B3PA&bZFEYyrmZ#Q!&@GPOR_IjfVUb!NuW3k$b3DdW&`iDff=pwF|peU&Za)a3-8>?41eJz_c zc$5`NfZi*!(=RKoa-E_y>@s3xdxji)w6Ge1oI;?=7bYZ@t^rm5NR)9u;5Vlbuht{i z_P?WhjTGQdWZ%e2*igJxmqg{eoZ|Ot7ps)T3NXCC?KFbSHF0Ze$!5%;KQr!&`Lf4lT*P?cHEKMEIaPqsyRF@ZRW1NLHZwv{YNcJ6}nn8>_BX zv*ZWsE@Y$?H)fy-kps^@f*}PJcvE5sjshOXHkdL4DnQ{$w!YDg9$bVD4;;i81a<7| z;cd&KvP+T@kPXz8VWS4?tID+-9yOxV<)?6I@?66klS7L7_#=}<`c6)WnbV)uaQHWc zCxGE1i^mb~m9W%ZT=h)&`}o*k*XDzP0(T_YK2R~8(KxTUFXu`ula22%Ug7gg^;|?~ zFfEVy|3yB7<_RMUOp4*G7O-1SdDZ6M+r6Kz zrh6UWO#YZoLX>ogKPSV1_rhKDE`4J#7lR_Ivb+u98%y;L3D7VBhevPWm`v@c3F!ka zPTIn3zN*qr*`K#y1yadcsw(&ue$!N361s)F_#=vFulzB$Fuu;IQ-N2;{p9fDvgJ#e zi~nlN-4T^7e=-~i7{<_OXXBN4r;s`vonDkb8Ql*t_uJX&i{{8f!a{`WyS2Nmm1WBejoXGSo4 zjj`;gUB)6_-3`6AC5+S9`!QTm|FnI;8qTh$AFH-js(Uy5UitVU%%q6x;X4N}H^Cxn zT^GSsSS3QVy&hv==Nemv)O>isfTmZ%o?1%#=*NfRYidjHKaZ8dND&Y2`Wza*}^cSK4rAG)Iu9A&oxw@+Qh z^@`@aN+PW;aNK8X8N;``O!TYwl-uprl2`pGIiZn8y2g5%09OySsYpjrZH3dsXwHmh6H=%SoECR6t2i-MnZT8g z>e2GpAo>vAs8kH3J&d=f;Fx@+Rmwi*+f6H+B8R7^^uLm_2<7N|l4(mee7eh_C(Le! zg&~m`tM-wPc+10hO?^$%yE}8_Ji{YbS%goCtwfglTG@4?!cF{fYa`Bz`uC)?8nRmT zvSCX4XmXbS@|OamiP5a@4xSVg!W)e|ZzXA|Q3c*nzpHQDfs;(|> zo#7^zrmJKRN$`QQ&|w+0bp0k@veIZjZCB)74@YUrK=E&U?Red!+3H|sVl3(>miv9OL(6m-E&)ZxyO$w8GdCP}jMLxB32YDx?CEv- z1}Ix$CwdInJ|C>HxLfSK7f9)GSH5*4!Pbd) z#`p!VyNp?n@R-HaIljbq27Co!ZPJSoQJjzui*)tS%1 zcuYA_%b(5_S4R3UX?$d3Ko4HP=tKvc#tg_Yjd!v9mD}$c@?e0mc~Q7w<$wv#PfR!WKkBJSvjBGeYaaNHa;|l(8Ff zJq4CT?-wt0I$s6oexKp0B^a~zB909@JwUzQWmEjPhYNN75iZl#CH!Pf)+wWE@<+5^ zT);3{0gb*p^Ek`a=my#77+$I1DrdIr&~{=EGU&u+@00G!%#Tlxy5g|Sg^p4#pP4D1 z>I0Zdh+G+tCqs8V?_AJtD)i(yLJ$ZVjmu2zZ|`-YJD5iOn>_Ux$;tOJ33Us3B@NkK zHprTNb7Q(Icl;v**)rYWKCyl5g?nl8+IMeCJqTaZpD1%9Qw*-Q{)DV7jsYeT9#xDr z4TC*)C9>#BFZDWia;>+l#rMC|>SX*Z6pQr7dXFHhFm+LOo7(oW;R2obYu0mkZg2*D*MSoRE1-+FT6F&)k2?%LSF)| zG4pX6jWCFF7c7wMhqnC1jhD}__v1>0B1!iCf%f6}?SM(vyALx6AwX z?&Ro|zsjVeNv39wBGL^^C4V0;A$HW-jwRS+c)AH>*)N>lrXM z$d3ot%ewaM`b;{%X8_s1{X^xiRf)!A_c=ikt|w}h2RhpeHN8VJM=W~=yrr8lI<%LF z8?b!MJ*xXTt$N)fTCplB_T3IchUoc=Aw0F1kQqn(F$NE1A`@AncZUr4`S5hU>_^GFtl{W&pP+UGT04qOzCB)k+ABOeb#fdrJ8uA*%6%|^a-2SCQAx9tFp6eUa zE=R9}lRLum0HNNDBu9(x2#;yO;zBHvae8WU@@pO5qlJcf&2fsaz(ih*74yB?yvs|v z#D_-TW$GF+w?I-UBg9wu&->0a1kHTJ^nkvR*D5%T*w={4~?mPb8>x7)(SF0 z13?CczDd_a4{hH#Cc;5DQj#eiUh5D`OyKR_pgm6z_jZ6lTlO5Nwba)2KjbWZfWXmU zUqX3RPuf?}H)NIbYhK7e4-8;olE^vQzOQdj7vA<`+Gh>0Jv00|Q0jDM;EQyKThP zdaj%JkchNG7C%9t{I_3pBoA231JXG$wD-PL{2ed+ijiIJBY_Njy^u#9%FLQP*u|I3ohf<_Ongf`9@MbBD&N)WsANPAxgh?9J9Wo zR_3r*Ta-4oHT1=FeCJb|zVI6!lK8dX{?N;TNb+p#j{e=4c(`gS(|69bP4@6JdD@*V z1BobP*)Dh5W3{mt)7cjXCt{`ec%p9p#kehOej6UEwBr9i<@d`TiYC5e@s?3iiQVXz zO`2e~?1lXrN_6)T+o?OOcfv{>jH7DXEa3n7SI1aE=koq7NH7?NX~AzD`^UccLnCXe zH=`a3d|&?8yMrkKE3GqfpqrMg$r)g2b~>NQgJZ2Ny(DXr_B!TZkR)a$8A;Zo*TlRT z+1pS!L`V?*ydzLYDt?`~f4u7V_W8Z8v}zZS4g2F5O9N|AQWC42TTn3kdf(1ztjqad zg7KdZ_(LtkE5N*}Z25;jHH`Tex?)i;4O08!P0wzxi=Xj-&dGlbH0rSe4ED1M)|rW@-jVp zVj2zk+28LRt_uIxWBAW+-JYz8CqTamQ|>hM%=J7;ol_HdJReRww-QN6FEU>Xd+JI0 zt2A-^Z37=hUNgyQ_jY>T&-xXx|f_yB{6R)^9~jj@&t zG>TztHAmjKxrQdN`#=4*0Dmud6l^cWr6kjbS)|UcFEo;=uKL$8!d91i$&iJc>2dpP zY59LnN(2`nw$Z}8$LUtz!D>`ciL|sZD_Q%&j>W^XuyanH{X+u&mxJN=Vz(b7ka~J< zr+vU#T#T#_CH((-tOW*5(y#CC>$6QQnp>AXJu*XeBGqApTeCa!GZ?(UAFy+n}tw;CJY zPfkS+^4;n>BBD(DwRXsV*|kqdzpQKMo&dK9+MiE`L5BYfOzG;U25Zvx4=c~+9gV1| zubRzTjuNsSwESXw$~fYe1@-?zGAtGeJtuFekv^vi4`VY~R0@Y_Z~gJ-k%z^{r<%8) z9YTcBemfTUU!P!+i64mIuuTo0q$$bEmA^$Caum9p?I9Bg9Gkb(&KugpI?ck2p};4& zD*yfeBG?wwU-H5U9+&gcq#jYc-8ze5Ba?;1NSOCsCL?SA&4s2LkUP|i#@kV z`&xMuND)6(B;R41l~I~)cc?zsVmZlM_apXj z*!(3C;~O?*-8R~14^UkSFg3k6Z2}X`2)``w%>E|p>K*J204_bbp^xT?%NWR;!^KpTq zwH*?AUqgT0BAYH$SIFecG5XFr_Ex zC-VS9vh2zbJjPz+CeGV{ZF}HRf7KLVqaw~UWM46f?{UnR8tz&TEu@O?Ke02s-4k?? zw?p2GrQ>XoXBf+7?tH~Mk7BtlRACpO}Xjiwuh!%E#rnOF_@fQW~nXz z6jWWM8?AN$=&=}k^xYNAy+0OB{NMD^q&LD>x}V>pjhaKPY%=Lya>gIXm&Fr=JTE^L z8BWHT0}&p_X`dg{6xEsKbG3OO1)HCjH#B(P=zP99zfYR0L>d|;7qt`bi2A#xuLra8 zGsGMMcx+w3zv$=_39rq)nZlJfaq7+dvCMpOI?Wc(v1IXT&n(1{9Brk(<{gbW^V^Pr zoDFXC_dDjuGxeZiH!QM8>wLBpv23a~XR7U&as6BHu64~!+N!EIyGowuoTABXmjq+J z;+N|>?_AexJ=A8p){r-xZxkh+qS*jvzxpUOe;t%Jj&jm$=z20FGn6aFIjYsLM(N|- z{sK|XV0-X@l1JxpqK^efEWSj1|84qO{zVZSq-`rx=SEF`8Gyvux++IvmI$Yw(tf9W zbz)~m=wdS|iTg_^IDyJY{j(I87i^8p5sn!;q@NQd8r%1pn(@W4*47IC5DT*{@|@4n zd0o?1dlt15pA!5i^AumOin9K7fWLZ=H|`a%*D!2GuBK{o2L>Q<8V87tYM1gKHstMk z^yO1S*RL%oE3U2bR&epm$8@vvIS$dH_BWqD|Fx-p+nU$AGB=!!C`ra{WKZ_R&q^B2 zr3iB1j#rtpUrXLjLC~Jhf=x_0HMQMe(embBhqp)HWzQJP=O#{yDMFZkP4<+X95yq? zO}-@5;FNxQj2*z~O*wzt``?$_H_=)BrzW>PKxT07Yl6dQ5}U~UTSPAA)qP8AYid%x zVA+3EUr1GaaA|xe>~uCc*#0rJXpV*DU;Z#Un-cpwI<&=UdbErCKVF)1BXgiUFaO_P zllu%TvSvj*`;OAL&Ac3w`p=;4V+=20%^u$e&n`0+Lhaf94pG$;L3xB5bBhM<1NJ;| zsK#~v*$W}dq^B8P!Af`N*Vt$prR(&aQYFpVY>#8fr6(QFR~!(kAOfQV6TdSwX@&(& zj6vAWg!`%7p|5z6Xq;v6ZScX#?RWVp3U08W?PSIAIcVd;liRgU;81k>^if91`)ie# zYeC!MB2GyfSc7Udf%Q!Dk z^A`;Xzv6-7kqirNZa5({7~~yynt4yRc53qZegCqg7TXf(aD2UR;B1>g$?s zQpG?1xkEF-+S=$PmCBFAGAzASF8lfukr9BzL^m?f-kkr~z3>T3C#kR7(eggZZPYTy zbp9C%dB-8FlIC`|eB;vA2vwr|4E}EUtytl`UB~|3?yn6*rND)Um0O$hLYwTZV4a>o7WC{UATcqoD^J{TCshA z6pz)y#APctX0+)CMs43&d6+kCSo-sdM-1USPHG_ZM*GwcGfU*t>qws{w-BM4E*572 z{L$H+?|ErPUnUw&D3!gXxpHSNAkp}8vN3H4l@_-a2p4~q=&R(rwx+UAKbe(YgUR^1 z=WK?`^tB6!HxCR=%q$hGh?kd@_33UDnyEPq1qBYhfZ>kse(5~;>>S%@yt}d3minw* zSf6j-G;wSTwY%&a@dCN#e|I{JVJgjpydQ__!{db-BT*J+%8OxPZ!FP2kF!&7l zc+KUmV24trQo{k7+fKKCbjUuhdop;KJM~m>X69A%j(2z6bzlpG_N?wx&97~2^;l-r zUtTi0!M?p}AcgfoL}?{&Y_w^lTB8_Cis%r3E#nTUmJ=PQVS3LK9`lOM$&ern) zoVk8^oxKU$DewknIMnOhEVMD1FP9zBV>4>vpdh9_q;RbAE`WPN6D;bwxxTQOeR=%) zx#GS0%RzSu%oXYL+OvuK6V6>^=+wmF-0I~p2;ppmDNDp-K5`h96ba52vnlyI^zbmA0F8 z)6wasd8(_ibma|vYPavxGmiT-JsJw; zda2Vy8X)x`R&L`Bn&n*I*pvBl-92yYT-h_44XEw*C4j_+sXW|_C^KxSdX$jC9r4bw zV!bf2KOuf`l&Bdywaz4*UTamy>189ftv>w%$q5M?n~SjYPS3TejtC2Dg)^bn=X`FU z8kyQZLei&gwmorU6W?kPy|(YQdbol8%=&fjPp4a&?X*e#ZtA;rZpVoOOABwn5_rjca?^^$oFbZUg*>#HB!VQrC3XXtqr9{K+hnPwVqK0Q4 zz<$(DST@YNmp#EmCxiup^Ug|j&Aaif&N-@T|NGlMkNb=^y*!8C8mK(>F=m0-VU<=Q@j;T!XU#lGe`fd;$cuuK~2-6KQ6Q1n-}L z+=5V99{)KN5|lkt&!%~Nf7)nfLkMV(FbEioveF)=LKTig zga*K+N>2YcF60FY%yr<~-wR&+jC~K8t6=$K`cwrT5mbyx2U_IN}R`ru5h{F|eD#Lsp>}juO!bfYdh$bh0 z*@4va%D3vXUz=prb5t&xYo6mRp=Aeb;-FHE8qh;PPldGxD)*Fw(Z6JbGEsj7&v`_%gf9rgG+LCn~SJoIjoa6N)S7y?vauC zpbvuTifoM7Z`4w=`V*+#2?aQ`fz$RmkW)msIP>XO+hyup%5d9xvFsT)eHeu;v#TG&++;cSCSsPk z@w43J>IjPEXa87t07Rdz`^R@NFF{KZRcww8GA@J(C7VOQc7!BtO|L;5225G;fFX3= z7@0KC4$o0zS!y+k9Zm2pkL~D->nyAl>s|gAFuN|4x*Hd&XefWdbqCet3l_5HdcGq# znH2;ey>koG1)Q1h8NRoRsvgKy!gj^`5~gt1+}kiBQapc}p zGQ&A3P6-_~Lg?mhn_-)cO>u0s#l$X{OK6tOCT3N^XA4(lC@u%n=*9BeJn%k9n)1kIh=3)zsLFwBOS&if0s_GGfZb|vn2c% zrrLejW&Zx8o7u`E5aY<4j(B*(&UfI%1cxw_6at^p0kyMA`7(0J+P`ccz5%)T)gT6d z6x>NL4~=fHIL|+vZf|(ZN#PxO{Z!xjbeZRmy#@;*v7X-Pw`!$-`~&RnvX1st_8s-7 zE?|uEE#+5eU;m5fu2|t#Kj>J_={T~sV7=b7tfN$gw6E~+R%;VxYcGQSPfX3b8jKFj zwz?LJ;~*w3wc~2Cbtk=Z1dHy*&gCLTg8BC02k zS?GI~u$I=IwD7GMG7+z7cB)*468Z;!bJo!Zl6aFkV5nyt_u2*Dl+_v4Lb(oa zOWj%X_^Xc9T76r`xAw#QpAYe1>8m^!RyPipPOSHL%<2B~bZOGJ?VaVmp=Gm@7LmLl zS7C{8;CbWAhq_%?Qf|CN0o$Mt^gk@J-SJQI1WW>Tk$GxQ+*zMfksIC2dvKR!zR(r-o~ z!Vef_P1(1^jlMco!fowoZfSn?qM~|!XI{P8wrHJP?KU2|gKpL_k}JJnfJt9|WwvX4 zwO93k+#!>7Z!|=|x@!0n@TkJRTMbM3ote7~IP-sv#S|b~8l1|r)RFC!O;%afBj4;lSJ z70mpKmmbvVV#f2IvEuykBA$0vMI$RthyaX|pBdgNaLY}OKs=` zY?|tmX;koiLGt4Rq~dFW^|!BoJR&jE3C1mGr@_QR`y;NBZ1$%@GjR-XJ zzR(^6=hNpa0{!ow?_Al!G-%?cAOr))DM*%#A>;4%UFuX#Z6MQbm2Sgm_9)j*B?7lS zq-3=%0;+CxlteZRg*#&vdm09i!MSiI8`4<2{O< z^R2f6hyJZWO;^oPF8HGNlzoHuD9Ce~wn&n`2|ik*>b9;zNz{LUpF%haz;fqlbLL3n z>4%ZGxEr*`Un5uNHSs7Uw792Y_L_WL6xe@R)UI|q7pGG!BpcP!6lI8d2Fvn;*{=m* zhkvo`0DC0(_b8>%z&zYk&^@FaG0F^sry#k#Xpl4`dSF{LPScJ(pN9Kwlv$Mm%>&S^ z!_S5!VMFl@^V0OOCXt1~do$lQQ*r%=3oK~_{oT{4(ODDhZa+zILSr^{h&5##`jDEr%qNZ_(68=6&(#ie31S&W$W9D^3|{C?{t(a3N(79 zXP`X7T>i|7LBcd}=d`4AL8?TLMqJH^7lQU*%ljK7>9b1pb{@~jGYL|mRF~$uE;pW* zHHkPgU?N$FfXu)cPp)g&GH-aS!|bg`q4h$?epQv5?>7k0_;>8NbWry0vbWuuy}yA> z62_8Ci`7D}adMJmC=b>{X8u?oQ!GsFXiAuBtRH~584^cA=Zk9%yT-rx2H?(gbQ)Zx7^ z1FcySCUCHssLScb%EYJm7TRdAhnsozf3qH7VG!pM)>00PfeT<5iQ3ORCS6^T@tE(W zjzo%{$Llyd)w;AxLlAg|w)8Q^wsOq>aysqVG_FLdf$pXE>?_O6tdl;&c1l$KWZO0m z!_qM|V2{{cTqi~Td``5-yHHEdDm+8I?A5WGjMcR3VJsmj7uRYQ$th^k98sF`4ss1ENlT`y^ZMA-oFW7#nX?f=qKa-1 zQ9?*~J?Fku$(OJ6OW!v5=1&ZP+ADr;5!r|>JF=|nL-4B+IseUlk+agM?u;vlzUa8d z)Y#VbR7&G+0T#(rDHiUik#yvuGAJu-goVoJX)_sWOvmBW!KuSfo|ofFhZ^V@{^pUA zx2Lv!;;}Wa(YyFn#P)-wIK^t*y#W&771}uKy$HEc`H<12Jr&saCKmxJH;6Pz{jeow zY~qtn(BgufAKfumAB|BKMfi-?+0LC5sD#GNJtxZq$RP2W{utq&>?L0GB%!Um4lXhm+71VZID!HhyjWPWxzmuYVBPuHB{a3A!+-{otBOt zA*P*ffj;m{6zEo$uSYOu8<5~p3R3eiPodmXC(INUn8~o~=_Lyq7UsqW@N4N`+@wzF z3yupl>Kz9wtTKM0`RX>%-pep4CZ^&|Ro{Ml#a$isI?}m{AQ_ahihdq>!b@KH3*(Hl zDiDla#BmP{p94!;8~luR$s(>5l?CDRMZ9z5q_$h6_ptcz?tH(MSqt(%PlIH`6$p`1 z+sG3KwJqEGRoxos3F($z*oJr}AJ*$=aKO&Bl1>ONPT3vjPldmf*f-rCf(48!`6N}^ zOx;Dq0c7s7^A;?$;J!eRSUkyd?)8=I1C{PWJ_OuRTf)@#fST$P`qN4E*waAnSdus;W~cxT>O>n?c#7|!ew^-BSl;qK4CHK9*3HKkL-QTSEHh4> zQhZ4EgCYTX{(MN)HRM+7*I)j#1ltQLC(SeMd!0I-;WUGO9UVHP=(qqSy|I_S_e^lv zCnM9F6mb*=#p4+i?Q!<(509h9qdSUS2-FkRbwwHMbCp+ z!&R~aX=#?PM){R>`Q(s(oV4wu+^AmuAs@O2JJ66;KbL6!q|{=(DX_Mmxl7Ei3`*LY zW>P7mg?!2Bf7^$vc5y$EEED6uvz^^n1o32d#D!>R$>4+-a4H>$=wkiRvJ_R+du4@s zt9=t=w@x|D@~*Vy*{a%)dT})&WU4aNyjnVHR?4gGAuHLZiz4R-4Kn7YzWNndV(y1`>egLJc+ zy8cEG&uWn;HF?xKlC;WEI0Y6cC(Jx$JsG+wh#EfHlJ9P|yY6Gbu4*(pu}d!aF2s{J zkLVJd3bBlzBe~;s)pDgI*eSGL#wJ-&><5!V(f)AvT$}K7? zywg546gH7&dUbor4p3Ue$BPvJhoNi`-U=gg{$LxoH_}&Vlk3X_`ww3XF(RyZ6#X6y zcqnEU74#BUj6vtI{`_p(mtK)-(*W*O5`bg}f>>slynBkWR(YqZJ<5a(VSd&hQ{mbPebVbODNS@`7h(yFPOY+kKCe<-&6r8bt%=&oX~XU1jp*>5jG zcI8-6`Y>ZhkJ10KgTJrRS*AyP$xP`zzJf=ZWTKCcd@-nSfg z8g109aHThp=&}|)VedkLQA>x&~P5v=6Q*k7J&LEwB0Sa>m%iVqu9?pfN3la7qodiNrSrrgcsJ+?&o3W)1CeK9}6;3Sf_C}FHgW?^yv?H@3L^W zz@PdC`YatdQ6AY-6D`IJSHOKH+{u*uDFlF38~J}?0VX+;~P_RMKukO40PhtFYn0Up#)UF~p5?k3j4qnP

S$gUWTw;5E88sjV9Pp&eOeNd#M$x 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 Hu0mjfe|H1w literal 0 HcmV?d00001 diff --git a/docs/images/gui/gui-status-bar.png b/docs/images/gui/gui-status-bar.png new file mode 100644 index 0000000000000000000000000000000000000000..4d23efe4eebe1560a25c20f12d7175be84de7cf8 GIT binary patch literal 107598 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

qB*n^dy&;k>dd#ZP59NH zMWVjcEAP!kN_V_8__>q2V|I}eygA@P@lYawZfz#_LE(j>+MgfigKwVqBpz>^jbEnU z5UGDxUR6`h`8xghC6@Gt*c(1!XV2~I)0t0MfjTN2fhuCIIPp#fPId&(x44SE#*~;? zBd(LsCmv5ZPAA$U+mN3m`I0p^oy7Ru0)On*%0{ctMrapr4kG=F3I&NjS`C>e>s+s| z!*Lqa%XuzuGm0KPO}}?4H(G2yGNF0;B30__wxslu?c!FUr^8v7+Cc1yQ7vZG-LB&a zrE+5w@#Ri-J<%r@dazrbKj+xhnUU~-BTqN3KF@R*XL$CaogSsTdOv4}o2d6&oUR!U z*Ro`vnbT`H%E%il_Y4Xcif>GI(_X&a^I=pKRdv2&c~$nu$D^6{j16!2{E4ra_WF3W z(iYb_Z_>32t>|*s(=g}z4ilNM@p`B!zdNqg4HY;!tH5&|*RL(Ct=Aj!Ep6PzBS`v7 ziAguy=0yZzt*G*3zxNh;BxQNzIp&aRNp%oZp0_qy@f0pvrXG2`R<(J4&BnwuxLRnj zn>Dcd<7WG)HotTAZ{sdWKQhV6XJa)tlg|ZNep*g-UBKjN&Gz=)$C<>|chk@D528ih zocSo9R+l=yrM#}^&#ZM%@Gfd?Q1Cgq&>Fc!a$uo6on6+v>J7d(IdJ=Wbho!=$twGJ z>$6u6#}~J{A*2JGV|~5ECx+He>v+mIJuRdrLnfqLw^8p%p6T8KssbramluNnPx!Kz z5F6`hUi7`=R%@yag|frmYyI~He2N58<012}(n7HHs&|~FDti8k{y@q1S0X?exksU_ z+0p{}T7I_*CRDwHf--_(!#u=lvrOEWiPfpijf$hY;xARa)2iShM$4sJ* zMfr|W)WQ|p4r4c^$xu+zm>~|EyMajrpAk9=O7lkbiev-nd(a|W09g5b3kKOk>n>GV zF31B1q8m3}gKm4St-T2_9y>|Dy+~#b>GkCEj|p@VK~y4|F&-D{k8?a#|YU;UqIB^ z!HUY08VXAmNE=oj&uj76@!0Dyakc_DK@Yu%dxc9ao&S3Vj0UdN=Tl>>rMZA@j*aJS zvdctg_4(TOD68iz8ih3tClg&S^d><%tk=9X6o5SwI1*$!Q{hw|o4EV75 zcoHM?dmYr|`cc8m2qGo3u@>f-6A`Olw z!?1;_p<*zUx%3o^5Pd`;|Bgb2)Gk0jd0D6Y zT^2$99#}wL)8hi9Y}|LpL6E*E%W;JB0`1{DG!< zJ&W`kiw8PC;$y z)|c(brdKxK8HGHOUo`)nuk!pp3W}k^ zfaCB+T9L}o-}Fb2zjW2K%j6?R){#Ds!Gq!JtC1!jAG@A(i>~*8^Q$-M{-_X)h-Oe_ zkmXu+=HKkEmuFIAXu&kB5?P+VC{(5~iiDw{6j~OZ+hlznQ`3-^NVLMaNA-+LOU2;C zPi}IPtjVf7cehULW@Crn)_R^ZTIzObmHa`U2Nfmgex#Ghw=Tz~

_S8Lmu#(lT3@u))Ac7??&$v zE9G*oWL=C@H=02z3m&q;*e)=Qo3Wx?drcqLSRuNW7Es?sK0WKim1SY*m0svkY|#%~ zkcv)fHLn#n!!3_*3%s+D&lW!l`NhY8DcAW;<}p|&+mog?mBSi0!Udeg&eXthA&nZC z4jmUs1vIS-fk9s=X0Ztn&+I#{d5+J$Io${Ih*RhEd`c7)mT_&Ojlvwxh6+f4M++~j zoT0e*&4F%zaiUmkWQf;^iLhJ`k;GR^55e{-uj!YiL^w{(0auwltj6CoL&SKZ;3jzE zk8dUcht_dB)_|4(tkK*@W5oty#iiINC`xie6Ow#2L)5Pvt(pquz=N6MLpY_jfz(2V zt#iIRp2~}%ykKy3R*L0@xc_>96@Rl_cHyc~*e*VZOU6+Fn*9;T(`$EE3#}B`mwK5C z%(#6Aq4NqinH4Eog8^gAupzSU2}SmokUPAoLKv!`4N=izFs1FDqgnAH2nycY?Bk31 zc~38((R0)}$L!UGko(D&C3w>F#J+5fZHQ5Vw`%qK?Bl^AVrk}rSKmw#@YKKgDqXK| z%e#&y`pU-3R|<~Ss#&G3r`ILqW@R`0l+1wab5F?zB#Vn~Y=hxN+&gL?kDSkMhgXwZ zt0BntMawXD)d3)AU*^+L4O~J(BI{r4aqelVHiFbT9fN35If%lGaWGufkrRnqo1B7l zD9G&1f$BtrJ`^1X((7J+BN0hZx7h<6gE8}b&n zHagNlY0gs-U~+dE@-^9b?gmDOoe5{*8pAsShhI}NhghTx&Nku|_^K=30aY>0Mj+?g z;f_5h4-U+vPgaGFzZEz`KIO%uzGfS(506v}3>A>}7d+~G?OZmHWsb zVhBg75_x1+lyDTxnR?t!)|>0aSKKm+nrv|0h5vx(7nYE!0QY*{l+pm#iGrF7{hSASqWLN5u$ynMVuu9I$eG+ z`b{167(QMHEB-WTr&zmd*tH@>UglJCG|gh{{gK(RzQ?v1V9APkHXS)Lg<9us_91;! z(mO!*#32ewK>^oAq4$&gHP`qJkD&8!%+bmB{<0JG6n{xiS(}%;lSIRH)R&1B$i-kQ z6h|bY#2kk^c=1^I5*&5bqHx*1G4G5J91%}IdC0%v%EZDC^2!L|9;O4hEWve)%qlEG zEguWYb{GQVNkPbW&l3^j9eMy|}NYzz0pgeR}(O<0NS+HTrlHQp$-xG3N{(M*kyHd0jZs zcE9cl2Ci!ce^~YgDq6EZxI<6ssaoP|q@8=Oic&VG-LUhA#;IxG&{uC3=r16my5X9V z(seYI?fbeVCgpYsn8@sNsH>SMTmtWt@)BIUh5a?L<+>PjkvteS*z{E zKv;4^Lz1*Uu3|vGdA%vfwZ)3_OYNt^sl+b5a8&Gf6DvxQkPj)efFm$J;Wadxj2Yko zi1D7HN`=Bf-_|*!oM(rQ!&~L`zN7wn8Wg2X>;Lnqo};{ONXi$Iw;PVb7U@bFsS+lq zV1o><$W*c`wDkU2c0`vQB0Bubn#F4~Pl@ySo{R}ld@K6EhS7ctH16a3oDDKnYhA2x zKX0{JbL5=f5|Af&hqEbZ;AhpCW?s7vs&6MvzMG)}G*BZ&=g8%u(g$&8h$|vB{Za~t zc<7HP3s#@>*ISLQ`{~RlSuKWH`Q{21OELr*O>j($glS7KISulDYo$GQD9CEcICoP1 zls&2x@UA7ksQG zI1=C1+}vs1i|fQDG%HX2VL6rvDK%WBj?R&{^1I(x%|$e`EN=ksyUNFiT9i?FF3Th5 zlbY9fu7=432!HyE>CTRzz9=|;5a^hHl9jN4ilVfBu)kKtqVD_wfiEILwdVKzcg73S zRL*RdMA`7FuyC2fe(AE)I*D1P%@!$w=!+N?ulzu)rkDd@Qa|0aZ4*kfbOe*LVFnwq z<uSFX)VDxj#z zzag;b$$c=UmggK-xEz~>mG0zd2%WoWC9(<@ByXAfRJS>f9kTb{f#Gv8{$z!yLQBL* zer_J7W?yU`{X-S;93>`&%~tmynLNMj>!EH9nZcfK_fSY~_@0;!daq~I&U;yg%^D*I)<>g=l&gxyKB%r?%jt_rvq)<)QZ)k5tQ;^jkq0AZ$ zYyRM`?L|+$6ZO=E`r`G#XnMkz@y)(N`xBufEsNcsC)V%M1i=fEXjBUTPKnh#e}|9q z4J0F)*Nj*bq8>*#fS$)UnE6Us9_`pJ`v90R4jT+NIgnqgwM1a%y;Lpaz>r;^dxHLI zV?ufm2Rp!NTd1Y*J{di&I)aVEBC6ykdOD@0)~^P= z2E6*bdN3_BID^_=mI;LhBbo!mwc(wYS z-!@0EVMJa~@xhYUc$E}hH*R5~sMSrCZPfPf4i9i%aZUGGpJC`QeQoR<;!y}0J#xC> zySiVT%gnBd*q!clxk1dHn%sdu&uw}^lX3@zRZ2mDZ!Z?p@1^Q?J_y9?fCAwN0wYCE z;CAox?zA3#L;zu&dy`Z8c>YLkj!R-rRVrpmA8(%3cjmSJX3Gr=#q`gzx$1@7l_g5B zA2-v)47pheC#nj9{X%>m4O?Yhp8C+pE_D|;(w`ELRjHo=$4@LoA+O%|3$LG`*znSe ziOkiYS?_oF2~>`wBs1Aoj2m7oHA8=XQV?n?C^%nRP2^G7ZD0xMDhh67D}KMq6BFIe zUUgEo5$nIa>~(EiJcY~X{b5ogIet)R-n6dmheIRq7 zNJKk{=0lHfMT~fRBT1V|Y59b9U3wuo!K=XLaIsMPNN|&4h?oq;cct#?!|ZsUkPRcEDg{&8vJ-h9 zrNq`!K3!i_Brd2><#2K(rjBgze(RrN&|<&^a(`>{uc;`Mf~JL!JdK%3g{bGfL{#Hf zeQNqoMP_DbBK^l}bpyMqYG_Jcg2)8Ed>J$m<(FeqbPAQXL+|*h*TSs!flyJtQ}?i- z+c{LU3uGAuiW8uejO5J9y$lbN`l*uYH9k;A@BsWelnGIU7AxSplCrxugwUBH!CiF#V1DWM)-?;93 zNj=bX(SJ305iW11X`A}~AhU62!FyL*yGl$X2xFea*`{tiOG91#TlXD&sFmIh5xYv) zd*Au!1e^vV*--mqx}Em`1Rv>va1a-8o?bltcM)$r4l*C9k6x{n{GRmtENkCI-^HQ zfx5I3&ZXK5%Ewo6+VjL0EX~gsKk~i z8yhOEM|-ouc7%c=KB;%Odr33Fd|9Ss|gcVc}-jf;Yp zgI=6ZbohH93t!M2bMrYuL0%Bhf0&>S!1-$DwZ4|vSx|2Cdz`$R1PXNd{su@U zEJAbo@8BeL$`q{iqb`7a`|r(;|eB^M)zZPANjU z=vAhj_fz%NSr2cY{JXt2oo2DOsrZvtB^D1iZeC^PvCur1Jb&X}o7Dy;53jmjk^HTA z-Rf;5bWL&A*m)$RAh0mM`gm;gbY`N8asqjCc1l*swXR;WPF%yZ2PpLUo}c{w&0<)) z0dPt7^hIH-%CFh(*Ktq!= zK^pHfqe%c&TZ^3mKbs4|3R@s#cL5$87w^|6Dy@0UMyl;*CroAl6`KY2`G%M;R(+lR7`RvJYHb|#8;#J)IRQk237|~&JkO8`zf$LmS7YU%E@5_3 zix5VD>6XZqDn2_j#(OtjW=1m*mQ|@wKMP1>I@tsb@A4=kbN3WmbopO@-4hItq+Agg zHzhL2%1YIa999%F@WcG0G?z#CMg3`^nS}e~N&=s?29FuP<4U(F&c8Rc;?c782ADvW zR(hr3A!|R#=+v9mMj*_hr;o=d_i>^w7P#_riEAK){Egr;F{G|60KQzE2^Yjy3W@ye z@%i%fP(EEu_i#3>&~df5QjehewA9DXXV2f*;5Qb)hA^x^=)j++7=Gh6 zpi8xd2ryeoj=lTZ0dJ|-v+^%=)mhd2^g)oOm(aCV@hbzjyzAVopDaM~_p*%me^+~6 zbE2n<({9B|tTt$^wOy9>c09p7^IGG>z*GFPc{H1^1S#6`IV~sU^1Idfwm!eJoxOU( zkBlkU)f5cW(RP6wgVXpcv$LB!Zo3h|8PoKD9ojUQHTPc}z61u}b+EXgR@PYMmE(gJ+rO zL^QFYVh{!SVnay*d*%!O?o!%zrw6TjW#;@xMH8z)n+27L;^=mS`uS1OXS@QoAbMa% z5fep~mz8TJo9|n!%XBiNs)$JSn0WI&VS`W4aTiM6dOtV<`97zCZH*`0%)fh-+uj$lD0Il&(J(2-0V+B-QDSx<*Vcz% zUk+b5EI`F4{j}ryPC=>3X+vI-@pqPWH=lH6f&1~s@+7B0qp+6M2yn>Xja5`YB+6fJ zLR;YrDTS!e3AMcI%od^6IclvuuN-Sq{2ysZSfRX_MV?G{pTg;F!|BZ7XM2*ZA{P9C zf1~{)vsgWIuzHWz)rU!MZvS1fp}MZi`R#Z~A*RP8re#JJ_5X{qFOP?E{r}c+;zZK! z9Kz{TN?BSM*^8o*En9ZVI%BBp!_bB{6h(~~`_9NRGR#PdB*r#|8Ag%aSZ4+^hUdDS ze#`guJpVlN&v8mK_uR{MeLnBcd$B59OImt%l<3gys>45?iWbYJ{vVjB7~%CUx8$tu6o zJ<8dZU-0W4E&u$VYN_&4esb$*qWPVcKQ>o8fxDEFNqGlz^f2YZk-DTS2c{;?Y7P$+ zElzsnxk)kiNIV@I>AIETviUIr0%C}Fg@H1CIS-%zI>d9W@E2yvQWINMnz#P@J9aV! z0J4N$X0HEp2(R~F864hnO67I*CqYlIvhDx=hJd1Cbur5K)bN?ldJQ5`7}|njrB>b> z2OT>Z;UGKfV~6RyfI0^pxFmKK6y^t$EEJ^tz1*`gbOLkDyJ z`qFI&C>=-dtZ#T24R4qVv~?C(9Hj&Qj}oxwwgAZ5j*)Q0Xa+!m^ELurPg<2WbfxeV z7c2hJjCNVZG<5mlbHsh*9EiTphNLswnYjK>b#=LuM3~Jq3oRe^Xypd^| zUybqs)gosy56k6mk*t7R@NGzo`=;sDxD8zu00xCYA#V`MxBrA61V40MlvtJB3E7gM z0fVOnW2K)joURT7OFA?!P;_|TvGG<=AU$U?G#Jzp)uoQL+@VG=vZ7tfr)G~;f1Z$y zx{b*CU*)6l5udB0in2Ui>g(-Ty$k55kdc4@WC!{_qOJSc+zBm+%oaF1(4VB_2!|x{P=QDC1-ZB| z;LhZyS771{xux+K%Op=wN^)+(Yaush(o*VNALLiW%a4aqSR!p|fy%O|iG$evlz#?5 zxlT&L^Nvpi$!Ok68ppGR#&LAxgSFu?-PqzpP z9X7fF@$MqWPUxq0V^LEDI%u$!;jia4unNH4ymCfRpu=t@RWMzh7KBC7IuQAv8wLsI z{_kp$ucNZOrB7CmRTtl)oMOIphNUebS9iNb>~6j3U9VLJIH9ytrQI_o!#h`q0V8aRrzV zfXsnOV{W;~?^g6Vz}{V4g?vQ~eXc30U&dN=Cm07Fsa2TCng-JMU>^YTu~u&pA=wzY zXwvs)owdgztpk02X+lQ`ex;X2NKm&Rb71`+9BAmnlKKMF~GV&J8>-usXX38S#F zSC@mRH~m%?uOEn3rGP|DZ^-P^!bJVE$=M&jPcw+M%E}VC^4~FlOMTjg3h1T8H$@Y# z_Z&4T4hGrVVW`-8m8}n<&d4-bbNtD%HPJ2U`C~Q{L#7!w^rHt~(q=*VWVwa@_-NW|g%a8F0$fc%H?v2dy9T zn(z_~BU&=j_|^J>a%#R^kn0=nFl6N)j=K1D>l5hCNS$@f-||=fwg8TQCyW+kmEY@W zq3^JHIK9$2Pp(c^^Q3+>Uj1>vIBcewSGLX%;#9v zfc}9uN8OR|BIn-ez0bUFJv-NSqw!SLEU-HQ@e8bsJUIgVe!=MZn&(PVhm%CV|5hSO zEhX`K)Y7=E7TqqY8^#uA1_O-W-6;nrEX(5^F#L1@^Gc%S_>os(>-{d!H{4v-_Se#wC1r_{2np1<1Bd>@*xBk10mvG77_ z!1r7d8<@#Z%bR=NK_>fiBeQ@s%;8)02s=$InUn)qA)+odpzAWJ#Hr$IRQy3!7(jB- z&v&r<^W-W4@qBSKbA54#HVe3(*_1bXPmt1ARp9erplVBBcEu!l4;IELyp_p6qW$ZN z#zAo9=baz38(#-ZWR6DZRqe~VcoF9@VU!qJqI_X`WXs=`*ehBkRW&sC+w#b)OeP@w zBskt$7Z2#L%EZ7CsAG3jA6sE>PtBPaTiaYvW$%6N&0~Gv17gzW=dT)D2VQMMxICLV zwU1C>fE7~d;5+QvdP~yG)e3&+@hp3b@BtS9!<3MVnq2<;l=XIuA)#WjZ1Er&xUDLz zM@|rz!w(Mtv_j6lSc~!rxG{^OxxiAXqQ)Ntq!+?PHzI$_Kd|;@i-4EnTKkrc1IdmN zkHw^6eM^5<=_$M-EV-g%`+T#Pejdo zea>)_A1HE6t25ZJJKr4pm9}@PDjO=wg%wvoznE|Y(f+;R0TQeL92fI@2^c!KHcL+S zJH13?k_1$QBRvEZO$D*4)f?_EjvwX@fLbU?j~K3$Czzk_SN%4|S7tc$gUz0!!KA=C zO&S3cF7Zv7PQ;58eg5eo zfRMB}DD*&uBO;#SAa>PCh`pPCR37zuURY$GUmdiA7dt{`7d#-#zX5=yh4pxG^`AS# zfP)_~>Z^ei^5+9{Z{q#>==_=)h`0Zs2tBSjbUCT?H5b7E4|oDV`pfWk^ih9qoTew_P?%W5jR3)HG-%-i_(}n|lS5m&`hYc~dt;2a>DV_C zyTmV4@q-JrGlDsIW2=1L+u>G1;PluM@W7uZy#P)-Va`fZH!dEUK%}<6(QzIkp_v7F zY31NA=v2g@O+D8NcYCE>bFL3;$)kKlL4YD(%!kVP%9BxUqfpeYaIEOwO4XnFS#gUU zbHLH^9mLll-P5aNk$_7&NJ733f?Md&PBogF;=AY$p-i1whGBo^ufG7kWY*3~5Ct1p zcT*r=9?juLhqT^rawP^K<%V_`hqdGf*a|^l^9kz+fr{62aBv#1Xb=iV0+-NBJCvOx zk^J6Io6rMwA=Yg`&ysfaRsLiNSI$KIVJ^*g6v}gkfJ2<>QOR>mPhH*{Q(Q49FG6)& z2ysLF*a7ew^pl(>$%e!!p z0}2N)022NOiV;nyA?(dJPSfg#wku`HbH;)$*@hn-Dfg6<6l`yRLZBf2nI@vQ*vvnFhqI zpG$U;U`oT|cuW4R-{w%Y22!$?GGE-W@}C1h-7*p?KfXZmj1KG8|ZNX>SwPK2t@`Pivdl`iht$@KH zP`M8Z+>SkTcTtyZCdn14A`I|cDP4tgAQhF${>*5_&|Rk&q2=$|Y1(>_2A zt>UGuFW`ZQRI~@r6D&nDxHKlrwI67Gp3nTHIrjP{T)6rYP>;42JJ(0(lr!Ta*KW$b z*E`fj0+R!sk4zx%L2I%fFSi`Uu6(J4mI7c${xMnYf-0}aVFj|-WCGuRwp^8@<%pp! z*S!29dP?-xOy9fp!u1VS1au+3Di)>LZ%<`loOYLCb7f(DXv`t=`)H}#< zvf`-XNJ{{G&{Py{9bw+7Y6Xyq!9_Z8c@_d9tZ?PJx4xg-u-st_1`e=k#Dr=5lf@VoY>D#6IDwfbNt1! z+$w1G0*hhNH`PIC%wb%rnhk;~Bjz8-_WkAg))i<7@nW#P50Im2H^?2&bp)9UMF+Oa z-ukemmxK-jCa$T>`i=FLYnxxRBdK^P!>N7>P7j;BCRG_;!>o6E?0CvEPFojEC>i?1 zAuCP-dx~mcNGtaS;Q$awh#6`;W_^_o$5sOBU_VG~fAQ^AFvznzVsR=;n`U}`x7`rn zYaziNa(Qe@N+TfOui`l9K~CAr@Hf&IL1%Z==wGPVUeBKViA)HtmR4i*apnf;u!4ej zMx>R0Y`alS@v?qkNhAf%^qsQpDg)_AINJsc{3jS99DMT>;zq%Me$cH{%h42bKMlI` z$J$bo70x5fU+2+Yd5i8>DBD~F-T@mznB-c?7XS~sKvt6y#?Yz*n1Ojy!_I3go0fnJ zSzY_5>Y^tdlw@BWN50IIdtF28Jo}Z{o62cHwR)1%Zi2-jpNx9~JZ<|REc}~lEM`ci z*6YP!1p@`kanww4v~qX0CG-T@;=d_5Kc;y>Zma0g6VC6z?$!^v4MB1$wOA;pg4kb} zWD0`S_Fo(y0OJTIAlxjwVum12~k+*c_iTaMe@_x%+E}XZ{$G zyB*}5D;AZOn)DW9UafBI`9OH8dj`-Hqc)%R?>K2M?J(rTU_w-;Z`oI4wW>=s?g?pR zCGtZQ&FrYnRuTbtG=H~{k+@&8v;DG0z9=p*4_(e!#P6MInQcw#e>B?433;8>9fLUojXuaXBnK$JFPV5y40-akQ=!T~bKD)0I6$#3Xyx70aG|63k{hba^ z{rJx8fzp8`h)Q=e0<2D?LGYtjick4ttWop!A5>5{c|tQ4Dwj89Qx6CJBkZ!P=LezT zNSpk=iN_xy(lJUM$RC-Ty;U+{KSiM1GwXw~pd4flDKpUmym~jo3QV!UY!`O3X**=B z%LI~D&P{+M1c7s05gQ1>hca0 z&*JaKRciW*rco#`GtM?uqk>7RjntLtaEM-2?m?L^`~5$GglqYROZ3zGC!#On+KB2# z$x3WHlZ=+a`=`v$=3)XeZ<#KM^z3zz-?=9$G}A6z#DgGrFEg&c#@|!i=falMY~3%F zM~sqIis1ciZ_YHshQj@^%13`M%JhzUm^t>AU~<_nF$WVy=#7y<<#W)J=!d-L#IfSC z_z9~mX%QJykpd;6%fFu*q@L5g2_}gwXx<|Io9<;lxOai&MP$nCK=x~~PS8t>dTxK0g3gb=oQ#ot~f_)n6ASY(fZFLyn(2g%b51sYBu%+ZTZLz!H8)q~WvZC@NQwrS+ zz=Irr7%XG?;hO97=2Cp!L}Xue>HKPVZ5*B-wM=tL4)?N84JlAYJWF%Dv+5J9!VZ+G ztYEG!y=xhmdQ}AX92B1Ob`=7LWV^RTWhqcF@x28Ro8s@X#M! z)M6K9qvb z@`l{P)~U5H*87brq)Baq!N!>OG_mnVi%?N1J*k#{m){=i*>i+E6=6DhVfpmZmkHE% z#vn|LDkShhqtN7QmO-B6_Cu^3o8uzyd&HfLEqmOq;d07h6Sl|tB8-yyhCJHJW8v@_ z8?oPgjWf(f0dg^q@et`M|M4nc?iE+@ghXXQq?Yy{g+n#x+JXUSU}B7@$og5xNx}>Y zO~zbSZME|6FP;(1`*=Wgi}m%xDCPe7Q+2XpA3MPwDtkBU?X5PN!JI|&wq`(-QUtj+ z6=Kn8)5lexLg;j2&Fcu6a#hJHW5PD1KNAeHgMiSRt{kx-3O0Hdr1@2}dahh*O<`AZ zVicx{w_w%X?Q1{dp=u=2Cl?lR)Yi@k;pR+FLl*^3Rn^22L$Zl0ezpBe^#h%0ynYyL zx+JN{UDtnr$XUY~9Pvj)r)jhflK<+gB9=wt#02iDDpm)qzRlE=+&6dGWa#6?F4gP+s&74fFJ%?nBkI5vtleTj@Q;<8SesPc;Io#CzX1H~j~M!-D3(nvWr zC4b_*uynxlxgzp4bXh@=Qw>4~a4m>rEy_PV$4cQhiYCu3>l+6K9$@An#0Br9|AdIr zV7N7$J9&mWjxVfY0h90^+OEfe8&EXmuZjNMK#53?^qjz1_4H1;cTp3z>^HKo0f~hq z!}SzoS<9`I1=T~w4NbvyQgK;XXSEanT-B&T$-OK-iNUrQGebvY<(DsX3zUM zyGrdCBIikjYcw!c8gE+{+j^i4q}?Cv;Jq0w(wO?uF&3%Po}~O5Ddk(`7x3-4z19*s zY~<_BVh`KFS(DzOkVniF<#<}()9Q+;i=&#$vLO>>pOI9O_iB)Iu}4dI*bnF60^*LX z?xGgdgM&hGXlH!FWHU z6FKUO9hxbUK%JQ|ZC-D|Yme_h)JKSK@V>-|Z}jGBqqD@c*LP2MxE~6erX>~+q6U$* zOLO>bYFH+HyAD@(hCM;MYxNm|RBrc=Cigwhut$|byHWqsni&9G;3z8=C z?ds+9iAlMn#l2SxwLTfwiCsr z4ezDhZ(vnrDGfTSzOdGP#Emr0l8Fa;VSTz)4px3^^&Q}3Pl?DadJ=O)psfM>mzTFl z4qkJv-L3R(rZ$^zoH)ttnc=h*#Z|0g>%6lzJQIi!{V!mVQ}!g^X$xF6TTKRza}0VP z<70nd0WnP+%&6M(`hMZ4X#FFZiXxvp?6Y^^Iw2Eh*WL(QOV|4CmR=4)Pz#1Wa z)%$Nav{6I*cc{nA`3Et`AD(-BJ}L&LyxDQ~dpJELd4!o0IiziSZAeKsvRx;1QIf~1 zvCmw>KTRVRl{k+3S`$NjiPK*7PQi3G6-WD78{vy3&fC=QN7gsw(tCwM2eWw9fH;wU z=VyOpZ2R*i-a&gb@6L32e;jN!;X(0rpx6uF(6Q%^dUD28hZdK@xdUx5=9`poIx&KG z4}HqJn7FSb(Ofs8O9!=5-Rp~t&b>d8Cpb?VnEY^%>}hVGQuTQt%+zpvx*Z`#Q(8iBP8kadnvY#LcbLvieO018oTRgX-xboeUFj%!lVa^vjl@YWbBSqC^L)7O3 z$#b=mWMisJ$-~{Op3vatl>*n|O!Cpy01U@eR=;YTAjasH8(i&nyBy#^+?eQl>WO}- zVE#z`DA%uHt_D64IH~(FdAj2D0+-*ZW7V5MZ|X(-Y*?2Gd^fTYl1p8b;t$Qy<`Z#> z#JP#}{3Q2MN3c-+R9z^)Fx9(7pv>f-Q}D)~>;(9C(14!s8`wa%jTwjb!*nNvWu}NM zY!;E1e6DJ^X+1o;OKQVyV0=mo@d&w82Ir zygU4lx3KzsWh8nnUwTMX{=bq>Z+? zSO--{ge8~i11w+pBdm4*x5^!k!@k$cKf1CCY0qTW_*8u3vTT`H0xd{nIN+^AZCCms zhU8H$qNzL-OYc(Ft(wXa%?!iGv;RCz%hkD^gH~VNDpfWV+N(=Y!xC?$%w^K<;T*YQ zVGBdcW$i?vzM%1D^rdN!hy})(PS2GD=32#g_=nvsbr3*vxxh@BOZk)EWx3}IDre`u zNG)$AU6*Sr&M1X1p_;?*=MfMKwg@!h8=p~eXTK19Nm=5A=j3Fel&`@9={*Wn&)P_+ zdx{wK&rY?hlTypP#+j=82`kkOZfu-kgCnc|cRKgwbT}k~sP3b*vOUA+d)!(NZBQDuD0g2@Di2KYx5FR)IN}^Wb#K^!9y!=M zt@$lCuE3sWa{jyUGCx`P`H_oZ<4tq4wRop0>B#w8<=LFYEdtj)OYUJ%og?>zL%|pR zFr~J{<{_I-1ouChW864ICaBsh_zbd7lpa7ATX?T%YsnZ`Td$HEg%453Q2iHY{0Nu2y>XE25pb>O6v^I+K2m zGfAT|c*{21LtGPWw18vGZ%wzPQw*rPxPQLoH5lo7DYVMT-O*#VZs z1{^>ieRyrGAat>`KBN<`&J;@Fu-GImz6^u3P+Pd$TR(2q59>Fx4n_$|UpB!>)x|qp z%EUc;ey86NpS)1*-ygm}`qvb?*#`Zj6S|}Hw4@E{755gRBZfc8E1u>m-o-yP|GYWD zaS61opF1hV&T3AqnOKFP($HMtNjujQxI%xM>GBq9M*TonO?P6>DT>x~UrGFOOx_k% zyC>YrX~L?B^>uv5eiQ6ZK@$}NYG+^t)2B6Sz43wB;((8UK=$d&7tULrXNzA?SjufO z8b_0^QS{L7Uz#+e1l%qbmhA~5h%C~CKh+UzMHQIW){pX z;}jM5Oc;bXC2&~&SAojgrTnZom;lwi4X2nB86wB>muNNB78{XZ{ghRFfTs>~yy+<8a zVx!yh)DZWJ$JAkW`muvognjM`fso-ea9*~}?-ygLw`T?(bg=I!uj^39CKLw%dHR*g z)L6vQz9$sxV+!$K%da6xs3Xr_+B<_;_87hSi0WZw@WExUFQY{YL#6tssoQJnM(M#< zJbLR2cT69#C^1@Au0FjCzFFY!(!=0`TvE$M>b2lu>tr|j6y0VeoP;){&)yG1&Kc=c zx*m0!YW-?1+r`x*92CLcC)K98q=q5Kx;6Q3&)AHGKd`=W;IVwpmO@`SO+A@fWI~8d?tec}>g;jUKfVwc@WRJxs28-wnj14U zZl$FMo7bwD+#;I<^$O!pVqOYaP%K3Nl7$IfM z1?N3JSxE8ANhEv4Jf783omMFMZacsQk=SJX^`cTy zJIWla8u=B0GrekW3UPeU8jK;9Y3YqhCP|+xjr`hcZ-R@9F8}0{(!JrO9nBo+)*(6U ze!~8OR%1ENOuk_xJ~3*fO%9$WSO$rGRNc!(q_qVy5W7iKKuGP>xe-x zY@MEZhyv7mOk1xan>Tt%l=o0a|35cmrqmf8er#_&=D3N2*c?28W9(9#K(E=v{D9kokYsm9u6px#&o0ze-dnT%c}>-PC? z_?_T=CvB9y@Ek~RxY@oa-CNNnUiXDu1$x!6$)VtC&q%^62$+S3Z7`he zGf*|i%E1i$`T7zf?8igK3Yf%Ju~4h>amJ)jGi6(awdxx+$ay5%UW6AW?CR1@OpG)9{KrVy~cF! z#}luvJ+dkNbkI3H-OD)U;_B(t{nb&^Q$Tu_ih>UexL6fWoCT>h$O;?tX&S1$kHQSia;9%He#aNy9M?dvyfR|5F67w-of!)G5X6ln_iXD@326?J7#L|o z1oJ2-@ROwR>!d=K`z>PQMbYvhrgEz0Ab}<8>L?60{X_w!l|`awvHtE`5u6!g{Q1h% z34f^kk5JYOp!cC-wfd6UvAas-X)C&E-cfpi0mhpRKvg);gcL7h%$r^}E(zNJ1I6Nd zW7TXU7S^rkD`W_2UmL_ssObY7`xy}3cxg;B7_jfIZ>;B#F4$c$ih2?&ZKygAQ47L= zM@Qn3&IhBY>3-m|a1+cUg3CY60FT0IFg>*$Z%<7;0#=Pzo}1l7#C$n2VqScv(J*5C zfl&yu@*%td!VlX}wJaY?=gI!5it!D%L+$TB?S7`c{`8=2vlv zbNs5nS=A1Mw7;ld6Lb7^tHH*0)c@30$JQ~b*cZ1i?=}#LbKEy-M$){cUDiam^X0j- zHC=i_d3;9wvNLf*ol=+xR=tXLZM6!|jiFn*W6kkN;u_uM<6hNH;<%dkAx*-)N)*qu zW3P*C2_1QXk?{Pm8~ZXs)8{*T=1goski;)jjC^3@7tSE{aQ(x(ik=2EHEN|>>( zcdvq@oASnZ8s2Hh754M>&`P;CdB9~*i0MElJM-9Tm1*{Ke%)!L&T&!IR$>e8X=z}~ z+k%D4?E7^yHC-QJ@L|o-hK+a3_WGk5rs73Y?@1Jer)h!~}%t(b~2t75AJs3fg2g$ZiZDu4pyN!kyw_`6Z8*K*aM2L-7SSOE2S} zD5fmepl=ODh7Lt!8Wr2(zc_K@wUG~c_=pK@K_DY6STXZkng0<4+O{z;3h?Z!1MwS848P$bSWpp zrwZ~CYR=Ye#=%v1P&PbP08t!ILQBbP+W`cLyT66=BY11CYU`p0zf|}d0~5R(#TvB0 ztEz#q`PiD|b+lT_MvGKSf|o7(+H^;b&1s3tGL5XWxwlHpERZ7+Vd?DtNw4Ws0e)cR zmg2M|Qv2FB=H&e(>p)RaIjKD8>d!!TEvGC@5%H5Y?Ra0o;Bs$MCnP=1zm~bU^bX(p znQTuQK{4_iON3;+yW{U$G&>-iNxHoN;ZsWC^aL`(gO?*CTZ7j7EAW%+ zC+z*G=-Bn;hA?hTFpo<0>8n<%3f>66M=kBz+WUHp~oL$VB?n#9++HX>{lxa)D&$PQKK>Azkk^ z3qb*w!RPjba1-+_&!1q13L72d_g>+DkD4rVWce{GdnYB3u}+QQi+0C8#cy3WO>E-z z^yK5Tk$e2q7KoRF8di1Zktx#{#8gD%GK9x>cLI|5QKKmfCJ7q0;|}!!8wMX>I=DO{ zzjEy^M0Da2plP>3tfV_+IYd5D*R5_jqG*P!zCF^^54=HtYWcRtPhIeGw_^Z^wiZwTh)L<~%Yc8- z|LO4_z8&Pk1EsDhI@R@o$*n9Pm|P-;Wi3tAfgycpdG-b4C!XJ4yF#kiq)&VIGC-d9 zCii4p*&U%B2fRRBD^Sa(-(GujkK)cJHTv|#%b1Ss{gcwyt@{WOT?M23=@6Ot13zD} zP3Mn`cD3z;S_;LsBxu@27ve{URrFrOnih&Ra#rH2k!#rYDrJTu+LdkzItdSH0_WZ5 zGmL7mvmP~SwLlt(T{j2log&5@wQg)5IdTyHvz45Qv>tnNYMk0lGE%lb%SJ|$&mo3` zcibI&KeK4$GyhystD?a94m+vfpQJ0l`ozNj=@Z*~dY^b6V zQFd}pvQZ7wUXN>>l(pnu-O}EC-YvXMnY5B=Wn~q5tam5V*-={enKz&-1QeJehQNc@ zO9*04wE~@BFz&CsbEcK495HI(r(K32*vNw&d)TXap~z~l&VN!)y55idb62odv9lgz z;HykMC1;GW*05Zx(P<15xgNpndeQP+WKC?f^(J5ngTGNMQ!*5)?mzFPEzbaeLa7jNZnrG_J za?bP#!w_q5Ob&F5m4RO!GdFrncxxiL-8fs<+xpYp<@BCy>?gUjz}C!6Kxs8s)RO&- z&miz$IFM3DTaya&E0=fco!5S}a!Om}sgaSH7_n8U_S@53sjN?DQc+LtSF>W-w8~RrP~T!$g}S<+xc9ws#9loIB{Tf+ zmQkzLH$=vFWT2PHYY7E=e6f@BZOE6I`a1DWh9j4h&vC3lcD3$phe!gPX5ID9lm;ho z;;VQ?2B`*b+q%}LMrPl5ro98w4-#HhH`R0$I4-kq7TC^P**Lm`>shUw>@#o8-EOq^ zFib5Sk~yQn9J1vAIIiAGvNNe$jrT zh~6`!Z(p>g=!tYgo z1R`Qmrw>T>{lltJL(){jfCh+oxm2js%%28vAE{eKjvqBmn-RSJ1#I)vhkj1kHHK;i z&(2J*uP@iz$IEELhsYqceynO&YbWnpTG2ru5hkJm(jP1I7Mw2d@CwmzV&9C6$7q^lRqwFXYs+phFrN?$@zb@pZF%hY4W%1%UteJ@j%L(zYDofpim_W z!wv#FZ)43&XcZtLOlfI2t7{B_aq@Iqh*YhwW6FJfvGxVvw0zX)-mB*P8=L&Q|7O>8 zwMHQHRk+AkcEqNv{=zB`tyCl9=GKedu5YfOY#qYQQv=WU$^FrdYK!rxu4=ngAvj$6 zxkrTqs4eiI^rIpQ`+`*WKYRR9gYvBvfK2BsGF&WxjX{0n8INzr!kkLFYa~Tj7hKcz zzHN%0t}%dKcX}=!z)R38omqhWF2BW6bD*knQt=xU`z@s#5q{c+0d{!=2)K?s4Pi+T z5$mw5TD4VyIpy7Zyb}psFqWoA3O}%zQ*E5w)Z;6mrzh65OPU&Q^C+q4!uZ8$^H>sK zz8rpY!c=u#Wj6#1ffp?;Fs}q+MuS&b()9-baoXk5RWJMRpWKzp%$51~M>E$y*uVZz zq+(1@+b`IN{$CaeHJAfaqnXD3HD({byuWShEx9e(<=-zO;FT1vk^gl4=I0x+$?34V z)|iu~ILV)SNBVKQKHtc|zmBuFf3hIdvheR)3#b^A_7dd3ojCk-`o#XHRrF8Y#}^`E z<)flky$l*$kNpneyWsWVt4sPXDPQ`>O@V$iCipyxG!T%nlPn=JX%}WEL(X$<4A_+B z`{hltravvq(W4~RI^wsvFjWBcGUc@U6>uVB5i{iNB|!gHBgrFv^gn(#m%u6?qCG!o zf1Py2v1e2POSZRS1}LdYD@z#1xF4T%zrAoVuy?C#%HLOcccM@tjIO8Jy#e>*$iLSa>4*PrL!R_0t)`$Q57sutDG#(-@IW#K$9x^Ez9Q*sd ze@~@=okQZDn)lI;`GLu$x2a~2tN#m!EYJ7&_eK-=-$G6m?VciPC0;)2zf3XC8D;&? zJ)+fv==!j~8 zwWhY<_6vaH0OdLIvZBtfUpR5W7z{fE()U4j@H>Fe1ei6rm1i?o!2087Wi{ml4zeSv3lSJ4zNkfJ=y^Aojn7h1MtAc?kv>M(8^UMrbboXM`ONE?e>ZQeE++6KTy6^ z*RPE#!=TIvkVGea=gUD3pjh)h_(4077|<9}Ppq_Zfe)lFL{|kGDH>-m*qIIp%U^w~ za~13=zYI|!A{OXD(-r?cgB=&3w@fTZSm*QTpr}jxHd#CU7V?rb8N0HVt7u#PNhC@S zpnN|*My(g#nuAmdljo|oScRDYZ^FpFm4y-H$A{ZCrT>7oGtv5BFnDMX^1ye-JvaZ6 z!D7;>X{agrO`QxBOX^A1;S~o0@*?$VJG$FgZM71=j~D}K4LG23U5S8vF_n`cvZPZa zKhSv{P`6fiA`s^q@-EOx|JnbuKnK;)Ee8^5fMDK7%kW5dMpQ~S=uIHk*A|nA! zI2;lZj7?Y7_d>=GN#t=Rq*Y)qmiVDtJ;-UyZjHSBzh=R?o8_;2*FzE|(i(_ZuLSDc zJ`#|EgvIJ_eFir<0QlodFyKofr4#->74Q$mWWbG9tO1__rJ8_owj|t^`;iMO9afow z*#^oMl0dr5f{FqU2z_4=p9^YyExaQ`$KH6()B})(=&WP@B@Yv_>7lgI9Jv+)l2aOi z_;qecRh4@hFh7U{0uA_1{GgpP+q;=!+~VM~zP2iJHQFu&swL{zfukKB3hzLS$bVXk z+?d<21z1R(?VYlK$!n368c7Y%;$8psB~CL(R76 zhMtnH#u{LVA&0mL*C>*Vf)_%mA}FM$dF)5P5N6~FZVePU(ds>M7R(VS6jAoa>X3xA z{cSN9T^%{9iVRXvjK-2HAqaY&JE9B5=zw-=h1*v8TODLNtHYf*gr#!9E?0}sbx;@S zvg|fN&lDul->Ul;LoN`seHOYAG@HsfkV@bJo+KpFe2F2hyVgPVgvv8W6HNo~&YbF; zo>;wtBNp?m&^isi{20zCxa^044@WIKnbeE`h@wF9Yt^tfHo&zeyITIJg0B}^|6j$p z$~`!x!0&xAv}znlcDSLt+G1K)P~=tXbC*I43&u%HK*s(uKOwsXoi9&a?|!=OiLYIC2xD`yr9i~VU=>b3J3Pc@+>}>XFxB_&ruKJ>A++S-Lk}N{2vQtuR2NmA6dQb|EL>=FRW*y|(l&kg zhJ0j0Z6P9W%%ei>YL|xlZr+dK^F)wL6rnBIVm)q_$k}s z$HTV&x~8K^RE1-`Jql{E7mCb%x~+bz=)PT`iOUrn9b-$Hj%Qxm!(S|Y3arvywZB_R4nH`s_qk-|f5j zyPMqT<>Ylg7B5J&b#zJRk~QAa>NAe3XX4y3X>~+F6gTPJX>?uYN;>m?7pHSwXY`4+ z!l$9%@pTjbBo-92E<%C_L%EWz63o9J_x0r)&TCW%{JY;_m(lyp$qq`@VcA(RpRNwqv{%mSEGwoI)e_M zZyY?X>V5_I7K}Qn_#L;t8uX+W5t^Zq_UjEg4qGPv8V?@7`t%{H#zCia(gx=h>Y#wZ zS$x?8tzTdhd!<+YuXf@;w)aYxCHlTs4u$#ZzDoVS{1ndln{7Hnc%1_(1|+aNXc>5d z1R)>z7P*we{$fRih2`No>%*G-!fV?k2_{5PT6Snmt-;zSvEKwF*hcxTHkCKtH5n;5 zIVtQfzMhZQa}BjD90^sX>AbuTV=*tbLdS#m`1EMkFDqX>K)>lA$Rzcf>ZT_!1eXD` z+yk{h^~h&KyEAY{(eBSgLT*hy55!=X#%?$g0^WgVO$`Fgi|D|E&+LZ_^6n)I2k_>V z+5k0sJ_!CA96DIzBP&2Chr$~~A9kH}l-DS`?g9ZX`$VE>lfY6I)}MKL!5u78B}V}r zG`Vw*!L1r&N#?TCcM1EgdJ&q&t>{WuQwlI>r8l^DH!KSgmPX2@&-0LQ_%IEHiCd-y zXz4BxTR23~$b5?}*NqA$yI#s$0G*%Lfk+RRi;#^SvWG6Ndc1)2v~C@UdRJ{597;{XfUqhW8DbxZA}SxCnR zZIA}>GDsj1S_%XYO%Aln<9+&1)4`tT0~nz!&w=?UHe@XWT(XxZoAPrRz@=o2mn#kY z?_l6@18$0mrZYetyo`i|gVX__l+&dGb0MFu?8^~i1bhx4xdJO>@f3Cc+_^Bo+2!4? zoGZ4in1P&U!zw|-(+b)=(RMHq=yw_+T^kUQT}xfg*pQG7{kaRf1G3$#1?vib;2wM` z6|h?&Rv2#$Bux1M8OHm-ImTd;va>J1p58&$OB#dCKN@3rHGxF6fm;tEb0L8s8~9#v zzy{9sEFAID;lG(8Lc(R85(EhV(MaH68sWEThydja5gX z{d|%sp)23{iX(ht`E3L?dUw)Or^eHrxIq?vp}Y(6I_acy;DU0*Vv(EegDdqt{yHVQ z6^)Ijf4erFdChDO{epMx47kW)oC84PW{3v$y#;86*OYYyI`#rsY@hj`fdJQ|gPLOE z-V41M|;jjvtv;IXTapUISrJ4!UZA>2>&|h z5TY!yl53JWMa|*)LmpY$Hs|ulrpxx{bCM?gX}~dT3Y|-UZGH0N3`K`#P0bRpzPNm| zC*M5cZ7htI&tZ7kZ^df(YUFP%-*j-;p|MibTj43*dL+Z)=8@ln`~P8&w<;FZB6p5a zuP@p2Psj0kEmkCA!oh=m&O-jcZpy^9zFdLJAR1L)JP*@G%e(Ewcq zQ)*}>TKe{$ic#$EQ^`S2KukJrTRatIGBCbxwu2p z?cSP>i9%&7v%&S#s_c(=2=Jo9yZ@FhRD7L#*TmRn-)q&MVD+tnicRoXYeZ7Q-`nuH z>?ON!NEj#nN2blzSP?Gf%G@FF^quMTJPv65@y^STjBaSPJ@}e&@>HY~*VYVa1OPJ_ zP#!w5=&<9zM}y<{`m6ueRlPP1RWaie)yyYujVe(llc8IUMV_Yt$8xvJ-8u1Dh~Ap6 z^QpZ>`38>G9KvBO=c3hO%zpUJI7qUMDBX&UT(60_Jr(a8BONp^SKCz&R!ZQ;{F{`; z>|Z(#F_^{UdwbL=d#!Pj+Yb+J$7djRsK|cQ*fwo7?F;g#*tsnlc)ia}UiC9IpkS7A z)6>>gm`G{xk&iXA=ekQFQ)Xm@{coQe59gIR-+2~dTzR|FxU!`60JI%?ZJYdi8~{Cw z=l|rgCP&Z{1>T5|Tna2_oX+iP_V%%liUwr=W181We=83-`zq(AM?4SEXnVZd3qRZf z<$Tro8z|l_w72UUcxyoc+-#Ps%V$|YWi&hmF9W(r#zPz9Nbp7K?qOy?`N~yN$QuZ2 zmr<^b&8;&2Gym{l@mU`U{1yQh$Bt)!^QPRqleV=}`6~a{y%D6>k@do5-|2!Owb-UY zv;2aCX56!}{ooR7Z_2C@DQ^oe7h&RM01l}0-9Z0qs`HO@J$y%im7wcO(d`eSOU)m+ zA9H|Y(M-q{dW(|Ym%Q8}yIh;C@j#0__Fi&D@6c+re-XJZSA?2DiwDN~Kq6onDW1`CTfZ6k)$<3^{eL`Q*FaIxJLL~83hR$e!0!3iEV8}56Q9(3R ztYHUjgo%-ZeyO!b{tstg0TtC2_lqKO9TZSO5Cl{dkyZ(*p+r==yOi$kAq7+fR7wYF z=@^ji5>OF|nPHF=knR|eq24}#_ug-<`_}i?JIi&~1%|^p`|Pvh_y0v_%CfS2n5%vB zW!8qTKcT|G@7?r$YoGxA=(E19Z8(kPUdbwFOGz43YI5qf)?(>4Nw6I^O~>(Y8rNOq zgK#kauW35wuvIYGF7V;i*cX6qQ@*#rmz{q4IUz$A3+QC0&kVn2e64wG0J!kguYv(6RM`6^zNlIUxWah(uel z1v`78AlnUn=MFwMAnRhWehiH7%fLn+{w_@F8l|pm4?;5&!C!?uDxl!bsLIy#pq2yBXV}{wD!|ze*8sDIUT7ByCQqr`<&Pnp zNL^EN05~{CU3X1iDwZq+MnpfrL}~@RskA=2ET9LIfU3Ix#o$ zj>banI8=l2MZDSj)>H&IBGv$o@J_I%*!&Hur=?H`E=n1PTEKhG6r*>*u6qGI;1oe*` z!!or(qHkX7)5g$d8A7(lJ5T=!2tY}1g7q>$ zwWVyfRwp(VtC#wLE1HOsia#FsgnE6B${@`4Lm2P z4GWNNx;90@koLC|{R&zGcIN+7O3@=sl|UsK?|RDUJJiD93$j=H49xZwd1sIybY3I(h%D3&hw@DJGBq3AE^1RDn}R7heIKY~%H zUb0t;T<{avnoApIcFAq3T|o}l_bSHo=Wq18Mq>R^0>v53K^Dl`T-P2_^K-^#YAON` zce%8SkXTX=c>f4xP`g7|_iYxf2LTU=1Ya|7F?T7hx-L}VU@ESscR8lLs5Y{>K_B($ zn(Rzk+73;k$&3H@!Zq@)mTa&~%C9xoDfMgB9QtE;*Kz=fB#<{b9XKi^%L9j$NE}?1 zC#_3XUYh#C&9|o{X*2veYeg(9bb}480+AX01~4pCVDt7by9x`Xs)@07%5^?5W341! z3LJDPN=o#$*=eH{5`?yLBQnL6j|DMeT=VAftc6|x-?p-*PG?#s#+uZC?LZBGYD6aT zIdB)T{8;8Fl=vNmw2r!=s1O@F-H-Ro(r5&GlV7;3es97zh~Fo#-E97*&TVKMxM_4* z_XSgGOsTxR0h=x{{Oh8tjlLgnj6oQnbx>u>s7es{L^(&ek~i1?M$$f6ddP+EZ; z0{~^Qz!Xpws*3uYrmNx#wy`S6)d>ywo~aX*iVTKrUE__yPM6Rv2q5yURC2dd=@1e^ zU2nRgW%zcE2V3;tm(WsO90JxJwPk|`^~mGsbpyyxnn`3FMEP)TWGP1Mt zhl`PG{P&i0J1mGve2on{^1;?v&)zQ2tZ`KI!-#h>OMgyy_AxogehQee@a;ezM=XB5 zVD)W^fnXc)_*8#(i~(7XakN2uu%PA4Pqy0pz_fKCJUU~Ul_5I+nN@xEOuiKbTc2tn zmXgE(L=^iE0k`1N(w=)AlBET}8-X8~Tpu9ul~#?}NdowROqLW_^S#}{>{IzxKhtMw z(}U`j*g0!K?N_=(3E$sWOE!n0QpbTRrZ?u)#=1eG$Ji-*{#>)x9ElsSv*=^yI;u;K zQn++Fe=r5+Lq-q7-!_05WA5(h_FAT3H)sQmgJDXSz!dp~Zfz1fyQWx*?`Z(}4p0YA-SOAFmaud(P1DvI5iu*ceP9>JtF@1a7!U zr#_%i+JuQShz-1RW;%K9uM~qqhfzsyu&WjAJCE1T6la6H&DL{ftdS?rDCgIS?`~!< zRb&j;i}P&&DnPxbp?i)ct5@U%l3{N5CPwv4Wr;&%vGIrK>O`keB%H!_pzB#J_(gF% zSr#%W;J9{h5KPr|DQI#SEXezhA9#$G+|04v06s6xGLJ>qzTH6(oy3~S3M|>=nW?+! zyTMP~1zTuTXy}di4uJyKv6UD-z(*=u3!wVg;S%IRP^J0QkZMIor)5Bmlx^>C4j+~Ix;jq}5HI@3{Sw%$&?Yoev++vXN zG^5{~?#QaO<|)3Zulh2C;Sg)}hHQU@HmoOpjnYaq)>}*{v<%Ic<@UtO#n3i)ZWnTz z0-jHsounYpB1X4rr+wPpc&Ggi%`RZ~yv%q}s=k@7)K#8Z zGWAAbB)Jg=&nWZyNs8S90yb|uNAh!25eSfu`D0J(2;GLpsPo#Br@0)ls z(tz%nguGL{uBx^Abj^g-hf-b=bt@$O$!AK_RNzJ0%!r@ozc-q8|Ec=jx5EZ)NG^@;bgW%8c_gu*Dv3z+S z0p>!|ZD=mk`@heHs;a0gzZZPU%FD4_bhqS$r=P0hA?xWvA=a8LfDY>Lbwyz>PG`II zftk(@;+u$4kp(0^<&on^QotBT_N{JbwH@nZfW=^H zb-K;d9udRt6*IE{YF}B(7E~ec`ijvU2B^vm0!Y$`@=hOgyku$wSkDaa4}!JMYYy;z z3;4nJ!RM2kOeAZ&G7ZW;5kL8am=3PPKRPO{iqCzy<^-FcrFeQauo~EOEXOfCU}lt) zLVa(p>+v)7X0tO9ecH(q$CPd zcBpS9({=le1s{AA_+(`%WR>4e>k2{iY@0=@+QARquK_3ULvfzdmPX}P@}SEM)^OIQ z`0CcGYq{z)(ENnkgp7<5gHpp6L|LMTUW$3i*1Y!;>26WiCpCnr)%g%-RoN)wi6u<_^%`AW~WyP_nokS~?Q#L~d{;7ptaLkcJ%1wyIa z&EmZKyLf6_Xot8xvi+Kgw4vYfZ%SvP!p zXbBiaoc__Yx>Ylgnu@EsJ_S0Q`d&`aO&^~ZCRKyCsk`e|to2B_Yq@G3X$`L{DYmC! z`GV;v#(m-~;KKq_1EDz4`GQR|;P`8q>s?Y!tx~lfp9Up)&n{?YQl30|^6bn^xVkXe zbtZ}hNlHyNcBZCmGef;<#Wuw_l~ReLbIRj>{i(wf^`HEw=TRP1DZz)RS(pelVcS?T z#H^{TF-fyAF9+Pt^4W03r?sO~AQhRlu0@@T^w+KbHD{x4P^3UfiwOyo&ZoHkcSXTx z%Z2C$a4VVlIM!`7N9w-fI6e_ta*XpUXD1iC0Mm<4X$!GpM^*o>-Veq3$$E7$2EAP_N(FIM zP6H3M5>MgBj&aS#NtT#RU?LLf_4Tv=xr ziudhwqi(-9`m^m*1JdbIYW;0(FZQ%kragr0^yuLHM}M#CUxw5Wo4~2Ne(M8oirEp( zhDhds;s8GU@5?a@5B3`V)k`|nb-ion=*_ao>7~H?T+YtCKdfmb=OdQE||pv!c>tdd6&%X1d}da#yCA zdG4sYXXWB^CvSdQbBMfrbRk+7InT|;SS`=+MSIj;8E!Nz3E$$!3vWY67dVs*=5T+W zP2r<|$Vq6)4*NOs_sVSHyt(-d9#a1hRaBM#F*s+OrG{aZwar?lORrS=00Y|%WlM8G zrrNS-($=+!N8hc-T8JN;JSyR$Vc?!>W7a)1C?i6?;5~Er=d*Y~)lOB+%fK3y{LbSd z_-20|gnhu+WjVD0H#hoQzNhu<9otz?*ZSX;fM6tYz#Pa6qT;|W4h;b09LZq`72GYL z9p>$Fx4FKeG$;rL1fpC7<)8(S#!v>(`yU$v_DLN2&Mu+=S9~z3qXFs+d73B#Ao_+B z4Y#osLv8~d0E>+Jc8tiM7a9WmU0+IG555lZP5=Y47Z_c2n#eb+J+V>`r{nwDoLF`b zm$v;n!!+KuO#3$OHW&bHKYn4^0F+D*Pq2M>!Wf@lpwz~KNEPLjez>Z6YiSj zKvFqi1my#^AGvgeE{lVe9enBY00gO>EQHLs0Fjpeo%KbieuqTTES&!iLU^5j0sEeZVtMsakJS7FQ3f;t71y@R_%4Iindm z(Lxyo`fX_QD^|C2&Co(|K487hNNWe^4RA5wv<2sC(Lgp z%1}Bije6xiY;Md&acqK(@tfyD)jBsHL!L`RVZpHV#{@Gzh8L)?Sim=l1E5RfmWY38 z)ZXnhQVpU8WV(@5Ks)ezH34Lm{PS>-Zs2hMwSXe^xbgGAvm_T_#wC#%(xY#WKL?>i zUgGIICM#ntzCQV?h6q>$WI;+HL-h%&3le9ZI?ob)=2uC*Bp->cP+7qpn@k?rfYjc( zD(X)+KYi)-v7V-d8M*y1rKx8Omek3xrN-QRrSdtuP~q5z*qd7D8dj~IZ519~z#aBO z38Bic-aU6v!?}y$$UZhD0${2kM#KYRjx*JgGH3>bH{G?}2Z0@1co6u=2-&YPTCfrI zl(HI~#-(;j4a|xH`|N@81b|CJ)xxFFN$T&xm+!E;FS&9lC#R2Z0%BQMf#?ng>oqbl zGMHZT6ccL}db?*A?K=W>a`5SPA-ubi$mmD0%X9H!Wn14ZImHzvfD zAI0XK{ghlvL>GM-UBp~a_(Xi7%TlS{_MG`WT=HhBHAbkme7O#<9h@{n`FQqyj&$xW zb=Pq~dIc!qWx{S(GjW;Txo^Se0;}d-${tj1E$i4-nq6BnA-z%~NswhUyuXG_WoewPd>EPCKoeC z4c-DR*HuMzjrD^{_+=6k*e2%Qz0t(uQ6MiOw`^J)ST+*`6&fR6AgMzZ)MOtHJT-52 zsX(jc1ZQ*`^Jz7_xZkg?^ybX&HN*eg#}w9<+4fPvUV)}#i1iSp+Umvb%!hlew8 zvsdRIPqOK78z2cDc^8 zgKRn4Z+rV{d#cupnte7HEw%ZRd+|B-(z|)H#XX;!R2F@J9hlBDFT<+V<$XXfu?rtv zPGk4jWcZ4y+$`dgD@SGiGNHO^I#Zi{Gu_G~{bq@Emd0Dwz8**QaW~iCl+9`-y9B@M(VMq8XCm zm?ITD%bu6yhHbw?IUpisZLX%D+lD(QC1Zq|PDuinaqJwG$x}Mr ze)wsg%XD25jdDVIvP@FD+cUJVd6(0*0>MvUwoTsWYu)i98FSbTFF%k?M5!1={mjbW zk}}w#o@x>h6Y1*|0>UEV8Rf2c=cgvKAmnbA%k36`oN0RqtlMlEdXnu&q~4 zix8^)C5;%QmgJ?|dOE|NHVT&{MW9~YTKCSRR5+GfhO6z_#Z;=f_$Dy#4)?f-m7pT% z7eg1{4Mz^=7AW)GZm#T6yQ3&JP^E!@&k`$7Va!vlPjBAUOKY;{L~p>brM13p?Yr-@ zCX>o2l4=VcmNMZzO{}jT2hPN2qKigDM0Ymx>#|Ov!sSs}4+y1$PG1zJV!8G|(#l)$ z6Vi2NIqr%WQNtY3fp=fEZ~MG&YhjqRsz)Lx;Q6M;o~(R)Sh$m&NIs&O@S9eL-*c7f z0REafR_MmI`4dK@b%)9=EA|cJNovX&PYzkW2r7{e@r+j9B%(8>La}Ao%OqZ{va0?m zuO#Qz2X7dAlTaCJBO_2e(o;&d4KZgUQhu@w=&3D9OyPuL#BtfKVEEXcsd6#Y5E!}< ztXAdD31G0CTm3h-i(g$>EI*#tYW01FTVQrmp{=J(8#YgM!VEX_yXqvTqMuo6r`+!K z**-7B3Ex=yR745zrl0ny(hLW+h~jQHg5dXI*;<)khf#y4oB7sgZ`Mucq>O!g7w)Dq zq}}u|F_F#)v=SE#Xfzm?Zq)axTd{9;qLUZURL_X!I@%wJ zqXFypaDzltV*5v=1a^kNvOy=;cj;>k$iK0%l;hI0($!;g956omY{$QLz#dteIRTm_ z6=wSH#+ZNc8{V}WN?qfFM<3x}{;U7mOlO38l_Ey5sFpwEN&J68VwO%>ach(1R(ABr z(U8AkiDbTiZ3yMb@h@5)t69ClN#XKWVL?clrpuR?mQ1^+g0fq~ei!<+rLn&Lmf zhj&Vf%#N67&NuDq>G#)IZK29OIFkSS-u@Gy_-8u|2t*)rt9;@SExxQyk1NS0>QaGL z;bWE5{d@ZtI$+3%SZdzKb~ONhgAVbve>KTJkMDo^8~@zeTab#P$7SB~xH}V{qr!^8 zaD|mUE6ZM00Wn%S<=nh9jhn6e8mcQ`_9};iB`UiRVH!7r)qF9@g z^-JaYMStd_$Wq|Yi{Tu%JU(h%?1LlF&>lY`=uC##1E1!^R%H9#F`QR(trK8tOtBtoZ!$5hTit5u% z|MnI#%>X9FMuYXp$-_SiNzI_>qb1mvKX{cbmTpd!JJZi;&oJo-Wa{GWAjkLYj+g?0 zm!+|1A4_$9(;wuJlkB2KB}cB_UQBEP1)WY}%uq!4AFC!<6iOJ$iTH!U+UmpOaz82o z0q#j3SJ_6V*m56rVF)pdpRg{EBq{H)(U)|3xrvZnv#{S9)85X_fU%kzWVo*0_gy8xwgK#zC15I zF25xFUE~1Kfzw(LI~{`~p5Otic@6wKEON(yaXCD3rPxxaK8fc3_i8XaL^wgjH-9q)^5n zXFasS^wfWRR8dpyw4N{ZSr9+G1|!@1|NO>(zE%G7iL8PV0(cv zw@;4UJ}&(%B@BN`0fsCX)oBg7GdP(~^7pJ~^U1C=HkR!?t1xyMP6TTN^b2b!l zcy&ZbD1!pF*fiEE^CK%gxq#AkPC|d}xX|jK{_Y8Ep`>@m`4-0dUDD5S%9dF^gq@A2 zKYsL#=c?q^1~fjfd8wg~-fg69NAS_ON~H0K1w+Mjj&IhM*R&So5ohsS5V`ZJ{r}+#nbkg7z zOCleoBqPS3L_caQZ=Z1Sv8AV3nF%bTxNdMX5~u}wX=}dQDtRAjf>dd}5NBa|J0Is! zRl*USGp&_A$Z6v6@bS^`Y2?b*tMNuw(@phMijiC8BfYcSVSnboe-9}CF(GxaNhW!n ziBS>rntAiGP`S;D96v_BmlyCyl@h~Dvs=yPh`e8x&t~W{Z}wSF`X=Y;D|)m-K@ z8s+!)AC~2yd>1uJGms-J0!RHOi}h;>Oxe=+dMTKNe{%B{bH+@IebJCFMoQIAcX)=2 z)LJF}sW?!7C$8X&cg|1wv*IAq&|n8n$uajz*)`r~&xRabM2q$rKD@{sc2)t|k9W9N zd@7y6+MrXIu!b&~r#~A0bmBJy%6kOkB&$4L^;bzF)}`BQ?h7E^DRIASP@X`abMK{L z7@Yc=JH+CAE9FxHX)E9wxED>Od3|CIe(HQQ(%5P*Sd<3Sv0sSw?#?Pt-;r(9|J*V= zX$tFG-6A)5bWyKHk;{;pvoqGs;H#6ICf-(`IHur<3_SYhm-%B#f~W123)rEJwrH|6~Gdvtk>6|t4j7j&v^FJS#2ewIjn z^m#J27t=n0GXnFN^-4>yj`!h@vMy&MU10-{(toDtzDLYRqa0UAdTY!o&f#Wr2byC4 zJW=uBzdLMy-g_MEn@(DX{kX&(ms2@je_HA<5?lDP&Cof4C|lq21Fh2*c;rYcv-zj% z*EWhwZ>51YKwQDH`v=_=pmADt%xf%PeY6v-KX?WjG zxRy#zBr3fg6>6IP#6n;1&*EH3BHG^bdS%u1@3mjcA9c(yjR0enh4Z(|T{bdoC4)sK zEW4wR^C3a;`rwGU%7lw%$A0*;G%0Iw-ME_aPX&+D5)vmvN3x2?nr`5qb&8Y6+CaZA zp3c%Y_n6fje>Z1%Z#ON|N^|*h((wmO%rBqjDmxwaH~+FcT2hl0IopDmb-SrKULD&f z)YFoQ8vW%y55r&RR@WLto#qshvHi0gC8E<*GolHsePBy+mts%{HAeTRMA~(laek)$ zN*9^ai+(z?jS@w@c$C4zG7Gl9OVpJw-idRc6?m<`%eV2TLHuFp58ypICTAcO>f?2$ zz4>;Bx$LVUmcQeMQ{V2K?~JyZySe>BcDEv*pSaypr^DVlPbq6dvp7(3?sJF4pPp@G zdcDy=FNL!J(@VEHGwL}0eMcu&h=6ve!;_V+AGVW{Al2Z6v${deeFN6l6|Pny)6!W* zao>kD)ab}gjF0Dp7-p@+W&W*hx76^?qREWSA$iN0ugP%s@Elt4|Gnb`Wyjfh?$a~$ zLZ{TC^!|=gVh{SKCw^&b9?hWwg192d6d`q9rWmA^Vg2nUhwAim6btkWci$<5j2(?8 zY{9GfkHM7W(VK3=Vd6OP=yUrl7kB7C=Xx0IC0+c!s!Dd#FYepaS~nY^;i#YV2M)e5 zEe;Eo%(k@6x~gTx70`#|Dtg7>A zXw+Y8IYJZIp3=2u#DD4KkL$?SW2n+AG0Zku6U6SJfw#bz`AysRH5n&c{<3vH8ZGl+ zBz~^M)k-A=f=;q=e(%(x!`wM8SkIOaZ>T&lK!n9IvZh%?-SD#ztC+s=rF$U6iP*F& zMWk@`Z&RZ}mgOlr=rZ$=>P)Q6!~E59V-t?>%o(L7AEj#4e!0e_E^OMaittg}S}>MI zgj-ME93+!&PsN$UuTqU>^BZOZTb+*q23~LzFH@=d`xed%=Qm_;gv}gzF(j*eSmU}G z=Le(`C3!QITJU}Y6j<(dUr7tc{4f}b@(ip9tU03ih&R7@xS_$3(_W=i!r>)fc9Z-3 zsPruN^J(!beX4k|!xkmVWqay{mo0lU^x_xwPim`JZTXI#X;;q@RyB&3=qAv)*xJ)( zCK)_}ogBSK9gZj#$&XoaJz1siRID`OF%7CKKKVjk>K>YKGWL;?#78cAVhTf)udEx_<3ee+?z>``Y(y4to;&ab33HKyp1K z`I@m1m+Mq%T?R|lTN48`;G7mQ>8+m^)6~7?+Ft?%970`+?T6P~zB$kj1VBCnp>I#J zJ>UDL2dXSk2@_C*qd9Ep_Vl3U_E%r?tKSX>!c+0~sWk&9W#zKueotdP9w`(&@Rt=-}_ z&Se{fPeM_fP?#4;Fl313(05Ol5BU2_S6@1f@RWfqWx) zEnX=ydCwR10(n}J-6N)4 zK3BwowP7*H2p`Ne??9S@dkR%#f+9O1@o-6mBQRIl?yPP~PKyxw3c1|v6LW*X09dA( zKsh>e=CF(dyAvqJ(GdF=q@+gZ?$)n@)V97t<7S&$eDB&8sIT`!8KwiimBK!(g=+2wm_9|pg@xS23bRH z&l&E|;=F*21#;Q)G3*cnB=pivUPPx0)B4tIZCrIdBvgXb(bHcEm%zyg_m{$uvP%bA z=qJ+zm-+e5&pTUd3&3S+v;P_3Cd==?G?`fgq>N1;o$WaGrX*|VRVlRM+N$>}=PVn< z>?dA&vA361@0LE*e0yJ5yF?CUjQH)X17|WA4=MREqa@PMF5R}qgTk0<_3V^UO&*)O z8f*burTWOq`(i&cTM_XMKZ9 zN(p6Loy>y0znAiMqq|OL1Ej$bR(VORAOXK&d|)#O%5009^8n0%jd9C_;|1%kInP&t zImu#Mucs>o)gzE!J^>gyrG_IQU2dTrSee;CnuS_G{;e~(Vl1^OJcx#fG{d6QBwkhg z3Cs-B&%bATM`irvc%KR6F2gRGOWROj-DVzt7eIqfZx*kXfH##4* zb(?dK)>&w8)XBZS+l|s}(|F(s38S6PY@GGIU!u^6^1V-0Xtf9YRI{9y+D{oGdDWmN zLBx4wXZI0m=Bm7Q?_es--MFftMx)iQ)ApUj?RALZi}cjh)H z87|YTJx7^!=83{vo|KKZkWUL~u$N(~WkTAeAvE;mcTonAC5S=`(r>?~)4kCzHV9;N zdwq$B4efgVZto!|&0Gl4`E|(fAxd$C=F2b*!mZz6Bu4~jNf;*|-gRRoTGGHmKQ~5KVh<; ztR!Cn87Dvn6R4HA^abo7xSq~ZNcsm~zMs{~4s^c2U#1k7pRBaoo&Y?3+@S38+{Khd z-MbmzvsrS5%&N>%0&7a<`D80XTsoR4_hu4f2X5pJ@*8WrC0I2Dd*^s(P%3O*^X~l0 zaA!Bv{>i4a8Z*xzO*nymI5Q$19^rcd`$3?q^FgI^X7Fxt{b^pN16U4ZTkkCB`~4W_ z_R$i^*3M{R#=B;irOr0XOMHXUP;Jhx)m;v25lV@Bs>DSflFgx4(Y5)I^fo(8>uY{{ z;++Tfxij-_Rwr|OhcS6th;LAS?~wV&PD4DogkOO4@N4bE0SxPwX?KV z8uSs_86R{uL4Z2}o+{2u?=pMo$L6K)G2~+Csn~hAWsY$ic}rl(MmIhLkyQa~;Lzz(sr5K`ctkdfQF~e{ z-NRYpJT;$sz3Qy2Q>phYPD`&@IcZYcQj+?tz*lbhKf);el}cw1OUFE21zfCh!1K&^ zt87Z@`gMDq^ajkjr;VNFKHmi;Q`1Nqi-r&e_rY$|LmUk<0LU=2hX+-6k6Fc+0F|BR zU#@_*Hvn?J9|MwEF{zV;lth#jrZSaA z{e{uh^>6k#=&HGMN(EhK)#`CVb&KwmU79Z6LKYFKAnG38F?7IaR#Ny~t>d9Tc|DojU$HCAqNO2#jYZ=ufjrQTIugoMrjzX?Vej9_bSosX7Hw`__1*0U9lO z2Y89RuiezI_x4tt!q!%0nwuq@BGPpf7A`R8ze=&X?Zh<~(Fqj8QyQIuyOnBg>UWpA zt}Kzgce?H8(4Zw|=erh<)>l&vsBSQNDdh8;r^EP|dq|_lQ}@&bQV%_9reg8x!p;=V zRmZR}uBvAn^;8}^RJLuQ7oC_2J_k7QIE*gd*WU*zq%urI53#3$sNrnK9Ap^#k;M2k zLqbBZ)a$)*asZ;qs_<_?yZ8czqV&02 zOr*0F1h0J0-B!o&MQVR1w6dormJk1&wA6-iJe zcdo{0oEcVqn|t0fT_4eU3nSK=O!{HJ(znrqX5@Nbda+i${;&1G&0PuZcH|BHaU0qY zpGB$+j}G(eGGg!?}kr(qPewo+pgZk zrLyYROd%p7NU|G|Gfm0n?!mMnMF#JIE84L6->0U%{yE%!+nypf+{-5}(RW>g?lXC$ z%YwS__t6#~j}y2yywB+%u@5Op6s^V03bCTT*5CeOjQezFA*9IC$e=y+!C@PN|GqS3 zZWZ~ln1l#>>17*~cwaz6fYz6GU87mZl2-p`t!3LK=a&>0TPOWJ!#CG2{<$?;nF|BI zmr3e;s;j+&ojowIXWS}?BN;|slS(BHn$iF{xh>CMWup|G98jix_KS<^(svdB^)tL>m(1fF#$`6^bCHZPxPRNxD_eUHR)A z)04mN{?bjGwKgt{3^18-g*BQr+NB=wU|%WT`nv%lu9b0Bk2Q(zdgKv_iAK!z4Gl3fj|?;FeXg#i zXY34SidZWas}Ln99oumgoVvF7-uZieqBFrcdp^g(U`%q0%KfZup1@l_57LV-uWHs4 zu(@C@MWAZdqA>d0Jml%|i{CwTc%!U_(KvS$>f}BV{`uB_w_~&5I3IVeont$FwKZqm za8o#q(-3u=a!n+$s~)1ZRd?YUD&qVn-Kpo{Uh5GxwK@IQ$LZ;}mdQnE@oqEf9=dRD z`lY@|_f<|ajQ8%`u%xMGaHroW*)UDT_R!)aqO~e;E7bD_`5HGleXCGV3j-;oqtH$Z zH{#0_tgp)KPmA7qZ!WU-5X))j`J%cPhSTl)C}Bta(hJLfQ6NycvxWIPNdm6Y_676! zCB$FEo<9mnEArgjG82o0K8u`ozWdGZZnKv8dJ{z^1ugjQpKjgy7`(u7UTKW<)8v=# zY8kpO1Su~q8`ysHUlXJp{$WfUN-N|xqaeypxWYwyukS8t+t%YW1D>DQ2`i})1=sI_ zBZl5-!(h{)&j5m{*B?v?IfH~nbs9sTTL`tstiYPCBNxi<4b@1R95l^r4k`LRO&kFK z>$1=E&Kjb?|04eI8Nb{$8!EUNZfrAKu{vJ<7}Kn(+@EzL1Wt?JaeWs_{64~(SL7`F zUPe@nP(c%O>H03&BN)D0-@TgjN2N%`oWf$=xMV|2U}Ml}CQa;2BT-Wbe9xkla%#WLHAcXwlV8!GeiN4=9kzSnLN+YHk!akpD*l;5iQz0W;Rl9{QAu6r@e5Q zq>~Um$=Uk5o4`mi7tFiii_t;ev#LotLsRh0mF%^mQzu1_d5Ne{28Gg!mW=T9J_naj zc{CbbdOirmgg4A-+ldqt-`wojKY!;P1>aan?zs1}q|5VOsSflwACsGdAtYyeC65Jv z>YFMvk#~_#4*SF!-efCf;PCl;b&KclTB4@tDrQ2*qOC#j~4!`PrSS1< z!$*@*#5D(V^rfjLTvc!BXHK%dy9R`wI1OCoch?-?h}Hiqsi)8#9Y0(mH2(5Vjxca% zz*6`w2hG~=P1@%~`mU#8fhnWp#tZPIDzUN#t8ZpO3&wL7?Oy^g3-(sqrEw{A;Y*>Z z$NuV)r#;4YA|xWa#&*3luQWK`2@qa~?nMn((aPr|M^D@Jiqv!Syw*YyaRU)Pkfx@g zO#=(Itjn_x6}?nkN4AH{3GMYevP{CF?iI}pIlx7H1zu9Mvthqqw&_YNMYq0@B_sM` zj}X|F@3*w++nGsDdQx}=$63ZB$3TXZh3 zIH$Z0pN|zDUWu4}!n94h&~?3mm9+WtdW7)Jl{jjVR)&$HxI1r*$bqLsj2eH8-S@%w z1)Xn6|IEfRboxtI;$N74@t2r2<@VNYPQG!O+)|ZxV{a>IztXE+cUThl<5s87gf zo#O(>^&MNNu~TCEo*z#&3_E|lnfV*T!lN;^C{h!NUH8;a+;dTB|@^k)l37 zPg|ozTmKburfBS9vU9R1qopd-#m%3A0g+;M$HXp;HIqE~{kXvG*^|&}0+QUZfN_cY z$64QT0pI3r+UOPrPj0X7u<45+%9Vi4C)gq8biGG*YG&0@sR`u0CL&hs4VUr2SFoA@ z-aGlu+)NK(IHHHx8D~Pl@!5*%(EE~e1HSSc>pi^22y1?e?+S%urt)$~#eO$ZQ9y%H zq+J?GdCdf8F64gmi`3o=9X~6i+$JP~qz@VhB%yUO#?Zc?U3akN{MyLl4s21nX-6YO zIbPmIsglQT1JAe|0x$s{jK{sN+;uye7#*(#%;^sj9j7N+>TPYntNC0-7x zHC^3Z@=IDA3)=pwSvU41j%=QVpl;QL8vGQb8MeC|Dt1lG242A;1~9Ihmx!f?XzG8r z%=+z3`T5AvVX_yH#CF4Fs*D&OK8*m?h$*mOQbTWVIQRwA;&ZT^H?Q!cwc zao=yvCg@F`1$Me?S2PF^vdWZW(PZpE@(VI^S(I7}dbpm>jmQSQoxtsNjrKl&Mf~6| z7A$BKQ75l+mW6+fa#fRx{5*!Sr%fAP4iQkI6^86qZEimZ=tL|!V~Lr5i$8+w+#Z#G zJw6zIQoG51x7Ysl6^gCI{hcZF_MV+bGd%%=9|wkK-gq#sVL}Ow`feVi0rNx^Ll;6s z?@q(Yryk#UQ=L>u?LIVT5XZ4sIF@$F1%>W-H$XwtscBPs=V%gS4yHg%OAXWf{z)n} zjhJf&e!8mkn4es=>ncv?Le5+>mw5}Lz2}Ig8XOqj;Ag7VbGVVnk7;ZOwYK+P09f4@ z3a+}}WAj5V3hVWN>dB>J#8v*OP^suB)RS$W0A4sP{?n_ts(5loCCak?fV&F9Jd2Od z69)#IRU!U>)(jrmN*KxhF0%G5q`e&~x6sRcM z(%&nzm0j`)koJo?(aN;z!MP())gA8hto}lf@Zd%~H-MPepXIrE46cdouZbZde2p;Z zrHPkl~hU@K5h==yaM^l9##^9pwxLnnz_E2;-4xUl+5UYh->wL&k2 z$Ey4#sJKf$rM2={L|o^mw9Z`eY7z-5xI*S9*l_o!r}tn%Zu{Fi0_Es6wfBFI=s$@= z=xA4l@dfU$(I-VIr#MLzhZ-j@ljEKiDo)@1rF~L0X(}vWRj||Z`?ZkIHyffNcs@Qi zrWgQAu(+KHH=G;!ph-}?OVu=5JLfxQn?&>7LNxYc+pBklE*tK2(jWAEMzwDX;?v5A zOmCfCsS~Qvq&R=OfTBEek;a3R{P!AJTHlvuQuU9gt7(EUHGK=<{iRLnkaoJjdZizwJ2-X$?zZ2U z;Q4!&D{-+|HKd~QRD*wLyw!<8i=b+J)#o2dC;FtugdEEam{e}e(p;26wY`dzd*VIt zu)Ng0!B$_ts9j`X_R%d4!^JFX`k4*NPzrNQ@~Firzv<(4?(U(31`CpVYWt6bxPGoY zsJV3Zl-xH#a5mxb}=VLdSTLh=1Uror*{pVzFdc!i8dI@ zKW=7hS}|08yiCh6Y#nMoJw@aU3mJvRU>EbtWniVVFG0pX8T;*t^W@~8k<|DbX=3a) z)TcfeQ#d||(zM7YE4cIIla|Rfx zi@?V1z7$Y|SttO*Ho~Ha{tbWmhqIRg_2Qn(%SBb0$xhJAWzfn6*b%qi;CRo|^iaX3 zbj{txwsN7&ns=-V?npQCyUUmLw0pEsn1@d{vG1PMVp*=Dn7~k*;ZBR88qlM9O+AU@ znZ81?x_HBbh7oSGQj#ee{?fahs5Kuf{QNRe{Y^ig3ilB>dUFH$=g-`lKIY?aB62v< z?f*3O9sX>%(YtNcDuPlgwzg=MT9MeQ=(1aCQ?!&I2%W7Q_a zsu{$n5qsQx@4dhKx&OlZyyrd7dCob{OK{FHtv)R5)Z}i^gm8$v{14T(dq(wbe0Zy3 zSa0ZO`W3E?d8Pfa(aZ=utl1;Ha^aKp;m?~hUTFNcxdvo)qEw){=Y%DE^u->Rq?`3| zmww>9{at@J_xvUd{ceC((r&l8j;0yv{%&%O;`gF*B>&2T?VnpfeX7mO+y%v#y%4)g z5qxg}6Gy&KRo>-NZnwo%mW+#W#CcBxy3rSt^`lrvm%{X<2q++Md@Jn&pOQf|Ud#=- zUk+moeH*bBPaTNp^Yi807VCi}`1;a1*YOg|JZl#xe)#xcJp-$!*G_kXj2h0#?!|rQ z%T)z^S;||8{z3}*NsYHrJ%8`Ja(ODNDpF&sUNfRz%HzqhSC_hlul+wi2|{kuNExH; zpAO7-PAl*D;ngEmA6|m+4DGRXf*Q3O!m0yPj#Phh*b-yMOP_@OpMq4HHrmp79_dlN zJofu`yiED;m!W8bXP2?tHaiI^aTnB5ej3okmo)l;JBi=bj4Sw*=jindW-iVT5`}jD zV|3hm9-Kf%6Sb3x{vVW3F(}_~q1j_N6g$82>Pqi@JWVG8g#ND+!lD-KD{?3(~(QC3#WE@@d1lY1_TYi8)W#fx3iwz{TF1 z(Rb(jTZKW#xE-8>^tP=^AWmsUHmEBUt}EhzIoL5GEZ);Si}oRDI8y_z8?9% zsndkDE^q1?2xhr;(X?f(vJXQS=~d6byJ&Kme0kk?$TwA8?>dewD8dyPeJ|-(2?JtV z>$T1@0mEB#0Y|mjpPwrOFSU0A8Qt);%kn=c_8l{$^rE4h>;UR5xz_wNyZEpdWLGj)XX{|O`%jYbPH*G7upxCSc9YO9o{N0)xj6$7p1#=u?G3#)!k z&P6B#Olg+Jg+9#oi*Bx9N=&RSXz+#n`w`69>m)jUzUiy>T#T5LZLXsKDb6Vwu;a(h z&j^*DirdHd;@HMx!yBq>r4aF#7knl z=W+IBm+TO2$$b39FSh&i5lF>vD_S1Dv|ISZvU66e3q2LEa#H~1H@O!!=R5cx=zzn8 z(Ye+WmzQO@5k`vq<7)MP508K$Mb2t*5>;fOT2eEO;z!XY*LMAIMOxrOxgj&mV$|&ns|*@4 zv_2&5r7OntVDvz1S-dEJC8fI2{-EuiwQL3{tm^J+|BDDfy8b4oq?_CNJpMjPxAp&1 z9Rsmm=`Nv1T|b8e7dwv>wssrP(yqyUe6zq; z7Z&4BOdQoid9UZ|vWsfh+twGJDXp($v<*kdr3Env42~Da_)A8$stQ!zUkUqX4L`bJ z`aH#Qr*LhjN3w*%z+^rqdgHIZ)#vfnQ;fW~)6KCF|FaDuFb(WlO~Scn90uuoP2LV7 zo1WQx?W!^)dxeb|UIge^Z?$4vWs7pC+4N4Ry!w2;AvgdT@S*#SO=xoZ zhN;9uB3n>xQ5p*s!NwU|4b@D>)(aK9FAt!B|L`se(KAcPiLMD^pEh)5H)}u(ZKjMA zJA#AKv^sLH{z#CD4E3+q*~*9HVM~~cOQ^XajsCOFmtNSOZ4@WVj-1p37ykM-UH8F? zKOxn|-)FK9FZ;#4f||W8^P2p*P!2f#%%IST7Tf#z+-PIt!_<}OSg^i1t#K9;u;;d# zo2JH5f?{UflH~Kzz2L?N4FGL<)^|~N3O4S+r|;-_ zY`oXAwN>)_rw-p6#hO^1eZ~~JSdn=42w>^%RVDJxpf$Y)#r4X0qS9_!0U=Cz1+7m~B45jesounXX#mW_7G@eGCnnjgxfyS|h{hg`Op_OHV{w3 z{njUM*|(SRAOFR98Eg**9a<3de)RNe%@Oq{5=Nd%U-F$*?@;QM$VX{w@tl`v}r zb9hmM6&ybJ!UX4)x^$^u07KCK$~pXS0j=LkW?g=S)z{$2xT*-j(tQ$v$k5!%EdJ3D z@*ZUb$$V-e8HnPI!=i~yynVqrn?f?Rgg?$ZcfDjg)xk489sQFAO|H|unMZ;|e_bCK z7T?nU$(%N}!;)`O^$RA8&q<1u^*#7^cA2&jDc-`u<|QKCyCfQ!DRG>O3(5JZ5dIKy zT zts}bZL)up54jlu$kU*T=Lim0Z(0JB!8M_HQk@1{5$5u?+4s2WS-{MX1<;k0!-TD1j z29V~S?TQW1+Y-xdvp~LQr{Za;(inz0XcR#+@pgT{#kQBU7@Dyi7L)D9 z^JTt(dR-oJKz<9Ev4t0Bv34U2%&V{7>B2+aM6R_W&Hs93oP0i5nsC7Ici0-!`=>=L zb-VO!pTjgQ+H_@2mtQsDnBZODPb+*&_w$zQ$#Jw5G5!K4hjk6^*K+jxZavq!=R+b- zEsQ-}tfLa8-o7hp`LF$iAzuM+y!gqFX-1j>b9uf!M#v@^@8ceKlNfj=9mmA6p21m8 z@9C&|cdd^2kBTogwJx|)0yJL@PI-v4Rdh~$Fjl61;sB7J`)rxIWU1jjK-)9urG?R_ zbIe#g8fC(#(3DsV%9zpc_RSdtO$8nrhgaH#s&*#l3`qg^zg|7Mg!uag|1yP#^XDx^ z+JqEh!5Alj0fJCT>E@wYOF4^JTB-SKzhe4#|4C}QT4Rg^K+QwH%z9ZIQl#m_HtnY@B(U5Boey7i89ilc5aj0%DZ>oh%71 zB7+qm$I*~8_Ri`*g}C;mtbn|JR*7rJn&$~MkiG(1M8GMFnXr^w2skQbcK}qg9mY>C zeOm)Y4oI6Uh6S!S8A@GBj}+49#dU$W{#_!@c_XX%iTT$sJM}kk+NwTA8HeO#t4J#L z6xp@QirfD#Z@b_+uK0c^V+i5j`Dxd8YcPz7(J{fDlSY*jw;Jgs>Cc=2mh3;e+0ba; zFU3EF9?)Px`G1%}1mqVyt2frr8!qr&Yd9asr|wfE3Diq56BNT$2$yRFcvNhq2iUwH z$myw5ms4*aDLyH1{L_}U11fa?4w<1Q$hghqy_~U%gf8<}IcrQxo6LV*JOKqZ1^%NF zAR>c<4%?D^*RlY;D#YufhSAesE1MRU%}a@d->Pq!c}ReKI*1L0jQV?ALfo=uBgPCz zB+LrH0$NIx0_yIiZGE7^nMI{?mOm!2b=iD1)K3G((4+i!`yO{DKt@9CJ)u5CEv@4a z-rgjX1Uyjaj7Yf<{SEAKA#9$D>ih>!Wq$VeP}jF~wILS9K*u9e3R)|gPKlk%`*QJT z5gPpTn0r5%$dDJ0@G`aW_3PCtjFg+P3gZ=VVaY8J@&;U6NfdOf`1T&_o8wu|kesJBnI zt@`-N{Db+QcwhFY*E-2TB}Le|xM8dVC8TF)0axw$(+(>%^z)+(^)1CTZ)n5p7>-f? zx@zv!A5X}RoCaG1(wsMgUwwXH<0ze645}>Ez$b0grqAEO)M2c7T5w; z{WeG(pWG4*rPKr_%VN`%$R2@4L!x6h`Zq!)Dx^L(A_C#9UQ*PHwzJ^ByVbMD(t2Fv z=acEmGo~D}Yt#=?TN<0or@EYjU+big`5u2l+#$yC`P31foaS(${huv zt}W7#qg*eRM;TY|ac*auTIDAN-;E!o;nXHY@6FaU_^8za@~xaN$yYUxCksOt_jB9w z9CKNY{7}XRruc#3(oeR}fDta=*&D_%P|wE1SAKWnjMLu3Wj0SdafSEX#hetn0X71M zmYN6eB3=7%8_zO;#NQ|5Jf1v`xzkY~)cv)XVG*9^16e%cA~LyX*Tt4fRsK5N^motL z_$Dn}#cq~qH-HA{2_hZx^!BMT`%6szotigZ4}nzUlC;=ECs3Ex+ zo+!7tR%71p-5-<5(Y9ihe-h`xA$nEy4io?wB&j#KBV z7)l6s96j#h^148{i8>gv2YLXmsvV6F2Y-8~D@YV0#!(@rO42gKG5p^MbYO&qDFiR2 zK&|(Y{>z(KLg&*B=aw@zvngL6@a)4hQM-|aQ_Z;g_43A_B|`|PC$AP@=!w&T7kJtc zKFM9PAQRg!guIf)+t2V_a~^JyV=3n4knK(79@<#&)Tl_Yw;?oB6Uf_G2+;bx)VW}$@a0iD&^JvlgiP|-nY=Xf`l-CY^=`gDHlEu7#6JD!*5~6V#G?voU_@SSGl*sB>m#fsQEeBG`H;7*u}Q)yMZ}+HEf0 zqOCN8(Z}t9=lG{d^47Q}Kl!kDL_Dz31--B)$2GM-)s=yPjw~n#yy8@(7i-k^xe_~> z9jtBUn_vbc`xMsAZU`m0QF{Gd0c#mEM5J*rd9?VK{{(r(zBf&7sRmRn_-f_R99m#A z?juX_h3=x?Wl`tOo~5sCh|(qh9`%W8KE|h(qL^>9M&&CG@f2_=nY>drtjJMl<86P; z=GgpcPL6(>>9P-FKM)*r{;)A{X9%WBjp5%>&r96sA8jkpA)0qz(z(uge{Hm2zS%Yu zYT7`-F<{foSix{;Zb!vk7%{PG0 zZI{}vy=SDC!X075|3=fsNmplrkK$XR8xNP&eLO3c-!qG>*O64&6hFGI&9f7o%3*0M z1t9PoIMa!Vo~3@f?`A)lLItIf(SCRCS9HP}VM;xy<$PkGHx=iZhDJZ_69UI=LZZr1 zr}FpLa+jjA7)jx?0%>%r%ZpiG%l@GIf)lh=r0zZdPXA@;;Pa50fyLb1ozDH{8 z6gjH2>vSQ1b_;2ojVFNYbU9c6h0+W7MUE_MUpmaJ%%U*HOp;f}Z?c1Ls?wu5J#iH< zP`>1|+nkKHfDS}>ylQqr!uT}*F7S8PLL0aF79HoR(?6VWlP=AHu~AB2hqvU^XU)GQ zqm%86@)G0~AS$t|%**d;RrxIN65o#7I9Y4<+idxTOZdP1T{vRze;4-=NV8QXthuEb zoq3{4{RH^0_N=__wce=c`m|uiN@J+di3t92)8Q)gU2HWps&wc^icc=hCsc;M_*W9@ zc5(DM42aPf`MwESrIu;ay}Z~XQd%G@KkJ~XwQYsV1z5?G3rL+QW}IKWp6tBm(Q3r?5>+ghr$~L4fM0uicSVOTnl8 zgs!TAJKy8%lbWQ_j5g+P%FdODEKyi93+Jg~OnM)Fs`g?8P#?l9H~g0fO}jlez7+yj z&ODUXRsnMnZm$1$%eAx!Q2NM*u}%*UuueY7XYz&fVU=g1MeH6^mMshJ>TXM9fH1Hh zyN$PfYkJNa`tLK^dr>5#i*J=L?GKn$W99TO<*Jl*_B7vJT=;y>zz4CW<8|vGKM<9y zk#VB0tNrBtv%U0AE`9_iij*){MOb;0eHd@?Fxw}krflZq4_+Tr(|p5OE}zM!Q1#@6 z;p~PJ-SJibirebD*n)?X|j_G5y6nasR7#}nVOxKZ@}n0iy7f3PI#sM zw1=r@TyOW%ERO0hrqO#Mm;sBZ%}f{At))1HTTN&?lGf+MAxW5KyegCZ;8|S`@>PiK zq{eqkQGHIc@dA8slZH6;-Y8hq$2t@+KznCEYSUiLDJ_+ak9f@)7^CcL1g5o$FE@05 zJK)4M_K%ejbs0Dfj%}U4FK~FbN?GBip8G_N_l3M==J5E|+XK6FfOq{N@+YhQWSSxK zKR$RQGi)B;_d=61^g0KI|H$|B9XTmciF^n%hnDvM$qqd>T66yIUF%l5MLs%Faf7Af zyf^coJxE!5U>)_A$UpS0%Y*aF(vHFFDS))gEU#C0YK@ERL9sr^khDOXkOZx}_}yKt z<=Q?`=iawusQJ%X_d8ysGI|Gl{w@uEJILcWpDtyFBXHhq0cR`n-ntYwIY-yO5A)4~ zG4Acr_=knaq(MPl{N4UcE+#+vK!b392N^VyO zJ@(_)Uet?bTjVR8LnjP-CfQ0W=9a!S%gVt)&uoT$?c1-K{fAGpGEd)p-Fur18bKob zxHbr^Cs3}6Fc)alK+eDPol-b_Q6uEdMK-T??d%GF4b$w-hPf4K^NYgA`xt*m z(oLQ}9Uy2g6@iNuN28)gyq8Z3cn*3(^(TNgHhqyTNbG55O z)LzhLViS7#1R@Yb25~L@ycH32M=yWQo<~WsXa}XRmb)7zY*Z9t$gC(%RVy(ov>yV2 zLrB$-*BtneUMTFdKqMQjLu02U{^(}R#5!zps&PX~22DX`_bF!g@CKKtu*#2*iDp$Q zVXxM77$5y0Wb>;^dZdECq3?lBvMd>ga_R-)y}H(%;J(~{6f1jBjge#MkwA&;ysxNc z4CuGO&Syhrb<@B}j<${syF`wqZgM@%?;+7AE!U zzUP)X@KfRgnb}j#o)P}5h)H%0@rb!x8)K!}EnEUD)WcZU@~^}%9YqR(A+9OG5=OnY z-OF@5?KBFM122eJNYz$~!kI84n`=B{PMZZR3oI5R6YtZlmOJOVTlrH5iBUN%wW0;x zS(!0k4>jvScOG+@we{Pjm{xYnZ?5dFmc!k2a#xj?v{kz9_PMl(e0(%07x>r=5b-r_ zXtZy7_8a||i2{ZHBA_QV5pn{uq;z|FnUh#4hi?lcx|B7yzV`AICX}&sq+O5jcpJE? z{PJCC)~rn0Y1xc$l^dJGPV*;?w9p!r0N3ViTE|y$92M_c7-qhAI!Bp*b#W_ot5esV zf0%g)Yr)`tw@2Cf>3lnda%A5v&d*w@aVwJK5`o-KxjvSM^_pvc=>S;ns`5RBxB=bX zStncC$PK6D_keagui1|$<)3x_nsMnswI&C{c{TrZg8=%i2zS~IM1q3DQnPeLhTz>^ z;B0hor?NR{s__MAt<;eIyg&%q`)Y)$Gee+61&$gtkZNdl1$(M8nK?SPbAcGK88Pm> zB3|Yc4WuN-U$MbQl~3{ozb$4MjNM?iBghRoPrezUas^-02LE1lb<%Xaz6{?H2|mXgVx`$vNEkA#KM9o2Ja3oSLA5{OAfKAY$J&pJ6s1&m3-sy*=k z^UO>xHj+asZB;v!J5K*1V~5}Orho;U`a~yeh_<;S)ywN!X-N<~WR*Agm`PW%pHl9i63)sj=Buwr7WI z|7nWq8kxgsqEiI7;Y=v+ssl;>r5gAWYR~r$`Wi0%x=|#fIxCW~+Rabz$-9ou1eZuV z+*bSkW$C1xHCte)w!CtC8)3IaM zbMPI#d_G3&5IOiKEUhs|xx)rHYh=@MLv(4Ijp)3b{{sk9Ke@$psLMwKCpem(k5?#& zJwCM~g`*^g?06ZR=jVTdrPoJ0Y6y>TFgagKSiPD)js*Qj%RS_ANCq)?!%iNrIOWu* zY@I{k#^X%P?ZFfv=%zoK-(fxuRqcjrEPzCQt$Ds)UZ0FZh7zfkr7OAKaJ&5X;uD_^ ztLPid*6%v;P9t4IRAab}DO}^Gnw!02F<=8L^nmo>SPZb@4MQ9n#CI+547`0HIs`bQ z6`ts}oVzc8X=y%uB)c|88Ay%@OnL9$7Nx$ei8Dk#iP&6kJ_%1qsCb;T5~ybtiGoa; zDl+2)UB)es*%XqN&$Fah@*G`HYFFQGCAkPc?&@xVR3F@_g+ulaV-#_lBF4XW>Q0gs z$BLPU*Qo}oP%j!AubU;wrc6fxWm*!wp@PFJF#eZSh^3Abg##x1#bJn>vznD-y0(ho zF3pkcX>ik?Wrv~Dh-TB@PGti+d~))rmdS>hwp^dFWKT>RDCiPZWMErVv*aNwT81{> zqTMiWPo(A!>eM`TpH^pZF+%btBe)L=%m1c&>7$zKgjDPT!44X!>HY66aVKYI6)a8H zw!Im|r>8MJsPg=8`$tP``Zw_hay^j(A7gwTB8B3eXJvkCJ^iA*cmqGUeZ$Vgs!xoe z_g)F`3$siKYhSvbu)f@dNZVnCHooDbmjNM6wQ@2=Kw*y_o1`!QqxfMi;NpnrsoL$umDTkGDqgeI*+5O3B(bRpG+2`3(8sKHp#!1&|NyZgz$ zH0?Rd@9{#s>5RKIR4W3UIraex%+`sm0VLX^_N5NTJO6Oz<@cr=_o~D8ySjdA>v;Z5{nfz{>$viY9-mftqheD1Vl!rved?JRg3<-5Zwrs4Gfdxo7M|pTyjCxcke~3!V)~bMJuH; z&74JE1Z4*1bQ1Q;WFmjm3q1_DvZsI9Ar4aIoD?g)@?zZKALK=C%qon;U%0uecp8p$ zM`R+SSAP}?T<*-|*3XjRaGAZ&;XKQ=Q%3Xs7X$vr7?%mL-jI3W_HJ^*3;so-eAgQ% zLDDDe^A-Y=hJ!6X;9epX*)}8Uq>xJZDWNWqadP&U>-EUpPoGq20QutX@wLa3#g3&_ zMJ0lGn-Qk`;fR&I=d+CaI$O~DC5C9VBw$i7gp?f9k3Baq9I7F{&o)lbCGZIxmx+RBisT)656B;g?ed$8x(d+ zKG|z?wogCVMs$*_WP{ed5eI7YaPhrDje~UmFArju?SB~YUyJp5=s0p$w(K4a%HJ`Q zd0{eT_^j-&bZP{|@sBR0SrZG90N6nwiDfc4$H5ejR5yf?sAXg9nI_@WrhTJ(B%ajH zV(_|G!y8pJnq90DIw)Xfk&9N}c@ne`FrWOL*|dpkFGrUSnl z>GK$vqk@D#=2rmi!AGu2)|6|FTyuWHzd2%|z!Nrn5#&_`m1sUMRk} zawqi_Ae-OXJ@srfFg|P}duKnwc|X`ryTOP{$oR{Ibj%Y*r>rQQQR>a&~p8I-}?H`XPqb(zQN126kOa zTO~*2!O;B9^?b+3#Ol|I2>L$RQbT>5v)Tt;5z9v@eg`2Jk`hbaM{xp@FHE_ObdGH? zpI8$W*0dZ)+Q+Lwu~ga(GKkJ-)nZyoE>SgXt4ER&!@PT)VRSv`!j6u8J(qP_pvOzO z2koPmHWE}TyAGJP{TsL*R23=PI}~$3RfJLJo#uCts9hobJF{i*xy3j`{Tc4XMGfy3 zmMVo6hkm{s!wA)6l-$}qn#Jg7c>04K4bJb|uH)E7==+V1YmjJ>Dl*N1Q}>t0x?%cB^VY{>kGEA=n2cq%IVryfIWws{8__Wavvxe({=BJE~ToDGV>ovM}m6Gkzm1H z>RrQ~%M&!m#3*c&Rb!|#F{fPT_~o@@YK5Hj?6O5cwLI6aXVXo&Qu8chyH{8CzyhZ| zZC%Zm%i+FXfojtaX$uVrDXn}t@mTb&P4EYKJ_DW_NF1a64DZ zo-*Z@D-I~4t+@teE&WlPR;CC?FTlmL@|Mh6*1JDeLTNKmJQ5p1$|>NUOfN;k3@HfPgIaDZ^>>Ch$lbwGh`Dx&AIrN5<-l zWky}>h1IUaG@xn=Pt?7Y33t+|<2|5;gNRHd&o)FC9fm(>#g5T4?e1m!u?BR}6`890 zj1YTX;1&z|9^>rk;k}`+M>PO?h2aO55)l*-=Dwr+)fu6eCdbj4vF^Zq79UPow{M_@ z5XPwCbB{qY5bNOruchfTAEO$+D_iqkxvT~jVvsu+j;*vby+UvS< zzhCnC^mLJ~Z|6cKZi!ND*IIS55lQZ|^Sf7<()(c9H?vu^IOWABkh?m7`7|^k!ajSe z@abdINm@=Rb%GSOtP7!+Iya2frK z^dp<~DRNVBf0yQN`Ru7|q73ymYg=0Tz}oP9?B#S8FX5%I%cuBl>T$pz!!h#{cq3ih zuw@X5A&YwQw~9~G;nu!5W#EbRi~npP{y_SVZE09$D5^Po;KvgrmU3C1h;Q8>$i%vt z9lqRY5Ym2p+x|Tyk7zb7@alLe>)BCrTzaP!$59|z)}(*c6QL68oVrq9 zUbKQ#V^u+|D#&h}TMYpgZ(3S4~=InDonhi(k1rXTk~FIzn2!?yYVi1 z?kMV+OR(k7wTh>e1+0z)c9ItDN}^KXLDrndaQ-MRXS7Dr5$BS` zYk87`hD(TI*^h2EKDTWuYA0rw_aD`BeKGY;Yp>^WJnU7^7JPKPC~QunD%Dh~_D-eE z?1V*Hs~R4B?LiZ-$T7z|_0*R-X&J~FeHQ>Hpmq89<$=v{^iR5A8`9;!pM!24@hmVS z4JrTBFV+<##PGi_H(L}#aCh`~y&Nn14I5m&x)9IRdrAOqI;4h_X6M9=`#@@YST8uR8%pmb9 z%j%faC^A%WXUD<1hgw6N(Ag-ysQ<4Pch5XTQA4HE#z_0uxxY<-K-{@zM6N#zCFD1Vg zEUiS}owS9E&OXs@w6rd#m}fBR)b&DU$aueg-92k zDE~80`FixT{AfpmMrQ9s3wNdV0a#8s9~W!q+Y~mlMDib|cgVvbyhlOMZ%c^>+*Ovp z5eus7s@PkQhX}DI$*N~<1reJk+5=+{UiU=Z0cdOT$@|7Z)ek!(?$$0X)iv<%n@uZx zc8l#}c+I|$RQn%y3mTVSLe#j)U%kQ|AD`sR{IQ)n-BOkG(LFGne@%nzsmWuF4XWLJ zqB5y4qZmAqI%S z>EXx#46Yz0#O)Tvr~K3GZHBf2PPQJ;vlMCuYuPp3jwz&)3_QxOiMdKo+24TnU(NDK zpzJiShRREwY`6_c|&xHW#EkvJu*&-}C&<8*<*gygB}QDNpW2O3|>^ zkScE#`XIg1kol7!>U33Yxu%EZI30zp=+b&)zO+DBRIAq=9^&bGaN^Ik(PMdv9EN-~t1`Dn9Q#^C(NdwW-}Y-7i`oH8-` zq@*b}`u#d8ZH!5g)IFGf+j`tqka_8q$zXv3bJuM*I_EQY8n1(53;v-Y(StMFAV!Aq zJlT!vm{Fx}yBIU~1}SV$4JB$2Q8Pk(h#uU_bT)~M8x(Qyht?K!?1)QRe^N+9jka#t z?6B44$YDgZg2e1SP}=9fRrq15Jaa7pUfXGn;Xfr@ejYBf@r20L=pS1RcpCjG@0*VO zKE$ns@!F~u+A6XB+teXP$yUMBDHh~vz$#pkxkJ@T`&e$8*Mt1ouwOR95dDgm*6Jq! z{HiHs;$^f){>&X9x&94JN8bh3v(R&<{L$>+A>XN^vmdN(b+5k+BtK7|Pl5g$4v*ZW fx3I7ny`a_l7b{@$py=s;_9&0F^d4d#*naq58kjto literal 0 HcmV?d00001 diff --git a/docs/images/gui/gui-sync-actions.png b/docs/images/gui/gui-sync-actions.png new file mode 100644 index 0000000000000000000000000000000000000000..97f6cdda56ff6d8fd35ffda5075d89d60cf807bd GIT binary patch literal 105599 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 Pl6wP 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

6|R(sLTT5mg3f3de<@~(loRRS0UA2j%oh&9}l&+ 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 literal 0 HcmV?d00001 From 34765d18b1b378b923d7b5e9e180bd2307cc574a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Karbowski?= <67231265+mikolajkarbowski@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:47:04 +0100 Subject: [PATCH 40/42] Calculate `ahead`/`behind` (#60) --- .../src/branch_manager/meva_branch_manager.rs | 2 +- engine/src/engine_container.rs | 15 +-- engine/src/handlers/clone/handlers.rs | 18 +-- engine/src/handlers/status/handlers.rs | 116 +++++++++++++++--- engine/src/ref_manager/head.rs | 18 +-- engine/src/repositories/meva_repository.rs | 28 ----- engine/src/traversal.rs | 2 + engine/src/traversal/commit_comparison.rs | 12 ++ .../traversal/meva_commit_history_walker.rs | 72 ++++++++++- engine/src/traversal/traits.rs | 20 ++- 10 files changed, 215 insertions(+), 88 deletions(-) create mode 100644 engine/src/traversal/commit_comparison.rs diff --git a/engine/src/branch_manager/meva_branch_manager.rs b/engine/src/branch_manager/meva_branch_manager.rs index 7f9aa96..2d074e9 100644 --- a/engine/src/branch_manager/meva_branch_manager.rs +++ b/engine/src/branch_manager/meva_branch_manager.rs @@ -257,7 +257,7 @@ impl BranchManager for MevaBranchManager { branch_data.insert( "merge".to_string(), format!( - "{}/{}", + "{}{}", self.ref_manager.heads_refs_prefix(), remote_branch_path ), diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index c33365c..3096294 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -130,25 +130,12 @@ impl EngineContainer for MevaContainer { let object_storage = Arc::new(MevaObjectStorage::new(layout.clone())); let ref_manager = Arc::new(MevaRefManager::new(layout.clone())); let remotes_manager = Arc::new(MevaRemotesManager); - let working_dir = Arc::new(MevaWorkingDir::new(layout.clone())); - let commit_tree_walker = Arc::new(MevaCommitTreeWalker::new(object_storage.clone())); - let index = Arc::new(RwLock::new(MevaIndex::new( - working_dir.clone(), - object_storage.clone(), - )?)); - let restore_manager = Arc::new(MevaRestoreManager::new( - index, - working_dir, - object_storage.clone(), - commit_tree_walker, - )); let repository = Arc::new(MevaRepository::new( layout, config_loader, object_storage, ref_manager, remotes_manager, - restore_manager, )); let handler = InitHandler { repository }; @@ -232,6 +219,7 @@ impl EngineContainer for MevaContainer { working_dir.clone(), index.clone(), )?); + let commit_history_walker = Arc::new(MevaCommitHistoryWalker::new(object_storage.clone())); let handler = StatusHandler::new( working_dir, @@ -239,6 +227,7 @@ impl EngineContainer for MevaContainer { refs_manager, object_storage, diff_builder, + commit_history_walker, ); Ok(handler) diff --git a/engine/src/handlers/clone/handlers.rs b/engine/src/handlers/clone/handlers.rs index 2a462d9..3f63346 100644 --- a/engine/src/handlers/clone/handlers.rs +++ b/engine/src/handlers/clone/handlers.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use std::{ fs, path::{Path, PathBuf}, - sync::{Arc, RwLock}, + sync::Arc, }; use tempfile::TempDir; @@ -10,14 +10,11 @@ use super::{CloneOperations, Request, Response}; use crate::{ ConfigLoader, MevaConfigLoader, MevaRepository, RepositoryLayout, errors::{CloneError, EngineError, EngineResult, NetworkError}, - index::{MevaIndex, MevaWorkingDir}, network::{MevaRemotesManager, PackfileCodec, SshConnectionParams, SshService, SshSession}, object_storage::MevaObjectStorage, objects::MevaObject, ref_manager::{MevaRefManager, RefEntry}, repositories::{meva_repository::Repository, meva_repository_layout::MevaRepositoryLayout}, - restore_manager::MevaRestoreManager, - traversal::MevaCommitTreeWalker, }; #[derive(Debug)] @@ -116,18 +113,6 @@ impl CloneHandler { let object_storage = Arc::new(MevaObjectStorage::new(repository_layout.clone())); let ref_manager = Arc::new(MevaRefManager::new(repository_layout.clone())); let remotes_manager = Arc::new(MevaRemotesManager); - let commit_tree_walker = Arc::new(MevaCommitTreeWalker::new(object_storage.clone())); - let working_dir = Arc::new(MevaWorkingDir::new(repository_layout.clone())); - let index = Arc::new(RwLock::new(MevaIndex::new( - working_dir.clone(), - object_storage.clone(), - )?)); - let restore_manager = Arc::new(MevaRestoreManager::new( - index, - working_dir, - object_storage.clone(), - commit_tree_walker, - )); let repository = MevaRepository::new( repository_layout.clone(), @@ -135,7 +120,6 @@ impl CloneHandler { object_storage, ref_manager, remotes_manager, - restore_manager, ); repository.clone(objects, refs, request) diff --git a/engine/src/handlers/status/handlers.rs b/engine/src/handlers/status/handlers.rs index cec2e20..0d76b46 100644 --- a/engine/src/handlers/status/handlers.rs +++ b/engine/src/handlers/status/handlers.rs @@ -1,21 +1,24 @@ use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; -use std::sync::Arc; use std::{ collections::{HashMap, HashSet}, path::PathBuf, + sync::Arc, }; use super::{ Request, Response, StatusOperations, models::{BranchInfo, MergeEntry, StatusEntry}, }; -use crate::{DiffBuilder, Index, ObjectStorage, RefManager, WorkingDir}; +use crate::{ConfigDocument, DiffBuilder, Index, ObjectStorage, RefManager, WorkingDir}; use crate::{ + config::ConfigDocumentOperations, diff_builder::{ChangeKind, DiffMode}, errors::EngineResult, + errors::{BranchError, ConfigError, EngineError, RefEntryError}, index::{index_entry::IndexEntry, stage::Stage, working_dir::WorkingDirEntry}, objects::{MevaCommit, ObjectEntry}, ref_manager::{Head, HeadMode}, + traversal::CommitHistoryWalker, }; /// Handles the logic for calculating and reporting the repository status. @@ -29,6 +32,7 @@ pub struct StatusHandler { ref_manager: Arc, object_storage: Arc, diff_builder: Arc, + commit_history_walker: Arc, } impl StatusHandler { @@ -47,6 +51,7 @@ impl StatusHandler { ref_manager: Arc, object_storage: Arc, diff_builder: Arc, + commit_history_walker: Arc, ) -> Self { Self { working_dir, @@ -54,6 +59,7 @@ impl StatusHandler { ref_manager, object_storage, diff_builder, + commit_history_walker, } } @@ -141,6 +147,66 @@ impl StatusHandler { status_entries } + /// Resolves the upstream (remote-tracking) branch for a given local branch. + /// + /// Reads repository configuration to determine which remote and merge ref + /// are associated with the provided branch name. + /// + /// Returns a string in the form `remote/branch`. + fn get_upstream(&self, branch_name: &str) -> EngineResult { + let local_config_path = self.object_storage.layout().config_file(); + let config_document = ConfigDocument::load(local_config_path)?; + + let table_name = format!("branch.{branch_name}"); + + let data = config_document.get_table(&table_name)?; + + let remote = data.get("remote").ok_or_else(|| ConfigError::KeyNotFound { + key: format!("branch.{branch_name}.remote"), + })?; + let merge = data.get("merge").ok_or_else(|| ConfigError::KeyNotFound { + key: format!("branch.{branch_name}.merge"), + })?; + + let heads_prefix = self.ref_manager.heads_refs_prefix(); + let branch = + merge + .strip_prefix(&heads_prefix) + .ok_or_else(|| RefEntryError::RefPrefixMismatch { + name: merge.to_string(), + prefix: heads_prefix, + })?; + + Ok(format!("{remote}/{branch}")) + } + + /// Returns `(ahead, behind)` commit counts between two branches. + fn get_ahead_behind_info( + &self, + left_branch_name: &str, + right_branch_name: &str, + ) -> EngineResult<(usize, usize)> { + let left_hash = self + .ref_manager + .resolve_branch_ref(left_branch_name)? + .ok_or_else(|| BranchError::BranchNotFound(left_branch_name.into()))? + .ref_entry + .commit_hash; + + let right_hash = self + .ref_manager + .resolve_branch_ref(right_branch_name)? + .ok_or_else(|| BranchError::BranchNotFound(right_branch_name.into()))? + .ref_entry + .commit_hash; + + let comparison = self + .commit_history_walker + .compare_commits(&left_hash, &right_hash)?; + + Ok((comparison.ahead, comparison.behind)) + } + /// Collects information about the current branch or HEAD state. /// /// Populates a [BranchInfo] struct based on the [Head] mode (symbolic or direct/detached) @@ -149,26 +215,46 @@ impl StatusHandler { &self, head: &Head, head_result: &EngineResult>, - ) -> BranchInfo { + ) -> EngineResult { let has_commits = matches!(head_result, Ok(Some(_))); match &head.mode { - HeadMode::Direct => BranchInfo { + HeadMode::Direct => Ok(BranchInfo { head: Some(head.target.clone()), is_detached: true, has_commits, upstream: None, ahead: 0, behind: 0, - }, - // TODO: Fetch real upstream/ahead/behind info - HeadMode::Symbolic => BranchInfo { - head: head.extract_branch_name(), - is_detached: false, - has_commits, - upstream: None, - ahead: 0, - behind: 0, - }, + }), + + HeadMode::Symbolic => { + let branch_name = head.extract_branch_name(); + let upstream: Option = match branch_name.as_deref() { + None => None, + Some(branch_name) => match self.get_upstream(branch_name) { + Ok(upstream) => Some(upstream), + Err(EngineError::Config(ConfigError::KeyNotFound { .. })) => None, + Err(e) => return Err(e), + }, + }; + + let mut ahead = 0usize; + let mut behind = 0usize; + if let Some(branch_name) = &branch_name + && let Some(upstream) = &upstream + { + (ahead, behind) = self.get_ahead_behind_info(branch_name, upstream)?; + } + + Ok(BranchInfo { + head: branch_name, + is_detached: false, + has_commits, + upstream, + ahead, + behind, + }) + } } } @@ -336,7 +422,7 @@ impl StatusOperations for StatusHandler { let head_result = self.ref_manager.resolve_commit_hash(&head); if request.show_branch { - response.branch = Some(self.get_branch_info(&head, &head_result)); + response.branch = Some(self.get_branch_info(&head, &head_result)?); } let head_hash = head_result.unwrap_or(None); diff --git a/engine/src/ref_manager/head.rs b/engine/src/ref_manager/head.rs index 8e41e8d..75eb733 100644 --- a/engine/src/ref_manager/head.rs +++ b/engine/src/ref_manager/head.rs @@ -1,9 +1,6 @@ -use std::path::PathBuf; - use crate::ref_manager::head_mode::HeadMode; use crate::serialize_deserialize::MevaEncode; use serde::{Deserialize, Serialize}; -use shared::PathToString; /// Represents the current branch reference (HEAD) in the repository. /// @@ -28,12 +25,15 @@ impl Head { /// Extracts the branch name if the HEAD is in symbolic mode. pub fn extract_branch_name(&self) -> Option { match self.mode == HeadMode::Symbolic { - true => Some( - PathBuf::from(&self.target) - .file_name() - .unwrap() - .to_utf8_string(), - ), + true => { + let parts = self.target.splitn(3, '/').collect::>(); + + if parts.len() != 3 { + return None; + } + + Some(parts[2].trim().to_string()) + } false => None, } } diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index 555cac5..f355f21 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -15,7 +15,6 @@ use crate::{ config::config_loader::ConfigLoader, ref_manager::{Head, HeadMode, RefEntry, RefManager}, repositories::RepositoryLayout, - restore_manager::RestoreManager, }; use crate::{ object_storage::ObjectStorage, objects::MevaObject, serialize_deserialize::MevaEncode, @@ -66,9 +65,6 @@ pub struct MevaRepository { /// Manager for configuring remotes. remotes_manager: Arc, - - /// Manager for restoring the repository. - restore_manager: Arc, } impl MevaRepository { @@ -79,7 +75,6 @@ impl MevaRepository { object_storage: Arc, ref_manager: Arc, remotes_manager: Arc, - restore_manager: Arc, ) -> Self { Self { layout, @@ -87,7 +82,6 @@ impl MevaRepository { object_storage, ref_manager, remotes_manager, - restore_manager, } } @@ -357,11 +351,6 @@ impl Repository for MevaRepository { initial_branch_name.trim_start_matches(&heads_refs_prefix), )?; - // Restore working dir - if let Some(hash) = self.ref_manager.resolve_head()? { - self.restore_manager.restore(&hash, true, true, &[])?; - } - Ok(ref_entries) } @@ -379,15 +368,11 @@ mod tests { use crate::repositories::meva_repository_layout::MevaRepositoryLayout; use super::*; - use crate::index::{MevaIndex, MevaWorkingDir}; - use crate::restore_manager::MevaRestoreManager; - use crate::traversal::MevaCommitTreeWalker; use pretty_assertions::assert_eq; use rstest::rstest; use std::fs; use std::io::Read; use std::path::PathBuf; - use std::sync::RwLock; use tempfile::TempDir; fn read_file(path: &PathBuf) -> String { @@ -406,18 +391,6 @@ mod tests { let object_storage = Arc::new(MevaObjectStorage::new(layout.clone())); let ref_manager = Arc::new(MevaRefManager::new(layout.clone())); let remotes_manager = Arc::new(MevaRemotesManager); - let working_dir = Arc::new(MevaWorkingDir::new(layout.clone())); - let index = Arc::new(RwLock::new(MevaIndex::new( - working_dir.clone(), - object_storage.clone(), - )?)); - let commit_tree_walker = Arc::new(MevaCommitTreeWalker::new(object_storage.clone())); - let restore_manager = Arc::new(MevaRestoreManager::new( - index, - working_dir, - object_storage.clone(), - commit_tree_walker, - )); let repo = MevaRepository::new( layout, @@ -425,7 +398,6 @@ mod tests { object_storage, ref_manager, remotes_manager, - restore_manager, ); Ok(repo) diff --git a/engine/src/traversal.rs b/engine/src/traversal.rs index 336b205..1b538b3 100644 --- a/engine/src/traversal.rs +++ b/engine/src/traversal.rs @@ -1,7 +1,9 @@ +mod commit_comparison; mod meva_commit_history_walker; mod meva_commit_tree_walker; mod traits; +pub use commit_comparison::CommitComparison; pub use meva_commit_history_walker::MevaCommitHistoryWalker; pub use meva_commit_tree_walker::MevaCommitTreeWalker; pub use traits::{CommitHistoryWalker, CommitTreeWalker}; diff --git a/engine/src/traversal/commit_comparison.rs b/engine/src/traversal/commit_comparison.rs new file mode 100644 index 0000000..dc33e22 --- /dev/null +++ b/engine/src/traversal/commit_comparison.rs @@ -0,0 +1,12 @@ +/// Result of comparing two commits in the history graph. +#[derive(Debug, Default, Clone)] +pub struct CommitComparison { + /// Number of commits reachable only from the left commit. + pub ahead: usize, + + /// Number of commits reachable only from the right commit. + pub behind: usize, + + /// Hash of the nearest common ancestor commit, if found. + pub common_base: Option, +} diff --git a/engine/src/traversal/meva_commit_history_walker.rs b/engine/src/traversal/meva_commit_history_walker.rs index 8e0daa5..bc5f754 100644 --- a/engine/src/traversal/meva_commit_history_walker.rs +++ b/engine/src/traversal/meva_commit_history_walker.rs @@ -1,8 +1,8 @@ use crate::errors::EngineResult; use crate::object_storage::ObjectStorage; use crate::objects::MevaCommit; -use crate::traversal::CommitHistoryWalker; -use std::{collections::VecDeque, sync::Arc}; +use crate::traversal::{CommitComparison, CommitHistoryWalker}; +use std::{collections::HashMap, collections::VecDeque, sync::Arc}; /// Concrete implementation of [`CommitHistoryWalker`] for the Meva repository. /// @@ -38,8 +38,10 @@ impl CommitHistoryWalker for MevaCommitHistoryWalker { queue.push_back(root_commit_hash); while let Some(hash) = queue.pop_front() { - let object = self.object_storage.get_object(&hash)?; - let commit = MevaCommit::try_from(object)?; + let commit = self + .object_storage + .get_object(&hash) + .and_then(MevaCommit::try_from)?; for parent in commit.parents { queue.push_back(parent); @@ -50,4 +52,66 @@ impl CommitHistoryWalker for MevaCommitHistoryWalker { Ok(ancestors) } + + fn compare_commits(&self, left: &str, right: &str) -> EngineResult { + let mut left_queue: VecDeque<(String, usize)> = VecDeque::from([(left.to_string(), 0)]); + let mut left_visited: HashMap = HashMap::new(); + + let mut right_queue: VecDeque<(String, usize)> = [(right.to_string(), 0)].into(); + let mut right_visited: HashMap = HashMap::new(); + + let mut common_base: Option = None; + + while !left_queue.is_empty() || !right_queue.is_empty() { + if let Some((commit_hash, depth)) = left_queue.pop_front() { + let commit = self + .object_storage + .get_object(&commit_hash) + .and_then(MevaCommit::try_from)?; + + left_visited.insert(commit_hash.clone(), depth); + + if right_visited.contains_key(&commit_hash) { + common_base = Some(commit_hash); + break; + } + + for parent in commit.parents { + left_queue.push_back((parent, depth + 1)); + } + } + + if let Some((commit_hash, depth)) = right_queue.pop_front() { + let commit = self + .object_storage + .get_object(&commit_hash) + .and_then(MevaCommit::try_from)?; + + right_visited.insert(commit_hash.clone(), depth); + + if left_visited.contains_key(&commit_hash) { + common_base = Some(commit_hash); + break; + } + + for parent in commit.parents { + right_queue.push_back((parent, depth + 1)); + } + } + } + + let mut ahead = 0usize; + let mut behind = 0usize; + + if let Some(base) = &common_base { + ahead = left_visited.get(base).cloned().unwrap_or_default(); + behind = right_visited.get(base).cloned().unwrap_or_default(); + } + + Ok(CommitComparison { + ahead, + behind, + common_base, + }) + } } diff --git a/engine/src/traversal/traits.rs b/engine/src/traversal/traits.rs index 23e072c..77375c2 100644 --- a/engine/src/traversal/traits.rs +++ b/engine/src/traversal/traits.rs @@ -1,5 +1,6 @@ use crate::errors::EngineResult; use crate::objects::ObjectEntry; +use crate::traversal::CommitComparison; use std::path::PathBuf; /// Defines a generic interface for walking (traversing) the tree structure @@ -38,7 +39,7 @@ pub trait CommitTreeWalker: Send + Sync { ) -> EngineResult>; } -/// Defines the interface for traversing the commit history graph. +/// Defines the interface for traversing and analyzing the commit history graph. /// /// The commit history in Meva is a Directed Acyclic Graph (DAG). This trait /// abstracts the algorithms used to walk through this graph, allowing for different @@ -73,4 +74,21 @@ pub trait CommitHistoryWalker: Send + Sync { /// /// * `root_commit_hash` - The starting point of the traversal (typically HEAD). fn walk_bfs(&self, root_commit_hash: String) -> EngineResult>; + + /// Compares two commits to determine their relative divergence. + /// + /// This method calculates how many commits the `left` side is "ahead" of the `right` side, + /// and how many it is "behind", based on their most recent common ancestor (Merge Base). + /// + /// ### Terminology + /// * **Ahead**: Commits reachable from `left` but not from `right`. + /// * **Behind**: Commits reachable from `right` but not from `left`. + /// + /// # Arguments + /// * `left` - Usually the local branch head hash. + /// * `right` - Usually the remote-tracking branch head hash. + /// + /// # Returns + /// A [`CommitComparison`] struct containing the count of unique commits on both sides. + fn compare_commits(&self, left: &str, right: &str) -> EngineResult; } From c5967a6172f2fed1f9da19aa45848897af5b1b49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Thu, 15 Jan 2026 21:19:01 +0100 Subject: [PATCH 41/42] Remote error handling (#62) --- engine/src/network/ssh_session.rs | 61 ++++++++++++++++++++++++++++--- server/src/server_handler.rs | 5 ++- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/engine/src/network/ssh_session.rs b/engine/src/network/ssh_session.rs index 7b96c09..a45fd4c 100644 --- a/engine/src/network/ssh_session.rs +++ b/engine/src/network/ssh_session.rs @@ -75,6 +75,7 @@ impl SshSession { .await .map_err(NetworkError::from)?; + let mut remote_stderr = String::new(); let mut protocol = UploadPackProtocol::with_haves(haves); let mut protocol_completed = false; @@ -94,13 +95,25 @@ impl SshSession { if verbose { println!("Remote command exited with status: {exit_status}"); } + if exit_status != 0 { + if !remote_stderr.is_empty() { + let clean_msg = remote_stderr.trim().to_string(); + return Err(NetworkError::RemoteError { message: clean_msg }.into()); + } + return Err(NetworkError::RemoteCommand { status: exit_status, } .into()); } } + ChannelMsg::ExtendedData { data, ext } => { + if ext == 1 { + let text = String::from_utf8_lossy(&data); + remote_stderr.push_str(&text); + } + } _ => {} } } @@ -144,6 +157,7 @@ impl SshSession { .await .map_err(NetworkError::from)?; + let mut remote_stderr = String::new(); let mut protocol = ReceivePackProtocol::new(updates.to_vec()); let mut protocol_completed = false; @@ -168,12 +182,23 @@ impl SshSession { println!("Remote command exited with status: {exit_status}"); } if exit_status != 0 { + if !remote_stderr.is_empty() { + let clean_msg = remote_stderr.trim().to_string(); + return Err(NetworkError::RemoteError { message: clean_msg }.into()); + } + return Err(NetworkError::RemoteCommand { status: exit_status, } .into()); } } + ChannelMsg::ExtendedData { data, ext } => { + if ext == 1 { + let text = String::from_utf8_lossy(&data); + remote_stderr.push_str(&text); + } + } _ => {} } } @@ -213,16 +238,42 @@ impl SshSession { .await .map_err(NetworkError::from)?; + let mut remote_stderr = String::new(); let mut protocol = UploadPackProtocol::discovery_only(); let mut protocol_completed = false; while let Some(msg) = channel.wait().await { - if let ChannelMsg::Data { data } = msg { - let is_complete = protocol.process_data(&data, &mut channel, verbose).await?; - if is_complete { - protocol_completed = true; - channel.close().await.map_err(NetworkError::from)?; + match msg { + ChannelMsg::Data { data } => { + let is_complete = protocol.process_data(&data, &mut channel, verbose).await?; + if is_complete { + protocol_completed = true; + channel.close().await.map_err(NetworkError::from)?; + } } + ChannelMsg::ExitStatus { exit_status } => { + if verbose { + println!("Remote command exited with status: {exit_status}"); + } + if exit_status != 0 { + if !remote_stderr.is_empty() { + let clean_msg = remote_stderr.trim().to_string(); + return Err(NetworkError::RemoteError { message: clean_msg }.into()); + } + + return Err(NetworkError::RemoteCommand { + status: exit_status, + } + .into()); + } + } + ChannelMsg::ExtendedData { data, ext } => { + if ext == 1 { + let text = String::from_utf8_lossy(&data); + remote_stderr.push_str(&text); + } + } + _ => {} } } diff --git a/server/src/server_handler.rs b/server/src/server_handler.rs index acf3bc8..dd2425a 100644 --- a/server/src/server_handler.rs +++ b/server/src/server_handler.rs @@ -656,11 +656,12 @@ impl Handler for ServerHandler { session: &mut Session, ) -> Result<(), Self::Error> { let command_str = std::str::from_utf8(data)?; - let mut reject = |msg: &str| -> Result<(), Self::Error> { error!("Executing command on channel {channel:?}: {msg}"); - session.data( + + session.extended_data( channel, + 1, CryptoVec::from_slice(format!("{msg}\n").as_bytes()), ); session.exit_status_request(channel, 1); From 2114c579b89d5b5a8b35eadb03cb90b34deed81c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Gr=C4=85cikowski?= <138117618+adamgracikowski@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:32:43 +0100 Subject: [PATCH 42/42] Command `merge` (#63) --- README.md | 189 +++++++++++++++++- cli/src/commands.rs | 3 + cli/src/commands/merge.rs | 130 +++++++++++++ docs/plugins/README.md | 393 -------------------------------------- 4 files changed, 319 insertions(+), 396 deletions(-) create mode 100644 cli/src/commands/merge.rs delete mode 100644 docs/plugins/README.md diff --git a/README.md b/README.md index fbe6f5a..0565391 100644 --- a/README.md +++ b/README.md @@ -28,14 +28,20 @@ Promotorem pracy był **[mgr inż. Tomasz Herman](https://github.com/tomasz-herm - [Architektura](#architektura) - [Struktura projektu](#struktura-projektu) - [Model rozproszony i architektura repozytoriów](#model-rozproszony-i-architektura-repozytoriów) - - [Warstwa sieciowa i protokoły synchronizacji](#warstwa-sieciowa-i-protokoły-synchronizacji) + - [Warstwa sieciowa i protokoły synchronizacji](#warstwa-sieciowa-i-protokoly-synchronizacji) +- [Skrypty użytkownika](#skrypty-uzytkownika) + - [Założenia systemu skryptów użytkownika](#zalozenia-systemu-skryptow-uzytkownika) + - [Koncepcja niezależności językowej](#koncepcja-niezaleznosci-jezykowej) + - [Fizyczna organizacja i konfiguracja skyptów użytkownika](#fizyczna-organizacja-i-konfiguracja-skyptow-uzytkownika) + - [Struktura obiektów kontekstu](#struktura-obiektow-kontekstu) + - [Przykład implementacji](#przyklad-implementacji) - [Instalacja](#instalacja) - - [Wymagania wstępne i środowisko budowania](#wymagania-wstępne-i-środowisko-budowania) + - [Wymagania wstępne i środowisko budowania](#wymagania-wstepne-i-srodowisko-budowania) - [Instalacja klienta CLI](#instalacja-klienta-cli) - [Instalacja klienta GUI](#instalacja-klienta-gui) - [Instalacja i konfiguracja serwera SSH](#instalacja-i-konfiguracja-serwera-ssh) - [Weryfikacja instalacji](#weryfikacja-instalacji) -- [Instrukcja użytkownika](#instrukcja-użytkownika) +- [Instrukcja użytkownika](#instrukcja-uzytkownika) - [Praca z interfejsem CLI](#praca-z-interfejsem-cli) - [Praca z interfejsem GUI](#praca-z-interfejsem-gui) - [Praca z serwerem SSH](#praca-z-serwerem-ssh) @@ -398,6 +404,183 @@ Diagram sekwencji dla protokołu `meva-receive-pack`:

+## Skrypty użytkownika + +System kontroli wersji MEVA został zaprojektowany jako rozwiązanie otwarte na rozszerzenia. Oprócz wbudowanego zestawu poleceń, udostępnia on mechanizm skryptów użytkownika (nazywanych również pluginami), który pozwala na automatyzację zadań, walidację danych oraz integrację zewnętrznych narzędzi bez konieczności ingerencji w kod źródłowy samej aplikacji. + +### Założenia systemu skryptów użytkownika + +Projektując moduł skryptów użytkownika, przyjęto szereg założeń definiujących jego zachowanie, zakres oraz sposób interakcji z otoczeniem: + +- **Model zdarzeń**: System obsługuje dwa rodzaje zdarzeń: + - `pre-execute`: Występujące przed wykonaniem właściwej operacji (np. `commit`, `push`). Służy do walidacji oraz modyfikacji danych wejściowych. Błąd zwrócony przez plugin w tej fazie skutkuje natychmiastowym przerwaniem całej operacji. + - `post-execute`: Występujące po pomyślnym wykonaniu operacji. Służy do raportowania i analizowania wyników polecenia. Błąd w tej fazie nie wpływa na wynik operacji wykonanej już na repozytorium. + +- **Zakresy**: Pluginy mogą być rejestrowane na dwóch poziomach widoczności: + - **Lokalny**: Ogranicza działanie skryptu do pojedynczego repozytorium. Konfiguracja i skrypty przechowywane są wewnątrz katalogu `.meva/plugins/`. + - **Globalny**: Rozszerza widoczność na wszystkie repozytoria danego użytkownika w systemie. Konfiguracja i skrypty przechowywane są w katalogu domowym użytkownika (`~/.meva/plugins/`). + +- **Niezależna komunikacja**: Interfejs wymiany danych między systemem a pluginem jest zrealizowany poprzez system plików. W momencie wywołania, skrypt otrzymuje ścieżkę do tymczasowego pliku JSON (tzw. pliku kontekstu), co uniezależnia mechanizm od języka programowania, w którym napisano rozszerzenie. + +- **Synchroniczne wykonanie**: Pluginy są uruchamiane synchronicznie, jeden po drugim. Kolejność ich wykonywania wynika z priorytetu (`order`) nadanego podczas rejestracji. + +- **Kontrola czasu wykonania**: Istnieje możliwość zdefiniowania maksymalnego czasu wykonania osobno dla każdego zarejestrowanego skryptu. Jeżeli wykonanie logiki zapisanej w pluginie potrwa dłużej niż określony limit, system automatycznie przerywa wykonanie, informując o przekroczeniu czasu. + +- **Przechwytywanie wejścia/wyjścia**: System w pełni zarządza standardowymi strumieniami (`stdin`, `stdout`, `stderr`) każdego skryptu. Strumienie wyjściowe są przechwytywane w czasie rzeczywistym, dzięki czemu komunikaty wypisywane na standardowe wyjście (`stdout`) i standardowe wyjście błędów (`stderr`) są jednocześnie prezentowane użytkownikowi w konsoli oraz zapisywane w logach wywołania. Skrypty mogą pobierać dane od użytkownika poprzez standardowe wejście (`stdin`). + +- **Konfiguracja**: Działanie całego systemu rozszerzeń jest kontrolowane przez wpisy w głównych plikach konfiguracyjnych (sekcja `[plugins]`), co pozwala na ich szybkie włączenie lub wyłączenie bez konieczności odinstalowywania skryptów czy rekompilacji kodu. + +### Koncepcja niezależności językowej + +Istotną cechą systemu rozszerzeń jest brak wymogu tworzenia pluginów w konkretnym języku programowania. Zamiast dostarczać wewnętrzne API lub biblioteki dynamiczne (`.dll`, ang. _dynamic link libraries_), system **MEVA** wykorzystuje mechanizm procesów potomnych oraz komunikację opartą na plikach. Procesy potomne komunikują się poprzez ustandaryzowany plik kontekstu operacji w formacie [JSON](https://json.org/json-en.html). + +Plugin został zdefiniowany jako dowolny plik wykonywalny lub skrypt interpretowany (na przykład w języku `Python`, `JavaScript`, `PowerShell` czy `Bash`). + +Proces wymiany danych wygląda następująco: + +1. System MEVA generuje plik tymczasowy w formacie JSON, zawierający metadane polecenia (kontekst wykonywanej operacji). +2. System uruchamia skrypt użytkownika jako osobny proces, przekazując ścieżkę do pliku kontekstu jako pierwszy argument wywołania. +3. Skrypt odczytuje dane, wykonuje swoją logikę i (opcjonalnie) modyfikuje plik kontekstu. Może również zapisać w nim informację o błędzie oraz zwrócić niezerowy kod wyjścia, przerywając wykonanie kolejnych skryptów lub samego polecenia. + +Dzięki takiemu podejściu użytkownik ma pełną swobodę w doborze technologii do tworzenia rozszerzeń, o ile w danym środowisku systemowym dostępny jest odpowiedni interpreter. + +### Fizyczna organizacja i konfiguracja skyptów użytkownika + +System pluginów posiada własną, ustrukturyzowaną hierarchię plików, która umożliwia logiczną organizację skryptów i ich metadanych. Zdefiniowano dwa poziomy zasięgu pluginów: globalny (dla użytkownika systemu operacyjnego) oraz lokalny (dla konkretnego repozytorium). + +``` +.meva/plugins/ +├── .invocations/ +│ ├── commit/ +│ │ ├── 20260112-202222/ +│ │ │ ├── invocation.log +│ │ │ ├── pre-execute-context.json +│ │ │ ├── post-execute-context.json +│ │ │ ├── stdout.log +│ │ │ └── stderr.log +│ │ └── ... +│ └── ... +├── commit/ +│ ├── plugins.json +│ ├── validate_message.py +│ └── ... +├── config/ +│ ├── set/ +│ │ ├── plugins.json +│ │ └── ... +│ └── unset/ +│ ├── plugins.json +│ └── ... +└── ... +``` + +### Struktura obiektów kontekstu + +Komunikacja między systemem MEVA a skryptami użytkownika odbywa się poprzez wymianę sformalizowanych obiektów danych w formacie JSON. + +Każdy plik kontekstu operacji, do którego ścieżka jest przekazywana jako pierwszy argument pozycyjny podczas uruchamiania skryptu, zawiera cztery główne sekcje: metadane (`context`), dane wejściowe (`pre-payload`), dane wyjściowe (`post-payload`) oraz kanał błędów (`error`). + +```json +{ + "context": { + "command": "commit", + "event": "pre-execute", + "timestamp": "2026-01-12T12:34:56Z", + "working_dir": "/home/john/company_project" + }, + "pre-payload": { + "message": "Implement user authentication.", + "author": { + "name": "John Doe", + "email": "john.doe@example.com" + }, + "amend": false + }, + "post-payload": { + "commit_hash": "a1b2c3d4e5f6...", + "changes": [ + { + "added": { + "new_path": "src/auth.rs", + "insertions": 45 + } + } + ] + }, + "error": null +} +``` + +Rola poszczególnych sekcji obiektu JSON jest następująca: + +1. `context`: Sekcja zawierająca podstawowe informacje o kontekście wywołania skryptu użytkownika. Znajdują się tu informacje o typie polecenia (`command`), rodzaju zdarzenia (`event`), czasie wywołania (`timestamp`) oraz katalogu roboczym (`working_dir`). +2. `pre-payload`: Sekcja zawierająca dane wejściowe specyficzne dla danego polecenia. Są one dostępne zarówno w fazie `pre-execute`, jak i `post-execute`. +3. `post-payload`: Sekcja przeznaczona na wynik działania operacji. W fazie `pre-execute` wartość ta zawsze wynosi `null`. W fazie `post-execute` (jak na rysunku przedstawiającym kontekst polecenia `commit`) zawiera ona informacje dotyczące rezultatu polecenia. +4. `error`: Opcjonalna sekcja, która w przypadku poprawnego wykonania skryptu użytkownika przyjmuje wartość `null`. Plugin może ją zmodyfikować w celu zgłoszenia błędu wykonania. + +W przypadku polecenia `commit` informacje zawarte w `pre-payload` obejmują między innymi treść wiadomości, dane autora oraz flagi sterujące (np. `amend`). Sekcja `post-payload` zawiera natomiast między innymi skrót zatwierdzenia, a także listę plików objętych zatwierdzeniem wraz z metadanymi (np. liczbę zmodyfikowanych linii). + +#### Struktura obiektów błędu + +Skrypt użytkownika ma możliwość przerwania łańcucha wywołań następujących po nim pluginów, a w fazie `pre-execute` — dodatkowo zablokowania wykonania głównego polecenia (np. uniemożliwienia utworzenia zatwierdzenia, którego format wiadomości nie spełnia określonych wymogów). + +Sygnałem do przerwania operacji jest zakończenie procesu pluginu z kodem wyjścia (ang. _exit code_) różnym od zera. W takiej sytuacji system **MEVA** analizuje pole `error` w pliku kontekstu, aby wyświetlić użytkownikowi przyczynę blokady. + +```json +{ + "code": "VALIDATION_ERROR", + "message": "Commit message must reference an Azure DevOps work item (e.g. AB#1234)", + "details": "Regex pattern '^AB#\\d+' not matched" +} +``` + +Obiekt ten składa się z trzech pól: + +1. `code`: Stały identyfikator typu błędu (np. `VALIDATION_ERROR`). +2. `message`: Komunikat przeznaczony bezpośrednio dla użytkownika końcowego, wyjaśniający, dlaczego operacja została przerwana. +3. `details`: Opcjonalne pole zawierające szczegóły techniczne, pomocne w diagnostyce. + +### Przykład implementacji + +W celu zaprezentowania praktycznego zastosowania omówionych mechanizmów, przedstawiona zostanie przykładowa implementacja pluginu walidacyjnego. Scenariusz ten zakłada integrację systemu MEVA z platformą Azure DevOps. Jedną z cech tej platformy jest identyfikowanie elementów roboczych (ang. _work items_) poprzez unikalny identyfikator (liczbę). + +Wymaganiem biznesowym może być oznaczanie każdego zatwierdzenia identyfikatorem odpowiadającego mu elementu roboczego poprzez umieszczenie tego identyfikatora na początku wiadomości zatwierdzenia. + +Poniżej przedstawiono kompletny kod skryptu napisanego w języku Python. Realizuje on logikę walidacji w fazie `pre-execute`, wykorzystując mechanizm pliku kontekstu do komunikacji z systemem MEVA. + +```python +#!/usr/bin/env python3 +import sys, json, re + +filename = sys.argv[1] +with open(filename, "r", encoding="utf-8") as f: + data = json.load(f) + +msg = data.get("pre-payload", {}).get("message", "") + +pattern = r"^AB#[0-9]+" + +if not re.match(pattern, msg): + data["error"] = { + "code": "VALIDATION_ERROR", + "message": "Commit message must reference an Azure DevOps work item (e.g. AB#1234)", + "details": f"Regex pattern '{pattern}' not matched" + } + with open(filename, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + sys.exit(1) + +data["error"] = None +with open(filename, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + +sys.exit(0) +``` + +Wszystkie pliki związane z rozszerzeniami przechowywane są w katalogu `plugins/`. Dla zasięgu lokalnego katalog ten znajduje się wewnątrz folderu `.meva/`, natomiast dla zasięgu globalnego jego lokalizacja to katalog domowy użytkownika (ścieżka ``~/.meva/plugins/`). + +Pluginy są grupowane w katalogach odpowiadających nazwom poleceń, na które mają one reagować (np. `commit/`, `config/set/`). Taka struktura umożliwa szybkie zlokalizowanie wszystkich skryptów zarejestrowanych na wywołanie konkretnego polecenia. + ## Instalacja Zarówno narzędzie wiersza poleceń (CLI), jak i interfejs graficzny (GUI) są dystrybuowane jako diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 9f22bdb..d67d5bb 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -11,6 +11,7 @@ pub mod init; pub mod log; pub mod ls_files; pub mod ls_tree; +pub mod merge; pub mod meva_command; pub mod plugins; pub mod pull; @@ -33,6 +34,7 @@ pub use init::InitCommand; pub use log::LogCommand; pub use ls_files::LsFilesCommand; pub use ls_tree::LsTreeCommand; +pub use merge::MergeCommand; pub use plugins::PluginsCommand; pub use pull::PullCommand; pub use push::PushCommand; @@ -91,5 +93,6 @@ pub fn collect_commands() -> CommandsCollection { Box::new(PushCommand), Box::new(PullCommand), Box::new(CheckoutCommand), + Box::new(MergeCommand), ] } diff --git a/cli/src/commands/merge.rs b/cli/src/commands/merge.rs new file mode 100644 index 0000000..0b98961 --- /dev/null +++ b/cli/src/commands/merge.rs @@ -0,0 +1,130 @@ +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use miette::Result; + +use engine::engine_container::MevaContainer; + +use crate::{commands::MevaCommand, extensions::WithVerbose}; + +/// Implements the `merge` command for Meva DVCS. +#[derive(Default)] +pub struct MergeCommand; + +impl MergeCommand { + /// The branch to merge into the current branch + const ARG_BRANCH: &'static str = "branch"; + + /// Merge changes but do not create a merge commit (squash) + const ARG_SQUASH: &'static str = "squash"; + + /// Abort the current merge process and restore the previous state + const ARG_ABORT: &'static str = "abort"; + + /// Use the given message as the merge commit message + const ARG_MESSAGE: &'static str = "message"; + + /// Allow fast-forward merge if possible + const ARG_FAST_FORWARD: &'static str = "ff"; + + /// Create a merge commit even when fast-forward is possible + const ARG_NO_FAST_FORWARD: &'static str = "no-ff"; +} + +#[async_trait] +impl MevaCommand for MergeCommand { + type Container = MevaContainer; + + fn name(&self) -> &'static str { + "merge" + } + + fn about(&self) -> &'static str { + "Join development histories together" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the CLI argument parser for the `merge` command. + fn build_command(&self) -> Command { + self.build_base_command() + .with_verbose_arg("Enable verbose output during the merge process") + .arg( + Arg::new(Self::ARG_BRANCH) + .help("The branch to merge into the current branch") + .value_name("BRANCH") + .required_unless_present(Self::ARG_ABORT), + ) + .arg( + Arg::new(Self::ARG_ABORT) + .long(Self::ARG_ABORT) + .short('a') + .help("Abort the current merge process and restore the previous state") + .action(ArgAction::SetTrue) + .conflicts_with_all([ + Self::ARG_BRANCH, + Self::ARG_SQUASH, + Self::ARG_MESSAGE, + Self::ARG_FAST_FORWARD, + Self::ARG_NO_FAST_FORWARD, + ]), + ) + .arg( + Arg::new(Self::ARG_SQUASH) + .long(Self::ARG_SQUASH) + .short('s') + .help("Merge changes but do not create a merge commit (squash)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_FAST_FORWARD) + .long(Self::ARG_FAST_FORWARD) + .help("Allow fast-forward merge if possible") + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_NO_FAST_FORWARD), + ) + .arg( + Arg::new(Self::ARG_NO_FAST_FORWARD) + .long(Self::ARG_NO_FAST_FORWARD) + .help("Create a merge commit even when fast-forward is possible") + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_FAST_FORWARD), + ) + .arg( + Arg::new(Self::ARG_MESSAGE) + .long(Self::ARG_MESSAGE) + .short('m') + .value_name("MESSAGE") + .help("Use the given message as the merge commit message") + .requires(Self::ARG_BRANCH), + ) + } + + /// Executes the `merge` command. + /// + /// # Arguments + /// * `matches`: Parsed command-line arguments. + /// * `container`: Dependency injection container. + /// + /// # Returns + /// * `Result<()>`: Success or error during execution. + async fn execute(&self, _matches: &ArgMatches, _container: &Self::Container) -> Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = MergeCommand; + assert_eq!(cmd.name(), "merge"); + assert_eq!(cmd.about(), "Join development histories together"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/docs/plugins/README.md b/docs/plugins/README.md deleted file mode 100644 index ea03650..0000000 --- a/docs/plugins/README.md +++ /dev/null @@ -1,393 +0,0 @@ -# Rozszerzalność projektu MEVA poprzez system pluginów - -System pluginów umożliwia rozszerzanie funkcjonalności systemu kontroli wersji poprzez uruchamianie zewnętrznych skryptów użytkownika w odpowiedzi na określone polecenia wywoływane przez narzędzie `meva`. - -Pluginy pozwalają na walidację, automatyzację i integrację zewnętrznych usług bez modyfikacji kodu źródłowego. - -## Spis treści - -- [Czym jest plugin?](#czym-jest-plugin) - - [Rodzaje pluginów](#rodzaje-pluginów) -- [Zdarzenia systemowe](#zdarzenia-systemowe) - - [pre-execute](#pre-execute) - - [post-execute](#post-execute) -- [Rejestracja pluginu](#rejestracja-pluginu) - - [Polecenie register](#polecenie-register) - - [Argumenty](#argumenty) - - [Opcje](#opcje) - - [Manualna edycja plików konfiguracyjnych](#manualna-edycja-plików-konfiguracyjnych) -- [Kontekst wywołania](#kontekst-wywołania) - - [Struktura pliku](#struktura-pliku) - - [Przykłady](#przykłady) - - [Polecenie config list – zdarzenie post-execute](#polecenie-config-list--zdarzenie-post-execute) - - [Polecenie init – zdarzenie pre-execute](#polecenie-init--zdarzenie-pre-execute) - - [Obiekt error](#obiekt-error) - - [Struktura obiektu](#struktura-obiektu) - - [Przykład obiektu error](#przykład-obiektu-error) -- [Struktura plików w folderze plugins/](#struktura-plików-w-folderze-plugins) - - [Pluginy lokalne](#pluginy-lokalne) - - [Pluginy globalne](#pluginy-globalne) - - [Pliki opisujące informacje o wywołaniu](#pliki-opisujące-informacje-o-wywołaniu) -- [Integracja z plikami konfiguracyjnymi](#integracja-z-plikami-konfiguracyjnymi) - - [Przykładowa konfiguracja](#przykładowa-konfiguracja) - - [Dostępne opcje](#dostępne-opcje) - -## Czym jest plugin? - -Plugin to samodzielny skrypt lub plik wykonywalny, który po zarejestrowaniu w naszym systemie jest wywoływany w określonym momencie. - -Pluginy mogą być napisane w różnych językach programowania, np. Python, JavaScript, PowerShell itp. - -Pluginy pozwalają m.in. na: - -- Walidację danych przed wykonaniem operacji, -- Przetwarzanie wyników po wykonaniu komend, -- Automatyzację zadań związanych z zarządzaniem repozytorium, -- Integrację z zewnętrznymi systemami, -- Inicjalizację i konfigurację projektów. - -Każdy plugin jest wywoływany z pojedynczym argumentem pozycyjnym, będącym ścieżką do pliku zawierającego kontekst wywołania, czyli metadane dotyczące danego polecenia (np. `init`, `commit` czy `push`). - -### Rodzaje pluginów - -Ze względu na lokalizację wyróżniamy dwa rodzaje pluginów: - -- Globalne: - - Zlokalizowane w folderze `~/.meva/plugins/`. - - Dostępne we wszystkich repozytoriach użytkownika. -- Lokalne: - - Zlokalizowane w folderze `/.meva/plugins/`. - - Specyficzne dla konkretnego repozytorium. - -Fizycznie plugin jest przechowywany w systemie w postaci następującej pary: - -- plik wykonywalny/zawierający kod źródłowy pluginu (w wybranym języku programowania), -- wpis w odpowiednim pliku konfiguracyjnym pluginów (położenie pliku odzwierciedla polecenie systemowe, którego dotyczy plugin, czyli np. pluginy uruchamiane dla zdarzeń związanych z poleceniem `commit` znajdują się w pliku `.meva/plugins/commit/plugins.json`). - -## Zdarzenia systemowe - -System pluginów obsługuje dwa zdarzenia `pre-execute` oraz `post-execute`. Dla każdego zdarzenia system obsługuje dowolną liczbę synchronicznie wykonywanych pluginów. - -### `pre-execute` - -- **Kiedy:** Zdarzenie to występuje przed wykonaniem głównej operacji systemu określonej poprzez polecenie, np. `init`, `commit`, `push`. -- **Zastosowanie:** Walidacja, przygotowanie danych, sprawdzenie warunków wstępnych. -- **Zachowanie:** Jeśli plugin zwraca błąd, główna operacja nie zostanie wykonana. - -### `post-execute` - -- **Kiedy:** Zdarzenie to występuje po pomyślnym wykonaniu głównej operacji. -- **Zastosowanie:** Raportowanie, powiadamianie, automatyzacja powtarzalnych czynności, inicjalizacja dodatkowych plików. -- **Zachowanie:** Błąd pluginu nie wpływa na status głównej operacji (przerywa jedynie wykonanie pozostałych zarejestrowanych pluginów). - -## Rejestracja pluginu - -Rejestracja, czyli dodanie nowego pluginu do systemu MEVA, może odbyć się na dwa sposoby: - -1. Poprzez dedykowane polecenie systemu, -2. Poprzez ręczną edycję plików konfiguracyjnych. - -### Polecenie `register` - -Operację rejestracji nowego pluginu umożliwia polecenie `register`: - -```bash - meva plugins register [OPTIONS] --name --command --event --order -``` - -#### Argumenty - -- `` – ścieżka do skryptu/pliku wykonywalnego, który zostanie skopiowany do repozytorium. - -#### Opcje - -- `-f, --file ` – ścieżka względna określająca położenie skryptu wewnątrz repozytorium (przykładowo wewnątrz folderu `.meva/plugins/commit/`). - - Pozwala na tworzenie zagnieżdżonej struktury plików (ułatwia logiczne grupowanie pluginów użytkownikowi systemu). - - Ścieżki względne rozpoczynające się od `./meva/plugins/.invocations/` są niedozwolone. -- `-s, --scope ` – zakres pluginu. - - Dostępne wartości to lokalny (`local`) oraz globalny (`global`). -- `-n, --name ` – nazwa pluginu. - - Może być różna od nazwy pliku określonego przez opcję `file`. - - Musi być unikatowa wśród wszystkich pluginów zarejestrowanych dla danego polecenia i zdarzenia. -- `-d, --description ` – opis pluginu. - - Pełni on jedynie rolę informacyjną dla użytkownika naszego systemu. -- `-c, --command ` – typ komendy (w formacie kebab-case). - - Możliwe wartości to na przykład: `init`, `config-get`, `add`. -- `-e, --event ` – typ zdarzenia, dla którego będzie uruchamiany plugin (w formacie kebab-case). - - Możliwe wartości: `pre-execute` i `post-execute`. -- `-o, --order ` – kolejność uruchomienia pluginu w ramach danego polecenia i zdarzenia systemowego. - - Wszystkie pluginy zarejestrowane na to samo zdarzenie dla danego polecenia uruchamiane są synchronicznie w kolejności określonej przez pole `order`. - - Wartość `order = 1` oznacza, że plugin zostanie uruchomiony jako pierwszy (odpowiednio większy `order` oznacza, że plugin zostanie uruchomiony później). - - Wszystkie pluginy zarejestrowane dla danego polecenia systemowego powinny mieć unikalną wartość `order`. - - W przypadku próby zarejestrowania pluginu dla polecenia, dla którego występuje już plugin o określonej wartości `order`, operacja zakończy się błędem. -- `-t, --timeout ` – limit czasu wykonania w milisekundach (liczba całkowita). - - W przypadku braku wartości czas wykonania nie jest ograniczony z góry (rekomendowane dla pluginów pobierających dane od użytkownika, np. z konsoli). -- `-D, --disabled` – rejestracja pluginu jako wyłączonego. - - Wyłączony plugin nie będzie uruchamiany dla danego zdarzenia systemowego. -- `-i, --interpreter ` – opcjonalny interpreter używany do uruchamiania skryptu (np. `python3`). - - W przypadku braku wartości system będzie bazował na rozszerzeniu pliku z kodem źródłowym. -- `-h, --help` – wyświetla pomoc. -- `-V, --version` – wyświetla wersję. - -### Manualna edycja plików konfiguracyjnych - -Użytkownik może zarejestrować plugin poprzez edycję pliku `plugins.json` dla określonego zdarzenia systemowego. Przykładowo, w celu manualnego dodania nowego pluginu dla polecenia `commit`, należy zmodyfikować plik `.meva/plugins/commit/plugins.json`. - -Przykładowa zawartość pliku `plugins.json`: - -```json -[ - { - "name": "validate-commit-message", - "description": "This python script validates if the commit message contains task number eg. #123 - minor changes", - "file": "validators/validate-commit-message.py", - "event": "pre-execute", - "order": 1, - "timeout": 500, - "enabled": true, - "interpreter": "python" - }, - { - "name": "another-plugin", - "description": "This is another example to show the file structure clearly", - "file": "validators/another-plugin.py", - "event": "post-execute", - "order": 2, - "timeout": null, - "enabled": false, - "interpreter": "python" - } -] -``` - -Dodatkowo, należy przekopiować plik zawierający kod źródłowy do folderu `.meva/plugins/commit/`. Dla pierwszego pluginu z przykładowej zawartości pliku `plugins.json` będzie to: `.meva/plugins/commit/validators/validate-commit-message.py`. - -## Kontekst wywołania - -Każde wywołanie pluginu odbywa się w oparciu o plik w formacie **JSON**, który zawiera informacje o aktualnym stanie, przekazywanych danych oraz błędach. -Plik ten przekazywany jest do skryptu w momencie uruchomienia i pozwala mu zareagować w odpowiedni sposób na zdarzenie. - -### Struktura pliku - -Plik kontekstu zawiera cztery główne pola: - -- **`context`** – informacje stałe, wspólne dla wszystkich wywołań. - Zawiera m.in. typ komendy, typ zdarzenia, znacznik czasu oraz katalog roboczy. - -- **`pre-payload`** – dane wejściowe specyficzne dla danego polecenia. - To pole występuje zawsze, niezależnie od zdarzenia. - -- **`post-payload`** – dane wyjściowe specyficzne dla danego polecenia. - - - Dla zdarzeń `pre-execute` wartość **zawsze** jest `null`. - - Dla zdarzeń `post-execute` zawiera dane zwracane przez wykonane polecenie. - -- **`error`** – obiekt opisujący błąd. - Wypełniany jest przez skrypt pluginu, a następnie sprawdzany przez silnik pluginów. - Umożliwia zgłaszanie zarówno błędów technicznych, jak i biznesowych. - -Dzięki takiemu ujednoliconemu formatowi skrypty pluginów mają zawsze dostęp do niezbędnych danych wejściowych, a silnik pluginów może w spójny sposób obsługiwać zarówno poprawne wyniki, jak i błędy. - -### Przykłady - -#### Polecenie `config list` – zdarzenie `post-execute` - -```json -{ - "context": { - "command": "config-list", - "event": "post-execute", - "timestamp": "2025-09-21T15:09:27.770490400Z", - "working_dir": "C:\\meva\\target\\debug" - }, - "pre-payload": { - "config_file": "C:\\meva\\target\\debug\\.meva\\mevaconfig" - }, - "post-payload": { - "key_values": [ - ["plugins.enabled", "true"], - ["plugins.collect_logs", "true"], - ["plugins.timeout_ms", "500"] - ] - }, - "error": null -} -``` - -#### Polecenie `init` – zdarzenie `pre-execute` - -```json -{ - "context": { - "command": "init", - "event": "pre-execute", - "timestamp": "2025-09-21T20:34:23.091035200Z", - "working_dir": "." - }, - "pre-payload": { - "initial_branch": "master" - }, - "post-payload": null, - "error": { - "code": "BUSINESS_LOGIC_ERROR", - "message": "A business rule was violated during plugin execution", - "details": "Optional additional context or stack trace" - } -} -``` - -### Obiekt `error` - -Pole `error` w pliku kontekstu pozwala skryptowi przekazać szczegółowe informacje o błędach napotkanych podczas wykonania. -Silnik pluginów wykorzystuje je **dopiero wtedy**, gdy proces uruchamiający skrypt zakończy się kodem różnym od `0` (czyli wystąpi błąd). - -W przypadku poprawnego wykonania (`exit status = 0`) zawartość pola `error` nie jest walidowana przez silnik. - -#### Struktura obiektu - -Obiekt `error` jest zdefiniowany w formacie JSON i zawiera następujące pola: - -- **`code`** _(string, wymagane)_ - Kod błędu identyfikujący jego typ. Może przyjmować wartości takie jak np.: - - - `BUSINESS_LOGIC_ERROR` – naruszenie reguły biznesowej, - - `VALIDATION_ERROR` – nieprawidłowe dane wejściowe, - - `RUNTIME_ERROR` – błąd wykonania (np. problem z dostępem do pliku). - -- **`message`** _(string, wymagane)_ - Krótki, czytelny opis błędu, przeznaczony do logów lub komunikatów użytkownika. - -- **`details`** _(string, opcjonalne)_ - Szczegółowe informacje diagnostyczne – np. fragment stosu wywołań, dodatkowy kontekst lub wskazówki do debugowania. - -Dzięki temu mechanizmowi możliwe jest precyzyjne raportowanie zarówno błędów technicznych, jak i naruszeń reguł biznesowych, a logi pluginów pozostają spójne i czytelne. - -#### Przykład obiektu `error` - -```json -"error": { - "code": "VALIDATION_ERROR", - "message": "Missing required configuration parameter", - "details": "Parameter 'plugins.enabled' not found in configuration file" -} -``` - -## Struktura plików w folderze `plugins/` - -Układ katalogów w folderze `plugins/` jest analogiczny w obu lokalizacjach – różni się jedynie miejscem przechowywania: - -- **Pluginy lokalne** – w repozytorium, wersjonowane razem z projektem. -- **Pluginy globalne** – w katalogu użytkownika, dostępne dla wszystkich repozytoriów na danej maszynie. - -### Pluginy lokalne - -Przechowywane w repozytorium – dzięki temu są wersjonowane razem z projektem i dostępne dla wszystkich osób pracujących w tym repozytorium. - -```txt -/.meva/ -└── plugins/ - ├── commit/ - │ ├── plugins.json - │ ├── validate_commit_message.py - │ └── ... - ├── config-set/ - │ ├── plugins.json - │ ├── check_if_secret_key_not_included.py - │ └── ... - ├── config-unset/ - │ ├── plugins.json - │ └── ... - └── ... -``` - -### Pluginy globalne - -Instalowane w katalogu użytkownika – dostępne dla wszystkich repozytoriów na danej maszynie. -Są przydatne np. do wymuszania organizacyjnych reguł (formatowanie kodu, walidacja commitów). - -```txt -~/.meva/ -└── plugins/ - ├── commit/ - │ ├── plugins.json - │ ├── validate_commit_message.py - │ └── ... - ├── config-set/ - │ ├── plugins.json - │ ├── check_if_secret_key_not_included.py - │ └── ... - ├── config-unset/ - │ ├── plugins.json - │ └── ... - └── ... -``` - -### Pliki opisujące informacje o wywołaniu - -Każde uruchomienie pluginu generuje w katalogu `.invocations/` zestaw plików pomocniczych, które umożliwiają analizę działania i debugowanie. -Struktura odzwierciedla rodzaj polecenia i datę/godzinę wywołania. - -```txt -.meva/ -└── plugins/ - └── .invocations/ - ├── commit/ - │ ├── 20250828-202222/ - │ │ ├── invocation.log - │ │ ├── pre-execute-context.json - │ │ ├── post-execute-context.json - │ │ ├── stdout.log - │ │ └── stderr.log - │ └── ... - └── ... -``` - -Zawartość katalogu wywołania: - -- `invocation.log` - Główny log wywołania – zawiera ogólne informacje o uruchomieniu pluginów. - -- `pre-execute-context.json` - Kontekst wywołania dla zdarzenia `pre-execute`. - Zawiera stałe pola (`context`), `pre-payload` oraz ewentualny obiekt `error`. - Pole `post-payload` zawsze ma wartość `null`. - -- `post-execute-context.json` - Kontekst wywołania dla zdarzenia `post-execute`. - Oprócz pól z `pre-execute` zawiera również `post-payload` z danymi wynikowymi. - -- `stdout.log` - Standardowe wyjście (`stdout`) ze skryptów pluginów. - -- `stderr.log` - Standardowe wyjście błędów (`stderr`) ze skryptów pluginów. - -Dzięki takiemu układowi możliwe jest łatwe odtworzenie i przeanalizowanie każdego wywołania – od danych wejściowych (`pre-execute-context.json`), przez dane wynikowe (`post-execute-context.json`), aż po logi i ewentualne błędy. - -## Integracja z plikami konfiguracyjnymi - -Działanie pluginów można kontrolować poprzez wpisy w plikach konfiguracyjnych systemu MEVA. -W sekcji `[plugins]` definiowane są podstawowe parametry, które wpływają na uruchamianie oraz logowanie pluginów. - -### Przykładowa konfiguracja - -```toml -[plugins] -enabled = true -collect_logs = true -``` - -### Dostępne opcje - -- `enabled` (_boolean_): - Określa, czy mechanizm pluginów jest aktywny. - - `true` - pluginy są uruchamiane zgodnie z rejestracją, - - `false` - wszystkie pluginy są ignorowane (lokalne i globalne). -- `collect_logs` (_boolean_): - Kontroluje zapisywanie logów z uruchomień pluginów do katalogu `.invocations/`. - - `true` - pliki `invocation.log`, `stdout.log`, `stderr.log` oraz konteksty (`*-context.json`) są zapisywane, - - `false` - logi nie są przechowywane (działanie pluginów jest wtedy trudniejsze do debugowania). - -Dzięki integracji z plikami konfiguracyjnymi możliwe jest centralne sterowanie zachowaniem pluginów – zarówno lokalnie (w repozytorium), jak i globalnie (w katalogu użytkownika). - -**_Uwaga_**: Jeśli dana opcja występuje zarówno w konfiguracji **globalnej**, jak i **lokalnej**, **pierwszeństwo ma konfiguracja lokalna**.

4NXp#SVl)|xQh42Z8L8fjZeYTcnlH2r9l_*ufUEf+vVt+SKeAF3mhe)01F zPk5L4n*8>(Rb{tg2D!7K&B?pYsSuZamcG5C5{e&U>yKp1AR8r$$%my%@~z)MpB#TL zImym(BDm6YR6u)g>+?v%-kIP@Q>zsj%1&CupY8omGl_gUBsP4hSey-~4e>hJu$ zMY%@IkG+3&#Vd;M>hx8{XJMe?^D_Iv#$^VXeT zT8}Y~O90W9se0GDXLRe+p@KQZC!pYlYJX$;H70F6nj^Az<`PQ%E^Oj4>1%_$4mqjn z3M_UDhZ*Wn&VrQ{nj4zX&8CZl(+0!0*YB8jzEY72t2cp-f7*m5QaP^)Kg8f_XdEXi z>w3#?IVNlj0i(Q+1!X9ZJDAX}wX)LCS zF%`tDktyN(ZFnzDv|i{)spg~rvqgLC5 z2SJ5~&a3P!6;_mYh$h{b#qY}S<+0?3+Edv&Lw>rIEc%D2%ItjGy$t51G@xxoJR7s6QKe~T}wWm z`U4TF5@grOO#U(cfO-?1)BU>M4Qy{eivg&cyd9YqF9J~4<{l~c4`#=Pr~6YaKE)8> z8+qqDHu4<~(a^p}U~c&2qaLU&mr-{ojfqmfiJ#Sa>e4pkJt>$H)57?HhiG4&FvNw# zFbomZh7%^bms23?5Hx_&!c6roq$L6oRs}<2{fISr28iqu^Em=(Ih!MT<4D6f;-cxvxEXJ>ZH~|{telSq}jU`Ty zxBS)KwJIB?i)xtz?5)MIR)v%a{b0W|m9gJ|f*B`Z!$+1y44zfjn)6$; z13tL=>&k`@I^)czPkq{1H()6B21h^wY;U+za$7#a=pB2NF*rmeyUL5yJX74FkR%8G zarP#rZ$i<8{l4kY?d(d(vrp4r`W5flr}oIim^XF%vOpCV?Uuuyb1=iM4qJosjq8xv zSQNGd*bv&mYbAZF|E1iz$B5I1g4@!!{oGVdN9byWehh2q?#g=Tx%26ECKs^$|CaY% zSZCDgT$Jmj+b*;}ki^5suE2s{nPEHU`$@FRKQqxSn^ocMv?q`-ygtNi1N;~0B zN_1;$6d}EAznU6D=un>mcD(>AuvmQASW0Epxa-#6R^JRajoCjZ*b2oWUk43^!ZQ-v z3{+C`#wY9-qLPBpZEG_5aU7BN3=G6iY;Kkn!!1HsXhzvkteCO)sGG}lXQ+h9K*(xl kO5J`xxG_hXjo&E2#a^;#kLO(dd&>AyU0V(E>|MnF0N}~eBLDyZ literal 0 HcmV?d00001 diff --git a/docs/images/receive-pack.png b/docs/images/receive-pack.png new file mode 100644 index 0000000000000000000000000000000000000000..7285452bedecdd6c7e19730bcaaebc058412ac61 GIT binary patch literal 107808 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 DZo%o6 literal 0 HcmV?d00001 diff --git a/docs/images/upload-pack.png b/docs/images/upload-pack.png new file mode 100644 index 0000000000000000000000000000000000000000..b6c973bc7be47c41f85faddad48df97919a33917 GIT binary patch literal 103100 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

o5ACa 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