diff --git a/.gitignore b/.gitignore index ad679558..a7715742 100644 --- a/.gitignore +++ b/.gitignore @@ -13,9 +13,36 @@ 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 # 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 +.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 + +/server_files \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 8bf84e68..babbdb9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,412 +3,7346 @@ version = 4 [[package]] -name = "aho-corasick" -version = "1.1.3" +name = "ab_glyph" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" dependencies = [ - "memchr", + "ab_glyph_rasterizer", + "owned_ttf_parser", ] [[package]] -name = "anstyle" -version = "1.0.11" +name = "ab_glyph_rasterizer" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] -name = "cfg-if" -version = "1.0.1" +name = "accesskit" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" [[package]] -name = "cli" -version = "0.1.0" +name = "accesskit_atspi_common" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "890d241cf51fc784f0ac5ac34dfc847421f8d39da6c7c91a0fcc987db62a8267" dependencies = [ - "engine", - "mockall", - "plugins", - "pretty_assertions", - "rstest", - "shared", + "accesskit", + "accesskit_consumer", + "atspi-common", + "serde", + "thiserror 1.0.69", + "zvariant", ] [[package]] -name = "diff" -version = "0.1.13" +name = "accesskit_consumer" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +checksum = "db81010a6895d8707f9072e6ce98070579b43b717193d2614014abd5cb17dd43" +dependencies = [ + "accesskit", + "hashbrown 0.15.5", +] [[package]] -name = "downcast" -version = "0.11.0" +name = "accesskit_macos" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" - -[[package]] -name = "engine" -version = "0.1.0" +checksum = "a0089e5c0ac0ca281e13ea374773898d9354cc28d15af9f0f7394d44a495b575" dependencies = [ - "mockall", - "pretty_assertions", - "rstest", - "shared", + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "accesskit_unix" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "301e55b39cfc15d9c48943ce5f572204a551646700d0e8efa424585f94fec528" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] [[package]] -name = "fragile" -version = "2.0.1" +name = "accesskit_windows" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +checksum = "d2d63dd5041e49c363d83f5419a896ecb074d309c414036f616dc0b04faca971" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "static_assertions", + "windows 0.61.3", + "windows-core 0.61.2", +] [[package]] -name = "futures-core" -version = "0.3.31" +name = "accesskit_winit" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "c8cfabe59d0eaca7412bfb1f70198dd31e3b0496fee7e15b066f9c36a1a140a0" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] [[package]] -name = "futures-macro" -version = "0.3.31" +name = "addr2line" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ - "proc-macro2", - "quote", - "syn", + "gimli", ] [[package]] -name = "futures-task" -version = "0.3.31" +name = "adler2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] -name = "futures-timer" -version = "3.0.3" +name = "aead" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.6", + "generic-array", +] [[package]] -name = "futures-util" -version = "0.3.31" +name = "aes" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "futures-core", - "futures-macro", - "futures-task", - "pin-project-lite", - "pin-utils", - "slab", + "cfg-if", + "cipher", + "cpufeatures", ] [[package]] -name = "glob" -version = "0.3.2" +name = "aes-gcm" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" - -[[package]] -name = "gui" -version = "0.1.0" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "engine", - "mockall", - "plugins", - "pretty_assertions", - "rstest", - "shared", + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", ] [[package]] -name = "hashbrown" -version = "0.15.4" +name = "ahash" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.3", + "once_cell", + "version_check", + "zerocopy", +] [[package]] -name = "indexmap" -version = "2.10.0" +name = "aho-corasick" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ - "equivalent", - "hashbrown", + "memchr", ] [[package]] -name = "memchr" -version = "2.7.5" +name = "aligned" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "377e4c0ba83e4431b10df45c1d4666f178ea9c552cac93e60c3a88bf32785923" +dependencies = [ + "as-slice", +] [[package]] -name = "mockall" -version = "0.13.1" +name = "aligned-vec" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" dependencies = [ - "cfg-if", - "downcast", - "fragile", - "mockall_derive", - "predicates", - "predicates-tree", + "equator", ] [[package]] -name = "mockall_derive" -version = "0.13.1" +name = "android-activity" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" dependencies = [ - "cfg-if", - "proc-macro2", - "quote", - "syn", + "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 = "pin-project-lite" -version = "0.2.16" +name = "android-properties" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" [[package]] -name = "pin-utils" -version = "0.1.0" +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "plugins" -version = "0.1.0" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ - "mockall", - "pretty_assertions", - "rstest", - "shared", + "libc", ] [[package]] -name = "predicates" -version = "3.1.3" +name = "anstream" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", - "predicates-core", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", ] [[package]] -name = "predicates-core" -version = "1.0.9" +name = "anstyle" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] -name = "predicates-tree" -version = "1.0.12" +name = "anstyle-parse" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ - "predicates-core", - "termtree", + "utf8parse", ] [[package]] -name = "pretty_assertions" -version = "1.4.1" +name = "anstyle-query" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "diff", - "yansi", + "windows-sys 0.60.2", ] [[package]] -name = "proc-macro-crate" -version = "3.3.0" +name = "anstyle-wincon" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ - "toml_edit", + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", ] [[package]] -name = "proc-macro2" -version = "1.0.95" +name = "anyhow" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" -dependencies = [ - "unicode-ident", -] +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] -name = "quote" -version = "1.0.40" +name = "arbitrary" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" -dependencies = [ - "proc-macro2", -] +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] -name = "regex" -version = "1.11.1" +name = "arboard" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", + "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 = "regex-automata" -version = "0.4.9" +name = "arg_enum_proc_macro" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "regex-syntax" -version = "0.8.5" +name = "arrayref" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] -name = "relative-path" -version = "1.9.3" +name = "arrayvec" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "rstest" -version = "0.25.0" +name = "as-raw-xcb-connection" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" -dependencies = [ - "futures-timer", - "futures-util", - "rstest_macros", - "rustc_version", -] +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" [[package]] -name = "rstest_macros" -version = "0.25.0" +name = "as-slice" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" dependencies = [ - "cfg-if", - "glob", - "proc-macro-crate", - "proc-macro2", - "quote", - "regex", - "relative-path", - "rustc_version", - "syn", - "unicode-ident", + "stable_deref_trait", ] [[package]] -name = "rustc_version" -version = "0.4.1" +name = "ash" +version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" dependencies = [ - "semver", + "libloading", ] [[package]] -name = "semver" -version = "1.0.26" +name = "ashpd" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" - -[[package]] -name = "shared" -version = "0.1.0" +checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" dependencies = [ - "mockall", - "pretty_assertions", - "rstest", + "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 = "slab" -version = "0.4.10" +name = "async-broadcast" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] [[package]] -name = "syn" -version = "2.0.104" +name = "async-channel" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", ] [[package]] -name = "termtree" -version = "0.5.1" +name = "async-executor" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] [[package]] -name = "toml_datetime" -version = "0.6.11" +name = "async-fs" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] [[package]] -name = "toml_edit" -version = "0.22.27" +name = "async-io" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ - "indexmap", - "toml_datetime", - "winnow", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.2", + "slab", + "windows-sys 0.61.2", ] [[package]] -name = "unicode-ident" -version = "1.0.18" +name = "async-lock" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] [[package]] -name = "winnow" -version = "0.7.12" +name = "async-net" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" dependencies = [ - "memchr", + "async-io", + "blocking", + "futures-lite", ] [[package]] -name = "yansi" +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.2", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[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 = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83247582e7508838caf5f316c00791eee0e15c0bf743e6880585b867e16815c" +dependencies = [ + "atspi-common", + "atspi-connection", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33dfc05e7cdf90988a197803bf24f5788f94f7c94a69efa95683e8ffe76cfdfb" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-connection" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4193d51303d8332304056ae0004714256b46b6635a5c556109b319c0d3784938" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite", + "zbus", +] + +[[package]] +name = "atspi-proxies" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2eebcb9e7e76f26d0bcfd6f0295e1cd1e6f33bedbc5698a971db8dc43d7751c" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.17", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +dependencies = [ + "arrayvec", +] + +[[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 0.52.6", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[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" +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 = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = [ + "core2", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +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", +] + +[[package]] +name = "block-buffer" +version = "0.11.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9ef36a6fcdb072aa548f3da057640ec10859eb4e91ddf526ee648d50c76a949" +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 = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.3", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = [ + "bitflags 2.9.4", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" +dependencies = [ + "bitflags 2.9.4", + "polling", + "rustix 1.1.2", + "slab", + "tracing", +] + +[[package]] +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 = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.3", + "rustix 1.1.2", + "wayland-backend", + "wayland-client", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = [ + "libc", +] + +[[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" +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" +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.1", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[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 = [ + "async-trait", + "clap", + "dateparser", + "engine", + "globset", + "miette", + "mockall", + "owo-colors", + "plugins", + "pretty_assertions", + "regex", + "rstest", + "shared", + "strum", + "strum_macros", + "thiserror 2.0.17", + "tokio", + "url", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width 0.2.1", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "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", + "url", +] + +[[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", + "wasm-bindgen", + "windows-core 0.62.0", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.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 = "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 = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.9.4", + "libc", + "redox_syscall 0.5.18", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +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 = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.9.4", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[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 = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess2" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca" +dependencies = [ + "mime", + "phf", + "phf_shared", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[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.2", +] + +[[package]] +name = "mockall" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "moxcms" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80986bbbcf925ebd3be54c26613d861255284584501595cf418320c078945608" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "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 = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.9.4", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.8.5", +] + +[[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 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.9.4", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.9.4", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.4", + "dispatch2", + "objc2 0.6.3", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.9.4", + "dispatch2", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "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 = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.9.4", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.9.4", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "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 = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.9.4", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[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 = "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" +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" +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 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" +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 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "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" +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 = "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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +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" +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 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" +dependencies = [ + "chrono", + "mockall", + "owo-colors", + "pretty_assertions", + "rstest", + "serde", + "serde_json", + "shared", + "strum", + "strum_macros", + "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" +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 = "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" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +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 = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "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 = "ravif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[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_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 2.9.4", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.17", +] + +[[package]] +name = "regex" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "relative-path" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "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" +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 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rstest" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "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 2.9.4", + "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 0.8.5", + "rand_core 0.6.4", + "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 0.8.5", + "rand_core 0.6.4", + "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" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +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 2.9.4", + "errno", + "libc", + "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]] +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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +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" +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 = "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" +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" +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" +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 = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.225" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "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_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" +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.17", + "tokio", + "toml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[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 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]] +name = "shared" +version = "0.1.0" +dependencies = [ + "editor-command", + "mockall", + "owo-colors", + "path-absolutize", + "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 = "shlex" +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 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]] +name = "similar" +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" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +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" +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 0.6.4", + "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 = "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" +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 = "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" +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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "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]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix 1.1.2", + "windows-sys 0.60.2", +] + +[[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 = "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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[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]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +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" +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.2", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae2a4cf385da23d1d53bc15cdfa5c2109e93d8d362393c801e87da2f72f0e201" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a197c0ec7d131bfc6f7e82c8442ba1595aeab35da7adbf05b6b73cd06a16b6be" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ad0b7ae9cfeef5605163839cb9221f453399f15cfb5c10be9885fcf56611f9" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710c388a2bb52d1a7186ae4aa2e92c6fae0606426bf4bdfb4106fa1e8e5c602e" +dependencies = [ + "lazy_static", + "sequential_gen", + "serde", + "spin 0.10.0", + "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]] +name = "typed-builder" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef81aec2ca29576f9f6ae8755108640d0a86dd3161b2e8bca6cfa554e98f77d" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "unicode-linebreak" +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" +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 = "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 = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be33efd9986755f20533a6d724f20c4f62f680de544999f47ae04cffc1d996ae" +dependencies = [ + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +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", + "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]] +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[package]] +name = "wait-timeout" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f3bf741a801531993db6478b95682117471f76916f5e690dd8d45395b09349" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +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 = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" +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" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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" +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 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-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +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-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 = "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" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "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]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "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]] +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.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "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]] +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "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]] +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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_gnullvm" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[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" +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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[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" +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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[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" +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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[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" +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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[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" +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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +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 = "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" 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 = "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" +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", +] + +[[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/Cargo.toml b/Cargo.toml index 4f0d42cb..a8f9e941 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,12 +2,37 @@ resolver = "3" -members = ["engine", "cli", "gui", "plugins", "shared"] +members = ["engine", "cli", "gui", "plugins", "shared", "server"] [workspace.package] 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.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" +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" +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" +itertools = "0.14.0" diff --git a/README.md b/README.md index f270385f..0565391c 100644 --- a/README.md +++ b/README.md @@ -1,155 +1,925 @@ -# 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-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-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-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) +- [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/ -│ └── ... +│ └── ... +├── cli/ +│ └── ... +├── server/ +│ └── ... +├── engine/ +│ └── ... ├── plugins/ -│ ├── Cargo.toml -│ └── src/ -│ └── ... +│ └── ... ├── shared/ -│ ├── Cargo.toml -│ └── src/ -│ └── ... -├── tests/ │ └── ... -└── target/ - └── ... +├── Cargo.toml +├── LICENSE +└── README.md ``` -It consists of 5 crates: +| 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. | -- Library crates: - - `shared`, - - `engine`, - - `plugins`. -- Binary crates: - - `cli`, - - `gui`. +Projekt generuje trzy niezależne pliki wykonywalne: `meva`, `meva-gui` oraz `meva-server` odpowiednio dla modułów `cli`, `gui` oraz `server`. -## Getting Started +### Model rozproszony i architektura repozytoriów -> The installation guide assumes you have already installed [Rust](https://www.rust-lang.org/learn/get-started). +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. -### Building the project +Diagram ilustruje współpracę dwóch niezależnych użytkowników za pośrednictwem węzła centralnego: -To build all crates in the workspace: +

+ +

-```bash -cargo build +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`: + +

+ +

