diff --git a/Cargo.lock b/Cargo.lock index a386ad6..4a66099 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,72 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ast-grep-config" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45423a2c4402b727ca88d9f13531f8b08f5bfab4c1576096ec4189a5ac412c0b" +dependencies = [ + "ast-grep-core", + "bit-set", + "globset", + "regex", + "schemars", + "serde", + "serde_yaml", + "thiserror", +] + +[[package]] +name = "ast-grep-core" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4ae49b5c42878311768f4cdd576ef470c6e45c3105d558af928fd04ac8c588" +dependencies = [ + "bit-set", + "regex", + "thiserror", + "tree-sitter", +] + +[[package]] +name = "ast-grep-language" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fccbced91e848baf5d25278972bfc18b2248c38e411dcfeb65e431a5b530a5c6" +dependencies = [ + "ast-grep-core", + "ignore", + "serde", + "tree-sitter", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-c-sharp", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-dart", + "tree-sitter-elixir", + "tree-sitter-go", + "tree-sitter-haskell", + "tree-sitter-hcl", + "tree-sitter-html", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-json", + "tree-sitter-kotlin-sg", + "tree-sitter-lua", + "tree-sitter-nix", + "tree-sitter-php", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-scala", + "tree-sitter-solidity", + "tree-sitter-swift", + "tree-sitter-typescript", + "tree-sitter-yaml", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -135,6 +201,24 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.10.0" @@ -2176,6 +2260,8 @@ version = "3.4.0" dependencies = [ "anyhow", "assert_cmd", + "ast-grep-config", + "ast-grep-language", "clap", "clap_complete_command", "cocogitto", @@ -2185,12 +2271,15 @@ dependencies = [ "emojis", "git2", "gix", + "globset", "indexmap", "inquire", "predicates", + "regex", "rexpect", "serde", "tempfile", + "thiserror", "xdg", ] @@ -2747,6 +2836,26 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.2" @@ -2860,6 +2969,31 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2914,12 +3048,24 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ + "indexmap", "itoa", "memchr", "ryu", @@ -2945,6 +3091,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3077,6 +3236,12 @@ dependencies = [ "thread_local", ] +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strsim" version = "0.11.1" @@ -3290,6 +3455,286 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tree-sitter" +version = "0.26.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "887bd495d0582c5e3e0d8ece2233666169fa56a9644d172fc22ad179ab2d0538" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-bash" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5ec769279cc91b561d3df0d8a5deb26b0ad40d183127f409494d6d8fc53062" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-css" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cbc5e18f29a2c6d6435891f42569525cf95435a3e01c2f1947abcde178686f" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-dart" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bba6bf8675e6fe92ba6da371a5497ee5df2a04d2c503e3599c8ad771f6f1faec" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-elixir" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66dd064a762ed95bfc29857fa3cb7403bb1e5cb88112de0f6341b7e47284ba40" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-haskell" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977c51e504548cba13fc27cb5a2edab2124cf6716a1934915d07ab99523b05a4" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-hcl" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a7b2cc3d7121553b84309fab9d11b3ff3d420403eef9ae50f9fd1cd9d9cf012" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-html" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-json" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86a5d6b3ea17e06e7a34aabeadd68f5866c0d0f9359155d432095f8b751865e4" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-kotlin-sg" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0e175b7530765d1e36ad234a7acaa8b2a3316153f239d724376c7ee5e8d8e98" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-lua" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8daaf5f4235188a58603c39760d5fa5d4b920d36a299c934adddae757f32a10c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-nix" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4952a9733f3a98f6683a0ccd1035d84ab7a52f7e84eeed58548d86765ad92de3" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-php" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-scala" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3394d6bc99bceae03c75482a93f1bcefff11e69d3a405f1410e864212b52739a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-solidity" +version = "1.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eacf8875b70879f0cb670c60b233ad0b68752d9e1474e6c3ef168eea8a90b25" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-swift" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef216011c3e3df4fa864736f347cb8d509b1066cf0c8549fb1fd81ac9832e59" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-yaml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "typeid" version = "1.0.3" @@ -3350,6 +3795,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "url" version = "2.5.7" diff --git a/Cargo.toml b/Cargo.toml index 5ed5ebd..1a52f3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,8 +6,8 @@ authors = [ "Finley Thomalla ", "Danny Tatom ", ] -description = "An interactive CLI for creating conventional commits." documentation = "https://docs.rs/koji" +description = "An interactive CLI for creating conventional commits." repository = "https://github.com/cococonscious/koji" license = "MIT" @@ -26,19 +26,24 @@ cocogitto = { version = "6.5", default-features = false } conventional_commit_parser = "0.9" dirs = "6.0" emojis = "0.8" +globset = "0.4" indexmap = "2.10" serde = { version = "1.0", features = ["derive"] } inquire = "0.9" +regex = "1.12" +thiserror = "2" clap_complete_command = { version = "0.6", features = ["nushell"]} config = { version = "0.15", features = ["toml"] } xdg = "3.0" gix = "0.80.0" +ast-grep-config = { version = "0.42.1" } +ast-grep-language = { version = "0.42.1" } [dev-dependencies] +git2 = "0.20.4" assert_cmd = "2.0.16" predicates = "3.1.2" tempfile = "3.12.0" -git2 = "0.20.3" [target.'cfg(not(windows))'.dev-dependencies] rexpect = "0.6.2" diff --git a/README.md b/README.md index caac06f..7f1681f 100644 --- a/README.md +++ b/README.md @@ -191,3 +191,27 @@ emoji = true issues = true ``` +#### `scope-patterns` + +- Type: `table` +- Optional: `true` +- Description: Pre-assign commit scopes from staged paths. Patterns are matched against repo-relative paths prefixed with `/`, and each entry can be a single regex/glob string or a list of patterns. +```toml +[scope_patterns] +flakes = "/flake\\.nix$" +core = "/crates/core/**/*.rs" +build = ["^/build\\.rs$", "/justfile"] +``` + +#### Ast-grep Scope Config + +- Location: In the normal Koji config file alongside the rest of the scope config +- Optional: `true` +- Description: Ast-grep rules can pre-assign commit scopes from structural code matches. +```toml +[[scope_ast_grep]] +scope = "test" +language = "Rust" +files = ["**/*.rs"] +rule = { kind = "function_item", has = { stopBy = "end", pattern = "#[test]" } } +``` diff --git a/meta/config/default.toml b/meta/config/default.toml index 8d0b072..7d312dc 100644 --- a/meta/config/default.toml +++ b/meta/config/default.toml @@ -3,6 +3,8 @@ breaking_changes = true issues = true emoji = false sign = false +force_scope = false +allow_empty_scope = true [[commit_types]] name = "feat" diff --git a/src/bin/main.rs b/src/bin/main.rs index ee3b42c..271a309 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -9,6 +9,7 @@ use koji::answers::{get_extracted_answers, ExtractedAnswers}; use koji::commit::{commit, generate_commit_msg, write_commit_msg}; use koji::config::{Config, ConfigArgs}; use koji::questions::{create_prompt, prompt_confirm}; +use koji::scope::detect_scope_matches; use koji::status::{check_staging, StagingStatus}; #[derive(Parser, Debug)] @@ -193,10 +194,13 @@ fn main() -> Result<()> { sign, _user_config_path: None, _current_dir: Some(current_dir.clone()), + ..Default::default() }))?; + let scope_matches = detect_scope_matches(&repo, &config)?; + // Get answers from interactive prompt - let answers = create_prompt(commit_message, &config)?; + let answers = create_prompt(commit_message, &config, &scope_matches)?; // Get data necessary for a conventional commit let ExtractedAnswers { diff --git a/src/lib/config.rs b/src/lib/config.rs index 0ca30c6..4a3e014 100644 --- a/src/lib/config.rs +++ b/src/lib/config.rs @@ -4,6 +4,7 @@ use dirs::config_dir; use indexmap::IndexMap; use serde::Deserialize; use std::env::current_dir; +use std::fmt; use std::path::PathBuf; #[cfg(any(unix, target_os = "redox"))] use xdg::BaseDirectories; @@ -13,10 +14,15 @@ pub struct Config { pub autocomplete: bool, pub breaking_changes: bool, pub commit_types: IndexMap, + pub commit_scopes: IndexMap, pub emoji: bool, pub issues: bool, pub sign: bool, + pub force_scope: bool, + pub allow_empty_scope: bool, pub workdir: PathBuf, + pub scope_patterns: IndexMap, + pub scope_ast_grep: Vec, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] @@ -26,15 +32,60 @@ pub struct CommitType { pub name: String, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct CommitScope { + pub name: String, + pub description: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum ScopePatternValue { + One(String), + Many(Vec), +} + +impl ScopePatternValue { + pub fn iter(&self) -> Box + '_> { + match self { + Self::One(pattern) => Box::new(std::iter::once(pattern.as_str())), + Self::Many(patterns) => Box::new(patterns.iter().map(String::as_str)), + } + } +} + +#[derive(Clone, Deserialize)] +pub struct ScopeAstGrepRule { + pub scope: String, + #[serde(flatten)] + pub rule: ast_grep_config::SerializableRuleConfig, +} + +impl fmt::Debug for ScopeAstGrepRule { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ScopeAstGrepRule") + .field("scope", &self.scope) + .finish_non_exhaustive() + } +} + #[derive(Clone, Debug, Deserialize)] struct ConfigTOML { pub autocomplete: bool, pub breaking_changes: bool, #[serde(default)] commit_types: Vec, + #[serde(default)] + commit_scopes: Vec, pub emoji: bool, pub issues: bool, pub sign: bool, + pub force_scope: bool, + pub allow_empty_scope: bool, + #[serde(default)] + scope_patterns: IndexMap, + #[serde(default)] + scope_ast_grep: Vec, } #[derive(Default)] @@ -45,6 +96,8 @@ pub struct ConfigArgs { pub emoji: Option, pub issues: Option, pub sign: Option, + pub force_scope: Option, + pub allow_empty_scope: Option, pub _user_config_path: Option, pub _current_dir: Option, } @@ -59,6 +112,8 @@ impl Config { emoji, issues, sign, + force_scope, + allow_empty_scope, _user_config_path, _current_dir, } = args.unwrap_or_default(); @@ -75,7 +130,7 @@ impl Config { let mut config_dirs = vec![config_dir()]; #[cfg(any(unix, target_os = "redox"))] config_dirs.push(BaseDirectories::new().get_config_home()); - config_dirs.push(_user_config_path); + config_dirs.push(_user_config_path.clone()); settings = config_dirs .into_iter() @@ -89,7 +144,7 @@ impl Config { settings = settings.add_source(config::File::from(working_dir_path).required(false)); // Try to get config from passed directory - if let Some(path) = path { + if let Some(path) = path.clone() { settings = settings.add_source(config::File::from(path).required(false)); } @@ -101,15 +156,47 @@ impl Config { commit_types.insert(commit_type.name.clone(), commit_type.to_owned()); } - Ok(Config { + // Gather up commit scopes + let mut commit_scopes = IndexMap::new(); + for commit_scope in config.commit_scopes.iter() { + commit_scopes.insert(commit_scope.name.clone(), commit_scope.to_owned()); + } + for scope in config.scope_patterns.keys() { + commit_scopes + .entry(scope.clone()) + .or_insert_with(|| CommitScope { + name: scope.clone(), + description: None, + }); + } + for rule in &config.scope_ast_grep { + commit_scopes + .entry(rule.scope.clone()) + .or_insert_with(|| CommitScope { + name: rule.scope.clone(), + description: None, + }); + } + + let config = Config { autocomplete: autocomplete.unwrap_or(config.autocomplete), breaking_changes: breaking_changes.unwrap_or(config.breaking_changes), commit_types, + commit_scopes, emoji: emoji.unwrap_or(config.emoji), issues: issues.unwrap_or(config.issues), sign: sign.unwrap_or(config.sign), + force_scope: force_scope.unwrap_or(config.force_scope), + allow_empty_scope: allow_empty_scope.unwrap_or(config.allow_empty_scope), workdir, - }) + scope_patterns: config.scope_patterns, + scope_ast_grep: config.scope_ast_grep, + }; + + crate::scope::validate_scope_patterns(&config)?; + crate::scope::validate_ast_grep_rules(&config)?; + + Ok(config) } } @@ -285,4 +372,92 @@ mod tests { Ok(()) } + + #[test] + fn test_commit_scopes() -> Result<(), Box> { + let tempdir = tempfile::tempdir()?; + std::fs::write( + tempdir.path().join(".koji.toml"), + "[[commit_scopes]]\nname=\"app\"\ndescription=\"Application code\"", + )?; + let config = Config::new(Some(ConfigArgs { + _current_dir: Some(tempdir.path().to_path_buf()), + ..Default::default() + }))?; + assert!(config.commit_scopes.get("app").is_some()); + assert_eq!( + config.commit_scopes.get("app"), + Some(&CommitScope { + name: "app".into(), + description: Some("Application code".into()) + }) + ); + tempdir.close()?; + Ok(()) + } + #[test] + fn test_commit_scopes_from_config() -> Result<(), Box> { + let tempdir_config = tempfile::tempdir()?; + std::fs::create_dir(tempdir_config.path().join("koji"))?; + std::fs::write( + tempdir_config.path().join("koji").join("config.toml"), + "[[commit_scopes]]\nname=\"server\"\ndescription=\"Server code\"\n[[commit_scopes]]\nname=\"shared\"", + )?; + let tempdir_current = tempfile::tempdir()?; + let config = Config::new(Some(ConfigArgs { + _user_config_path: Some(tempdir_config.path().to_path_buf()), + _current_dir: Some(tempdir_current.path().to_path_buf()), + ..Default::default() + }))?; + assert!(config.commit_scopes.get("server").is_some()); + assert!(config.commit_scopes.get("shared").is_some()); + assert_eq!(config.commit_scopes.len(), 2); + tempdir_current.close()?; + tempdir_config.close()?; + Ok(()) + } + + #[test] + fn test_scope_patterns_add_scopes() -> Result<(), Box> { + let tempdir = tempfile::tempdir()?; + std::fs::write( + tempdir.path().join(".koji.toml"), + "[scope_patterns]\ncore = \"/crates/core/**/*.rs\"\nbuild = [\"^/build\\\\.rs$\", \"/justfile\"]", + )?; + + let config = Config::new(Some(ConfigArgs { + _current_dir: Some(tempdir.path().to_path_buf()), + ..Default::default() + }))?; + + assert!(config.commit_scopes.contains_key("core")); + assert!(config.commit_scopes.contains_key("build")); + assert_eq!( + config.scope_patterns.get("core"), + Some(&ScopePatternValue::One("/crates/core/**/*.rs".into())) + ); + + tempdir.close()?; + Ok(()) + } + + #[test] + fn test_scope_ast_grep_adds_scopes() -> Result<(), Box> { + let tempdir = tempfile::tempdir()?; + std::fs::write( + tempdir.path().join(".koji.toml"), + "[[scope_ast_grep]]\nscope = \"test\"\nlanguage = \"Rust\"\nrule = { kind = \"function_item\", has = { stopBy = \"end\", pattern = \"#[test]\" } }\nfiles = [\"**/*.rs\"]", + )?; + + let config = Config::new(Some(ConfigArgs { + _current_dir: Some(tempdir.path().to_path_buf()), + ..Default::default() + }))?; + + assert!(config.commit_scopes.contains_key("test")); + assert_eq!(config.scope_ast_grep.len(), 1); + + tempdir.close()?; + Ok(()) + } } diff --git a/src/lib/lib.rs b/src/lib/lib.rs index 3a8daa1..faa00dc 100644 --- a/src/lib/lib.rs +++ b/src/lib/lib.rs @@ -3,4 +3,5 @@ pub mod commit; pub mod config; pub mod emoji; pub mod questions; +pub mod scope; pub mod status; diff --git a/src/lib/questions.rs b/src/lib/questions.rs index 841e442..dc56b32 100644 --- a/src/lib/questions.rs +++ b/src/lib/questions.rs @@ -1,14 +1,36 @@ use crate::config::{CommitType, Config}; +use crate::scope::ScopeMatches; use anyhow::{Context, Result}; use conventional_commit_parser::parse_summary; use gix::bstr::ByteSlice; use indexmap::IndexMap; +use inquire::error::InquireError; use inquire::ui::{Attributes, Color, RenderConfig, StyleSheet}; use inquire::{ autocompletion::{Autocomplete, Replacement}, validator::Validation, Confirm, CustomUserError, Select, Text, }; +use thiserror::Error; + +#[derive(Debug, Error)] +enum PromptError { + #[error("{0} cancelled")] + Cancelled(&'static str), + #[error(transparent)] + Inquire(InquireError), +} + +impl PromptError { + fn from_inquire(e: InquireError, prompt_name: &'static str) -> Self { + match e { + InquireError::OperationCanceled | InquireError::OperationInterrupted => { + PromptError::Cancelled(prompt_name) + } + other => PromptError::Inquire(other), + } + } +} fn get_skip_hint() -> &'static str { " or to skip" @@ -79,14 +101,15 @@ fn prompt_type(config: &Config) -> Result { let selected_type = Select::new("What type of change are you committing?", type_values) .with_render_config(get_render_config()) .with_formatter(&|v| transform_commit_type_choice(v.value)) - .prompt()?; + .prompt() + .map_err(|e| PromptError::from_inquire(e, "Commit type selection"))?; Ok(transform_commit_type_choice(&selected_type)) } #[derive(Debug, Clone)] -struct ScopeAutocompleter { - config: Config, +pub struct ScopeAutocompleter { + pub config: Config, } impl ScopeAutocompleter { @@ -124,13 +147,28 @@ impl ScopeAutocompleter { Ok(scopes) } + + pub fn get_config_scopes(&self) -> Vec { + self.config.commit_scopes.keys().cloned().collect() + } + + pub fn get_all_scopes(&self) -> Vec { + let mut scopes = self.get_config_scopes(); + let existing_scopes = self.get_existing_scopes().unwrap_or_default(); + // Add existing scopes that aren't already in the config scopes + for scope in existing_scopes { + if !scopes.contains(&scope) { + scopes.push(scope); + } + } + scopes + } } impl Autocomplete for ScopeAutocompleter { fn get_suggestions(&mut self, input: &str) -> Result, CustomUserError> { - let existing_scopes = self.get_existing_scopes().unwrap_or_default(); - - Ok(existing_scopes + let all_scopes = self.get_all_scopes(); + Ok(all_scopes .iter() .filter(|s| s.contains(input)) .cloned() @@ -147,20 +185,91 @@ impl Autocomplete for ScopeAutocompleter { } } -fn prompt_scope(config: &Config) -> Result> { +fn format_scope_value(scope: &crate::config::CommitScope) -> String { + if let Some(desc) = &scope.description { + format!("{}: {}", scope.name, desc) + } else { + scope.name.clone() + } +} + +fn get_force_scope_values(config: &Config, scope_matches: &ScopeMatches) -> Vec { + let mut scope_values = Vec::new(); + + for matched_scope in &scope_matches.matches { + if let Some(scope) = config.commit_scopes.get(matched_scope) { + scope_values.push(format_scope_value(scope)); + } + } + + for scope in config.commit_scopes.values() { + if !scope_matches.matches.contains(&scope.name) { + scope_values.push(format_scope_value(scope)); + } + } + + scope_values +} + +fn prompt_scope(config: &Config, scope_matches: &ScopeMatches) -> Result> { + if config.force_scope && !config.commit_scopes.is_empty() { + if let Some(scope) = scope_matches.suggested() { + return Ok(Some(scope)); + } + + let scope_values = get_force_scope_values(config, scope_matches); + + let prompt = Select::new("What's the scope of this change?", scope_values) + .with_render_config(get_render_config()) + .with_formatter(&|v| v.value.split(':').next().unwrap().trim().to_string()); + + let result = if config.allow_empty_scope { + prompt + .prompt_skippable() + .map_err(|e| PromptError::from_inquire(e, "Scope selection"))? + } else { + Some( + prompt + .prompt() + .map_err(|e| PromptError::from_inquire(e, "Scope selection"))?, + ) + }; + + return Ok(result.map(|s| s.split(':').next().unwrap().trim().to_string())); + } + let mut scope_autocompleter = ScopeAutocompleter { config: config.clone(), }; - let help_message = - if config.autocomplete && !scope_autocompleter.get_suggestions("").unwrap().is_empty() { + let detected_scope = scope_matches.suggested(); + let help_message = match ( + config.autocomplete && !scope_autocompleter.get_suggestions("").unwrap().is_empty(), + detected_scope.as_ref(), + scope_matches.matches.len(), + ) { + (true, Some(scope), _) => { + format!("↑↓ to move, tab to autocomplete, enter to use `{scope}`, to skip") + } + (true, None, count) if count > 1 => format!( + "↑↓ to move, tab to autocomplete, matched scopes: {}, {}", + scope_matches.matches.join(", "), + get_skip_hint() + ), + (true, None, _) => format!( + "{}, {}", + "↑↓ to move, tab to autocomplete, enter to submit", + get_skip_hint() + ), + (false, Some(scope), _) => format!("enter to use `{scope}`, to skip"), + (false, None, count) if count > 1 => { format!( - "{}, {}", - "↑↓ to move, tab to autocomplete, enter to submit", + "matched scopes: {}, {}", + scope_matches.matches.join(", "), get_skip_hint() ) - } else { - get_skip_hint().to_string() - }; + } + _ => get_skip_hint().to_string(), + }; let mut selected_scope = Text::new("What's the scope of this change?") .with_render_config(RenderConfig { @@ -173,13 +282,23 @@ fn prompt_scope(config: &Config) -> Result> { selected_scope = selected_scope.with_autocomplete(scope_autocompleter); } - if let Some(scope) = selected_scope.prompt_skippable()? { - if scope.is_empty() { - return Ok(None); - } - Ok(Some(scope)) - } else { - Ok(None) + if !config.allow_empty_scope { + let allow_detected_scope = detected_scope.is_some(); + selected_scope = selected_scope.with_validator(move |input: &str| { + if input.trim().is_empty() && !allow_detected_scope { + Ok(Validation::Invalid("A scope is required".into())) + } else { + Ok(Validation::Valid) + } + }); + } + + match selected_scope + .prompt_skippable() + .map_err(|e| PromptError::from_inquire(e, "Scope selection"))? + { + Some(scope) if scope.is_empty() => Ok(detected_scope), + scope => Ok(scope), } } @@ -193,7 +312,8 @@ fn prompt_summary(msg: String) -> Result { .with_render_config(get_render_config()) .with_placeholder(&previous_summary) .with_validator(validate_summary) - .prompt()?; + .prompt() + .map_err(|e| PromptError::from_inquire(e, "Commit summary input"))?; Ok(summary) } @@ -201,18 +321,15 @@ fn prompt_summary(msg: String) -> Result { fn prompt_body() -> Result> { let help_message = format!("{}, {}", "Use '\\n' for newlines", get_skip_hint()); - let summary = Text::new("Provide a longer description of the change:") + match Text::new("Provide a longer description of the change:") .with_render_config(get_render_config()) .with_help_message(help_message.as_str()) - .prompt_skippable()?; - - if let Some(summary) = summary { - if summary.is_empty() { - return Ok(None); - } - Ok(Some(summary.replace("\\n", "\n"))) - } else { - Ok(None) + .prompt_skippable() + .map_err(|e| PromptError::from_inquire(e, "Body input"))? + { + Some(summary) if summary.is_empty() => Ok(None), + Some(summary) => Ok(Some(summary.replace("\\n", "\n"))), + None => Ok(None), } } @@ -220,7 +337,8 @@ fn prompt_breaking() -> Result { let answer = Confirm::new("Are there any breaking changes?") .with_render_config(get_render_config()) .with_default(false) - .prompt()?; + .prompt() + .map_err(|e| PromptError::from_inquire(e, "Breaking changes prompt"))?; Ok(answer) } @@ -228,18 +346,15 @@ fn prompt_breaking() -> Result { fn prompt_breaking_text() -> Result> { let help_message = format!("{}, {}", "Use '\\n' for newlines", get_skip_hint()); - let breaking_text = Text::new("Describe the breaking changes in detail:") + match Text::new("Describe the breaking changes in detail:") .with_render_config(get_render_config()) .with_help_message(help_message.as_str()) - .prompt_skippable()?; - - if let Some(breaking_text) = breaking_text { - if breaking_text.is_empty() { - return Ok(None); - } - Ok(Some(breaking_text.replace("\\n", "\n"))) - } else { - Ok(None) + .prompt_skippable() + .map_err(|e| PromptError::from_inquire(e, "Breaking changes description"))? + { + Some(text) if text.is_empty() => Ok(None), + Some(text) => Ok(Some(text.replace("\\n", "\n"))), + None => Ok(None), } } @@ -247,7 +362,8 @@ fn prompt_issues() -> Result { let answer = Confirm::new("Does this change affect any open issues?") .with_render_config(get_render_config()) .with_default(false) - .prompt()?; + .prompt() + .map_err(|e| PromptError::from_inquire(e, "Issues prompt"))?; Ok(answer) } @@ -257,7 +373,8 @@ fn prompt_issue_text() -> Result { .with_render_config(get_render_config()) .with_help_message("e.g. \"closes #123\"") .with_validator(validate_issue_reference) - .prompt()?; + .prompt() + .map_err(|e| PromptError::from_inquire(e, "Issue reference input"))?; Ok(summary) } @@ -274,9 +391,13 @@ pub struct Answers { } /// Create the interactive prompt -pub fn create_prompt(last_message: String, config: &Config) -> Result { +pub fn create_prompt( + last_message: String, + config: &Config, + scope_matches: &ScopeMatches, +) -> Result { let commit_type = prompt_type(config)?; - let scope = prompt_scope(config)?; + let scope = prompt_scope(config, scope_matches)?; let summary = prompt_summary(last_message)?; let body = prompt_body()?; @@ -310,7 +431,8 @@ pub fn prompt_confirm() -> Result { let answer = Confirm::new("Proceed with this commit?") .with_render_config(get_render_config()) .with_default(true) - .prompt()?; + .prompt() + .map_err(|e| PromptError::from_inquire(e, "Confirmation"))?; Ok(answer) } diff --git a/src/lib/scope.rs b/src/lib/scope.rs new file mode 100644 index 0000000..25919c4 --- /dev/null +++ b/src/lib/scope.rs @@ -0,0 +1,378 @@ +use anyhow::{Context, Result}; +use gix::bstr::ByteSlice; +use gix::diff::index::ChangeRef; +use gix::Repository; +use globset::{Glob, GlobMatcher}; +use indexmap::IndexSet; +use regex::Regex; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use ast_grep_config::{GlobalRules, RuleCollection, RuleConfig}; +use ast_grep_language::{LanguageExt, SupportLang}; + +use crate::config::Config; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ScopeMatches { + pub matches: Vec, +} + +impl ScopeMatches { + pub fn suggested(&self) -> Option { + match self.matches.as_slice() { + [scope] => Some(scope.clone()), + _ => None, + } + } +} + +struct CompiledAstGrepRules { + rules: RuleCollection, + ids_to_scope: HashMap, +} + +#[derive(Debug, Clone)] +enum PathPatternMatcher { + Regex(Regex), + Glob(GlobMatcher), +} + +impl PathPatternMatcher { + fn new(pattern: &str) -> Result { + if looks_like_regex(pattern) { + let regex = Regex::new(pattern) + .with_context(|| format!("invalid scope path regex `{pattern}`"))?; + return Ok(Self::Regex(regex)); + } + + let glob_pattern = pattern.strip_prefix('/').unwrap_or(pattern); + let glob = Glob::new(glob_pattern) + .with_context(|| format!("invalid scope path pattern `{pattern}`"))? + .compile_matcher(); + + Ok(Self::Glob(glob)) + } + + fn is_match(&self, path: &str) -> bool { + match self { + Self::Regex(regex) => regex.is_match(path), + Self::Glob(glob) => glob.is_match(path.strip_prefix('/').unwrap_or(path)), + } + } +} + +fn looks_like_regex(pattern: &str) -> bool { + pattern.starts_with('^') + || pattern.ends_with('$') + || pattern.contains('\\') + || pattern.contains('(') + || pattern.contains('|') + || pattern.contains('+') +} + +pub fn validate_scope_patterns(config: &Config) -> Result<()> { + for (scope, patterns) in &config.scope_patterns { + for pattern in patterns.iter() { + PathPatternMatcher::new(pattern) + .with_context(|| format!("invalid scope path pattern for `{scope}`"))?; + } + } + + Ok(()) +} + +pub fn validate_ast_grep_rules(config: &Config) -> Result<()> { + compile_ast_grep_rules(config).map(|_| ()) +} + +pub fn detect_scope_matches(repo: &Repository, config: &Config) -> Result { + let changed_paths = staged_paths(repo)?; + if changed_paths.is_empty() { + return Ok(ScopeMatches::default()); + } + + let mut matched_scopes = IndexSet::new(); + + for relative_path in &changed_paths { + let normalized_path = normalize_relative_path(relative_path); + + for (scope, patterns) in &config.scope_patterns { + let is_match = patterns + .iter() + .map(PathPatternMatcher::new) + .collect::>>()? + .iter() + .any(|matcher| matcher.is_match(&normalized_path)); + + if is_match { + matched_scopes.insert(scope.clone()); + } + } + } + + let workdir = repo + .workdir() + .context("could not determine repository workdir")?; + let ast_scopes = detect_ast_grep_scopes(config, workdir, &changed_paths)?; + for scope in ast_scopes { + matched_scopes.insert(scope); + } + + Ok(ScopeMatches { + matches: matched_scopes.into_iter().collect(), + }) +} + +fn staged_paths(repo: &Repository) -> Result> { + let index = repo.index_or_empty().context("could not read index")?; + let head_tree_id = repo + .head_tree_id_or_empty() + .context("could not resolve HEAD tree")?; + + let mut paths = IndexSet::new(); + repo.tree_index_status( + &head_tree_id, + &index, + None, + gix::status::tree_index::TrackRenames::Disabled, + |change, _, _| { + match change { + ChangeRef::Addition { location, .. } + | ChangeRef::Deletion { location, .. } + | ChangeRef::Modification { location, .. } => { + paths.insert(PathBuf::from(location.to_str_lossy().into_owned())); + } + ChangeRef::Rewrite { + source_location, + location, + .. + } => { + paths.insert(PathBuf::from(source_location.to_str_lossy().into_owned())); + paths.insert(PathBuf::from(location.to_str_lossy().into_owned())); + } + } + + Ok::<_, std::convert::Infallible>(gix::diff::index::Action::Continue(())) + }, + ) + .context("could not diff HEAD tree against index")?; + + Ok(paths.into_iter().collect()) +} + +fn normalize_relative_path(path: &Path) -> String { + let path = path.to_string_lossy().replace('\\', "/"); + + if path.starts_with('/') { + path + } else { + format!("/{path}") + } +} + +fn compile_ast_grep_rules(config: &Config) -> Result { + let capacity = config.scope_ast_grep.len(); + + let globals = GlobalRules::default(); + let mut ids_to_scope = HashMap::new(); + let mut compiled_rules = Vec::with_capacity(capacity); + + for (index, rule) in config.scope_ast_grep.iter().enumerate() { + let mut serializable = rule.rule.clone(); + let id = if serializable.id.is_empty() { + format!("{index:04}-{}", rule.scope) + } else { + format!("{index:04}-{}", serializable.id) + }; + + serializable.id = id.clone(); + ids_to_scope.insert(id, rule.scope.clone()); + compiled_rules.push(RuleConfig::try_from(serializable, &globals)?); + } + + Ok(CompiledAstGrepRules { + rules: RuleCollection::try_new(compiled_rules)?, + ids_to_scope, + }) +} + +fn detect_ast_grep_scopes( + config: &Config, + workdir: &Path, + changed_paths: &[PathBuf], +) -> Result> { + if config.scope_ast_grep.is_empty() { + return Ok(Vec::new()); + } + + let compiled_rules = compile_ast_grep_rules(config)?; + + let mut matched_scopes = IndexSet::new(); + + for relative_path in changed_paths { + let applicable_rules = compiled_rules.rules.for_path(relative_path); + if applicable_rules.is_empty() { + continue; + } + + let full_path = workdir.join(relative_path); + let Ok(source) = std::fs::read_to_string(&full_path) else { + continue; + }; + + for rule in applicable_rules { + let root = rule.language.ast_grep(&source); + if root.root().find(&rule.matcher).is_some() { + if let Some(scope) = compiled_rules.ids_to_scope.get(&rule.id) { + matched_scopes.insert(scope.clone()); + } + } + } + } + + Ok(matched_scopes.into_iter().collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{CommitScope, Config, ScopePatternValue}; + use indexmap::IndexMap; + use std::error::Error; + + fn empty_config(workdir: PathBuf) -> Config { + Config { + autocomplete: false, + breaking_changes: false, + commit_types: IndexMap::new(), + commit_scopes: IndexMap::new(), + emoji: false, + issues: false, + sign: false, + force_scope: false, + allow_empty_scope: true, + workdir, + scope_patterns: IndexMap::new(), + scope_ast_grep: Vec::new(), + } + } + + #[test] + fn test_normalize_relative_path() { + assert_eq!( + normalize_relative_path(Path::new("src/lib.rs")), + "/src/lib.rs" + ); + assert_eq!( + normalize_relative_path(Path::new("/src/lib.rs")), + "/src/lib.rs" + ); + } + + #[test] + fn test_validate_scope_patterns_with_glob_fallback() -> Result<(), Box> { + let tempdir = tempfile::tempdir()?; + let mut config = empty_config(tempdir.path().to_path_buf()); + config.scope_patterns.insert( + "core".into(), + ScopePatternValue::Many(vec!["/crates/core/**/*.rs".into(), "^/src/.*\\.rs$".into()]), + ); + + validate_scope_patterns(&config)?; + + tempdir.close()?; + Ok(()) + } + + #[test] + fn test_detect_scope_matches_from_staged_paths() -> Result<(), Box> { + let tempdir = tempfile::tempdir()?; + let repo = git2::Repository::init(tempdir.path())?; + + let mut index = repo.index()?; + let tree_id = index.write_tree()?; + let tree = repo.find_tree(tree_id)?; + let sig = git2::Signature::now("Tester", "test@example.com")?; + repo.commit(Some("HEAD"), &sig, &sig, "chore: initial", &tree, &[])?; + + let gix_repo = gix::discover(tempdir.path())?; + + std::fs::create_dir_all(tempdir.path().join("crates/core/src"))?; + std::fs::write( + tempdir.path().join("crates/core/src/lib.rs"), + "pub fn core() {}\n", + )?; + + let mut index = repo.index()?; + index.add_path(Path::new("crates/core/src/lib.rs"))?; + index.write()?; + + let mut config = empty_config(tempdir.path().to_path_buf()); + config.commit_scopes.insert( + "core".into(), + CommitScope { + name: "core".into(), + description: None, + }, + ); + config.scope_patterns.insert( + "core".into(), + ScopePatternValue::One("/crates/core/**/*.rs".into()), + ); + + let matches = detect_scope_matches(&gix_repo, &config)?; + assert_eq!(matches.suggested(), Some("core".into())); + + tempdir.close()?; + Ok(()) + } + + #[test] + fn test_detect_scope_matches_from_ast_grep_rules() -> Result<(), Box> { + let tempdir = tempfile::tempdir()?; + let repo = git2::Repository::init(tempdir.path())?; + let gix_repo = gix::discover(tempdir.path())?; + + std::fs::create_dir_all(tempdir.path().join("src"))?; + std::fs::write( + tempdir.path().join("src/lib.rs"), + "#[test]\nfn detects_test_scope() {}\n", + )?; + + let mut index = repo.index()?; + index.add_path(Path::new("src/lib.rs"))?; + index.write()?; + + let mut config = empty_config(tempdir.path().to_path_buf()); + config.scope_ast_grep.push(crate::config::ScopeAstGrepRule { + scope: "test".into(), + rule: ast_grep_config::SerializableRuleConfig { + core: ast_grep_config::SerializableRuleCore { + rule: ast_grep_config::from_str("pattern: fn $NAME() {}")?, + constraints: None, + transform: None, + utils: None, + fix: None, + }, + id: String::new(), + language: SupportLang::Rust, + rewriters: None, + message: String::new(), + note: None, + severity: ast_grep_config::Severity::Hint, + labels: None, + files: None, + ignores: None, + url: None, + metadata: None, + }, + }); + + let matches = detect_scope_matches(&gix_repo, &config)?; + assert_eq!(matches.suggested(), Some("test".into())); + + tempdir.close()?; + Ok(()) + } +} diff --git a/tests/integration.rs b/tests/integration.rs index f8504f8..9b7bc6d 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,10 +1,16 @@ -use git2::{Commit, IndexAddOption, Oid, Repository, RepositoryInitOptions}; +use git2::{IndexAddOption, Repository}; +use indexmap::IndexMap; +use inquire::autocompletion::Autocomplete; +use koji::config::{CommitScope, Config}; +use koji::questions::ScopeAutocompleter; +use koji::scope::detect_scope_matches; #[cfg(not(target_os = "windows"))] use rexpect::{ process::wait, session::{spawn_command, PtySession}, }; -use std::{error::Error, fs, path::PathBuf, process::Command}; +use std::fs; +use std::{error::Error, path::PathBuf, process::Command}; use tempfile::TempDir; #[cfg(not(target_os = "windows"))] @@ -15,22 +21,21 @@ fn setup_config_home() -> Result> { Ok(temp_dir) } -fn setup_test_dir() -> Result<(PathBuf, TempDir, Repository), Box> { +fn setup_test_dir() -> Result<(PathBuf, TempDir, Repository), Box> { let bin_path = assert_cmd::cargo::cargo_bin!("koji").to_path_buf(); let temp_dir = tempfile::tempdir()?; - let mut init_options = RepositoryInitOptions::new(); - init_options.initial_head("main"); - let repo = Repository::init_opts(&temp_dir, &init_options)?; - let mut gitconfig = repo.config()?; - gitconfig.set_str("user.name", "test")?; - gitconfig.set_str("user.email", "test@example.org")?; + let repo = Repository::init(temp_dir.path())?; + + let mut config = repo.config()?; + config.set_str("user.name", "test")?; + config.set_str("user.email", "test@example.org")?; Ok((bin_path, temp_dir, repo)) } #[cfg(not(target_os = "windows"))] -fn get_last_commit(repo: &Repository) -> Result, git2::Error> { +fn get_last_commit(repo: &Repository) -> Result, git2::Error> { let mut walk = repo.revwalk()?; walk.push_head()?; let oid = walk.next().expect("cannot get commit in revwalk")?; @@ -38,12 +43,20 @@ fn get_last_commit(repo: &Repository) -> Result, git2::Error> { repo.find_commit(oid) } -fn do_initial_commit(repo: &Repository, message: &'static str) -> Result { - let signature = repo.signature()?; - let oid = repo.index()?.write_tree()?; - let tree = repo.find_tree(oid)?; +fn do_initial_commit(repo: &Repository, message: &'static str) -> Result<(), Box> { + let mut index = repo.index()?; + let tree_id = index.write_tree()?; + let tree = repo.find_tree(tree_id)?; + let sig = repo.signature()?; + repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?; + Ok(()) +} - repo.commit(Some("HEAD"), &signature, &signature, message, &tree, &[]) +fn git_add(repo: &Repository, pattern: &str) -> Result<(), Box> { + let mut index = repo.index()?; + index.add_all([pattern].iter(), IndexAddOption::DEFAULT, None)?; + index.write()?; + Ok(()) } #[cfg(not(target_os = "windows"))] @@ -105,16 +118,12 @@ fn test_everything_correct() -> Result<(), Box> { let config_temp_dir = setup_config_home()?; fs::write(temp_dir.path().join("README.md"), "foo")?; - let mut index = repo.index()?; - index.add_all(["."].iter(), IndexAddOption::default(), None)?; - index.write()?; + git_add(&repo, ".")?; do_initial_commit(&repo, "docs(readme): initial draft")?; fs::write(temp_dir.path().join("config.json"), "bar")?; // TODO properly test "-a" - repo.index()? - .add_all(["."].iter(), IndexAddOption::default(), None)?; - repo.index()?.write()?; + git_add(&repo, ".")?; let mut cmd = Command::new(bin_path); cmd.env("NO_COLOR", "1") @@ -182,9 +191,7 @@ fn test_hook_correct() -> Result<(), Box> { let config_temp_dir = setup_config_home()?; fs::write(temp_dir.path().join("config.json"), "abc")?; - repo.index()? - .add_all(["*"].iter(), IndexAddOption::default(), None)?; - repo.index()?.write()?; + git_add(&repo, "*")?; fs::remove_file(temp_dir.path().join(".git").join("COMMIT_EDITMSG")).unwrap_or(()); let mut cmd = Command::new(bin_path); @@ -243,9 +250,7 @@ fn test_stdout_correct() -> Result<(), Box> { let config_temp_dir = setup_config_home()?; fs::write(temp_dir.path().join("config.json"), "abc")?; - repo.index()? - .add_all(["*"].iter(), IndexAddOption::default(), None)?; - repo.index()?.write()?; + git_add(&repo, "*")?; fs::remove_file(temp_dir.path().join(".git").join("COMMIT_EDITMSG")).unwrap_or(()); let mut cmd = Command::new(bin_path); @@ -306,9 +311,7 @@ fn test_empty_breaking_text_correct() -> Result<(), Box> { let config_temp_dir = setup_config_home()?; fs::write(temp_dir.path().join("Cargo.toml"), "bar")?; - repo.index()? - .add_all(["."].iter(), IndexAddOption::default(), None)?; - repo.index()?.write()?; + git_add(&repo, ".")?; let mut cmd = Command::new(bin_path); cmd.env("NO_COLOR", "1") @@ -379,6 +382,7 @@ fn test_non_repository_error() -> Result<(), Box> { } #[test] +#[cfg(not(target_os = "windows"))] fn test_empty_repository_error() -> Result<(), Box> { let (bin_path, temp_dir, _) = setup_test_dir()?; @@ -487,9 +491,7 @@ fn test_xdg_config() -> Result<(), Box> { )?; fs::write(temp_dir.path().join("config.json"), "bar")?; - repo.index()? - .add_all(["."].iter(), IndexAddOption::default(), None)?; - repo.index()?.write()?; + git_add(&repo, ".")?; let mut cmd = Command::new(bin_path); cmd.env("NO_COLOR", "1") @@ -765,3 +767,275 @@ fn test_confirmation_decline() -> Result<(), Box> { Ok(()) } + +#[test] +fn test_scope_autocompletion() -> Result<(), Box> { + let tempdir = tempfile::tempdir()?; + let workdir = tempdir.path(); + + // Init git repo and commit using git2 + let repo = git2::Repository::init(workdir)?; + + // Create a commit with a scope + let mut index = repo.index()?; + let tree_id = index.write_tree()?; + let tree = repo.find_tree(tree_id)?; + let sig = git2::Signature::now("Tester", "test@example.com")?; + + repo.commit( + Some("HEAD"), + &sig, + &sig, + "feat(database): initial db schema", + &tree, + &[], + )?; + + // Create another commit with a different scope + let head = repo.head()?.peel_to_commit()?; + repo.commit( + Some("HEAD"), + &sig, + &sig, + "fix(api): fix login endpoint", + &tree, + &[&head], + )?; + + // Setup config with a configured scope + let mut commit_scopes = IndexMap::new(); + commit_scopes.insert( + "frontend".into(), + CommitScope { + name: "frontend".into(), + description: Some("Frontend code".into()), + }, + ); + + let config = Config { + commit_scopes, + workdir: workdir.to_path_buf(), + autocomplete: true, + breaking_changes: false, + commit_types: IndexMap::new(), + emoji: false, + issues: false, + sign: false, + force_scope: false, + allow_empty_scope: true, + scope_patterns: IndexMap::new(), + scope_ast_grep: Vec::new(), + }; + + let mut autocompleter = ScopeAutocompleter { config }; + + // Test get_all_scopes + let scopes = autocompleter.get_all_scopes(); + + assert!(scopes.contains(&"database".to_string())); + assert!(scopes.contains(&"api".to_string())); + assert!(scopes.contains(&"frontend".to_string())); + + // Test get_suggestions (Autocomplete trait) + let suggestions = autocompleter + .get_suggestions("data") + .expect("Failed to get suggestions"); + assert!(suggestions.contains(&"database".to_string())); + assert!(!suggestions.contains(&"api".to_string())); + + let suggestions = autocompleter + .get_suggestions("front") + .expect("Failed to get suggestions"); + assert!(suggestions.contains(&"frontend".to_string())); + + Ok(()) +} + +#[test] +#[cfg(not(target_os = "windows"))] +fn test_force_scope_integration() -> Result<(), Box> { + let (bin_path, temp_dir, repo) = setup_test_dir()?; + let config_temp_dir = setup_config_home()?; + + fs::write( + temp_dir.path().join(".koji.toml"), + "force_scope = true\n[[commit_scopes]]\nname = \"app\"", + )?; + + fs::write(temp_dir.path().join("a.txt"), "foo")?; + git_add(&repo, ".")?; + + let mut cmd = Command::new(bin_path); + cmd.env("NO_COLOR", "1") + .arg("-C") + .arg(temp_dir.path()) + .arg("--stdout"); + + let mut process = spawn_command(cmd, Some(5000))?; + + process.expect_commit_type()?; + process.send_line("feat")?; + process.flush()?; + process.expect_scope()?; + // In Select mode, "app" should be the first and only option. + // Pressing enter should select "app". + process.send_line("")?; + process.flush()?; + process.expect_summary()?; + process.send_line("test force scope")?; + process.flush()?; + process.expect_body()?; + process.send_line("")?; + process.flush()?; + process.expect_breaking()?; + process.send_line("N")?; + process.flush()?; + process.expect_issues()?; + process.send_line("N")?; + process.flush()?; + + let expected_output = "feat(app): test force scope"; + let _ = process.exp_string(expected_output)?; + + temp_dir.close()?; + config_temp_dir.close()?; + Ok(()) +} + +#[test] +#[cfg(not(target_os = "windows"))] +fn test_require_scope_integration() -> Result<(), Box> { + let (bin_path, temp_dir, repo) = setup_test_dir()?; + let config_temp_dir = setup_config_home()?; + + fs::write( + temp_dir.path().join(".koji.toml"), + "allow_empty_scope = false", + )?; + + fs::write(temp_dir.path().join("a.txt"), "foo")?; + git_add(&repo, ".")?; + + let mut cmd = Command::new(bin_path); + cmd.env("NO_COLOR", "1") + .arg("-C") + .arg(temp_dir.path()) + .arg("--stdout"); + + let mut process = spawn_command(cmd, Some(5000))?; + + process.expect_commit_type()?; + process.send_line("feat")?; + process.flush()?; + process.expect_scope()?; + // Try to skip by sending empty line + process.send_line("")?; + process.flush()?; + + // It should NOT proceed to summary, but show error. + // Inquire shows error message "A scope is required" + process.exp_string("A scope is required")?; + + // Now provide a scope + process.send_line("required-scope")?; + process.flush()?; + + process.expect_summary()?; + process.send_line("test require scope")?; + process.flush()?; + process.expect_body()?; + process.send_line("")?; + process.flush()?; + process.expect_breaking()?; + process.send_line("N")?; + process.flush()?; + process.expect_issues()?; + process.send_line("N")?; + process.flush()?; + + let expected_output = "feat(required-scope): test require scope"; + let _ = process.exp_string(expected_output)?; + + temp_dir.close()?; + config_temp_dir.close()?; + Ok(()) +} + +#[test] +#[cfg(not(target_os = "windows"))] +fn test_scope_pattern_auto_assigns_scope() -> Result<(), Box> { + let (bin_path, temp_dir, repo) = setup_test_dir()?; + let config_temp_dir = setup_config_home()?; + + fs::write( + temp_dir.path().join(".koji.toml"), + "[scope_patterns]\nconfig = \"/config\\\\.json$\"", + )?; + fs::write(temp_dir.path().join("config.json"), "abc")?; + git_add(&repo, ".")?; + + let mut cmd = Command::new(bin_path); + cmd.env("NO_COLOR", "1") + .arg("-C") + .arg(temp_dir.path()) + .arg("--stdout"); + + let mut process = spawn_command(cmd, Some(5000))?; + + process.expect_commit_type()?; + process.send_line("feat")?; + process.flush()?; + process.expect_scope()?; + process.send_line("")?; + process.flush()?; + process.expect_summary()?; + process.send_line("wire auto scope")?; + process.flush()?; + process.expect_body()?; + process.send_line("")?; + process.flush()?; + process.expect_breaking()?; + process.send_line("N")?; + process.flush()?; + process.expect_issues()?; + process.send_line("N")?; + process.flush()?; + + let _ = process.exp_string("feat(config): wire auto scope")?; + let eof_output = process.exp_eof(); + let exitcode = process.process.wait()?; + let success = matches!(exitcode, wait::WaitStatus::Exited(_, 0)); + + if !success { + panic!("Command exited non-zero, end of output: {eof_output:#?}"); + } + + temp_dir.close()?; + config_temp_dir.close()?; + Ok(()) +} + +#[test] +fn test_detect_scope_matches_from_scope_patterns() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let repo = Repository::init(temp_dir.path())?; + let gix_repo = gix::discover(temp_dir.path())?; + + fs::write(temp_dir.path().join("config.json"), "abc")?; + git_add(&repo, ".")?; + fs::write( + temp_dir.path().join(".koji.toml"), + "[scope_patterns]\nconfig = \"/config\\\\.json$\"", + )?; + + let config = Config::new(Some(koji::config::ConfigArgs { + _current_dir: Some(temp_dir.path().to_path_buf()), + ..Default::default() + }))?; + + let matches = detect_scope_matches(&gix_repo, &config)?; + assert_eq!(matches.suggested(), Some("config".into())); + + temp_dir.close()?; + Ok(()) +}