Skip to content

Commit

Permalink
feat: implement scope rules, add config system, print all reports
Browse files Browse the repository at this point in the history
  • Loading branch information
Matthias Zaunseder committed Feb 6, 2024
1 parent 361bc1a commit 3d52fda
Show file tree
Hide file tree
Showing 7 changed files with 471 additions and 104 deletions.
5 changes: 4 additions & 1 deletion src/commitlint.config.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"rules": {
"scope-enum": ["error", "always", ["foo", "bar", "baz"]]
"scope-empty": ["error", "never"],
"scope-enum": ["error", "always", ["foo", "bar", "baz"]],
"scope-max-length": ["error", 2],
"scope-case": ["error", "always", "lower-case"]
}
}
137 changes: 35 additions & 102 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,117 +1,50 @@
mod parser;
mod rules;

use config::Config;
use miette::{miette, LabeledSpan, Report, Result};
use parser::{parse_commit, Commit};
use serde::Deserialize;
use std::process::ExitCode;

fn rule_scope_enum(commit: &Commit, opts: &ScopeEnumOpts) -> Option<Report> {
let severity = &opts.0;
let condition = &opts.1;
let scopes = &opts.2;
use miette::GraphicalReportHandler;
use parser::parse_commit;

if severity == &Severity::Off {
return None;
}

if let Some(scope) = &commit.scope {
let is_in_scopes = scopes.contains(&scope.to_string());
let is_valid = match condition {
Condition::Never => !is_in_scopes,
Condition::Always => is_in_scopes,
};
if !is_valid {
return Some(
miette!(
severity = match severity {
Severity::Warning => miette::Severity::Warning,
Severity::Error => miette::Severity::Error,
Severity::Off => miette::Severity::Advice,
},
labels = vec![LabeledSpan::at(
scope.start()..scope.end(),
"not allowed scope"
),],
help = String::from("scope must") + match condition {
Condition::Never => " not",
Condition::Always => "",
} + " be one of " + &scopes.join(", "),
code = "rule/scope-enum",
url = "https://example.com",
"Scope not allowed",
)
.with_source_code(commit.raw.clone()),
);
}
}

None
}

/// Severity of the rule
#[derive(Debug, Deserialize, PartialEq)]
enum Severity {
/// Turn off the rule
#[serde(rename = "off")]
Off,
/// Warn about the violation of a rule
#[serde(rename = "warning")]
Warning,
/// Error about the violation of a rule
#[serde(rename = "error")]
Error,
}

/// When the rule should be applied
#[derive(Debug, Deserialize)]
enum Condition {
/// The options should "never" be found (e.g. in a list of disallowed values)
#[serde(rename = "never")]
Never,
/// The options should "always" be found (e.g. in a list of allowed values)
#[serde(rename = "always")]
Always,
}

/// Options for the scope-enum rule
type ScopeEnumOpts = (Severity, Condition, Vec<String>);

/// Config all the rules
#[derive(Debug, Deserialize)]
struct RulesDetails {
#[serde(rename = "scope-enum")]
scope_enum: ScopeEnumOpts,
}

/// Config
#[derive(Debug, Deserialize)]
struct RulesConfig {
rules: RulesDetails,
}

fn main() -> Result<()> {
fn main() -> ExitCode {
let commit_message =
"feat(nice): add cool feature\n\nsome body\n\nsecond body line\n\nsome footer";

let commit = parse_commit(&commit_message);
println!("{:#?}", commit);

let settings = Config::builder()
// Source can be `commitlint.config.toml` or `commitlint.config.json
.add_source(config::File::with_name("src/commitlint.config"))
// Add in settings from the environment (with a prefix of APP)
// Eg.. `COMMITLINT_DEBUG=1 ./target/app` would set the `debug` key
.add_source(config::Environment::with_prefix("COMMITLINT"))
.build()
.unwrap();
let lint_result = rules::run(&commit);
let report_handler = GraphicalReportHandler::new();

if lint_result.has_warnings() {
let mut out = String::new();
lint_result.warnings().unwrap().iter().for_each(|report| {
out.push('\n');
let _ = report_handler.render_report(&mut out, report.as_ref());
});

println!("{}", out);
}

if lint_result.has_errors() {
let mut out = String::new();
lint_result.errors().unwrap().iter().for_each(|report| {
out.push('\n');
let _ = report_handler.render_report(&mut out, report.as_ref());
});

println!("{}", out);
}

// Print out our settings
let config: RulesConfig = settings.try_deserialize::<RulesConfig>().unwrap();
println!("{:?}", config);
println!(
"You have {} warnings and {} errors",
lint_result.warnings_len(),
lint_result.errors_len()
);

if let Some(report) = rule_scope_enum(&commit, &config.rules.scope_enum) {
return Err(report);
if lint_result.has_errors() {
return ExitCode::FAILURE;
}

Ok(())
ExitCode::SUCCESS
}
2 changes: 1 addition & 1 deletion src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use pest_derive::Parser;
struct CommitParser;

/// A span of a part of the commit message
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, Default)]
pub struct CommitSpan<'a> {
input: &'a str,
start: usize,
Expand Down
184 changes: 184 additions & 0 deletions src/rules/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
use config::Config;
use serde::Deserialize;

