diff --git a/.gitignore b/.gitignore index ff2474a..116b1a2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ target # Contains mutation testing data **/mutants.out*/ +.meva + # RustRover # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore @@ -21,4 +23,25 @@ target #.idea/ # Ignore all the files created by running `cargo run --bin meva -- init` at the root of the project -.meva \ No newline at end of file +.idea/workspace.xml +.idea/dictionaries/project.xml +.idea/codeStyles/* +.idea/vcs.xml +.idea/inspectionProfiles/** +.idea/inspectionProfiles/Project_Default.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + diff --git a/Cargo.lock b/Cargo.lock index d9f40d7..a740a83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,12 +26,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -43,9 +37,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -73,22 +67,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.60.2", ] [[package]] @@ -109,7 +103,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -123,9 +117,18 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.9.1" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "block-buffer" +version = "0.11.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "e9ef36a6fcdb072aa548f3da057640ec10859eb4e91ddf526ee648d50c76a949" +dependencies = [ + "hybrid-array", +] [[package]] name = "bstr" @@ -145,39 +148,39 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "cc" -version = "1.2.34" +version = "1.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" +checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" dependencies = [ + "find-msvc-tools", "shlex", ] [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.0", ] [[package]] name = "clap" -version = "4.5.41" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" dependencies = [ "clap_builder", "clap_derive", @@ -185,9 +188,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.41" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" dependencies = [ "anstream", "anstyle", @@ -197,9 +200,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.41" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ "heck", "proc-macro2", @@ -237,18 +240,78 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "const-oid" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dabb6555f92fb9ee4140454eb5dcd14c7960e1225c6d1a6cc361f032947713e" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.2.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8235645834fbc6832939736ce2f2d08192652269e11010a6240f61b908a1c6" +dependencies = [ + "hybrid-array", +] + [[package]] name = "diff" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "digest" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6749b668519cd7149ee3d11286a442a8a8bdc3a9d529605f579777bfccc5a4bc" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -267,7 +330,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.61.0", ] [[package]] @@ -285,6 +348,12 @@ dependencies = [ "shell-words", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "engine" version = "0.1.0" @@ -292,17 +361,21 @@ dependencies = [ "chrono", "dirs", "globset", + "hex", "mockall", "plugins", "pretty_assertions", + "rayon", "rstest", "serde", "serde_json", + "sha1", "shared", "tempfile", "thiserror", "toml", - "toml_edit 0.23.2", + "toml_edit", + "walkdir", ] [[package]] @@ -313,12 +386,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.0", ] [[package]] @@ -327,6 +400,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" + [[package]] name = "fragile" version = "2.0.1" @@ -396,7 +475,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] @@ -407,9 +486,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" @@ -438,9 +517,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" [[package]] name = "heck" @@ -448,11 +527,26 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hybrid-array" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7116c472cf19838450b1d421b4e842569f52b519d640aee9ace1ebcf5b21051" +dependencies = [ + "typenum", +] + [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -474,9 +568,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "92119844f513ffa41556430369ab02c295a3578af21cf945caa3e9e0c2481ac3" dependencies = [ "equivalent", "hashbrown", @@ -502,9 +596,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" dependencies = [ "once_cell", "wasm-bindgen", @@ -512,15 +606,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.174" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libredox" -version = "0.1.4" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ "bitflags", "libc", @@ -528,15 +622,15 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "memchr" @@ -718,18 +812,18 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.22.27", + "toml_edit", ] [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -749,11 +843,31 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", @@ -762,9 +876,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", @@ -774,9 +888,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", @@ -785,9 +899,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "relative-path" @@ -827,9 +941,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -848,15 +962,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.0", ] [[package]] @@ -871,26 +985,45 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.225" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" dependencies = [ "proc-macro2", "quote", @@ -899,23 +1032,35 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] name = "serde_spanned" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +checksum = "2789234a13a53fc4be1b51ea1bab45a3c338bdb884862a257d10e5a74ae009e6" dependencies = [ - "serde", + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e046edf639aa2e7afb285589e5405de2ef7e61d4b0ac1e30256e3eab911af9" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", ] [[package]] @@ -944,9 +1089,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "slab" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "strsim" @@ -995,9 +1140,9 @@ checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" [[package]] name = "syn" -version = "2.0.104" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -1006,25 +1151,25 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.20.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.0", ] [[package]] name = "terminal_size" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ "rustix", - "windows-sys", + "windows-sys 0.60.2", ] [[package]] @@ -1045,18 +1190,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", @@ -1065,14 +1210,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.2" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" +checksum = "ae2a4cf385da23d1d53bc15cdfa5c2109e93d8d362393c801e87da2f72f0e201" dependencies = [ "indexmap", - "serde", + "serde_core", "serde_spanned", - "toml_datetime 0.7.0", + "toml_datetime", "toml_parser", "toml_writer", "winnow", @@ -1080,38 +1225,21 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" - -[[package]] -name = "toml_datetime" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +checksum = "a197c0ec7d131bfc6f7e82c8442ba1595aeab35da7adbf05b6b73cd06a16b6be" dependencies = [ - "serde", + "serde_core", ] [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.23.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "c2ad0b7ae9cfeef5605163839cb9221f453399f15cfb5c10be9885fcf56611f9" dependencies = [ "indexmap", - "toml_datetime 0.6.11", - "winnow", -] - -[[package]] -name = "toml_edit" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1dee9dc43ac2aaf7d3b774e2fba5148212bf2bd9374f4e50152ebe9afd03d42" -dependencies = [ - "indexmap", - "toml_datetime 0.7.0", + "toml_datetime", "toml_parser", "toml_writer", "winnow", @@ -1119,9 +1247,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" dependencies = [ "winnow", ] @@ -1134,29 +1262,35 @@ checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" [[package]] name = "typed-builder" -version = "0.21.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce63bcaf7e9806c206f7d7b9c1f38e0dce8bb165a80af0898161058b19248534" +checksum = "fef81aec2ca29576f9f6ae8755108640d0a86dd3161b2e8bca6cfa554e98f77d" dependencies = [ "typed-builder-macro", ] [[package]] name = "typed-builder-macro" -version = "0.21.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d8d828da2a3d759d3519cdf29a5bac49c77d039ad36d0782edadbf9cd5415b" +checksum = "1ecb9ecf7799210407c14a8cfdfe0173365780968dc57973ed082211958e0b18" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-linebreak" @@ -1201,6 +1335,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1209,30 +1353,40 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" dependencies = [ "bumpalo", "log", @@ -1244,9 +1398,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1254,9 +1408,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" dependencies = [ "proc-macro2", "quote", @@ -1267,22 +1421,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.0", +] + [[package]] name = "windows-core" -version = "0.61.2" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.2.0", "windows-result", "windows-strings", ] @@ -1315,31 +1478,46 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + [[package]] name = "windows-result" -version = "0.3.4" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" dependencies = [ - "windows-link", + "windows-link 0.2.0", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" dependencies = [ - "windows-link", + "windows-link 0.2.0", ] [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link 0.2.0", ] [[package]] @@ -1348,14 +1526,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link 0.1.3", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", ] [[package]] @@ -1364,65 +1559,110 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + [[package]] name = "winnow" -version = "0.7.12" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "yansi" diff --git a/Cargo.toml b/Cargo.toml index e8e3d8f..19f90f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ up_finder = "0.0.4" dirs = "6.0.0" editor-command = "1.0.0" globset = "0.4.16" -chrono = { version = "0.4.41", features = ["serde"] } +chrono = { version = "0.4.42", features = ["serde"] } clap = { version = "4.5.41", features = ["derive"] } strum = "0.27" strum_macros = "0.27" diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 30074e0..2c138b3 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1,9 +1,11 @@ +pub mod add; pub mod config; pub mod ignore; pub mod init; pub mod meva_command; pub mod plugins; +pub use add::AddCommand; pub use config::ConfigCommand; pub use ignore::IgnoreCommand; pub use init::InitCommand; diff --git a/cli/src/commands/add.rs b/cli/src/commands/add.rs new file mode 100644 index 0000000..3937493 --- /dev/null +++ b/cli/src/commands/add.rs @@ -0,0 +1,151 @@ +use std::path::PathBuf; + +use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command, value_parser}; +use miette::{IntoDiagnostic, Result}; + +use crate::commands::MevaCommand; +use engine::EngineContainer; +use engine::add::operations::AddRequest; +use engine::engine_container::MevaContainer; + +/// CLI command for adding files to the Meva staging area. +/// +/// The `AddCommand` allows +/// users to select files or directories to add to the repository's staging area. +/// +/// # Supported arguments +/// - **`path`**: Optional path to file or directory. Use `.` to add the current directory. +/// - **`--all, -a`**: Add all files in the repository. +/// - **`--force, -f`**: Add files even if they are normally ignored. +/// - **`--update, -u`**: Add modified and deleted files only. Conflicts with `--all`. +/// - **`--dry-run, -n`**: Show which files *would* be added, without actually staging them. +/// - **`--verbose, -v`**: Increase verbosity of output. +pub struct AddCommand; + +impl AddCommand { + /// Creates a new instance of the `AddCommand`. + pub fn new() -> Self { + Self + } + + /// Path argument key. + const ARG_PATH: &'static str = "path"; + + /// `--all` flag key. + const ARG_ALL: &'static str = "all"; + + /// `--force` flag key. + const ARG_FORCE: &'static str = "force"; + + /// `--update` flag key. + const ARG_UPDATE: &'static str = "update"; + + /// `--dry-run` flag key. + const ARG_DRY_RUN: &'static str = "dry-run"; + + /// `--verbose` flag key. + const ARG_VERBOSE: &'static str = "verbose"; +} + +impl MevaCommand for AddCommand { + fn name(&self) -> &'static str { + "add" + } + + fn about(&self) -> &'static str { + "Add files to the staging area" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Builds the underlying Clap [`Command`] definition for this command. + /// + /// This defines supported arguments, their flags, and argument groups. + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_PATH) + .value_name("PATH") + .help("Path to file or directory to add, use '.' for current catalog") + .value_parser(value_parser!(PathBuf)) + .index(1), + ) + .arg( + Arg::new(Self::ARG_ALL) + .short('a') + .long(Self::ARG_ALL) + .help("Adds all files to staging area") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_FORCE) + .short('f') + .long(Self::ARG_FORCE) + .help("Add file to staging area despite it's ignored") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_UPDATE) + .short('u') + .help("Add modified and deleted files only") + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_ALL), + ) + .arg( + Arg::new(Self::ARG_DRY_RUN) + .short('n') + .long(Self::ARG_DRY_RUN) + .help("Show files to be added without adding them") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(Self::ARG_VERBOSE) + .short('v') + .long(Self::ARG_VERBOSE) + .help("Be verbose") + .action(ArgAction::SetTrue), + ) + .group( + ArgGroup::new("path_group") + .args([Self::ARG_PATH, Self::ARG_ALL]) + .required(true) + .multiple(true), + ) + } + + /// Executes the `add` command. + /// + /// - Reads all flags and arguments from `matches`. + /// - Constructs an [`AddRequest`] with the provided options. + /// - Delegates to the repository's [`add_handler`] to perform the operation. + /// + /// Returns a [`miette::Result`] with diagnostic information if an error occurs. + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { + let all_flag = matches.get_flag(Self::ARG_ALL); + let force_flag = matches.get_flag(Self::ARG_FORCE); + let update_flag = matches.get_flag(Self::ARG_UPDATE); + let dry_run_flag = matches.get_flag(Self::ARG_DRY_RUN); + let verbose_flag = matches.get_flag(Self::ARG_VERBOSE); + let path_arg = matches.get_one::(Self::ARG_PATH); + + let add_handler = container.add_handler().into_diagnostic()?; + let interceptor = container.plugins_interceptor().into_diagnostic()?; + + let request = AddRequest { + all_flag, + force_flag, + update_flag, + dry_run_flag, + verbose_flag, + path_arg: path_arg.cloned(), + }; + + add_handler + .handle_add(request, &interceptor) + .into_diagnostic()?; + + Ok(()) + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 7a71ec5..eccdaa5 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -3,7 +3,7 @@ mod extensions; mod meva_cli; use crate::meva_cli::MevaCli; -use commands::{ConfigCommand, IgnoreCommand, InitCommand, PluginsCommand}; +use commands::{AddCommand, ConfigCommand, IgnoreCommand, InitCommand, PluginsCommand}; use engine::engine_container::MevaContainer; use miette::Result; @@ -17,6 +17,7 @@ fn main() -> Result<()> { cli.add_command(Box::new(ConfigCommand::new())); cli.add_command(Box::new(IgnoreCommand::new())); cli.add_command(Box::new(PluginsCommand::new())); + cli.add_command(Box::new(AddCommand::new())); cli.run() } diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 7729002..2612b32 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -15,8 +15,11 @@ toml = "0.9.2" toml_edit = "0.23.2" dirs.workspace = true globset.workspace = true +walkdir = "2.5.0" +sha1 = "0.11.0-rc.0" +hex = "0.4.3" chrono.workspace = true - +rayon = "1.11.0" [dev-dependencies] rstest.workspace = true diff --git a/engine/src/add.rs b/engine/src/add.rs new file mode 100644 index 0000000..4ae1878 --- /dev/null +++ b/engine/src/add.rs @@ -0,0 +1,2 @@ +pub mod handlers; +pub mod operations; diff --git a/engine/src/add/handlers.rs b/engine/src/add/handlers.rs new file mode 100644 index 0000000..997d2a0 --- /dev/null +++ b/engine/src/add/handlers.rs @@ -0,0 +1,134 @@ +use crate::RepositoryLayout; +use crate::add::operations::{AddOperations, AddRequest, AddResponse}; +use crate::errors::path_error::PathError; +use crate::errors::{EngineError, EngineResult, RepositoryError}; +use crate::index::meva_index::MevaIndex; +use crate::plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}; +use crate::repositories::meva_repository_layout::MevaRepositoryLayout; + +use plugins::{ + AddPostPayload, AddPrePayload, CommandType, InvocationInput, InvocationPostPayload, + InvocationPrePayload, MevaPluginsLayout, PluginError, +}; +use shared::UpwardSearch; +use shared::extensions::canonicalize_clean::CanonicalizeClean; +use shared::extensions::is_within::IsWithin; + +pub struct AddHandler; + +impl AddHandler { + pub fn handle_add( + &self, + request: AddRequest, + interceptor: &PluginsInterceptor, + ) -> EngineResult { + interceptor + .intercept_with_plugins(CommandType::Add, None, request, self, |req| self.add(req)) + } +} + +impl AddOperations for AddHandler { + fn add(&self, request: AddRequest) -> EngineResult { + let workdir_abs = std::env::current_dir()?; + let mut repo_layout = MevaRepositoryLayout::new(workdir_abs.clone())?; + let repository_root_abs = match workdir_abs.search_dir_up(repo_layout.repository_dir_name()) + { + Some(val) => val + .parent() + .ok_or_else(|| PathError::NoParent { path: val.clone() })? + .to_owned(), + None => return Err(RepositoryError::RepositoryNotFound { path: workdir_abs }.into()), + }; + + let path_abs = match request.path_arg { + Some(val) if val.is_absolute() => val.clone(), + Some(val) => workdir_abs.join(val), + None => workdir_abs.clone(), + }; + + let path_abs = path_abs.canonicalize_clean()?; + + if !path_abs.is_within(&repository_root_abs)? { + return Err(PathError::NotInBaseDirectory { + path: path_abs, + path_base: repository_root_abs, + } + .into()); + } + + let add_new = request.all_flag || !request.update_flag; + let add_deleted = request.all_flag || request.update_flag; + let add_ignored = request.force_flag; + + let verbose = request.dry_run_flag || request.verbose_flag; + + repo_layout = MevaRepositoryLayout::new(repository_root_abs)?; + let mut index = MevaIndex::new(&repo_layout)?; + + index.load()?; + let (added, modified, removed) = + index.add(&path_abs, add_new, add_deleted, add_ignored, verbose)?; + + if !request.dry_run_flag { + index.save()?; + } + + Ok(AddResponse { + added, + modified, + removed, + }) + } +} + +impl PluginsInvocationMapper for AddHandler { + fn request_to_payload(&self, req: &AddRequest) -> EngineResult { + Ok(InvocationPrePayload::Add(AddPrePayload { + all_flag: req.all_flag, + force_flag: req.force_flag, + update_flag: req.update_flag, + dry_run_flag: req.dry_run_flag, + verbose_flag: req.verbose_flag, + path_arg: req.path_arg.clone(), + })) + } + + fn response_to_payload(&self, res: &AddResponse) -> EngineResult { + Ok(InvocationPostPayload::Add(AddPostPayload { + added: res.added.clone(), + modified: res.modified.clone(), + removed: res.removed.clone(), + })) + } + + fn input_to_request(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPrePayload::Add(pre)) = &input.pre_payload { + Ok(AddRequest { + all_flag: pre.all_flag, + force_flag: pre.force_flag, + update_flag: pre.update_flag, + dry_run_flag: pre.dry_run_flag, + verbose_flag: pre.verbose_flag, + path_arg: pre.path_arg.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PrePayload { + payload: input.pre_payload.clone(), + })) + } + } + + fn input_to_response(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPostPayload::Add(post)) = &input.post_payload { + Ok(AddResponse { + added: post.added.clone(), + modified: post.modified.clone(), + removed: post.removed.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PostPayload { + payload: input.post_payload.clone(), + })) + } + } +} diff --git a/engine/src/add/operations.rs b/engine/src/add/operations.rs new file mode 100644 index 0000000..d491dea --- /dev/null +++ b/engine/src/add/operations.rs @@ -0,0 +1,22 @@ +use std::path::PathBuf; + +use crate::errors::EngineResult; + +pub struct AddRequest { + pub all_flag: bool, + pub force_flag: bool, + pub update_flag: bool, + pub dry_run_flag: bool, + pub verbose_flag: bool, + pub path_arg: Option, +} + +pub struct AddResponse { + pub added: Vec, + pub modified: Vec, + pub removed: Vec, +} + +pub trait AddOperations { + fn add(&self, request: AddRequest) -> EngineResult; +} diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs index 46100c4..60940fc 100644 --- a/engine/src/engine_container.rs +++ b/engine/src/engine_container.rs @@ -1,5 +1,6 @@ use plugins::{MevaPluginsLayout, PluginsDiscovery, PluginsEngine}; +use crate::add::handlers::AddHandler; use crate::{ InitHandler, config::ConfigHandler, errors::EngineResult, plugins::PluginsHandler, plugins_interceptor::PluginsInterceptor, @@ -22,6 +23,9 @@ pub trait EngineContainer { /// Returns the handler responsible for plugin operations. fn plugins_handler(&self) -> EngineResult; + + /// Returns the handler responsible for add command + fn add_handler(&self) -> EngineResult; } /// Concrete implementation of `EngineContainer` for Meva. @@ -72,4 +76,8 @@ impl EngineContainer for MevaContainer { repository_layout: Box::new(self.repository_layout()?), }) } + + fn add_handler(&self) -> EngineResult { + Ok(AddHandler) + } } diff --git a/engine/src/errors.rs b/engine/src/errors.rs index a085c79..0d2e7d0 100644 --- a/engine/src/errors.rs +++ b/engine/src/errors.rs @@ -1,11 +1,14 @@ pub mod config_error; pub mod engine_error; pub mod ignore_error; +pub mod index_error; pub mod init_error; +pub mod path_error; pub mod repository_error; pub use config_error::ConfigError; pub use ignore_error::IgnoreError; +pub use index_error::IndexError; pub use init_error::InitError; pub use repository_error::RepositoryError; diff --git a/engine/src/errors/engine_error.rs b/engine/src/errors/engine_error.rs index 6eb97dd..1a7593c 100644 --- a/engine/src/errors/engine_error.rs +++ b/engine/src/errors/engine_error.rs @@ -1,9 +1,11 @@ use std::io; +use std::path::StripPrefixError; use plugins::PluginError; use thiserror::Error; -use crate::errors::{ConfigError, IgnoreError, InitError, RepositoryError}; +use crate::errors::path_error::PathError; +use crate::errors::{ConfigError, IgnoreError, IndexError, InitError, RepositoryError}; /// A convenient result type alias for engine-related operations. pub type Result = std::result::Result; @@ -39,6 +41,26 @@ pub enum EngineError { #[error(transparent)] Plugins(#[from] PluginError), + /// An error originating from index-file access or processing. + #[error(transparent)] + Index(#[from] IndexError), + + /// Errors related to invalid or inconsistent filesystem paths. + /// + /// Typically raised when verifying whether a path belongs inside a base + /// directory, or when a required path cannot be found. + #[error(transparent)] + Path(#[from] PathError), + + /// Error raised when attempting to strip a prefix from a path but the + /// operation is invalid (e.g., the prefix is not actually a parent). + #[error(transparent)] + StripPrefix(#[from] StripPrefixError), + + /// JSON serialization/deserialization error + #[error(transparent)] + Serde(#[from] serde_json::Error), + /// A catch-all variant for any unknown or unexpected engine error. /// Accepts a descriptive string message. #[error("Unknown engine error: {0}")] diff --git a/engine/src/errors/ignore_error.rs b/engine/src/errors/ignore_error.rs index 556a02b..1a55767 100644 --- a/engine/src/errors/ignore_error.rs +++ b/engine/src/errors/ignore_error.rs @@ -12,4 +12,8 @@ pub enum IgnoreError { /// Indicates that no ignore file could be located at the expected path. #[error("Ignore file not found at {path}")] IgnoreNotFound { path: String }, + + /// Indicates that ignore patterns were expected but not loaded (cache missing). + #[error("Ignore patterns not loaded")] + IgnoreCacheEmpty, } diff --git a/engine/src/errors/index_error.rs b/engine/src/errors/index_error.rs new file mode 100644 index 0000000..e0cc2d6 --- /dev/null +++ b/engine/src/errors/index_error.rs @@ -0,0 +1,21 @@ +use std::path::PathBuf; +use thiserror::Error; + +/// Represents errors that can occur when working with the index file. +/// +/// The index file is a core component of the repository that tracks the +/// current state of files (similar to Git’s index). +/// Errors here typically indicate that the index cannot be found, +/// accessed, or is otherwise missing. +#[derive(Error, Debug)] +pub enum IndexError { + /// Indicates that the index file could not be located at the expected path. + /// + /// This usually means the repository has not been initialized properly, + /// or the index file was accidentally deleted. + #[error("Index file not found at `{path}`")] + IndexFileNotFound { + /// The absolute or relative path where the index file was expected. + path: PathBuf, + }, +} diff --git a/engine/src/errors/path_error.rs b/engine/src/errors/path_error.rs new file mode 100644 index 0000000..c430595 --- /dev/null +++ b/engine/src/errors/path_error.rs @@ -0,0 +1,42 @@ +use std::path::PathBuf; +use thiserror::Error; + +/// Represents errors related to filesystem path operations. +/// +/// These errors typically arise when validating repository structure, +/// resolving relative paths, or performing path-based computations. +#[derive(Error, Debug)] +pub enum PathError { + /// The given path is not located inside the specified base directory. + /// + /// This error is returned when a check ensures that a path + /// must be contained within a base directory (for example, + /// to prevent escaping a repository root), but the condition fails. + #[error("Path `{path}` is not located inside the directory `{path_base}`")] + NotInBaseDirectory { + /// The path that was validated. + path: PathBuf, + /// The base directory it was expected to be inside. + path_base: PathBuf, + }, + + /// The given path does not exist in the filesystem. + /// + /// This error is returned when attempting to access or operate on + /// a path that cannot be found. + #[error("Path: `{path}` not found")] + PathNotFound { + /// The path that was expected to exist. + path: PathBuf, + }, + + /// The given path has no parent directory. + /// + /// This can occur if the path is the filesystem root, + /// or if it was otherwise malformed. + #[error("Cannot determine parent of path: `{path}`")] + NoParent { + /// The path for which a parent could not be determined. + path: PathBuf, + }, +} diff --git a/engine/src/ignore/ignore_operations.rs b/engine/src/ignore/ignore_operations.rs index c279e44..c0ffb5a 100644 --- a/engine/src/ignore/ignore_operations.rs +++ b/engine/src/ignore/ignore_operations.rs @@ -1,8 +1,7 @@ use std::path::{Path, PathBuf}; -use globset::Glob; - use crate::{IgnoreResult, errors::EngineResult}; +use globset::Glob; /// Trait defining core operations on Meva *ignore* files. /// @@ -17,7 +16,7 @@ pub trait IgnoreOperations { /// /// # Arguments /// * `pattern` – Pre-compiled [`Glob`] expression to append. - /// * `path` – Optional path to an explicit ignore file. + /// * `path` – Optional path to an explicit ignore file. /// /// # Returns /// Path to the file that was modified. @@ -33,7 +32,7 @@ pub trait IgnoreOperations { /// /// # Returns /// Tuple containing: - /// 1. Path to the file that was modified. + /// 1. Path to the file that was modified. /// 2. Vector of string patterns that were actually removed (empty if none /// matched). fn remove

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

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

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

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

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

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

