From 205e4897a0a0a59e26f8d0a7ea535af52568a691 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Fri, 2 Aug 2019 12:13:16 -0600 Subject: [PATCH] feat: Report commit ID in failure message This is a part of #9 --- Cargo.lock | 40 ++++++++++ Cargo.toml | 3 + src/checks.rs | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 49 ++++++++++-- src/report.rs | 136 +++++++++++++++++++++++++++++++++ 5 files changed, 425 insertions(+), 5 deletions(-) create mode 100644 src/checks.rs create mode 100644 src/report.rs diff --git a/Cargo.lock b/Cargo.lock index 786f742..a6ed812 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,7 +117,9 @@ dependencies = [ name = "committed" version = "0.1.0" dependencies = [ + "clap 2.33.0 (registry+https://github.com/rust-lang/crates.io-index)", "clap-verbosity-flag 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "derive_more 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", "env_logger 0.5.13 (registry+https://github.com/rust-lang/crates.io-index)", "failure 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)", "git2 0.9.2 (registry+https://github.com/rust-lang/crates.io-index)", @@ -127,12 +129,26 @@ dependencies = [ "once_cell 0.2.4 (registry+https://github.com/rust-lang/crates.io-index)", "regex 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", "serde 1.0.98 (registry+https://github.com/rust-lang/crates.io-index)", + "serde_json 1.0.40 (registry+https://github.com/rust-lang/crates.io-index)", "structopt 0.2.18 (registry+https://github.com/rust-lang/crates.io-index)", "toml 0.4.10 (registry+https://github.com/rust-lang/crates.io-index)", "unicase 2.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "unicode-segmentation 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "derive_more" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "lazy_static 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)", + "proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 0.6.13 (registry+https://github.com/rust-lang/crates.io-index)", + "regex 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "rustc_version 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 0.15.42 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "env_logger" version = "0.5.13" @@ -254,6 +270,11 @@ dependencies = [ "rust-stemmers 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "itoa" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "lazy_static" version = "1.3.0" @@ -608,6 +629,11 @@ dependencies = [ "semver 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "ryu" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "same-file" version = "1.0.5" @@ -652,6 +678,16 @@ dependencies = [ "syn 0.15.42 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "serde_json" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "itoa 0.4.4 (registry+https://github.com/rust-lang/crates.io-index)", + "ryu 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "serde 1.0.98 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "siphasher" version = "0.2.3" @@ -865,6 +901,7 @@ dependencies = [ "checksum clap 2.33.0 (registry+https://github.com/rust-lang/crates.io-index)" = "5067f5bb2d80ef5d68b4c87db81601f0b75bca627bc2ef76b141d7b846a3c6d9" "checksum clap-verbosity-flag 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "bda14f5323b2b747f52908c5b7b8af7790784088bc7c2957a11695e39ad476dc" "checksum cloudabi 0.0.3 (registry+https://github.com/rust-lang/crates.io-index)" = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" +"checksum derive_more 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7a141330240c921ec6d074a3e188a7c7ef95668bb95e7d44fa0e5778ec2a7afe" "checksum env_logger 0.5.13 (registry+https://github.com/rust-lang/crates.io-index)" = "15b0a4d2e39f8420210be8b27eeda28029729e2fd4291019455016c348240c38" "checksum failure 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "795bd83d3abeb9220f257e597aa0080a508b27533824adf336529648f6abf7e2" "checksum failure_derive 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "ea1063915fd7ef4309e222a5a07cf9c319fb9c7836b1f89b85458672dbb127e1" @@ -877,6 +914,7 @@ dependencies = [ "checksum humantime 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "3ca7e5f2e110db35f93b837c81797f3714500b81d517bf20c431b16d3ca4f114" "checksum idna 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "02e2673c30ee86b5b96a9cb52ad15718aa1f966f5ab9ad54a8b95d5ca33120a9" "checksum imperative 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b9811a6b00f278c39083dc6773cb2dc59cf6261a803bdddf078d6887170e3011" +"checksum itoa 0.4.4 (registry+https://github.com/rust-lang/crates.io-index)" = "501266b7edd0174f8530248f87f99c88fbe60ca4ef3dd486835b8d8d53136f7f" "checksum lazy_static 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "bc5729f27f159ddd61f4df6228e827e86643d4d3e7c32183cb30a1c08f604a14" "checksum libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)" = "d44e80633f007889c7eff624b709ab43c92d708caad982295768a7b13ca3b5eb" "checksum libgit2-sys 0.8.2 (registry+https://github.com/rust-lang/crates.io-index)" = "4c179ed6d19cd3a051e68c177fbbc214e79ac4724fac3a850ec9f3d3eb8a5578" @@ -919,12 +957,14 @@ dependencies = [ "checksum rust-stemmers 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "05928c187b85b38f6b98db43057a24f0245163635a5ce6325a4f77a833d646aa" "checksum rustc-demangle 0.1.15 (registry+https://github.com/rust-lang/crates.io-index)" = "a7f4dccf6f4891ebcc0c39f9b6eb1a83b9bf5d747cb439ec6fba4f3b977038af" "checksum rustc_version 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +"checksum ryu 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "c92464b447c0ee8c4fb3824ecc8383b81717b9f1e74ba2e72540aef7b9f82997" "checksum same-file 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)" = "585e8ddcedc187886a30fa705c47985c3fa88d06624095856b36ca0b82ff4421" "checksum scopeguard 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b42e15e59b18a828bbf5c58ea01debb36b9b096346de35d941dcb89009f24a0d" "checksum semver 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" "checksum semver-parser 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" "checksum serde 1.0.98 (registry+https://github.com/rust-lang/crates.io-index)" = "7fe5626ac617da2f2d9c48af5515a21d5a480dbd151e01bb1c355e26a3e68113" "checksum serde_derive 1.0.98 (registry+https://github.com/rust-lang/crates.io-index)" = "01e69e1b8a631f245467ee275b8c757b818653c6d704cdbcaeb56b56767b529c" +"checksum serde_json 1.0.40 (registry+https://github.com/rust-lang/crates.io-index)" = "051c49229f282f7c6f3813f8286cc1e3323e8051823fce42c7ea80fe13521704" "checksum siphasher 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "0b8de496cf83d4ed58b6be86c3a275b8602f6ffe98d3024a869e124147a9a3ac" "checksum smallvec 0.6.10 (registry+https://github.com/rust-lang/crates.io-index)" = "ab606a9c5e214920bb66c458cd7be8ef094f813f20fe77a54cc7dbfff220d4b7" "checksum strsim 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" diff --git a/Cargo.toml b/Cargo.toml index e008db2..11b5e21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,8 +17,10 @@ once_cell = "0.2.4" unicase = "2.4.0" failure = "0.1" git2 = "0.9" +clap = "2" structopt = "0.2.18" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" toml = "0.4" unicode-segmentation = "1.3.0" log = "0.4" @@ -26,3 +28,4 @@ env_logger = "0.5" clap-verbosity-flag = "0.2.0" grep-cli = "0.1" imperative = "1.0" +derive_more = "0.15.0" diff --git a/src/checks.rs b/src/checks.rs new file mode 100644 index 0000000..2e0585f --- /dev/null +++ b/src/checks.rs @@ -0,0 +1,202 @@ +use crate::report; + +pub fn check_all( + source: report::Source, + message: &str, + config: &crate::config::Config, + report: report::Report, +) -> Result<(), failure::Error> { + if !config.no_wip() { + check_wip(source, message, report)?; + } + if !config.no_fixup() { + check_fixup(source, message, report)?; + } + match config.style() { + crate::config::Style::Conventional => { + let parsed = committed::conventional::Message::parse(message).unwrap(); + if config.imperative_subject() { + check_imperative_subject(source, parsed.subject, report)?; + } + if config.subject_capitalized() { + check_capitalized_subject(source, parsed.subject, report)?; + } + if config.subject_not_punctuated() { + check_subject_not_punctuated(source, parsed.subject, report)?; + } + } + crate::config::Style::None => { + let parsed = committed::no_style::Message::parse(message).unwrap(); + if config.imperative_subject() { + check_imperative_subject(source, parsed.raw_subject, report)?; + } + if config.subject_capitalized() { + check_capitalized_subject(source, parsed.raw_subject, report)?; + } + if config.subject_not_punctuated() { + check_subject_not_punctuated(source, parsed.raw_subject, report)?; + } + } + } + if config.subject_length() != 0 { + check_subject_length(source, message, config.subject_length(), report)?; + } + if config.line_length() != 0 { + check_line_length(source, message, config.line_length(), report)?; + } + + Ok(()) +} + +pub fn check_subject_length( + source: report::Source, + message: &str, + max_length: usize, + report: report::Report, +) -> Result<(), failure::Error> { + let subject = message + .split('\n') + .next() + .ok_or_else(|| failure::Context::new("Commit cannot be empty"))?; + let subject = subject.trim_end(); + let count = unicode_segmentation::UnicodeSegmentation::graphemes(subject, true).count(); + if max_length < count { + report(report::Message::error( + source, + report::SubjectTooLong { + max_length, + actual_length: count, + }, + )); + failure::bail!( + "Commit subject is {}, exceeding the max length of {}", + count, + max_length + ); + } + Ok(()) +} + +pub fn check_line_length( + source: report::Source, + message: &str, + max_length: usize, + report: report::Report, +) -> Result<(), failure::Error> { + for line in message.split('\n') { + let line = line.trim_end(); + let count = unicode_segmentation::UnicodeSegmentation::graphemes(line, true).count(); + if max_length < count { + report(report::Message::error( + source, + report::LineTooLong { + max_length, + actual_length: count, + }, + )); + failure::bail!( + "Commit line is {}, exceeding the max length of {}", + count, + max_length + ); + } + } + Ok(()) +} + +pub fn check_capitalized_subject( + source: report::Source, + subject: &str, + report: report::Report, +) -> Result<(), failure::Error> { + let first_word = subject + .split_whitespace() + .next() + .ok_or_else(|| failure::Context::new("Subject cannot be empty"))?; + let first = first_word + .chars() + .next() + .ok_or_else(|| failure::Context::new("Subject cannot be empty"))?; + if !first.is_uppercase() { + report(report::Message::error( + source, + report::CapitalizeSubject { first_word }, + )); + failure::bail!("Subject must be capitalized: `{}`", subject); + } + Ok(()) +} + +pub fn check_subject_not_punctuated( + source: report::Source, + subject: &str, + report: report::Report, +) -> Result<(), failure::Error> { + let last = subject + .chars() + .last() + .ok_or_else(|| failure::Context::new("Subject cannot be empty"))?; + if last.is_ascii_punctuation() { + report(report::Message::error( + source, + report::NoPunctuation { punctuation: last }, + )); + failure::bail!("Subject must not be punctuated: `{}`", last); + } + Ok(()) +} + +pub fn check_imperative_subject( + source: report::Source, + subject: &str, + report: report::Report, +) -> Result<(), failure::Error> { + let first_word = subject + .split_whitespace() + .next() + .expect("Subject should have at least one word"); + if !imperative::Mood::new() + .is_imperative(first_word) + .unwrap_or(true) + { + report(report::Message::error( + source, + report::Imperative { first_word }, + )); + failure::bail!( + "Subject does not start with imperative verb: {}", + first_word + ); + } + Ok(()) +} + +static WIP_RE: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| regex::Regex::new("^(wip|WIP)\\b").unwrap()); + +pub fn check_wip( + source: report::Source, + message: &str, + report: report::Report, +) -> Result<(), failure::Error> { + if WIP_RE.is_match(message) { + report(report::Message::error(source, report::Wip {})); + failure::bail!("Work-in-progress commits must be cleaned up"); + } + Ok(()) +} + +static FIXUP_RE: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| regex::Regex::new("^fixup! ").unwrap()); + +pub fn check_fixup( + source: report::Source, + message: &str, + report: report::Report, +) -> Result<(), failure::Error> { + if FIXUP_RE.is_match(message) { + report(report::Message::error(source, report::Fixup {})); + failure::bail!("Fixup commits must be squashed"); + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index d6b2a16..b1fd2aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,7 @@ +// 2015-edition macros. +#[macro_use] +extern crate clap; + use std::fs; use std::io::Read; use std::io::Write; @@ -7,6 +11,7 @@ use structopt::StructOpt; mod checks; mod config; mod git; +mod report; #[derive(Debug, StructOpt)] #[structopt(rename_all = "kebab-case")] @@ -22,10 +27,42 @@ struct Options { #[structopt(long = "config", parse(from_os_str))] config: Option, + #[structopt( + long = "format", + raw(possible_values = "&Format::variants()", case_insensitive = "true"), + default_value = "brief" + )] + format: Format, + #[structopt(flatten)] verbose: clap_verbosity_flag::Verbosity, } +arg_enum! { + #[derive(Debug, Copy, Clone, PartialEq, Eq)] + enum Format { + Silent, + Brief, + Json, + } +} + +impl Format { + fn report(self) -> report::Report { + match self { + Format::Silent => report::print_silent, + Format::Brief => report::print_brief, + Format::Json => report::print_json, + } + } +} + +impl Default for Format { + fn default() -> Self { + Format::Brief + } +} + fn load_toml(path: &std::path::Path) -> Result { let mut f = fs::File::open(path)?; let mut text = String::new(); @@ -77,7 +114,9 @@ fn run() -> Result { } }; - if let Some(path) = options.commit_file { + let report = options.format.report(); + + if let Some(path) = options.commit_file.as_ref() { let mut text = String::new(); if path == std::path::Path::new("-") { std::io::stdin().read_to_string(&mut text)?; @@ -85,24 +124,24 @@ fn run() -> Result { let mut f = fs::File::open(path)?; f.read_to_string(&mut text)?; } - checks::check_all(&text, &config)?; + checks::check_all(path.as_path().into(), &text, &config, report)?; } else if let Some(commits) = options.commits.as_ref() { let revspec = git::RevSpec::parse(&repo, commits)?; for commit in revspec.iter() { let message = commit.message().unwrap(); - checks::check_all(message, &config)?; + checks::check_all(commit.id().into(), message, &config, report)?; } } else if grep_cli::is_readable_stdin() { let mut text = String::new(); std::io::stdin().read_to_string(&mut text)?; - checks::check_all(&text, &config)?; + checks::check_all(std::path::Path::new("-").into(), &text, &config, report)?; } else { debug_assert_eq!(options.commits, None); let commits = "HEAD"; let revspec = git::RevSpec::parse(&repo, commits)?; for commit in revspec.iter() { let message = commit.message().unwrap(); - checks::check_all(message, &config)?; + checks::check_all(commit.id().into(), message, &config, report)?; } } diff --git a/src/report.rs b/src/report.rs new file mode 100644 index 0000000..ab5b3b3 --- /dev/null +++ b/src/report.rs @@ -0,0 +1,136 @@ +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub struct Message<'s> { + pub source: Source<'s>, + pub severity: Severity, + pub content: Content<'s>, +} + +impl<'s> Message<'s> { + pub fn error(source: S, content: C) -> Self + where + S: Into>, + C: Into>, + { + Message { + source: source.into(), + severity: Severity::Error, + content: content.into(), + } + } +} + +#[derive(Copy, Clone, Debug, serde::Serialize, derive_more::From, derive_more::Display)] +#[serde(rename_all = "snake_case")] +#[serde(untagged)] +pub enum Source<'s> { + #[serde(serialize_with = "serialize_oid")] + Oid(git2::Oid), + #[display(fmt = "{}", "_0.display()")] + Path(&'s std::path::Path), +} + +fn serialize_oid(oid: &git2::Oid, s: S) -> Result +where + S: serde::Serializer, +{ + let oid = oid.to_string(); + s.serialize_str(&oid) +} + +#[derive(Copy, Clone, Debug, serde::Serialize, derive_more::Display)] +#[serde(rename_all = "snake_case")] +pub enum Severity { + #[display(fmt = "error")] + Error, +} + +#[derive(Clone, Debug, serde::Serialize, derive_more::From, derive_more::Display)] +#[serde(rename_all = "snake_case")] +#[serde(tag = "type")] +pub enum Content<'s> { + SubjectTooLong(SubjectTooLong), + LineTooLong(LineTooLong), + CapitalizeSubject(CapitalizeSubject<'s>), + NoPunctuation(NoPunctuation), + Imperative(Imperative<'s>), + Wip(Wip), + Fixup(Fixup), +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[derive(derive_more::Display)] +#[display( + fmt = "Commit subject is too long, {} exceeds the max length of {}", + actual_length, + max_length +)] +pub struct SubjectTooLong { + pub max_length: usize, + pub actual_length: usize, +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[derive(derive_more::Display)] +#[display( + fmt = "Line is too long, {} exceeds the max length of {}", + actual_length, + max_length +)] +pub struct LineTooLong { + pub max_length: usize, + pub actual_length: usize, +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[derive(derive_more::Display)] +#[display(fmt = "Subject should be capitalized but found {}", first_word)] +pub struct CapitalizeSubject<'s> { + pub first_word: &'s str, +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[derive(derive_more::Display)] +#[display(fmt = "Subject should not be punctuated but found {}", punctuation)] +pub struct NoPunctuation { + pub punctuation: char, +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[derive(derive_more::Display)] +#[display( + fmt = "Subject should be in the imperative mood but found {}", + first_word +)] +pub struct Imperative<'s> { + pub first_word: &'s str, +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[derive(derive_more::Display)] +#[display(fmt = "Work-in-progress commits must be cleaned up")] +pub struct Wip {} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[derive(derive_more::Display)] +#[display(fmt = "Fixup commits must be squashed")] +pub struct Fixup {} + +pub type Report = fn(msg: Message); + +pub fn print_silent(_: Message) {} + +pub fn print_brief(msg: Message) { + println!("{}: {} {}", msg.source, msg.severity, msg.content) +} + +pub fn print_json(msg: Message) { + println!("{}", serde_json::to_string(&msg).unwrap()); +}