use crate::parser::Commit;

pub mod scope_empty;
pub mod scope_enum;
pub mod scope_max_length;

pub(crate) trait Rule {
fn run(&self, commit: &Commit) -> Option<miette::Report>;
}

/// Severity of the rule
#[derive(Debug, Deserialize, PartialEq)]
pub(crate) enum Severity {
/// Turn off the rule
#[serde(rename = "off")]
Off,
/// Warn about the violation of a rule
#[serde(rename = "warning")]
Warning,
/// Error about the violation of a rule
#[serde(rename = "error")]
Error,
}

/// When the rule should be applied
#[derive(Debug, Deserialize)]
pub(crate) enum Condition {
/// The options should "never" be found (e.g. in a list of disallowed values)
#[serde(rename = "never")]
Never,
/// The options should "always" be found (e.g. in a list of allowed values)
#[serde(rename = "always")]
Always,
}

/// Possible target cases for the rule (e.g. subject must start with a capital letter: `TargetCase::Sentence`)
#[derive(Debug, Deserialize)]
enum TargetCase {
/// Lower case (e.g. `sometext`)
#[serde(rename = "lower-case")]
Lower,
/// Upper case (e.g. `SOMETEXT`)
#[serde(rename = "upper-case")]
Upper,
/// Pascal case (e.g. `SomeText`)
#[serde(rename = "pascal-case")]
Pascal,
/// Camel case (e.g. `someText`)
#[serde(rename = "camel-case")]
Camel,
/// Kebab case (e.g. `some-text`)
#[serde(rename = "kebab-case")]
Kebab,
/// Snake case (e.g. `some_text`)
#[serde(rename = "snake-case")]
Snake,
/// Start case (e.g. `Some Text`)
#[serde(rename = "start-case")]
Start,
/// Sentence case (e.g. `Some text`)
#[serde(rename = "sentence-case")]
Sentence,
}

/// Options for all rules without options
#[derive(Debug, Deserialize)]
pub(crate) struct NoOpts(Severity, Condition);
/// Options for all enum rules
type EnumOpts = (Severity, Condition, Vec<String>);
/// Options for all length rules
type LengthOpts = (Severity, usize);
/// Options for all case rules
type CaseOpts = (Severity, Condition, TargetCase);

/// Config all the rules
#[derive(Debug, Deserialize)]
struct RulesDetails {
#[serde(rename = "scope-empty")]
scope_empty: NoOpts,
#[serde(rename = "scope-enum")]
scope_enum: EnumOpts,
#[serde(rename = "scope-max-length")]
scope_max_length: LengthOpts,
#[serde(rename = "scope-case")]
scope_case: CaseOpts,
}

/// Config
#[derive(Debug, Deserialize)]
struct RulesConfig {
rules: RulesDetails,
}

pub struct LintResult {
errors: Option<Vec<miette::Report>>,
warnings: Option<Vec<miette::Report>>,
}

impl LintResult {
pub fn errors(&self) -> Option<&Vec<miette::Report>> {
self.errors.as_ref()
}

pub fn errors_len(&self) -> usize {
match self.errors() {
None => return 0,
Some(errors) => errors.len(),
}
}

pub fn has_errors(&self) -> bool {
self.errors.is_some() && !self.errors().unwrap().is_empty()
}

pub fn warnings(&self) -> Option<&Vec<miette::Report>> {
self.warnings.as_ref()
}

pub fn warnings_len(&self) -> usize {
match self.warnings() {
None => return 0,
Some(warnings) => warnings.len(),
}
}

pub fn has_warnings(&self) -> bool {
self.warnings.is_some() && !self.warnings().unwrap().is_empty()
}
}

pub fn run(commit: &Commit) -> LintResult {
let settings = Config::builder()
// Source can be `commitlint.config.toml` or `commitlint.config.json``
.add_source(config::File::with_name("src/commitlint.config"))
.build()
.unwrap();

// Print out our settings
let config: RulesConfig = settings.try_deserialize::<RulesConfig>().unwrap();
println!("{:?}", config);

// create list of rules to iterate over them
let rules: Vec<Box<dyn Rule>> = vec![
Box::new(scope_empty::ScopeEmptyRule {
opts: config.rules.scope_empty,
}),
Box::new(scope_enum::ScopeEnumRule {
opts: config.rules.scope_enum,
}),
Box::new(scope_max_length::ScopeMaxLengthRule {
opts: config.rules.scope_max_length,
}),
];

// iterate over all rules and run them and return all found errors and warnings
let mut lint_result = LintResult {
errors: None,
warnings: None,
};
for rule in rules {
if let Some(report) = rule.run(&commit) {
match report.severity() {
Some(miette::Severity::Error) => {
if lint_result.errors.is_none() {
lint_result.errors = Some(vec![]);
}
lint_result.errors.as_mut().unwrap().push(report);
}
Some(miette::Severity::Warning) => {
if lint_result.warnings.is_none() {
lint_result.warnings = Some(vec![]);
}
lint_result.warnings.as_mut().unwrap().push(report);
}
_ => {}
}
}
}

return lint_result;
}
Loading

0 comments on commit 3d52fda

Please sign in to comment.