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

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

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

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

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

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

(&self, checked_path: &P, path: Option<&P>) -> EngineResult + where + P: AsRef, + { + let ignore_file = match path { + Some(p) => p.as_ref().to_path_buf(), + None => self.find_ignore_file(None)?, + }; + + let file = fs::File::open(&ignore_file)?; + let reader = BufReader::new(file); + + let mut builder = GlobSetBuilder::new(); + let mut patterns: Vec = Vec::new(); + + for line_result in reader.lines() { + let line = line_result?; + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + patterns.push(trimmed.to_string()); + + let glob = Glob::new(trimmed).map_err(IgnoreError::Glob)?; + builder.add(glob); + } + + let set = builder.build().map_err(IgnoreError::Glob)?; + + let matches = set.matches(checked_path); + let matched_patterns: Vec = + matches.into_iter().map(|m| patterns[m].clone()).collect(); + + Ok(IgnoreResult::from_matches(matched_patterns, &ignore_file)) + } + + fn find_ignore_file(&self, starting_path: Option<&Path>) -> EngineResult { + let search_path = if let Some(path) = starting_path { + path.to_path_buf() + } else { + env::current_dir()? + }; + + search_path.search_file_up(&self.ignore_file).ok_or( + IgnoreError::IgnoreNotFound { + path: self.ignore_file.clone(), + } + .into(), + ) + } +} diff --git a/engine/src/lib.rs b/engine/src/lib.rs index bd785df..fab8f7b 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,8 +1,10 @@ pub mod config; pub mod errors; +pub mod ignore; pub mod repositories; use errors::{EngineError, EngineResult, InitError}; pub use config::{ConfigDocument, ConfigLoader, ConfigLocation, ConfigOperations}; +pub use ignore::{IgnoreOperations, IgnoreResult, IgnoreService}; pub use repositories::{MevaRepository, RepositoryLayout}; diff --git a/engine/src/repositories/meva_repository.rs b/engine/src/repositories/meva_repository.rs index 308de4e..574fb21 100644 --- a/engine/src/repositories/meva_repository.rs +++ b/engine/src/repositories/meva_repository.rs @@ -136,6 +136,8 @@ impl RepositoryLayout for MevaRepository { const CONFIG_FILE: &'static str = "mevaconfig"; + const IGNORE_FILE: &'static str = ".mevaignore"; + /// Returns the repository’s working directory path. fn working_dir(&self) -> &std::path::Path { &self.working_dir diff --git a/engine/src/repositories/repository_layout.rs b/engine/src/repositories/repository_layout.rs index 2825767..759f51e 100644 --- a/engine/src/repositories/repository_layout.rs +++ b/engine/src/repositories/repository_layout.rs @@ -27,6 +27,9 @@ pub trait RepositoryLayout { /// Name of the config file. const CONFIG_FILE: &'static str; + /// Name of the ignore file. + const IGNORE_FILE: &'static str; + /// Returns the working directory where the repository is located. fn working_dir(&self) -> &Path; @@ -151,6 +154,7 @@ mod tests { const HEAD_FILE: &'static str = "HEAD"; const CONFIG_FILE: &'static str = "mevaconfig"; + const IGNORE_FILE: &'static str = ".mevaignore"; fn working_dir(&self) -> &Path { &self.dir diff --git a/shared/src/extensions/upward_search.rs b/shared/src/extensions/upward_search.rs index 18fbc5e..da417b5 100644 --- a/shared/src/extensions/upward_search.rs +++ b/shared/src/extensions/upward_search.rs @@ -48,3 +48,93 @@ impl> UpwardSearch for P { .next() } } + +#[cfg(test)] +mod tests { + use std::fs::{self, File}; + + use super::*; + use rstest::rstest; + use tempfile::TempDir; + + fn make_nested_dirs(base: &TempDir, parts: &[&str]) -> PathBuf { + let mut path = base.path().to_path_buf(); + for p in parts { + path.push(p); + fs::create_dir(&path).unwrap(); + } + path + } + + #[rstest] + fn search_files_up_returns_matches_in_order() { + let tmp = TempDir::new().unwrap(); + let start = make_nested_dirs(&tmp, &["a", "b", "c"]); + + let mut f2 = start.parent().unwrap().to_path_buf(); + f2.push("f.txt"); + File::create(&f2).unwrap(); + let f1 = tmp.path().join("a").join("f.txt"); + File::create(&f1).unwrap(); + + let results = start.search_files_up("f.txt"); + let got: Vec = results + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + let want = vec![ + f2.to_string_lossy().into_owned(), + f1.to_string_lossy().into_owned(), + ]; + assert_eq!(got, want); + } + + #[rstest] + fn search_file_up_returns_only_nearest_match() { + let tmp = TempDir::new().unwrap(); + let start = make_nested_dirs(&tmp, &["a", "b", "c"]); + + let bfile = start.parent().unwrap().join("f.log"); + File::create(&bfile).unwrap(); + + let afile = tmp.path().join("a").join("f.log"); + File::create(&afile).unwrap(); + + let found = start.search_file_up("f.log").unwrap(); + assert_eq!(found, bfile); + } + + #[rstest] + fn search_files_up_when_no_matches_returns_empty_vec() { + let tmp = TempDir::new().unwrap(); + let start = make_nested_dirs(&tmp, &["a", "b"]); + let results = start.search_files_up("f.txt"); + assert_eq!(results.len(), 0); + } + + #[rstest] + fn search_file_up_when_no_matches_returns_none() { + let tmp = TempDir::new().unwrap(); + let start = make_nested_dirs(&tmp, &["a", "b"]); + assert!(start.search_file_up("f.txt").is_none()); + } + + #[rstest] + fn search_dir_up_returns_nearest_directory() { + let tmp = TempDir::new().unwrap(); + let start = make_nested_dirs(&tmp, &["a", "b", "c"]); + let p2 = tmp.path().join("a").join("b").join("d"); + fs::create_dir(&p2).unwrap(); + let p1 = tmp.path().join("a").join("d"); + fs::create_dir(&p1).unwrap(); + let found = start.search_dir_up("d").unwrap(); + assert_eq!(found, p2); + } + + #[test] + fn search_dir_up_when_no_matches_returns_none() { + let tmp = TempDir::new().unwrap(); + let start = make_nested_dirs(&tmp, &["a"]); + assert!(start.search_dir_up("b").is_none()); + } +}