diff --git a/README.md b/README.md index cc938f0..708356e 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ git cmt -h - ✅ **Built-in validation** - Enforce message length, patterns, and required fields - ✍️ **Git integration** - Uses `git commit` under the hood, respecting all your existing Git configuration - 🔍 **Git-aware** - Detects repository and shows staged files +- 📋 **Conditional rules** - Enforce constraints based on field values with a flexible rule engine ## Usage @@ -270,6 +271,77 @@ Use regex pattern matching: pattern = "^[a-zA-Z-]+[: #].+$" ``` +### Rules + +Rules enforce conditional constraints across fields after all values are collected. +A rule consists of a **condition** and one or more **actions** that apply when condition is met. + +```toml +[[rule]] +name = "feat-requires-scope" + +[rule.condition] +eq = ["type", "feat"] + +[[rule.action]] +require = { fields = ["scope"] } +``` + +#### Conditions + +| Condition | Description | Example | +|---|---|---| +| `eq` | Field equals a value | `eq = ["type", "feat"]` | +| `neq` | Field does not equal a value | `neq = ["type", "chore"]` | +| `in` | Field is one of a set of values | `in = ["type", ["feat", "fix"]]` | +| `exists` | Field is non-empty | `exists = "scope"` | +| `and` | Both expressions are true | | +| `or` | Either expression is true | | +| `not` | Expression is false | | + +#### Actions + +| Action | Description | Example | +|---|---|---| +| `require` | Fields must be non-empty | `require = { fields = ["scope"] }` | +| `forbid` | Fields must be empty | `forbid = { fields = ["scope"] }` | +| `equals` | Fields must equal a value | `equals = { fields = ["type"], value = "feat" }` | + +All actions accept an optional `message` to override the default error: + +```toml +[[rule.action]] +require = { fields = ["scope"], message = "Features must have a scope" } +``` + + +#### Example: Require footer for breaking changes + +```toml +[[rule]] +name = "breaking-requires-footer" + +[rule.condition] +eq = ["type", "feat"] + +[[rule.action]] +require = { fields = ["footer"], message = "Feature commits must reference an issue in the footer" } +``` + +#### Logical combinators + +```toml +[[rule]] +name = "scope-required-for-feat-or-fix" + +[rule.condition.or] +0 = { eq = ["type", "feat"] } +1 = { eq = ["type", "fix"] } + +[[rule.action]] +require = { fields = ["scope"] } +``` + ### Template System The `output.template` uses placeholders like `{type}`, `{scope}`, etc. that correspond to field IDs. diff --git a/src/commands/commit.rs b/src/commands/commit.rs index c9264f8..2fbf649 100644 --- a/src/commands/commit.rs +++ b/src/commands/commit.rs @@ -6,7 +6,7 @@ use crate::{cli::CommitArgs, config, git, ui}; pub fn run(args: &CommitArgs) -> Result<()> { // Check if we're in a git repository - let _ = git::find_repository()?; + git::find_repository()?; // Checjk if there are staged changes if !git::has_staged_changes()? { @@ -35,6 +35,8 @@ pub fn run(args: &CommitArgs) -> Result<()> { // Resolve field values from args and prompts let values = resolve_values(args, &config)?; + config::evaluate_rules(&config.rules, &values)?; + let commit_message = config.render(&values)?; println!("\nCommit message:\n"); diff --git a/src/config/mod.rs b/src/config/mod.rs index e7df54f..e8f9fb8 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,7 +1,13 @@ mod loader; +mod render; +mod rules; mod schema; mod templates; pub use loader::{load, save}; +pub use rules::evaluate_rules; pub use schema::{Config, Field, FieldType}; pub use templates::Template; + +#[cfg(test)] +mod tests; diff --git a/src/config/render.rs b/src/config/render.rs new file mode 100644 index 0000000..1371212 --- /dev/null +++ b/src/config/render.rs @@ -0,0 +1,57 @@ +use std::collections::{HashMap, HashSet}; + +use anyhow::{Result, bail}; + +use super::schema::Config; + +impl Config { + /// Render the commit message by substituting field values into the template. + /// + /// Optional fields that are emtpy are removed (along with empty parantheses, etc.). + /// Required fields that are empty produce an error. + pub fn render(&self, values: &HashMap) -> Result { + let mut output = self.output.template.clone(); + + let optional_fields: HashSet = self + .fields + .iter() + .filter(|f| !f.required) + .map(|f| f.id.clone()) + .collect(); + + for (key, value) in values { + let placeholder = format!("{{{}}}", key); + + if value.is_empty() { + // If it's optional, remove the placeholder + if optional_fields.contains(key) { + output = output.replace(&placeholder, ""); + } else { + // If it's required and empty, that's an error + bail!("Required field '{}' cannot be empty", key); + } + } else { + output = output.replace(&placeholder, value); + } + } + Ok(Self::clean_output(&output)) + } + + fn clean_output(text: &str) -> String { + let mut result = text.to_string(); + + result = result.replace("()", ""); + + result = result + .lines() + .filter(|line| !line.trim().is_empty() || line.is_empty()) + .collect::>() + .join("\n"); + + while result.contains("\n\n\n") { + result = result.replace("\n\n\n", "\n\n") + } + result = result.trim().to_string(); + result + } +} diff --git a/src/config/rules.rs b/src/config/rules.rs new file mode 100644 index 0000000..5f4b317 --- /dev/null +++ b/src/config/rules.rs @@ -0,0 +1,462 @@ +use std::collections::HashMap; +use std::fmt; + +use anyhow::{Result, bail}; +use serde::{Deserialize, Serialize}; +use toml::Value; + +/// A rule that conditionally enforces constraints on field values. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Rule { + /// Human-readable name for the rule (used in error messages) + pub name: Option, + + /// Condition that must be true for the rule's actions to apply + pub condition: Expr, + + /// Actions to enforce when the condition is met + pub action: Vec, +} + +/// An action to enforce when a rule's condition is met. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Action { + /// These fields must be non-empty + Require { + fields: Vec, + message: Option, + }, + /// These fields must be empty + Forbid { + fields: Vec, + message: Option, + }, + /// These fields must equal the given value + Equals { + fields: Vec, + value: Value, + message: Option, + }, +} + +/// A boolean expression tree for rule conditions. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Expr { + // Logical combinators + And(Box, Box), + Or(Box, Box), + Not(Box), + + // Comparisons + Eq(String, Value), + Neq(String, Value), + + // Set membership + In(String, Vec), + + // Field presence (non-empty) + Exists(String), +} + +type Context = HashMap; + +impl Expr { + /// Evaluate this expression against a context of field values. + pub fn evaluate(&self, ctx: &Context) -> bool { + match self { + Expr::And(a, b) => a.evaluate(ctx) && b.evaluate(ctx), + Expr::Or(a, b) => a.evaluate(ctx) || b.evaluate(ctx), + Expr::Not(e) => !e.evaluate(ctx), + + Expr::Eq(field, value) => ctx.get(field) == Some(value), + Expr::Neq(field, value) => ctx.get(field) != Some(value), + + Expr::In(field, values) => ctx.get(field).map_or(false, |v| values.contains(v)), + + Expr::Exists(field) => ctx.contains_key(field), + } + } + + /// Collect all field names referenced by this expression (for config validation). + pub fn referenced_fields(&self) -> Vec<&str> { + match self { + Expr::And(a, b) | Expr::Or(a, b) => { + let mut fields = a.referenced_fields(); + fields.extend(b.referenced_fields()); + fields + } + Expr::Not(e) => e.referenced_fields(), + Expr::Eq(f, _) | Expr::Neq(f, _) | Expr::In(f, _) | Expr::Exists(f) => { + vec![f.as_str()] + } + } + } +} + +impl fmt::Display for Expr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Expr::Eq(field, value) => write!(f, "{}={}", field, value), + Expr::Neq(field, value) => write!(f, "{}!={}", field, value), + Expr::In(field, values) => write!(f, "{} in {:?}", field, values), + Expr::Exists(field) => write!(f, "{} exists", field), + Expr::And(a, b) => write!(f, "{} and {}", a, b), + Expr::Or(a, b) => write!(f, "{} or {}", a, b), + Expr::Not(e) => write!(f, "not {}", e), + } + } +} + +impl Action { + /// Get the field names referenced by this action. + pub fn referenced_fields(&self) -> Vec<&str> { + match self { + Action::Require { fields, .. } + | Action::Forbid { fields, .. } + | Action::Equals { fields, .. } => fields.iter().map(|f| f.as_str()).collect(), + } + } + fn check( + &self, + rule_name: &Option, + condition: &Expr, + values: &HashMap, + violations: &mut Vec, + ) { + match self { + Action::Require { fields, message } => { + for field in fields { + if values.get(field).map_or(true, |v| v.is_empty()) { + violations.push(RuleViolation { + rule_name: rule_name.clone(), + message: message.clone().unwrap_or_else(|| { + format!("When {}: field '{}' is required", condition, field) + }), + }); + } + } + } + Action::Forbid { fields, message } => { + for field in fields { + if values.get(field).map_or(false, |v| !v.is_empty()) { + violations.push(RuleViolation { + rule_name: rule_name.clone(), + message: message.clone().unwrap_or_else(|| { + format!("When {}: field '{}' is forbidden", condition, field) + }), + }); + } + } + } + Action::Equals { + fields, + value, + message, + } => { + for field in fields { + let field_value = values.get(field).map(|v| Value::String(v.clone())); + if field_value.as_ref() != Some(value) { + violations.push(RuleViolation { + rule_name: rule_name.clone(), + message: message.clone().unwrap_or_else(|| { + format!( + "When {}: field '{}' must equal {}", + condition, field, value + ) + }), + }); + } + } + } + } + } +} + +/// A single rule violation with context for error reporting. +#[derive(Debug)] +pub(crate) struct RuleViolation { + pub(crate) rule_name: Option, + pub(crate) message: String, +} + +impl fmt::Display for RuleViolation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.rule_name { + Some(name) => write!(f, "[{}] {}", name, self.message), + None => write!(f, "{}", self.message), + } + } +} + +/// Build a rule evaluation context from collected field values. +/// +/// Empty values are excluded so that `Exists` checks work correctly — +/// a field only "exists" if the user actually provided a value. +fn build_context(values: &HashMap) -> Context { + values + .iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(k, v)| (k.clone(), Value::String(v.clone()))) + .collect() +} + +/// Evaluate all rules against the collected field values. +/// +/// Returns `Ok(())` if all rules pass, or an error listing all violations. +pub fn evaluate_rules(rules: &[Rule], values: &HashMap) -> Result<()> { + let ctx = build_context(values); + let mut violations = Vec::new(); + + for rule in rules { + if rule.condition.evaluate(&ctx) { + for action in &rule.action { + action.check(&rule.name, &rule.condition, values, &mut violations); + } + } + } + + if violations.is_empty() { + Ok(()) + } else { + let messages: Vec = violations.iter().map(|v| v.to_string()).collect(); + bail!("Rule violations:\n • {}", messages.join("\n • ")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx(pairs: &[(&str, &str)]) -> Context { + pairs + .iter() + .map(|(k, v)| (k.to_string(), Value::String(v.to_string()))) + .collect() + } + + fn values(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + fn rule(name: &str, condition: Expr, action: Vec) -> Rule { + Rule { + name: Some(name.into()), + condition, + action, + } + } + + // ── Expr evaluation ────────────────────────────────────── + + #[test] + fn test_expr_eq() { + let expr = Expr::Eq("type".into(), Value::String("feat".into())); + assert!(expr.evaluate(&ctx(&[("type", "feat")]))); + assert!(!expr.evaluate(&ctx(&[("type", "fix")]))); + } + + #[test] + fn test_expr_neq() { + let expr = Expr::Neq("type".into(), Value::String("feat".into())); + assert!(!expr.evaluate(&ctx(&[("type", "feat")]))); + assert!(expr.evaluate(&ctx(&[("type", "fix")]))); + } + + #[test] + fn test_expr_in() { + let expr = Expr::In( + "type".into(), + vec![Value::String("feat".into()), Value::String("fix".into())], + ); + assert!(expr.evaluate(&ctx(&[("type", "feat")]))); + assert!(expr.evaluate(&ctx(&[("type", "fix")]))); + assert!(!expr.evaluate(&ctx(&[("type", "docs")]))); + } + + #[test] + fn test_expr_exists() { + let expr = Expr::Exists("scope".into()); + assert!(expr.evaluate(&ctx(&[("scope", "api")]))); + assert!(!expr.evaluate(&ctx(&[]))); + } + + #[test] + fn test_expr_and() { + let expr = Expr::And( + Box::new(Expr::Eq("type".into(), Value::String("feat".into()))), + Box::new(Expr::Exists("scope".into())), + ); + assert!(expr.evaluate(&ctx(&[("type", "feat"), ("scope", "api")]))); + assert!(!expr.evaluate(&ctx(&[("type", "feat")]))); + } + + #[test] + fn test_expr_or() { + let expr = Expr::Or( + Box::new(Expr::Eq("type".into(), Value::String("feat".into()))), + Box::new(Expr::Eq("type".into(), Value::String("fix".into()))), + ); + assert!(expr.evaluate(&ctx(&[("type", "feat")]))); + assert!(expr.evaluate(&ctx(&[("type", "fix")]))); + assert!(!expr.evaluate(&ctx(&[("type", "docs")]))); + } + + #[test] + fn test_expr_not() { + let expr = Expr::Not(Box::new(Expr::Exists("scope".into()))); + assert!(expr.evaluate(&ctx(&[]))); + assert!(!expr.evaluate(&ctx(&[("scope", "api")]))); + } + + // ── build_context ──────────────────────────────────────── + + #[test] + fn test_build_context_excludes_empty_values() { + let vals = values(&[("type", "feat"), ("scope", ""), ("desc", "hello")]); + let ctx = build_context(&vals); + assert!(ctx.contains_key("type")); + assert!(!ctx.contains_key("scope")); + assert!(ctx.contains_key("desc")); + } + + // ── Rule evaluation: Require ───────────────────────────── + + #[test] + fn test_require_passes_when_field_present() { + let rules = vec![rule( + "require-scope-for-feat", + Expr::Eq("type".into(), Value::String("feat".into())), + vec![Action::Require { + fields: vec!["scope".into()], + message: None, + }], + )]; + assert!(evaluate_rules(&rules, &values(&[("type", "feat"), ("scope", "api")])).is_ok()); + } + + #[test] + fn test_require_fails_when_field_empty() { + let rules = vec![rule( + "require-scope-for-feat", + Expr::Eq("type".into(), Value::String("feat".into())), + vec![Action::Require { + fields: vec!["scope".into()], + message: None, + }], + )]; + assert!(evaluate_rules(&rules, &values(&[("type", "feat"), ("scope", "")])).is_err()); + } + + // ── Rule evaluation: Forbid ────────────────────────────── + + #[test] + fn test_forbid_passes_when_field_empty() { + let rules = vec![rule( + "no-scope-for-chore".into(), + Expr::Eq("type".into(), Value::String("chore".into())), + vec![Action::Forbid { + fields: vec!["scope".into()], + message: None, + }], + )]; + assert!(evaluate_rules(&rules, &values(&[("type", "chore"), ("scope", "")])).is_ok()); + } + + #[test] + fn test_forbid_fails_when_field_present() { + let rules = vec![rule( + "no-scope-for-chore", + Expr::Eq("type".into(), Value::String("chore".into())), + vec![Action::Forbid { + fields: vec!["scope".into()], + message: None, + }], + )]; + assert!( + evaluate_rules( + &rules, + &values(&[("type", "chore"), ("scope", "something")]) + ) + .is_err() + ); + } + + // ── Rule evaluation: Equals ────────────────────────────── + + #[test] + fn test_equals_passes() { + let rules = vec![rule( + "breaking-must-be-feat", + Expr::Exists("breaking".into()), + vec![Action::Equals { + fields: vec!["type".into()], + value: Value::String("feat".into()), + message: None, + }], + )]; + assert!(evaluate_rules(&rules, &values(&[("type", "feat"), ("breaking", "yes")])).is_ok()); + } + + #[test] + fn test_equals_fails() { + let rules = vec![rule( + "breaking-must-be-feat", + Expr::Exists("breaking".into()), + vec![Action::Equals { + fields: vec!["type".into()], + value: Value::String("feat".into()), + message: None, + }], + )]; + assert!(evaluate_rules(&rules, &values(&[("type", "fix"), ("breaking", "yes")])).is_err()); + } + + // ── Condition not met → rule skipped ───────────────────── + + #[test] + fn test_rule_skipped_when_condition_false() { + let rules = vec![rule( + "require-scope-for-feat", + Expr::Eq("type".into(), Value::String("feat".into())), + vec![Action::Require { + fields: vec!["scope".into()], + message: None, + }], + )]; + // type=fix, not feat → rule doesn't fire even though scope is empty + assert!(evaluate_rules(&rules, &values(&[("type", "fix"), ("scope", "")])).is_ok()); + } + + // ── Multiple violations ────────────────────────────────── + + #[test] + fn test_multiple_violations_reported() { + let rules = vec![rule( + "strict-feat", + Expr::Eq("type".into(), Value::String("feat".into())), + vec![ + Action::Require { + fields: vec!["scope".into()], + message: None, + }, + Action::Require { + fields: vec!["body".into()], + message: None, + }, + ], + )]; + let err = evaluate_rules( + &rules, + &values(&[("type", "feat"), ("scope", ""), ("body", "")]), + ) + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("scope")); + assert!(msg.contains("body")); + } +} diff --git a/src/config/schema.rs b/src/config/schema.rs index e013a1e..ac96652 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -1,7 +1,7 @@ -use std::collections::HashSet; - +use super::rules::Rule; use anyhow::{Result, bail}; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { @@ -9,8 +9,12 @@ pub struct Config { pub output: OutputConfig, /// List of fields to prompt for - #[serde(rename = "field")] + #[serde(rename = "field", default)] pub fields: Vec, + + /// List of Rules + #[serde(rename = "rule", default)] + pub rules: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -90,6 +94,13 @@ pub struct Validation { impl Config { /// Validate that all template placeholders have corresponding fields pub fn validate(&self) -> Result<()> { + self.validate_template_fields()?; + self.validate_select_options()?; + self.validate_rules()?; + Ok(()) + } + + fn validate_template_fields(&self) -> Result<()> { // Extract placeholders from template let placeholder_regex = regex::Regex::new(r"\{([^}]+)\}").unwrap(); let placeholders: HashSet = placeholder_regex @@ -99,7 +110,6 @@ impl Config { let known_fields: HashSet = self.fields.iter().map(|f| f.id.clone()).collect(); - // Check for undefined placeholders in template let undefined: Vec = placeholders.difference(&known_fields).cloned().collect(); if !undefined.is_empty() { bail!( @@ -108,12 +118,15 @@ impl Config { ) } - // Check for unused fields let unused: Vec = known_fields.difference(&placeholders).cloned().collect(); if !unused.is_empty() { bail!("Config defines unused fields: {}", unused.join(", ")) } + Ok(()) + } + + fn validate_select_options(&self) -> Result<()> { // Validate that Select fields have options for field in &self.fields { if field.field_type == FieldType::Select { @@ -126,245 +139,36 @@ impl Config { } } } - Ok(()) } + fn validate_rules(&self) -> Result<()> { + let known_fields: HashSet = self.fields.iter().map(|f| f.id.clone()).collect(); - /// Render the commit message using the template and field values - pub fn render(&self, values: &std::collections::HashMap) -> Result { - let mut output = self.output.template.clone(); - - let optional_fields: HashSet = self - .fields - .iter() - .filter(|f| !f.required) - .map(|f| f.id.clone()) - .collect(); - - for (key, value) in values { - let placeholder = format!("{{{}}}", key); + for rule in &self.rules { + let label = rule.name.as_deref().unwrap_or(""); - if value.is_empty() { - // If it's optional, remove the placeholder - if optional_fields.contains(key) { - output = output.replace(&placeholder, ""); - } else { - // If it's required and empty, that's an error - bail!("Required field '{}' cannot be empty", key); + for field in rule.condition.referenced_fields() { + if !known_fields.contains(field) { + bail!( + "Rule '{}' references unknown field '{}' in condition", + label, + field + ); } - } else { - output = output.replace(&placeholder, value); } - } - Ok(Self::clean_output(&output)) - } - - /// Clean up the rendered output by removing empty sections - fn clean_output(text: &str) -> String { - let mut result = text.to_string(); - - result = result.replace("()", ""); - - result = result - .lines() - .filter(|line| !line.trim().is_empty() || line.is_empty()) - .collect::>() - .join("\n"); - while result.contains("\n\n\n") { - result = result.replace("\n\n\n", "\n\n") - } - result = result.trim().to_string(); - - result - } -} - -#[cfg(test)] -mod test { - use super::*; - use std::collections::HashMap; - - // Test fixtures - fn create_field(id: &str, required: bool, field_type: FieldType) -> Field { - Field { - id: id.to_string(), - field_type, - prompt: format!("{} field", id), - required, - help: None, - options: None, - validate: None, - wrap: None, - values: None, - } - } - - fn create_config(template: &str, field_ids: Vec<(&str, bool, FieldType)>) -> Config { - Config { - output: OutputConfig { - template: template.to_string(), - }, - fields: field_ids - .into_iter() - .map(|(id, required, field_type)| create_field(id, required, field_type)) - .collect(), + for action in &rule.action { + for field in action.referenced_fields() { + if !known_fields.contains(field) { + bail!( + "Rule '{}' references unknown field '{}' in action", + label, + field + ); + } + } + } } - } - - fn create_values(pairs: Vec<(&str, &str)>) -> HashMap { - pairs - .into_iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() - } - - // Validation tests - #[test] - fn test_validate_rejects_undefined_placeholders() { - let config = create_config( - "{type}: {undefined_field}", - vec![("type", true, FieldType::Text)], - ); - - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_rejects_unused_fields() { - let config = create_config( - "{type}: {description}", - vec![ - ("type", true, FieldType::Text), - ("description", true, FieldType::Text), - ("unused_field", false, FieldType::Text), - ], - ); - - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_accepts_valid_configs() { - let config = create_config( - "{type}({scope}): {description}", - vec![ - ("type", true, FieldType::Text), - ("scope", false, FieldType::Text), - ("description", true, FieldType::Text), - ], - ); - - assert!(config.validate().is_ok()); - } - - // Render tests - #[test] - fn test_render_with_all_fields() { - let config = create_config( - "{type}({scope}): {description}", - vec![ - ("type", true, FieldType::Text), - ("scope", false, FieldType::Text), - ("description", true, FieldType::Text), - ], - ); - - let values = create_values(vec![ - ("type", "feat"), - ("scope", "api"), - ("description", "add endpoint"), - ]); - - let result = config.render(&values).unwrap(); - assert_eq!(result, "feat(api): add endpoint"); - } - - #[test] - fn test_render_removes_empty_optional_field() { - let config = create_config( - "{type}({scope}): {description}", - vec![ - ("type", true, FieldType::Text), - ("scope", false, FieldType::Text), - ("description", true, FieldType::Text), - ], - ); - let values = create_values(vec![ - ("type", "feat"), - ("scope", ""), - ("description", "add endpoint"), - ]); - - let result = config.render(&values).unwrap(); - assert_eq!(result, "feat: add endpoint"); - } - - #[test] - fn test_render_with_multiline_fields() { - let config = create_config( - "{type}: {description}\n\n{body}\n\n{footer}", - vec![ - ("type", true, FieldType::Text), - ("description", true, FieldType::Text), - ("body", false, FieldType::Multiline), - ("footer", false, FieldType::Text), - ], - ); - - let values = create_values(vec![ - ("type", "feat"), - ("description", "add feature"), - ("body", "Detailed explanation"), - ("footer", "Closes #123"), - ]); - - let result = config.render(&values).unwrap(); - assert_eq!( - result, - "feat: add feature\n\nDetailed explanation\n\nCloses #123" - ); - } - - #[test] - fn test_render_removes_empty_optional_multiline_fields() { - let config = create_config( - "{type}: {description}\n\n{body}\n\n{footer}", - vec![ - ("type", true, FieldType::Text), - ("description", true, FieldType::Text), - ("body", false, FieldType::Multiline), - ("footer", false, FieldType::Text), - ], - ); - - let values = create_values(vec![ - ("type", "feat"), - ("description", "add feature"), - ("body", ""), - ("footer", ""), - ]); - - let result = config.render(&values).unwrap(); - assert_eq!(result, "feat: add feature"); - } - - #[test] - fn test_render_rejects_empty_required_fields() { - let config = create_config( - "{type}({scope}): {description}", - vec![ - ("type", true, FieldType::Select), - ("scope", false, FieldType::Multiline), - ("description", true, FieldType::Text), - ], - ); - let values = create_values(vec![ - ("type", ""), - ("scope", ""), - ("description", "add feature"), - ]); - - assert!(config.render(&values).is_err()); + Ok(()) } } diff --git a/src/config/templates/conventional.rs b/src/config/templates/conventional.rs index 96b9c75..65fc106 100644 --- a/src/config/templates/conventional.rs +++ b/src/config/templates/conventional.rs @@ -82,6 +82,7 @@ pub fn conventional_commits() -> Config { values: None, }, ], + rules: vec![], } } diff --git a/src/config/templates/minimal.rs b/src/config/templates/minimal.rs index d593074..386e1cb 100644 --- a/src/config/templates/minimal.rs +++ b/src/config/templates/minimal.rs @@ -40,6 +40,7 @@ pub fn minimal() -> Config { values: None, }, ], + rules: vec![], } } diff --git a/src/config/tests.rs b/src/config/tests.rs new file mode 100644 index 0000000..6ec7c60 --- /dev/null +++ b/src/config/tests.rs @@ -0,0 +1,179 @@ +use crate::config::schema::*; +use std::collections::HashMap; + +fn field(id: &str, required: bool, field_type: FieldType) -> Field { + Field { + id: id.to_string(), + field_type, + prompt: format!("{} field", id), + required, + help: None, + options: None, + validate: None, + wrap: None, + values: None, + } +} + +fn config(template: &str, fields: Vec) -> Config { + Config { + output: OutputConfig { + template: template.to_string(), + }, + fields, + rules: vec![], + } +} + +fn values(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +// ── Validation ─────────────────────────────────────────────────── + +#[test] +fn test_validate_rejects_undefined_placeholders() { + let c = config( + "{type}: {undefined_field}", + vec![field("type", true, FieldType::Text)], + ); + assert!(c.validate().is_err()); +} + +#[test] +fn test_validate_rejects_unused_fields() { + let c = config( + "{type}: {description}", + vec![ + field("type", true, FieldType::Text), + field("description", true, FieldType::Text), + field("unused_field", false, FieldType::Text), + ], + ); + assert!(c.validate().is_err()); +} + +#[test] +fn test_validate_accepts_valid_config() { + let c = config( + "{type}({scope}): {description}", + vec![ + field("type", true, FieldType::Text), + field("scope", false, FieldType::Text), + field("description", true, FieldType::Text), + ], + ); + assert!(c.validate().is_ok()); +} + +// ── Render ─────────────────────────────────────────────────────── + +#[test] +fn test_render_all_fields_present() { + let c = config( + "{type}({scope}): {description}", + vec![ + field("type", true, FieldType::Text), + field("scope", false, FieldType::Text), + field("description", true, FieldType::Text), + ], + ); + let result = c + .render(&values(&[ + ("type", "feat"), + ("scope", "api"), + ("description", "add endpoint"), + ])) + .unwrap(); + assert_eq!(result, "feat(api): add endpoint"); +} + +#[test] +fn test_render_removes_empty_optional_field() { + let c = config( + "{type}({scope}): {description}", + vec![ + field("type", true, FieldType::Text), + field("scope", false, FieldType::Text), + field("description", true, FieldType::Text), + ], + ); + let result = c + .render(&values(&[ + ("type", "feat"), + ("scope", ""), + ("description", "add endpoint"), + ])) + .unwrap(); + assert_eq!(result, "feat: add endpoint"); +} + +#[test] +fn test_render_multiline_fields() { + let c = config( + "{type}: {description}\n\n{body}\n\n{footer}", + vec![ + field("type", true, FieldType::Text), + field("description", true, FieldType::Text), + field("body", false, FieldType::Multiline), + field("footer", false, FieldType::Text), + ], + ); + let result = c + .render(&values(&[ + ("type", "feat"), + ("description", "add feature"), + ("body", "Detailed explanation"), + ("footer", "Closes #123"), + ])) + .unwrap(); + assert_eq!( + result, + "feat: add feature\n\nDetailed explanation\n\nCloses #123" + ); +} + +#[test] +fn test_render_removes_empty_optional_multiline_fields() { + let c = config( + "{type}: {description}\n\n{body}\n\n{footer}", + vec![ + field("type", true, FieldType::Text), + field("description", true, FieldType::Text), + field("body", false, FieldType::Multiline), + field("footer", false, FieldType::Text), + ], + ); + let result = c + .render(&values(&[ + ("type", "feat"), + ("description", "add feature"), + ("body", ""), + ("footer", ""), + ])) + .unwrap(); + assert_eq!(result, "feat: add feature"); +} + +#[test] +fn test_render_rejects_empty_required_fields() { + let c = config( + "{type}({scope}): {description}", + vec![ + field("type", true, FieldType::Select), + field("scope", false, FieldType::Multiline), + field("description", true, FieldType::Text), + ], + ); + assert!( + c.render(&values(&[ + ("type", ""), + ("scope", ""), + ("description", "add feature") + ])) + .is_err() + ); +}