(&self, checked_path: &P) -> EngineResult + where + P: AsRef, + { + if self.cached_set.is_none() { + return Err(IgnoreError::IgnoreCacheEmpty.into()); } - let set = builder.build().map_err(IgnoreError::Glob)?; + let cached_set = self.cached_set.as_ref().unwrap(); - let matches = set.matches(checked_path); - let matched_patterns: Vec = - matches.into_iter().map(|m| patterns[m].clone()).collect(); + let mut checked_path = checked_path.as_ref().to_path_buf(); - Ok(IgnoreResult::from_matches(matched_patterns, &ignore_file)) + if checked_path.is_absolute() { + if !Self::ignore_file_applies_to(&cached_set.path_abs, &checked_path) { + return Ok(IgnoreResult::NotIgnored { path: checked_path }); + } + checked_path = checked_path + .canonicalize_clean()? + .strip_prefix( + cached_set + .path_abs + .parent() + .unwrap_or_else(|| Path::new("")), + )? + .to_path_buf(); + } + + let matches = cached_set.set.matches(checked_path); + let matched_patterns: Vec = matches + .into_iter() + .map(|m| cached_set.patterns[m].clone()) + .collect(); + + Ok(IgnoreResult::from_matches( + matched_patterns, + Path::new(&self.ignore_file_name), + )) } fn find_ignore_file(&self, starting_path: Option<&Path>) -> EngineResult { @@ -144,11 +241,36 @@ impl IgnoreOperations for IgnoreService { env::current_dir()? }; - search_path.search_file_up(&self.ignore_file).ok_or( + search_path.search_file_up(&self.ignore_file_name).ok_or( IgnoreError::IgnoreNotFound { - path: self.ignore_file.clone(), + path: self.ignore_file_name.clone(), } .into(), ) } + + fn find_ignore_files_down(&self, path: &Path) -> Vec { + WalkDir::new(path) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_type().is_file() && e.file_name().to_str() == Some(&self.ignore_file_name) + }) + .map(|e| e.path().to_path_buf()) + .collect() + } + + fn ignore_file_applies_to(ignore_file: &Path, file: &Path) -> bool { + let ignore_dir = ignore_file + .parent() + .unwrap_or_else(|| Path::new("")) + .to_path_buf(); + + let file = file.to_path_buf(); + if let Ok(val) = file.is_within(&ignore_dir) { + return val; + } + + false + } } diff --git a/engine/src/index.rs b/engine/src/index.rs new file mode 100644 index 0000000..99fddba --- /dev/null +++ b/engine/src/index.rs @@ -0,0 +1,7 @@ +mod meva_hasher; + +pub mod file_mode; +pub mod index_entry; +pub mod meva_index; +pub mod serde_utils; +pub mod stage; diff --git a/engine/src/index/file_mode.rs b/engine/src/index/file_mode.rs new file mode 100644 index 0000000..c0a89af --- /dev/null +++ b/engine/src/index/file_mode.rs @@ -0,0 +1,80 @@ +use std::fs; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +/// Represents the file mode used to describe how a file +/// should be tracked in the repository. +/// +/// These values are used to +/// differentiate between regular files, executables, symbolic links, +/// and submodules (gitlinks). +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum FileMode { + /// A regular non-executable file (`100644`). + Normal = 0o100644, + + /// A regular executable file (`100755`). + Executable = 0o100755, + + /// A symbolic link (`120000`). + Symlink = 0o120000, + + /// A submodule (gitlink) entry (`160000`). + GitLink = 0o160000, +} + +impl TryFrom<&Path> for FileMode { + type Error = std::io::Error; + + /// Attempts to determine the appropriate [`FileMode`] for the given path, + /// based on its filesystem metadata. + /// + /// # Unix + /// On Unix platforms, the mode bits are inspected directly. + /// + /// # Windows + /// On Windows, executability is inferred from common executable file + /// extensions (`.exe`, `.bat`, `.cmd`, `.ps1`). + fn try_from(path: &Path) -> Result { + let metadata = fs::metadata(path)?; + + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let mode = metadata.mode(); + let file_mode = match mode & 0o170000 { + 0o100000 => { + if mode & 0o111 != 0 { + FileMode::Executable + } else { + FileMode::Normal + } + } + 0o120000 => FileMode::Symlink, + 0o160000 => FileMode::GitLink, + _ => FileMode::Normal, + }; + Ok(file_mode) + } + + #[cfg(windows)] + { + if metadata.is_file() { + let executable = path + .extension() + .and_then(|s| s.to_str()) + .is_some_and(|ext| { + matches!(ext.to_lowercase().as_str(), "exe" | "bat" | "cmd" | "ps1") + }); + Ok(if executable { + FileMode::Executable + } else { + FileMode::Normal + }) + } else { + Ok(FileMode::Normal) + } + } + } +} diff --git a/engine/src/index/index_entry.rs b/engine/src/index/index_entry.rs new file mode 100644 index 0000000..a6fc862 --- /dev/null +++ b/engine/src/index/index_entry.rs @@ -0,0 +1,73 @@ +use std::fs; +use std::path::Path; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::errors::EngineResult; +use crate::index::file_mode::FileMode; +use crate::index::stage::Stage; + +/// Represents a single entry in the repository index. +/// +/// The index entry tracks metadata about a file as it exists in the +/// working directory, including timestamps, file mode, size, hash, and stage. +/// This information is used to detect changes, manage staging, and build commits. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct IndexEntry { + /// The file's creation time (ctime). + pub ctime: DateTime, + + /// The file's last modification time (mtime). + pub mtime: DateTime, + + /// The file mode (e.g. normal, executable, symlink). + pub mode: FileMode, + + /// The file size in bytes. + pub file_size: u64, + + /// The SHA-1 object hash of the file's contents. + /// + /// Defaults to an empty string when the hash is not yet computed. + pub sha1: String, + + /// The stage number associated with the entry (e.g. for merge conflicts). + pub stage: Stage, + + /// The file system path to the entry, stored as a UTF-8 string. + pub path: String, +} + +impl IndexEntry { + /// Creates a new [`IndexEntry`] from the given file path, + /// without computing its SHA-1 hash. + /// + /// This is useful for cases where metadata (ctime, mtime, mode, size) + /// must be captured first, and the hash will be computed later. + /// + /// # Errors + /// Returns an [`EngineError`](crate::errors::EngineError) if: + /// - The file metadata cannot be accessed. + /// - The file mode cannot be determined. + pub fn from_path_without_hash(file_path: &Path) -> EngineResult { + let metadata = fs::metadata(file_path)?; + + let path = file_path.to_string_lossy().into_owned(); + let ctime: DateTime = metadata.created()?.into(); + let mtime: DateTime = metadata.modified()?.into(); + let mode = FileMode::try_from(file_path)?; + let stage = Stage::Normal; + let file_size = metadata.len(); + + Ok(Self { + ctime, + mtime, + mode, + file_size, + stage, + path, + sha1: String::default(), + }) + } +} diff --git a/engine/src/index/meva_hasher.rs b/engine/src/index/meva_hasher.rs new file mode 100644 index 0000000..34f08d3 --- /dev/null +++ b/engine/src/index/meva_hasher.rs @@ -0,0 +1,33 @@ +use std::fs::File; +use std::io::{BufReader, Read, Result}; +use std::path::Path; + +use sha1::{Digest, Sha1}; + +/// Utility struct for computing hashes used in the Meva repository. +/// +/// Currently supports SHA-1, which is used for file content hashing +pub struct MevaHasher; + +impl MevaHasher { + /// Computes the SHA-1 hash of a file at the given path. + /// + /// The file is read in chunks to avoid loading the entire file + /// into memory at once, making this efficient even for large files. + /// + /// # Errors + /// Returns an [`io::Error`] if the file cannot be opened or read. + pub fn sha1_file(path: &Path) -> Result { + let file = File::open(path)?; + let mut reader = BufReader::new(file); + let mut hasher = Sha1::new(); + + let mut buffer = Vec::new(); + reader.read_to_end(&mut buffer)?; + hasher.update(&buffer); + + let result = hasher.finalize(); + let sha1 = hex::encode(result); + Ok(sha1) + } +} diff --git a/engine/src/index/meva_index.rs b/engine/src/index/meva_index.rs new file mode 100644 index 0000000..837bd74 --- /dev/null +++ b/engine/src/index/meva_index.rs @@ -0,0 +1,298 @@ +use std::collections::{HashMap, HashSet}; +use std::fs::OpenOptions; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::{fs, io}; + +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use serde_json::Deserializer; +use walkdir::WalkDir; + +use crate::errors::path_error::PathError; +use crate::errors::{EngineError, EngineResult, IndexError}; +use crate::index::index_entry::IndexEntry; +use crate::index::meva_hasher::MevaHasher; +use crate::index::serde_utils::deserialize_entries_with_absolute_keys; +use crate::{IgnoreOperations, IgnoreResult, IgnoreService, RepositoryLayout}; +use shared::extensions::canonicalize_clean::CanonicalizeClean; + +/// Represents the in-memory index of tracked files in a Meva repository. +/// +/// The index keeps metadata (`IndexEntry`) for each tracked file, and is +/// persisted in a JSON file on disk (the *index file*). It is responsible for: +/// - loading and saving the index, +/// - tracking new/modified/deleted files, +/// - respecting ignore rules, +/// - updating file hashes (SHA1). +pub struct MevaIndex<'a> { + /// Layout of the repository (provides paths to working dir, index file, etc.). + repo_layout: &'a dyn RepositoryLayout, + + /// Map of tracked entries, keyed by their path (absolute or relative). + entries: HashMap, +} + +impl<'a> MevaIndex<'a> { + /// Creates a new, empty `MevaIndex` bound to the given repository layout. + pub fn new(repo_layout: &'a dyn RepositoryLayout) -> EngineResult { + Ok(Self { + repo_layout, + entries: HashMap::new(), + }) + } + + /// Loads the index file from disk into memory. + /// + /// If the file does not exist or is empty, the index will be cleared. + pub fn load(&mut self) -> EngineResult<()> { + let mut file = OpenOptions::new() + .write(true) + .read(true) + .create(true) + .truncate(false) + .open(self.repo_layout.index_file())?; + + let mut data = String::new(); + file.read_to_string(&mut data)?; + + if data.trim().is_empty() { + self.entries.clear(); + return Ok(()); + } + + let deserializer = &mut Deserializer::from_str(&data); + + self.entries = + deserialize_entries_with_absolute_keys(deserializer, self.repo_layout.working_dir()) + .map_err(io::Error::other)?; + + Ok(()) + } + + /// Saves the in-memory index back to disk. + /// + /// - Converts absolute paths in entries into repository-relative paths. + /// - Writes the index as a pretty-printed JSON array. + /// + /// Returns an error if: + /// - the index file cannot be found, + /// - a path in the index lies outside the repository root. + pub fn save(&mut self) -> EngineResult<()> { + if !self.repo_layout.index_file().exists() { + return Err(IndexError::IndexFileNotFound { + path: self.repo_layout.index_file(), + } + .into()); + } + let meva_repository_dir = self.repo_layout.working_dir().canonicalize_clean()?; + + for entry in self.entries.values_mut() { + let entry_absolute_path = PathBuf::from(entry.path.clone()); + let relative_path = match entry_absolute_path.strip_prefix(&meva_repository_dir) { + Ok(relative_path) => relative_path, + Err(_) => { + return Err(EngineError::from(PathError::NotInBaseDirectory { + path: entry_absolute_path, + path_base: meva_repository_dir, + })); + } + }; + + entry.path = relative_path.to_string_lossy().to_string(); + } + + let json = + serde_json::to_string_pretty(&self.entries.values().collect::>())?; + + fs::write(self.repo_layout.index_file(), json)?; + Ok(()) + } + + /// Removes and returns entries from the index corresponding to files that no longer exist + /// in the working directory. + /// + /// - `dir_path`: the directory being checked, + /// - `dir_files`: list of still-existing files, + /// - `verbose`: whether to print removed paths to stdout. + fn remove_deleted_files( + &mut self, + dir_path: &PathBuf, + dir_files: &[PathBuf], + verbose: bool, + ) -> Vec { + let files_in_index: Vec = self + .entries + .keys() + .filter(|k| Path::new(&k).starts_with(dir_path)) + .cloned() + .collect(); + + let workdir_set: HashSet = dir_files + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + + let deleted_files: Vec = files_in_index + .into_iter() + .filter(|k| !workdir_set.contains(k)) + .collect(); + + for file in &deleted_files { + if verbose { + println!("remove: {file}") + } + self.entries.remove(file); + } + + deleted_files + } + + /// Updates or inserts index entries with recalculated SHA1 values. + /// + /// The given list of entries is processed in parallel to speed up hashing. + fn update_index( + &mut self, + index_entries: &mut Vec, + verbose: bool, + ) -> EngineResult<()> { + let result: Vec<_> = index_entries + .par_iter() + .map(|index_entry| { + let mut updated_entry = index_entry.clone(); + updated_entry.sha1 = MevaHasher::sha1_file(index_entry.path.as_ref())?; + if verbose { + println!("add: {}", updated_entry.path) + } + Ok(updated_entry) + }) + .collect::>()?; + + for entry in result { + self.entries.insert(entry.path.clone(), entry); + } + + Ok(()) + } + + /// Groups files into: + /// - new (not yet in the index), + /// - modified (metadata differs from index entry). + /// + /// Returns a tuple `(new_files, modified_files)`. + fn group_files( + &self, + files: &Vec, + ) -> EngineResult<(Vec, Vec)> { + let mut new_files = Vec::::new(); + let mut modified_files = Vec::::new(); + + for file_path in files { + let new_entry = IndexEntry::from_path_without_hash(file_path)?; + + if let Some(old) = self.entries.get(&new_entry.path) { + let modified = old.mtime != new_entry.mtime || old.file_size != new_entry.file_size; + + if modified { + modified_files.push(new_entry); + } + } else { + new_files.push(new_entry); + } + } + + Ok((new_files, modified_files)) + } + + /// Creates all ignore services (`IgnoreService`) for the repository. + /// + /// This scans the working directory downward for ignore files and builds + /// services with cached glob patterns. + pub fn create_ignore_services(&self) -> Vec { + let ignore_service = IgnoreService::new(self.repo_layout.ignore_file_name()); + let ignore_files_abs: Vec = + ignore_service.find_ignore_files_down(self.repo_layout.working_dir()); + + ignore_files_abs + .iter() + .filter_map(|path| { + IgnoreService::with_cache(self.repo_layout.ignore_file_name(), Some(path)).ok() + }) + .collect() + } + + /// Adds files to the index according to the provided flags: + /// + /// - `add_new`: include new files, + /// - `add_deleted`: remove missing files, + /// - `add_ignored`: include ignored files (skip ignore check), + /// - `verbose`: print operations to stdout. + /// + /// The path provided (`path_abs`) is the starting point for traversal. + pub fn add( + &mut self, + path_abs: &PathBuf, + add_new: bool, + add_deleted: bool, + add_ignored: bool, + verbose: bool, + ) -> EngineResult<(Vec, Vec, Vec)> { + let ignore_services = match add_ignored { + true => Vec::new(), + false => self.create_ignore_services(), + }; + + let files_abs = self.collect_files(path_abs, ignore_services); + + let (mut new_entries, mut modified_entries) = self.group_files(&files_abs)?; + + let mut new_files = Vec::::new(); + let mut deleted_files = Vec::::new(); + let modified_files = modified_entries + .iter() + .map(|e| PathBuf::from(e.path.clone())) + .collect(); + + if add_deleted { + deleted_files = self + .remove_deleted_files(path_abs, &files_abs, verbose) + .into_iter() + .map(PathBuf::from) + .collect(); + } + + self.update_index(&mut modified_entries, verbose)?; + + if add_new { + self.update_index(&mut new_entries, verbose)?; + new_files = new_entries + .into_iter() + .map(|e| PathBuf::from(e.path)) + .collect(); + } + + Ok((new_files, modified_files, deleted_files)) + } + + /// Recursively collects files under a given path, applying ignore services + /// if provided. + /// + /// Returns only regular files (`is_file()`). + fn collect_files(&self, path: &Path, ignore_services: Vec) -> Vec { + WalkDir::new(path) + .into_iter() + .filter_entry(|e| { + for ignore_service in &ignore_services { + match ignore_service.check_cached(&e.path()) { + Ok(IgnoreResult::Ignored { .. }) => return false, + Ok(IgnoreResult::NotIgnored { .. }) => {} + Err(_) => return true, + } + } + true + }) + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .map(|e| e.path().to_path_buf()) + .collect() + } +} diff --git a/engine/src/index/serde_utils.rs b/engine/src/index/serde_utils.rs new file mode 100644 index 0000000..67fcb3e --- /dev/null +++ b/engine/src/index/serde_utils.rs @@ -0,0 +1,63 @@ +use std::collections::HashMap; +use std::path::Path; + +use serde::{Deserialize, Deserializer}; + +use crate::index::index_entry::IndexEntry; + +/// Deserializes a list of [`IndexEntry`] objects and rewrites their paths +/// into absolute paths based on a given repository base path. +/// +/// # Arguments +/// +/// * `deserializer` - The Serde deserializer providing a JSON array of index entries. +/// * `base_path` - The working directory of the repository; all relative paths +/// inside the entries will be joined with this path to form absolute paths. +/// +/// # Returns +/// +/// A [`HashMap`] where: +/// - the key is the absolute path of the file as a `String`, +/// - the value is the corresponding [`IndexEntry`] with its `path` field +/// updated to the absolute path. +/// +/// # Errors +/// +/// Returns a Serde [`D::Error`] if deserialization of the underlying +/// `Vec` fails. +/// +/// # Example +/// +/// ```rust,ignore +/// let json = r#"[{ +/// "ctime": "...", +/// "mtime": "...", +/// "mode": "Normal", +/// "file_size": 123, +/// "sha1": "abc123", +/// "stage": "Normal", +/// "path": "src/main.rs" +/// }]"#; +/// +/// let base = Path::new("/home/user/project"); +/// let mut deserializer = serde_json::Deserializer::from_str(json); +/// let entries = deserialize_entries_with_absolute_keys(&mut deserializer, base).unwrap(); +/// +/// assert!(entries.contains_key("/home/user/project/src/main.rs")); +/// ``` +pub fn deserialize_entries_with_absolute_keys<'de, D>( + deserializer: D, + base_path: &Path, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let list: Vec = Vec::deserialize(deserializer)?; + list.into_iter() + .map(|mut ie| { + let absolute_path = base_path.join(&ie.path).to_string_lossy().to_string(); + ie.path = absolute_path.clone(); + Ok((absolute_path, ie)) + }) + .collect::, D::Error>>() +} diff --git a/engine/src/index/stage.rs b/engine/src/index/stage.rs new file mode 100644 index 0000000..d2460b2 --- /dev/null +++ b/engine/src/index/stage.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +/// Represents the stage of a file in the index during merge operations. +/// +/// Stages are used to distinguish different versions of the same file +/// when a merge conflict occurs: +/// +/// - **Normal (0)** — standard entry in the index, no conflict. +/// - **Base (1)** — the common ancestor version in a merge conflict. +/// - **Ours (2)** — the file version from the current branch. +/// - **Theirs (3)** — the file version from the branch being merged. +/// +/// This allows the index to hold multiple versions of a file +/// until the conflict is resolved. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum Stage { + /// Standard entry with no merge conflict. + Normal = 0, + + /// Base (common ancestor) version in a merge conflict. + Base = 1, + + /// "Ours" version — file state from the current branch. + Ours = 2, + + /// "Theirs" version — file state from the branch being merged. + Theirs = 3, +} diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 000e266..4d38533 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,7 +1,9 @@ +pub mod add; pub mod config; pub mod engine_container; pub mod errors; pub mod ignore; +pub mod index; pub mod init; pub mod plugins; pub mod plugins_interceptor; diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index 806f3f9..99639b5 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -1,5 +1,8 @@ -use std::path::PathBuf; -use std::{fs, io::Write, path::Path}; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; use tempfile::TempDir; diff --git a/engine/src/repositories/meva_repository_layout.rs b/engine/src/repositories/meva_repository_layout.rs index bb6015a..19997c5 100644 --- a/engine/src/repositories/meva_repository_layout.rs +++ b/engine/src/repositories/meva_repository_layout.rs @@ -28,15 +28,6 @@ impl MevaRepositoryLayout { } impl RepositoryLayout for MevaRepositoryLayout { - /// Returns the repository’s working directory path. - fn working_dir(&self) -> &std::path::Path { - &self.working_dir - } - - fn set_working_dir(&mut self, new_working_dir: PathBuf) { - self.working_dir = new_working_dir; - } - fn repository_dir_name(&self) -> &str { ".meva" } @@ -57,6 +48,10 @@ impl RepositoryLayout for MevaRepositoryLayout { "heads" } + fn plugins_dir_name(&self) -> &str { + "plugins" + } + fn head_file_name(&self) -> &str { "HEAD" } @@ -69,7 +64,16 @@ impl RepositoryLayout for MevaRepositoryLayout { ".mevaignore" } - fn plugins_dir_name(&self) -> &str { - "plugins" + fn index_file_name(&self) -> &str { + "index" + } + + /// Returns the repository’s working directory path. + fn working_dir(&self) -> &std::path::Path { + &self.working_dir + } + + fn set_working_dir(&mut self, new_working_dir: PathBuf) { + self.working_dir = new_working_dir; } } diff --git a/engine/src/repositories/repository_layout.rs b/engine/src/repositories/repository_layout.rs index cbdfe51..70115df 100644 --- a/engine/src/repositories/repository_layout.rs +++ b/engine/src/repositories/repository_layout.rs @@ -33,6 +33,9 @@ pub trait RepositoryLayout { /// Name of the ignore file. fn ignore_file_name(&self) -> &str; + /// name of the index file + fn index_file_name(&self) -> &str; + /// Returns the working directory where the repository is located. fn working_dir(&self) -> &Path; @@ -90,6 +93,11 @@ pub trait RepositoryLayout { self.logs_dir_rel().join(self.head_file_name()) } + /// Path to index file relative to `workdir` + fn index_file_rel(&self) -> PathBuf { + self.repository_dir_rel().join(self.index_file_name()) + } + // --- absolute paths --- /// Absolute path to the repository directory. @@ -141,6 +149,11 @@ pub trait RepositoryLayout { fn head_logs_file(&self) -> PathBuf { self.working_dir().join(self.head_logs_file_rel()) } + + /// Absolute path to the index file. + fn index_file(&self) -> PathBuf { + self.working_dir().join(self.index_file_rel()) + } } #[cfg(test)] @@ -161,14 +174,6 @@ mod tests { } impl RepositoryLayout for TestRepoLayout { - fn working_dir(&self) -> &Path { - &self.dir - } - - fn set_working_dir(&mut self, new_working_dir: PathBuf) { - self.dir = new_working_dir; - } - fn repository_dir_name(&self) -> &str { ".meva" } @@ -189,6 +194,10 @@ mod tests { "heads" } + fn plugins_dir_name(&self) -> &str { + "plugins" + } + fn head_file_name(&self) -> &str { "HEAD" } @@ -201,8 +210,16 @@ mod tests { ".mevaignore" } - fn plugins_dir_name(&self) -> &str { - "plugins" + fn index_file_name(&self) -> &str { + "index" + } + + fn working_dir(&self) -> &Path { + &self.dir + } + + fn set_working_dir(&mut self, new_working_dir: PathBuf) { + self.dir = new_working_dir; } } diff --git a/plugins/src/enums/command_type.rs b/plugins/src/enums/command_type.rs index 392acc5..a18d5a3 100644 --- a/plugins/src/enums/command_type.rs +++ b/plugins/src/enums/command_type.rs @@ -13,4 +13,5 @@ pub enum CommandType { ConfigSet, ConfigUnset, ConfigList, + Add, } diff --git a/plugins/src/enums/invocation_payload.rs b/plugins/src/enums/invocation_payload.rs index 5bc5b14..a05b1ce 100644 --- a/plugins/src/enums/invocation_payload.rs +++ b/plugins/src/enums/invocation_payload.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use crate::{ - InitPostPayload, InitPrePayload, + AddPostPayload, AddPrePayload, InitPostPayload, InitPrePayload, models::{ ConfigGetPostPayload, ConfigGetPrePayload, ConfigListPostPayload, ConfigListPrePayload, ConfigSetPostPayload, ConfigSetPrePayload, ConfigUnsetPostPayload, ConfigUnsetPrePayload, @@ -31,6 +31,9 @@ pub enum InvocationPrePayload { /// Context for the `config list` command. ConfigList(ConfigListPrePayload), + + /// Context for the `add` command. + Add(AddPrePayload), } /// Represents the context data passed to plugins @@ -55,4 +58,7 @@ pub enum InvocationPostPayload { /// Context for the `config list` command. ConfigList(ConfigListPostPayload), + + /// Context for the `add` command. + Add(AddPostPayload), } diff --git a/plugins/src/models/invocation/invocation_context.rs b/plugins/src/models/invocation/invocation_context.rs index 8baccd4..3ed4d8a 100644 --- a/plugins/src/models/invocation/invocation_context.rs +++ b/plugins/src/models/invocation/invocation_context.rs @@ -36,10 +36,7 @@ impl InvocationContext { Ok(Self { command, event, - timestamp: match timestamp { - Some(ts) => ts, - None => Utc::now(), - }, + timestamp: timestamp.unwrap_or_else(Utc::now), working_dir: match working_dir { Some(wd) => wd, None => env::current_dir()?, diff --git a/plugins/src/models/payloads.rs b/plugins/src/models/payloads.rs index dff6e8a..dee4900 100644 --- a/plugins/src/models/payloads.rs +++ b/plugins/src/models/payloads.rs @@ -1,5 +1,7 @@ +mod add_payload; mod config_payload; mod init_payload; +pub use add_payload::*; pub use config_payload::*; pub use init_payload::*; diff --git a/plugins/src/models/payloads/add_payload.rs b/plugins/src/models/payloads/add_payload.rs new file mode 100644 index 0000000..012da06 --- /dev/null +++ b/plugins/src/models/payloads/add_payload.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Payload provided to plugins **before** the `meva add` command execution +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AddPrePayload { + pub all_flag: bool, + pub force_flag: bool, + pub update_flag: bool, + pub dry_run_flag: bool, + pub verbose_flag: bool, + pub path_arg: Option, +} + +/// Payload provided to plugins **after** the `meva add` command execution +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AddPostPayload { + pub added: Vec, + pub modified: Vec, + pub removed: Vec, +} diff --git a/shared/src/extensions.rs b/shared/src/extensions.rs index b9de664..b14ded6 100644 --- a/shared/src/extensions.rs +++ b/shared/src/extensions.rs @@ -1,5 +1,8 @@ +pub mod canonicalize_clean; pub mod fs; +pub mod is_within; pub mod open_in_editor; +pub mod remove_windows_prefix; pub mod upward_search; pub use fs::create_file_with_dirs; diff --git a/shared/src/extensions/canonicalize_clean.rs b/shared/src/extensions/canonicalize_clean.rs new file mode 100644 index 0000000..7e09ad5 --- /dev/null +++ b/shared/src/extensions/canonicalize_clean.rs @@ -0,0 +1,63 @@ +use crate::extensions::remove_windows_prefix::RemoveWindowsPrefix; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +/// A trait providing a helper method to canonicalize paths +/// and remove the Windows UNC prefix (`\\?\`) if present. +/// +/// This trait allows you to call `canonicalize_clean()` directly on any `Path` +/// without manually handling Windows path normalization. +pub trait CanonicalizeClean { + /// Canonicalizes the path and removes the Windows UNC prefix. + /// + /// # Returns + /// + /// * `Result` - Returns the canonicalized and normalized path, + /// or an `io::Error` if canonicalization fails. + /// + /// # Example + /// + /// ```no_run + /// use std::path::Path; + /// use shared::extensions::canonicalize_clean::CanonicalizeClean; + /// + /// let path = Path::new("./some/path"); + /// let clean_path = path.canonicalize_clean().unwrap(); + /// println!("Canonicalized path: {}", clean_path.display()); + /// ``` + fn canonicalize_clean(&self) -> Result; +} + +impl CanonicalizeClean for Path { + fn canonicalize_clean(&self) -> Result { + let canonical = self.canonicalize()?; + Ok(canonical.remove_windows_prefix()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::fs; + + #[test] + fn canonicalize_existing_relative_path() { + let current_dir = env::current_dir().unwrap(); + let relative = Path::new("."); + let clean = relative.canonicalize_clean().unwrap(); + + assert_eq!(clean, current_dir); + } + + #[test] + fn canonicalize_nested_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + fs::write(&file_path, "hello").unwrap(); + + let clean = file_path.canonicalize_clean().unwrap(); + assert!(clean.ends_with("test.txt")); + } +} diff --git a/shared/src/extensions/is_within.rs b/shared/src/extensions/is_within.rs new file mode 100644 index 0000000..1ded6f4 --- /dev/null +++ b/shared/src/extensions/is_within.rs @@ -0,0 +1,78 @@ +use std::io; +use std::path::{Path, PathBuf}; + +/// Extension trait for checking whether a given path +/// is located within another path. +/// +/// This is useful for verifying repository layout constraints, +/// ensuring that files are inside a specific directory, etc. +pub trait IsWithin { + /// Returns `Ok(true)` if `self` (the candidate path) is located + /// within the given `path` (the base directory). + /// + /// Both paths are canonicalized before comparison, so symbolic + /// links and relative paths are resolved. + /// + /// # Errors + /// + /// Returns an [`io::Error`] if canonicalization of either path fails. + fn is_within(&self, path: &Path) -> Result; +} + +impl IsWithin for PathBuf { + fn is_within(&self, path: &Path) -> Result { + let parent_can = path.canonicalize()?; + let child_can = self.canonicalize()?; + + Ok(child_can.starts_with(&parent_can)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn path_within_base_directory() { + let tmp_dir = tempdir().unwrap(); + let base = tmp_dir.path().to_path_buf(); + + let child = base.join("file.txt"); + fs::write(&child, "hello world").unwrap(); + + assert!(child.is_within(&base).unwrap()); + } + + #[test] + fn path_outside_base_directory() { + let tmp_dir = tempdir().unwrap(); + let base = tmp_dir.path().join("base"); + fs::create_dir(&base).unwrap(); + + let sibling = tmp_dir.path().join("sibling"); + fs::create_dir(&sibling).unwrap(); + + let outside = sibling.join("file.txt"); + fs::write(&outside, "outside file").unwrap(); + + assert!(!outside.is_within(&base).unwrap()); + } + + #[test] + fn relative_path_is_resolved() { + let tmp_dir = tempdir().unwrap(); + let base = tmp_dir.path(); + + let nested = base.join("nested"); + fs::create_dir(&nested).unwrap(); + + let file_path = nested.join("file.txt"); + fs::write(&file_path, "hello world").unwrap(); + + let child_relative = nested.join("..").join("nested/file.txt"); + + assert!(child_relative.is_within(base).unwrap()); + } +} diff --git a/shared/src/extensions/remove_windows_prefix.rs b/shared/src/extensions/remove_windows_prefix.rs new file mode 100644 index 0000000..d30ae48 --- /dev/null +++ b/shared/src/extensions/remove_windows_prefix.rs @@ -0,0 +1,60 @@ +use std::path::PathBuf; + +/// Extension trait for removing the Windows long path prefix (`\\?\`) from a path. +/// +/// On Windows, paths can be prefixed with `\\?\` to allow long paths (> 260 characters) +/// or to use extended path syntax. This trait provides a convenient way to strip +/// that prefix, returning a normalized `PathBuf`. +/// # Example +/// ``` +/// use std::path::PathBuf; +/// use shared::extensions::remove_windows_prefix::RemoveWindowsPrefix; +/// +/// let path = PathBuf::from(r"\\?\C:\Users\example\file.txt"); +/// let stripped = path.remove_windows_prefix(); +/// assert_eq!(stripped, PathBuf::from(r"C:\Users\example\file.txt")); +/// ``` +pub trait RemoveWindowsPrefix { + /// Returns a new `PathBuf` with the `\\?\` prefix removed if present. + /// + /// If the path does not start with the Windows long path prefix, the original + /// path is returned as a clone. + fn remove_windows_prefix(&self) -> PathBuf; +} + +impl RemoveWindowsPrefix for PathBuf { + fn remove_windows_prefix(&self) -> PathBuf { + let s = self.as_os_str().to_string_lossy(); + if let Some(stripped) = s.strip_prefix(r"\\?\") { + PathBuf::from(stripped) + } else { + self.clone() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn removes_windows_prefix_if_present() { + let path = PathBuf::from(r"\\?\C:\Users\example\file.txt"); + let stripped = path.remove_windows_prefix(); + assert_eq!(stripped, PathBuf::from(r"C:\Users\example\file.txt")); + } + + #[test] + fn leaves_path_unchanged_if_no_prefix() { + let path = PathBuf::from(r"C:\Users\example\file.txt"); + let stripped = path.remove_windows_prefix(); + assert_eq!(stripped, path); + } + + #[test] + fn works_with_non_windows_like_path() { + let path = PathBuf::from("/home/user/file.txt"); + let stripped = path.remove_windows_prefix(); + assert_eq!(stripped, path); + } +}