diff --git a/.gitignore b/.gitignore index ad67955..ff2474a 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Ignore all the files created by running `cargo run --bin meva -- init` at the root of the project +.meva \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index e1973a6..d9f40d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.19" @@ -76,6 +91,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "backtrace" version = "0.3.75" @@ -116,12 +137,42 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "cc" +version = "1.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" +dependencies = [ + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.41" @@ -129,6 +180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -143,6 +195,18 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_derive" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "clap_lex" version = "0.7.5" @@ -154,7 +218,6 @@ name = "cli" version = "0.1.0" dependencies = [ "clap", - "editor-command", "engine", "globset", "miette", @@ -163,6 +226,8 @@ dependencies = [ "pretty_assertions", "rstest", "shared", + "strum", + "strum_macros", "thiserror", ] @@ -172,6 +237,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "diff" version = "0.1.13" @@ -218,12 +289,15 @@ dependencies = [ name = "engine" version = "0.1.0" dependencies = [ + "chrono", "dirs", "globset", "mockall", + "plugins", "pretty_assertions", "rstest", "serde", + "serde_json", "shared", "tempfile", "thiserror", @@ -368,6 +442,36 @@ version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "indexmap" version = "2.10.0" @@ -390,6 +494,22 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.174" @@ -489,6 +609,15 @@ dependencies = [ "syn", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.36.7" @@ -538,10 +667,17 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" name = "plugins" version = "0.1.0" dependencies = [ + "chrono", "mockall", "pretty_assertions", "rstest", + "serde", + "serde_json", "shared", + "strum", + "strum_macros", + "thiserror", + "wait-timeout", ] [[package]] @@ -723,6 +859,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + [[package]] name = "semver" version = "1.0.26" @@ -749,6 +897,18 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "serde_spanned" version = "1.0.0" @@ -762,6 +922,7 @@ dependencies = [ name = "shared" version = "0.1.0" dependencies = [ + "editor-command", "mockall", "pretty_assertions", "rstest", @@ -775,6 +936,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "slab" version = "0.4.10" @@ -787,6 +954,24 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "supports-color" version = "3.0.2" @@ -1007,6 +1192,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wait-timeout" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f3bf741a801531993db6478b95682117471f76916f5e690dd8d45395b09349" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1022,6 +1216,123 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index 44c86ce..e8e3d8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,12 @@ mockall = "0.13.1" pretty_assertions = "1.4.1" tempfile = "3.20.0" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0.143" up_finder = "0.0.4" dirs = "6.0.0" editor-command = "1.0.0" globset = "0.4.16" +chrono = { version = "0.4.41", features = ["serde"] } +clap = { version = "4.5.41", features = ["derive"] } +strum = "0.27" +strum_macros = "0.27" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index c30a2ee..49f8606 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -12,11 +12,12 @@ path = "src/main.rs" shared = { path = "../shared" } engine = { path = "../engine" } plugins = { path = "../plugins" } -clap = "4.5.41" +clap.workspace = true thiserror.workspace = true miette = { version = "7.6.0", features = ["fancy"] } -editor-command.workspace = true globset.workspace = true +strum.workspace = true +strum_macros.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/cli/src/commands.rs b/cli/src/commands.rs index a935c19..30074e0 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -2,9 +2,11 @@ pub mod config; pub mod ignore; pub mod init; pub mod meva_command; +pub mod plugins; pub use config::ConfigCommand; pub use ignore::IgnoreCommand; pub use init::InitCommand; +pub use plugins::PluginsCommand; pub use meva_command::MevaCommand; diff --git a/cli/src/commands/config.rs b/cli/src/commands/config.rs index f4f9bbc..0d0ef1b 100644 --- a/cli/src/commands/config.rs +++ b/cli/src/commands/config.rs @@ -1,6 +1,8 @@ pub mod subcommands; use crate::commands::MevaCommand; + +use engine::engine_container::MevaContainer; use subcommands::*; /// Implements the `config` command for Meva DVCS. @@ -16,7 +18,7 @@ impl ConfigCommand { } } -impl MevaCommand for ConfigCommand { +impl MevaCommand for ConfigCommand { fn name(&self) -> &'static str { "config" } @@ -32,7 +34,7 @@ impl MevaCommand for ConfigCommand { /// Define the set of subcommands under `config` namespace. /// /// Returns boxed instances of each config operation command. - fn subcommands(&self) -> Vec> { + fn subcommands(&self) -> Vec>> { vec![ Box::new(ConfigListCommand), Box::new(ConfigGetCommand), diff --git a/cli/src/commands/config/subcommands/edit.rs b/cli/src/commands/config/subcommands/edit.rs index 21a4ad4..9fffe6c 100644 --- a/cli/src/commands/config/subcommands/edit.rs +++ b/cli/src/commands/config/subcommands/edit.rs @@ -1,10 +1,12 @@ use clap::{ArgMatches, Command}; +use engine::engine_container::MevaContainer; use miette::{Context, IntoDiagnostic}; use engine::{ConfigDocument, ConfigLoader}; +use shared::extensions::OpenInEditor; use crate::commands::MevaCommand; -use crate::extensions::{LocationSelection, OpenInEditor, WithLocations}; +use crate::extensions::{LocationSelection, WithLocations}; /// Implements the `edit` subcommand for Meva configuration management. /// @@ -20,7 +22,7 @@ impl ConfigEditCommand { } } -impl MevaCommand for ConfigEditCommand { +impl MevaCommand for ConfigEditCommand { fn name(&self) -> &'static str { "edit" } @@ -41,8 +43,8 @@ impl MevaCommand for ConfigEditCommand { ) } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { - let loader = ConfigLoader::new_default(); + fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> miette::Result<()> { + let loader = ConfigLoader::default(); let override_cmd = loader.get("core.editor", None).ok(); let location = matches .get_config_location() @@ -52,7 +54,9 @@ impl MevaCommand for ConfigEditCommand { ConfigDocument::validate_existing_file(&location).into_diagnostic()?; - location.open_in_editor(override_cmd) + location.open_in_editor(override_cmd).into_diagnostic()?; + + Ok(()) } } diff --git a/cli/src/commands/config/subcommands/get.rs b/cli/src/commands/config/subcommands/get.rs index 1e8469f..88106a6 100644 --- a/cli/src/commands/config/subcommands/get.rs +++ b/cli/src/commands/config/subcommands/get.rs @@ -1,5 +1,7 @@ use clap::{Arg, ArgMatches, Command}; -use engine::{ConfigDocument, ConfigOperations}; +use engine::EngineContainer; +use engine::config::GetRequest; +use engine::engine_container::MevaContainer; use miette::IntoDiagnostic; use crate::commands::MevaCommand; @@ -23,7 +25,7 @@ impl ConfigGetCommand { const ARG_DEFAULT: &'static str = "default"; } -impl MevaCommand for ConfigGetCommand { +impl MevaCommand for ConfigGetCommand { fn name(&self) -> &'static str { "get" } @@ -59,18 +61,26 @@ impl MevaCommand for ConfigGetCommand { ) } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { - let location = matches - .get_config_location() - .get_default_path() - .into_diagnostic()?; - + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + let location = matches.get_config_location(); let key = matches.get_one::(Self::ARG_KEY).unwrap(); let default = matches.get_one::(Self::ARG_DEFAULT); - let doc = ConfigDocument::load(location).into_diagnostic()?; - let config_value = doc.get(key, default).into_diagnostic()?; - println!("{config_value}"); + let config_handler = container.config_handler().into_diagnostic()?; + let interceptor = container.plugins_interceptor().into_diagnostic()?; + + let request = GetRequest { + location, + key: key.clone(), + default: default.cloned(), + }; + + let response = config_handler + .handle_get(request, &interceptor) + .into_diagnostic()?; + + println!("{}", response.value); + Ok(()) } } diff --git a/cli/src/commands/config/subcommands/list.rs b/cli/src/commands/config/subcommands/list.rs index c100cc5..461aa86 100644 --- a/cli/src/commands/config/subcommands/list.rs +++ b/cli/src/commands/config/subcommands/list.rs @@ -1,5 +1,7 @@ use clap::{ArgMatches, Command}; -use engine::{ConfigDocument, ConfigOperations}; +use engine::EngineContainer; +use engine::config::ListRequest; +use engine::engine_container::MevaContainer; use miette::IntoDiagnostic; use crate::commands::MevaCommand; @@ -19,7 +21,7 @@ impl ConfigListCommand { } } -impl MevaCommand for ConfigListCommand { +impl MevaCommand for ConfigListCommand { fn name(&self) -> &'static str { "list" } @@ -40,22 +42,30 @@ impl MevaCommand for ConfigListCommand { ) } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { - let location = matches - .get_config_location() - .get_default_path() - .into_diagnostic()?; + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + let location = matches.get_config_location(); + + let config_handler = container.config_handler().into_diagnostic()?; + let interceptor = container.plugins_interceptor().into_diagnostic()?; - let doc = ConfigDocument::load(location).into_diagnostic()?; - let key_values = doc.list(); + let request = ListRequest { location }; + + let response = config_handler + .handle_list(request, &interceptor) + .into_diagnostic()?; - if key_values.is_empty() { + if response.key_values.is_empty() { println!("Config file has no key-value pairs to display.") } - let max_key_len = key_values.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + let max_key_len = response + .key_values + .iter() + .map(|(k, _)| k.len()) + .max() + .unwrap_or(0); - for (key, value) in key_values { + for (key, value) in response.key_values { println!("{key:max_key_len$} = {value}"); } diff --git a/cli/src/commands/config/subcommands/set.rs b/cli/src/commands/config/subcommands/set.rs index 09a2dc1..35950b0 100644 --- a/cli/src/commands/config/subcommands/set.rs +++ b/cli/src/commands/config/subcommands/set.rs @@ -1,5 +1,7 @@ use clap::{Arg, ArgMatches, Command}; -use engine::{ConfigDocument, ConfigOperations}; +use engine::EngineContainer; +use engine::config::SetRequest; +use engine::engine_container::MevaContainer; use miette::IntoDiagnostic; use crate::commands::MevaCommand; @@ -21,7 +23,7 @@ impl ConfigSetCommand { const ARG_VALUE: &'static str = "value"; } -impl MevaCommand for ConfigSetCommand { +impl MevaCommand for ConfigSetCommand { fn name(&self) -> &'static str { "set" } @@ -51,18 +53,25 @@ impl MevaCommand for ConfigSetCommand { ) } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { let key = matches.get_one::(Command::ARG_KEY).unwrap(); let value = matches.get_one::(Self::ARG_VALUE).unwrap(); - let location = matches - .get_config_location() - .get_default_path() - .into_diagnostic()?; + let location = matches.get_config_location(); + + let config_handler = container.config_handler().into_diagnostic()?; + let interceptor = container.plugins_interceptor().into_diagnostic()?; - let mut doc = ConfigDocument::load(location).into_diagnostic()?; + let request = SetRequest { + location, + key: key.clone(), + value: value.clone(), + }; + + let response = config_handler + .handle_set(request, &interceptor) + .into_diagnostic()?; - doc.set(key, value).into_diagnostic()?; - doc.save().into_diagnostic()?; + println!("{} = {}", response.key, response.value); Ok(()) } diff --git a/cli/src/commands/config/subcommands/unset.rs b/cli/src/commands/config/subcommands/unset.rs index d20bf80..3b34a6d 100644 --- a/cli/src/commands/config/subcommands/unset.rs +++ b/cli/src/commands/config/subcommands/unset.rs @@ -1,5 +1,7 @@ use clap::{ArgMatches, Command}; -use engine::{ConfigDocument, ConfigOperations}; +use engine::EngineContainer; +use engine::config::UnsetRequest; +use engine::engine_container::MevaContainer; use miette::IntoDiagnostic; use crate::commands::MevaCommand; @@ -18,7 +20,7 @@ impl ConfigUnsetCommand { } } -impl MevaCommand for ConfigUnsetCommand { +impl MevaCommand for ConfigUnsetCommand { fn name(&self) -> &'static str { "unset" } @@ -41,17 +43,23 @@ impl MevaCommand for ConfigUnsetCommand { .with_key_arg("TOML path to the config entry") } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { let key = matches.get_one::(Command::ARG_KEY).unwrap(); - let location = matches - .get_config_location() - .get_default_path() - .into_diagnostic()?; + let location = matches.get_config_location(); + + let config_handler = container.config_handler().into_diagnostic()?; + let interceptor = container.plugins_interceptor().into_diagnostic()?; - let mut doc = ConfigDocument::load(location).into_diagnostic()?; + let request = UnsetRequest { + location, + key: key.clone(), + }; + + let response = config_handler + .handle_unset(request, &interceptor) + .into_diagnostic()?; - doc.unset(key).into_diagnostic()?; - doc.save().into_diagnostic()?; + println!("{}", response.value); Ok(()) } diff --git a/cli/src/commands/ignore.rs b/cli/src/commands/ignore.rs index 39e48b5..3d37505 100644 --- a/cli/src/commands/ignore.rs +++ b/cli/src/commands/ignore.rs @@ -2,6 +2,7 @@ pub mod subcommands; use crate::commands::MevaCommand; +use engine::engine_container::MevaContainer; use subcommands::*; /// Implements the `ignore` command for Meva DVCS. @@ -16,7 +17,7 @@ impl IgnoreCommand { } } -impl MevaCommand for IgnoreCommand { +impl MevaCommand for IgnoreCommand { fn name(&self) -> &'static str { "ignore" } @@ -32,7 +33,7 @@ impl MevaCommand for IgnoreCommand { /// Define the set of subcommands under `ignore` namespace. /// /// Returns boxed instances of each ignore operation command. - fn subcommands(&self) -> Vec> { + fn subcommands(&self) -> Vec>> { vec![ Box::new(IgnoreAddCommand), Box::new(IgnoreCheckCommand), diff --git a/cli/src/commands/ignore/subcommands/add.rs b/cli/src/commands/ignore/subcommands/add.rs index b545a62..b33bf85 100644 --- a/cli/src/commands/ignore/subcommands/add.rs +++ b/cli/src/commands/ignore/subcommands/add.rs @@ -1,7 +1,10 @@ use std::path::PathBuf; use clap::{ArgMatches, Command}; -use engine::{IgnoreOperations, IgnoreService, MevaRepository, RepositoryLayout}; +use engine::{ + IgnoreOperations, IgnoreService, RepositoryLayout, engine_container::MevaContainer, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; use globset::Glob; use miette::IntoDiagnostic; @@ -24,7 +27,7 @@ impl IgnoreAddCommand { } } -impl MevaCommand for IgnoreAddCommand { +impl MevaCommand for IgnoreAddCommand { fn name(&self) -> &'static str { "add" } @@ -43,11 +46,12 @@ impl MevaCommand for IgnoreAddCommand { .with_file_arg("Path to a specific ignore file") } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> miette::Result<()> { let pattern = matches.get_one::(Command::ARG_PATTERN).unwrap(); let file = matches.get_one::(Command::ARG_FILE); - let ignore_service = IgnoreService::new(MevaRepository::IGNORE_FILE); + let layout = MevaRepositoryLayout::from_env().into_diagnostic()?; + let ignore_service = IgnoreService::new(layout.ignore_file_name()); let result_path = ignore_service.add(pattern, file).into_diagnostic()?; diff --git a/cli/src/commands/ignore/subcommands/check.rs b/cli/src/commands/ignore/subcommands/check.rs index 056d820..d4119ea 100644 --- a/cli/src/commands/ignore/subcommands/check.rs +++ b/cli/src/commands/ignore/subcommands/check.rs @@ -1,7 +1,10 @@ use std::path::{Path, PathBuf}; use clap::{Arg, ArgAction, ArgMatches, Command}; -use engine::{IgnoreOperations, IgnoreResult, IgnoreService, MevaRepository, RepositoryLayout}; +use engine::{ + IgnoreOperations, IgnoreResult, IgnoreService, RepositoryLayout, + engine_container::MevaContainer, repositories::meva_repository_layout::MevaRepositoryLayout, +}; use miette::IntoDiagnostic; use crate::{commands::MevaCommand, extensions::WithFile}; @@ -23,6 +26,7 @@ impl IgnoreCheckCommand { const ARG_EXPLAIN: &'static str = "explain"; + #[allow(dead_code)] fn print_check_result(result: &IgnoreResult, checked_path: &Path, explain: bool) { match result { IgnoreResult::Ignored { patterns, path } => { @@ -55,7 +59,7 @@ impl IgnoreCheckCommand { } } -impl MevaCommand for IgnoreCheckCommand { +impl MevaCommand for IgnoreCheckCommand { fn name(&self) -> &'static str { "check" } @@ -87,12 +91,13 @@ impl MevaCommand for IgnoreCheckCommand { ) } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> miette::Result<()> { let file = matches.get_one::(Command::ARG_FILE); let explain = matches.get_flag(Self::ARG_EXPLAIN); let checked_path = matches.get_one::(Self::ARG_PATH).unwrap(); - let ignore_service = IgnoreService::new(MevaRepository::IGNORE_FILE); + let layout = MevaRepositoryLayout::from_env().into_diagnostic()?; + let ignore_service = IgnoreService::new(layout.ignore_file_name()); let ignore_result = ignore_service.check(checked_path, file).into_diagnostic()?; diff --git a/cli/src/commands/ignore/subcommands/edit.rs b/cli/src/commands/ignore/subcommands/edit.rs index 295011a..3ec7de5 100644 --- a/cli/src/commands/ignore/subcommands/edit.rs +++ b/cli/src/commands/ignore/subcommands/edit.rs @@ -1,13 +1,14 @@ use std::path::PathBuf; use clap::{ArgMatches, Command}; -use engine::{ConfigLoader, IgnoreOperations, IgnoreService, MevaRepository, RepositoryLayout}; +use engine::{ + ConfigLoader, IgnoreOperations, IgnoreService, RepositoryLayout, + engine_container::MevaContainer, repositories::meva_repository_layout::MevaRepositoryLayout, +}; use miette::IntoDiagnostic; +use shared::extensions::OpenInEditor; -use crate::{ - commands::MevaCommand, - extensions::{OpenInEditor, WithFile}, -}; +use crate::{commands::MevaCommand, extensions::WithFile}; /// Implements the `edit` subcommand for Meva ignored files management. /// @@ -23,7 +24,7 @@ impl IgnoreEditCommand { } } -impl MevaCommand for IgnoreEditCommand { +impl MevaCommand for IgnoreEditCommand { fn name(&self) -> &'static str { "edit" } @@ -41,11 +42,13 @@ impl MevaCommand for IgnoreEditCommand { .with_file_arg("Path to a specific ignore file") } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> miette::Result<()> { let file = matches.get_one::(Command::ARG_FILE); - let ignore_service = IgnoreService::new(MevaRepository::IGNORE_FILE); - let loader = ConfigLoader::new_default(); + let layout = MevaRepositoryLayout::from_env().into_diagnostic()?; + let ignore_service = IgnoreService::new(layout.ignore_file_name()); + + let loader = ConfigLoader::default(); let override_cmd = loader.get("core.editor", None).ok(); let ignore_file = match file { @@ -53,7 +56,9 @@ impl MevaCommand for IgnoreEditCommand { None => ignore_service.find_ignore_file(None).into_diagnostic()?, }; - ignore_file.open_in_editor(override_cmd) + ignore_file.open_in_editor(override_cmd).into_diagnostic()?; + + Ok(()) } } diff --git a/cli/src/commands/ignore/subcommands/remove.rs b/cli/src/commands/ignore/subcommands/remove.rs index 9bfc64e..b65e160 100644 --- a/cli/src/commands/ignore/subcommands/remove.rs +++ b/cli/src/commands/ignore/subcommands/remove.rs @@ -1,7 +1,10 @@ use std::path::PathBuf; use clap::{ArgMatches, Command}; -use engine::{IgnoreOperations, IgnoreService, MevaRepository, RepositoryLayout}; +use engine::{ + IgnoreOperations, IgnoreService, RepositoryLayout, engine_container::MevaContainer, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; use globset::Glob; use miette::IntoDiagnostic; @@ -24,7 +27,7 @@ impl IgnoreRemoveCommand { } } -impl MevaCommand for IgnoreRemoveCommand { +impl MevaCommand for IgnoreRemoveCommand { fn name(&self) -> &'static str { "remove" } @@ -43,11 +46,12 @@ impl MevaCommand for IgnoreRemoveCommand { .with_file_arg("Path to a specific ignore file") } - fn execute(&self, matches: &ArgMatches) -> miette::Result<()> { + fn execute(&self, matches: &ArgMatches, _container: &MevaContainer) -> miette::Result<()> { let pattern = matches.get_one::(Command::ARG_PATTERN).unwrap(); let file = matches.get_one::(Command::ARG_FILE); - let ignore_service = IgnoreService::new(MevaRepository::IGNORE_FILE); + let layout = MevaRepositoryLayout::from_env().into_diagnostic()?; + let ignore_service = IgnoreService::new(layout.ignore_file_name()); let (result_path, removed_patterns) = ignore_service.remove(pattern, file).into_diagnostic()?; diff --git a/cli/src/commands/init.rs b/cli/src/commands/init.rs index c67ee44..25b5062 100644 --- a/cli/src/commands/init.rs +++ b/cli/src/commands/init.rs @@ -1,21 +1,21 @@ use std::path::PathBuf; -use clap::{Arg, ArgMatches, Command}; +use clap::{Arg, ArgMatches, Command, ValueHint}; +use engine::{EngineContainer, engine_container::MevaContainer, init::Request}; use miette::{IntoDiagnostic, Result}; use crate::commands::MevaCommand; -use engine::MevaRepository; /// Implements the `init` command for Meva DVCS. /// /// Initializes a new repository at a specified path, /// optionally setting the initial branch name. -pub struct InitCommand; +pub struct InitCommand {} impl InitCommand { /// Creates a new instance of the `InitCommand`. pub fn new() -> Self { - Self + Self {} } /// Argument name for specifying the initial branch. @@ -25,7 +25,7 @@ impl InitCommand { const ARG_PATH: &'static str = "path"; } -impl MevaCommand for InitCommand { +impl MevaCommand for InitCommand { fn name(&self) -> &'static str { "init" } @@ -60,6 +60,7 @@ impl MevaCommand for InitCommand { .help("Path to initialize repository") .default_value(".") .value_parser(clap::value_parser!(PathBuf)) + .value_hint(ValueHint::FilePath) .index(1), ) } @@ -77,14 +78,26 @@ impl MevaCommand for InitCommand { /// /// # Returns /// * `Result<()>`: Indicates success or detailed error if initialization fails. - fn execute(&self, matches: &ArgMatches) -> Result<()> { + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> { let branch = matches.get_one::(Self::ARG_BRANCH).unwrap(); let target = matches.get_one::(Self::ARG_PATH).unwrap(); - let repository = MevaRepository::new(target); - repository.init(branch).into_diagnostic()?; + let init_handler = container.init_handler().into_diagnostic()?; + let interceptor = container.plugins_interceptor().into_diagnostic()?; - println!("Repository initialized successfully!"); + let request = Request { + working_dir: target.clone(), + initial_branch: branch.clone(), + }; + + let response = init_handler + .handle_init(request, &interceptor) + .into_diagnostic()?; + + println!( + "Repository initialized successfully at: {}", + response.repository_dir.to_string_lossy() + ); Ok(()) } diff --git a/cli/src/commands/meva_command.rs b/cli/src/commands/meva_command.rs index 6f48c06..50b9da1 100644 --- a/cli/src/commands/meva_command.rs +++ b/cli/src/commands/meva_command.rs @@ -1,8 +1,12 @@ use clap::{ArgMatches, Command}; +use engine::EngineContainer; use miette::{Context, Result}; /// A trait representing a top-level command in the Meva CLI. -pub trait MevaCommand { +pub trait MevaCommand +where + T: EngineContainer, +{ /// Returns the unique name of the command. fn name(&self) -> &'static str; @@ -47,12 +51,12 @@ pub trait MevaCommand { /// /// # Returns /// * `Result<()>`: Indicates whether the execution succeeded or an error occurred during dispatch. - fn execute(&self, matches: &ArgMatches) -> Result<()> { + fn execute(&self, matches: &ArgMatches, container: &T) -> Result<()> { if let Some((name, sub_matches)) = matches.subcommand() { for sub_command in self.subcommands() { if sub_command.name() == name { return sub_command - .execute(sub_matches) + .execute(sub_matches, container) .wrap_err_with(|| format!("Error running `{name}` subcommand")); } } @@ -64,7 +68,7 @@ pub trait MevaCommand { /// Returns a vector of boxed subcommands for this command. /// /// Default implementation returns an empty vector. - fn subcommands(&self) -> Vec> { + fn subcommands(&self) -> Vec>> { Vec::new() } } diff --git a/cli/src/commands/plugins.rs b/cli/src/commands/plugins.rs new file mode 100644 index 0000000..b489ea4 --- /dev/null +++ b/cli/src/commands/plugins.rs @@ -0,0 +1,62 @@ +pub mod subcommands; + +use crate::commands::MevaCommand; + +use engine::engine_container::MevaContainer; +use subcommands::*; + +/// Implements the `plugins` top-level command for Meva. +/// +/// The `plugins` command serves as a namespace for all plugin-related operations +/// such as listing, editing, registering, unregistering, or retrieving information +/// about plugins. +pub struct PluginsCommand; + +impl PluginsCommand { + /// Creates a new instance of the `PluginsCommand`. + pub fn new() -> Self { + Self + } +} + +impl MevaCommand for PluginsCommand { + fn name(&self) -> &'static str { + "plugins" + } + + fn about(&self) -> &'static str { + "Manage plugins" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + /// Define the set of subcommands under `plugins` namespace. + /// + /// Returns boxed instances of each ignore operation command. + fn subcommands(&self) -> Vec>> { + vec![ + Box::new(PluginsEditCommand), + Box::new(PluginsInfoCommand), + Box::new(PluginsListCommand), + Box::new(PluginsRegisterCommand), + Box::new(PluginsUnregisterCommand), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = PluginsCommand::new(); + assert_eq!(cmd.name(), "plugins"); + assert_eq!(cmd.about(), "Manage plugins"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/plugins/subcommands.rs b/cli/src/commands/plugins/subcommands.rs new file mode 100644 index 0000000..4a82e4e --- /dev/null +++ b/cli/src/commands/plugins/subcommands.rs @@ -0,0 +1,11 @@ +mod edit; +mod info; +mod list; +mod register; +mod unregister; + +pub use edit::PluginsEditCommand; +pub use info::PluginsInfoCommand; +pub use list::PluginsListCommand; +pub use register::PluginsRegisterCommand; +pub use unregister::PluginsUnregisterCommand; diff --git a/cli/src/commands/plugins/subcommands/edit.rs b/cli/src/commands/plugins/subcommands/edit.rs new file mode 100644 index 0000000..f1d3d76 --- /dev/null +++ b/cli/src/commands/plugins/subcommands/edit.rs @@ -0,0 +1,125 @@ +use clap::{Arg, ArgAction, ArgMatches, Command}; +use engine::{ + ConfigLoader, EngineContainer, + engine_container::MevaContainer, + plugins::{EditRequest, PluginsOperations}, +}; +use miette::IntoDiagnostic; +use plugins::{CommandType, ScopeType}; +use shared::extensions::OpenInEditor; + +use crate::{ + commands::MevaCommand, + extensions::{WithCommandPlugin, WithScope}, +}; + +pub struct PluginsEditCommand; + +impl PluginsEditCommand { + #[allow(dead_code)] + pub fn new() -> Self { + Self + } + + const ARG_ENABLE: &'static str = "enable"; + + const ARG_DISABLE: &'static str = "disable"; +} + +impl MevaCommand for PluginsEditCommand { + fn name(&self) -> &'static str { + "edit" + } + + fn about(&self) -> &'static str { + "Open the file with plugin's source code in your default editor" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .with_command_and_plugin_arg( + "The command associated with the plugin", + "The name of the plugin to edit", + ) + .with_scope_arg("Scope of the plugin") + .arg( + Arg::new(Self::ARG_ENABLE) + .short('e') + .long(Self::ARG_ENABLE) + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_DISABLE) + .help("Enable plugin"), + ) + .arg( + Arg::new(Self::ARG_DISABLE) + .short('d') + .long(Self::ARG_DISABLE) + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_ENABLE) + .help("Disable plugin"), + ) + } + + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + let command_str = matches.get_one::(Command::ARG_COMMAND).unwrap(); + let name = matches.get_one::(Command::ARG_PLUGIN).unwrap(); + let default_scope = ScopeType::Local.to_string(); + let scope_str = matches + .get_one::(Command::ARG_SCOPE) + .unwrap_or(&default_scope); + let enable = matches.get_flag(Self::ARG_ENABLE); + let disable = matches.get_flag(Self::ARG_DISABLE); + + let enabled = if enable || disable { + Some(!disable) + } else { + None + }; + + let request = EditRequest { + command: command_str.parse::().unwrap(), + name: name.clone(), + scope: scope_str.parse::().unwrap(), + enabled, + }; + + let handler = container.plugins_handler().into_diagnostic()?; + + let response = handler.edit(request).into_diagnostic()?; + + if enabled.is_none() { + let loader = ConfigLoader::default(); + let override_cmd = loader.get("core.editor", None).ok(); + response + .source_file + .open_in_editor(override_cmd) + .into_diagnostic()?; + } else { + println!("{response}"); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = PluginsEditCommand::new(); + assert_eq!(cmd.name(), "edit"); + assert_eq!( + cmd.about(), + "Open the file with plugin's source code in your default editor" + ); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/plugins/subcommands/info.rs b/cli/src/commands/plugins/subcommands/info.rs new file mode 100644 index 0000000..570349d --- /dev/null +++ b/cli/src/commands/plugins/subcommands/info.rs @@ -0,0 +1,88 @@ +use clap::{ArgMatches, Command}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + plugins::{InfoRequest, PluginsOperations}, +}; +use miette::IntoDiagnostic; +use plugins::{CommandType, ScopeType}; + +use crate::{ + commands::MevaCommand, + extensions::{WithCommandPlugin, WithScope}, +}; + +pub struct PluginsInfoCommand; + +impl PluginsInfoCommand { + #[allow(dead_code)] + pub fn new() -> Self { + Self + } +} + +impl MevaCommand for PluginsInfoCommand { + fn name(&self) -> &'static str { + "info" + } + + fn about(&self) -> &'static str { + "Display detailed information about a registered plugin" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .with_command_and_plugin_arg( + "The command associated with the plugin", + "The name of the plugin to show details for", + ) + .with_scope_arg("Scope of the plugin") + } + + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + let command_str = matches.get_one::(Command::ARG_COMMAND).unwrap(); + let default_scope = ScopeType::Local.to_string(); + let scope_str = matches + .get_one::(Command::ARG_SCOPE) + .unwrap_or(&default_scope); + + let request = InfoRequest { + name: matches + .get_one::(Command::ARG_PLUGIN) + .unwrap() + .clone(), + command: command_str.parse::().unwrap(), + scope: scope_str.parse::().unwrap(), + }; + + let handler = container.plugins_handler().into_diagnostic()?; + + let response = handler.info(request).into_diagnostic()?; + + println!("{response}"); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = PluginsInfoCommand::new(); + assert_eq!(cmd.name(), "info"); + assert_eq!( + cmd.about(), + "Display detailed information about a registered plugin" + ); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/plugins/subcommands/list.rs b/cli/src/commands/plugins/subcommands/list.rs new file mode 100644 index 0000000..9dcbdf2 --- /dev/null +++ b/cli/src/commands/plugins/subcommands/list.rs @@ -0,0 +1,123 @@ +use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + plugins::{ListRequest, PluginsOperations}, +}; +use miette::IntoDiagnostic; +use plugins::{CommandType, EventType, ScopeType}; + +use crate::{commands::MevaCommand, extensions::WithScope}; + +pub struct PluginsListCommand; + +impl PluginsListCommand { + #[allow(dead_code)] + pub fn new() -> Self { + Self + } + + const ARG_COMMAND: &'static str = "command"; + + const ARG_EVENT: &'static str = "event"; + + const ARG_DISABLED: &'static str = "disabled"; + + const ARG_ENABLED: &'static str = "enabled"; +} + +impl MevaCommand for PluginsListCommand { + fn name(&self) -> &'static str { + "list" + } + + fn about(&self) -> &'static str { + "List registered plugins with optional filters" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_COMMAND) + .index(1) + .required(true) + .value_name("COMMAND") + .help("Filter plugins by associated command"), + ) + .arg( + Arg::new(Self::ARG_EVENT) + .index(2) + .required(true) + .value_name("EVENT") + .help("Filter plugins by event"), + ) + .with_scope_arg("Scope of the plugin") + .arg( + Arg::new(Self::ARG_ENABLED) + .short('E') + .long(Self::ARG_ENABLED) + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_DISABLED) + .help("Include enabled plugins in the output"), + ) + .arg( + Arg::new(Self::ARG_DISABLED) + .short('D') + .long(Self::ARG_DISABLED) + .action(ArgAction::SetTrue) + .conflicts_with(Self::ARG_ENABLED) + .help("Include disabled plugins in the output"), + ) + .group( + ArgGroup::new("filter") + .args([Self::ARG_ENABLED, Self::ARG_DISABLED]) + .multiple(true), + ) + } + + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + let command_str = matches.get_one::(Self::ARG_COMMAND).unwrap(); + let event_str = matches.get_one::(Self::ARG_EVENT).unwrap(); + let default_scope = ScopeType::Local.to_string(); + let scope_str = matches + .get_one::(Command::ARG_SCOPE) + .unwrap_or(&default_scope); + + let include_disabled = matches.get_flag(Self::ARG_DISABLED); + let include_enabled = matches.get_flag(Self::ARG_ENABLED) || !include_disabled; + + let request = ListRequest { + command: command_str.parse::().unwrap(), + event: event_str.parse::().unwrap(), + scope: scope_str.parse::().unwrap(), + include_disabled, + include_enabled, + }; + + let handler = container.plugins_handler().into_diagnostic()?; + let response = handler.list(request).into_diagnostic()?; + + println!("{response}"); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = PluginsListCommand::new(); + assert_eq!(cmd.name(), "list"); + assert_eq!(cmd.about(), "List registered plugins with optional filters"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/plugins/subcommands/register.rs b/cli/src/commands/plugins/subcommands/register.rs new file mode 100644 index 0000000..e2cc1f4 --- /dev/null +++ b/cli/src/commands/plugins/subcommands/register.rs @@ -0,0 +1,187 @@ +use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint, builder::PossibleValuesParser}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + plugins::{PluginsOperations, RegisterRequest}, +}; +use miette::IntoDiagnostic; +use plugins::{CommandType, EventType, ScopeType}; +use std::path::PathBuf; +use strum::VariantNames; + +use crate::{commands::MevaCommand, extensions::WithFile}; + +pub struct PluginsRegisterCommand; + +impl PluginsRegisterCommand { + #[allow(dead_code)] + pub fn new() -> Self { + Self + } + + const ARG_PATH: &'static str = "path"; + + const ARG_SCOPE: &'static str = "scope"; + + const ARG_NAME: &'static str = "name"; + + const ARG_DESCRIPTION: &'static str = "description"; + + const ARG_COMMAND: &'static str = "command"; + + const ARG_EVENT: &'static str = "event"; + + const ARG_ORDER: &'static str = "order"; + + const ARG_DISABLED: &'static str = "disabled"; + + const ARG_INTERPRETER: &'static str = "interpreter"; +} + +impl MevaCommand for PluginsRegisterCommand { + fn name(&self) -> &'static str { + "register" + } + + fn about(&self) -> &'static str { + "Register a new plugin" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .arg( + Arg::new(Self::ARG_PATH) + .value_name("PATH") + .index(1) + .required(true) + .value_parser(clap::value_parser!(PathBuf)) + .value_hint(ValueHint::FilePath) + .help("Path to the script to register"), + ) + .with_file_arg("Relative path to the file") + .arg( + Arg::new(Self::ARG_SCOPE) + .short('s') + .long(Self::ARG_SCOPE) + .value_parser(PossibleValuesParser::new(ScopeType::VARIANTS)) + .help("Scope of the plugin"), + ) + .arg( + Arg::new(Self::ARG_NAME) + .short('n') + .long(Self::ARG_NAME) + .value_name("NAME") + .required(true) + .help("Plugin name"), + ) + .arg( + Arg::new(Self::ARG_DESCRIPTION) + .short('d') + .long(Self::ARG_DESCRIPTION) + .value_name("DESCRIPTION") + .help("Plugin description"), + ) + .arg( + Arg::new(Self::ARG_COMMAND) + .short('c') + .long(Self::ARG_COMMAND) + .value_name("COMMAND") + .required(true) + .value_parser(PossibleValuesParser::new(CommandType::VARIANTS)) + .help("Command type (kebab-case)"), + ) + .arg( + Arg::new(Self::ARG_EVENT) + .short('e') + .long(Self::ARG_EVENT) + .value_name("EVENT") + .required(true) + .value_parser(PossibleValuesParser::new(EventType::VARIANTS)) + .help("Event type (kebab-case)"), + ) + .arg( + Arg::new(Self::ARG_ORDER) + .short('o') + .long(Self::ARG_ORDER) + .value_name("ORDER") + .required(true) + .value_parser(clap::value_parser!(u32)) + .help("Execution order (integer)"), + ) + .arg( + Arg::new(Self::ARG_DISABLED) + .short('D') + .long(Self::ARG_DISABLED) + .action(ArgAction::SetTrue) + .help("Disable plugin"), + ) + .arg( + Arg::new(Self::ARG_INTERPRETER) + .short('i') + .long(Self::ARG_INTERPRETER) + .value_name("INTERPRETER") + .required(false) + .help("Interpreter used use to run the script (e.g. 'python3')"), + ) + } + + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + let command_str = matches.get_one::(Self::ARG_COMMAND).unwrap(); + let event_str = matches.get_one::(Self::ARG_EVENT).unwrap(); + let default_scope = ScopeType::Local.to_string(); + let scope_str = matches + .get_one::(Self::ARG_SCOPE) + .unwrap_or(&default_scope); + + let request = RegisterRequest { + path: matches.get_one::(Self::ARG_PATH).unwrap().clone(), + scope: scope_str.parse::().unwrap(), + name: matches.get_one::(Self::ARG_NAME).unwrap().clone(), + description: matches.get_one::(Self::ARG_DESCRIPTION).cloned(), + file: matches + .get_one::(Command::ARG_FILE) + .unwrap() + .clone(), + command: command_str.parse::().unwrap(), + event: event_str.parse::().unwrap(), + order: *matches.get_one::(Self::ARG_ORDER).unwrap(), + enabled: !matches.get_flag(Self::ARG_DISABLED), + interpreter: matches.get_one::(Self::ARG_INTERPRETER).cloned(), + }; + + let handler = container.plugins_handler().into_diagnostic()?; + + let response = handler.register(request).into_diagnostic()?; + + println!("Plugin registered successfully!"); + println!( + "Source code copied to: {}", + response.plugin_source_file.display() + ); + println!( + "Metadata saved at: {}", + response.plugins_metadata_file.display() + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = PluginsRegisterCommand::new(); + assert_eq!(cmd.name(), "register"); + assert_eq!(cmd.about(), "Register a new plugin"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/commands/plugins/subcommands/unregister.rs b/cli/src/commands/plugins/subcommands/unregister.rs new file mode 100644 index 0000000..236742d --- /dev/null +++ b/cli/src/commands/plugins/subcommands/unregister.rs @@ -0,0 +1,93 @@ +use clap::{ArgMatches, Command}; +use engine::{ + EngineContainer, + engine_container::MevaContainer, + plugins::{PluginsOperations, UnregisterRequest}, +}; +use miette::IntoDiagnostic; +use plugins::{CommandType, ScopeType}; + +use crate::{ + commands::MevaCommand, + extensions::{WithCommandPlugin, WithScope}, +}; + +pub struct PluginsUnregisterCommand; + +impl PluginsUnregisterCommand { + #[allow(dead_code)] + pub fn new() -> Self { + Self + } +} + +impl MevaCommand for PluginsUnregisterCommand { + fn name(&self) -> &'static str { + "unregister" + } + + fn about(&self) -> &'static str { + "Unregister a previously registered plugin" + } + + fn version(&self) -> &'static str { + "1.0.0" + } + + fn build_command(&self) -> Command { + self.build_base_command() + .with_command_and_plugin_arg( + "The command associated with the plugin", + "The plugin name to unregister", + ) + .with_scope_arg("Scope of the plugin") + } + + fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> miette::Result<()> { + let command_str = matches.get_one::(Command::ARG_COMMAND).unwrap(); + let default_scope = ScopeType::Local.to_string(); + let scope_str = matches + .get_one::(Command::ARG_SCOPE) + .unwrap_or(&default_scope); + + let request = UnregisterRequest { + command: command_str.parse::().unwrap().clone(), + name: matches + .get_one::(Command::ARG_PLUGIN) + .unwrap() + .clone(), + scope: scope_str.parse::().unwrap(), + }; + + let handler = container.plugins_handler().into_diagnostic()?; + + let response = handler.unregister(request).into_diagnostic()?; + + println!("Plugin unregistered successfully!"); + println!( + "Removed entry from configuration file: {}", + response.plugins_metadata_file.display() + ); + println!( + "Deleted source code file: {}", + response.plugin_source_file.display() + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_command_name_about_version() { + let cmd = PluginsUnregisterCommand::new(); + assert_eq!(cmd.name(), "unregister"); + assert_eq!(cmd.about(), "Unregister a previously registered plugin"); + assert_eq!(cmd.version(), "1.0.0"); + } +} diff --git a/cli/src/extensions.rs b/cli/src/extensions.rs index 1d5427f..da95e64 100644 --- a/cli/src/extensions.rs +++ b/cli/src/extensions.rs @@ -1,7 +1,5 @@ pub mod arg_matches; pub mod command; -pub mod path; pub use arg_matches::*; pub use command::*; -pub use path::*; diff --git a/cli/src/extensions/command.rs b/cli/src/extensions/command.rs index 0a8fe8d..eaf038e 100644 --- a/cli/src/extensions/command.rs +++ b/cli/src/extensions/command.rs @@ -1,9 +1,13 @@ +pub mod with_command_and_plugin; pub mod with_file; pub mod with_key; pub mod with_locations; pub mod with_pattern; +pub mod with_scope; +pub use with_command_and_plugin::WithCommandPlugin; pub use with_file::WithFile; pub use with_key::WithKey; pub use with_locations::WithLocations; pub use with_pattern::WithPattern; +pub use with_scope::WithScope; diff --git a/cli/src/extensions/command/with_command_and_plugin.rs b/cli/src/extensions/command/with_command_and_plugin.rs new file mode 100644 index 0000000..f4e0327 --- /dev/null +++ b/cli/src/extensions/command/with_command_and_plugin.rs @@ -0,0 +1,61 @@ +use clap::{Arg, Command, builder::PossibleValuesParser}; +use plugins::CommandType; +use strum::VariantNames; + +/// Trait to add required `command` and `plugin` arguments to a Clap command. +/// +/// This allows CLI commands to explicitly specify which command type and plugin +/// they want to operate on. +pub trait WithCommandPlugin { + /// Constant name of the CLI argument for the command. + const ARG_COMMAND: &'static str; + + /// Constant name of the CLI argument for the plugin. + const ARG_PLUGIN: &'static str; + + /// Extends a `Command` by adding the `command` and `plugin` positional arguments. + /// + /// # Arguments + /// + /// * `self` – The command being extended. + /// * `command_help` – Help message describing the purpose of the `command` argument. + /// * `plugin_help` – Help message describing the purpose of the `plugin` argument. + /// + /// # Returns + /// + /// The original [`Command`] with both `command` and `plugin` arguments appended. + fn with_command_and_plugin_arg( + self, + command_help: &'static str, + plugin_help: &'static str, + ) -> Self; +} + +impl WithCommandPlugin for Command { + const ARG_COMMAND: &'static str = "command"; + + const ARG_PLUGIN: &'static str = "plugin"; + + fn with_command_and_plugin_arg( + self, + command_help: &'static str, + plugin_help: &'static str, + ) -> Self { + self.arg( + Arg::new(Self::ARG_COMMAND) + .value_name("COMMAND") + .index(1) + .required(true) + // Restricts values to the variants of `CommandType` + .value_parser(PossibleValuesParser::new(CommandType::VARIANTS)) + .help(command_help), + ) + .arg( + Arg::new(Self::ARG_PLUGIN) + .value_name("PLUGIN") + .index(2) + .required(true) + .help(plugin_help), + ) + } +} diff --git a/cli/src/extensions/command/with_file.rs b/cli/src/extensions/command/with_file.rs index 9d7bb01..72bc96f 100644 --- a/cli/src/extensions/command/with_file.rs +++ b/cli/src/extensions/command/with_file.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use clap::{Arg, Command}; +use clap::{Arg, Command, ValueHint}; /// Trait to add an optional `file` argument to a Clap command. pub trait WithFile { @@ -29,8 +29,9 @@ impl WithFile for Command { .short('f') .long(Self::ARG_FILE) .value_name("FILE") - .help(file_help) - .value_parser(clap::value_parser!(PathBuf)), + .value_parser(clap::value_parser!(PathBuf)) + .value_hint(ValueHint::FilePath) + .help(file_help), ) } } diff --git a/cli/src/extensions/command/with_locations.rs b/cli/src/extensions/command/with_locations.rs index e58e1e9..1b479b6 100644 --- a/cli/src/extensions/command/with_locations.rs +++ b/cli/src/extensions/command/with_locations.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use clap::{Arg, ArgAction, ArgGroup, Command}; +use clap::{Arg, ArgAction, ArgGroup, Command, ValueHint}; /// Trait for adding CLI flags or options to select configuration source locations. /// @@ -74,6 +74,7 @@ impl WithLocations for Command { .long(Self::ARG_FILE) .value_name("FILE") .help(file_help) + .value_hint(ValueHint::FilePath) .value_parser(clap::value_parser!(PathBuf)) // Cannot be used with global or local .conflicts_with_all([Self::ARG_GLOBAL, Self::ARG_LOCAL]), diff --git a/cli/src/extensions/command/with_scope.rs b/cli/src/extensions/command/with_scope.rs new file mode 100644 index 0000000..450fea3 --- /dev/null +++ b/cli/src/extensions/command/with_scope.rs @@ -0,0 +1,86 @@ +use clap::{Arg, Command, builder::PossibleValuesParser}; +use plugins::ScopeType; +use strum::VariantNames; + +/// Trait to add an optional `scope` argument to a Clap command. +/// +/// This argument allows users to specify the scope in which the command should operate +/// (e.g., local, global, etc.), using the variants defined in [`ScopeType`]. +pub trait WithScope { + /// Constant name of the CLI argument for the scope. + const ARG_SCOPE: &'static str; + + /// Extends a `Command` by adding a `scope` argument with the given help text. + /// + /// # Arguments + /// + /// * `self` – The command being extended. + /// * `scope_help` – Help message describing the purpose of the `scope` argument. + /// + /// # Returns + /// + /// The original [`Command`] with the `scope` argument appended. + fn with_scope_arg(self, scope_help: &'static str) -> Self; +} + +impl WithScope for Command { + const ARG_SCOPE: &'static str = "scope"; + + fn with_scope_arg(self, scope_help: &'static str) -> Self { + self.arg( + Arg::new(Self::ARG_SCOPE) + .short('s') + .long(Self::ARG_SCOPE) + // Uses the variants of `ScopeType` as allowed values + .value_parser(PossibleValuesParser::new(ScopeType::VARIANTS)) + .help(scope_help), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + use rstest::{fixture, rstest}; + + #[fixture] + fn cmd() -> Command { + Command::new("cmd").with_scope_arg("scope help") + } + + #[rstest] + fn test_short_scope_local(cmd: Command) { + let matches = cmd + .try_get_matches_from(vec!["cmd", "-s", "local"]) + .unwrap(); + assert_eq!( + matches.get_one::("scope"), + Some(&"local".to_string()) + ); + } + + #[rstest] + fn test_long_scope_global(cmd: Command) { + let matches = cmd + .try_get_matches_from(vec!["cmd", "--scope", "global"]) + .unwrap(); + assert_eq!( + matches.get_one::("scope"), + Some(&"global".to_string()) + ); + } + + #[rstest] + fn test_invalid_scope_value(cmd: Command) { + let result = cmd.try_get_matches_from(vec!["cmd", "--scope", "invalid"]); + assert!(result.is_err_and(|e| e.kind() == ErrorKind::InvalidValue)); + } + + #[rstest] + fn test_no_scope_provided(cmd: Command) { + let matches = cmd.try_get_matches_from(vec!["cmd"]).unwrap(); + assert!(matches.get_one::("scope").is_none()); + } +} diff --git a/cli/src/extensions/path.rs b/cli/src/extensions/path.rs deleted file mode 100644 index 50a14bb..0000000 --- a/cli/src/extensions/path.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod open_in_editor; - -pub use open_in_editor::OpenInEditor; diff --git a/cli/src/main.rs b/cli/src/main.rs index d869b3b..7a71ec5 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -3,17 +3,20 @@ mod extensions; mod meva_cli; use crate::meva_cli::MevaCli; -use commands::{ConfigCommand, IgnoreCommand, InitCommand}; +use commands::{ConfigCommand, IgnoreCommand, InitCommand, PluginsCommand}; +use engine::engine_container::MevaContainer; use miette::Result; fn main() -> Result<()> { miette::set_panic_hook(); - let mut cli = MevaCli::new(); + let container = MevaContainer {}; + let mut cli = MevaCli::new(container); cli.add_command(Box::new(InitCommand::new())); cli.add_command(Box::new(ConfigCommand::new())); cli.add_command(Box::new(IgnoreCommand::new())); + cli.add_command(Box::new(PluginsCommand::new())); cli.run() } diff --git a/cli/src/meva_cli.rs b/cli/src/meva_cli.rs index 7fecb08..595fd5b 100644 --- a/cli/src/meva_cli.rs +++ b/cli/src/meva_cli.rs @@ -1,4 +1,5 @@ use clap::{Command, error::ErrorKind}; +use engine::EngineContainer; use miette::{IntoDiagnostic, Result, WrapErr, miette}; use crate::commands::MevaCommand; @@ -7,16 +8,23 @@ use crate::commands::MevaCommand; /// /// This struct manages registration of commands, building the CLI parser, /// and dispatching command execution based on user input. -pub struct MevaCli { +pub struct MevaCli +where + T: EngineContainer, +{ /// Registered commands available in the CLI. - commands: Vec>, + commands: Vec>>, + + /// Dependency injection container. + container: T, } -impl MevaCli { +impl MevaCli { /// Creates a new instance of the Meva CLI application with no commands registered. - pub fn new() -> Self { + pub fn new(container: T) -> Self { Self { commands: Vec::new(), + container, } } @@ -26,7 +34,7 @@ impl MevaCli { /// /// # Arguments /// * `command`: The boxed command to register. - pub fn add_command(&mut self, command: Box) { + pub fn add_command(&mut self, command: Box>) { self.commands.push(command); } @@ -73,7 +81,7 @@ impl MevaCli { for cmd in &self.commands { if cmd.name() == name { return cmd - .execute(sub_matches) + .execute(sub_matches, &self.container) .wrap_err_with(|| format!("Error running `{name}` command")); } } diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 7ae4517..7729002 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -6,13 +6,17 @@ authors.workspace = true [dependencies] shared = { path = "../shared" } +plugins = { path = "../plugins" } tempfile.workspace = true thiserror.workspace = true serde.workspace = true +serde_json.workspace = true toml = "0.9.2" toml_edit = "0.23.2" dirs.workspace = true globset.workspace = true +chrono.workspace = true + [dev-dependencies] rstest.workspace = true diff --git a/engine/src/config.rs b/engine/src/config.rs index 8d796e0..672316b 100644 --- a/engine/src/config.rs +++ b/engine/src/config.rs @@ -1,9 +1,13 @@ pub mod config_document; +pub mod config_document_operations; pub mod config_loader; pub mod config_location; -pub mod config_operations; +mod handler; +mod operations; pub use config_document::ConfigDocument; +pub use config_document_operations::ConfigDocumentOperations; pub use config_loader::ConfigLoader; pub use config_location::ConfigLocation; -pub use config_operations::ConfigOperations; +pub use handler::ConfigHandler; +pub use operations::*; diff --git a/engine/src/config/config_document.rs b/engine/src/config/config_document.rs index 3e898ad..245ba98 100644 --- a/engine/src/config/config_document.rs +++ b/engine/src/config/config_document.rs @@ -6,7 +6,7 @@ use std::{ use toml_edit::{Datetime, DocumentMut, Item, Table, Value, value}; use crate::{ - ConfigOperations, + config::ConfigDocumentOperations, errors::{ConfigError, EngineResult}, }; @@ -59,15 +59,7 @@ impl ConfigDocument { if !path.is_file() { return Err(ConfigError::InvalidConfigFile { path: path.display().to_string(), - reason: "path is not a regular file".to_string(), - } - .into()); - } - - if !path.exists() { - return Err(ConfigError::InvalidConfigFile { - path: path.display().to_string(), - reason: "file does not exist".to_string(), + reason: "path is not a regular file or it does not exist".to_string(), } .into()); } @@ -157,7 +149,7 @@ impl ConfigDocument { } } -impl ConfigOperations for ConfigDocument { +impl ConfigDocumentOperations for ConfigDocument { fn get(&self, key_path: &str, default: Option<&String>) -> EngineResult { let keys = Self::split_key_path(key_path); let mut current_item = self.doc.as_item(); @@ -213,12 +205,10 @@ impl ConfigOperations for ConfigDocument { let last = keys[keys.len() - 1]; current_table[last] = item; - println!("{key_path} = {val}"); - Ok(()) } - fn unset(&mut self, key_path: &str) -> EngineResult<()> { + fn unset(&mut self, key_path: &str) -> EngineResult { let keys = Self::split_key_path(key_path); if keys.is_empty() { @@ -242,17 +232,47 @@ impl ConfigOperations for ConfigDocument { } let last_key = keys[keys.len() - 1]; + if let Some(table) = current.as_table_like_mut() { - table.remove(last_key); + if let Some(removed_item) = table.remove(last_key) { + let value_str = match removed_item { + Item::Value(v) => { + Self::value_to_string(&v).ok_or_else(|| ConfigError::InvalidValueType { + type_name: v.type_name().to_string(), + })? + } + Item::Table(t) => t.to_string(), + other => { + let s = other.to_string(); + if s.is_empty() { + return Err(ConfigError::InvalidValueType { + type_name: other.type_name().to_string(), + } + .into()); + } else { + s + } + } + }; + + return Ok(value_str); + } else { + return Err(ConfigError::KeyNotFound { + key: key_path.to_string(), + } + .into()); + } } - Ok(()) + Err(ConfigError::KeyNotFound { + key: key_path.to_string(), + } + .into()) } fn list(&self) -> Vec<(String, String)> { let mut result = Vec::new(); Self::collect_key_values("", self.doc.as_item(), &mut result); - result } } diff --git a/engine/src/config/config_operations.rs b/engine/src/config/config_document_operations.rs similarity index 89% rename from engine/src/config/config_operations.rs rename to engine/src/config/config_document_operations.rs index 08e4694..6fe0566 100644 --- a/engine/src/config/config_operations.rs +++ b/engine/src/config/config_document_operations.rs @@ -1,7 +1,7 @@ use crate::errors::EngineResult; /// Defines operations for accessing and modifying configuration values -pub trait ConfigOperations { +pub trait ConfigDocumentOperations { /// Retrieves the value at the specified key path. /// /// # Arguments @@ -34,8 +34,8 @@ pub trait ConfigOperations { /// /// # Returns /// - /// An `EngineResult<()>` indicating success or containing an error. - fn unset(&mut self, key_path: &str) -> EngineResult<()>; + /// An `EngineResult` indicating success or containing an error. + fn unset(&mut self, key_path: &str) -> EngineResult; /// Lists all configuration entries as key/value pairs. /// diff --git a/engine/src/config/config_loader.rs b/engine/src/config/config_loader.rs index 5b42217..6aa14b6 100644 --- a/engine/src/config/config_loader.rs +++ b/engine/src/config/config_loader.rs @@ -1,5 +1,10 @@ +use std::{io::Write, path::Path, str::FromStr}; + +use shared::fs::create_file_with_dirs; + use crate::{ - ConfigDocument, ConfigLocation, ConfigOperations, + ConfigLocation, + config::{ConfigDocument, ConfigDocumentOperations}, errors::{ConfigError, EngineError, EngineResult}, }; @@ -10,14 +15,16 @@ pub struct ConfigLoader { locations: Vec, } -impl ConfigLoader { +impl Default for ConfigLoader { /// Creates a loader with the default search order. - pub fn new_default() -> Self { + fn default() -> Self { Self { locations: vec![ConfigLocation::Local, ConfigLocation::Global], } } +} +impl ConfigLoader { /// Creates a loader with a custom list of locations. /// /// # Arguments @@ -39,24 +46,58 @@ impl ConfigLoader { /// /// The found `String` value or an error if not found or on I/O issues. pub fn get(&self, key_path: &str, default: Option<&String>) -> EngineResult { + let mut last_key_not_found = None; + for loc in &self.locations { match self.try_load_from(loc, key_path, default) { Ok(val) => { return Ok(val); } Err(err) => match err { - // If key not found, continue to next location - EngineError::Config(ConfigError::KeyNotFound { .. }) => {} + EngineError::Config(ConfigError::KeyNotFound { .. }) => { + last_key_not_found = Some(err); + } + EngineError::Config(ConfigError::ConfigNotFound { .. }) => { + continue; + } other => { return Err(other); } }, } } - Err(ConfigError::KeyNotFound { - key: key_path.to_string(), - } - .into()) + + Err(last_key_not_found.unwrap_or_else(|| { + EngineError::Config(ConfigError::KeyNotFound { + key: key_path.to_string(), + }) + })) + } + + /// Retrieve a configuration value and parse it into a specific type. + /// + /// # Arguments + /// + /// * `key` - Dot-separated key string to locate the configuration value. + /// * `default` - Default value returned if key is missing. + /// + /// # Returns + /// + /// Parsed value of type `T`, or an error if parsing fails. + pub fn get_parsed(&self, key: &str, default: T) -> EngineResult + where + T: FromStr + ToString, + ::Err: std::fmt::Display, + { + let default_string = default.to_string(); + + let raw_value = self.get(key, Some(&default_string))?; + + raw_value.parse::().map_err(|e| { + EngineError::Config(ConfigError::InvalidValueType { + type_name: format!("{}: {}", std::any::type_name::(), e), + }) + }) } /// Attempt to load a configuration value from a single location. @@ -72,13 +113,83 @@ impl ConfigLoader { /// The found value or an error wrapped in `EngineResult`. fn try_load_from( &self, - loc: &ConfigLocation, + location: &ConfigLocation, key_path: &str, default: Option<&String>, ) -> EngineResult { - let path = loc.get_default_path()?; - let doc = ConfigDocument::load(&path)?; + match location.get_default_path() { + Ok(path) => { + let doc = ConfigDocument::load(&path)?; + doc.get(key_path, default) + } + Err(err) => Err(err), + } + } + + /// Create a new local configuration file with default settings. + /// + /// # Arguments + /// + /// * `path` - Path where the local config file should be created. + pub fn create_local_config(&self, path: &Path) -> EngineResult<()> { + let (mut config_file, created) = create_file_with_dirs(path)?; + + if created { + config_file.write_all(self.get_default_local_config().as_bytes())?; + } + + Ok(()) + } + + /// Create a new global configuration file with default settings. + /// If the file already exists, it will not overwrite it. + pub fn create_global_config(&self) -> EngineResult<()> { + let path = ConfigLocation::Global.get_default_path()?; + let (mut config_file, created) = create_file_with_dirs(path)?; + + if created { + config_file.write_all(self.get_default_global_config().as_bytes())?; + } + + Ok(()) + } + + /// Returns the default content for a local configuration file. + pub fn get_default_local_config(&self) -> &str { + let default_config = concat!( + "# Meva Configuration File\n", + "# Edit this file to customize your local settings\n", + "\n", + "[plugins]\n", + "enabled = false\n", + "collect_logs = false\n", + "timeout_ms = 500\n", + "\n" + ); + + default_config + } + + /// Returns the default content for a global configuration file. + pub fn get_default_global_config(&self) -> &str { + let default_config = concat!( + "# Meva Configuration File\n", + "# Edit this file to customize your global settings\n", + "\n", + "# [user]\n", + "# name = \"Your Name\"\n", + "# email = \"your.email@example.com\"\n", + "\n", + "# [editor]\n", + "# default = \"vim\"\n", + "\n", + "[plugins]\n", + "enabled = false\n", + "collect_logs = false\n", + "timeout_ms = 500\n", + "\n" + ); - doc.get(key_path, default) + default_config } } diff --git a/engine/src/config/config_location.rs b/engine/src/config/config_location.rs index ea0a747..b2267a7 100644 --- a/engine/src/config/config_location.rs +++ b/engine/src/config/config_location.rs @@ -1,10 +1,11 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use shared::UpwardSearch; use crate::{ - MevaRepository, RepositoryLayout, + RepositoryLayout, errors::{ConfigError, EngineResult}, + repositories::meva_repository_layout::MevaRepositoryLayout, }; /// Defines where to load configuration from: global, local repository, or a specific file. @@ -21,16 +22,51 @@ pub enum ConfigLocation { } impl ConfigLocation { + /// Determines the `ConfigLocation` variant based on a given path. + /// + /// # Arguments + /// + /// * `path` - The path to check against known global and local config locations. + /// + /// # Returns + /// + /// Returns `Global` if the path matches the OS config directory, `Local` if it matches + /// a repository-specific config, or `File` for any other path. + pub fn from_path(path: &Path) -> EngineResult { + // TODO: MevaRepositoryLayout should not be directly used here + let layout = MevaRepositoryLayout::from_env()?; + + let path = path.canonicalize()?; + let expected_global = + Self::global_path(layout.repository_dir_name(), layout.config_file_name())?; + if path == expected_global { + return Ok(ConfigLocation::Global); + } + + if let Ok(local_candidate) = + Self::local_path(layout.repository_dir_name(), layout.config_file_name()) + && path == local_candidate + { + return Ok(ConfigLocation::Local); + } + + Ok(ConfigLocation::File(path.to_path_buf())) + } + /// Resolve the full path to the configuration file for this location variant. /// /// # Returns /// /// A `PathBuf` pointing to the config file, or an error if resolution fails. pub fn get_default_path(&self) -> EngineResult { + // TODO: MevaRepositoryLayout should not be directly used here + let layout = MevaRepositoryLayout::from_env()?; match self { - ConfigLocation::Global => Self::global_path(MevaRepository::CONFIG_FILE), + ConfigLocation::Global => { + Self::global_path(layout.repository_dir_name(), layout.config_file_name()) + } ConfigLocation::Local => { - Self::local_path(MevaRepository::REPOSITORY_DIR, MevaRepository::CONFIG_FILE) + Self::local_path(layout.repository_dir_name(), layout.config_file_name()) } ConfigLocation::File(path) => Ok(path.to_path_buf()), } @@ -40,15 +76,16 @@ impl ConfigLocation { /// /// # Arguments /// + /// * `repository_dir` – Marker directory name indicating the repo root. /// * `config_file` – The filename for the config. /// /// # Errors /// /// * `HomeDirNotFound` if the OS config directory is unavailable. - pub fn global_path(config_file: &str) -> EngineResult { + pub fn global_path(repository_dir: &str, config_file: &str) -> EngineResult { let base = dirs::config_dir().ok_or(ConfigError::HomeDirNotFound)?; // Prefix filename with a dot for hidden file convention - Ok(base.join(format!(".{config_file}"))) + Ok(base.join(repository_dir).join(format!(".{config_file}"))) } /// Locate the repository root by searching upward, then append the config file. diff --git a/engine/src/config/handler.rs b/engine/src/config/handler.rs new file mode 100644 index 0000000..3af5a48 --- /dev/null +++ b/engine/src/config/handler.rs @@ -0,0 +1,270 @@ +use plugins::{ + CommandType, InvocationPostPayload, InvocationPrePayload, MevaPluginsLayout, PluginError, + models::*, +}; + +use crate::{ + ConfigLocation, + config::{ConfigDocument, ConfigDocumentOperations, operations::*}, + errors::{EngineError, EngineResult}, + plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; + +pub struct ConfigHandler; + +impl ConfigHandler { + pub fn handle_get( + &self, + request: GetRequest, + interceptor: &PluginsInterceptor, + ) -> EngineResult { + interceptor.intercept_with_plugins(CommandType::ConfigGet, None, request, self, |req| { + self.get(req) + }) + } + + pub fn handle_set( + &self, + request: SetRequest, + interceptor: &PluginsInterceptor, + ) -> EngineResult { + interceptor.intercept_with_plugins(CommandType::ConfigSet, None, request, self, |req| { + self.set(req) + }) + } + + pub fn handle_unset( + &self, + request: UnsetRequest, + interceptor: &PluginsInterceptor, + ) -> EngineResult { + interceptor.intercept_with_plugins(CommandType::ConfigUnset, None, request, self, |req| { + self.unset(req) + }) + } + + pub fn handle_list( + &self, + request: ListRequest, + interceptor: &PluginsInterceptor, + ) -> EngineResult { + interceptor.intercept_with_plugins(CommandType::ConfigList, None, request, self, |req| { + self.list(req) + }) + } +} + +impl ConfigOperations for ConfigHandler { + fn get(&self, request: GetRequest) -> EngineResult { + let doc = ConfigDocument::load(request.location.get_default_path()?)?; + let value = doc.get(&request.key, request.default.as_ref())?; + + let response = GetResponse { + key: request.key, + value, + }; + + Ok(response) + } + + fn set(&self, request: SetRequest) -> EngineResult { + let mut doc = ConfigDocument::load(request.location.get_default_path()?)?; + + doc.set(&request.key, &request.value)?; + doc.save()?; + + let response = SetResponse { + key: request.key, + value: request.value, + }; + + Ok(response) + } + + fn unset(&self, request: UnsetRequest) -> EngineResult { + let mut doc = ConfigDocument::load(request.location.get_default_path()?)?; + + let value = doc.unset(&request.key)?; + doc.save()?; + + let response = UnsetResponse { value }; + + Ok(response) + } + + fn list(&self, request: ListRequest) -> EngineResult { + let doc = ConfigDocument::load(request.location.get_default_path()?)?; + let key_values = doc.list(); + + let response = ListResponse { key_values }; + + Ok(response) + } +} + +impl PluginsInvocationMapper for ConfigHandler { + fn request_to_payload(&self, req: &GetRequest) -> EngineResult { + Ok(InvocationPrePayload::ConfigGet(ConfigGetPrePayload { + config_file: req.location.get_default_path()?, + key: req.key.clone(), + default: req.default.clone(), + })) + } + + fn response_to_payload(&self, res: &GetResponse) -> EngineResult { + Ok(InvocationPostPayload::ConfigGet(ConfigGetPostPayload { + key: res.key.clone(), + value: res.value.clone(), + })) + } + + fn input_to_request(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPrePayload::ConfigGet(pre)) = &input.pre_payload { + Ok(GetRequest { + location: ConfigLocation::from_path(&pre.config_file)?, + key: pre.key.clone(), + default: pre.default.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PrePayload { + payload: input.pre_payload.clone(), + })) + } + } + + fn input_to_response(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPostPayload::ConfigGet(post)) = &input.post_payload { + Ok(GetResponse { + key: post.key.clone(), + value: post.value.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PostPayload { + payload: input.post_payload.clone(), + })) + } + } +} + +impl PluginsInvocationMapper for ConfigHandler { + fn request_to_payload(&self, req: &SetRequest) -> EngineResult { + Ok(InvocationPrePayload::ConfigSet(ConfigSetPrePayload { + config_file: req.location.get_default_path()?, + key: req.key.clone(), + value: req.value.clone(), + })) + } + + fn response_to_payload(&self, res: &SetResponse) -> EngineResult { + Ok(InvocationPostPayload::ConfigSet(ConfigSetPostPayload { + key: res.key.clone(), + value: res.value.clone(), + })) + } + + fn input_to_request(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPrePayload::ConfigSet(pre)) = &input.pre_payload { + Ok(SetRequest { + location: ConfigLocation::from_path(&pre.config_file)?, + key: pre.key.clone(), + value: pre.value.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PrePayload { + payload: input.pre_payload.clone(), + })) + } + } + + fn input_to_response(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPostPayload::ConfigGet(post)) = &input.post_payload { + Ok(SetResponse { + key: post.key.clone(), + value: post.value.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PrePayload { + payload: input.pre_payload.clone(), + })) + } + } +} + +impl PluginsInvocationMapper for ConfigHandler { + fn request_to_payload(&self, req: &UnsetRequest) -> EngineResult { + Ok(InvocationPrePayload::ConfigUnset(ConfigUnsetPrePayload { + config_file: req.location.get_default_path()?, + key: req.key.clone(), + })) + } + + fn response_to_payload(&self, res: &UnsetResponse) -> EngineResult { + Ok(InvocationPostPayload::ConfigUnset(ConfigUnsetPostPayload { + value: res.value.clone(), + })) + } + + fn input_to_request(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPrePayload::ConfigUnset(pre)) = &input.pre_payload { + Ok(UnsetRequest { + location: ConfigLocation::from_path(&pre.config_file)?, + key: pre.key.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PrePayload { + payload: input.pre_payload.clone(), + })) + } + } + + fn input_to_response(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPostPayload::ConfigUnset(post)) = &input.post_payload { + Ok(UnsetResponse { + value: post.value.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PostPayload { + payload: input.post_payload.clone(), + })) + } + } +} + +impl PluginsInvocationMapper for ConfigHandler { + fn request_to_payload(&self, req: &ListRequest) -> EngineResult { + Ok(InvocationPrePayload::ConfigList(ConfigListPrePayload { + config_file: req.location.get_default_path()?, + })) + } + + fn response_to_payload(&self, res: &ListResponse) -> EngineResult { + Ok(InvocationPostPayload::ConfigList(ConfigListPostPayload { + key_values: res.key_values.clone(), + })) + } + + fn input_to_request(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPrePayload::ConfigList(pre)) = &input.pre_payload { + Ok(ListRequest { + location: ConfigLocation::from_path(&pre.config_file)?, + }) + } else { + Err(EngineError::Plugins(PluginError::PrePayload { + payload: input.pre_payload.clone(), + })) + } + } + + fn input_to_response(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPostPayload::ConfigList(post)) = &input.post_payload { + Ok(ListResponse { + key_values: post.key_values.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PostPayload { + payload: input.post_payload.clone(), + })) + } + } +} diff --git a/engine/src/config/operations.rs b/engine/src/config/operations.rs new file mode 100644 index 0000000..3d97b2a --- /dev/null +++ b/engine/src/config/operations.rs @@ -0,0 +1,50 @@ +use crate::{ConfigLocation, errors::EngineResult}; + +pub struct GetRequest { + pub location: ConfigLocation, + pub key: String, + pub default: Option, +} + +pub struct GetResponse { + pub key: String, + pub value: String, +} + +pub struct SetRequest { + pub location: ConfigLocation, + pub key: String, + pub value: String, +} + +pub struct SetResponse { + pub key: String, + pub value: String, +} + +pub struct UnsetRequest { + pub location: ConfigLocation, + pub key: String, +} + +pub struct UnsetResponse { + pub value: String, +} + +pub struct ListRequest { + pub location: ConfigLocation, +} + +pub struct ListResponse { + pub key_values: Vec<(String, String)>, +} + +pub trait ConfigOperations { + fn get(&self, request: GetRequest) -> EngineResult; + + fn set(&self, request: SetRequest) -> EngineResult; + + fn unset(&self, request: UnsetRequest) -> EngineResult; + + fn list(&self, request: ListRequest) -> EngineResult; +} diff --git a/engine/src/engine_container.rs b/engine/src/engine_container.rs new file mode 100644 index 0000000..46100c4 --- /dev/null +++ b/engine/src/engine_container.rs @@ -0,0 +1,75 @@ +use plugins::{MevaPluginsLayout, PluginsDiscovery, PluginsEngine}; + +use crate::{ + InitHandler, config::ConfigHandler, errors::EngineResult, plugins::PluginsHandler, + plugins_interceptor::PluginsInterceptor, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; + +/// Dependency injection container for the engine. +pub trait EngineContainer { + /// Creates a plugin interceptor, which executes plugins + /// before and after engine commands. + fn plugins_interceptor( + &self, + ) -> EngineResult>; + + /// Returns the handler responsible for repository initialization. + fn init_handler(&self) -> EngineResult; + + /// Returns the handler responsible for configuration management. + fn config_handler(&self) -> EngineResult; + + /// Returns the handler responsible for plugin operations. + fn plugins_handler(&self) -> EngineResult; +} + +/// Concrete implementation of `EngineContainer` for Meva. +pub struct MevaContainer {} + +impl MevaContainer { + /// Returns the Meva-specific plugin layout. + fn plugins_layout(&self) -> EngineResult { + Ok(MevaPluginsLayout) + } + + /// Returns the Meva-specific repository layout. + fn repository_layout(&self) -> EngineResult { + MevaRepositoryLayout::from_env() + } + + /// Builds a [`PluginsEngine`] instance used by Meva. + fn plugins_engine(&self) -> EngineResult> { + Ok(PluginsEngine { + discovery: PluginsDiscovery { + layout: self.plugins_layout()?, + }, + }) + } +} + +impl EngineContainer for MevaContainer { + fn plugins_interceptor( + &self, + ) -> EngineResult> { + Ok(PluginsInterceptor::new( + self.plugins_engine()?, + self.repository_layout()?, + )) + } + + fn init_handler(&self) -> EngineResult { + Ok(InitHandler) + } + + fn config_handler(&self) -> EngineResult { + Ok(ConfigHandler) + } + + fn plugins_handler(&self) -> EngineResult { + Ok(PluginsHandler { + plugins_repository: Box::new(self.plugins_engine()?), + repository_layout: Box::new(self.repository_layout()?), + }) + } +} diff --git a/engine/src/errors.rs b/engine/src/errors.rs index 9da11ca..a085c79 100644 --- a/engine/src/errors.rs +++ b/engine/src/errors.rs @@ -2,10 +2,12 @@ pub mod config_error; pub mod engine_error; pub mod ignore_error; pub mod init_error; +pub mod repository_error; pub use config_error::ConfigError; pub use ignore_error::IgnoreError; pub use init_error::InitError; +pub use repository_error::RepositoryError; pub use engine_error::EngineError; pub use engine_error::Result as EngineResult; diff --git a/engine/src/errors/config_error.rs b/engine/src/errors/config_error.rs index fb7ca3f..f873fbf 100644 --- a/engine/src/errors/config_error.rs +++ b/engine/src/errors/config_error.rs @@ -45,7 +45,7 @@ pub enum ConfigError { InvalidConfigFile { /// Path provided by the user or inferred by the system. path: String, - /// Reason why the file is invalid (e.g. "not a file", "missing"). + /// Reason why the file is invalid (e.g., "not a file", "missing"). reason: String, }, diff --git a/engine/src/errors/engine_error.rs b/engine/src/errors/engine_error.rs index 46d93a0..6eb97dd 100644 --- a/engine/src/errors/engine_error.rs +++ b/engine/src/errors/engine_error.rs @@ -1,8 +1,9 @@ use std::io; +use plugins::PluginError; use thiserror::Error; -use crate::errors::{ConfigError, IgnoreError, InitError}; +use crate::errors::{ConfigError, IgnoreError, InitError, RepositoryError}; /// A convenient result type alias for engine-related operations. pub type Result = std::result::Result; @@ -30,9 +31,17 @@ pub enum EngineError { #[error(transparent)] Ignore(#[from] IgnoreError), + /// Errors encountered when interacting with the repository layout or structure. + #[error(transparent)] + Repository(#[from] RepositoryError), + + /// Errors originating from plugin execution or management. + #[error(transparent)] + Plugins(#[from] PluginError), + /// A catch-all variant for any unknown or unexpected engine error. /// Accepts a descriptive string message. - #[error("Unknown Engine error: {0}")] + #[error("Unknown engine error: {0}")] Unknown( /// Human-readable message describing the unexpected condition. String, diff --git a/engine/src/errors/repository_error.rs b/engine/src/errors/repository_error.rs new file mode 100644 index 0000000..faee1d0 --- /dev/null +++ b/engine/src/errors/repository_error.rs @@ -0,0 +1,16 @@ +use std::path::PathBuf; + +use thiserror::Error; + +/// Represents common errors that can occur during Meva repository operations. +#[derive(Error, Debug)] +pub enum RepositoryError { + /// Raised when the repository root cannot be found by searching upwards + /// from the given path. + #[error("Repository not found above directory: `{path}`")] + RepositoryNotFound { path: PathBuf }, + + /// Raised when the user's configuration directory cannot be determined. + #[error("User's config directory not found")] + ConfigDirNotFound, +} diff --git a/engine/src/init.rs b/engine/src/init.rs new file mode 100644 index 0000000..15f98ea --- /dev/null +++ b/engine/src/init.rs @@ -0,0 +1,5 @@ +mod handler; +mod operations; + +pub use handler::InitHandler; +pub use operations::*; diff --git a/engine/src/init/handler.rs b/engine/src/init/handler.rs new file mode 100644 index 0000000..2470b2c --- /dev/null +++ b/engine/src/init/handler.rs @@ -0,0 +1,83 @@ +use plugins::{ + CommandType, InitPostPayload, InitPrePayload, InvocationInput, InvocationPostPayload, + InvocationPrePayload, MevaPluginsLayout, PluginError, +}; + +use crate::{ + ConfigLoader, MevaRepository, + errors::{EngineError, EngineResult}, + init::operations::{InitOperations, Request, Response}, + plugins_interceptor::{PluginsInterceptor, PluginsInvocationMapper}, + repositories::meva_repository_layout::MevaRepositoryLayout, +}; + +pub struct InitHandler; + +impl InitHandler { + pub fn handle_init( + &self, + request: Request, + interceptor: &PluginsInterceptor, + ) -> EngineResult { + interceptor.intercept_with_plugins( + CommandType::Init, + Some(request.working_dir.clone()), + request, + self, + |req| self.init(req), + ) + } +} + +impl InitOperations for InitHandler { + fn init(&self, request: Request) -> EngineResult { + let layout = Box::new(MevaRepositoryLayout::new(request.working_dir)?); + let config_loader = ConfigLoader::default(); + let repository = MevaRepository::new(layout, config_loader); + + let repository_dir = repository.init(&request.initial_branch)?; + + let response = Response { repository_dir }; + + Ok(response) + } +} + +impl PluginsInvocationMapper for InitHandler { + fn request_to_payload(&self, req: &Request) -> EngineResult { + Ok(InvocationPrePayload::Init(InitPrePayload { + initial_branch: req.initial_branch.clone(), + })) + } + + fn response_to_payload(&self, res: &Response) -> EngineResult { + Ok(InvocationPostPayload::Init(InitPostPayload { + repository_dir: res.repository_dir.clone(), + })) + } + + fn input_to_request(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPrePayload::Init(pre)) = &input.pre_payload { + Ok(Request { + working_dir: input.context.working_dir.clone(), + initial_branch: pre.initial_branch.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PrePayload { + payload: input.pre_payload.clone(), + })) + } + } + + fn input_to_response(&self, input: &InvocationInput) -> EngineResult { + if let Some(InvocationPostPayload::Init(post)) = &input.post_payload { + Ok(Response { + repository_dir: post.repository_dir.clone(), + }) + } else { + Err(EngineError::Plugins(PluginError::PostPayload { + payload: input.post_payload.clone(), + })) + } + } +} diff --git a/engine/src/init/operations.rs b/engine/src/init/operations.rs new file mode 100644 index 0000000..3f8bafe --- /dev/null +++ b/engine/src/init/operations.rs @@ -0,0 +1,16 @@ +use std::path::PathBuf; + +use crate::errors::EngineResult; + +pub struct Request { + pub working_dir: PathBuf, + pub initial_branch: String, +} + +pub struct Response { + pub repository_dir: PathBuf, +} + +pub trait InitOperations { + fn init(&self, request: Request) -> EngineResult; +} diff --git a/engine/src/lib.rs b/engine/src/lib.rs index fab8f7b..000e266 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,10 +1,16 @@ pub mod config; +pub mod engine_container; pub mod errors; pub mod ignore; +pub mod init; +pub mod plugins; +pub mod plugins_interceptor; pub mod repositories; use errors::{EngineError, EngineResult, InitError}; -pub use config::{ConfigDocument, ConfigLoader, ConfigLocation, ConfigOperations}; +pub use config::{ConfigDocument, ConfigHandler, ConfigLoader, ConfigLocation}; +pub use engine_container::EngineContainer; pub use ignore::{IgnoreOperations, IgnoreResult, IgnoreService}; +pub use init::InitHandler; pub use repositories::{MevaRepository, RepositoryLayout}; diff --git a/engine/src/plugins.rs b/engine/src/plugins.rs new file mode 100644 index 0000000..cf62a98 --- /dev/null +++ b/engine/src/plugins.rs @@ -0,0 +1,5 @@ +mod handler; +mod operations; + +pub use handler::PluginsHandler; +pub use operations::*; diff --git a/engine/src/plugins/handler.rs b/engine/src/plugins/handler.rs new file mode 100644 index 0000000..a745be9 --- /dev/null +++ b/engine/src/plugins/handler.rs @@ -0,0 +1,146 @@ +use std::{env, path::PathBuf}; + +use plugins::{PluginConfiguration, PluginsRepository, ScopeType}; +use shared::UpwardSearch; + +use crate::{ + RepositoryLayout, + errors::{EngineResult, RepositoryError}, + plugins::{ + EditRequest, EditResponse, InfoRequest, InfoResponse, ListRequest, ListResponse, + PluginsOperations, RegisterRequest, RegisterResponse, UnregisterRequest, + UnregisterResponse, + }, +}; + +pub struct PluginsHandler { + pub plugins_repository: Box, + pub repository_layout: Box, +} + +impl PluginsHandler { + pub fn new( + plugins_repository: Box, + repository_layout: Box, + ) -> Self { + Self { + plugins_repository, + repository_layout, + } + } + + fn get_plugins_dir(&self, scope: &ScopeType) -> EngineResult { + match scope { + ScopeType::Local => { + let current_dir = env::current_dir()?; + let repository_dir_rel = self.repository_layout.repository_dir_name(); + let repository_dir = current_dir + .search_dir_up(repository_dir_rel) + .ok_or(RepositoryError::RepositoryNotFound { path: current_dir })?; + Ok(repository_dir.join(self.repository_layout.plugins_dir_name())) + } + ScopeType::Global => { + let config_dir = dirs::config_dir().ok_or(RepositoryError::ConfigDirNotFound)?; + Ok(config_dir.join(self.repository_layout.plugins_dir_rel())) + } + } + } +} + +impl PluginsOperations for PluginsHandler { + fn register(&self, request: RegisterRequest) -> EngineResult { + let plugins_dir = self.get_plugins_dir(&request.scope)?; + + let plugin_configuration = PluginConfiguration::new( + request.name, + request.description, + request.file, + request.event, + request.order, + request.enabled, + request.interpreter, + ); + + let (plugins_metadata_file, plugin_source_file) = self.plugins_repository.register( + plugin_configuration, + &request.command, + &plugins_dir, + &request.path, + )?; + + let response = RegisterResponse { + plugin_source_file, + plugins_metadata_file, + }; + + Ok(response) + } + + fn unregister(&self, request: UnregisterRequest) -> EngineResult { + let plugins_dir = self.get_plugins_dir(&request.scope)?; + + let (plugins_metadata_file, plugin_source_file) = + self.plugins_repository + .unregister(&request.command, &request.name, &plugins_dir)?; + + let response = UnregisterResponse { + plugin_source_file, + plugins_metadata_file, + }; + + Ok(response) + } + + fn list(&self, request: ListRequest) -> EngineResult { + let plugins_dir = self.get_plugins_dir(&request.scope)?; + let plugin_entries = self.plugins_repository.list( + &request.command, + &request.event, + &plugins_dir, + request.include_enabled, + request.include_disabled, + )?; + + let response = ListResponse { + plugins: plugin_entries + .into_iter() + .map(|entry| (entry.plugin, entry.source_file)) + .collect(), + }; + + Ok(response) + } + + fn info(&self, request: InfoRequest) -> EngineResult { + let plugins_dir = self.get_plugins_dir(&request.scope)?; + + let plugin_entry = + self.plugins_repository + .info(&request.command, &request.name, &plugins_dir)?; + + let response = InfoResponse { + configuration: plugin_entry.plugin, + source_file: plugin_entry.source_file, + }; + + Ok(response) + } + + fn edit(&self, request: EditRequest) -> EngineResult { + let plugins_dir = self.get_plugins_dir(&request.scope)?; + + let plugin_entry = self.plugins_repository.update_enabled( + &request.command, + &request.name, + &plugins_dir, + request.enabled, + )?; + + let response = EditResponse { + configuration: plugin_entry.plugin, + source_file: plugin_entry.source_file, + }; + + Ok(response) + } +} diff --git a/engine/src/plugins/operations.rs b/engine/src/plugins/operations.rs new file mode 100644 index 0000000..61c99ad --- /dev/null +++ b/engine/src/plugins/operations.rs @@ -0,0 +1,134 @@ +use std::{ + fmt::{Display, Formatter, Result}, + path::PathBuf, +}; + +use plugins::{CommandType, EventType, PluginConfiguration, ScopeType}; + +use crate::errors::EngineResult; + +pub struct RegisterRequest { + pub path: PathBuf, + pub scope: ScopeType, + pub name: String, + pub description: Option, + pub file: PathBuf, + pub command: CommandType, + pub event: EventType, + pub order: u32, + pub enabled: bool, + pub interpreter: Option, +} + +pub struct RegisterResponse { + pub plugins_metadata_file: PathBuf, + pub plugin_source_file: PathBuf, +} + +pub struct UnregisterRequest { + pub name: String, + pub command: CommandType, + pub scope: ScopeType, +} + +pub struct UnregisterResponse { + pub plugins_metadata_file: PathBuf, + pub plugin_source_file: PathBuf, +} + +pub struct ListRequest { + pub command: CommandType, + pub event: EventType, + pub scope: ScopeType, + pub include_enabled: bool, + pub include_disabled: bool, +} + +pub struct ListResponse { + pub plugins: Vec<(PluginConfiguration, PathBuf)>, +} + +impl Display for ListResponse { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + if self.plugins.is_empty() { + writeln!(f, "No plugins found.")?; + return Ok(()); + } + + for (i, (config, path)) in self.plugins.iter().enumerate() { + writeln!(f, "[{}] Source file: {}", i + 1, path.display())?; + writeln!(f, "Configuration:")?; + + let conf_str = format!("{config}"); + for line in conf_str.lines() { + writeln!(f, " {line}")?; + } + + if i + 1 < self.plugins.len() { + writeln!(f)?; + } + } + Ok(()) + } +} + +pub struct InfoRequest { + pub name: String, + pub command: CommandType, + pub scope: ScopeType, +} + +pub struct InfoResponse { + pub configuration: PluginConfiguration, + pub source_file: PathBuf, +} + +impl Display for InfoResponse { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + writeln!(f, "Source file: {}", self.source_file.display())?; + writeln!(f, "Configuration:")?; + + let conf_str = format!("{}", self.configuration); + for line in conf_str.lines() { + writeln!(f, " {line}")?; + } + Ok(()) + } +} + +pub struct EditRequest { + pub name: String, + pub command: CommandType, + pub scope: ScopeType, + pub enabled: Option, +} + +pub struct EditResponse { + pub configuration: PluginConfiguration, + pub source_file: PathBuf, +} + +impl Display for EditResponse { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + writeln!(f, "Source file: {}", self.source_file.display())?; + writeln!(f, "Configuration:")?; + + let conf_str = format!("{}", self.configuration); + for line in conf_str.lines() { + writeln!(f, " {line}")?; + } + Ok(()) + } +} + +pub trait PluginsOperations { + fn register(&self, request: RegisterRequest) -> EngineResult; + + fn unregister(&self, request: UnregisterRequest) -> EngineResult; + + fn list(&self, request: ListRequest) -> EngineResult; + + fn info(&self, request: InfoRequest) -> EngineResult; + + fn edit(&self, request: EditRequest) -> EngineResult; +} diff --git a/engine/src/plugins_interceptor.rs b/engine/src/plugins_interceptor.rs new file mode 100644 index 0000000..123c7c1 --- /dev/null +++ b/engine/src/plugins_interceptor.rs @@ -0,0 +1,240 @@ +use std::{env, fs, path::PathBuf}; + +use chrono::Utc; +use plugins::{ + CommandType, EventType, InvocationContext, InvocationInput, InvocationOutput, + InvocationPostPayload, InvocationPrePayload, PluginError, PluginsEngine, PluginsLayout, + PluginsRunner, ScopeType, +}; +use shared::UpwardSearch; + +use crate::{ + ConfigLoader, RepositoryLayout, + errors::{EngineError, EngineResult, RepositoryError}, +}; + +/// Defines how requests and responses are mapped to plugin payloads and back. +/// +/// Implementations of this trait allow converting between application-specific +/// request/response types and the generic `InvocationInput`/payloads used by plugins. +pub trait PluginsInvocationMapper { + /// Converts an incoming request into a pre-execution payload for plugins. + fn request_to_payload(&self, req: &Req) -> EngineResult; + + /// Converts a response from the core execution into a post-execution payload for plugins. + fn response_to_payload(&self, res: &Res) -> EngineResult; + + /// Converts plugin input back into a request for the main engine. + fn input_to_request(&self, input: &InvocationInput) -> EngineResult; + + /// Converts plugin input back into a response for the main engine. + fn input_to_response(&self, input: &InvocationInput) -> EngineResult; +} + +/// Intercepts engine commands and routes them through plugins before and after execution. +/// +/// The interceptor is responsible for: +/// - Locating the appropriate plugin directories (local/global), +/// - Creating invocation contexts, +/// - Running plugins in order for `PreExecute` and `PostExecute` events, +/// - Handling errors, timeouts, and logging. +pub struct PluginsInterceptor +where + T: PluginsLayout, + E: RepositoryLayout, +{ + /// The engine responsible for managing plugin lifecycle and execution. + pub plugins_engine: PluginsEngine, + + /// Layout abstraction to locate repository-specific paths. + pub repository_layout: E, + + /// Configuration loader used for plugin-related settings. + pub config_loader: ConfigLoader, +} + +impl PluginsInterceptor { + /// Creates a new `PluginsInterceptor` with the provided plugin engine and repository layout. + pub fn new(plugins_engine: PluginsEngine, repository_layout: E) -> Self { + Self { + plugins_engine, + repository_layout, + config_loader: ConfigLoader::default(), + } + } + + // Runs the given request through all configured plugins for the given command. + pub fn intercept_with_plugins( + &self, + command: CommandType, + working_dir: Option, + request: Req, + mapper: &M, + exec: F, + ) -> EngineResult + where + F: FnOnce(Req) -> EngineResult, + M: PluginsInvocationMapper, + { + if !self.get_enabled()? { + return exec(request); + } + + let is_init = command == CommandType::Init; + + let local_plugins_dir = if is_init { + None + } else { + Some(self.get_plugins_dir(&ScopeType::Local)?) + }; + + let global_plugins_dir = self.get_plugins_dir(&ScopeType::Global)?; + + let (local_plugins, global_plugins) = self.plugins_engine.get_plugins_for_command( + &local_plugins_dir, + &global_plugins_dir, + &command, + )?; + + let timestamp = Utc::now(); + + let plugins_dir = if is_init { + global_plugins_dir + } else { + local_plugins_dir.unwrap() + }; + + let invocation_dir = + self.plugins_engine + .create_invocation_dir(×tamp, &plugins_dir, &command)?; + + let logger = self + .plugins_engine + .create_logger(&invocation_dir, self.get_collect_logs()?)?; + + let context = InvocationContext::new( + command.clone(), + EventType::PreExecute, + Some(timestamp), + working_dir, + )?; + + let pre_payload = mapper.request_to_payload(&request)?; + + let invocation_input = InvocationInput { + context, + pre_payload: Some(pre_payload), + post_payload: None, + error: None, + }; + + let invocation_file = self + .plugins_engine + .create_invocation_file(&invocation_input, &invocation_dir)?; + + let timeout = self.get_timeout_ms()?; + + let mut runner = PluginsRunner::new( + invocation_file.clone(), + plugins_dir.join(command.to_string()).clone(), + logger, + timeout, + ); + + let pre_outputs = runner.run_plugins_for_event( + &local_plugins, + &global_plugins, + &EventType::PreExecute, + )?; + + let json = fs::read_to_string(&invocation_file)?; + let mut invocation_input = InvocationInput::from_json(&json)?; + + self.handle_last_plugin_result(pre_outputs.last(), &invocation_input)?; + + let response = exec(mapper.input_to_request(&invocation_input)?)?; + + invocation_input.context.event = EventType::PostExecute; + invocation_input.post_payload = Some(mapper.response_to_payload(&response)?); + + let invocation_file = self + .plugins_engine + .create_invocation_file(&invocation_input, &invocation_dir)?; + + runner.invocation_file = invocation_file.clone(); + + let post_outputs = runner.run_plugins_for_event( + &local_plugins, + &global_plugins, + &EventType::PostExecute, + )?; + + self.handle_last_plugin_result(post_outputs.last(), &invocation_input)?; + + let json = fs::read_to_string(&invocation_file)?; + let invocation_input = InvocationInput::from_json(&json)?; + + mapper.input_to_response(&invocation_input) + } + + /// Resolves the plugin directory path for the given scope. + fn get_plugins_dir(&self, scope: &ScopeType) -> EngineResult { + match scope { + ScopeType::Local => { + let current_dir = env::current_dir()?; + let repository_dir_rel = self.repository_layout.repository_dir_name(); + let repository_dir = current_dir + .search_dir_up(repository_dir_rel) + .ok_or(RepositoryError::RepositoryNotFound { path: current_dir })?; + Ok(repository_dir.join(self.repository_layout.plugins_dir_name())) + } + ScopeType::Global => { + let config_dir = dirs::config_dir().ok_or(RepositoryError::ConfigDirNotFound)?; + Ok(config_dir.join(self.repository_layout.plugins_dir_rel())) + } + } + } + + // Reads from configuration whether plugins are globally enabled. + pub fn get_enabled(&self) -> EngineResult { + self.config_loader.get_parsed("plugins.enabled", false) + } + + /// Reads the maximum allowed execution time for plugins (in milliseconds). + fn get_timeout_ms(&self) -> EngineResult { + self.config_loader.get_parsed("plugins.timeout_ms", 500u64) + } + + /// Reads from configuration whether plugin logs should be collected. + fn get_collect_logs(&self) -> EngineResult { + self.config_loader.get_parsed("plugins.collect_logs", false) + } + + /// Handles the result of the last executed plugin in a chain, + /// returning an appropriate error if it failed or timed out. + fn handle_last_plugin_result( + &self, + last: Option<&InvocationOutput>, + invocation_input: &InvocationInput, + ) -> EngineResult<()> { + if let Some(last) = last + && last.is_error() + { + if last.is_timed_out() { + return Err(EngineError::Plugins(PluginError::Timeout { + plugin: last.plugin.clone(), + duration_ms: last.duration_ms, + })); + } + + let unknown_msg = match &invocation_input.error { + Some(err) => serde_json::to_string(err).unwrap_or_else(|_| format!("{err:?}")), + None => format!("plugin exited with code {}", last.status_code), + }; + + return Err(EngineError::Plugins(PluginError::Unknown(unknown_msg))); + } + + Ok(()) + } +} diff --git a/engine/src/repositories.rs b/engine/src/repositories.rs index 7cc2b1f..153583e 100644 --- a/engine/src/repositories.rs +++ b/engine/src/repositories.rs @@ -1,4 +1,5 @@ pub mod meva_repository; +pub mod meva_repository_layout; pub mod repository_layout; pub use meva_repository::MevaRepository; diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index 574fb21..806f3f9 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -1,13 +1,10 @@ -use std::{ - fs, - io::Write, - path::{Path, PathBuf}, -}; +use std::path::PathBuf; +use std::{fs, io::Write, path::Path}; use tempfile::TempDir; use crate::repositories::RepositoryLayout; -use crate::{EngineError, EngineResult, InitError}; +use crate::{ConfigLoader, EngineError, EngineResult, InitError}; use shared::fs::create_file_with_dirs; /// Represents a Meva repository on disk. @@ -15,14 +12,16 @@ use shared::fs::create_file_with_dirs; /// Provides functionality to initialize repository layout and manage /// directory structure and configuration files. pub struct MevaRepository { - working_dir: PathBuf, + pub layout: Box, + pub config_loader: ConfigLoader, } impl MevaRepository { /// Creates a new `MevaRepository` for the given working directory. - pub fn new>(working_dir: P) -> Self { + pub fn new(layout: Box, config_loader: ConfigLoader) -> Self { Self { - working_dir: working_dir.into(), + layout, + config_loader, } } @@ -31,15 +30,10 @@ impl MevaRepository { /// Creates required directories and files under `.meva/`. /// Returns an error if the repository is already initialized /// or the working directory is invalid. - pub fn init(&self, initial_branch: &str) -> EngineResult<()> { - if !self.working_dir.exists() || !self.working_dir.is_dir() { - return Err(InitError::InvalidWorkingDir { - path: self.working_dir.to_string_lossy().into(), - } - .into()); - } + pub fn init(&self, initial_branch: &str) -> EngineResult { + self.config_loader.create_global_config()?; - let repository_dir = self.repository_dir(); + let repository_dir = self.layout.repository_dir(); if repository_dir.exists() { return Err(InitError::AlreadyInitialized { @@ -48,28 +42,28 @@ impl MevaRepository { .into()); } - let tmp_parent = &self.working_dir; + let tmp_parent = &self.layout.working_dir(); let tmp_dir = TempDir::new_in(tmp_parent).map_err(EngineError::Io)?; - let tmp_repo = tmp_dir.path().join(Self::REPOSITORY_DIR); + let tmp_repo = tmp_dir.path().join(self.layout.repository_dir_name()); self.create_dirs_at(&tmp_dir)?; self.create_files_at(&tmp_dir, initial_branch)?; - let final_repo = self.repository_dir(); + let final_repo = self.layout.repository_dir(); fs::rename(&tmp_repo, &final_repo).map_err(EngineError::Io)?; - Ok(()) + Ok(final_repo) } /// Creates the internal repository directories. fn create_dirs_at>(&self, root: P) -> EngineResult<()> { - fs::create_dir_all(root.as_ref().join(self.objects_dir_rel()))?; - fs::create_dir_all(root.as_ref().join(self.refs_dir_rel()))?; - fs::create_dir_all(root.as_ref().join(self.heads_refs_dir_rel()))?; - fs::create_dir_all(root.as_ref().join(self.logs_dir_rel()))?; - fs::create_dir_all(root.as_ref().join(self.heads_logs_dir_rel()))?; + fs::create_dir_all(root.as_ref().join(self.layout.objects_dir_rel()))?; + fs::create_dir_all(root.as_ref().join(self.layout.refs_dir_rel()))?; + fs::create_dir_all(root.as_ref().join(self.layout.heads_refs_dir_rel()))?; + fs::create_dir_all(root.as_ref().join(self.layout.logs_dir_rel()))?; + fs::create_dir_all(root.as_ref().join(self.layout.heads_logs_dir_rel()))?; Ok(()) } @@ -77,82 +71,46 @@ impl MevaRepository { fn create_files_at>(&self, root: P, initial_branch: &str) -> EngineResult<()> { let ref_path = root .as_ref() - .join(self.heads_refs_dir_rel()) + .join(self.layout.heads_refs_dir_rel()) .join(initial_branch); let log_path = root .as_ref() - .join(self.heads_logs_dir_rel()) + .join(self.layout.heads_logs_dir_rel()) .join(initial_branch); create_file_with_dirs(ref_path)?; create_file_with_dirs(log_path)?; - let config_path = root.as_ref().join(self.config_file_rel()); - let mut config_file = fs::File::create(&config_path)?; - config_file.write_all(Self::DEFAULT_CONFIG.as_bytes())?; + let config_path = root.as_ref().join(self.layout.config_file_rel()); + self.config_loader.create_local_config(&config_path)?; - let head_path = root.as_ref().join(self.head_file_rel()); + let head_path = root.as_ref().join(self.layout.head_file_rel()); let mut head_file = fs::File::create(&head_path)?; write!( head_file, "ref: {}/{}/{}", - Self::REFS_DIR, - Self::HEADS_DIR, + self.layout.refs_dir_name(), + self.layout.heads_dir_name(), initial_branch )?; - let head_log_path = root.as_ref().join(self.head_logs_file_rel()); + let head_log_path = root.as_ref().join(self.layout.head_logs_file_rel()); fs::File::create(head_log_path)?; Ok(()) } - - const DEFAULT_CONFIG: &'static str = concat!( - "# Meva Configuration File\n", - "# Edit this file to customize your settings\n", - "\n", - "# [user]\n", - "# name = \"Your Name\"\n", - "# email = \"your.email@example.com\"\n", - "\n", - "# [editor]\n", - "# default = \"vim\"\n", - "\n", - ); -} - -impl RepositoryLayout for MevaRepository { - const REPOSITORY_DIR: &'static str = ".meva"; - - const OBJECTS_DIR: &'static str = "objects"; - - const REFS_DIR: &'static str = "refs"; - - const LOGS_DIR: &'static str = "logs"; - - const HEADS_DIR: &'static str = "heads"; - - const HEAD_FILE: &'static str = "HEAD"; - - const CONFIG_FILE: &'static str = "mevaconfig"; - - const IGNORE_FILE: &'static str = ".mevaignore"; - - /// Returns the repository’s working directory path. - fn working_dir(&self) -> &std::path::Path { - &self.working_dir - } } #[cfg(test)] mod tests { - use crate::EngineError; + use crate::repositories::meva_repository_layout::MevaRepositoryLayout; use super::*; use pretty_assertions::assert_eq; use rstest::rstest; - use std::fs::{self, File}; + use std::fs; use std::io::Read; + use std::path::PathBuf; use tempfile::TempDir; fn read_file(path: &PathBuf) -> String { @@ -163,10 +121,18 @@ mod tests { content } + fn get_repo(path: &Path) -> MevaRepository { + let layout = Box::new(MevaRepositoryLayout { + working_dir: path.to_path_buf(), + }); + + MevaRepository::new(layout, ConfigLoader::default()) + } + #[rstest] fn init_creates_expected_structure() { - let tmp = TempDir::new().unwrap(); - let repo = MevaRepository::new(tmp.path()); + let tmp = TempDir::new().expect("failed to create TempDir"); + let repo = get_repo(tmp.path()); let result = repo.init("main"); assert!(result.is_ok()); @@ -174,37 +140,40 @@ mod tests { let repo_dir = tmp.path().join(".meva"); assert!(repo_dir.exists()); - assert!(repo.objects_dir().exists()); - assert!(repo.refs_dir().exists()); - assert!(repo.heads_refs_dir().exists()); - assert!(repo.logs_dir().exists()); - assert!(repo.heads_logs_dir().exists()); + assert!(repo.layout.objects_dir().exists()); + assert!(repo.layout.refs_dir().exists()); + assert!(repo.layout.heads_refs_dir().exists()); + assert!(repo.layout.logs_dir().exists()); + assert!(repo.layout.heads_logs_dir().exists()); - let head_path = repo.head_file(); + let head_path = repo.layout.head_file(); assert!(head_path.exists()); let head_contents = read_file(&head_path); assert_eq!(head_contents, "ref: refs/heads/main"); - assert!(repo.head_logs_file().exists()); + assert!(repo.layout.head_logs_file().exists()); - let branch_ref = repo.heads_refs_dir().join("main"); + let branch_ref = repo.layout.heads_refs_dir().join("main"); assert!(branch_ref.exists()); - let branch_log = repo.heads_logs_dir().join("main"); + let branch_log = repo.layout.heads_logs_dir().join("main"); assert!(branch_log.exists()); - let config_path = repo.config_file(); + let config_path = repo.layout.config_file(); assert!(config_path.exists()); let config_content = read_file(&config_path); - assert_eq!(config_content, MevaRepository::DEFAULT_CONFIG); + assert_eq!( + config_content, + repo.config_loader.get_default_local_config() + ); } #[rstest] fn init_fails_if_repo_already_exists() { let tmp = TempDir::new().expect("failed to create TempDir"); - let repo = MevaRepository::new(tmp.path()); + let repo = get_repo(tmp.path()); // First init succeeds assert!(repo.init("dev").is_ok()); @@ -221,36 +190,4 @@ mod tests { ); } } - - #[rstest] - fn init_fails_if_nonexistent_working_dir() { - let tmp = TempDir::new().expect("failed to create TempDir"); - let bad_path = tmp.path().join("does_not_exist"); - - let repo = MevaRepository::new(&bad_path); - - let err = repo.init("main").unwrap_err(); - assert!(matches!( - err, - EngineError::Init(InitError::InvalidWorkingDir { path }) - if path == bad_path.to_string_lossy() - )); - } - - #[rstest] - fn init_fails_if_path_is_a_file() { - let tmp = TempDir::new().expect("failed to create TempDir"); - let file_path = tmp.path().join("not_a_dir.txt"); - - File::create(&file_path).expect("failed to create test file"); - - let repo = MevaRepository::new(&file_path); - let err = repo.init("main").unwrap_err(); - - assert!(matches!( - err, - EngineError::Init(InitError::InvalidWorkingDir { path }) - if path == file_path.to_string_lossy() - )); - } } diff --git a/engine/src/repositories/meva_repository_layout.rs b/engine/src/repositories/meva_repository_layout.rs new file mode 100644 index 0000000..bb6015a --- /dev/null +++ b/engine/src/repositories/meva_repository_layout.rs @@ -0,0 +1,75 @@ +use std::{env, path::PathBuf}; + +use crate::{ + RepositoryLayout, + errors::{EngineResult, InitError}, +}; + +pub struct MevaRepositoryLayout { + pub working_dir: PathBuf, +} + +impl MevaRepositoryLayout { + pub fn new(working_dir: PathBuf) -> EngineResult { + if !working_dir.is_dir() { + return Err(InitError::InvalidWorkingDir { + path: working_dir.to_string_lossy().into(), + } + .into()); + } + Ok(Self { working_dir }) + } + + pub fn from_env() -> EngineResult { + Ok(Self { + working_dir: env::current_dir()?, + }) + } +} + +impl RepositoryLayout for MevaRepositoryLayout { + /// Returns the repository’s working directory path. + fn working_dir(&self) -> &std::path::Path { + &self.working_dir + } + + fn set_working_dir(&mut self, new_working_dir: PathBuf) { + self.working_dir = new_working_dir; + } + + fn repository_dir_name(&self) -> &str { + ".meva" + } + + fn objects_dir_name(&self) -> &str { + "objects" + } + + fn refs_dir_name(&self) -> &str { + "refs" + } + + fn logs_dir_name(&self) -> &str { + "logs" + } + + fn heads_dir_name(&self) -> &str { + "heads" + } + + fn head_file_name(&self) -> &str { + "HEAD" + } + + fn config_file_name(&self) -> &str { + "mevaconfig" + } + + fn ignore_file_name(&self) -> &str { + ".mevaignore" + } + + fn plugins_dir_name(&self) -> &str { + "plugins" + } +} diff --git a/engine/src/repositories/repository_layout.rs b/engine/src/repositories/repository_layout.rs index 759f51e..cbdfe51 100644 --- a/engine/src/repositories/repository_layout.rs +++ b/engine/src/repositories/repository_layout.rs @@ -7,77 +7,87 @@ use std::path::{Path, PathBuf}; #[allow(dead_code)] pub trait RepositoryLayout { /// Name of the root repository directory. - const REPOSITORY_DIR: &'static str; + fn repository_dir_name(&self) -> &str; /// Subdirectory for storing objects. - const OBJECTS_DIR: &'static str; + fn objects_dir_name(&self) -> &str; /// Subdirectory for storing references. - const REFS_DIR: &'static str; + fn refs_dir_name(&self) -> &str; /// Subdirectory for reference logs. - const LOGS_DIR: &'static str; + fn logs_dir_name(&self) -> &str; /// Subdirectory for storing heads references and logs. - const HEADS_DIR: &'static str; + fn heads_dir_name(&self) -> &str; + + /// Subdirectory for storing plugins related metadata. + fn plugins_dir_name(&self) -> &str; /// Name of the HEAD file. - const HEAD_FILE: &'static str; + fn head_file_name(&self) -> &str; /// Name of the config file. - const CONFIG_FILE: &'static str; + fn config_file_name(&self) -> &str; /// Name of the ignore file. - const IGNORE_FILE: &'static str; + fn ignore_file_name(&self) -> &str; /// Returns the working directory where the repository is located. fn working_dir(&self) -> &Path; + fn set_working_dir(&mut self, new_working_dir: PathBuf); + // --- relative paths --- /// Returns the relative path to the repository directory. fn repository_dir_rel(&self) -> PathBuf { - PathBuf::from(Self::REPOSITORY_DIR) + PathBuf::from(self.repository_dir_name()) } /// Path to the objects directory relative to `working_dir`. fn objects_dir_rel(&self) -> PathBuf { - self.repository_dir_rel().join(Self::OBJECTS_DIR) + self.repository_dir_rel().join(self.objects_dir_name()) } /// Path to the refs directory relative to `working_dir`. fn refs_dir_rel(&self) -> PathBuf { - self.repository_dir_rel().join(Self::REFS_DIR) + self.repository_dir_rel().join(self.refs_dir_name()) } /// Path to heads in refs relative to `working_dir`. fn heads_refs_dir_rel(&self) -> PathBuf { - self.refs_dir_rel().join(Self::HEADS_DIR) + self.refs_dir_rel().join(self.heads_dir_name()) } /// Path to the logs directory relative to `working_dir`. fn logs_dir_rel(&self) -> PathBuf { - self.repository_dir_rel().join(Self::LOGS_DIR) + self.repository_dir_rel().join(self.logs_dir_name()) } /// Path to heads in logs relative to `working_dir`. fn heads_logs_dir_rel(&self) -> PathBuf { - self.logs_dir_rel().join(Self::HEADS_DIR) + self.logs_dir_rel().join(self.heads_dir_name()) + } + + /// Path to plugins directory relative to `working_dir`. + fn plugins_dir_rel(&self) -> PathBuf { + self.repository_dir_rel().join(self.plugins_dir_name()) } /// Path to the HEAD file relative to `working_dir`. fn head_file_rel(&self) -> PathBuf { - self.repository_dir_rel().join(Self::HEAD_FILE) + self.repository_dir_rel().join(self.head_file_name()) } /// Path to the config file relative to `working_dir`. fn config_file_rel(&self) -> PathBuf { - self.repository_dir_rel().join(Self::CONFIG_FILE) + self.repository_dir_rel().join(self.config_file_name()) } /// Path to the HEAD log file relative to `working_dir`. fn head_logs_file_rel(&self) -> PathBuf { - self.logs_dir_rel().join(Self::HEAD_FILE) + self.logs_dir_rel().join(self.head_file_name()) } // --- absolute paths --- @@ -112,6 +122,11 @@ pub trait RepositoryLayout { self.working_dir().join(self.heads_logs_dir_rel()) } + /// Absolute path to plugins directory. + fn plugins_dir(&self) -> PathBuf { + self.working_dir().join(self.plugins_dir_rel()) + } + /// Absolute path to the HEAD file. fn head_file(&self) -> PathBuf { self.working_dir().join(self.head_file_rel()) @@ -146,19 +161,49 @@ mod tests { } impl RepositoryLayout for TestRepoLayout { - const REPOSITORY_DIR: &'static str = ".meva"; - const OBJECTS_DIR: &'static str = "objects"; - const REFS_DIR: &'static str = "refs"; - const LOGS_DIR: &'static str = "logs"; - const HEADS_DIR: &'static str = "heads"; - - const HEAD_FILE: &'static str = "HEAD"; - const CONFIG_FILE: &'static str = "mevaconfig"; - const IGNORE_FILE: &'static str = ".mevaignore"; - fn working_dir(&self) -> &Path { &self.dir } + + fn set_working_dir(&mut self, new_working_dir: PathBuf) { + self.dir = new_working_dir; + } + + fn repository_dir_name(&self) -> &str { + ".meva" + } + + fn objects_dir_name(&self) -> &str { + "objects" + } + + fn refs_dir_name(&self) -> &str { + "refs" + } + + fn logs_dir_name(&self) -> &str { + "logs" + } + + fn heads_dir_name(&self) -> &str { + "heads" + } + + fn head_file_name(&self) -> &str { + "HEAD" + } + + fn config_file_name(&self) -> &str { + "mevaconfig" + } + + fn ignore_file_name(&self) -> &str { + ".mevaignore" + } + + fn plugins_dir_name(&self) -> &str { + "plugins" + } } #[rstest] @@ -179,6 +224,7 @@ mod tests { layout.heads_logs_dir_rel(), PathBuf::from(".meva/logs/heads") ); + assert_eq!(layout.plugins_dir_rel(), PathBuf::from(".meva/plugins")); assert_eq!(layout.head_file_rel(), PathBuf::from(".meva/HEAD")); assert_eq!(layout.config_file_rel(), PathBuf::from(".meva/mevaconfig")); assert_eq!( @@ -193,6 +239,7 @@ mod tests { assert_eq!(layout.heads_refs_dir(), base.join(".meva/refs/heads")); assert_eq!(layout.logs_dir(), base.join(".meva/logs")); assert_eq!(layout.heads_logs_dir(), base.join(".meva/logs/heads")); + assert_eq!(layout.plugins_dir(), base.join(".meva/plugins")); assert_eq!(layout.config_file(), base.join(".meva/mevaconfig")); assert_eq!(layout.head_file(), base.join(".meva/HEAD")); assert_eq!(layout.head_logs_file(), base.join(".meva/logs/HEAD")); diff --git a/plugins/Cargo.toml b/plugins/Cargo.toml index 1daa0f5..126b783 100644 --- a/plugins/Cargo.toml +++ b/plugins/Cargo.toml @@ -6,6 +6,13 @@ authors.workspace = true [dependencies] shared = { path = "../shared" } +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +chrono.workspace = true +strum.workspace = true +strum_macros.workspace = true +wait-timeout = "0.1.5" [dev-dependencies] rstest.workspace = true diff --git a/plugins/src/enums.rs b/plugins/src/enums.rs new file mode 100644 index 0000000..1df7c8f --- /dev/null +++ b/plugins/src/enums.rs @@ -0,0 +1,9 @@ +mod command_type; +mod event_type; +mod invocation_payload; +mod scope_type; + +pub use command_type::CommandType; +pub use event_type::EventType; +pub use invocation_payload::{InvocationPostPayload, InvocationPrePayload}; +pub use scope_type::ScopeType; diff --git a/plugins/src/enums/command_type.rs b/plugins/src/enums/command_type.rs new file mode 100644 index 0000000..392acc5 --- /dev/null +++ b/plugins/src/enums/command_type.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; +use strum_macros::{Display, EnumString, VariantNames}; + +/// Defines the command types that plugins can be registered to. +#[derive( + Debug, Deserialize, Serialize, Clone, EnumString, VariantNames, Display, Eq, PartialEq, +)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum CommandType { + Init, + ConfigGet, + ConfigSet, + ConfigUnset, + ConfigList, +} diff --git a/plugins/src/enums/event_type.rs b/plugins/src/enums/event_type.rs new file mode 100644 index 0000000..201b3ae --- /dev/null +++ b/plugins/src/enums/event_type.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; +use strum_macros::{Display, EnumString, VariantNames}; + +/// Represents the types of events that plugins can be registered to. +/// +/// Plugins can hook into specific execution phases: +/// +/// - **PreExecute** +/// - Triggered before a command is executed, +/// - Useful for validation, preprocessing, or setup tasks. +/// +/// - **PostExecute** +/// - Triggered after a command has been executed, +/// - Useful for cleanup, logging, or post-processing. +#[derive( + Debug, Deserialize, Serialize, Clone, PartialEq, Eq, EnumString, VariantNames, Display, +)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum EventType { + /// Event fired before command execution. + PreExecute, + + /// Event fired after successful command execution. + PostExecute, +} diff --git a/plugins/src/enums/invocation_payload.rs b/plugins/src/enums/invocation_payload.rs new file mode 100644 index 0000000..5bc5b14 --- /dev/null +++ b/plugins/src/enums/invocation_payload.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ + InitPostPayload, InitPrePayload, + models::{ + ConfigGetPostPayload, ConfigGetPrePayload, ConfigListPostPayload, ConfigListPrePayload, + ConfigSetPostPayload, ConfigSetPrePayload, ConfigUnsetPostPayload, ConfigUnsetPrePayload, + }, +}; + +/// Represents the data passed to plugins +/// before a command is executed. +/// +/// Each variant contains a payload specific to the command +/// that is about to run. This allows plugins to inspect or +/// modify data prior to execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum InvocationPrePayload { + /// Context for the `init` command. + Init(InitPrePayload), + + /// Context for the `config set` command. + ConfigSet(ConfigSetPrePayload), + + /// Context for the `config unset` command. + ConfigUnset(ConfigUnsetPrePayload), + + /// Context for the `config get` command. + ConfigGet(ConfigGetPrePayload), + + /// Context for the `config list` command. + ConfigList(ConfigListPrePayload), +} + +/// Represents the context data passed to plugins +/// after a command has been executed. +/// +/// Each variant contains a payload specific to the command +/// that has just completed. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum InvocationPostPayload { + /// Context for the `init` command. + Init(InitPostPayload), + + /// Context for the `config get` command. + ConfigGet(ConfigGetPostPayload), + + /// Context for the `config set` command. + ConfigSet(ConfigSetPostPayload), + + /// Context for the `config unset` command. + ConfigUnset(ConfigUnsetPostPayload), + + /// Context for the `config list` command. + ConfigList(ConfigListPostPayload), +} diff --git a/plugins/src/enums/scope_type.rs b/plugins/src/enums/scope_type.rs new file mode 100644 index 0000000..b1a336c --- /dev/null +++ b/plugins/src/enums/scope_type.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; +use strum_macros::{Display, EnumString, VariantNames}; + +/// Defines the scope of plugins. +#[derive( + Debug, Deserialize, Serialize, Clone, PartialEq, Eq, EnumString, VariantNames, Display, +)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum ScopeType { + /// A plugin that is specific to a single repository. + Local, + + /// A plugin that is shared across all repositories. + Global, +} diff --git a/plugins/src/errors.rs b/plugins/src/errors.rs new file mode 100644 index 0000000..c1b86ed --- /dev/null +++ b/plugins/src/errors.rs @@ -0,0 +1,10 @@ +mod configuration_error; +mod invocation_error; +mod plugin_error; +mod register_error; + +pub use configuration_error::ConfigurationError; +pub use invocation_error::InvocationError; +pub use plugin_error::PluginError; +pub use plugin_error::Result as PluginResult; +pub use register_error::RegisterError; diff --git a/plugins/src/errors/configuration_error.rs b/plugins/src/errors/configuration_error.rs new file mode 100644 index 0000000..50a243b --- /dev/null +++ b/plugins/src/errors/configuration_error.rs @@ -0,0 +1,18 @@ +use thiserror::Error; + +/// Represents errors related to plugins metadata stored +/// in the configuration files. +#[derive(Error, Debug)] +pub enum ConfigurationError { + /// Raised when a plugin with the same name already exists. + #[error("Duplicate plugin name found: `{name}`")] + DuplicateName { name: String }, + + /// Raised when a plugin with the same execution order already exists. + #[error("Duplicate plugin order found: {order}")] + DuplicateOrder { order: u32 }, + + /// Raised when a referenced plugin name could not be found. + #[error("Plugin name not found: `{name}`")] + NameNotFound { name: String }, +} diff --git a/plugins/src/errors/invocation_error.rs b/plugins/src/errors/invocation_error.rs new file mode 100644 index 0000000..44cb661 --- /dev/null +++ b/plugins/src/errors/invocation_error.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; + +/// Represents an error produced during plugin invocation. +#[derive(Debug, Serialize, Deserialize)] +pub struct InvocationError { + pub code: String, + /// Human-readable description of the error. + pub message: String, + + /// Optional additional details (e.g., stack traces). + pub details: Option, +} diff --git a/plugins/src/errors/plugin_error.rs b/plugins/src/errors/plugin_error.rs new file mode 100644 index 0000000..828713d --- /dev/null +++ b/plugins/src/errors/plugin_error.rs @@ -0,0 +1,62 @@ +use std::{ + io::{self, Error}, + path::PathBuf, +}; + +use thiserror::Error; + +use crate::{ + InvocationPostPayload, InvocationPrePayload, + errors::{ConfigurationError, RegisterError}, +}; + +/// The common alias for results using `PluginError`. +pub type Result = std::result::Result; + +/// A unified error type for all plugin-related operations. +#[derive(Error, Debug)] +pub enum PluginError { + /// Wrapper around standard I/O errors. + #[error(transparent)] + Io(#[from] io::Error), + + /// Wrapper around JSON serialization or deserialization errors. + #[error(transparent)] + Serde(#[from] serde_json::Error), + + /// Errors related to invalid or conflicting plugin configuration. + #[error(transparent)] + Configuration(#[from] ConfigurationError), + + /// Errors encountered while registering plugins. + #[error(transparent)] + Register(#[from] RegisterError), + + /// Raised when the specified plugin file could not be found. + #[error("Plugin file not found: {path}")] + FileNotFound { path: PathBuf }, + + /// Raised when a plugin process could not be spawned. + #[error("Plugin spawn error: {error}")] + Spawn { error: Error }, + + /// Raised when a pre-execution payload could not be deserialized or was invalid. + #[error("Invalid pre-execute payload: {payload:?}")] + PrePayload { + payload: Option, + }, + + /// Raised when a post-execution payload could not be deserialized or was invalid. + #[error("Invalid post-execute payload: {payload:?}")] + PostPayload { + payload: Option, + }, + + /// Raised when a plugin exceeded the configured timeout limit. + #[error("Plugin '{plugin}' timed out after {duration_ms} ms")] + Timeout { plugin: String, duration_ms: u128 }, + + /// A catch-all for unexpected errors. + #[error("Unknown Plugin error: {0}")] + Unknown(String), +} diff --git a/plugins/src/errors/register_error.rs b/plugins/src/errors/register_error.rs new file mode 100644 index 0000000..967f734 --- /dev/null +++ b/plugins/src/errors/register_error.rs @@ -0,0 +1,17 @@ +use std::path::PathBuf; + +use thiserror::Error; + +/// Represents errors that can occur during plugin registration. +#[derive(Error, Debug)] +pub enum RegisterError { + /// Raised when the given directory path is not valid + /// or cannot be accessed. + #[error("Invalid directory path provided: {path}")] + InvalidDirPath { path: PathBuf }, + + /// Raised when the given file path is not valid + /// or cannot be accessed. + #[error("Invalid file path provided: {path}")] + InvalidFilePath { path: PathBuf }, +} diff --git a/plugins/src/layout.rs b/plugins/src/layout.rs new file mode 100644 index 0000000..7da037b --- /dev/null +++ b/plugins/src/layout.rs @@ -0,0 +1,51 @@ +use crate::EventType; + +/// Defines the file and directory layout for plugin management. +pub trait PluginsLayout { + /// Returns the name of the directory where plugin invocation records are stored. + fn invocations_dir_name(&self) -> &'static str; + + /// Returns the name of the plugins configuration files. + fn plugins_file_name(&self) -> &'static str; + + /// Returns the name of the log files recording plugin invocation metadata. + fn invocation_log_name(&self) -> &'static str; + + /// Returns the name of the log files for capturing plugin stdout. + fn stdout_log_name(&self) -> &'static str; + + /// Returns the name of the log files for capturing plugin stderr. + fn stderr_log_name(&self) -> &'static str; + + /// Returns the filename for a plugin context file, based on the event type. + fn context_file_name(&self, event: &EventType) -> String; +} + +/// Default implementation of `PluginsLayout` following the Meva conventions. +pub struct MevaPluginsLayout; + +impl PluginsLayout for MevaPluginsLayout { + fn invocations_dir_name(&self) -> &'static str { + ".invocations" + } + + fn plugins_file_name(&self) -> &'static str { + "plugins.json" + } + + fn invocation_log_name(&self) -> &'static str { + "invocation.log" + } + + fn stdout_log_name(&self) -> &'static str { + "stdout.log" + } + + fn stderr_log_name(&self) -> &'static str { + "stderr.log" + } + + fn context_file_name(&self, event: &EventType) -> String { + format!("{event}-context.json") + } +} diff --git a/plugins/src/lib.rs b/plugins/src/lib.rs index ab976c8..888d405 100644 --- a/plugins/src/lib.rs +++ b/plugins/src/lib.rs @@ -1,15 +1,18 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +mod enums; +mod errors; +mod layout; +mod plugins_discovery; +mod plugins_engine; +mod plugins_repository; +mod plugins_runner; -#[cfg(test)] -mod tests { - use super::*; - use rstest::*; +pub mod models; - #[rstest] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub use enums::{CommandType, EventType, InvocationPostPayload, InvocationPrePayload, ScopeType}; +pub use errors::PluginError; +pub use layout::{MevaPluginsLayout, PluginsLayout}; +pub use models::*; +pub use plugins_discovery::PluginsDiscovery; +pub use plugins_engine::PluginsEngine; +pub use plugins_repository::PluginsRepository; +pub use plugins_runner::PluginsRunner; diff --git a/plugins/src/models.rs b/plugins/src/models.rs new file mode 100644 index 0000000..de34346 --- /dev/null +++ b/plugins/src/models.rs @@ -0,0 +1,11 @@ +mod invocation; +mod payloads; +mod plugin_configuration; +mod plugin_entry; +mod plugins_configuration; + +pub use invocation::{InvocationContext, InvocationInput, InvocationLogger, InvocationOutput}; +pub use payloads::*; +pub use plugin_configuration::PluginConfiguration; +pub use plugin_entry::PluginEntry; +pub use plugins_configuration::PluginsConfiguration; diff --git a/plugins/src/models/invocation.rs b/plugins/src/models/invocation.rs new file mode 100644 index 0000000..528b50a --- /dev/null +++ b/plugins/src/models/invocation.rs @@ -0,0 +1,9 @@ +mod invocation_context; +mod invocation_input; +mod invocation_logger; +mod invocation_output; + +pub use invocation_context::InvocationContext; +pub use invocation_input::InvocationInput; +pub use invocation_logger::InvocationLogger; +pub use invocation_output::InvocationOutput; diff --git a/plugins/src/models/invocation/invocation_context.rs b/plugins/src/models/invocation/invocation_context.rs new file mode 100644 index 0000000..8baccd4 --- /dev/null +++ b/plugins/src/models/invocation/invocation_context.rs @@ -0,0 +1,49 @@ +use std::{env, path::PathBuf}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{ + enums::{CommandType, EventType}, + errors::PluginResult, +}; + +/// Represents the data passed to a plugin +/// during every invocation, regardless of the specific command. +#[derive(Debug, Serialize, Deserialize)] +pub struct InvocationContext { + /// The command type that triggered the plugin. + pub command: CommandType, + + /// The execution phase. + pub event: EventType, + + /// The time at which the invocation occurred. + pub timestamp: DateTime, + + /// The working directory where the command was executed. + pub working_dir: PathBuf, +} + +impl InvocationContext { + /// Creates a new `InvocationContext`. + pub fn new( + command: CommandType, + event: EventType, + timestamp: Option>, + working_dir: Option, + ) -> PluginResult { + Ok(Self { + command, + event, + timestamp: match timestamp { + Some(ts) => ts, + None => Utc::now(), + }, + working_dir: match working_dir { + Some(wd) => wd, + None => env::current_dir()?, + }, + }) + } +} diff --git a/plugins/src/models/invocation/invocation_input.rs b/plugins/src/models/invocation/invocation_input.rs new file mode 100644 index 0000000..e2ba734 --- /dev/null +++ b/plugins/src/models/invocation/invocation_input.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ + InvocationPostPayload, InvocationPrePayload, + errors::{InvocationError, PluginResult}, + models::invocation::InvocationContext, +}; + +/// Represents the complete set of data exchanged between +/// the runner and a plugin during execution. +#[derive(Debug, Serialize, Deserialize)] +pub struct InvocationInput { + /// Invocation context providing metadata about the execution environment. + pub context: InvocationContext, + + /// Optional data passed **before** the command execution. + #[serde(rename = "pre-payload")] + pub pre_payload: Option, + + /// Optional data passed **after** the command execution. + #[serde(rename = "post-payload")] + pub post_payload: Option, + + /// Optional error information captured during execution. + pub error: Option, +} + +impl InvocationInput { + /// Parses a JSON string into an `InvocationInput` instance. + pub fn from_json(json: &str) -> PluginResult { + Ok(serde_json::from_str::(json)?) + } +} diff --git a/plugins/src/models/invocation/invocation_logger.rs b/plugins/src/models/invocation/invocation_logger.rs new file mode 100644 index 0000000..8643e9b --- /dev/null +++ b/plugins/src/models/invocation/invocation_logger.rs @@ -0,0 +1,109 @@ +use std::{ + fs::{File, OpenOptions}, + io::Write, + path::Path, +}; + +use crate::{ + errors::PluginResult, + models::{InvocationOutput, PluginConfiguration}, +}; + +/// Handles logging of plugin invocations, including their stdout, +/// stderr, and metadata about the execution. +#[derive(Debug)] +pub enum InvocationLogger { + /// Writes stdout, stderr, and invocation metadata to separate files. + ToFiles { + stdout_log: File, + stderr_log: File, + invocation_log: File, + }, + + /// Only console output is printed (no files are written). + ConsoleOnly, +} + +impl InvocationLogger { + /// Creates a new `InvocationLogger` that writes logs to the specified file paths. + /// + /// # Arguments + /// + /// * `stdout_path` - File path for stdout logging. + /// * `stderr_path` - File path for stderr logging. + /// * `invocation_path` - File path for plugin invocation metadata. + /// + /// # Returns + /// + /// A `PluginResult` containing the initialized `InvocationLogger`. + pub fn from_paths( + stdout_path: &Path, + stderr_path: &Path, + invocation_path: &Path, + ) -> PluginResult { + let stdout_log = OpenOptions::new() + .create(true) + .append(true) + .open(stdout_path)?; + + let stderr_log = OpenOptions::new() + .create(true) + .append(true) + .open(stderr_path)?; + + let invocation_log = OpenOptions::new() + .create(true) + .append(true) + .open(invocation_path)?; + + Ok(InvocationLogger::ToFiles { + stdout_log, + stderr_log, + invocation_log, + }) + } + + /// Logs a full stdout content (used when you collect stdout in a thread). + pub fn log_stdout(&mut self, content: &str) -> PluginResult<()> { + print!("{content}"); + if let InvocationLogger::ToFiles { stdout_log, .. } = self { + stdout_log.write_all(content.as_bytes())?; + stdout_log.flush()?; + } + Ok(()) + } + + /// Logs a full stderr content (used when you collect stderr in a thread). + pub fn log_stderr(&mut self, content: &str) -> PluginResult<()> { + eprint!("{content}"); + if let InvocationLogger::ToFiles { stderr_log, .. } = self { + stderr_log.write_all(content.as_bytes())?; + stderr_log.flush()?; + } + Ok(()) + } + + /// Logs metadata about a plugin invocation, such as its exit status + /// and execution duration, to the invocation log file (if enabled). + pub fn log_invocation( + &mut self, + plugin_configuration: &PluginConfiguration, + output: &InvocationOutput, + ) -> PluginResult<()> { + let timeout_suffix = if output.timed_out { " (timed out)" } else { "" }; + + if let InvocationLogger::ToFiles { invocation_log, .. } = self { + let log_line = format!( + "{}) Plugin {} finished with exit status {} in {} ms{}\n", + plugin_configuration.order, + plugin_configuration.name, + output.status_code, + output.duration_ms, + timeout_suffix + ); + invocation_log.write_all(log_line.as_bytes())?; + invocation_log.flush()?; + } + Ok(()) + } +} diff --git a/plugins/src/models/invocation/invocation_output.rs b/plugins/src/models/invocation/invocation_output.rs new file mode 100644 index 0000000..d95141d --- /dev/null +++ b/plugins/src/models/invocation/invocation_output.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +/// Represents the result of a plugin invocation. +#[derive(Debug, Serialize, Deserialize)] +pub struct InvocationOutput { + /// Unique identifier of the plugin (its name). + pub plugin: String, + + /// Exit status code returned by the plugin. + /// Conventionally, `0` indicates success, any non-zero value indicates an error. + pub status_code: i32, + + /// Execution duration in milliseconds. + pub duration_ms: u128, + + // Flag indicating if the process has timed out. + pub timed_out: bool, +} + +impl InvocationOutput { + /// Returns `true` if the plugin exited successfully (status code `0`) + /// and did not time out. + pub fn is_success(&self) -> bool { + !self.timed_out && self.status_code == 0 + } + + /// Returns `true` if the plugin invocation should be considered an error. + /// We treat either a non-zero exit code or a timeout as an error. + pub fn is_error(&self) -> bool { + self.timed_out || self.status_code != 0 + } + + /// Convenience accessor for timeout state. + pub fn is_timed_out(&self) -> bool { + self.timed_out + } +} diff --git a/plugins/src/models/payloads.rs b/plugins/src/models/payloads.rs new file mode 100644 index 0000000..dff6e8a --- /dev/null +++ b/plugins/src/models/payloads.rs @@ -0,0 +1,5 @@ +mod config_payload; +mod init_payload; + +pub use config_payload::*; +pub use init_payload::*; diff --git a/plugins/src/models/payloads/config_payload.rs b/plugins/src/models/payloads/config_payload.rs new file mode 100644 index 0000000..711d237 --- /dev/null +++ b/plugins/src/models/payloads/config_payload.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Payload provided to plugins **before** the `config get` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigGetPrePayload { + /// Path to the configuration file being queried. + pub config_file: PathBuf, + + /// Configuration key to retrieve. + pub key: String, + + /// Default value to return if the key is not found. + pub default: Option, +} + +/// Payload provided to plugins **after** the `config get` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigGetPostPayload { + /// Configuration key that was retrieved. + pub key: String, + + /// Value associated with the key. + pub value: String, +} + +/// Payload provided to plugins **before** the `config set` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigSetPrePayload { + /// Path to the configuration file being modified. + pub config_file: PathBuf, + + /// Configuration key to set. + pub key: String, + + /// Value to assign to the key. + pub value: String, +} + +/// Payload provided to plugins **after** the `config set` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigSetPostPayload { + /// Configuration key that was set. + pub key: String, + + /// Value that was assigned to the key. + pub value: String, +} + +/// Payload provided to plugins **before** the `config unset` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigUnsetPrePayload { + /// Path to the configuration file being modified. + pub config_file: PathBuf, + + /// Configuration key to remove. + pub key: String, +} + +/// Payload provided to plugins **after** the `config unset` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigUnsetPostPayload { + /// Value that was previously associated with the key. + pub value: String, +} + +/// Payload provided to plugins **before** the `config list` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigListPrePayload { + /// Path to the configuration file being listed. + pub config_file: PathBuf, +} + +/// Payload provided to plugins **after** the `config list` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigListPostPayload { + /// List of all key-value pairs in the configuration file. + pub key_values: Vec<(String, String)>, +} diff --git a/plugins/src/models/payloads/init_payload.rs b/plugins/src/models/payloads/init_payload.rs new file mode 100644 index 0000000..b02ca7c --- /dev/null +++ b/plugins/src/models/payloads/init_payload.rs @@ -0,0 +1,17 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Payload provided to plugins **before** the `init` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct InitPrePayload { + /// Name of the initial branch to be created during repository initialization. + pub initial_branch: String, +} + +/// Payload provided to plugins **after** the `init` command execution. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct InitPostPayload { + /// Path to the newly initialized repository directory. + pub repository_dir: PathBuf, +} diff --git a/plugins/src/models/plugin_configuration.rs b/plugins/src/models/plugin_configuration.rs new file mode 100644 index 0000000..ab12f16 --- /dev/null +++ b/plugins/src/models/plugin_configuration.rs @@ -0,0 +1,90 @@ +use std::{ + fmt::{Display, Formatter, Result}, + path::PathBuf, +}; + +use serde::{Deserialize, Serialize}; +use shared::PrettyField; + +use crate::enums::EventType; + +/// Represents the configuration of a plugin within the system. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct PluginConfiguration { + /// Unique name of the plugin. + pub name: String, + + /// Optional human-readable description of the plugin. + pub description: Option, + + /// Relative path to the plugin's file on the filesystem. + pub file: PathBuf, + + /// The event type the plugin is registered to. + pub event: EventType, + + /// Execution order of the plugin relative to other plugins for the same event. + pub order: u32, + + /// Whether the plugin is enabled or disabled. + pub enabled: bool, + + /// Optional interpreter to use when executing the plugin (e.g., `python3`). + pub interpreter: Option, +} + +impl PluginConfiguration { + // Creates a new `PluginConfiguration` with the provided values. + pub fn new( + name: String, + description: Option, + file: PathBuf, + event: EventType, + order: u32, + enabled: bool, + interpreter: Option, + ) -> Self { + Self { + name, + description, + file, + event, + order, + enabled, + interpreter, + } + } +} + +impl Display for PluginConfiguration { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let labels = [ + "Description", + "File", + "Event", + "Order", + "Enabled", + "Interpreter", + ]; + let max_len = labels.iter().map(|s| s.len()).max().unwrap_or(0); + let indent = 2; + + writeln!(f, "Plugin: {}", self.name)?; + if let Some(desc) = &self.description { + f.field(indent, labels[0], desc, max_len)?; + } + f.field(indent, labels[1], self.file.display(), max_len)?; + f.field(indent, labels[2], format!("{:?}", self.event), max_len)?; + f.field(indent, labels[3], self.order, max_len)?; + f.field( + indent, + labels[4], + if self.enabled { "yes" } else { "no" }, + max_len, + )?; + if let Some(interp) = &self.interpreter { + f.field(indent, labels[5], interp, max_len)?; + } + Ok(()) + } +} diff --git a/plugins/src/models/plugin_entry.rs b/plugins/src/models/plugin_entry.rs new file mode 100644 index 0000000..8f6cd88 --- /dev/null +++ b/plugins/src/models/plugin_entry.rs @@ -0,0 +1,22 @@ +use std::path::PathBuf; + +use crate::models::PluginConfiguration; + +/// Represents a plugin along with its source file location. +pub struct PluginEntry { + /// The plugin's configuration metadata. + pub plugin: PluginConfiguration, + + /// The path to the plugin's source file on disk. + pub source_file: PathBuf, +} + +impl PluginEntry { + /// Creates a new `PluginEntry` from a plugin configuration and its source file path. + pub fn new(plugin: PluginConfiguration, source_file: PathBuf) -> Self { + Self { + plugin, + source_file, + } + } +} diff --git a/plugins/src/models/plugins_configuration.rs b/plugins/src/models/plugins_configuration.rs new file mode 100644 index 0000000..a000584 --- /dev/null +++ b/plugins/src/models/plugins_configuration.rs @@ -0,0 +1,123 @@ +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +use crate::{ + errors::{ConfigurationError, PluginError, PluginResult}, + models::PluginConfiguration, +}; + +/// Represents a collection of plugins loaded from a configuration file. +/// +/// This structure manages a list of `PluginConfiguration` entries and provides +/// methods for deserialization, validation, retrieval, and merging. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct PluginsConfiguration { + /// List of plugin configurations. + pub plugins: Vec, +} + +impl Default for PluginsConfiguration { + /// Creates an empty `PluginsConfiguration`. + fn default() -> Self { + Self { + plugins: Vec::new(), + } + } +} + +impl PluginsConfiguration { + /// Creates a `PluginsConfiguration` from a JSON string. + /// + /// Validates that plugin names and orders are unique. + pub fn from_json(json: &str) -> PluginResult { + let plugins_config: Self = serde_json::from_str(json)?; + plugins_config.validate_unique_names()?; + plugins_config.validate_unique_orders()?; + Ok(plugins_config) + } + + /// Retrieves a mutable reference to a plugin by name. + pub fn get_by_name(&mut self, name: &str) -> Option<&mut PluginConfiguration> { + self.plugins.iter_mut().find(|p| p.name == name) + } + + /// Retrieves an owned copy of a plugin by name. + pub fn get_by_name_owned(&self, name: &str) -> Option { + self.plugins.iter().find(|p| p.name == name).cloned() + } + + /// Adds a new plugin to the configuration, validating uniqueness. + pub fn push(&mut self, plugin: PluginConfiguration) -> PluginResult<()> { + Self::validate_name_not_present(&self.plugins, &plugin.name)?; + Self::validate_order_not_present(&self.plugins, plugin.order)?; + self.plugins.push(plugin); + Ok(()) + } + + /// Merges another `PluginsConfiguration` into this one, validating uniqueness. + pub fn merge(&self, other: &PluginsConfiguration) -> PluginResult { + let mut combined = Vec::with_capacity(self.plugins.len() + other.plugins.len()); + combined.extend(self.plugins.iter().cloned()); + combined.extend(other.plugins.iter().cloned()); + + let combined_config = PluginsConfiguration { plugins: combined }; + + combined_config.validate_unique_names()?; + combined_config.validate_unique_orders()?; + + Ok(combined_config) + } + + /// Validates that a given plugin name is not already present. + fn validate_name_not_present(plugins: &[PluginConfiguration], name: &str) -> PluginResult<()> { + if plugins.iter().any(|p| p.name == name) { + return Err(PluginError::Configuration( + ConfigurationError::DuplicateName { + name: name.to_string(), + }, + )); + } + Ok(()) + } + + /// Validates that a given plugin order is not already present. + fn validate_order_not_present(plugins: &[PluginConfiguration], order: u32) -> PluginResult<()> { + if plugins.iter().any(|p| p.order == order) { + return Err(PluginError::Configuration( + ConfigurationError::DuplicateOrder { order }, + )); + } + Ok(()) + } + + /// Ensures all plugin names are unique in the configuration. + fn validate_unique_names(&self) -> PluginResult<()> { + let mut seen: HashSet = HashSet::with_capacity(self.plugins.len()); + for plugin in self.plugins.iter() { + if !seen.insert(plugin.name.clone()) { + return Err(PluginError::Configuration( + ConfigurationError::DuplicateName { + name: plugin.name.clone(), + }, + )); + } + } + Ok(()) + } + + /// Ensures all plugin orders are unique in the configuration. + fn validate_unique_orders(&self) -> PluginResult<()> { + let mut seen: HashSet = HashSet::with_capacity(self.plugins.len()); + for plugin in self.plugins.iter() { + if !seen.insert(plugin.order) { + return Err(PluginError::Configuration( + ConfigurationError::DuplicateOrder { + order: plugin.order, + }, + )); + } + } + Ok(()) + } +} diff --git a/plugins/src/plugins_discovery.rs b/plugins/src/plugins_discovery.rs new file mode 100644 index 0000000..433ccb0 --- /dev/null +++ b/plugins/src/plugins_discovery.rs @@ -0,0 +1,86 @@ +use std::{ + fs, + io::ErrorKind, + path::{Path, PathBuf}, +}; + +use crate::{ + CommandType, PluginError, PluginsLayout, errors::PluginResult, models::PluginsConfiguration, +}; + +/// Responsible for discovering and loading plugins from local and global directories. +/// +/// `PluginsDiscovery` uses a layout (`PluginsLayout`) to locate plugin files and directories, +/// read their configuration, and provide structured plugin lists for a given command. +pub struct PluginsDiscovery { + /// Layout defining standard plugin directory and file names. + pub layout: T, +} + +impl PluginsDiscovery { + /// Creates a new `PluginsDiscovery` instance with the given layout. + pub fn new(layout: T) -> Self { + Self { layout } + } + + /// Retrieves plugins for a specific command, returning local and global plugins separately. + pub fn get_plugins_for_command( + &self, + local_plugins_dir: &Option, + global_plugins_dir: &Path, + command: &CommandType, + ) -> PluginResult<(PluginsConfiguration, PluginsConfiguration)> { + let local_plugins = match local_plugins_dir { + Some(plugins_dir) => { + let local_command_dir = self.get_command_dir(command, plugins_dir)?; + let local_plugins_file = local_command_dir.join(self.layout.plugins_file_name()); + let (local_plugins, _) = self.get_plugins(&local_plugins_file)?; + local_plugins + } + None => PluginsConfiguration::default(), + }; + + let global_command_dir = self.get_command_dir(command, global_plugins_dir)?; + let global_plugins_file = global_command_dir.join(self.layout.plugins_file_name()); + let (global_plugins, _) = self.get_plugins(&global_plugins_file)?; + + Ok((local_plugins, global_plugins)) + } + + /// Loads plugins from a given plugins JSON file. + pub fn get_plugins(&self, plugins_file: &Path) -> PluginResult<(PluginsConfiguration, bool)> { + match fs::read_to_string(plugins_file) { + Ok(content) => Ok((PluginsConfiguration::from_json(&content)?, true)), + Err(ref err) if err.kind() == ErrorKind::NotFound => { + Ok((PluginsConfiguration::default(), false)) + } + Err(err) => Err(PluginError::Io(err)), + } + } + + /// Returns the directory path for a specific command, creating it if necessary. + pub fn get_command_dir( + &self, + command: &CommandType, + plugins_dir: &Path, + ) -> PluginResult { + let command_str = command.to_string(); + let command_dir = plugins_dir.join(command_str); + + fs::create_dir_all(&command_dir).map_err(PluginError::Io)?; + + Ok(command_dir) + } + + /// Returns the command directory, plugins file path, and loaded plugins for a command. + pub fn get_command_dir_and_plugins( + &self, + command: &CommandType, + plugins_dir: &Path, + ) -> PluginResult<(PathBuf, PathBuf, PluginsConfiguration)> { + let command_dir = self.get_command_dir(command, plugins_dir)?; + let plugins_file = command_dir.join(self.layout.plugins_file_name()); + let (plugins, _) = self.get_plugins(&plugins_file)?; + Ok((command_dir, plugins_file, plugins)) + } +} diff --git a/plugins/src/plugins_engine.rs b/plugins/src/plugins_engine.rs new file mode 100644 index 0000000..b5fcf75 --- /dev/null +++ b/plugins/src/plugins_engine.rs @@ -0,0 +1,252 @@ +use std::{ + fs::{self}, + io::Write, + path::{Path, PathBuf}, +}; + +use chrono::{DateTime, Utc}; +use shared::fs::create_file_with_dirs; + +use crate::{ + CommandType, EventType, PluginsConfiguration, PluginsLayout, PluginsRepository, + errors::{ConfigurationError, PluginError, PluginResult}, + models::{InvocationInput, InvocationLogger, PluginEntry}, + plugins_discovery::PluginsDiscovery, +}; +use crate::{errors::RegisterError, models::PluginConfiguration}; + +/// Core engine responsible for managing plugins and their invocations +pub struct PluginsEngine { + /// Discovery component used to locate plugins in local and global directories. + pub discovery: PluginsDiscovery, +} + +impl PluginsEngine { + /// Retrieves plugins configured for a specific command, separating local and global plugins. + pub fn get_plugins_for_command( + &self, + local_plugins_dir: &Option, + global_plugins_dir: &Path, + command: &CommandType, + ) -> PluginResult<(PluginsConfiguration, PluginsConfiguration)> { + self.discovery + .get_plugins_for_command(local_plugins_dir, global_plugins_dir, command) + } + + // Creates a directory for a plugin invocation, structured by timestamp and command. + pub fn create_invocation_dir( + &self, + timestamp: &DateTime, + plugins_dir: &Path, + command: &CommandType, + ) -> PluginResult { + let formatted_timestamp = self.formatted_timestamp(timestamp); + let invocation_dir = plugins_dir + .join(self.discovery.layout.invocations_dir_name()) + .join(command.to_string()) + .join(formatted_timestamp); + + fs::create_dir_all(&invocation_dir)?; + + Ok(invocation_dir) + } + + /// Creates the invocation JSON file to pass to a plugin. + pub fn create_invocation_file( + &self, + invocation: &InvocationInput, + invocation_dir: &PathBuf, + ) -> PluginResult { + fs::create_dir_all(invocation_dir)?; + + let context_file = invocation_dir.join( + self.discovery + .layout + .context_file_name(&invocation.context.event), + ); + let mut context = fs::File::create(&context_file)?; + let json = serde_json::to_string_pretty(&invocation)?; + context.write_all(json.as_bytes())?; + + Ok(context_file) + } + + /// Creates a logger for capturing stdout, stderr, and invocation logs. + pub fn create_logger( + &self, + invocation_dir: &Path, + log_to_files: bool, + ) -> PluginResult { + let logger = if log_to_files { + InvocationLogger::from_paths( + &invocation_dir.join(self.discovery.layout.stdout_log_name()), + &invocation_dir.join(self.discovery.layout.stderr_log_name()), + &invocation_dir.join(self.discovery.layout.invocation_log_name()), + )? + } else { + InvocationLogger::ConsoleOnly + }; + + Ok(logger) + } + + /// Formats a timestamp into a string suitable for directory or file names. + fn formatted_timestamp(&self, timestamp: &DateTime) -> String { + timestamp.format("%Y%m%d-%H%M%S").to_string() + } +} + +impl PluginsRepository for PluginsEngine { + fn register( + &self, + plugin: PluginConfiguration, + command: &CommandType, + plugins_dir: &Path, + source_file: &Path, + ) -> PluginResult<(PathBuf, PathBuf)> { + let (command_dir, plugins_file, mut plugins) = self + .discovery + .get_command_dir_and_plugins(command, plugins_dir)?; + + let dest_file = command_dir.join(&plugin.file); + create_file_with_dirs(&dest_file)?; + + plugins.push(plugin)?; + + let json = serde_json::to_string_pretty(&plugins)?; + + fs::write(&plugins_file, json)?; + fs::copy(source_file, &dest_file)?; + + Ok((plugins_file, dest_file)) + } + + fn unregister( + &self, + command: &CommandType, + name: &str, + plugins_dir: &Path, + ) -> PluginResult<(PathBuf, PathBuf)> { + let (command_dir, plugins_file, mut plugins) = self + .discovery + .get_command_dir_and_plugins(command, plugins_dir)?; + + let position = plugins + .plugins + .iter() + .position(|p| p.name == name) + .ok_or_else(|| { + PluginError::Configuration(ConfigurationError::NameNotFound { + name: name.to_string(), + }) + })?; + + let plugin_to_remove = plugins.plugins.remove(position); + + let json = serde_json::to_string_pretty(&plugins)?; + fs::write(&plugins_file, json)?; + + let script_path = command_dir.join(&plugin_to_remove.file); + if script_path.exists() { + fs::remove_file(&script_path)?; + } + + Ok((plugins_file, script_path)) + } + + fn info( + &self, + command: &CommandType, + name: &str, + plugins_dir: &Path, + ) -> PluginResult { + let (command_dir, _, plugins) = self + .discovery + .get_command_dir_and_plugins(command, plugins_dir)?; + + let plugin = plugins.get_by_name_owned(name).ok_or_else(|| { + PluginError::Configuration(ConfigurationError::NameNotFound { + name: name.to_string(), + }) + })?; + + let source_code_file = command_dir.join(&plugin.file); + + if !source_code_file.is_file() { + return Err(PluginError::Register(RegisterError::InvalidFilePath { + path: plugin.file.clone(), + })); + } + + Ok(PluginEntry::new(plugin, source_code_file)) + } + + fn update_enabled( + &self, + command: &CommandType, + name: &str, + plugins_dir: &Path, + value: Option, + ) -> PluginResult { + if value.is_none() { + return self.info(command, name, plugins_dir); + } + + let (command_dir, plugins_file, mut plugins) = self + .discovery + .get_command_dir_and_plugins(command, plugins_dir)?; + + let plugin = { + let plugin = plugins.get_by_name(name).ok_or_else(|| { + PluginError::Configuration(ConfigurationError::NameNotFound { + name: name.to_string(), + }) + })?; + + plugin.enabled = value.unwrap(); + plugin.clone() + }; + + let json = serde_json::to_string_pretty(&plugins)?; + fs::write(&plugins_file, json)?; + + let source_code_file = command_dir.join(&plugin.file); + if !source_code_file.is_file() { + return Err(PluginError::Register(RegisterError::InvalidFilePath { + path: plugin.file.clone(), + })); + } + + Ok(PluginEntry::new(plugin, source_code_file)) + } + + fn list( + &self, + command: &CommandType, + event: &EventType, + plugins_dir: &Path, + include_enabled: bool, + include_disabled: bool, + ) -> PluginResult> { + let (command_dir, _, plugins) = self + .discovery + .get_command_dir_and_plugins(command, plugins_dir)?; + + let mut entries = Vec::new(); + + for plugin in &plugins.plugins { + if plugin.event != *event { + continue; + } + + if (plugin.enabled && include_enabled) || (!plugin.enabled && include_disabled) { + let script_path = &plugin.file; + let source_file = command_dir.join(script_path); + + entries.push(PluginEntry::new(plugin.clone(), source_file)); + } + } + + Ok(entries) + } +} diff --git a/plugins/src/plugins_repository.rs b/plugins/src/plugins_repository.rs new file mode 100644 index 0000000..84a6c74 --- /dev/null +++ b/plugins/src/plugins_repository.rs @@ -0,0 +1,108 @@ +use std::path::{Path, PathBuf}; + +use crate::{ + CommandType, EventType, + errors::PluginResult, + models::{PluginConfiguration, PluginEntry}, +}; + +/// Defines the interface for managing plugins within a repository. +pub trait PluginsRepository { + /// Registers a plugin in the repository. + /// + /// # Arguments + /// + /// * `plugin` - The plugin configuration to register. + /// * `command` - The command this plugin is associated with. + /// * `plugins_dir` - Directory where plugins for this command are stored. + /// * `source_file` - Path to the plugin file to be copied or linked. + /// + /// # Returns + /// + /// A tuple containing the paths to the registered plugin file and configuration file. + fn register( + &self, + plugin: PluginConfiguration, + command: &CommandType, + plugins_dir: &Path, + source_file: &Path, + ) -> PluginResult<(PathBuf, PathBuf)>; + + /// Unregisters a plugin from the repository. + /// + /// # Arguments + /// + /// * `command` - The command this plugin is associated with. + /// * `name` - The name of the plugin to remove. + /// * `plugins_dir` - Directory where plugins for this command are stored. + /// + /// # Returns + /// + /// A tuple containing the paths of the removed plugin file and configuration file. + fn unregister( + &self, + command: &CommandType, + name: &str, + plugins_dir: &Path, + ) -> PluginResult<(PathBuf, PathBuf)>; + + /// Retrieves detailed information about a specific plugin. + /// + /// # Arguments + /// + /// * `command` - The command this plugin is associated with. + /// * `name` - The name of the plugin. + /// * `plugins_dir` - Directory where plugins for this command are stored. + /// + /// # Returns + /// + /// A `PluginEntry` containing the plugin configuration and source file path. + fn info( + &self, + command: &CommandType, + name: &str, + plugins_dir: &Path, + ) -> PluginResult; + + /// Lists plugins for a specific command and event. + /// + /// # Arguments + /// + /// * `command` - The command to list plugins for. + /// * `event` - The event type (`PreExecute` or `PostExecute`) to filter by. + /// * `plugins_dir` - Directory where plugins for this command are stored. + /// * `include_enabled` - Whether to include enabled plugins. + /// * `include_disabled` - Whether to include disabled plugins. + /// + /// # Returns + /// + /// A vector of `PluginEntry` for the matching plugins. + fn list( + &self, + command: &CommandType, + event: &EventType, + plugins_dir: &Path, + include_enabled: bool, + include_disabled: bool, + ) -> PluginResult>; + + /// Updates the enabled state of a specific plugin. + /// + /// # Arguments + /// + /// * `command` - The command this plugin is associated with. + /// * `name` - The name of the plugin. + /// * `plugins_dir` - Directory where plugins for this command are stored. + /// * `value` - New enabled state for the plugin. + /// + /// # Returns + /// + /// A `PluginEntry` reflecting the updated plugin configuration. + fn update_enabled( + &self, + command: &CommandType, + name: &str, + plugins_dir: &Path, + value: Option, + ) -> PluginResult; +} diff --git a/plugins/src/plugins_runner.rs b/plugins/src/plugins_runner.rs new file mode 100644 index 0000000..7bee4e2 --- /dev/null +++ b/plugins/src/plugins_runner.rs @@ -0,0 +1,210 @@ +use std::{ + io::{self, BufRead, BufReader, Read, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +use wait_timeout::ChildExt; + +use crate::{ + EventType, PluginError, + errors::PluginResult, + models::{InvocationLogger, InvocationOutput, PluginConfiguration, PluginsConfiguration}, +}; + +/// Responsible for executing plugins for a given command/event. +/// +/// `PluginsRunner` handles running both local and global plugins, +/// capturing stdout/stderr, logging execution details, and enforcing +/// execution order and timeouts. +pub struct PluginsRunner { + /// Path to the invocation JSON file passed to plugins. + pub invocation_file: PathBuf, + + /// Directory where plugin files are located. + command_dir: PathBuf, + + /// Logger used to capture stdout, stderr, and invocation metadata. + logger: InvocationLogger, + + /// Timeout which prevents plugins from running for too long. + timeout: u64, +} + +impl PluginsRunner { + /// Creates a new `PluginsRunner` instance. + pub fn new( + invocation_file: PathBuf, + command_dir: PathBuf, + logger: InvocationLogger, + timeout: u64, + ) -> Self { + Self { + invocation_file, + command_dir, + logger, + timeout, + } + } + + /// Executes a single plugin and captures its output. + pub fn run_plugin(&mut self, plugin: PluginConfiguration) -> PluginResult { + let source_file = self.command_dir.join(&plugin.file); + + if !source_file.is_file() { + return Err(PluginError::FileNotFound { path: source_file }); + } + + let mut command = self.build_command(&plugin, &source_file)?; + + let start = Instant::now(); + + let mut child = command.spawn()?; + + let stdout_handle = child + .stdout + .take() + .map(|out| self.spawn_stream_reader(out, false)); + + let stderr_handle = child + .stderr + .take() + .map(|err| self.spawn_stream_reader(err, true)); + + let timeout = self.get_timeout_duration(); + let timed_out; + + let status_code = match child.wait_timeout(timeout)? { + Some(status) => { + timed_out = false; + status.code().unwrap_or(-1) + } + None => { + timed_out = true; + let _ = child.kill(); + child.wait()?.code().unwrap_or(-1) + } + }; + + let duration_ms = start.elapsed().as_millis(); + + if let Some(h) = stdout_handle { + let out = h.join().unwrap_or_default(); + self.logger.log_stdout(&out)?; + } + if let Some(h) = stderr_handle { + let err = h.join().unwrap_or_default(); + self.logger.log_stderr(&err)?; + } + + let output = InvocationOutput { + plugin: plugin.name.clone(), + status_code, + duration_ms, + timed_out, + }; + + self.logger.log_invocation(&plugin, &output)?; + + Ok(output) + } + + /// Executes all plugins registered for a specific event, combining + /// local and global plugins, respecting order and enabled state. + pub fn run_plugins_for_event( + &mut self, + local_plugins: &PluginsConfiguration, + global_plugins: &PluginsConfiguration, + event: &EventType, + ) -> PluginResult> { + let mut plugins: Vec = local_plugins + .plugins + .iter() + .chain(global_plugins.plugins.iter()) + .filter(|p| p.enabled && p.event == *event) + .cloned() + .collect(); + + plugins.sort_by_key(|p| p.order); + + let mut results = Vec::new(); + + for plugin in plugins { + let result = self.run_plugin(plugin)?; + if result.is_error() { + results.push(result); + break; + } + results.push(result); + } + + Ok(results) + } + + /// Builds a `Command` to run the plugin, optionally using an interpreter. + fn build_command( + &self, + plugin: &PluginConfiguration, + source_file: &Path, + ) -> PluginResult { + let mut command = if let Some(ref interpreter) = plugin.interpreter { + let mut cmd = Command::new(interpreter); + cmd.arg(source_file); + cmd + } else { + Command::new(source_file) + }; + + command + .arg(&self.invocation_file) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .stdin(Stdio::inherit()); + + Ok(command) + } + + /// Spawns a thread that reads from `stream` line-by-line, prints each line + /// immediately to stdout or stderr (controlled by `to_stderr`) and collects + /// the whole output into a `String` returned by the JoinHandle. + fn spawn_stream_reader(&self, stream: R, to_stderr: bool) -> thread::JoinHandle + where + R: Read + Send + 'static, + { + thread::spawn(move || { + let mut reader = BufReader::new(stream); + let mut collected = String::new(); + let mut line = String::new(); + + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => break, // EOF + Ok(_) => { + if to_stderr { + eprint!("{line}"); + let _ = io::stderr().flush(); + } else { + print!("{line}"); + let _ = io::stdout().flush(); + } + collected.push_str(&line); + } + Err(e) => { + eprintln!("Error reading stream: {e}"); + break; + } + } + } + + collected + }) + } + + /// Returns the timeout duration for plugin execution. + fn get_timeout_duration(&self) -> Duration { + Duration::from_millis(self.timeout) + } +} diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 4259774..754c9e7 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -7,6 +7,7 @@ authors.workspace = true [dependencies] tempfile.workspace = true up_finder.workspace = true +editor-command.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/shared/src/extensions.rs b/shared/src/extensions.rs index ecc3e20..b9de664 100644 --- a/shared/src/extensions.rs +++ b/shared/src/extensions.rs @@ -1,5 +1,7 @@ pub mod fs; +pub mod open_in_editor; pub mod upward_search; pub use fs::create_file_with_dirs; +pub use open_in_editor::OpenInEditor; pub use upward_search::UpwardSearch; diff --git a/shared/src/extensions/fs.rs b/shared/src/extensions/fs.rs index e649fac..f7a9bc5 100644 --- a/shared/src/extensions/fs.rs +++ b/shared/src/extensions/fs.rs @@ -1,20 +1,40 @@ -use std::{fs, io, path::Path}; +use std::{ + fs::{self, File, OpenOptions}, + io, + path::Path, +}; /// Creates a file at the given path, ensuring all parent directories exist. -/// Returns an `io::Error` if creating parent directories or the file itself fails. -pub fn create_file_with_dirs>(path: P) -> io::Result<()> { +/// +/// # Returns +/// +/// * `(File, true)` – if the file was newly created +/// * `(File, false)` – if the file already existed (its contents are preserved) +/// +/// # Errors +/// +/// Returns an [`io::Error`] if creating parent directories or opening the file fails. +pub fn create_file_with_dirs>(path: P) -> io::Result<(File, bool)> { let path = path.as_ref(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - fs::File::create(path)?; - Ok(()) + + match OpenOptions::new().write(true).create_new(true).open(path) { + Ok(file) => Ok((file, true)), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + let file = OpenOptions::new().write(true).open(path)?; + Ok((file, false)) + } + Err(e) => Err(e), + } } #[cfg(test)] mod tests { use super::*; use rstest::rstest; + use std::io::Write; use tempfile::TempDir; #[rstest] @@ -22,8 +42,32 @@ mod tests { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("a").join("b").join("c.txt"); - let result = create_file_with_dirs(&path); - assert!(result.is_ok()); - assert!(path.exists()); + let (file, created) = create_file_with_dirs(&path).unwrap(); + + assert!(path.exists(), "File should exist after creation"); + assert!(created, "File should be marked as newly created"); + drop(file); + } + + #[rstest] + fn returns_false_if_file_already_exists() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("d.txt"); + + { + let (_f, created) = create_file_with_dirs(&path).unwrap(); + assert!(created); + } + + { + let mut f = File::create(&path).unwrap(); + writeln!(f, "hello").unwrap(); + } + + let (_f, created_again) = create_file_with_dirs(&path).unwrap(); + assert!(!created_again, "File already existed, should return false"); + + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(contents.contains("hello")); } } diff --git a/cli/src/extensions/path/open_in_editor.rs b/shared/src/extensions/open_in_editor.rs similarity index 83% rename from cli/src/extensions/path/open_in_editor.rs rename to shared/src/extensions/open_in_editor.rs index f8890ec..4637f32 100644 --- a/cli/src/extensions/path/open_in_editor.rs +++ b/shared/src/extensions/open_in_editor.rs @@ -1,7 +1,9 @@ -use std::path::Path; +use std::{ + io::{Error, Result}, + path::Path, +}; use editor_command::EditorBuilder; -use miette::{IntoDiagnostic, Result, WrapErr}; /// Trait to open a file or directory in the user’s preferred text editor. /// @@ -37,21 +39,19 @@ impl> OpenInEditor for P { let mut cmd = builder .build() - .into_diagnostic() - .wrap_err("Failed to build editor command")?; + .map_err(|e| Error::other(format!("Failed to build editor command: {e}")))?; cmd.arg(self.as_ref()); let status = cmd .status() - .into_diagnostic() - .wrap_err("Failed to launch the editor")?; + .map_err(|e| Error::other(format!("Failed to launch the editor: {e}")))?; if !status.success() { - return Err(miette::miette!( + return Err(Error::other(format!( "Editor returned error code: {}", status.code().unwrap_or(-1) - )); + ))); } Ok(()) diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 1e5d68a..5034db1 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -1,3 +1,6 @@ +mod pretty_field; + pub mod extensions; pub use extensions::{UpwardSearch, fs}; +pub use pretty_field::PrettyField; diff --git a/shared/src/pretty_field.rs b/shared/src/pretty_field.rs new file mode 100644 index 0000000..807b28e --- /dev/null +++ b/shared/src/pretty_field.rs @@ -0,0 +1,12 @@ +use std::fmt::{Display, Formatter, Result}; + +pub trait PrettyField { + fn field(&mut self, indent: usize, label: &str, value: T, width: usize) -> Result; +} + +impl<'a> PrettyField for Formatter<'a> { + fn field(&mut self, indent: usize, label: &str, value: T, width: usize) -> Result { + let padding = " ".repeat(indent); + writeln!(self, "{padding}{label:width$} : {value}") + } +}