Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src/commands/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()? {
Expand Down Expand Up @@ -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");
Expand Down
6 changes: 6 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
57 changes: 57 additions & 0 deletions src/config/render.rs
Original file line number Diff line number Diff line change
@@ -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<String, String>) -> Result<String> {
let mut output = self.output.template.clone();

let optional_fields: HashSet<String> = 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::<Vec<_>>()
.join("\n");

while result.contains("\n\n\n") {
result = result.replace("\n\n\n", "\n\n")
}
result = result.trim().to_string();
result
}
}
Loading