+ +## 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 +} ``` -To build a specific crate only: +Rola poszczególnych sekcji obiektu JSON jest następująca: -```bash -cargo build -p +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" +} ``` -Replace `` with one of: `engine`, `cli`, `gui`, `plugins`, or `shared`. +Obiekt ten składa się z trzech pól: -### Running the project +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. -To run the CLI binary: +### Przykład implementacji -```bash -cargo run -p cli +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) ``` -To run the GUI binary: +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/`). -```bash -cargo run -p gui +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 +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) ``` -### Running tests +### Instalacja klienta CLI -To run all tests in the workspace: +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. -```bash -cargo test +Kompilację należy wykonać z poziomu katalogu głównego projektu, wykorzystując menedżer pa +kietów cargo: + +```sh +cargo build --release --bin meva ``` -To run tests for a specific crate: +Wygenerowany plik wykonywalny zostanie domyślnie umieszczony w katalogu `target/release/`. + +Uruchomienie systemu MEVA za pomocą wiersza poleceń z dowolnego miejsca systemu operacyjnego wymaga dodania ścieżki z plikiem wykonywalnym do zmiennej środowiskowej `PATH`. + +#### Konfiguracja globalna użytkownika + +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: -```bash -cargo test -p +``` +meva config create +meva config set --global user.name "John Doe" +meva config set --global user.email "john.doe@example.com" ``` -### Adding dependencies +### Instalacja klienta GUI -You can manage dependencies in your workspace efficiently with Cargo's CLI. +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: -To add a regular dependency to a chosen crate: +```sh +cargo build --release --bin meva-gui +``` -```bash -cargo add -p +Podobnie jak w przypadku klienta konsolowego, wynikowy plik wykonywalny (`meva-gui` lub `meva-gui.exe`) zostanie domyślnie umieszczony w katalogu `target/release/`. + +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`. + +### 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 ``` -To add a development-only dependency: +#### Generowanie kluczy kryptograficznych -```bash -cargo add --dev -p +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" ``` -To remove a dependency from a crate: +#### Struktura katalogów serwera -```bash -cargo remove -p +``` +opt/meva-server/ +├── bin/ +│ └── meva-server +├── config/ +│ ├── mevaserverconfig.toml +│ ├── access.toml +│ └── authorized_keys +├── secrets/ +│ ├── server_host_key +│ └── server_host_key.pub +├── logs/ +│ └── ... +└── repositories/ + └── ... ``` -Examples: +| 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. | -```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 +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 +``` + +**Sekcja `[server]`:** + +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 +``` + +#### Plik z kluczami publicznymi klientów + +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: + +```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: + +``` +$env:RUST_LOG = "info"; .\bin\meva-server.exe ‘ +"C:\meva\server\config\mevaserverconfig.toml" +``` + +Polecenie uruchomienia serwera w systemie operacyjnym Linux: + +``` +RUST_LOG=info ./bin/meva-server \ +"/opt/meva-server/config/mevaserverconfig.toml" +``` + +### Weryfikacja instalacji + +Aby uruchomić zestaw testów jednostkowych zdefiniowanych w kodzie źródłowym, należy w głównym katalogu projektu wywołać polecenie: + +``` +cargo test +``` + +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:** + +``` +... +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 ``` -To share a dependency (or dev-dependency) version across multiple crates, declare it in your workspace root `Cargo.toml` using `[workspace.dependencies]`: +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. + +#### 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] - = +# 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 +``` + +### 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" ``` -In any crate that should use a workspace-provided dependency, reference it like so in its own `Cargo.toml`: +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] - = { workspace = true } +[company_project.john-doe] +read = true +write = true +``` + + -### 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/cli/Cargo.toml b/cli/Cargo.toml index 0917a37a..1890361b 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -4,10 +4,26 @@ 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.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 +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 new file mode 100644 index 00000000..d67d5bb8 --- /dev/null +++ b/cli/src/commands.rs @@ -0,0 +1,98 @@ +pub mod add; +pub mod branch; +pub mod checkout; +pub mod clone; +pub mod commit; +pub mod config; +pub mod diff; +pub mod fetch; +pub mod ignore; +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; +pub mod push; +pub mod remote; +pub mod restore; +pub mod show; +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; +pub use diff::DiffCommand; +pub use fetch::FetchCommand; +pub use ignore::IgnoreCommand; +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; +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; + +/// Executes the appropriate subcommand based on the provided matches. +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(()) +} + +/// 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), + Box::new(CheckoutCommand), + Box::new(MergeCommand), + ] +} diff --git a/cli/src/commands/add.rs b/cli/src/commands/add.rs new file mode 100644 index 00000000..45f0cb32 --- /dev/null +++ b/cli/src/commands/add.rs @@ -0,0 +1,143 @@ +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; + +/// 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. +#[derive(Default)] +pub struct AddCommand; + +impl AddCommand { + /// 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"; +} + +#[async_trait] +impl MevaCommand for AddCommand { + type Container = MevaContainer; + + 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), + ) + .with_verbose_arg("Enable verbose output") + .group( + ArgGroup::new("path_group") + .args([Self::ARG_PATH, Self::ARG_ALL, Self::ARG_UPDATE]) + .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. + 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(Command::ARG_VERBOSE); + let path_arg = matches.get_one::(Self::ARG_PATH); + + let 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(), + }; + + handler + .handle_add(request, &interceptor) + .into_diagnostic()?; + + Ok(()) + } +} diff --git a/cli/src/commands/branch.rs b/cli/src/commands/branch.rs new file mode 100644 index 00000000..7d6fed49 --- /dev/null +++ b/cli/src/commands/branch.rs @@ -0,0 +1,237 @@ +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, + SetUpstream, +}; +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`]. +#[derive(Default)] +pub struct BranchCommand; + +impl BranchCommand { + /// 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"; + + /// Flag to configure local branch upstream. + const ARG_SET_UPSTREAM: &'static str = "set-upstream-to"; +} + +#[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), + ) + .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]) + .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 upstream = matches.get_one::(Self::ARG_SET_UPSTREAM); + + let mut print_list = false; + + 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(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()), + 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, + }; + + print_list = true; + + Request::List(request) + }; + + let handler = container.branch_handler().into_diagnostic()?; + + let response = handler.branch(request).into_diagnostic()?; + + if print_list && response.branches.is_some() { + println!("{}", response.branches.unwrap()); + } + + Ok(()) + } +} diff --git a/cli/src/commands/checkout.rs b/cli/src/commands/checkout.rs new file mode 100644 index 00000000..e4aec18a --- /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/cli/src/commands/clone.rs b/cli/src/commands/clone.rs new file mode 100644 index 00000000..7e54937e --- /dev/null +++ b/cli/src/commands/clone.rs @@ -0,0 +1,111 @@ +use crate::{commands::MevaCommand, extensions::WithVerbose}; +use async_trait::async_trait; +use clap::{Arg, 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. +#[derive(Default)] +pub struct CloneCommand; + +impl CloneCommand { + /// 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"; +} + +#[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() + .with_verbose_arg("Enable verbose output") + .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"), + ) + } + + /// 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).unwrap().clone(), + server_key: matches + .get_one::(Self::ARG_SERVER_KEY) + .unwrap() + .clone(), + quiet: !matches.get_flag(Command::ARG_VERBOSE), + }; + + let handler = container.clone_handler().into_diagnostic()?; + let response = handler.handle_clone(request).await.into_diagnostic()?; + + print!("{response}"); + + Ok(()) + } +} diff --git a/cli/src/commands/commit.rs b/cli/src/commands/commit.rs new file mode 100644 index 00000000..eaa1069c --- /dev/null +++ b/cli/src/commands/commit.rs @@ -0,0 +1,244 @@ +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}; +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; + +/// 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. +#[derive(Default)] +pub struct CommitCommand; + +impl CommitCommand { + /// 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"; + + /// 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()); + } + }; + } + } +} + +#[async_trait] +impl MevaCommand for CommitCommand { + type Container = MevaContainer; + + 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 [`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. + /// + /// # Returns + /// * [`miette::Result`]: Indicates success or a diagnostic error if the commit operation fails. + async fn execute( + &self, + matches: &ArgMatches, + container: &Self::Container, + ) -> miette::Result<()> { + 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 = matches.get_flag(Self::ARG_DRY_RUN); + let amend = 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_request = Request { + dry_run, + amend, + message: message.clone(), + author: author.cloned(), + }; + + let commit_handler = container.commit_handler(dry_run).into_diagnostic()?; + + let response = match commit_handler.handle_commit(commit_request, &interceptor) { + Ok(val) => val, + Err(err @ EngineError::Commit(CommitError::NothingToCommit)) => { + println!("{}", err.yellow()); + return Ok(()); + } + 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/config.rs b/cli/src/commands/config.rs new file mode 100644 index 00000000..63da9401 --- /dev/null +++ b/cli/src/commands/config.rs @@ -0,0 +1,69 @@ +pub mod subcommands; + +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. +/// +/// 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; + +#[async_trait] +impl MevaCommand for ConfigCommand { + type Container = MevaContainer; + + 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), + Box::new(ConfigCreateCommand), + ] + } + + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { + execute_multiple(matches, container, self.subcommands()).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = ConfigCommand; + 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 00000000..18411eea --- /dev/null +++ b/cli/src/commands/config/subcommands.rs @@ -0,0 +1,13 @@ +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; +pub use set::ConfigSetCommand; +pub use unset::ConfigUnsetCommand; diff --git a/cli/src/commands/config/subcommands/create.rs b/cli/src/commands/config/subcommands/create.rs new file mode 100644 index 00000000..ffe397a5 --- /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 new file mode 100644 index 00000000..cacbbae2 --- /dev/null +++ b/cli/src/commands/config/subcommands/edit.rs @@ -0,0 +1,76 @@ +use async_trait::async_trait; +use clap::{ArgMatches, Command}; +use engine::engine_container::MevaContainer; +use engine::{ConfigDocument, ConfigLoader, MevaConfigLoader}; +use miette::{Context, IntoDiagnostic}; +use shared::OpenInEditor; + +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 `editor.default` override in config or falling back to OS defaults. +#[derive(Default)] +pub struct ConfigEditCommand; + +#[async_trait] +impl MevaCommand for ConfigEditCommand { + type Container = MevaContainer; + + 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", + ) + } + + async fn execute( + &self, + matches: &ArgMatches, + _container: &Self::Container, + ) -> miette::Result<()> { + let loader = MevaConfigLoader::default(); + let override_cmd = loader.get("editor.default", None).ok(); + 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()?; + + location.open_in_editor(override_cmd).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 = 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 new file mode 100644 index 00000000..3eeaf040 --- /dev/null +++ b/cli/src/commands/config/subcommands/get.rs @@ -0,0 +1,101 @@ +use async_trait::async_trait; +use clap::{Arg, ArgMatches, Command}; +use engine::EngineContainer; +use engine::engine_container::MevaContainer; +use engine::handlers::config::GetRequest; +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. +/// +/// 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 { + const ARG_KEY: &'static str = "key"; + + const ARG_DEFAULT: &'static str = "default"; +} + +#[async_trait] +impl MevaCommand for ConfigGetCommand { + type Container = MevaContainer; + + 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"), + ) + } + + 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); + + let config_handler = container.config_handler().into_diagnostic()?; + + let request = GetRequest { + location, + key: key.clone(), + default: default.cloned(), + }; + + let response = config_handler.handle_get(request).into_diagnostic()?; + + println!("{}", response.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; + 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 00000000..4246f604 --- /dev/null +++ b/cli/src/commands/config/subcommands/list.rs @@ -0,0 +1,93 @@ +use async_trait::async_trait; +use clap::{ArgMatches, Command}; +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}; + +/// Implements the `list` subcommand for Meva configuration management. +/// +/// Retrieves and displays all key/value pairs from the selected +/// configuration scope. +#[derive(Default)] +pub struct ConfigListCommand; + +#[async_trait] +impl MevaCommand for ConfigListCommand { + type Container = MevaContainer; + + 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", + ) + } + + 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()?; + + let request = ListRequest { location }; + + let response = config_handler.handle_list(request).into_diagnostic()?; + + if response.key_values.is_empty() { + println!( + "{}", + "Config file has no key-value pairs to display.".yellow() + ); + } + + let max_key_len = response + .key_values + .iter() + .map(|(k, _)| k.len()) + .max() + .unwrap_or(0); + + for (key, value) in response.key_values { + let padded_key = format!("{key: &'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), + ) + } + + 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(); + + let config_handler = container.config_handler().into_diagnostic()?; + let interceptor = container.plugins_interceptor().into_diagnostic()?; + + let request = SetRequest { + location, + key: key.clone(), + value: value.clone(), + }; + + let response = config_handler + .handle_set(request, &interceptor) + .into_diagnostic()?; + + println!( + "{} {} {}", + response.key.green().bold(), + "=".dimmed(), + response.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 = 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 new file mode 100644 index 00000000..76aa1bc6 --- /dev/null +++ b/cli/src/commands/config/subcommands/unset.rs @@ -0,0 +1,82 @@ +use async_trait::async_trait; +use clap::{ArgMatches, Command}; +use engine::EngineContainer; +use engine::engine_container::MevaContainer; +use engine::handlers::config::UnsetRequest; +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. +#[derive(Default)] +pub struct ConfigUnsetCommand; + +#[async_trait] +impl MevaCommand for ConfigUnsetCommand { + type Container = MevaContainer; + + 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") + } + + 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(); + + let config_handler = container.config_handler().into_diagnostic()?; + let interceptor = container.plugins_interceptor().into_diagnostic()?; + + let request = UnsetRequest { + location, + key: key.clone(), + }; + + let response = config_handler + .handle_unset(request, &interceptor) + .into_diagnostic()?; + + println!("{}", response.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 = 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 new file mode 100644 index 00000000..91288fac --- /dev/null +++ b/cli/src/commands/diff.rs @@ -0,0 +1,192 @@ +use std::path::PathBuf; + +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; +use engine::{ + EngineContainer, + diff_builder::DiffMode, + engine_container::MevaContainer, + handlers::diff::{Request, Response}, + revision_parsing::Revision, +}; + +use miette::{IntoDiagnostic, Result}; +use shared::PathToString; + +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 { + /// 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"; + + /// 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(); + } + } + } + } + } +} + +#[async_trait] +impl MevaCommand for DiffCommand { + type Container = MevaContainer; + + 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) + .conflicts_with(Self::ARG_STAT) + .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..) + .value_parser(value_parser!(PathBuf)) + .last(true) + .help("Limit the diff to the given paths (use -- to separate ranges from paths)") + ) + } + + /// Executes the `diff` command. + 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); + 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 request = Request::new(from.cloned(), to.cloned(), cached, mode, paths); + + let handler = container.diff_handler().into_diagnostic()?; + let response = handler.handle_diff(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 = DiffCommand; + 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/fetch.rs b/cli/src/commands/fetch.rs new file mode 100644 index 00000000..1fcf28eb --- /dev/null +++ b/cli/src/commands/fetch.rs @@ -0,0 +1,113 @@ +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use engine::{EngineContainer, engine_container::MevaContainer, handlers::fetch::Request}; + +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. +#[derive(Default)] +pub struct FetchCommand; + +impl FetchCommand { + /// The name of the argument used to specify the prune flag. + const ARG_PRUNE: &'static str = "prune"; + + /// 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"; +} + +#[async_trait] +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_ORIGIN) + .value_name("ORIGIN") + .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) + .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 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 _ = handler.handle_fetch(request).await.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 = FetchCommand; + 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 new file mode 100644 index 00000000..0554c370 --- /dev/null +++ b/cli/src/commands/ignore.rs @@ -0,0 +1,67 @@ +pub mod subcommands; + +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. +/// +/// 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; + +#[async_trait] +impl MevaCommand for IgnoreCommand { + type Container = MevaContainer; + + 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), + ] + } + + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { + execute_multiple(matches, container, self.subcommands()).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = IgnoreCommand; + 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 00000000..26a70046 --- /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 00000000..214db6f9 --- /dev/null +++ b/cli/src/commands/ignore/subcommands/add.rs @@ -0,0 +1,84 @@ +use std::path::PathBuf; + +use async_trait::async_trait; +use clap::{ArgMatches, Command}; +use engine::{ + IgnoreOperations, IgnoreService, RepositoryLayout, engine_container::MevaContainer, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; +use globset::Glob; +use miette::IntoDiagnostic; +use owo_colors::OwoColorize; +use shared::PathToString; + +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. +#[derive(Default)] +pub struct IgnoreAddCommand; + +#[async_trait] +impl MevaCommand for IgnoreAddCommand { + type Container = MevaContainer; + + 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") + } + + 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); + + 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()?; + + println!( + "Pattern '{}' appended to {}", + pattern.green(), + result_path.to_utf8_string().cyan() + ); + + 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; + 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 00000000..542b5443 --- /dev/null +++ b/cli/src/commands/ignore/subcommands/check.rs @@ -0,0 +1,129 @@ +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use engine::{ + IgnoreOperations, IgnoreResult, IgnoreService, RepositoryLayout, + engine_container::MevaContainer, repositories::meva_repository_layout::MevaRepositoryLayout, +}; +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. +#[derive(Default)] +pub struct IgnoreCheckCommand; + +impl IgnoreCheckCommand { + const ARG_PATH: &'static str = "path"; + + 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 } => { + 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"); + } + } + } + } +} + +#[async_trait] +impl MevaCommand for IgnoreCheckCommand { + type Container = MevaContainer; + + 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), + ) + } + + 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(); + + 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()?; + + 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; + 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 00000000..579bbaa7 --- /dev/null +++ b/cli/src/commands/ignore/subcommands/edit.rs @@ -0,0 +1,79 @@ +use std::path::PathBuf; + +use async_trait::async_trait; +use clap::{ArgMatches, Command}; +use engine::{ + ConfigLoader, IgnoreOperations, IgnoreService, MevaConfigLoader, RepositoryLayout, + engine_container::MevaContainer, repositories::meva_repository_layout::MevaRepositoryLayout, +}; +use miette::IntoDiagnostic; +use shared::OpenInEditor; + +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 `editor.default` override in config or falling back to OS defaults. +#[derive(Default)] +pub struct IgnoreEditCommand; + +#[async_trait] +impl MevaCommand for IgnoreEditCommand { + type Container = MevaContainer; + + 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") + } + + 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()?; + let ignore_service = IgnoreService::new(layout.ignore_file_name()); + + let loader = MevaConfigLoader::default(); + let override_cmd = loader.get("editor.default", 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).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 = 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 new file mode 100644 index 00000000..f0b8d408 --- /dev/null +++ b/cli/src/commands/ignore/subcommands/remove.rs @@ -0,0 +1,94 @@ +use std::path::PathBuf; + +use async_trait::async_trait; +use clap::{ArgMatches, Command}; +use engine::{ + IgnoreOperations, IgnoreService, RepositoryLayout, engine_container::MevaContainer, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; +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. +#[derive(Default)] +pub struct IgnoreRemoveCommand; + +#[async_trait] +impl MevaCommand for IgnoreRemoveCommand { + type Container = MevaContainer; + + 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") + } + + 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); + + 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()?; + + 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; + 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 new file mode 100644 index 00000000..53ad4cbf --- /dev/null +++ b/cli/src/commands/init.rs @@ -0,0 +1,187 @@ +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}; + +use crate::commands::MevaCommand; + +/// Implements the `init` command for Meva DVCS. +/// +/// Initializes a new repository at a specified path, +/// optionally setting the initial branch name. +#[derive(Default)] +pub struct InitCommand; + +impl InitCommand { + /// Argument name for specifying the initial branch. + const ARG_INITIAL_BRANCH: &'static str = "initial-branch"; + + /// Argument name for specifying the repository path. + const ARG_PATH: &'static str = "path"; +} + +#[async_trait] +impl MevaCommand for InitCommand { + type Container = MevaContainer; + + 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_INITIAL_BRANCH) + .short('b') + .long(Self::ARG_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)) + .value_hint(ValueHint::FilePath) + .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. + /// + /// # 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. + 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(); + + let handler = container.init_handler().into_diagnostic()?; + let interceptor = container.plugins_interceptor().into_diagnostic()?; + + let request = Request { + working_dir: target.clone(), + initial_branch: branch.clone(), + }; + + let response = handler + .handle_init(request, &interceptor) + .into_diagnostic()?; + + println!( + "Repository initialized successfully at: {}", + response.repository_dir.to_string_lossy() + ); + + 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; + cmd.build_command().try_get_matches_from(args).unwrap() + } + + #[rstest] + fn test_command_name_about_version() { + let cmd = InitCommand; + 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; + 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_INITIAL_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_INITIAL_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_init_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_INITIAL_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/log.rs b/cli/src/commands/log.rs new file mode 100644 index 00000000..9f471ee2 --- /dev/null +++ b/cli/src/commands/log.rs @@ -0,0 +1,201 @@ +use crate::commands::MevaCommand; +use async_trait::async_trait; +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. +#[derive(Default)] +pub struct LogCommand; + +impl LogCommand { + /// 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) { + if response.entries.is_empty() { + println!("{}", "No commits found.".yellow()); + return; + } + + 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()); + } + } + } +} + +#[async_trait] +impl MevaCommand for LogCommand { + type Container = MevaContainer; + + 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. + 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()?; + + 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/commands/ls_files.rs b/cli/src/commands/ls_files.rs new file mode 100644 index 00000000..4548cf0a --- /dev/null +++ b/cli/src/commands/ls_files.rs @@ -0,0 +1,164 @@ +use async_trait::async_trait; +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. +#[derive(Default)] +pub struct LsFilesCommand; + +impl LsFilesCommand { + /// 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"; +} + +#[async_trait] +impl MevaCommand for LsFilesCommand { + type Container = MevaContainer; + + 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. + 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) { + 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; + 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/ls_tree.rs b/cli/src/commands/ls_tree.rs new file mode 100644 index 00000000..ab7cc05c --- /dev/null +++ b/cli/src/commands/ls_tree.rs @@ -0,0 +1,168 @@ +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; +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. +#[derive(Default)] +pub struct LsTreeCommand; + +impl LsTreeCommand { + /// 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"; +} + +#[async_trait] +impl MevaCommand for LsTreeCommand { + type Container = MevaContainer; + + 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. + 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); + 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/commands/merge.rs b/cli/src/commands/merge.rs new file mode 100644 index 00000000..0b989618 --- /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/cli/src/commands/meva_command.rs b/cli/src/commands/meva_command.rs new file mode 100644 index 00000000..6aa2235c --- /dev/null +++ b/cli/src/commands/meva_command.rs @@ -0,0 +1,62 @@ +use async_trait::async_trait; +use clap::{ArgMatches, Command}; +use engine::{EngineContainer, engine_container::MevaContainer}; +use miette::Result; + +/// A trait representing a top-level command in the Meva CLI. +#[async_trait] +pub trait MevaCommand: Send + Sync { + type Container: EngineContainer + Send + Sync; + + /// 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 { + let cmd = Command::new(self.name()) + .about(self.about()) + .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 the command logic. + /// + /// 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. + /// * `container`: The dependency injection container providing access to core engine features. + /// + /// # Returns + /// * `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>> { + Vec::new() + } +} diff --git a/cli/src/commands/plugins.rs b/cli/src/commands/plugins.rs new file mode 100644 index 00000000..f6f35615 --- /dev/null +++ b/cli/src/commands/plugins.rs @@ -0,0 +1,66 @@ +pub mod subcommands; + +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. +/// +/// 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; + +#[async_trait] +impl MevaCommand for PluginsCommand { + type Container = MevaContainer; + + 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), + ] + } + + async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> { + execute_multiple(matches, container, self.subcommands()).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + 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.rs b/cli/src/commands/plugins/subcommands.rs new file mode 100644 index 00000000..4a82e4e3 --- /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 00000000..c99fcfd3 --- /dev/null +++ b/cli/src/commands/plugins/subcommands/edit.rs @@ -0,0 +1,126 @@ +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use engine::{ + ConfigLoader, EngineContainer, MevaConfigLoader, + engine_container::MevaContainer, + handlers::plugins::{EditRequest, PluginsOperations}, +}; +use miette::IntoDiagnostic; +use plugins::{CommandType, ScopeType}; +use shared::OpenInEditor; + +use crate::{ + commands::MevaCommand, + extensions::{WithCommandPlugin, WithScope}, +}; + +#[derive(Default)] +pub struct PluginsEditCommand; + +impl PluginsEditCommand { + const ARG_ENABLE: &'static str = "enable"; + + const ARG_DISABLE: &'static str = "disable"; +} + +#[async_trait] +impl MevaCommand for PluginsEditCommand { + type Container = MevaContainer; + + 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"), + ) + } + + 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 scope_str = matches.get_one::(Command::ARG_SCOPE).unwrap(); + 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 = MevaConfigLoader::default(); + let override_cmd = loader.get("editor.default", 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; + 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 00000000..4720af8c --- /dev/null +++ b/cli/src/commands/plugins/subcommands/info.rs @@ -0,0 +1,87 @@ +use async_trait::async_trait; +use clap::{ArgMatches, Command}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::plugins::{InfoRequest, PluginsOperations}, +}; +use miette::IntoDiagnostic; +use plugins::{CommandType, ScopeType}; + +use crate::{ + commands::MevaCommand, + extensions::{WithCommandPlugin, WithScope}, +}; + +#[derive(Default)] +pub struct PluginsInfoCommand; + +#[async_trait] +impl MevaCommand for PluginsInfoCommand { + type Container = MevaContainer; + + 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") + } + + async fn execute( + &self, + matches: &ArgMatches, + container: &Self::Container, + ) -> miette::Result<()> { + let command_str = matches.get_one::(Command::ARG_COMMAND).unwrap(); + let scope_str = matches.get_one::(Command::ARG_SCOPE).unwrap(); + + 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; + 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 00000000..05b98bd5 --- /dev/null +++ b/cli/src/commands/plugins/subcommands/list.rs @@ -0,0 +1,127 @@ +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command, builder::PossibleValuesParser}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::plugins::{ListRequest, PluginsOperations}, +}; +use miette::IntoDiagnostic; +use plugins::{CommandType, EventType, ScopeType}; +use strum::VariantNames; + +use crate::{commands::MevaCommand, extensions::WithScope}; + +#[derive(Default)] +pub struct PluginsListCommand; + +impl PluginsListCommand { + const ARG_COMMAND: &'static str = "command"; + + const ARG_EVENT: &'static str = "event"; + + const ARG_DISABLED: &'static str = "disabled"; + + const ARG_ENABLED: &'static str = "enabled"; +} + +#[async_trait] +impl MevaCommand for PluginsListCommand { + type Container = MevaContainer; + + 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") + .value_parser(PossibleValuesParser::new(CommandType::VARIANTS)) + .help("Filter plugins by associated command"), + ) + .arg( + Arg::new(Self::ARG_EVENT) + .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") + .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), + ) + } + + 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 scope_str = matches.get_one::(Command::ARG_SCOPE).unwrap(); + + 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; + 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 00000000..6142831f --- /dev/null +++ b/cli/src/commands/plugins/subcommands/register.rs @@ -0,0 +1,178 @@ +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint, builder::PossibleValuesParser}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::plugins::{PluginsOperations, RegisterRequest}, +}; +use miette::IntoDiagnostic; +use owo_colors::OwoColorize; +use plugins::{CommandType, EventType, ScopeType}; +use shared::PathToString; +use std::path::PathBuf; +use strum::VariantNames; + +use crate::{ + commands::MevaCommand, + extensions::{WithCommandPlugin, WithFile, WithScope}, +}; + +#[derive(Default)] +pub struct PluginsRegisterCommand; + +impl PluginsRegisterCommand { + const ARG_PATH: &'static str = "path"; + + const ARG_DESCRIPTION: &'static str = "description"; + + const ARG_EVENT: &'static str = "event"; + + const ARG_ORDER: &'static str = "order"; + + const ARG_TIMEOUT: &'static str = "timeout"; + + const ARG_DISABLED: &'static str = "disabled"; + + const ARG_INTERPRETER: &'static str = "interpreter"; +} + +#[async_trait] +impl MevaCommand for PluginsRegisterCommand { + type Container = MevaContainer; + + 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() + .with_command_and_plugin_arg("Command type (kebab-case)", "Plugin name") + .arg( + Arg::new(Self::ARG_PATH) + .value_name("PATH") + .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") + .with_scope_arg("Scope of the plugin") + .arg( + Arg::new(Self::ARG_DESCRIPTION) + .short('d') + .long(Self::ARG_DESCRIPTION) + .value_name("DESCRIPTION") + .help("Plugin description"), + ) + .arg( + Arg::new(Self::ARG_EVENT) + .short('e') + .long(Self::ARG_EVENT) + .value_name("EVENT") + .default_value("post-execute") + .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") + .default_value("1") + .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 milliseconds (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')"), + ) + } + + async fn execute( + &self, + matches: &ArgMatches, + container: &Self::Container, + ) -> miette::Result<()> { + let command_str = matches.get_one::(Command::ARG_COMMAND).unwrap(); + let event_str = matches.get_one::(Self::ARG_EVENT).unwrap(); + 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::(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(), + timeout: matches.get_one::(Self::ARG_TIMEOUT).cloned(), + 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!".green().bold()); + + println!( + "Source code copied to: {}", + response.plugin_source_file.to_utf8_string().cyan() + ); + + println!( + "Metadata saved at: {}", + response.plugins_metadata_file.to_utf8_string().cyan() + ); + + 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; + 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 00000000..82e05941 --- /dev/null +++ b/cli/src/commands/plugins/subcommands/unregister.rs @@ -0,0 +1,98 @@ +use async_trait::async_trait; +use clap::{ArgMatches, Command}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::plugins::{PluginsOperations, UnregisterRequest}, +}; +use miette::IntoDiagnostic; +use owo_colors::OwoColorize; +use plugins::{CommandType, ScopeType}; + +use crate::{ + commands::MevaCommand, + extensions::{WithCommandPlugin, WithScope}, +}; + +#[derive(Default)] +pub struct PluginsUnregisterCommand; + +#[async_trait] +impl MevaCommand for PluginsUnregisterCommand { + type Container = MevaContainer; + + 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") + } + + 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 + .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!".green().bold()); + + println!( + "Removed entry from configuration file: {}", + response.plugins_metadata_file.display().cyan() + ); + + println!( + "Deleted source code file: {}", + response.plugin_source_file.display().cyan() + ); + + 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; + 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/pull.rs b/cli/src/commands/pull.rs new file mode 100644 index 00000000..71167a6e --- /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 00000000..dd9e54c0 --- /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.rs b/cli/src/commands/remote.rs new file mode 100644 index 00000000..2ed0f790 --- /dev/null +++ b/cli/src/commands/remote.rs @@ -0,0 +1,113 @@ +mod subcommands; + +use subcommands::*; + +use async_trait::async_trait; +use clap::{ArgMatches, Command}; +use miette::{IntoDiagnostic, Result}; + +use engine::{ + EngineContainer, engine_container::MevaContainer, handlers::remote::RemoteOperations, +}; + +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. +#[derive(Default)] +pub struct RemoteCommand; + +#[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); + 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) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + 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.rs b/cli/src/commands/remote/subcommands.rs new file mode 100644 index 00000000..bdba6f51 --- /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 00000000..4e43d93b --- /dev/null +++ b/cli/src/commands/remote/subcommands/add.rs @@ -0,0 +1,114 @@ +use std::path::PathBuf; + +use async_trait::async_trait; +use clap::{Arg, 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}; + +/// 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. +#[derive(Default)] +pub struct RemoteAddCommand; + +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] +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_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"), + ) + } + + /// 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<()> { + let name = matches + .get_one::(Command::ARG_NAME) + .unwrap() + .to_string(); + + let request = AddRequest { + name: name.clone(), + url: matches.get_one::(Self::ARG_URL).unwrap().clone(), + pub_key: matches + .get_one::(Self::ARG_SERVER_KEY) + .unwrap() + .clone(), + }; + + let handler = container.remote_handler().into_diagnostic()?; + + let response = handler.add(request).into_diagnostic()?; + + println!("{}", response.remote.display_verbose(&name)); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + 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 new file mode 100644 index 00000000..0c570ce6 --- /dev/null +++ b/cli/src/commands/remote/subcommands/get_url.rs @@ -0,0 +1,96 @@ +use async_trait::async_trait; +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}; + +/// 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. +#[derive(Default)] +pub struct RemoteGetUrlCommand; + +impl RemoteGetUrlCommand { + /// Argument name for the direction selection (fetch/push). + const ARG_DIRECTION: &'static str = "direction"; +} + +#[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_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"), + ) + } + + /// 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<()> { + 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(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + 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 new file mode 100644 index 00000000..61c0e238 --- /dev/null +++ b/cli/src/commands/remote/subcommands/remove.rs @@ -0,0 +1,78 @@ +use async_trait::async_trait; +use clap::{ArgMatches, Command}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::remote::{RemoteOperations, RemoveRequest}, +}; +use miette::IntoDiagnostic; + +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. +#[derive(Default)] +pub struct RemoteRemoveCommand; + +#[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<()> { + 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(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + 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 new file mode 100644 index 00000000..08b5aec3 --- /dev/null +++ b/cli/src/commands/remote/subcommands/rename.rs @@ -0,0 +1,108 @@ +use async_trait::async_trait; +use clap::{Arg, ArgMatches, Command}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::remote::{RemoteOperations, RenameRequest}, +}; +use miette::IntoDiagnostic; + +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. +#[derive(Default)] +pub struct RemoteRenameCommand; + +impl RemoteRenameCommand { + /// 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<()> { + 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(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + 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 new file mode 100644 index 00000000..f2674383 --- /dev/null +++ b/cli/src/commands/remote/subcommands/set_url.rs @@ -0,0 +1,129 @@ +use std::path::PathBuf; + +use async_trait::async_trait; +use clap::{Arg, ArgMatches, Command, ValueHint, 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}; + +/// 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. +#[derive(Default)] +pub struct RemoteSetUrlCommand; + +impl RemoteSetUrlCommand { + /// 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 specifying the server public key. + const ARG_NEW_SERVER_KEY: &'static str = "new-server-key"; +} + +#[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_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) + .short('d') + .value_name("DIRECTION") + .default_value("fetch") + .value_parser(PossibleValuesParser::new(RemoteDirection::VARIANTS)) + .help("Select which URL(s) to show"), + ) + } + + /// Executes the `remote set-url` command. + /// + /// Updates the configuration for the specified remote. + async fn execute( + &self, + matches: &ArgMatches, + container: &Self::Container, + ) -> miette::Result<()> { + 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(), + 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()?; + handler.set_url(request).into_diagnostic()?; + + println!("Remote URL changed successfully."); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + 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 new file mode 100644 index 00000000..cc6536dc --- /dev/null +++ b/cli/src/commands/remote/subcommands/show.rs @@ -0,0 +1,100 @@ +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + handlers::remote::{RemoteOperations, ShowRequest}, +}; +use miette::IntoDiagnostic; +use owo_colors::OwoColorize; + +use crate::{ + commands::MevaCommand, + extensions::{WithName, WithVerbose}, +}; + +/// 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. +#[derive(Default)] +pub struct RemoteShowCommand; + +impl RemoteShowCommand { + /// 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") + .with_verbose_arg("Enable verbose output") + .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<()> { + let name = matches.get_one::(Command::ARG_NAME).unwrap(); + + 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()?; + let response = handler.show(request).await.into_diagnostic()?; + + println!("* Remote {}", name.bold()); + 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 = 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 new file mode 100644 index 00000000..bec2131a --- /dev/null +++ b/cli/src/commands/restore.rs @@ -0,0 +1,121 @@ +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; +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. +#[derive(Default)] +pub struct RestoreCommand; + +impl RestoreCommand { + /// `--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"; +} + +#[async_trait] +impl MevaCommand for RestoreCommand { + type Container = MevaContainer; + + 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. + 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 + .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/commands/show.rs b/cli/src/commands/show.rs new file mode 100644 index 00000000..ed9f1da1 --- /dev/null +++ b/cli/src/commands/show.rs @@ -0,0 +1,227 @@ +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command, value_parser}; +use engine::{ + EngineContainer, + diff_builder::ChangeKind, + 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. +#[derive(Default)] +pub struct ShowCommand; + +impl ShowCommand { + /// 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(files) = &response.files { + for file in files { + file.display_full(); + } + } + } + + /// Prints only filenames. + fn display_name_only(&self, response: &Response) { + if let Some(files) = &response.files { + for file in files { + println!("{}", file.path().display()); + } + } + } + + /// Prints filenames with change kinds. + fn display_name_status(&self, response: &Response) { + if let Some(files) = &response.files { + for file in files { + println!( + "{}\t{}", + ChangeKind::from(&file.kind), + file.path().display() + ); + } + } + } + + /// Prints summary statistics. + fn display_stat(&self, response: &Response) { + if let Some(stat) = &response.stat { + println!("{stat}"); + } + } +} + +#[async_trait] +impl MevaCommand for ShowCommand { + type Container = MevaContainer; + + 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. + 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()?; + + let mode = if matches.get_flag(Self::ARG_PATCH) { + PatchMode::Patch + } else if matches.get_flag(Self::ARG_NO_PATCH) { + PatchMode::NoPatch + } else if matches.get_flag(Self::ARG_NAME_ONLY) { + PatchMode::NameOnly + } else if matches.get_flag(Self::ARG_NAME_STATUS) { + PatchMode::NameStatus + } else if matches.get_flag(Self::ARG_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; + 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 00000000..3ee3a435 --- /dev/null +++ b/cli/src/commands/status.rs @@ -0,0 +1,130 @@ +use async_trait::async_trait; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use engine::{EngineContainer, engine_container::MevaContainer, handlers::status::Request}; + +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. +#[derive(Default)] +pub struct StatusCommand; + +impl StatusCommand { + /// 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 ignored files in the output (`-i` / `--ignored`). + const ARG_IGNORED: &'static str = "ignored"; +} + +#[async_trait] +impl MevaCommand for StatusCommand { + type Container = MevaContainer; + + fn name(&self) -> &'static str { + "status" + } + + fn about(&self) -> &'static str { + "Display the current state of the working directory" + } + + 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) + .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_IGNORED) + .short('i') + .long(Self::ARG_IGNORED) + .action(ArgAction::SetTrue) + .help("Show ignored files"), + ) + } + + /// Executes the `status` command. + 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), + matches.get_flag(Self::ARG_NO_BRANCH), + matches.get_flag(Self::ARG_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()?; + + println!( + "{}", + response.render_status(Some(short_format), Some(show_branch)) + ); + + 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; + assert_eq!(cmd.name(), "status"); + assert_eq!( + cmd.about(), + "Display the current state of the working directory" + ); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/extensions.rs b/cli/src/extensions.rs new file mode 100644 index 00000000..da95e640 --- /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 00000000..37e1a44d --- /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 00000000..6eee4ff5 --- /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 00000000..6e6946cb --- /dev/null +++ b/cli/src/extensions/command.rs @@ -0,0 +1,17 @@ +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_command_and_plugin.rs b/cli/src/extensions/command/with_command_and_plugin.rs new file mode 100644 index 00000000..f4e0327d --- /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 new file mode 100644 index 00000000..72bc96fc --- /dev/null +++ b/cli/src/extensions/command/with_file.rs @@ -0,0 +1,89 @@ +use std::path::PathBuf; + +use clap::{Arg, Command, ValueHint}; + +/// 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") + .value_parser(clap::value_parser!(PathBuf)) + .value_hint(ValueHint::FilePath) + .help(file_help), + ) + } +} + +#[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 new file mode 100644 index 00000000..a0a35dbb --- /dev/null +++ b/cli/src/extensions/command/with_key.rs @@ -0,0 +1,76 @@ +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), + ) + } +} + +#[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 new file mode 100644 index 00000000..25f99015 --- /dev/null +++ b/cli/src/extensions/command/with_locations.rs @@ -0,0 +1,151 @@ +use std::path::PathBuf; + +use clap::{Arg, ArgAction, ArgGroup, Command, ValueHint}; + +/// 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. + /// + /// # Arguments + /// + /// * `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_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]), + ) + .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::{fixture, rstest}; + + #[fixture] + fn cmd() -> Command { + Command::new("cmd").with_location_args("global help", "local help", "file help") + } + + #[rstest] + 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(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(cmd: Command) { + let path = "config.toml"; + let matches = cmd + .try_get_matches_from(vec!["cmd", "--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(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(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(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_name.rs b/cli/src/extensions/command/with_name.rs new file mode 100644 index 00000000..ce4f6425 --- /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_pattern.rs b/cli/src/extensions/command/with_pattern.rs new file mode 100644 index 00000000..42982b04 --- /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/command/with_scope.rs b/cli/src/extensions/command/with_scope.rs new file mode 100644 index 00000000..097e384e --- /dev/null +++ b/cli/src/extensions/command/with_scope.rs @@ -0,0 +1,90 @@ +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) + .default_value("local") + // 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_eq!( + matches.get_one::("scope"), + Some(&"local".to_string()) + ); + } +} diff --git a/cli/src/extensions/command/with_verbose.rs b/cli/src/extensions/command/with_verbose.rs new file mode 100644 index 00000000..8bde929a --- /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 e7a11a96..2d2c76ca 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,3 +1,26 @@ -fn main() { - println!("Hello, world!"); +mod commands; +mod extensions; +mod meva_cli; + +use miette::Result; + +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 container = MevaContainer; + let mut cli = MevaCli::new(container); + + let commands = collect_commands(); + + for command in commands { + cli.add_command(command); + } + + cli.run().await?; + Ok(()) } diff --git a/cli/src/meva_cli.rs b/cli/src/meva_cli.rs new file mode 100644 index 00000000..ed2444cb --- /dev/null +++ b/cli/src/meva_cli.rs @@ -0,0 +1,94 @@ +use std::collections::HashMap; + +use clap::{Command, error::ErrorKind}; +use engine::engine_container::MevaContainer; +use miette::{IntoDiagnostic, Result, WrapErr, miette}; + +use crate::commands::MevaCommand; + +/// The main entry point for the Meva CLI application. +/// +/// 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 providing access to core engine services. + container: MevaContainer, +} + +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, + } + } + + /// Registers a command with the CLI. + /// + /// The command is stored in an internal registry keyed by its name (as returned by `command.name()`). + /// + /// # Arguments + /// * `command`: A boxed instance implementing the `MevaCommand` trait. + pub fn add_command(&mut self, command: Box>) { + self.commands.insert(command.name(), command); + } + + /// Constructs the `clap` argument parser configuration. + /// + /// 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`] builder ready for argument parsing. + 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.values() { + cli = cli.subcommand(command.build_command()); + } + + cli + } + + /// 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(()) + } + _ => Err(miette!("Failed to parse command-line arguments:\n{}", err)), + }; + } + }; + + if let Some((name, sub_matches)) = matches.subcommand() + && let Some(cmd) = self.commands.get(name) + { + return cmd + .execute(sub_matches, &self.container) + .await + .wrap_err_with(|| format!("Error running `{name}` command")); + } + + Ok(()) + } +} diff --git a/docs/images/distributed-architecture.png b/docs/images/distributed-architecture.png new file mode 100644 index 00000000..742b6a8c Binary files /dev/null and b/docs/images/distributed-architecture.png differ diff --git a/docs/images/feather.png b/docs/images/feather.png new file mode 100644 index 00000000..80e11520 Binary files /dev/null and b/docs/images/feather.png differ diff --git a/docs/images/gui/gui-branch.png b/docs/images/gui/gui-branch.png new file mode 100644 index 00000000..d8bbadd6 Binary files /dev/null and b/docs/images/gui/gui-branch.png differ diff --git a/docs/images/gui/gui-clone-repo.png b/docs/images/gui/gui-clone-repo.png new file mode 100644 index 00000000..75bd33fa Binary files /dev/null and b/docs/images/gui/gui-clone-repo.png differ diff --git a/docs/images/gui/gui-commit-modal.png b/docs/images/gui/gui-commit-modal.png new file mode 100644 index 00000000..11d066f9 Binary files /dev/null and b/docs/images/gui/gui-commit-modal.png differ diff --git a/docs/images/gui/gui-config-error.png b/docs/images/gui/gui-config-error.png new file mode 100644 index 00000000..4b4dbd63 Binary files /dev/null and b/docs/images/gui/gui-config-error.png differ diff --git a/docs/images/gui/gui-conflict-editor.png b/docs/images/gui/gui-conflict-editor.png new file mode 100644 index 00000000..e69048ee Binary files /dev/null and b/docs/images/gui/gui-conflict-editor.png differ diff --git a/docs/images/gui/gui-conflict-mark.png b/docs/images/gui/gui-conflict-mark.png new file mode 100644 index 00000000..8f1c64b9 Binary files /dev/null and b/docs/images/gui/gui-conflict-mark.png differ diff --git a/docs/images/gui/gui-conflict-menu.png b/docs/images/gui/gui-conflict-menu.png new file mode 100644 index 00000000..8517582a Binary files /dev/null and b/docs/images/gui/gui-conflict-menu.png differ diff --git a/docs/images/gui/gui-conflict-unmerged.png b/docs/images/gui/gui-conflict-unmerged.png new file mode 100644 index 00000000..54d73754 Binary files /dev/null and b/docs/images/gui/gui-conflict-unmerged.png differ diff --git a/docs/images/gui/gui-context-menu.png b/docs/images/gui/gui-context-menu.png new file mode 100644 index 00000000..92bd2580 Binary files /dev/null and b/docs/images/gui/gui-context-menu.png differ diff --git a/docs/images/gui/gui-create-repo.png b/docs/images/gui/gui-create-repo.png new file mode 100644 index 00000000..00efe31f Binary files /dev/null and b/docs/images/gui/gui-create-repo.png differ diff --git a/docs/images/gui/gui-dashboard.png b/docs/images/gui/gui-dashboard.png new file mode 100644 index 00000000..a5c87ca7 Binary files /dev/null and b/docs/images/gui/gui-dashboard.png differ diff --git a/docs/images/gui/gui-diff.png b/docs/images/gui/gui-diff.png new file mode 100644 index 00000000..09e1cf2c Binary files /dev/null and b/docs/images/gui/gui-diff.png differ diff --git a/docs/images/gui/gui-history.png b/docs/images/gui/gui-history.png new file mode 100644 index 00000000..173a0909 Binary files /dev/null and b/docs/images/gui/gui-history.png differ diff --git a/docs/images/gui/gui-menu-file.png b/docs/images/gui/gui-menu-file.png new file mode 100644 index 00000000..011d0eae Binary files /dev/null and b/docs/images/gui/gui-menu-file.png differ diff --git a/docs/images/gui/gui-open-error.png b/docs/images/gui/gui-open-error.png new file mode 100644 index 00000000..03c411c0 Binary files /dev/null and b/docs/images/gui/gui-open-error.png differ diff --git a/docs/images/gui/gui-plugin-edit.png b/docs/images/gui/gui-plugin-edit.png new file mode 100644 index 00000000..6847beb4 Binary files /dev/null and b/docs/images/gui/gui-plugin-edit.png differ diff --git a/docs/images/gui/gui-plugin-register.png b/docs/images/gui/gui-plugin-register.png new file mode 100644 index 00000000..6442ae5f Binary files /dev/null and b/docs/images/gui/gui-plugin-register.png differ 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 00000000..6484898a Binary files /dev/null and b/docs/images/gui/gui-plugins-list-disabled.png differ 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 00000000..64f4ee6b Binary files /dev/null and b/docs/images/gui/gui-plugins-list-enabled.png differ diff --git a/docs/images/gui/gui-settings-view.png b/docs/images/gui/gui-settings-view.png new file mode 100644 index 00000000..da63684d Binary files /dev/null and b/docs/images/gui/gui-settings-view.png differ diff --git a/docs/images/gui/gui-staging.png b/docs/images/gui/gui-staging.png new file mode 100644 index 00000000..bf4a967b Binary files /dev/null and b/docs/images/gui/gui-staging.png differ diff --git a/docs/images/gui/gui-status-bar.png b/docs/images/gui/gui-status-bar.png new file mode 100644 index 00000000..4d23efe4 Binary files /dev/null and b/docs/images/gui/gui-status-bar.png differ diff --git a/docs/images/gui/gui-sync-actions.png b/docs/images/gui/gui-sync-actions.png new file mode 100644 index 00000000..97f6cdda Binary files /dev/null and b/docs/images/gui/gui-sync-actions.png differ diff --git a/docs/images/receive-pack.png b/docs/images/receive-pack.png new file mode 100644 index 00000000..7285452b Binary files /dev/null and b/docs/images/receive-pack.png differ diff --git a/docs/images/upload-pack.png b/docs/images/upload-pack.png new file mode 100644 index 00000000..b6c973bc Binary files /dev/null and b/docs/images/upload-pack.png differ diff --git a/docs/plugins/examples/bash/infinite_counter.sh b/docs/plugins/examples/bash/infinite_counter.sh new file mode 100644 index 00000000..77a53814 --- /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 00000000..133e23c1 --- /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 00000000..884e47d4 --- /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 00000000..201cc388 --- /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/docs/plugins/examples/js/infinite_counter.js b/docs/plugins/examples/js/infinite_counter.js new file mode 100644 index 00000000..234918b6 --- /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 00000000..0ecf22e4 --- /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 00000000..a4d3ad13 --- /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 00000000..e4f5ca9f --- /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 00000000..7fe86f16 --- /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 00000000..bc4a8db3 --- /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 00000000..ca73a86e --- /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 00000000..c45befae --- /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 00000000..8dcea94c --- /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 00000000..6481eb90 --- /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 00000000..39b5cb27 --- /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 00000000..5f4c4e0b --- /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 00000000..8a764b77 --- /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/Cargo.toml b/engine/Cargo.toml index d29147f9..29c4dd21 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -6,6 +6,38 @@ authors.workspace = true [dependencies] shared = { path = "../shared" } +plugins = { path = "../plugins" } +tempfile.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +regex.workspace = true +toml.workspace = true +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" +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 +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 +itertools.workspace = true +strum.workspace = true +strum_macros.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/engine/src/branch_manager.rs b/engine/src/branch_manager.rs new file mode 100644 index 00000000..4250a7ec --- /dev/null +++ b/engine/src/branch_manager.rs @@ -0,0 +1,189 @@ +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. +/// +/// 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 + /// + /// * `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, commit: 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>; + + /// 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 + /// + /// * `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 00000000..1e583cf6 --- /dev/null +++ b/engine/src/branch_manager/branch.rs @@ -0,0 +1,33 @@ +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, Display)] +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 00000000..49083931 --- /dev/null +++ b/engine/src/branch_manager/branch_info.rs @@ -0,0 +1,58 @@ +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. + /// + /// 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, + + /// The type of the branch. + branch_type: BranchType, + }, + + /// 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, + + /// 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 new file mode 100644 index 00000000..2d074e95 --- /dev/null +++ b/engine/src/branch_manager/meva_branch_manager.rs @@ -0,0 +1,444 @@ +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, ConfigDocument, ObjectStorage, RefManager}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +/// `MevaBranchManager` is a concrete implementation of [`BranchManager`] +/// 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`]. + /// + /// # Arguments + /// + /// * `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, + } + } + + /// 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(()) + } + + /// 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 { + 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) + } + + 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) + } + + 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), + }) + } + + 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) + } + } + + 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)?; + 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, + branch_type: BranchType| + -> 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, + branch_type, + }) + } else { + Ok(BranchInfo::NameOnly { + name: branch_name, + branch_type, + }) + } + }; + + let local_iter = local_branches + .into_iter() + .map(|entry| process_entry(entry, &local_prefix, BranchType::Local)); + + let remote_iter = remote_branches + .into_iter() + .map(|entry| process_entry(entry, &remote_prefix, BranchType::Remote)); + + 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 00000000..550f7bb2 --- /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/commit_builder.rs b/engine/src/commit_builder.rs new file mode 100644 index 00000000..278a06dc --- /dev/null +++ b/engine/src/commit_builder.rs @@ -0,0 +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 new file mode 100644 index 00000000..d63c09db --- /dev/null +++ b/engine/src/commit_builder/meva_commit_builder.rs @@ -0,0 +1,216 @@ +use crate::CommitBuilder; +use crate::ObjectStorage; +use crate::errors::{EngineError, EngineResult, NodeError, PathError, TreeError}; +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. +/// +/// 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 { + /// The object storage backend responsible for persisting objects (trees, blobs, commits). + object_storage: Arc, + + /// The index providing the list of tracked files and their object hashes. + index: Arc, +} + +/// Represents the value stored in a file node within the constructed commit tree. +/// +/// 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 MevaCommitBuilder { + /// Creates a new [`MevaCommitBuilder`]. + /// + /// # Arguments + /// * `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 { + object_storage, + index, + } + } + + /// 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) -> 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) + } + + /// 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, + ) -> 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_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, tree_value.mode, tree_value.hash)); + } else { + let hash = self.store_tree_recursively(tree, node_id)?; + tree_entries.push(TreeEntry::tree(name, hash)); + } + } + + 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 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(); + 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 node_val = match components.len() { + 1 => Some(TreeValue { + hash: entry.sha1.clone(), + mode: entry.mode, + }), + _ => None, + }; + let node = Node::new(first_key, node_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 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, node_val); + tree.add_node(node, Some(&parent_key)) + .map_err(|e| TreeError::OperationFailed { + message: e.to_string(), + })?; + } + } + + 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 new file mode 100644 index 00000000..9c74b2f0 --- /dev/null +++ b/engine/src/config.rs @@ -0,0 +1,9 @@ +pub mod config_document; +pub mod config_document_operations; +pub mod config_loader; +pub mod config_location; + +pub use config_document::ConfigDocument; +pub use config_document_operations::ConfigDocumentOperations; +pub use config_loader::{ConfigLoader, ConfigLoaderExtensions, MevaConfigLoader}; +pub use config_location::ConfigLocation; diff --git a/engine/src/config/config_document.rs b/engine/src/config/config_document.rs new file mode 100644 index 00000000..29cbff65 --- /dev/null +++ b/engine/src/config/config_document.rs @@ -0,0 +1,453 @@ +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +use toml_edit::{Datetime, DocumentMut, Item, Table, Value, value}; + +use crate::{ + config::ConfigDocumentOperations, + 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 or it 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 + } + } + + /// 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 { + 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 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); + + 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; + + 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); + + 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() { + 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()); + } + } + + 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 + } + + 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 new file mode 100644 index 00000000..63479a8f --- /dev/null +++ b/engine/src/config/config_document_operations.rs @@ -0,0 +1,83 @@ +use std::collections::HashMap; + +use crate::errors::EngineResult; + +/// Defines operations for accessing and modifying configuration values +pub trait ConfigDocumentOperations { + /// 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; + + /// 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 + /// + /// * `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<()>; + + /// 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 + /// + /// * `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)>; + + /// 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 new file mode 100644 index 00000000..24f009d3 --- /dev/null +++ b/engine/src/config/config_loader.rs @@ -0,0 +1,222 @@ +use std::{fmt::Debug, io::Write, path::Path, str::FromStr}; + +use shared::fs::create_file_with_dirs; + +use crate::{ + ConfigLocation, + config::{ConfigDocument, ConfigDocumentOperations}, + errors::{ConfigError, EngineError, EngineResult}, +}; + +pub trait ConfigLoader: Send + Sync + Debug { + /// 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, path: Option<&Path>) -> 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). +#[derive(Debug)] +pub struct MevaConfigLoader { + /// Ordered list of locations to search for configuration files. + locations: Vec, +} + +impl Default for MevaConfigLoader { + /// Creates a loader with the default search order. + fn default() -> Self { + Self { + locations: vec![ConfigLocation::Local, ConfigLocation::Global], + } + } +} + +impl MevaConfigLoader { + /// Creates a loader with a custom list of locations. + /// + /// # Arguments + /// + /// * `locations` - A vector of `ConfigLocation` enums defining the search order. + #[allow(dead_code)] + fn with_locations(locations: Vec) -> Self { + Self { locations } + } + + /// 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), + } + } +} + +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 { + match self.try_load_from(loc, key_path, None) { + Ok(val) => { + return Ok(val); + } + Err(err) => match err { + EngineError::Config(ConfigError::KeyNotFound { .. }) => { + last_key_not_found = Some(err); + } + EngineError::Config(ConfigError::ConfigNotFound { .. }) => { + continue; + } + other => { + return Err(other); + } + }, + } + } + + 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(), + }) + })), + } + } + + 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(()) + } + + 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 { + config_file.write_all(self.get_default_global_config().as_bytes())?; + } + + Ok(()) + } + + 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", + "\n" + ); + + default_config + } + + 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", + "# signing_key = \"path/to/your/signing/key\"\n", + "\n", + "# [editor]\n", + "# default = \"vim\"\n", + "\n", + "[plugins]\n", + "enabled = false\n", + "collect_logs = false\n", + "\n" + ); + + 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/config/config_location.rs b/engine/src/config/config_location.rs new file mode 100644 index 00000000..050fd8ef --- /dev/null +++ b/engine/src/config/config_location.rs @@ -0,0 +1,110 @@ +use std::path::{Path, PathBuf}; + +use shared::UpwardSearch; +use strum_macros::{Display, EnumIter}; + +use crate::{ + RepositoryLayout, + errors::{ConfigError, EngineResult}, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; + +/// Defines where to load configuration from: global, local repository, or a specific file. +#[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 + Local, + + /// Explicit config file path provided by user + File(PathBuf), +} + +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 { + 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 { + let layout = MevaRepositoryLayout::from_env()?; + match self { + ConfigLocation::Global => { + Self::global_path(layout.repository_dir_name(), layout.config_file_name()) + } + ConfigLocation::Local => { + Self::local_path(layout.repository_dir_name(), layout.config_file_name()) + } + ConfigLocation::File(path) => Ok(path.to_path_buf()), + } + } + + /// Compute the global configuration path in the OS config directory. + /// + /// # 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(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(repository_dir).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/diff_builder.rs b/engine/src/diff_builder.rs new file mode 100644 index 00000000..bf862169 --- /dev/null +++ b/engine/src/diff_builder.rs @@ -0,0 +1,210 @@ +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, FileDiffStat, 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 { + /// 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, + to: &Revision, + mode: &DiffMode, + 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, + mode: &DiffMode, + 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, + mode: &DiffMode, + 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, + mode: &DiffMode, + 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, + paths: Option<&[PathBuf]>, + index_map: Option>, + 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, + to_objects: &HashMap, + 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, + to_objects: &'b HashMap, + ) -> ( + Vec<&'b ObjectEntry>, + Vec<&'b ObjectEntry>, + 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>; + + /// 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>; +} diff --git a/engine/src/diff_builder/meva_diff_builder.rs b/engine/src/diff_builder/meva_diff_builder.rs new file mode 100644 index 00000000..596f42e0 --- /dev/null +++ b/engine/src/diff_builder/meva_diff_builder.rs @@ -0,0 +1,814 @@ +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::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> { + /// 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 MevaDiffBuilder { + /// The object storage service for accessing repository objects. + object_storage: Arc, + /// Resolver for parsing revision strings (e.g., "HEAD", "main", SHA hashes) into commit hashes. + revision_resolver: Arc, + /// The staging area (index) of the repository. + index: Arc, + /// Represents the working directory, providing access to its files. + pub working_dir: Arc, +} + +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 + /// + /// * `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, + revision_resolver, + working_dir, + index, + }) + } + + /// 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 + } + + /// 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 { + 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) + } + + 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)) + } + + 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)) + } + + 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) + } + + fn diff_index_working_dir( + &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 = match index_map { + Some(map) => map, + None => self.index.get_entries_map(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_map { + 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) => { + if !FileChange::is_modified_heuristic( + entry.file_size, + &entry.mtime, + index_entry.file_size, + &index_entry.mtime, + ) { + 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( + 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(), + )), + }, + } + } + } + } + + for (path, entry) in &index_map { + if !working_map.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)) + } + + 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) + } + + 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(), + } + } + + 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(), + } + } + + 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(), + } + } + + 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_builder/models.rs b/engine/src/diff_builder/models.rs new file mode 100644 index 00000000..68273ec4 --- /dev/null +++ b/engine/src/diff_builder/models.rs @@ -0,0 +1,19 @@ +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 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 new file mode 100644 index 00000000..a6cd22f4 --- /dev/null +++ b/engine/src/diff_builder/models/change_kind.rs @@ -0,0 +1,113 @@ +use std::fmt::Display; + +use owo_colors::OwoColorize; +use serde::{Deserialize, Serialize}; + +use crate::diff_builder::FileChangeKind; + +/// 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 is in a merge conflict (unmerged state). + /// + /// Displayed as `'U'`. + 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 { + 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_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`. + fn from(value: &FileChangeKind) -> Self { + match value { + FileChangeKind::Added { .. } => Self::Added, + FileChangeKind::Deleted { .. } => Self::Deleted, + FileChangeKind::Modified { .. } => Self::Modified, + } + } +} + +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/diff_mode.rs b/engine/src/diff_builder/models/diff_mode.rs new file mode 100644 index 00000000..2ddb3b4e --- /dev/null +++ b/engine/src/diff_builder/models/diff_mode.rs @@ -0,0 +1,25 @@ +use crate::handlers::show::PatchMode; + +/// 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, +} + +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_builder/models/diff_stat.rs b/engine/src/diff_builder/models/diff_stat.rs new file mode 100644 index 00000000..09ce6324 --- /dev/null +++ b/engine/src/diff_builder/models/diff_stat.rs @@ -0,0 +1,134 @@ +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; + +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 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", + }; + + let mut result = format!( + "{} {} changed", + self.files_changed.to_string().yellow().bold(), + files_str + ); + + if self.insertions > 0 { + let insertions_str = match self.insertions { + 1 => "insertion", + _ => "insertions", + }; + result.push_str(&format!( + ", {} {}(+)", + self.insertions.green().bold(), + insertions_str + )); + } + + if self.deletions > 0 { + let deletions_str = match self.deletions { + 1 => "deletion", + _ => "deletions", + }; + 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 { + 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.fmt(max_path_len))?; + } + + writeln!(f, "{}", self.summary_string()) + } +} + +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_builder/models/file_change.rs b/engine/src/diff_builder/models/file_change.rs new file mode 100644 index 00000000..f18a2ee5 --- /dev/null +++ b/engine/src/diff_builder/models/file_change.rs @@ -0,0 +1,213 @@ +use std::{fmt::Display, path::PathBuf}; + +use chrono::{DateTime, Utc}; +use owo_colors::OwoColorize; +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 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".bright_black(); + let dev_null = PathBuf::from("dev").join("null"); + let index = "index"; + let pluses = "+++".green(); + let minuses = "---".red(); + 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} {:o}", mode.bright_black())?; + } + if let Some(hash) = hash { + writeln!( + f, + "{index} {empty_hash}..{}", + &hash[..7].to_string().bright_black() + )?; + } + writeln!(f, "{minuses} {}", dev_null.to_utf8_string().red())?; + writeln!( + f, + "{pluses} {}", + b_dir.join(new_path).to_utf8_string().green() + )?; + } + + 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].to_string().bright_black() + )?; + } + writeln!( + f, + "{minuses} {}", + a_dir.join(old_path).to_utf8_string().red() + )?; + writeln!(f, "{pluses} {}", dev_null.to_utf8_string().green())?; + } + + 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].to_string().bright_black(), + &n[..7].to_string().bright_black(), + )?; + } else { + 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().red() + )?; + writeln!( + f, + "{pluses} {}", + b_dir.join(new_path).to_utf8_string().green() + )?; + } + } + + Ok(()) + } +} diff --git a/engine/src/diff_builder/models/file_change_kind.rs b/engine/src/diff_builder/models/file_change_kind.rs new file mode 100644 index 00000000..f941b7e7 --- /dev/null +++ b/engine/src/diff_builder/models/file_change_kind.rs @@ -0,0 +1,127 @@ +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, + } + } + + /// 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/file_diff_stat.rs b/engine/src/diff_builder/models/file_diff_stat.rs new file mode 100644 index 00000000..e41d42be --- /dev/null +++ b/engine/src/diff_builder/models/file_diff_stat.rs @@ -0,0 +1,89 @@ +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)] +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) + } + + /// Formats the file diff stat with colored insertions/deletions bar. + pub fn fmt(&self, path_width: usize) -> String { + let changes = self.total_changes(); + 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}"); + + format!( + " {:3} {bar}", + self.path.to_utf8_string(), + ) + } +} diff --git a/engine/src/diff_builder/models/hunk.rs b/engine/src/diff_builder/models/hunk.rs new file mode 100644 index 00000000..7c9c63ba --- /dev/null +++ b/engine/src/diff_builder/models/hunk.rs @@ -0,0 +1,108 @@ +use std::fmt::Display; + +use owo_colors::OwoColorize; +use serde::{Deserialize, Serialize}; + +/// Represents a single *hunk* within a unified diff. +/// +/// A hunk defines a contiguous block of changes between two versions of a file. +/// It includes metadata about which lines were affected in both the old and new files, +/// as well as the list of changed lines themselves (`HunkLine`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Hunk { + /// The starting line number in the old file. + pub old_start: usize, + + /// The number of lines in the old version affected by this hunk. + pub old_lines: usize, + + /// The starting line number in the new file. + pub new_start: usize, + + /// The number of lines in the new version affected by this hunk. + pub new_lines: usize, + + /// The list of individual lines that compose this hunk. + /// + /// Each line is categorized as added (`+`), removed (`-`), or context (` `). + pub lines: Vec, +} + +impl Display for Hunk { + /// Formats the hunk in the unified diff style + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + 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(()) + } +} + +/// 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 { + 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) + } +} + +/// 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 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 { + let colored = match self { + HunkLineType::Context => ' '.to_string(), + HunkLineType::Addition => '+'.green().to_string(), + HunkLineType::Deletion => '-'.red().to_string(), + }; + write!(f, "{colored}") + } +} diff --git a/engine/src/diff_builder/models/revision_range.rs b/engine/src/diff_builder/models/revision_range.rs new file mode 100644 index 00000000..99278089 --- /dev/null +++ b/engine/src/diff_builder/models/revision_range.rs @@ -0,0 +1,61 @@ +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. + fn default() -> Self { + Self { + from: RevisionSide::Index, + to: RevisionSide::WorkingDir, + } + } +} + +impl RevisionRange { + /// Creates a `RevisionRange` between two specific revisions (snapshots). + /// + /// # 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. + /// If `false`, compares it against the working directory. + 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_builder/models/revision_side.rs b/engine/src/diff_builder/models/revision_side.rs new file mode 100644 index 00000000..75ba4c24 --- /dev/null +++ b/engine/src/diff_builder/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 new file mode 100644 index 00000000..30962944 --- /dev/null +++ b/engine/src/engine_container.rs @@ -0,0 +1,505 @@ +use std::sync::{Arc, RwLock}; + +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, 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; +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}; + +/// 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; + + /// 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 ls-tree command + fn ls_tree_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, dry_run: bool) -> EngineResult; + + /// Return the handler responsible for log command + fn log_handler(&self) -> EngineResult; + + /// 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; + + /// 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; + + /// 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; +} + +/// Concrete implementation of `EngineContainer` for Meva. +#[derive(Debug, Default)] +pub struct MevaContainer; + +impl MevaContainer { + /// Returns the Meva-specific plugin layout. + fn plugins_layout(&self) -> EngineResult> { + Ok(Arc::new(MevaPluginsLayout)) + } + + /// Returns the Meva-specific repository layout. + fn repository_layout_env(&self) -> EngineResult> { + 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 { + discovery: Arc::new(MevaPluginsDiscovery { + layout: self.plugins_layout()?, + }), + })) + } +} + +impl EngineContainer for MevaContainer { + 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 { + let layout = self.repository_layout_env()?; + 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) + } + + 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) + } + + fn add_handler(&self) -> EngineResult { + 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( + working_dir, + object_storage, + None, + )?)); + + let handler = AddHandler::new(repo_layout, index); + + Ok(handler) + } + + fn ls_files_handler(&self) -> EngineResult { + 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)?); + + let handler = LsFilesHandler::new(repo_layout, index); + + Ok(handler) + } + + fn ls_tree_handler(&self) -> EngineResult { + 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( + 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 { + 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.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 commit_history_walker = Arc::new(MevaCommitHistoryWalker::new(object_storage.clone())); + + let handler = StatusHandler::new( + working_dir, + index, + refs_manager, + object_storage, + diff_builder, + commit_history_walker, + ); + + Ok(handler) + } + + 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())); + 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 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 dry_run { + true => { + 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)), + }; + 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 { + 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( + 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 = 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( + 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 = 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( + 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 { + 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( + 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 restore_manager = Arc::new(MevaRestoreManager::new( + index, + working_dir, + object_storage, + commit_tree_walker, + )); + + let handler = RestoreHandler::new(restore_manager, revision_resolver); + + 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) + } + + 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)); + let remotes_manager = Arc::new(MevaRemotesManager); + + 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)); + let remotes_manager = Arc::new(MevaRemotesManager); + let config_loader = Arc::new(MevaConfigLoader::default()); + + Ok(RemoteHandler::new( + remotes_manager, + config_loader, + 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.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 = CheckoutHandler::new( + index2, + refs_manager, + diff_builder, + restore_manager, + revision_resolver, + ); + + 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.rs b/engine/src/errors.rs new file mode 100644 index 00000000..422b59f8 --- /dev/null +++ b/engine/src/errors.rs @@ -0,0 +1,35 @@ +pub mod branch_error; +pub mod checkout_error; +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 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 checkout_error::CheckoutError; +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 ref_entry_error::RefEntryError; +pub use repository_error::RepositoryError; +pub use revision_error::RevisionError; +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/branch_error.rs b/engine/src/errors/branch_error.rs new file mode 100644 index 00000000..a8da471e --- /dev/null +++ b/engine/src/errors/branch_error.rs @@ -0,0 +1,74 @@ +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 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 + /// 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), + + /// 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 00000000..5e40433c --- /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/clone_error.rs b/engine/src/errors/clone_error.rs new file mode 100644 index 00000000..f45a8345 --- /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/commit_error.rs b/engine/src/errors/commit_error.rs new file mode 100644 index 00000000..d5656f69 --- /dev/null +++ b/engine/src/errors/commit_error.rs @@ -0,0 +1,17 @@ +use thiserror::Error; + +/// Errors that can occur when working with commits. +/// +/// 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/errors/config_error.rs b/engine/src/errors/config_error.rs new file mode 100644 index 00000000..bde347e7 --- /dev/null +++ b/engine/src/errors/config_error.rs @@ -0,0 +1,62 @@ +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, + + /// 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/errors/engine_error.rs b/engine/src/errors/engine_error.rs new file mode 100644 index 00000000..51726396 --- /dev/null +++ b/engine/src/errors/engine_error.rs @@ -0,0 +1,163 @@ +use bincode::error::{DecodeError, EncodeError}; +use plugins::PluginError; +use std::path::StripPrefixError; +use std::{io, string::FromUtf8Error}; +use thiserror::Error; + +use crate::errors::{ + BranchError, CheckoutError, CloneError, CommitError, ConfigError, IgnoreError, IndexError, + InitError, NetworkError, PathError, RefEntryError, RepositoryError, RevisionError, TreeError, + UnpackError, +}; + +/// 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 operating-system I/O error (read, write, fs-metadata, etc.). + /// 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), + + /// 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), + + /// An error originating from ignore-file processing. + #[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), + + /// 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). + /// Automatically converted from [`std::path::StripPrefixError`]. + #[error(transparent)] + StripPrefix(#[from] StripPrefixError), + + /// JSON serialization or deserialization error. + /// Automatically converted from [`serde_json::Error`]. + #[error(transparent)] + Serde(#[from] serde_json::Error), + + /// 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), + + /// 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`-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 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 revision resolver. + /// + /// This allows propagating revision resolution errors through the engine + /// while keeping the original context and error message. + #[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), + + /// 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), + + /// 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}")] + 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 00000000..1a55767d --- /dev/null +++ b/engine/src/errors/ignore_error.rs @@ -0,0 +1,19 @@ +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 }, + + /// 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 00000000..922db688 --- /dev/null +++ b/engine/src/errors/index_error.rs @@ -0,0 +1,39 @@ +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. 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. + /// + /// 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, + }, + + /// 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/errors/init_error.rs b/engine/src/errors/init_error.rs new file mode 100644 index 00000000..364d35fe --- /dev/null +++ b/engine/src/errors/init_error.rs @@ -0,0 +1,19 @@ +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 { + /// 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 { + /// The invalid directory path + path: String, + }, +} diff --git a/engine/src/errors/network_error.rs b/engine/src/errors/network_error.rs new file mode 100644 index 00000000..27357816 --- /dev/null +++ b/engine/src/errors/network_error.rs @@ -0,0 +1,111 @@ +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 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/errors/path_error.rs b/engine/src/errors/path_error.rs new file mode 100644 index 00000000..1d54edb4 --- /dev/null +++ b/engine/src/errors/path_error.rs @@ -0,0 +1,49 @@ +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, + }, + + /// 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/ref_entry_error.rs b/engine/src/errors/ref_entry_error.rs new file mode 100644 index 00000000..b2c20d92 --- /dev/null +++ b/engine/src/errors/ref_entry_error.rs @@ -0,0 +1,37 @@ +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, + }, + + /// 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/errors/repository_error.rs b/engine/src/errors/repository_error.rs new file mode 100644 index 00000000..1f991886 --- /dev/null +++ b/engine/src/errors/repository_error.rs @@ -0,0 +1,20 @@ +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, + + /// Raised when the repository is in a detached HEAD state. + #[error("Repository is in a detached HEAD state")] + DetachedHead, +} diff --git a/engine/src/errors/revision_error.rs b/engine/src/errors/revision_error.rs new file mode 100644 index 00000000..aee8436b --- /dev/null +++ b/engine/src/errors/revision_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 RevisionError { + /// 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/errors/tree_error.rs b/engine/src/errors/tree_error.rs new file mode 100644 index 00000000..e1a8082e --- /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 00000000..af690ea0 --- /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/handlers.rs b/engine/src/handlers.rs new file mode 100644 index 00000000..88733444 --- /dev/null +++ b/engine/src/handlers.rs @@ -0,0 +1,18 @@ +pub mod add; +pub mod branch; +pub mod checkout; +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 push; +pub mod remote; +pub mod restore; +pub mod show; +pub mod status; diff --git a/engine/src/handlers/add.rs b/engine/src/handlers/add.rs new file mode 100644 index 00000000..995fa586 --- /dev/null +++ b/engine/src/handlers/add.rs @@ -0,0 +1,5 @@ +pub mod handlers; +pub mod operations; + +pub use handlers::AddHandler; +pub use operations::*; diff --git a/engine/src/handlers/add/handlers.rs b/engine/src/handlers/add/handlers.rs new file mode 100644 index 00000000..61dbbdc3 --- /dev/null +++ b/engine/src/handlers/add/handlers.rs @@ -0,0 +1,130 @@ +use super::{AddOperations, AddRequest, AddResponse}; +use crate::errors::{EngineError, EngineResult, PathError}; +use crate::plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}; +use crate::{Index, RepositoryLayout}; +use plugins::{ + AddPostPayload, AddPrePayload, CommandType, InvocationInput, InvocationPostPayload, + InvocationPrePayload, PluginError, +}; +use shared::IsWithin; +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, + ) -> 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 path = request.path_arg.clone().unwrap_or(".".into()); + + if !path.is_within(self.repo_layout.working_dir())? { + return Err(PathError::NotInBaseDirectory { + path, + 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_write_guard = self.index.write().unwrap(); + + let (added, modified, removed) = + index_write_guard.add(&path, add_new, add_deleted, add_ignored, verbose)?; + + if !request.dry_run_flag { + index_write_guard.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/handlers/add/operations.rs b/engine/src/handlers/add/operations.rs new file mode 100644 index 00000000..7eab475f --- /dev/null +++ b/engine/src/handlers/add/operations.rs @@ -0,0 +1,32 @@ +use std::path::{Path, PathBuf}; + +use crate::errors::EngineResult; + +#[derive(Debug, Clone, Default)] +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, +} + +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, + pub removed: Vec, +} + +pub trait AddOperations { + fn add(&self, request: AddRequest) -> EngineResult; +} diff --git a/engine/src/handlers/branch.rs b/engine/src/handlers/branch.rs new file mode 100644 index 00000000..9d2d49c1 --- /dev/null +++ b/engine/src/handlers/branch.rs @@ -0,0 +1,7 @@ +mod branch_collection; +mod handlers; +mod operations; + +pub use branch_collection::BranchCollection; +pub use handlers::BranchHandler; +pub use operations::*; diff --git a/engine/src/handlers/branch/branch_collection.rs b/engine/src/handlers/branch/branch_collection.rs new file mode 100644 index 00000000..fe489d78 --- /dev/null +++ b/engine/src/handlers/branch/branch_collection.rs @@ -0,0 +1,118 @@ +use crate::branch_manager::{BranchInfo, BranchType}; +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. +#[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, + + /// The vector of branch information to display. + pub branches_list: Vec, +} + +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::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}{} {message}", short_hash.dimmed())?; + } 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 00000000..ea1be56e --- /dev/null +++ b/engine/src/handlers/branch/handlers.rs @@ -0,0 +1,205 @@ +use crate::branch_manager::{Branch, BranchType}; +use crate::errors::{BranchError, EngineResult}; +use crate::handlers::branch::{ + BranchCollection, BranchOperations, CreateRequest, DeleteRequest, ListRequest, RenameRequest, + Request, Response, SetUpstream, +}; +use crate::ref_manager::{HeadMode, RefManager}; +use crate::revision_parsing::BaseReference; +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 { + /// 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 { + /// 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, + refs_manager: Arc, + ) -> Self { + Self { + branch_manager, + revision_resolver, + refs_manager, + } + } + + /// 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 start_point = request.start_point; + let branch_head = self.revision_resolver.resolve_hash(&start_point)?; + + let new_branch = Branch { + name: request.branch_name.clone(), + branch_type: BranchType::Local, + head_hash: branch_head, + }; + + 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. + /// + /// 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 = BranchCollection { + current_branch, + branches_list, + }; + + 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 { + 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::SetUpstream(request) => self.handle_set_upstream(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 new file mode 100644 index 00000000..bf4a5931 --- /dev/null +++ b/engine/src/handlers/branch/operations.rs @@ -0,0 +1,126 @@ +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 +/// 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), + + /// Request to configure branch upstream. + SetUpstream(SetUpstream), +} + +/// 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. +#[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. + 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, +} + +impl ListRequest { + pub fn local_only(verbose: bool) -> Self { + Self { + local: true, + verbose, + ..Default::default() + } + } +} + +/// 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, +} + +/// 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/handlers/checkout.rs b/engine/src/handlers/checkout.rs new file mode 100644 index 00000000..5fa4a3d9 --- /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 00000000..310d4da4 --- /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 00000000..02bda6ac --- /dev/null +++ b/engine/src/handlers/checkout/operations.rs @@ -0,0 +1,44 @@ +use crate::errors::EngineResult; +use crate::revision_parsing::Revision; + +/// Encapsulates the parameters for a checkout operation. +#[derive(Debug, Default)] +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, +} + +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. +/// +/// 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.rs b/engine/src/handlers/clone.rs new file mode 100644 index 00000000..13f46b5b --- /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 00000000..3f63346a --- /dev/null +++ b/engine/src/handlers/clone/handlers.rs @@ -0,0 +1,170 @@ +use async_trait::async_trait; +use std::{ + fs, + path::{Path, PathBuf}, + sync::Arc, +}; +use tempfile::TempDir; + +use super::{CloneOperations, Request, Response}; +use crate::{ + ConfigLoader, MevaConfigLoader, MevaRepository, RepositoryLayout, + errors::{CloneError, EngineError, EngineResult, NetworkError}, + network::{MevaRemotesManager, PackfileCodec, SshConnectionParams, SshService, SshSession}, + object_storage::MevaObjectStorage, + objects::MevaObject, + ref_manager::{MevaRefManager, RefEntry}, + repositories::{meva_repository::Repository, meva_repository_layout::MevaRepositoryLayout}, +}; + +#[derive(Debug)] +pub struct CloneHandler { + ssh_service: SshService, + repository_layout: Arc, + config_loader: Arc, +} + +impl CloneHandler { + /// Creates a new instance of [`CloneHandler`]. + pub fn new( + repository_layout: Arc, + config_loader: Arc, + ) -> Self { + Self { + repository_layout, + config_loader, + ssh_service: SshService, + } + } + + /// 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, + 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)) + } + } + } + + /// 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, !request.quiet) + .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], + 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, request) + } +} + +#[async_trait] +impl CloneOperations for CloneHandler { + /// Orchestrates the complete cloning process. + async fn clone(&self, request: Request) -> EngineResult { + 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(), + !request.quiet, + ) + .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, &request).await; + + match clone_result { + Ok(ref_entries) => { + fs::rename(&temp_path, &path)?; + Ok(Response { path, ref_entries }) + } + 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 00000000..a2032c59 --- /dev/null +++ b/engine/src/handlers/clone/operations.rs @@ -0,0 +1,36 @@ +use std::{fmt::Display, path::PathBuf}; + +use async_trait::async_trait; +use shared::PathToString; +use url::Url; + +use crate::{errors::EngineResult, ref_manager::RefEntry}; + +pub struct Request { + pub url: Url, + pub directory: Option, + pub origin: String, + pub server_key: PathBuf, + pub quiet: bool, +} + +pub struct Response { + 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] +pub trait CloneOperations { + async fn clone(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/handlers/commit.rs b/engine/src/handlers/commit.rs new file mode 100644 index 00000000..c784e9be --- /dev/null +++ b/engine/src/handlers/commit.rs @@ -0,0 +1,5 @@ +pub mod handlers; +pub mod operations; + +pub use handlers::CommitHandler; +pub use operations::*; diff --git a/engine/src/handlers/commit/handlers.rs b/engine/src/handlers/commit/handlers.rs new file mode 100644 index 00000000..af7b1897 --- /dev/null +++ b/engine/src/handlers/commit/handlers.rs @@ -0,0 +1,391 @@ +use super::{CommitOperations, Request, Response}; +use crate::diff_builder::{DiffMode, FileChange, FileChangeKind}; +use crate::errors::{CommitError, EngineError, EngineResult}; +use crate::index::FileMode; +use crate::objects::{MevaCommit, ObjectEntry, Person}; +use crate::plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}; +use crate::revision_parsing::Revision; +use crate::{BranchManager, CommitBuilder, ConfigLoader, DiffBuilder, Index, RefManager}; +use plugins::{ + CommandType, CommitAuthor, CommitContent, CommitFileChange, CommitFileMode, CommitPostPayload, + CommitPrePayload, InvocationInput, InvocationPostPayload, InvocationPrePayload, PluginError, +}; +use std::path::PathBuf; +use std::sync::Arc; + +/// 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: +/// - 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`]. +/// +/// 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 { + diff_builder: Arc, + refs_manager: Arc, + branch_manager: Arc, + commit_builder: Arc, + index: Arc, + config_loader: Arc, +} + +impl CommitHandler { + /// 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 { + diff_builder, + refs_manager, + branch_manager, + commit_builder, + index, + config_loader, + } + } + + /// Execute the commit flow with plugin interception. + /// + /// 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 `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: Request, + interceptor: &PluginsInterceptor, + ) -> EngineResult { + match self.should_execute(&request)? { + false => Err(CommitError::NothingToCommit.into()), + 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), + ) + } + } + } + + /// Determines whether a commit operation should be executed. + /// + /// 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 [`EngineError`](EngineError) if reading the index + /// or computing the diff fails. + fn should_execute(&self, request: &Request) -> EngineResult { + let head_hash = self.refs_manager.resolve_head()?; + match head_hash { + None => Ok(!self.index.get_entries().is_empty()), + Some(hash) => { + if request.amend { + return Ok(true); + } + let revision = Revision::hash(&hash, Vec::new()); + let diff_mode = DiffMode::NameOnly; + let diff = self + .diff_builder + .diff_snapshot_index(&revision, &diff_mode, None)?; + Ok(!diff.is_empty()) + } + } + } + + /// Retrieves the configured username from the repository settings. + fn get_user_name(&self) -> EngineResult { + self.config_loader + .get("user.name", Some(&String::default())) + } + + /// Retrieves the configured user email from the repository settings. + fn get_user_email(&self) -> EngineResult { + 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> { + match commit_hash { + None => { + let head = self.branch_manager.last_commit_hash()?; + match head { + None => { + let entries = self.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::>(); + + self.diff_builder.added_to_file_changes( + object_entries.iter().collect(), + &DiffMode::NameOnly, + ) + } + Some(_) => { + let revision = Revision::head(Vec::new()); + self.diff_builder + .diff_snapshot_index(&revision, &DiffMode::NameOnly, None) + } + } + } + Some(hash) => { + let (_, _, changes) = self.diff_builder.diff_snapshot_with_parent( + &Revision::hash(&hash, Vec::new()), + &DiffMode::Stat, + true, + )?; + Ok(changes) + } + } + } +} + +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_user_name()?, + email: self.get_user_email()?, + }, + }; + + let commit = self + .commit_builder + .build_commit(request.message.clone(), author)?; + + let commit_hash = if request.dry_run { + None + } else if request.amend { + Some(self.branch_manager.amend_last_commit(commit.clone())?) + } else { + Some(self.branch_manager.add_commit(commit.clone())?) + }; + + let changes = self.collect_commit_changes(commit_hash.clone())?; + Ok(Response { + commit_hash, + commit, + changes, + }) + } +} + +impl PluginsInvocationMapper for CommitHandler { + fn request_to_payload(&self, req: &Request) -> EngineResult { + Ok(InvocationPrePayload::Commit(CommitPrePayload { + message: req.message.clone(), + author: CommitAuthor { + 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, + amend: req.amend, + })) + } + + fn response_to_payload(&self, res: &Response) -> 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, + }, + 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(), + }))) + } + + fn input_to_request(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPrePayload::Commit(pre)) = &input.pre_payload { + Ok(Request { + message: pre.message.clone(), + author: Some(Person { + name: pre.author.name.clone(), + email: pre.author.name.clone(), + }), + dry_run: pre.dry_run, + amend: 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(Response { + 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, + }, + 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 { + payload: input.post_payload.clone(), + })) + } + } +} diff --git a/engine/src/handlers/commit/operations.rs b/engine/src/handlers/commit/operations.rs new file mode 100644 index 00000000..6378b8c6 --- /dev/null +++ b/engine/src/handlers/commit/operations.rs @@ -0,0 +1,51 @@ +use crate::diff_builder::FileChange; +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. +#[derive(Debug, Default, Clone)] +pub struct Request { + /// Commit message provided by the user. + pub message: String, + + /// Optional author information (name and email). + pub author: Option, + + /// If `true`, the commit is simulated without actually modifying the repository. + pub dry_run: bool, + + /// If `true`, the last commit is amended instead of creating a new one. + 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. +/// +/// Contains details about the created (or amended) commit and +/// echoes back the input arguments used for the operation. +pub struct Response { + /// SHA-1 hash of the created or amended commit. + pub commit_hash: Option, + + /// Commit object + pub commit: MevaCommit, + + /// List of file changes included in the commit. + pub changes: Vec, +} + +pub trait CommitOperations { + fn commit(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/handlers/config.rs b/engine/src/handlers/config.rs new file mode 100644 index 00000000..06b41d1b --- /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/handlers/config/handlers.rs b/engine/src/handlers/config/handlers.rs new file mode 100644 index 00000000..2c4734b4 --- /dev/null +++ b/engine/src/handlers/config/handlers.rs @@ -0,0 +1,193 @@ +use std::sync::Arc; + +use plugins::{CommandType, InvocationPostPayload, InvocationPrePayload, PluginError, models::*}; + +use super::*; + +use crate::{ + ConfigLoader, ConfigLocation, + config::{ConfigDocument, ConfigDocumentOperations}, + errors::{EngineError, EngineResult}, + handlers::config::CreateRequest, + plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}, +}; + +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) -> EngineResult { + self.get(request) + } + + 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) -> EngineResult { + self.list(request) + } + + pub fn handle_create(&self, request: CreateRequest) -> EngineResult { + self.create(request) + } +} + +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) + } + + fn create(&self, request: CreateRequest) -> EngineResult { + self.config_loader + .create_global_config(request.file.as_deref())?; + Ok(CreateResponse) + } +} + +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::ConfigSet(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(), + })) + } + } +} diff --git a/engine/src/handlers/config/operations.rs b/engine/src/handlers/config/operations.rs new file mode 100644 index 00000000..4e51f1b7 --- /dev/null +++ b/engine/src/handlers/config/operations.rs @@ -0,0 +1,127 @@ +use std::{collections::BTreeMap, path::PathBuf}; + +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)>, +} + +#[derive(Debug)] +pub struct CreateRequest { + pub file: Option, +} + +pub struct CreateResponse; + +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; + + fn create(&self, request: CreateRequest) -> EngineResult; +} diff --git a/engine/src/handlers/diff.rs b/engine/src/handlers/diff.rs new file mode 100644 index 00000000..0004597a --- /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 00000000..c37753e5 --- /dev/null +++ b/engine/src/handlers/diff/handlers.rs @@ -0,0 +1,96 @@ +use super::{DiffOperations, Request, Response}; +use crate::{ + diff_builder::{DiffBuilder, DiffStat, RevisionRange, RevisionSide}, + errors::EngineResult, +}; +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 { + /// The underlying builder responsible for the actual diff computation. + diff_builder: Arc, +} + +impl DiffHandler { + /// Creates a new [`DiffHandler`] instance. + /// + /// This constructor performs no I/O and only stores the provided diff builder. + /// + /// # Arguments + /// + /// * `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. + /// 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 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) + /// 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_builder::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(), None, None)?, + _ => 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 00000000..ba876054 --- /dev/null +++ b/engine/src/handlers/diff/operations.rs @@ -0,0 +1,94 @@ +use crate::diff_builder::{DiffMode, DiffStat, FileChange, RevisionRange}; +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, + 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"), + } + } +} + +/// 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/fetch.rs b/engine/src/handlers/fetch.rs new file mode 100644 index 00000000..68f05566 --- /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 00000000..43158f3d --- /dev/null +++ b/engine/src/handlers/fetch/handlers.rs @@ -0,0 +1,262 @@ +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + sync::Arc, +}; + +use async_trait::async_trait; +use itertools::Itertools; + +use crate::{ + ConfigLoader, + errors::EngineResult, + network::{ + PackfileCodec, RemoteDirection, RemoteEntry, RemotesManager, SshConnectionParams, + SshService, SshSession, + }, + 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, + object_storage: Arc, + config_loader: Arc, + ref_manager: Arc, + remotes_manager: Arc, +} + +impl FetchHandler { + /// Creates a new instance of the [`FetchHandler`]. + pub fn new( + object_storage: Arc, + config_loader: Arc, + ref_manager: Arc, + remotes_manager: Arc, + ) -> Self { + Self { + ssh_service: SshService, + 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. + /// + /// 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) + } + + /// 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)?, + )) + } + + /// Removes local remote-tracking branches that no longer exist on the server. + /// + /// 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 expected_local_names: HashSet = server_refs + .iter() + .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)); + + for branch in to_prune { + self.ref_manager.remove_ref(&branch.name)?; + } + + *local_refs = kept; + + 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. + /// * `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)?; + + if verbose { + println!("Successfully decoded {} objects.", objects.len()); + } + + self.object_storage.add_objects_from_packfile(&objects) + } + + /// 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_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.key_for(RemoteDirection::Fetch).to_path_buf(); + + Ok(( + self.ssh_service + .connect(&connection_params, request.verbose) + .await?, + connection_params, + )) + } + + /// 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::>(); + + 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 = upload_pack_result + .refs + .into_iter() + .filter(|r| r.name.starts_with(&heads_refs_prefix)) + .collect::>(); + + if request.prune { + self.prune_remote_refs(&request.origin, &mut local_remote_refs, &server_remote_refs)?; + } + + let refs_to_update = + self.find_refs_to_update(&request, &server_remote_refs, &local_remote_refs)?; + + if refs_to_update.is_empty() { + println!(); + println!("Already up-to-date."); + } else { + 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) + } +} diff --git a/engine/src/handlers/fetch/operations.rs b/engine/src/handlers/fetch/operations.rs new file mode 100644 index 00000000..6a48cf6b --- /dev/null +++ b/engine/src/handlers/fetch/operations.rs @@ -0,0 +1,29 @@ +use async_trait::async_trait; + +use crate::errors::EngineResult; + +#[derive(Debug, Default)] +pub struct Request { + pub origin: String, + pub branch: Option, + pub prune: bool, + pub verbose: bool, +} + +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/init.rs b/engine/src/handlers/init.rs new file mode 100644 index 00000000..01d47720 --- /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/handlers/init/handlers.rs b/engine/src/handlers/init/handlers.rs new file mode 100644 index 00000000..3de8bf49 --- /dev/null +++ b/engine/src/handlers/init/handlers.rs @@ -0,0 +1,85 @@ +use std::sync::Arc; + +use plugins::{ + CommandType, InitPostPayload, InitPrePayload, InvocationInput, InvocationPostPayload, + InvocationPrePayload, PluginError, +}; + +use super::{InitOperations, Request, Response}; +use crate::{ + errors::{EngineError, EngineResult}, + plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}, + repositories::meva_repository::Repository, +}; + +pub struct InitHandler { + pub repository: Arc, +} + +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 { + self.repository + .layout() + .set_working_dir(request.working_dir); + let repository_dir = self.repository.init(Some(&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/handlers/init/operations.rs b/engine/src/handlers/init/operations.rs new file mode 100644 index 00000000..49751bd0 --- /dev/null +++ b/engine/src/handlers/init/operations.rs @@ -0,0 +1,17 @@ +use std::path::PathBuf; + +use crate::errors::EngineResult; + +pub struct Request { + pub working_dir: PathBuf, + pub initial_branch: String, +} + +#[derive(Debug, Clone)] +pub struct Response { + pub repository_dir: PathBuf, +} + +pub trait InitOperations { + fn init(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/handlers/log.rs b/engine/src/handlers/log.rs new file mode 100644 index 00000000..18265f4c --- /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 00000000..eccc0b43 --- /dev/null +++ b/engine/src/handlers/log/handlers.rs @@ -0,0 +1,180 @@ +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::objects::MevaCommit; +use crate::revision_parsing::{Revision, RevisionResolver}; +use chrono::Utc; +use std::sync::Arc; + +/// 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 { + object_storage: Arc, + revision_resolver: Arc, + diff_builder: Arc, +} + +impl LogHandler { + /// 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. + /// + /// 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( + &self, + next_commit_hash: &Option, + ) -> EngineResult<(Option, Option)> { + match next_commit_hash { + Some(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)) + } + 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 [`MevaDiffBuilder`]. + /// + /// # 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 revision = request + .revision + .unwrap_or_else(|| Revision::head(Vec::new())); + + 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() { + 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(¤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) = 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, + }; + } + + result.entries.push(new_log_entry); + } + + current_commit_hash = next_commit_hash; + (current_commit, next_commit_hash) = self.load_next_commit(¤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 00000000..724492d6 --- /dev/null +++ b/engine/src/handlers/log/operations.rs @@ -0,0 +1,66 @@ +use crate::diff_builder::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. +#[derive(Debug)] +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. +#[derive(Debug, Default)] +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. +#[derive(Debug, Default)] +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/ls_files.rs b/engine/src/handlers/ls_files.rs new file mode 100644 index 00000000..c97b5e4d --- /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 00000000..258953bc --- /dev/null +++ b/engine/src/handlers/ls_files/handlers.rs @@ -0,0 +1,129 @@ +use super::{ + LsFilesOperations, Request, Response, + models::{LsFilesEntry, LsFilesFilter}, +}; +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 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) + } + + /// 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 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 = self.index.get_deleted_files(); + self.build_response_from_index_entries( + &entries, + abbrev, + request.stage, + &working_dir, + request.full_name, + ) + } + LsFilesFilter::Cached => { + let entries = self.index.get_entries(); + self.build_response_from_index_entries( + &entries, + abbrev, + request.stage, + &working_dir, + request.full_name, + ) + } + LsFilesFilter::Others => { + let untracked = self.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 00000000..2f385eed --- /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 00000000..5baec686 --- /dev/null +++ b/engine/src/handlers/ls_files/models/ls_files_entry.rs @@ -0,0 +1,48 @@ +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. +/// +/// 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.cyan()) + } + LsFilesEntry::Entry { + mode, + object, + stage, + path, + } => { + write!( + f, + "{} {} {} {}", + format!("{mode:o}").dimmed(), + object.yellow(), + stage.green(), + path.cyan() + ) + } + } + } +} 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 00000000..43857257 --- /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 00000000..35002ee3 --- /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/ls_tree.rs b/engine/src/handlers/ls_tree.rs new file mode 100644 index 00000000..de2ca735 --- /dev/null +++ b/engine/src/handlers/ls_tree.rs @@ -0,0 +1,7 @@ +mod handlers; +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 new file mode 100644 index 00000000..7c157f24 --- /dev/null +++ b/engine/src/handlers/ls_tree/handlers.rs @@ -0,0 +1,85 @@ +use super::{DisplayMode, LsTreeEntry}; +use super::{LsTreeOperations, Request, Response}; +use crate::errors::EngineResult; +use crate::revision_parsing::RevisionResolver; +use crate::traversal::CommitTreeWalker; +use std::sync::Arc; + +/// 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 { + commit_tree_walker: Arc, + revision_resolver: Arc, +} + +impl LsTreeHandler { + /// 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`]. + /// + /// Delegates the logic to [`LsTreeOperations::ls_tree`]. + pub fn handle_ls_tree(&self, request: Request) -> EngineResult { + self.ls_tree(request) + } +} + +impl LsTreeOperations for LsTreeHandler { + fn ls_tree(&self, request: Request) -> EngineResult { + 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, + false => Some(1), + }; + let path_filter = match request.paths.is_empty() { + true => None, + false => Some(request.paths.as_ref()), + }; + + 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 + } else if request.long { + DisplayMode::Long + } else { + DisplayMode::Normal + }; + + 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/models.rs b/engine/src/handlers/ls_tree/models.rs new file mode 100644 index 00000000..98d95114 --- /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 00000000..133adae6 --- /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 00000000..abf4f87f --- /dev/null +++ b/engine/src/handlers/ls_tree/models/ls_tree_entry.rs @@ -0,0 +1,54 @@ +use std::fmt::Display; + +use owo_colors::OwoColorize; +use shared::PathToString; + +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.to_utf8_string().cyan()) + } + DisplayMode::Normal => { + write!( + f, + "{} {} {}", + entry.entry_type.dimmed(), + entry.hash.yellow(), + entry.path.to_utf8_string().cyan() + ) + } + DisplayMode::Long => { + let size_str = match entry.size { + Some(s) => s.to_string(), + None => "-".to_string(), + }; + write!( + f, + "{} {} {:>8} {}", + entry.entry_type.dimmed(), + entry.hash.yellow(), + size_str.green(), + entry.path.to_utf8_string().cyan(), + ) + } + } + } +} diff --git a/engine/src/handlers/ls_tree/operations.rs b/engine/src/handlers/ls_tree/operations.rs new file mode 100644 index 00000000..a54c554f --- /dev/null +++ b/engine/src/handlers/ls_tree/operations.rs @@ -0,0 +1,44 @@ +use crate::errors::EngineResult; +use crate::revision_parsing::Revision; + +use super::LsTreeEntry; + +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/handlers/plugins.rs b/engine/src/handlers/plugins.rs new file mode 100644 index 00000000..d815b64c --- /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/handlers/plugins/handlers.rs b/engine/src/handlers/plugins/handlers.rs new file mode 100644 index 00000000..4c6abbff --- /dev/null +++ b/engine/src/handlers/plugins/handlers.rs @@ -0,0 +1,155 @@ +use std::{env, path::PathBuf, sync::Arc}; + +use plugins::{PluginConfiguration, PluginError, PluginsRepository, RegisterError, ScopeType}; +use shared::UpwardSearch; + +use super::{ + EditRequest, EditResponse, InfoRequest, InfoResponse, ListRequest, ListResponse, + PluginsOperations, RegisterRequest, RegisterResponse, UnregisterRequest, UnregisterResponse, +}; +use crate::{ + RepositoryLayout, + errors::{EngineResult, RepositoryError}, +}; + +pub struct PluginsHandler { + pub plugins_repository: Arc, + pub repository_layout: Arc, +} + +impl PluginsHandler { + pub fn new( + plugins_repository: Arc, + repository_layout: Arc, + ) -> 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 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: resolved_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, + &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/handlers/plugins/operations.rs b/engine/src/handlers/plugins/operations.rs new file mode 100644 index 00000000..5951d9fe --- /dev/null +++ b/engine/src/handlers/plugins/operations.rs @@ -0,0 +1,143 @@ +use std::{ + fmt::{Display, Formatter, Result}, + path::PathBuf, +}; + +use owo_colors::OwoColorize; +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: Option, + pub command: CommandType, + pub event: EventType, + pub order: u32, + pub timeout: Option, + 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.".yellow())?; + return Ok(()); + } + + for (i, (config, path)) in self.plugins.iter().enumerate() { + 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() { + 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().cyan())?; + + writeln!(f, "{}", "Configuration:".bold())?; + + 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/handlers/push.rs b/engine/src/handlers/push.rs new file mode 100644 index 00000000..f79684b9 --- /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 00000000..4424541e --- /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 00000000..aabf53d6 --- /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.rs b/engine/src/handlers/remote.rs new file mode 100644 index 00000000..56e0defe --- /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 00000000..606e09da --- /dev/null +++ b/engine/src/handlers/remote/handlers.rs @@ -0,0 +1,324 @@ +use std::{collections::HashMap, path::PathBuf, sync::Arc}; + +use async_trait::async_trait; + +use crate::{ + ConfigLoader, + 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, + ssh_service: SshService, +} + +impl RemoteHandler { + /// Creates a new instance of the [`RemoteHandler`]. + pub fn new( + remotes_manager: Arc, + config_loader: Arc, + ref_manager: Arc, + ) -> Self { + Self { + remotes_manager, + config_loader, + 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 = 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); + + 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, + None, + )?, + }) + } + + /// 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.new_server_key.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, request.verbose) + .await?; + + let packfile_result = ssh_session + .run_ls_remotes(&connection_params.repository_name, request.verbose) + .await?; + + // 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 00000000..fa416596 --- /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 00000000..6c63a463 --- /dev/null +++ b/engine/src/handlers/remote/operations.rs @@ -0,0 +1,136 @@ +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, +} + +#[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 new_server_key: PathBuf, + pub direction: RemoteDirection, +} + +#[derive(Debug)] +pub struct ListResponse { + pub remotes: HashMap, +} + +#[derive(Debug)] +pub struct ShowRequest { + pub name: String, + pub no_fetch: bool, + pub verbose: 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/handlers/restore.rs b/engine/src/handlers/restore.rs new file mode 100644 index 00000000..c5f83234 --- /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 00000000..9a753d01 --- /dev/null +++ b/engine/src/handlers/restore/handlers.rs @@ -0,0 +1,70 @@ +use super::{Request, Response, RestoreOperations}; +use crate::RevisionResolver; +use crate::errors::EngineResult; +use crate::restore_manager::RestoreManager; +use std::sync::Arc; + +/// 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 { + /// 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, +} + +impl RestoreHandler { + /// Creates a new instance of [`RestoreHandler`]. + pub fn new( + restore_manager: Arc, + revision_resolver: Arc, + ) -> Self { + Self { + restore_manager, + revision_resolver, + } + } + + /// 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) + } +} + +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 commit_hash = self.revision_resolver.resolve_hash(&request.source)?; + + self.restore_manager.restore( + &commit_hash, + request.staged, + request.worktree, + &request.paths, + )?; + + Ok(Response {}) + } +} diff --git a/engine/src/handlers/restore/operations.rs b/engine/src/handlers/restore/operations.rs new file mode 100644 index 00000000..f825d0e5 --- /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/show.rs b/engine/src/handlers/show.rs new file mode 100644 index 00000000..fce5eb60 --- /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, Snapshot}; diff --git a/engine/src/handlers/show/handlers.rs b/engine/src/handlers/show/handlers.rs new file mode 100644 index 00000000..456c8566 --- /dev/null +++ b/engine/src/handlers/show/handlers.rs @@ -0,0 +1,66 @@ +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; + +/// 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: Arc, +} + +impl ShowHandler { + /// Creates a new [`ShowHandler`] instance. + /// + /// # Parameters + /// + /// * `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 { + self.show(request) + } +} + +impl ShowOperations for ShowHandler { + 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.rs b/engine/src/handlers/show/models.rs new file mode 100644 index 00000000..511de35a --- /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 00000000..dd610d56 --- /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 ful 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 00000000..0c6b80b7 --- /dev/null +++ b/engine/src/handlers/show/models/snapshot.rs @@ -0,0 +1,67 @@ +use std::fmt::Display; + +use chrono::{DateTime, Utc}; +use owo_colors::OwoColorize; +use serde::{Deserialize, Serialize}; + +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 hash: 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 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 { + /// 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.hash.yellow())?; + 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 00000000..3ff7dbd6 --- /dev/null +++ b/engine/src/handlers/show/operations.rs @@ -0,0 +1,23 @@ +use crate::{ + diff_builder::{DiffStat, FileChange}, + errors::EngineResult, + revision_parsing::Revision, +}; + +use super::models::{PatchMode, Snapshot}; + +pub struct Request { + pub snapshot_id: Revision, + pub mode: PatchMode, +} + +#[derive(Debug)] +pub struct Response { + pub snapshot: Snapshot, + pub stat: Option, + pub files: 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 00000000..60353b88 --- /dev/null +++ b/engine/src/handlers/status.rs @@ -0,0 +1,7 @@ +mod handlers; +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 new file mode 100644 index 00000000..0d76b46c --- /dev/null +++ b/engine/src/handlers/status/handlers.rs @@ -0,0 +1,450 @@ +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + sync::Arc, +}; + +use super::{ + Request, Response, StatusOperations, + models::{BranchInfo, MergeEntry, StatusEntry}, +}; +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. +/// +/// 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 { + working_dir: Arc, + index: Arc, + ref_manager: Arc, + object_storage: Arc, + diff_builder: Arc, + commit_history_walker: 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, + commit_history_walker: Arc, + ) -> Self { + Self { + working_dir, + index, + ref_manager, + object_storage, + diff_builder, + commit_history_walker, + } + } + + /// 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 + } + + /// 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) + /// and whether any commits exist. + fn get_branch_info( + &self, + head: &Head, + head_result: &EngineResult>, + ) -> EngineResult { + let has_commits = matches!(head_result, Ok(Some(_))); + match &head.mode { + HeadMode::Direct => Ok(BranchInfo { + head: Some(head.target.clone()), + is_detached: true, + 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, + }) + } + } + } + + /// 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, + show_ignored: bool, + response: &mut Response, + ) -> EngineResult> { + match show_ignored { + true => { + 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); + + let ignored_files = ignored_files + .iter() + .map(|(p, _)| StatusEntry::ignored(p)) + .collect::>(); + + response.ignored = Some(ignored_files); + Ok(files.into_iter().collect()) + } + false => self.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, + 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 = self.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, + 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) 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)? + } + _ => HashMap::::new(), + }; + + let changes = self.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 { + /// Orchestrates the entire status operation. + fn status(&self, request: Request) -> EngineResult { + let mut response = Response::default(); + + 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)?); + } + + let head_hash = head_result.unwrap_or(None); + + let working_map = self.collect_working_dir(request.show_ignored, &mut response)?; + + let entries = self.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() { + 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.rs b/engine/src/handlers/status/models.rs new file mode 100644 index 00000000..520a2ade --- /dev/null +++ b/engine/src/handlers/status/models.rs @@ -0,0 +1,9 @@ +mod branch_info; +mod merge_entry; +mod status_entry; +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/branch_info.rs b/engine/src/handlers/status/models/branch_info.rs new file mode 100644 index 00000000..beb35698 --- /dev/null +++ b/engine/src/handlers/status/models/branch_info.rs @@ -0,0 +1,68 @@ +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 { + /// 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. + 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 (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 the branch info into a human-readable status message. + 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.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 {} (up to date with {})", + head.bold().cyan(), + up.cyan() + ) + } + Some(up) => writeln!( + f, + "On branch {} ({} ahead, {} behind of {})", + head.bold().cyan(), + self.ahead.green(), + self.behind.red(), + up.cyan() + ), + None => writeln!(f, "On branch {}", head.bold().cyan()), + }?; + if !self.has_commits { + // This is a new/orphan branch with no commit history + writeln!(f, "\n{}", "No commits yet".yellow())?; + return writeln!(f); + } + } else { + // Should not happen in a valid repository + writeln!(f, "On unknown branch")?; + } + writeln!(f) + } +} 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 00000000..b65093cd --- /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 new file mode 100644 index 00000000..e9e3d6ca --- /dev/null +++ b/engine/src/handlers/status/models/status_entry.rs @@ -0,0 +1,171 @@ +use std::{fmt::Display, path::PathBuf}; + +use owo_colors::OwoColorize; +use shared::PathToString; + +use crate::diff_builder::ChangeKind; + +use super::status_kind::StatusKind; + +/// 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 { + /// Specifies the status category for this file. + pub kind: StatusKind, + + /// The file path, typically relative to the repository root. + pub path: PathBuf, +} + +impl StatusEntry { + /// 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::Staged(change), + path: path.into(), + } + } + + /// 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(), + } + } + + /// 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(), + } + } + + /// 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) + } + + /// Checks if the status kind is `Ignored`. + pub fn is_ignored(&self) -> bool { + matches!(self.kind, StatusKind::Ignored) + } + + /// Checks if the status kind is `Unstaged`. + pub fn is_unstaged(&self) -> bool { + matches!(self.kind, StatusKind::Unstaged(_)) + } + + /// Checks if the status kind is `Staged`. + pub fn is_staged(&self) -> bool { + matches!(self.kind, StatusKind::Staged(_)) + } + + /// 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 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 | 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 (`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 new file mode 100644 index 00000000..8cb19766 --- /dev/null +++ b/engine/src/handlers/status/models/status_kind.rs @@ -0,0 +1,42 @@ +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. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusKind { + /// 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), + + /// 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, + }, +} + +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 new file mode 100644 index 00000000..bfe51425 --- /dev/null +++ b/engine/src/handlers/status/operations.rs @@ -0,0 +1,165 @@ +use super::models::{BranchInfo, StatusEntry}; + +use crate::errors::EngineResult; + +/// 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, + /// 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 { + /// 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_ignored: ignored, + 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, Clone, Default)] +pub struct Response { + /// Information about the current branch and HEAD state. + pub branch: Option, + /// 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}")); + } + + 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)); + 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. +pub trait StatusOperations { + /// Performs the status operation based on the provided [Request] options. + fn status(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/hasher.rs b/engine/src/hasher.rs new file mode 100644 index 00000000..ea3ce6ae --- /dev/null +++ b/engine/src/hasher.rs @@ -0,0 +1,23 @@ +pub mod meva_hasher; + +pub use meva_hasher::MevaHasher; + +/// Collection of utility functions for computing cryptographic hashes +/// used in the Meva repository. +/// +/// 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 new file mode 100644 index 00000000..598f78d8 --- /dev/null +++ b/engine/src/hasher/meva_hasher.rs @@ -0,0 +1,26 @@ +use crate::hasher::Hasher; +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 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 + /// 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) + } + + fn sha1_raw(&self, data: &[u8]) -> Vec { + let mut hasher = Sha1::new(); + hasher.update(data); + hasher.finalize().to_vec() + } +} diff --git a/engine/src/ignore.rs b/engine/src/ignore.rs new file mode 100644 index 00000000..50caad39 --- /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 00000000..c0ffb5a1 --- /dev/null +++ b/engine/src/ignore/ignore_operations.rs @@ -0,0 +1,83 @@ +use std::path::{Path, PathBuf}; + +use crate::{IgnoreResult, errors::EngineResult}; +use globset::Glob; + +/// 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; + + /// 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. + /// + /// # 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_result.rs b/engine/src/ignore/ignore_result.rs new file mode 100644 index 00000000..f32e4a59 --- /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 00000000..2f9138cd --- /dev/null +++ b/engine/src/ignore/ignore_service.rs @@ -0,0 +1,275 @@ +use std::{ + env, + fs::{self, OpenOptions}, + io::{BufRead, BufReader, BufWriter, Write}, + path::{Path, PathBuf}, +}; + +use globset::{Glob, GlobSetBuilder}; +use path_absolutize::Absolutize; +use walkdir::WalkDir; + +use crate::{ + errors::{EngineResult, IgnoreError}, + ignore::{IgnoreOperations, IgnoreResult}, +}; +use shared::{IsWithin, UpwardSearch}; + +/// 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. +/// +/// 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 + /// (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 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_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: &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: &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 (set, patterns) = Self::load_patterns_from_file(&ignore_file)?; + + 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 check_cached

