conventional_commit/
lib.rs1#![deny(
35 clippy::all,
36 clippy::cargo,
37 clippy::clone_on_ref_ptr,
38 clippy::dbg_macro,
39 clippy::indexing_slicing,
40 clippy::mem_forget,
41 clippy::multiple_inherent_impl,
42 clippy::nursery,
43 clippy::option_unwrap_used,
44 clippy::pedantic,
45 clippy::print_stdout,
46 clippy::result_unwrap_used,
47 clippy::unimplemented,
48 clippy::use_debug,
49 clippy::wildcard_enum_match_arm,
50 clippy::wrong_pub_self_convention,
51 deprecated_in_future,
52 future_incompatible,
53 missing_copy_implementations,
54 missing_debug_implementations,
55 missing_docs,
56 nonstandard_style,
57 rust_2018_idioms,
58 rustdoc,
59 trivial_casts,
60 trivial_numeric_casts,
61 unreachable_pub,
62 unsafe_code,
63 unused_import_braces,
64 unused_lifetimes,
65 unused_qualifications,
66 unused_results,
67 variant_size_differences,
68 warnings
69)]
70#![doc(html_root_url = "https://docs.rs/conventional-commit")]
71
72use itertools::Itertools;
73use std::fmt;
74use std::str::FromStr;
75use unicode_segmentation::UnicodeSegmentation;
76
77#[derive(Debug)]
79pub struct ConventionalCommit {
80 ty: String,
81 scope: Option<String>,
82 description: String,
83 body: Option<String>,
84 breaking_change: Option<String>,
85}
86
87impl ConventionalCommit {
88 pub fn type_(&self) -> &str {
90 self.ty.trim()
91 }
92
93 pub fn scope(&self) -> Option<&str> {
95 self.scope.as_ref().map(String::as_str).map(str::trim)
96 }
97
98 pub fn description(&self) -> &str {
100 self.description.trim()
101 }
102
103 pub fn body(&self) -> Option<&str> {
106 self.body.as_ref().map(String::as_str).map(str::trim)
107 }
108
109 pub fn breaking_change(&self) -> Option<&str> {
111 self.breaking_change
112 .as_ref()
113 .map(String::as_str)
114 .map(str::trim)
115 }
116}
117
118impl fmt::Display for ConventionalCommit {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 f.write_str(self.type_())?;
121
122 if let Some(scope) = &self.scope() {
123 f.write_fmt(format_args!("({})", scope))?;
124 }
125
126 f.write_fmt(format_args!(": {}", self.description))?;
127
128 if let Some(body) = &self.body() {
129 f.write_fmt(format_args!("\n\n{}", body))?;
130 }
131
132 if let Some(breaking_change) = &self.breaking_change() {
133 f.write_fmt(format_args!("\n\nBREAKING CHANGE: {}", breaking_change))?;
134 }
135
136 Ok(())
137 }
138}
139
140impl FromStr for ConventionalCommit {
141 type Err = Error;
142
143 fn from_str(s: &str) -> Result<Self, Self::Err> {
144 use Error::*;
145
146 let mut chars = UnicodeSegmentation::graphemes(s, true).peekable();
153
154 let ty: String = chars
156 .peeking_take_while(|&c| c != "(" && c != ":")
157 .collect();
158 if ty.is_empty() {
159 return Err(MissingType);
160 }
161
162 let mut scope: Option<String> = None;
164 if chars.peek() == Some(&"(") {
165 let _ = scope.replace(chars.peeking_take_while(|&c| c != ")").skip(1).collect());
166 chars = chars.dropping(1);
167 }
168
169 if chars.by_ref().take(2).collect::<String>() != ": " {
170 return Err(InvalidFormat);
171 }
172
173 let description: String = chars.peeking_take_while(|&c| c != "\n").collect();
175 if description.is_empty() {
176 return Err(MissingDescription);
177 }
178
179 let other: String = chars.collect::<String>().trim().to_owned();
180
181 let (body, breaking_change) = if other.is_empty() {
184 (None, None)
185 } else {
186 let mut data = other
187 .splitn(2, "BREAKING CHANGE:")
188 .map(|s| s.trim().to_owned());
189
190 (data.next(), data.next())
191 };
192
193 Ok(Self {
194 ty,
195 scope,
196 description,
197 body,
198 breaking_change,
199 })
200 }
201}
202
203#[derive(Copy, Clone, Debug, Eq, PartialEq)]
205pub enum Error {
206 MissingType,
208
209 InvalidScope,
211
212 MissingDescription,
214
215 InvalidBody,
217
218 InvalidFormat,
221}
222
223impl fmt::Display for Error {
224 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225 use Error::*;
226
227 match self {
228 MissingType => f.write_str("missing type definition"),
229 InvalidScope => f.write_str("invalid scope format"),
230 MissingDescription => f.write_str("missing commit description"),
231 InvalidBody => f.write_str("invalid body format"),
232 InvalidFormat => f.write_str("invalid commit format"),
233 }
234 }
235}
236
237impl std::error::Error for Error {
238 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
239 None
240 }
241}
242
243#[cfg(test)]
244#[allow(clippy::result_unwrap_used)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn test_valid_simple_commit() {
250 let commit = ConventionalCommit::from_str("my type(my scope): hello world").unwrap();
251
252 assert_eq!("my type", commit.type_());
253 assert_eq!(Some("my scope"), commit.scope());
254 assert_eq!("hello world", commit.description());
255 }
256
257 #[test]
258 fn test_valid_complex_commit() {
259 let commit = "chore: improve changelog readability\n
260 \n
261 Change date notation from YYYY-MM-DD to YYYY.MM.DD to make it a tiny bit \
262 easier to parse while reading.\n
263 \n
264 BREAKING CHANGE: Just kidding!";
265
266 let commit = ConventionalCommit::from_str(commit).unwrap();
267
268 assert_eq!("chore", commit.type_());
269 assert_eq!(None, commit.scope());
270 assert_eq!("improve changelog readability", commit.description());
271 assert_eq!(
272 Some(
273 "Change date notation from YYYY-MM-DD to YYYY.MM.DD to make it a tiny bit \
274 easier to parse while reading."
275 ),
276 commit.body()
277 );
278 assert_eq!(Some("Just kidding!"), commit.breaking_change());
279 }
280
281 #[test]
282 fn test_missing_type() {
283 let err = ConventionalCommit::from_str("").unwrap_err();
284
285 assert_eq!(Error::MissingType, err);
286 }
287}