(&self, checked_path: &P) -> EngineResult + where + P: AsRef, + { + if self.cached_set.is_none() { + return Err(IgnoreError::IgnoreCacheEmpty.into()); + } + + let cached_set = self.cached_set.as_ref().unwrap(); + + let mut checked_path = checked_path.as_ref().to_path_buf(); + + 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 + .absolutize()? + .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 { + 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_name).ok_or( + IgnoreError::IgnoreNotFound { + 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 00000000..4356e813 --- /dev/null +++ b/engine/src/index.rs @@ -0,0 +1,152 @@ +pub mod file_mode; +pub mod index_entry; +pub mod meva_index; +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 meva_index::MevaIndex; +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 + /// [`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/file_mode.rs b/engine/src/index/file_mode.rs new file mode 100644 index 00000000..0fe0d9bb --- /dev/null +++ b/engine/src/index/file_mode.rs @@ -0,0 +1,143 @@ +use std::fmt; +use std::fs; +use std::io::Error; +use std::io::ErrorKind; +use std::path::Path; + +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. +/// +/// These values are used to differentiate between regular +/// files, executables, symbolic links, +/// and submodules (gitlinks). +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, Eq, PartialEq)] +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 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<&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(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", + )), + } + } +} + +impl TryFrom<&Path> for FileMode { + type Error = 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 to determine if the + /// file is a regular file, executable, symlink, or gitlink. + /// + /// # Windows + /// 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)?; + + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let mode = metadata.mode(); + let file_mode = match mode & 0o170000 { + 0o100000 => { + if mode & 0o111 != 0 { + Self::Executable + } else { + Self::Normal + } + } + 0o120000 => Self::Symlink, + 0o160000 => Self::GitLink, + _ => Self::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 { + Self::Executable + } else { + Self::Normal + }) + } else { + // Directories and other types are not given a specific mode here. + 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 new file mode 100644 index 00000000..ab494741 --- /dev/null +++ b/engine/src/index/index_entry.rs @@ -0,0 +1,73 @@ +use std::fs; +use std::path::Path; + +use super::{FileMode, Stage}; +use crate::errors::EngineResult; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use shared::PathToString; + +/// 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 hash of the serialized [`MevaBlob`] object. + /// + /// 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). + 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_utf8_string(); + 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_index.rs b/engine/src/index/meva_index.rs new file mode 100644 index 00000000..e26467e2 --- /dev/null +++ b/engine/src/index/meva_index.rs @@ -0,0 +1,443 @@ +use super::{IndexEntry, 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}, + io::{self, Read}, + path::{Path, PathBuf}, + sync::Arc, +}; + +use crate::diff_builder::FileChange; +use crate::errors::{EngineResult, IndexError}; +use crate::objects::MevaBlob; +use crate::{Index, ObjectStorage, WorkingDir}; + +use shared::{PathToString, StripBase}; + +/// 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 +/// persisted in a JSON file on disk (the *index file*). It is responsible for: +/// - 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 helper for interacting with the working directory, including file collection and ignore rule processing. + 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 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 + /// + /// * `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 { + working_dir, + object_storage, + entries: HashMap::new(), + }) + } + + /// Creates a new [`MevaIndex`] and immediately loads its state from the + /// on-disk index file. + /// + /// This convenience constructor behaves equivalently to calling + /// [`MevaIndex::new`] with the provided components, followed by + /// [`MevaIndex::load_from_disk`]. + /// + /// The method initializes an empty in-memory index and then replaces its + /// contents with data parsed from the index file stored in the repository. + /// + /// # Arguments + /// + /// * `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( + working_dir: Arc, + object_storage: Arc, + with_absolute_keys: Option, + ) -> EngineResult { + let mut meva_index = MevaIndex::new(working_dir, object_storage)?; + meva_index.load_from_disk(with_absolute_keys)?; + + Ok(meva_index) + } + + /// 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 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) + } + Ok(updated_entry) + }) + .collect::>()?; + + for entry in result { + self.entries.insert(entry.path.clone(), entry); + } + + Ok(()) + } + + /// 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 + /// + /// * `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); + } + } + + Ok((new_files, modified_files)) + } +} + +impl Index for MevaIndex { + fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + 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 + .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::>() + } + + 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) + } + + 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.working_dir.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); + + match with_absolute_keys { + true => { + self.entries = deserialize_entries_with_absolute_keys( + deserializer, + &self.working_dir.layout().working_dir(), + ) + .map_err(io::Error::other)?; + } + false => { + self.entries = deserialize_entries(deserializer).map_err(io::Error::other)?; + } + } + + Ok(()) + } + + 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 files_in_index = self + .entries + .iter() + .filter(|(key, _)| Path::new(&key).starts_with(dir_path.clone())) + .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::>() + } + + 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)) + .cloned() + .collect::>(); + + dir_files + .into_iter() + .filter_map( + |(p, _)| match !files_in_index.contains(&p.to_utf8_string()) { + true => Some(p), + false => None, + }, + ) + .collect::>() + } + + fn add( + &mut self, + path: &Path, + 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) + .iter() + .map(|(p, _)| p.clone()) + .collect(); + + let (mut new_entries, mut modified_entries) = self.group_files(&files_abs)?; + + let mut new_files = vec![]; + let mut deleted_files = vec![]; + let modified_files = modified_entries + .iter() + .map(|e| PathBuf::from(&e.path)) + .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)) + } + + fn save(&mut self) -> EngineResult<()> { + if !self.working_dir.layout().index_file().exists() { + 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()?; + + 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); + + entry.path = relative_path.to_utf8_string(); + } + + let json = + serde_json::to_string_pretty(&self.entries.values().collect::>())?; + + fs::write(self.working_dir.layout().index_file(), json)?; + Ok(()) + } + + fn insert_entries(&mut self, entries: Vec) { + 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. + fn remove_entries(&mut self, keys: Vec) { + for key in keys { + self.entries.remove(&key); + } + } +} diff --git a/engine/src/index/serde_utils.rs b/engine/src/index/serde_utils.rs new file mode 100644 index 00000000..c7bfbb9b --- /dev/null +++ b/engine/src/index/serde_utils.rs @@ -0,0 +1,91 @@ +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>>() +} + +/// 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/stage.rs b/engine/src/index/stage.rs new file mode 100644 index 00000000..b6c43f3c --- /dev/null +++ b/engine/src/index/stage.rs @@ -0,0 +1,37 @@ +use std::fmt::Display; + +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, Copy, Eq, PartialEq)] +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, +} + +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/index/working_dir.rs b/engine/src/index/working_dir.rs new file mode 100644 index 00000000..a704b605 --- /dev/null +++ b/engine/src/index/working_dir.rs @@ -0,0 +1,318 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::Arc, +}; + +use chrono::{DateTime, Utc}; +use path_absolutize::Absolutize; +use shared::{IsWithin, 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, Hash, PartialEq, Eq)] +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, + pub is_ignored: bool, +} + +/// 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 trait WorkingDir: Send + Sync { + /// Returns a reference to the vector of initialized `IgnoreService` instances. + fn get_ignore_services(&self) -> &Vec; + + /// Returns the absolute path to the root of the working directory. + 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`. + 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. + /// + /// 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). + fn collect_all_files_including_ignored(&self) -> Vec<(PathBuf, bool)>; + + /// Recursively searches for files under the given path, optionally including ignored files. + /// + /// 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 directory path to start the search. + /// * `include_ignored` - If `true`, files matching ignore patterns will be included in the result. + /// + /// # Returns + /// + /// A vector of tuples of type `(PathBuf, bool)`: + /// - `PathBuf`: file path. + /// - `bool`: whether the file is ignored (`true` if ignored). + fn collect_files(&self, path: &Path, include_ignored: bool) -> Vec<(PathBuf, bool)>; + + /// 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. + fn collect_files_with_metadata( + &self, + include_ignored: bool, + paths: Option<&[PathBuf]>, + ) -> EngineResult>; + + /// 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; + + /// 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; + + fn layout(&self) -> &Arc; +} + +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(), + } + } + + /// 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. + /// + /// 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 { + WalkDir::new(path) + .into_iter() + .filter_entry(move |e| { + let is_repo_dir = + e.file_type().is_dir() && e.path() == self.repo_layout.repository_dir(); + if is_repo_dir { + return false; + } + + if !include_ignored && e.file_type().is_dir() && self.is_path_ignored(e.path()) { + return false; + } + + 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)) + }) + } + + /// 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: &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), + Ok(IgnoreResult::Ignored { .. }) + ) + }) + } + + 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) + } + + fn layout(&self) -> &Arc { + &self.repo_layout + } +} diff --git a/engine/src/lib.rs b/engine/src/lib.rs index ab976c82..07eebe4b 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,15 +1,39 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +mod commit_builder; +mod serialize_deserialize; +mod traversal; -#[cfg(test)] -mod tests { - use super::*; - use rstest::*; +pub mod branch_manager; +pub mod config; +pub mod diff_builder; +pub mod engine_container; +pub mod errors; +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 restore_manager; +pub mod revision_parsing; - #[rstest] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +use errors::{EngineResult, InitError}; +use object_storage::ObjectStorage; +use ref_manager::RefManager; + +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 index::{Index, working_dir::WorkingDir}; +pub use repositories::{MevaRepository, RepositoryLayout}; +pub use revision_parsing::RevisionResolver; +pub use traversal::CommitTreeWalker; diff --git a/engine/src/network.rs b/engine/src/network.rs new file mode 100644 index 00000000..52f24268 --- /dev/null +++ b/engine/src/network.rs @@ -0,0 +1,21 @@ +mod client_handler; +mod common; +mod packfiles; +mod protocols; +mod remotes; +mod ssh_connection_params; +mod ssh_service; +mod ssh_session; + +use client_handler::ClientHandler; + +pub use common::{ + 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; +pub use ssh_session::SshSession; diff --git a/engine/src/network/client_handler.rs b/engine/src/network/client_handler.rs new file mode 100644 index 00000000..4d882106 --- /dev/null +++ b/engine/src/network/client_handler.rs @@ -0,0 +1,51 @@ +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 { + if server_public_key == &self.server_public_key { + 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 00000000..d760a6a2 --- /dev/null +++ b/engine/src/network/common.rs @@ -0,0 +1,18 @@ +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, 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/channel_band.rs b/engine/src/network/common/channel_band.rs new file mode 100644 index 00000000..9b06876e --- /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 00000000..7b9da5bd --- /dev/null +++ b/engine/src/network/common/pkt_line.rs @@ -0,0 +1,87 @@ +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, +/// 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}") +} + +/// 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 = 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); + 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 new file mode 100644 index 00000000..f50ad385 --- /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/common/session_extension.rs b/engine/src/network/common/session_extension.rs new file mode 100644 index 00000000..fa3df522 --- /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 00000000..aeb2c098 --- /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 00000000..49560b67 --- /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 00000000..88c678ae --- /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 00000000..cbcc14e1 --- /dev/null +++ b/engine/src/network/protocols.rs @@ -0,0 +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 00000000..325b1a11 --- /dev/null +++ b/engine/src/network/protocols/receive_pack.rs @@ -0,0 +1,273 @@ +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 00000000..053aa142 --- /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 00000000..7c5d747d --- /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 new file mode 100644 index 00000000..707611ee --- /dev/null +++ b/engine/src/network/protocols/upload_pack.rs @@ -0,0 +1,259 @@ +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; + +use std::str::from_utf8; + +use russh::{Channel, client::Msg}; + +use crate::{ + errors::{EngineResult, NetworkError}, + network::{PktLine, common::ChannelBand, create_pkt_line, parse_next_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, + + /// Flag indicating wheather the protocol is going to end after the Discover phase. + discovery_only: bool, +} + +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, + 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() + } + } + + /// 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, + verbose: bool, + ) -> EngineResult { + self.buffer.extend_from_slice(data); + + loop { + let packet = parse_next_pkt_line(&mut self.buffer)?; + + match packet { + PktLine::Incomplete => { + // Not enough data for a full packet, wait for next chunk. + break; + } + PktLine::Payload(payload) => { + self.handle_line(payload, verbose).await?; + } + PktLine::Flush => { + self.handle_flush(channel, verbose).await?; + } + } + + if matches!(self.state, UploadPackState::Complete) { + return Ok(true); + } + } + + Ok(false) + } + + /// Handles a parsed payload packet based on the current protocol state. + async fn handle_line(&mut self, payload: Vec, verbose: bool) -> EngineResult<()> { + match self.state { + UploadPackState::ReceivingRefs => { + let line_str = from_utf8(&payload).map_err(NetworkError::from)?; + + 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(); + 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)?; + + if line_str.starts_with("NAK") { + if verbose { + println!("Received NAK. Waiting for packfile..."); + } + self.state = UploadPackState::Packfile; + } else if line_str.starts_with("ACK") { + if verbose { + println!("Received ACK. Waiting for packfile..."); + } + self.state = UploadPackState::Packfile; + } else { + eprintln!("Unexpected line during negotiation: {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 => { + 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: ".dimmed(), progress_msg.green()); + } + 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()); + } + } + } + _ => {} + } + + Ok(()) + } + + /// Handles a "flush" packet (0000), which signals a transition or end of a list. + async fn handle_flush( + &mut self, + channel: &mut Channel, + verbose: bool, + ) -> EngineResult<()> { + match self.state { + UploadPackState::Discovery => { + if verbose { + println!("Finished discovery. Waiting for references..."); + } + self.state = UploadPackState::ReceivingRefs; + } + UploadPackState::ReceivingRefs => { + if verbose { + println!("Finished receiving references."); + } + + if self.discovery_only { + channel + .data(b"0000".as_ref()) + .await + .map_err(NetworkError::from)?; + + self.state = UploadPackState::Complete; + return Ok(()); + } + + self.wants = self + .refs + .iter() + .map(|ref_entry| ref_entry.commit_hash.clone()) + .unique() + .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)?; + } + + 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)?; + } + + 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)?; + + self.state = UploadPackState::Negotiation; + } + UploadPackState::Packfile => { + if verbose { + println!("Finished transferring packfile."); + } + self.state = 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 00000000..75c40e2f --- /dev/null +++ b/engine/src/network/protocols/upload_pack/upload_pack_result.rs @@ -0,0 +1,45 @@ +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, +} + +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/protocols/upload_pack/upload_pack_state.rs b/engine/src/network/protocols/upload_pack/upload_pack_state.rs new file mode 100644 index 00000000..b3ac49b9 --- /dev/null +++ b/engine/src/network/protocols/upload_pack/upload_pack_state.rs @@ -0,0 +1,28 @@ +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. + #[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/remotes.rs b/engine/src/network/remotes.rs new file mode 100644 index 00000000..fea831ef --- /dev/null +++ b/engine/src/network/remotes.rs @@ -0,0 +1,79 @@ +mod meva_remotes_manager; + +pub use meva_remotes_manager::MevaRemotesManager; + +use std::{collections::HashMap, fmt::Debug, 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 + Debug { + /// 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, + config_path: Option<&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, + new_server_key: &Path, + 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 00000000..e96a801e --- /dev/null +++ b/engine/src/network/remotes/meva_remotes_manager.rs @@ -0,0 +1,149 @@ +use std::{collections::HashMap, path::Path}; + +use shared::PathToString; +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. +#[derive(Debug)] +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"), + } + } + + /// 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, + pub_signing_key: &Path, + config_path: Option<&Path>, + ) -> EngineResult { + 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.clone(), 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, + 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() + } + + 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_connection_params.rs b/engine/src/network/ssh_connection_params.rs new file mode 100644 index 00000000..512f2bde --- /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 00000000..df771343 --- /dev/null +++ b/engine/src/network/ssh_service.rs @@ -0,0 +1,97 @@ +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, + 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)?); + + 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?; + if verbose { + 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()); + } + + 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)), + 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 00000000..a45fd4c8 --- /dev/null +++ b/engine/src/network/ssh_session.rs @@ -0,0 +1,364 @@ +use std::{collections::HashSet, mem, sync::Arc}; + +use russh::{ + Channel, ChannelMsg, + client::{Handle, Msg}, +}; + +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, + 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, + verbose: bool, + ) -> 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 remote_stderr = String::new(); + 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, verbose).await?; + + if is_complete { + if verbose { + println!("Protocol finished successfully."); + } + protocol_completed = true; + } + } + 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); + } + } + _ => {} + } + } + + if !protocol_completed { + return Err(NetworkError::ConnectionClosedPrematurely.into()); + } + + Ok(UploadPackResult::new( + mem::take(&mut protocol.packfile_data), + protocol.refs, + )) + } + + /// 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 remote_stderr = String::new(); + 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 { + 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); + } + } + _ => {} + } + } + + if !protocol_completed { + return Err(NetworkError::ConnectionClosedPrematurely.into()); + } + + Ok(protocol.result) + } + + /// 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, + verbose: bool, + ) -> 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 remote_stderr = String::new(); + 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, 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); + } + } + _ => {} + } + } + + if !protocol_completed { + return Err(NetworkError::ConnectionClosedPrematurely.into()); + } + + 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 new file mode 100644 index 00000000..1eec7187 --- /dev/null +++ b/engine/src/object_storage.rs @@ -0,0 +1,86 @@ +pub mod meva_dry_run_object_storage; +pub mod meva_object_storage; + +use std::collections::HashSet; +use std::sync::Arc; + +use crate::RepositoryLayout; +use crate::errors::EngineResult; +use crate::objects::{MevaBlob, MevaCommit, MevaObject, 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: Send + Sync { + /// 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; + + /// 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; + + /// 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>; + + /// 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 new file mode 100644 index 00000000..a854d348 --- /dev/null +++ b/engine/src/object_storage/meva_dry_run_object_storage.rs @@ -0,0 +1,82 @@ +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. +/// +/// `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 Default for MevaDryRunObjectStorage { + /// Creates a new dry-run object storage instance. + fn default() -> 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()) + } + + 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!() + } + + 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 new file mode 100644 index 00000000..a2ceb7f2 --- /dev/null +++ b/engine/src/object_storage/meva_object_storage.rs @@ -0,0 +1,262 @@ +use crate::ObjectStorage; +use crate::RepositoryLayout; +use crate::errors::{EngineResult, PathError}; +use crate::objects::{ + MevaBlob, MevaCommit, MevaObject, MevaObjectDecodeContext, MevaObjectType, MevaTree, +}; +use crate::serialize_deserialize::BinaryCompress; +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. +/// +/// `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 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 { + /// Provides access to repository paths such as the `objects` directory. + repo_layout: Arc, +} + +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: Arc) -> Self { + Self { repo_layout } + } + + /// Stores a [`MevaObject`] on disk under its SHA-1-derived path. + /// + /// 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. + /// + /// Returns the SHA-1 hash of the object. + /// + /// # Errors + /// + /// 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 object_path = self.object_path(hash); + + let parent = object_path.parent().ok_or(PathError::NoParent { + path: object_path.clone(), + })?; + if !parent.exists() { + 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)?; + } + 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(hash.clone()) + } + + /// 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, 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 ObjectStorage for MevaObjectStorage { + fn add_blob(&self, blob: MevaBlob) -> EngineResult { + let object = MevaObject::try_from(blob)?; + self.add_object(object) + } + + fn add_tree(&self, tree: MevaTree) -> EngineResult { + let object = MevaObject::try_from(tree)?; + self.add_object(object) + } + + fn add_commit(&self, commit: MevaCommit) -> EngineResult { + let object = MevaObject::try_from(commit)?; + self.add_object(object) + } + + 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) + } + + fn object_exists(&self, hash: &str) -> EngineResult { + let path = self.object_path(hash); + + Ok(path.try_exists()?) + } + + 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) + } + + 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/objects.rs b/engine/src/objects.rs new file mode 100644 index 00000000..295e32c2 --- /dev/null +++ b/engine/src/objects.rs @@ -0,0 +1,21 @@ +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 object_entry; +pub mod person; +pub mod tree_entry; +pub mod tree_entry_type; + +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 object_entry::ObjectEntry; +pub use person::Person; +pub use tree_entry::TreeEntry; +pub use tree_entry_type::TreeEntryType; diff --git a/engine/src/objects/meva_blob.rs b/engine/src/objects/meva_blob.rs new file mode 100644 index 00000000..6ce6035d --- /dev/null +++ b/engine/src/objects/meva_blob.rs @@ -0,0 +1,100 @@ +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; + +/// 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(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 } + } + + /// Reads the contents of the file at the given path into a new [`MevaBlob`]. + /// + /// # Errors + /// + /// 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(); + file.read_to_end(&mut buffer)?; + + Ok(Self::new(buffer)) + } + + /// Returns the size of the blob’s data in bytes. + pub fn size(&self) -> u64 { + self.data.len() as u64 + } + + /// 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()) + } +} + +/// Implements the conversion from a generic [`MevaObject`] to a specific [`MevaBlob`]. +/// +/// This is a fallible conversion that succeeds only if the object's type is `Blob`. +/// +/// # Errors +/// +/// - 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 { + 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()), + } + } +} + +/// Provides binary serialization and deserialization capabilities for `MevaBlob`. +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 00000000..ed0a8484 --- /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 00000000..7853cd9f --- /dev/null +++ b/engine/src/objects/meva_object.rs @@ -0,0 +1,323 @@ +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}; +use std::sync::Arc; + +/// 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` +#[derive(Clone)] +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: Arc, +} + +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. + /// + /// # 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. + /// + /// # 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: Arc::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<&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) + } +} + +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 00000000..874ded17 --- /dev/null +++ b/engine/src/objects/meva_object_decode_context.rs @@ -0,0 +1,19 @@ +use crate::Hasher; +use std::sync::Arc; + +/// 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/objects/meva_object_type.rs b/engine/src/objects/meva_object_type.rs new file mode 100644 index 00000000..b6a4e841 --- /dev/null +++ b/engine/src/objects/meva_object_type.rs @@ -0,0 +1,66 @@ +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`] +/// contains. This is used during serialization, hashing, and object interpretation. +/// +/// This enum is specifically designed for use within the Meva repository, +/// and is not intended as a general-purpose type. +/// +/// # 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, PartialEq, Eq)] +pub enum MevaObjectType { + /// A raw data blob (e.g., file contents). + #[default] + 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, +} + +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/objects/meva_tree.rs b/engine/src/objects/meva_tree.rs new file mode 100644 index 00000000..0ce162e8 --- /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/object_entry.rs b/engine/src/objects/object_entry.rs new file mode 100644 index 00000000..e31c692a --- /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/objects/person.rs b/engine/src/objects/person.rs new file mode 100644 index 00000000..a376f5ab --- /dev/null +++ b/engine/src/objects/person.rs @@ -0,0 +1,102 @@ +use bincode::{Decode, Encode}; +use regex::Regex; +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, Serialize, Deserialize, PartialEq, Eq)] +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, +} + +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 +/// 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 00000000..97670907 --- /dev/null +++ b/engine/src/objects/tree_entry.rs @@ -0,0 +1,53 @@ +use crate::index::file_mode::FileMode; +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, 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) + } +} diff --git a/engine/src/objects/tree_entry_type.rs b/engine/src/objects/tree_entry_type.rs new file mode 100644 index 00000000..085d3576 --- /dev/null +++ b/engine/src/objects/tree_entry_type.rs @@ -0,0 +1,62 @@ +use crate::index::file_mode::FileMode; +use bincode::{Decode, Encode}; +use std::fmt::Display; + +/// Represents the type and mode of an entry stored in a [`TreeEntry`](crate::objects::tree_entry::TreeEntry). +/// +/// 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 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 = 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", + }; + + write!(f, "{mode:06o} {type_name:<6}") + } +} diff --git a/engine/src/plugins_interceptor.rs b/engine/src/plugins_interceptor.rs new file mode 100644 index 00000000..9186108a --- /dev/null +++ b/engine/src/plugins_interceptor.rs @@ -0,0 +1,233 @@ +use std::{env, fs, path::PathBuf, sync::Arc}; + +use chrono::Utc; +use plugins::{ + CommandType, EventType, InvocationContext, InvocationInput, InvocationOutput, + InvocationPostPayload, InvocationPrePayload, PluginError, PluginsEngine, PluginsRunner, + ScopeType, +}; +use shared::UpwardSearch; + +use crate::{ + ConfigLoader, ConfigLoaderExtensions, 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 { + /// The engine responsible for managing plugin lifecycle and execution. + pub plugins_engine: Arc, + + pub repository_layout: Arc, + + /// Configuration loader used for plugin-related settings. + pub config_loader: Arc, +} + +impl PluginsInterceptor { + /// Creates a new `PluginsInterceptor` with the provided plugin engine and repository layout. + pub fn new( + plugins_engine: Arc, + repository_layout: Arc, + config_loader: Arc, + ) -> Self { + Self { + plugins_engine, + repository_layout, + config_loader, + } + } + + // 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 mut runner = PluginsRunner::new( + invocation_file.clone(), + plugins_dir.join(command.to_path()).clone(), + logger, + ); + + let pre_outputs = runner.run_plugins_for_event( + &local_plugins, + &global_plugins, + &EventType::PreExecute, + )?; + + let mut invocation_input = + self.handle_last_plugin_result(pre_outputs.last(), &invocation_file)?; + + 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, + )?; + + let invocation_input = + self.handle_last_plugin_result(post_outputs.last(), &invocation_file)?; + + 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 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_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() + { + 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) => format!("{err}"), + None => format!("Exited with code {}", last.status_code), + }; + + return Err(EngineError::Plugins(PluginError::Unknown { + plugin: last.plugin.clone(), + message: unknown_msg, + })); + } + + Ok(invocation_input) + } +} diff --git a/engine/src/ref_manager.rs b/engine/src/ref_manager.rs new file mode 100644 index 00000000..26b6e940 --- /dev/null +++ b/engine/src/ref_manager.rs @@ -0,0 +1,168 @@ +mod branch_ref_entry; + +pub mod head; +pub mod head_mode; +pub mod meva_ref_manager; +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; +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: 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; + + /// 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<()>; + + /// 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/`). + /// + /// 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 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>; + + /// 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; + + /// 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 + /// 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<()>; + + /// 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 + /// + /// * `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 00000000..9089e86a --- /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/head.rs b/engine/src/ref_manager/head.rs new file mode 100644 index 00000000..75eb733a --- /dev/null +++ b/engine/src/ref_manager/head.rs @@ -0,0 +1,73 @@ +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(), + } + } + + /// Extracts the branch name if the HEAD is in symbolic mode. + pub fn extract_branch_name(&self) -> Option { + match self.mode == HeadMode::Symbolic { + true => { + let parts = self.target.splitn(3, '/').collect::>(); + + if parts.len() != 3 { + return None; + } + + Some(parts[2].trim().to_string()) + } + 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 { + 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 00000000..ecb1e7b7 --- /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 00000000..1c76f406 --- /dev/null +++ b/engine/src/ref_manager/meva_ref_manager.rs @@ -0,0 +1,263 @@ +use owo_colors::OwoColorize; +use walkdir::WalkDir; + +use crate::RepositoryLayout; +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}, + path::{Path, PathBuf}, + sync::Arc, +}; + +/// 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 { + /// Provides access to repository paths such as `.meva/HEAD` and `refs/`. + repo_layout: Arc, +} + +impl MevaRefManager { + /// Creates a new reference manager instance for a given repository layout. + pub fn new(repo_layout: Arc) -> 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() + .create(true) + .write(true) + .truncate(true) + .open(path)?; + 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 { + 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) + } + + fn resolve_commit_hash(&self, head: &Head) -> EngineResult> { + let hash = match head.mode { + HeadMode::Direct => Some(head.target.clone()), + HeadMode::Symbolic => self.read_ref(&head.target)?.map(|e| e.commit_hash), + }; + + Ok(hash) + } + + 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(()) + } + + 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)) + } + + fn collect_refs_heads(&self) -> EngineResult> { + self.collect_refs(self.repo_layout.heads_refs_dir()) + } + + fn collect_refs_remotes(&self, origin: &str) -> EngineResult> { + 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 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(),); + + 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() })?; + + fs::create_dir_all(parent)?; + + self.write_file(&path, &entry.to_json()?) + } + + 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)?; + + 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 new file mode 100644 index 00000000..436bba73 --- /dev/null +++ b/engine/src/ref_manager/ref_entry.rs @@ -0,0 +1,172 @@ +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}; + +/// 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(Debug, Clone, Serialize, Deserialize, Eq, Hash, PartialEq)] +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 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) -> 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, + /// automatically applying the correct system prefix (`refs/heads/` or `refs/remotes/`). + /// + /// This method 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) -> EngineResult { + 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()) + } + } + } + + /// 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) -> 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) -> 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) + } +} + +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.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.rs b/engine/src/repositories.rs new file mode 100644 index 00000000..153583e5 --- /dev/null +++ b/engine/src/repositories.rs @@ -0,0 +1,6 @@ +pub mod meva_repository; +pub mod meva_repository_layout; +pub mod repository_layout; + +pub use meva_repository::MevaRepository; +pub 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 00000000..f355f21c --- /dev/null +++ b/engine/src/repositories/meva_repository.rs @@ -0,0 +1,464 @@ +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + sync::Arc, +}; + +use tempfile::TempDir; + +use crate::{ + EngineResult, InitError, errors::NetworkError, handlers::clone::Request as CloneRequest, + network::RemotesManager, +}; +use crate::{ + config::config_loader::ConfigLoader, + ref_manager::{Head, HeadMode, RefEntry, RefManager}, + repositories::RepositoryLayout, +}; +use crate::{ + object_storage::ObjectStorage, objects::MevaObject, serialize_deserialize::MevaEncode, +}; + +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: 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], + request: &CloneRequest, + ) -> EngineResult>; + + /// Returns the layout strategy used by this repository. + /// + /// The layout determines the physical locations of files. + 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 { + /// Strategy for resolving file paths within the repository. + layout: Arc, + + /// Component responsible for reading and writing configuration files. + config_loader: Arc, + + /// Backend storage for repository objects (blobs, trees, commits). + object_storage: Arc, + + /// Manager for branch and reference handling within the repository. + ref_manager: Arc, + + /// Manager for configuring remotes. + remotes_manager: Arc, +} + +impl MevaRepository { + /// Creates a new `MevaRepository` for the given working directory. + pub fn new( + layout: Arc, + config_loader: Arc, + object_storage: Arc, + ref_manager: Arc, + remotes_manager: Arc, + ) -> Self { + Self { + layout, + config_loader, + object_storage, + ref_manager, + remotes_manager, + } + } + + /// 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 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.remotes_refs_dir_rel(), + ]; + + for dir in dirs { + fs::create_dir_all(root.join(dir))?; + } + + let config_path = root.join(self.layout.config_file_rel()); + self.config_loader.create_local_config(&config_path)?; + + Ok(config_path) + } + + /// 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 heads_refs_prefix = self.ref_manager.heads_refs_prefix(); + let target_ref = format!("{heads_refs_prefix}{branch_name}"); + + let head = Head { + mode: HeadMode::Symbolic, + target: target_ref, + }; + + write!(head_file, "{}", head.to_json()?)?; + + 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>, + 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 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.unwrap_or(""))?; + + 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)?; + } + + if commit_hash.is_some() { + fs::write(&ref_path, entry.to_json()?)?; + } else { + fs::File::create(&ref_path)?; + } + + ref_entries.push(entry); + } + + 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 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)?; + } + + if commit_hash.is_some() { + fs::write(&ref_path, entry.to_json()?)?; + } else { + fs::File::create(&ref_path)?; + } + + 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) + } +} + +impl Repository for MevaRepository { + /// 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(None)?; + + let tmp_parent = self.layout.working_dir(); + let tmp_dir = TempDir::new_in(tmp_parent)?; + + let tmp_working_dir = tmp_dir.path(); + + self.initialize_structure(tmp_working_dir)?; + 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()); + 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. + /// + /// 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 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)], + refs: &[RefEntry], + request: &CloneRequest, + ) -> EngineResult> { + self.check_if_exists()?; + + let working_dir = self.layout.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)?; + } + + 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, + &request.origin, + )?; + + self.setup_head( + &working_dir, + initial_branch_name.trim_start_matches(&heads_refs_prefix), + )?; + + Ok(ref_entries) + } + + fn layout(&self) -> &Arc { + &self.layout + } +} + +#[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; + + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + use std::fs; + use std::io::Read; + use std::path::PathBuf; + 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 + } + + 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); + + let repo = MevaRepository::new( + layout, + config_loader, + object_storage, + ref_manager, + remotes_manager, + ); + + Ok(repo) + } + + #[rstest] + fn init_creates_expected_structure() -> EngineResult<()> { + let tmp = TempDir::new().expect("failed to create TempDir"); + let repo = get_repo(tmp.path())?; + + let result = repo.init(None); + assert!(result.is_ok()); + + let repo_dir = tmp.path().join(".meva"); + assert!(repo_dir.exists()); + + assert!(repo.layout.objects_dir().exists()); + assert!(repo.layout.refs_dir().exists()); + assert!(repo.layout.heads_refs_dir().exists()); + + let head_path = repo.layout.head_file(); + assert!(head_path.exists()); + + let head_contents = read_file(&head_path); + assert_eq!(head_contents, Head::default().to_json()?); + + let branch_ref = repo.layout.heads_refs_dir().join("master"); + assert!(branch_ref.exists()); + + let config_path = repo.layout.config_file(); + assert!(config_path.exists()); + + let config_content = read_file(&config_path); + assert_eq!( + config_content, + repo.config_loader.get_default_local_config() + ); + + Ok(()) + } + + #[rstest] + fn init_fails_if_repo_already_exists() -> EngineResult<()> { + let tmp = TempDir::new().expect("failed to create TempDir"); + let repo = get_repo(tmp.path())?; + + // First init succeeds + assert!(repo.init(Some("dev")).is_ok()); + + // Second init should fail + let result = repo.init(Some("dev")); + assert!(result.is_err()); + + if let Err(err) = result { + let message = format!("{err}"); + assert!( + message.contains("already initialized"), + "Unexpected error: {message}" + ); + } + + Ok(()) + } +} diff --git a/engine/src/repositories/meva_repository_layout.rs b/engine/src/repositories/meva_repository_layout.rs new file mode 100644 index 00000000..137211d3 --- /dev/null +++ b/engine/src/repositories/meva_repository_layout.rs @@ -0,0 +1,116 @@ +use crate::RepositoryLayout; +use crate::errors::{EngineResult, InitError, PathError, RepositoryError}; +use shared::{PathToString, UpwardSearch}; +use std::sync::RwLock; +use std::{env, path::PathBuf}; + +#[derive(Debug)] +pub struct MevaRepositoryLayout { + pub working_dir: RwLock, +} + +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_utf8_string(), + } + .into()); + } + 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: RwLock::new(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 { + fn repository_dir_name(&self) -> &str { + ".meva" + } + + fn objects_dir_name(&self) -> &str { + "objects" + } + + fn refs_dir_name(&self) -> &str { + "refs" + } + + fn heads_dir_name(&self) -> &str { + "heads" + } + + fn remotes_dir_name(&self) -> &str { + "remotes" + } + + fn plugins_dir_name(&self) -> &str { + "plugins" + } + + fn head_file_name(&self) -> &str { + "HEAD" + } + + fn config_file_name(&self) -> &str { + "mevaconfig" + } + + fn ignore_file_name(&self) -> &str { + ".mevaignore" + } + + fn index_file_name(&self) -> &str { + "index" + } + + /// Returns the repository’s working directory path. + fn working_dir(&self) -> PathBuf { + self.working_dir.read().unwrap().clone() + } + + 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 new file mode 100644 index 00000000..8dca574c --- /dev/null +++ b/engine/src/repositories/repository_layout.rs @@ -0,0 +1,243 @@ +use std::fmt::Debug; +use std::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: Send + Sync + Debug { + /// Name of the root repository directory. + fn repository_dir_name(&self) -> &str; + + /// Subdirectory for storing objects. + fn objects_dir_name(&self) -> &str; + + /// Subdirectory for storing references. + fn refs_dir_name(&self) -> &str; + + /// Subdirectory for storing heads references. + 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; + + /// Name of the HEAD file. + fn head_file_name(&self) -> &str; + + /// Name of the config file. + fn config_file_name(&self) -> &str; + + /// 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) -> PathBuf; + + /// Sets a new working directory for the repository. + fn set_working_dir(&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_name()) + } + + /// Path to the objects directory relative to `working_dir`. + fn objects_dir_rel(&self) -> PathBuf { + 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_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_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 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_name()) + } + + /// Path to the config file relative to `working_dir`. + fn config_file_rel(&self) -> PathBuf { + self.repository_dir_rel().join(self.config_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. + 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 remotes in refs. + fn remotes_refs_dir(&self) -> PathBuf { + self.working_dir().join(self.remotes_refs_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()) + } + + /// Absolute path to the config file. + fn config_file(&self) -> PathBuf { + self.working_dir().join(self.config_file_rel()) + } + + /// Absolute path to the index file. + fn index_file(&self) -> PathBuf { + self.working_dir().join(self.index_file_rel()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + use std::{path::PathBuf, sync::RwLock}; + + #[derive(Debug)] + struct TestRepoLayout { + dir: RwLock, + } + + impl TestRepoLayout { + fn new>(path: P) -> Self { + Self { + dir: RwLock::new(path.into()), + } + } + } + + impl RepositoryLayout for TestRepoLayout { + fn repository_dir_name(&self) -> &str { + ".meva" + } + + fn objects_dir_name(&self) -> &str { + "objects" + } + + fn refs_dir_name(&self) -> &str { + "refs" + } + + fn heads_dir_name(&self) -> &str { + "heads" + } + + fn remotes_dir_name(&self) -> &str { + "remotes" + } + + fn plugins_dir_name(&self) -> &str { + "plugins" + } + + fn head_file_name(&self) -> &str { + "HEAD" + } + + fn config_file_name(&self) -> &str { + "mevaconfig" + } + + fn ignore_file_name(&self) -> &str { + ".mevaignore" + } + + fn index_file_name(&self) -> &str { + "index" + } + + fn working_dir(&self) -> PathBuf { + self.dir.read().unwrap().clone() + } + + fn set_working_dir(&self, new_working_dir: PathBuf) { + let mut w = self.dir.write().unwrap(); + *w = new_working_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.remotes_refs_dir_rel(), + PathBuf::from(".meva/refs/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")); + + // 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.remotes_refs_dir(), base.join(".meva/refs/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/engine/src/restore_manager.rs b/engine/src/restore_manager.rs new file mode 100644 index 00000000..4e792764 --- /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 00000000..8203c069 --- /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 new file mode 100644 index 00000000..5306e3b0 --- /dev/null +++ b/engine/src/revision_parsing.rs @@ -0,0 +1,13 @@ +mod base_reference; +mod meva_revision_resolver; +mod revision; +mod revision_modifier; +mod revision_parse_error; +mod revision_resolver; + +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/base_reference.rs b/engine/src/revision_parsing/base_reference.rs new file mode 100644 index 00000000..8eb7992c --- /dev/null +++ b/engine/src/revision_parsing/base_reference.rs @@ -0,0 +1,22 @@ +/// 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, 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), + /// 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/meva_revision_resolver.rs b/engine/src/revision_parsing/meva_revision_resolver.rs new file mode 100644 index 00000000..5dd80b89 --- /dev/null +++ b/engine/src/revision_parsing/meva_revision_resolver.rs @@ -0,0 +1,122 @@ +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.rs b/engine/src/revision_parsing/revision.rs new file mode 100644 index 00000000..ce21672e --- /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, 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, + + /// 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 00000000..3cf39056 --- /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 00000000..a51df30d --- /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/revision_parsing/revision_resolver.rs b/engine/src/revision_parsing/revision_resolver.rs new file mode 100644 index 00000000..a171888d --- /dev/null +++ b/engine/src/revision_parsing/revision_resolver.rs @@ -0,0 +1,27 @@ +use super::Revision; +use crate::errors::EngineResult; +use crate::objects::MevaObject; + +pub trait RevisionResolver: Send + Sync { + /// Resolves a revision to its commit hash. + /// + /// # 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. + fn resolve_hash(&self, revision: &Revision) -> EngineResult; + + /// Resolves a revision to the corresponding repository object ([`MevaObject`]). + /// + /// # 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. + fn resolve_object(&self, revision: &Revision) -> EngineResult; +} diff --git a/engine/src/serialize_deserialize.rs b/engine/src/serialize_deserialize.rs new file mode 100644 index 00000000..bcfdc0ec --- /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 00000000..7b17ab83 --- /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 00000000..eb48da07 --- /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/traversal.rs b/engine/src/traversal.rs new file mode 100644 index 00000000..1b538b36 --- /dev/null +++ b/engine/src/traversal.rs @@ -0,0 +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 00000000..dc33e227 --- /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 new file mode 100644 index 00000000..bc5f7549 --- /dev/null +++ b/engine/src/traversal/meva_commit_history_walker.rs @@ -0,0 +1,117 @@ +use crate::errors::EngineResult; +use crate::object_storage::ObjectStorage; +use crate::objects::MevaCommit; +use crate::traversal::{CommitComparison, CommitHistoryWalker}; +use std::{collections::HashMap, 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 commit = self + .object_storage + .get_object(&hash) + .and_then(MevaCommit::try_from)?; + + for parent in commit.parents { + queue.push_back(parent); + } + + ancestors.push(hash); + } + + 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/meva_commit_tree_walker.rs b/engine/src/traversal/meva_commit_tree_walker.rs new file mode 100644 index 00000000..48c071c5 --- /dev/null +++ b/engine/src/traversal/meva_commit_tree_walker.rs @@ -0,0 +1,188 @@ +use super::CommitTreeWalker; +use crate::errors::EngineResult; +use crate::object_storage::ObjectStorage; +use crate::objects::{MevaBlob, MevaCommit, MevaTree, ObjectEntry, TreeEntry, TreeEntryType}; +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. +/// +/// 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 { + /// The object storage backend providing access to commit, tree, and blob objects. + object_storage: Arc, +} + +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: Arc) -> 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.is_within(entry_path).unwrap_or(false) || entry_path.is_within(f).unwrap_or(false) + }), + 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.is_within(f).unwrap_or(false)), + 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 CommitTreeWalker for MevaCommitTreeWalker { + /// 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 00000000..77375c2f --- /dev/null +++ b/engine/src/traversal/traits.rs @@ -0,0 +1,94 @@ +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 +/// 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: Send + Sync { + /// 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>; +} + +/// 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 +/// 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>; + + /// 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; +} diff --git a/gui/Cargo.toml b/gui/Cargo.toml index ef803a48..710424ba 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -4,10 +4,25 @@ 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"] } 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 +url.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/gui/assets/feather.png b/gui/assets/feather.png new file mode 100644 index 00000000..80e11520 Binary files /dev/null and b/gui/assets/feather.png differ diff --git a/gui/src/events.rs b/gui/src/events.rs new file mode 100644 index 00000000..5642c97c --- /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 00000000..cff645fb --- /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 00000000..56047ad8 --- /dev/null +++ b/gui/src/events/worker_result.rs @@ -0,0 +1,110 @@ +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 plugins::PluginConfiguration; + +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 }, + + /// 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/icons.rs b/gui/src/icons.rs new file mode 100644 index 00000000..d80a7fa2 --- /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 00000000..f265ae8f --- /dev/null +++ b/gui/src/lib.rs @@ -0,0 +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 e7a11a96..b336f427 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -1,3 +1,27 @@ +use eframe::egui; +use gui::{MevaGui, icons::load_icon}; + fn main() { - println!("Hello, world!"); + eframe::run_native( + "MEVA GUI", + configure_frame_options(), + Box::new(|context| { + context.egui_ctx.set_theme(egui::Theme::Dark); + Ok(Box::new(MevaGui::new(context))) + }), + ) + .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([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 new file mode 100644 index 00000000..02a499d0 --- /dev/null +++ b/gui/src/meva_gui.rs @@ -0,0 +1,349 @@ +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, + checkout_branch_form: CheckoutBranchForm, + + 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.checkout_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| { + 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| { + 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.checkout_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.rs b/gui/src/ui.rs new file mode 100644 index 00000000..5dc45a8b --- /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 00000000..d80c147a --- /dev/null +++ b/gui/src/ui/components.rs @@ -0,0 +1,85 @@ +mod branch_status; +mod buttons; +mod dialogs; +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/branch_status.rs b/gui/src/ui/components/branch_status.rs new file mode 100644 index 00000000..0bfc1b9b --- /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 00000000..684afbbf --- /dev/null +++ b/gui/src/ui/components/buttons.rs @@ -0,0 +1,17 @@ +mod commit_button; +mod icon_button; +mod more_actions_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 more_actions_button::MoreActionsButton; +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 00000000..2119a221 --- /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 00000000..8ec967ca --- /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/more_actions_button.rs b/gui/src/ui/components/buttons/more_actions_button.rs new file mode 100644 index 00000000..cc3864a7 --- /dev/null +++ b/gui/src/ui/components/buttons/more_actions_button.rs @@ -0,0 +1,141 @@ +use std::{sync::Arc, thread}; + +use egui::{RichText, Ui}; +use egui_phosphor::regular as icons; + +use engine::engine_container::MevaContainer; + +use crate::{ + events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}, + ui::execute_full_refresh, +}; + +/// 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 report = |msg: &str| { + let _ = tx.send(WorkerEvent::Progress(msg.to_string())); + ctx_clone.request_repaint(); + }; + + match execute_full_refresh(container, 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 report = |msg: &str| { + let _ = tx.send(WorkerEvent::Progress(msg.to_string())); + ctx_clone.request_repaint(); + }; + + match Self::execute_abort_merge(container, 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: impl Fn(&str), + ) -> Result { + report("Aborting merge..."); + 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())?; + + execute_full_refresh(container, report) + } +} 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 00000000..cc8826bf --- /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 00000000..95bc3061 --- /dev/null +++ b/gui/src/ui/components/buttons/open_repository_button.rs @@ -0,0 +1,114 @@ +use std::{path::PathBuf, sync::Arc, thread}; + +use egui_phosphor::regular as icons; + +use engine::engine_container::MevaContainer; +use shared::change_directory; + +use crate::{ + events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}, + ui::execute_full_refresh, +}; + +/// 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 report = |msg: &str| { + let _ = tx.send(WorkerEvent::Progress(msg.to_string())); + ctx_clone.request_repaint(); + }; + + match Self::execute_open_logic(container, path, 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: impl Fn(&str), + ) -> Result { + 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())?; + + 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 new file mode 100644 index 00000000..25149579 --- /dev/null +++ b/gui/src/ui/components/buttons/refresh_status_button.rs @@ -0,0 +1,78 @@ +use std::sync::Arc; + +use egui_phosphor::regular as icons; + +use engine::engine_container::MevaContainer; + +use crate::{ + events::{AsyncWorker, EventError, WorkerEvent}, + ui::execute_full_refresh, +}; + +/// 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 report = |msg: &str| { + let _ = tx.send(WorkerEvent::Progress(msg.to_string())); + ctx_clone.request_repaint(); + }; + + match execute_full_refresh(container, 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(); + }); + } +} 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 00000000..f4c17a39 --- /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 00000000..42bf6687 --- /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 00000000..3118c971 --- /dev/null +++ b/gui/src/ui/components/dialogs.rs @@ -0,0 +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; + +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}; diff --git a/gui/src/ui/components/dialogs/checkout_branch_dialog.rs b/gui/src/ui/components/dialogs/checkout_branch_dialog.rs new file mode 100644 index 00000000..e175a8bc --- /dev/null +++ b/gui/src/ui/components/dialogs/checkout_branch_dialog.rs @@ -0,0 +1,391 @@ +use egui::{Align, Context, Layout, RichText, TextEdit, Ui}; +use egui_phosphor::regular as icons; +use std::sync::Arc; + +use crate::{ + events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}, + ui::execute_full_refresh, +}; +use engine::{ + EngineContainer, + branch_manager::{BranchInfo, BranchType}, + engine_container::MevaContainer, + handlers::{ + branch::{BranchCollection, BranchOperations, CreateRequest, Request as BranchRequest}, + checkout::{CheckoutOperations, Request}, + }, + revision_parsing::Revision, +}; + +/// 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 "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 CheckoutBranchForm { + /// 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 CheckoutBranchForm { + /// 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 operations (checkout/create) without freezing the UI. +pub struct SwitchBranchDialog<'a> { + /// Mutable reference to the form state. + 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, + /// Egui context for repainting. + ctx: &'a Context, +} + +impl<'a> SwitchBranchDialog<'a> { + /// Creates a new [`SwitchBranchDialog`]. + pub fn new( + form: &'a mut CheckoutBranchForm, + branches: &'a Option, + worker: &'a mut AsyncWorker, + container: &'a Arc, + ctx: &'a Context, + ) -> Self { + Self { + form, + branches, + worker, + 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_button(ui, enabled); + }); + } + + /// Renders the button that triggers creation of a new branch. + fn show_create_button(&mut self, ui: &mut Ui, enabled: bool) { + if ui + .add_enabled(enabled, egui::Button::new("Create")) + .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; + 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(); + }; + + report(&format!("Checking out '{branch_name}'...")); + std::thread::sleep(std::time::Duration::from_millis(500)); + + 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; + self.form.reset(); + let branch_name = self.form.new_branch_name.clone(); + let point = self.form.start_point.clone(); + + 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(); + }; + + report(&format!("Creating branch '{branch_name}'...")); + std::thread::sleep(std::time::Duration::from_millis(500)); + + 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 new file mode 100644 index 00000000..769d7bf0 --- /dev/null +++ b/gui/src/ui/components/dialogs/clone_repository_dialog.rs @@ -0,0 +1,283 @@ +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::clone::Request as CloneRequest, +}; +use shared::{PathToString, change_directory}; +use url::Url; + +use crate::{ + events::{AsyncWorker, EventError, WorkerEvent, WorkerResult}, + ui::execute_full_refresh, +}; + +/// 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())?; + + 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 new file mode 100644 index 00000000..a2885b53 --- /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 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 00000000..210965ed --- /dev/null +++ b/gui/src/ui/components/dialogs/create_repository_dialog.rs @@ -0,0 +1,250 @@ +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::{PathToString, 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 = 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); + + 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); + } + } + + /// 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 `.meva` 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 00000000..313b7c1c --- /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 00000000..b21555ef --- /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/register_plugin_dialog.rs b/gui/src/ui/components/dialogs/register_plugin_dialog.rs new file mode 100644 index 00000000..5ca3bac6 --- /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/file_changes.rs b/gui/src/ui/components/file_changes.rs new file mode 100644 index 00000000..a515c52c --- /dev/null +++ b/gui/src/ui/components/file_changes.rs @@ -0,0 +1,585 @@ +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::{ + ConfigLoader, EngineContainer, MevaConfigLoader, + 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::{OpenInEditor, 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. +/// 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, + ); + + // 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, + &Some(unmerged_files), + "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 `+`, Unstage `-` or Mark as Resolved). + fn show_primary_action_button( + &mut self, + ui: &mut Ui, + entry: &StatusEntry, + is_section_staged: bool, + ) { + 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 + .add(egui::Button::new(RichText::new(icon).size(14.0)).frame(false)) + .on_hover_text(tooltip) + .clicked() + { + 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); + } + } + } + + /// 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(140.0); + + self.show_diff_menu_item(ui, entry, is_section_staged); + + 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 */ } + } + }, + ); + } + + /// 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 (`meva restore`). + /// + /// **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())?; + 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(); + }); + } + + /// 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: + /// 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/navigation.rs b/gui/src/ui/components/navigation.rs new file mode 100644 index 00000000..a6a2f592 --- /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/components/remote_actions.rs b/gui/src/ui/components/remote_actions.rs new file mode 100644 index 00000000..f2f67b4f --- /dev/null +++ b/gui/src/ui/components/remote_actions.rs @@ -0,0 +1,190 @@ +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, push::Request as PushRequest, 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) { + // TODO: Implement sync operation + self.handle_fetch_click(upstream.clone(), branch.clone()); + } + + if IconButton::new(icons::ARROW_UP, "Push to upstream").show(ui) { + self.handle_push_click(upstream.clone(), branch.clone()); + } + + if IconButton::new(icons::ARROW_DOWN, "Pull from upstream").show(ui) { + // TODO: Implement pull operation + self.handle_fetch_click(upstream.clone(), branch.clone()); + } + + if IconButton::new(icons::DOWNLOAD_SIMPLE, "Fetch from upstream").show(ui) { + self.handle_fetch_click(upstream.clone(), branch.clone()); + } + + 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 = Self::create_runtime()?; + + rt.block_on(async { + let fetch_handler = container.fetch_handler().map_err(|e| e.to_string())?; + let request = FetchRequest::new(upstream, Some(branch)); + 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 00000000..a643189d --- /dev/null +++ b/gui/src/ui/views.rs @@ -0,0 +1,49 @@ +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). +/// +/// 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); +} + +/// 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/dashboard.rs b/gui/src/ui/views/dashboard.rs new file mode 100644 index 00000000..96278761 --- /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 00000000..ce8df27b --- /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 00000000..8b665c9e --- /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/plugins.rs b/gui/src/ui/views/plugins.rs new file mode 100644 index 00000000..9383639f --- /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("editor.default", 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/gui/src/ui/views/settings.rs b/gui/src/ui/views/settings.rs new file mode 100644 index 00000000..336598c8 --- /dev/null +++ b/gui/src/ui/views/settings.rs @@ -0,0 +1,263 @@ +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 config_request = ConfigListRequest { + location: location.clone(), + }; + let config = handler + .handle_list(config_request) + .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 00000000..cce86d8d --- /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 00000000..a6b06b24 --- /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/plugins/Cargo.toml b/plugins/Cargo.toml index 1daa0f5b..1c9c9f66 100644 --- a/plugins/Cargo.toml +++ b/plugins/Cargo.toml @@ -6,6 +6,14 @@ 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" +owo-colors.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/plugins/src/enums.rs b/plugins/src/enums.rs new file mode 100644 index 00000000..1df7c8fc --- /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 00000000..049286f0 --- /dev/null +++ b/plugins/src/enums/command_type.rs @@ -0,0 +1,54 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use strum_macros::{Display, EnumIter, EnumString, VariantNames}; + +/// Defines the command types that plugins can be registered to. +#[derive( + 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, + ConfigUnset, + ConfigList, + 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/event_type.rs b/plugins/src/enums/event_type.rs new file mode 100644 index 00000000..56ec386d --- /dev/null +++ b/plugins/src/enums/event_type.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; +use strum_macros::{Display, EnumIter, 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, + Default, + Deserialize, + Serialize, + Clone, + PartialEq, + Eq, + EnumString, + VariantNames, + EnumIter, + 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. + #[default] + PostExecute, +} diff --git a/plugins/src/enums/invocation_payload.rs b/plugins/src/enums/invocation_payload.rs new file mode 100644 index 00000000..2ae4933f --- /dev/null +++ b/plugins/src/enums/invocation_payload.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; + +use crate::models::payloads::*; + +/// 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(tag = "type", rename_all = "kebab-case")] +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 `add` command. + Add(AddPrePayload), + + /// Context for the `commit` command + Commit(CommitPrePayload), +} + +/// 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(tag = "type", rename_all = "kebab-case")] +pub enum InvocationPostPayload { + /// Context for the `init` command. + Init(InitPostPayload), + + /// Context for the `config set` command. + ConfigSet(ConfigSetPostPayload), + + /// Context for the `config unset` command. + ConfigUnset(ConfigUnsetPostPayload), + + /// Context for the `add` command. + Add(AddPostPayload), + + /// Context for the `commit` command. + Commit(Box), +} diff --git a/plugins/src/enums/scope_type.rs b/plugins/src/enums/scope_type.rs new file mode 100644 index 00000000..5d6c7203 --- /dev/null +++ b/plugins/src/enums/scope_type.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; +use strum_macros::{Display, EnumIter, EnumString, VariantNames}; + +/// Defines the scope of plugins. +#[derive( + Debug, + Default, + Deserialize, + Serialize, + Clone, + PartialEq, + Eq, + EnumString, + VariantNames, + EnumIter, + 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. + #[default] + Global, +} diff --git a/plugins/src/errors.rs b/plugins/src/errors.rs new file mode 100644 index 00000000..c1b86ed6 --- /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 00000000..50a243b2 --- /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 00000000..6835b040 --- /dev/null +++ b/plugins/src/errors/invocation_error.rs @@ -0,0 +1,31 @@ +use std::fmt::{Display, Formatter, Result}; + +use serde::{Deserialize, Serialize}; +use shared::PrettyField; + +/// 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, +} + +/// 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 new file mode 100644 index 00000000..bdd21c2e --- /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 in '{plugin}':\n{message}")] + Unknown { plugin: String, message: String }, +} diff --git a/plugins/src/errors/register_error.rs b/plugins/src/errors/register_error.rs new file mode 100644 index 00000000..967f7342 --- /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 00000000..689fa1cc --- /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: Send + Sync { + /// 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 ab976c82..4971a741 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, RegisterError}; +pub use layout::{MevaPluginsLayout, PluginsLayout}; +pub use models::*; +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/models.rs b/plugins/src/models.rs new file mode 100644 index 00000000..9ccbee5d --- /dev/null +++ b/plugins/src/models.rs @@ -0,0 +1,12 @@ +mod invocation; +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; +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 00000000..528b50ac --- /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 00000000..3ed4d8a6 --- /dev/null +++ b/plugins/src/models/invocation/invocation_context.rs @@ -0,0 +1,46 @@ +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: timestamp.unwrap_or_else(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 00000000..e2ba7343 --- /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 00000000..fccd2161 --- /dev/null +++ b/plugins/src/models/invocation/invocation_logger.rs @@ -0,0 +1,107 @@ +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<()> { + 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<()> { + 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 00000000..d95141d0 --- /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 00000000..fcf3ecb2 --- /dev/null +++ b/plugins/src/models/payloads.rs @@ -0,0 +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/add_payload.rs b/plugins/src/models/payloads/add_payload.rs new file mode 100644 index 00000000..26dd4cb7 --- /dev/null +++ b/plugins/src/models/payloads/add_payload.rs @@ -0,0 +1,37 @@ +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 { + /// 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. +#[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/payloads/commit_payload.rs b/plugins/src/models/payloads/commit_payload.rs new file mode 100644 index 00000000..11477331 --- /dev/null +++ b/plugins/src/models/payloads/commit_payload.rs @@ -0,0 +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 { + /// 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, +} diff --git a/plugins/src/models/payloads/config_payload.rs b/plugins/src/models/payloads/config_payload.rs new file mode 100644 index 00000000..04bf72f9 --- /dev/null +++ b/plugins/src/models/payloads/config_payload.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// 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, +} diff --git a/plugins/src/models/payloads/init_payload.rs b/plugins/src/models/payloads/init_payload.rs new file mode 100644 index 00000000..b02ca7cf --- /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 00000000..23d8c479 --- /dev/null +++ b/plugins/src/models/plugin_configuration.rs @@ -0,0 +1,91 @@ +use std::{ + fmt::{Display, Formatter, Result}, + path::PathBuf, +}; + +use owo_colors::OwoColorize; +use serde::{Deserialize, Serialize}; +use shared::{PathToString, 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, + + /// 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, + + /// Optional interpreter to use when executing the plugin (e.g., `python3`). + pub interpreter: Option, +} + +impl Display for PluginConfiguration { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let labels = [ + "Description", + "File", + "Event", + "Order", + "Timeout", + "Enabled", + "Interpreter", + ]; + let max_len = labels.iter().map(|s| s.len()).max().unwrap_or(0); + let indent = 2; + + 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.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.magenta(), max_len)?; + } + + Ok(()) + } +} diff --git a/plugins/src/models/plugin_entry.rs b/plugins/src/models/plugin_entry.rs new file mode 100644 index 00000000..8f6cd884 --- /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 00000000..a000584a --- /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 00000000..da0bf7fb --- /dev/null +++ b/plugins/src/plugins_discovery.rs @@ -0,0 +1,111 @@ +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 MevaPluginsDiscovery { + /// Layout defining standard plugin directory and file names. + pub layout: Arc, +} + +impl MevaPluginsDiscovery { + /// Creates a new `PluginsDiscovery` instance with the given layout. + pub fn new(layout: Arc) -> Self { + Self { layout } + } +} + +impl PluginsDiscovery for MevaPluginsDiscovery { + 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)) + } + + 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. + fn get_command_dir(&self, command: &CommandType, plugins_dir: &Path) -> PluginResult { + let command_str = command.to_path(); + let command_dir = plugins_dir.join(command_str); + + fs::create_dir_all(&command_dir).map_err(PluginError::Io)?; + + Ok(command_dir) + } + + 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)) + } + + fn layout(&self) -> &Arc { + &self.layout + } +} diff --git a/plugins/src/plugins_engine.rs b/plugins/src/plugins_engine.rs new file mode 100644 index 00000000..89760617 --- /dev/null +++ b/plugins/src/plugins_engine.rs @@ -0,0 +1,283 @@ +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, 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 MevaPluginsEngine { + /// Discovery component used to locate plugins in local and global directories. + pub discovery: Arc, +} + +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, + command: &CommandType, + ) -> PluginResult<(PluginsConfiguration, PluginsConfiguration)> { + self.discovery + .get_plugins_for_command(local_plugins_dir, global_plugins_dir, command) + } + + 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_path()) + .join(formatted_timestamp); + + fs::create_dir_all(&invocation_dir)?; + + Ok(invocation_dir) + } + + fn create_invocation_file( + &self, + invocation: &InvocationInput, + invocation_dir: &Path, + ) -> 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) + } + + 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) + } +} + +impl PluginsRepository for MevaPluginsEngine { + 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 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, + 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)) + } +} diff --git a/plugins/src/plugins_repository.rs b/plugins/src/plugins_repository.rs new file mode 100644 index 00000000..84a6c745 --- /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 00000000..ea89540a --- /dev/null +++ b/plugins/src/plugins_runner.rs @@ -0,0 +1,208 @@ +use std::{ + io::{self, BufRead, BufReader, Read, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + thread::{self, JoinHandle}, + 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, +} + +impl PluginsRunner { + /// Creates a new `PluginsRunner` instance. + pub fn new(invocation_file: PathBuf, command_dir: PathBuf, logger: InvocationLogger) -> Self { + Self { + invocation_file, + command_dir, + logger, + } + } + + /// 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 (status_code, timed_out) = self.wait_for_status(child, plugin.timeout)?; + + 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) -> 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 + }) + } + + /// 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)) + } +} diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 00000000..a9db54ce --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "server" +version = "0.1.0" +edition = "2024" +authors.workspace = true + +[[bin]] +name = "meva-server" +path = "src/main.rs" + +[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 00000000..dadcc52a --- /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 00000000..a2b336a8 --- /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 00000000..317fc1d1 --- /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 00000000..f1e38df2 --- /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 00000000..4e31b44f --- /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 00000000..a78aa177 --- /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 00000000..b7d707b9 --- /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 00000000..4697a46d --- /dev/null +++ b/server/src/enums.rs @@ -0,0 +1,15 @@ +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; + +pub use active_protocol::ActiveProtocol; +pub use channel_state::ChannelState; +pub use protocol_command::ProtocolCommand; +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/active_protocol.rs b/server/src/enums/active_protocol.rs new file mode 100644 index 00000000..35bff13b --- /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 00000000..36d1d8f4 --- /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 00000000..8d513d96 --- /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_command.rs b/server/src/enums/receive_pack_command.rs new file mode 100644 index 00000000..ffee2ab7 --- /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 new file mode 100644 index 00000000..b6d9ccb6 --- /dev/null +++ b/server/src/enums/receive_pack_state.rs @@ -0,0 +1,48 @@ +use std::path::PathBuf; + +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/enums/upload_pack_command.rs b/server/src/enums/upload_pack_command.rs new file mode 100644 index 00000000..d38d3c60 --- /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 00000000..cb172e5d --- /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 00000000..7abbbbc1 --- /dev/null +++ b/server/src/errors.rs @@ -0,0 +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 00000000..c55c7e06 --- /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 new file mode 100644 index 00000000..b90316b7 --- /dev/null +++ b/server/src/errors/server_error.rs @@ -0,0 +1,37 @@ +use std::io; + +use flexi_logger::FlexiLoggerError; +use thiserror::Error; + +use super::{ReceivePackError, 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 specific to the `receive-pack` protocol negotiation. + #[error(transparent)] + ReceivePack(#[from] ReceivePackError), + + /// 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 00000000..ad47b2e4 --- /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 00000000..fce91cf2 --- /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 00000000..58b4df00 --- /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 00000000..bcd9bab3 --- /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 00000000..dd2425ab --- /dev/null +++ b/server/src/server_handler.rs @@ -0,0 +1,767 @@ +use anyhow::Result; +use log::{debug, error, info, warn}; +use russh::{ + Channel, + keys::key, + server::{self, Auth, Handler, Session}, + *, +}; + +use std::{ + collections::HashMap, + io::ErrorKind, + path::{Path, PathBuf}, + str::FromStr, + sync::Arc, +}; + +use ::server::{ + ActiveProtocol, AuthResult, ChannelState, ProtocolCommand, ReceivePackCommand, + ReceivePackState, ReceivePhase, UploadPackCommand, UploadPackState, challenge_public_key, + check_repository_access, validate_repository_name, +}; +use engine::{ + errors::EngineError, + network::{CHUNK_SIZE, ChannelBand, PackfileCodec, SessionExtension, create_pkt_line}, + object_storage::{MevaObjectStorage, ObjectStorage}, + objects::MevaObject, + ref_manager::{MevaRefManager, RefEntry, RefManager}, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; + +/// 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<()> { + 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); + + let repository_layout = Arc::new(MevaRepositoryLayout::new(repository_root.to_path_buf())?); + let ref_manager = MevaRefManager::new(repository_layout); + + if let Some(head) = ref_manager.resolve_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); + } + + session.send_flush(channel); + debug!("List of references sent."); + Ok(()) + } + + /// 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 { + state.buffer.drain(0..4); + + if upload_state.wants.is_empty() && upload_state.haves.is_empty() { + info!( + "Client sent flush without wants/haves (indicating ls-remote). Closing channel." + ); + session.exit_status_request(channel, 0); + session.eof(channel); + session.close(channel); + return Ok(()); + } + 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)?.trim(); + + let command = UploadPackCommand::try_from(line_str)?; + + match command { + UploadPackCommand::Want(sha) => { + debug!("Received 'want': {sha}"); + upload_state.wants.insert(sha.to_string()); + } + UploadPackCommand::Have(sha) => { + debug!("Received 'have': {sha}"); + upload_state.haves.insert(sha.to_string()); + } + UploadPackCommand::Done => { + debug!("Received 'done'. Negotiation finished."); + state.buffer.drain(0..len); + return self.handle_packfile_generation(session, channel); + } + } + + state.buffer.drain(0..len); + } + + 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, + ) -> 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<()> { + 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. + /// + /// This is the final phase of the `upload-pack` protocol. + fn handle_packfile_generation( + &mut self, + session: &mut Session, + channel: ChannelId, + ) -> Result<()> { + info!("Generating packfile..."); + + let state = self.sessions.get(&channel).unwrap(); + let upload_state = match &state.protocol { + ActiveProtocol::UploadPack(state) => state, + _ => return Ok(()), + }; + + 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); + common_ancestor_found = true; + break; + } + } + + if !common_ancestor_found { + let nak = create_pkt_line("NAK"); + session.send_pkt_line(channel, &nak); + } + + let objects = + object_storage.collect_reachable_objects(&upload_state.wants, &upload_state.haves)?; + let objects_count = objects.len(); + + debug!("Collected {objects_count} objects to pack."); + + 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)?; + + 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!("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; + } + + if objects_count > 0 { + session.send_channel_band( + channel, + &ChannelBand::Progress, + format!("Total {chunk_counter} chunks sent.").as_bytes(), + )?; + } + + session.send_flush(channel); + + info!("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 { + 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); + 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 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, + /// 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.extended_data( + channel, + 1, + CryptoVec::from_slice(format!("{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(()) + } + } + } +} + +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(), + 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 00000000..2b709a7a --- /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 00000000..92e38aa3 --- /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) +} diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 17916db2..76eaab3d 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -4,6 +4,13 @@ version = "0.1.0" edition = "2024" authors.workspace = true +[dependencies] +tempfile.workspace = true +up_finder.workspace = true +editor-command.workspace = true +path-absolutize.workspace = true +owo-colors.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 00000000..bb837eba --- /dev/null +++ b/shared/src/extensions.rs @@ -0,0 +1,19 @@ +pub mod change_directory; +pub mod cumulative_paths; +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; + +pub use change_directory::change_directory; +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_empty::remove_empty_parents; +pub use strip_base::StripBase; +pub use upward_search::UpwardSearch; diff --git a/shared/src/extensions/change_directory.rs b/shared/src/extensions/change_directory.rs new file mode 100644 index 00000000..acc7b26f --- /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/extensions/cumulative_paths.rs b/shared/src/extensions/cumulative_paths.rs new file mode 100644 index 00000000..9de8b88c --- /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/fs.rs b/shared/src/extensions/fs.rs new file mode 100644 index 00000000..f7a9bc59 --- /dev/null +++ b/shared/src/extensions/fs.rs @@ -0,0 +1,73 @@ +use std::{ + fs::{self, File, OpenOptions}, + io, + path::Path, +}; + +/// Creates a file at the given path, ensuring all parent directories exist. +/// +/// # 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)?; + } + + 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] + fn creates_file_and_nested_directories() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("a").join("b").join("c.txt"); + + 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/shared/src/extensions/is_within.rs b/shared/src/extensions/is_within.rs new file mode 100644 index 00000000..68968895 --- /dev/null +++ b/shared/src/extensions/is_within.rs @@ -0,0 +1,78 @@ +use path_absolutize::Absolutize; +use std::io; +use std::path::Path; + +/// 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 `base` (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, base: P) -> Result; +} + +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)) + } +} + +#[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/open_in_editor.rs b/shared/src/extensions/open_in_editor.rs new file mode 100644 index 00000000..4637f320 --- /dev/null +++ b/shared/src/extensions/open_in_editor.rs @@ -0,0 +1,59 @@ +use std::{ + io::{Error, Result}, + path::Path, +}; + +use editor_command::EditorBuilder; + +/// 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() + .map_err(|e| Error::other(format!("Failed to build editor command: {e}")))?; + + cmd.arg(self.as_ref()); + + let status = cmd + .status() + .map_err(|e| Error::other(format!("Failed to launch the editor: {e}")))?; + + if !status.success() { + return Err(Error::other(format!( + "Editor returned error code: {}", + status.code().unwrap_or(-1) + ))); + } + + Ok(()) + } +} diff --git a/shared/src/extensions/path_to_string.rs b/shared/src/extensions/path_to_string.rs new file mode 100644 index 00000000..83629b40 --- /dev/null +++ b/shared/src/extensions/path_to_string.rs @@ -0,0 +1,45 @@ +use std::path::Path; + +/// 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; + + /// 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 +where + T: AsRef + ?Sized, +{ + 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 + } + } +} diff --git a/shared/src/extensions/remove_empty.rs b/shared/src/extensions/remove_empty.rs new file mode 100644 index 00000000..94638ec0 --- /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/extensions/strip_base.rs b/shared/src/extensions/strip_base.rs new file mode 100644 index 00000000..ab2a079e --- /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/extensions/upward_search.rs b/shared/src/extensions/upward_search.rs new file mode 100644 index 00000000..da417b54 --- /dev/null +++ b/shared/src/extensions/upward_search.rs @@ -0,0 +1,140 @@ +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() + } +} + +#[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()); + } +} diff --git a/shared/src/lib.rs b/shared/src/lib.rs index ab976c82..5169298b 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -1,15 +1,9 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +mod pretty_field; -#[cfg(test)] -mod tests { - use super::*; - use rstest::*; +pub mod extensions; - #[rstest] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub use extensions::{ + CumulativePaths, IsWithin, OpenInEditor, PathToString, StripBase, UpwardSearch, + change_directory, fs, remove_empty_parents, +}; +pub use pretty_field::PrettyField; diff --git a/shared/src/pretty_field.rs b/shared/src/pretty_field.rs new file mode 100644 index 00000000..574be111 --- /dev/null +++ b/shared/src/pretty_field.rs @@ -0,0 +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_str = " ".repeat(indent); + let padded_label = format!("{label: