cuddle_please_misc/cliff/
mod.rs1use anyhow::Context;
2use chrono::{DateTime, NaiveDate, Utc};
3use git_cliff_core::{
4 changelog::Changelog,
5 commit::Commit,
6 config::{ChangelogConfig, CommitParser, Config, GitConfig},
7 release::Release,
8};
9use regex::Regex;
10
11pub struct ChangeLogBuilder {
12 commits: Vec<String>,
13 version: String,
14 config: Option<Config>,
15 release_date: Option<NaiveDate>,
16 release_link: Option<String>,
17}
18
19impl ChangeLogBuilder {
20 pub fn new<C>(commits: C, version: impl Into<String>) -> Self
21 where
22 C: IntoIterator,
23 C::Item: AsRef<str>,
24 {
25 Self {
26 commits: commits
27 .into_iter()
28 .map(|s| s.as_ref().to_string())
29 .collect(),
30 version: version.into(),
31 config: None,
32 release_date: None,
33 release_link: None,
34 }
35 }
36
37 pub fn with_release_date(self, release_date: NaiveDate) -> Self {
38 Self {
39 release_date: Some(release_date),
40 ..self
41 }
42 }
43
44 pub fn with_release_link(self, release_link: impl Into<String>) -> Self {
45 Self {
46 release_link: Some(release_link.into()),
47 ..self
48 }
49 }
50
51 pub fn with_config(self, config: Config) -> Self {
52 Self {
53 config: Some(config),
54 ..self
55 }
56 }
57
58 pub fn build<'a>(self) -> ChangeLog<'a> {
59 let git_config = self
60 .config
61 .clone()
62 .map(|c| c.git)
63 .unwrap_or_else(default_git_config);
64 let timestamp = self.release_timestamp();
65 let commits = self
66 .commits
67 .clone()
68 .into_iter()
69 .map(|c| Commit::new("id".into(), c))
70 .filter_map(|c| c.process(&git_config).ok())
71 .collect();
72
73 ChangeLog {
74 release: Release {
75 version: Some(self.version),
76 commits,
77 commit_id: None,
78 timestamp,
79 previous: None,
80 },
81 config: self.config,
82 release_link: self.release_link,
83 }
84 }
85
86 fn release_timestamp(&self) -> i64 {
87 self.release_date
88 .and_then(|date| date.and_hms_opt(0, 0, 0))
89 .map(|d| DateTime::<Utc>::from_utc(d, Utc))
90 .unwrap_or_else(Utc::now)
91 .timestamp()
92 }
93}
94
95pub struct ChangeLog<'a> {
96 release: Release<'a>,
97 config: Option<Config>,
98 release_link: Option<String>,
99}
100
101impl ChangeLog<'_> {
102 pub fn generate(&self) -> anyhow::Result<String> {
103 let config = self.config.clone().unwrap_or_else(|| self.default_config());
104 let changelog = Changelog::new(vec![self.release.clone()], &config)?;
105 let mut buffer = Vec::new();
106 changelog
107 .generate(&mut buffer)
108 .context("failed to generate changelog")?;
109 String::from_utf8(buffer)
110 .context("cannot convert bytes to string (contains non utf-8 char indices)")
111 }
112
113 pub fn prepend(self, old_changelog: impl Into<String>) -> anyhow::Result<String> {
114 let old_changelog = old_changelog.into();
115 if let Ok(Some(last_version)) = changelog_parser::last_version_from_str(&old_changelog) {
116 let next_version = self
117 .release
118 .version
119 .as_ref()
120 .context("current release contains no version")?;
121 if next_version == &last_version {
122 return Ok(old_changelog);
123 }
124 }
125
126 let old_header = changelog_parser::parse_header(&old_changelog);
127 let config = self
128 .config
129 .clone()
130 .unwrap_or_else(|| self.default_config_with_header(old_header));
131 let changelog = Changelog::new(vec![self.release], &config)?;
132 let mut out = Vec::new();
133 changelog.prepend(old_changelog, &mut out)?;
134 String::from_utf8(out)
135 .context("cannot convert bytes to string (contains non utf-8 char indices)")
136 }
137
138 fn default_config(&self) -> Config {
139 let config = Config {
140 changelog: default_changelog_config(None, self.release_link.as_deref()),
141 git: default_git_config(),
142 };
143
144 config
145 }
146
147 fn default_config_with_header(&self, header: Option<String>) -> Config {
148 let config = Config {
149 changelog: default_changelog_config(header, self.release_link.as_deref()),
150 git: default_git_config(),
151 };
152
153 config
154 }
155}
156
157fn default_git_config() -> GitConfig {
158 GitConfig {
159 conventional_commits: Some(true),
160 filter_unconventional: Some(false),
161 filter_commits: Some(true),
162 commit_parsers: Some(default_commit_parsers()),
163 ..Default::default()
164 }
165}
166
167fn default_commit_parsers() -> Vec<CommitParser> {
168 fn create_commit_parser(message: &str, group: &str) -> CommitParser {
169 CommitParser {
170 message: Regex::new(&format!("^{message}")).ok(),
171 body: None,
172 group: Some(group.into()),
173 default_scope: None,
174 scope: None,
175 skip: None,
176 }
177 }
178
179 vec![
180 create_commit_parser("feat", "added"),
181 create_commit_parser("changed", "changed"),
182 create_commit_parser("deprecated", "deprecated"),
183 create_commit_parser("removed", "removed"),
184 create_commit_parser("fix", "fixed"),
185 create_commit_parser("security", "security"),
186 create_commit_parser("docs", "docs"),
187 CommitParser {
188 message: Regex::new(".*").ok(),
189 group: Some(String::from("other")),
190 body: None,
191 default_scope: None,
192 skip: None,
193 scope: None,
194 },
195 ]
196}
197
198const CHANGELOG_HEADER: &str = r#"# Changelog
199All notable changes to this project will be documented in this file.
200
201The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
202and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
203
204## [Unreleased]
205"#;
206
207fn default_changelog_config(header: Option<String>, release_link: Option<&str>) -> ChangelogConfig {
208 ChangelogConfig {
209 header: Some(header.unwrap_or(String::from(CHANGELOG_HEADER))),
210 body: Some(default_changelog_body_config(release_link)),
211 footer: None,
212 trim: Some(true),
213 }
214}
215
216fn default_changelog_body_config(release_link: Option<&str>) -> String {
217 const PRE: &str = r#"
218 ## [{{ version | trim_start_matches(pat="v") }}]"#;
219 const POST: &str = r#" - {{ timestamp | date(format="%Y-%m-%d") }}
220{% for group, commits in commits | group_by(attribute="group") %}
221### {{ group | upper_first }}
222{% for commit in commits %}
223{%- if commit.scope -%}
224- *({{commit.scope}})* {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }}{%- if commit.links %} ({% for link in commit.links %}[{{link.text}}]({{link.href}}) {% endfor -%}){% endif %}
225{% else -%}
226- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }}
227{% endif -%}
228{% endfor -%}
229{% endfor %}"#;
230
231 match release_link {
232 Some(link) => format!("{}{}{}", PRE, link, POST),
233 None => format!("{}{}", PRE, POST),
234 }
235}
236
237pub mod changelog_parser {
238
239 use anyhow::Context;
240 use regex::Regex;
241
242 pub fn parse_header(changelog: &str) -> Option<String> {
248 lazy_static::lazy_static! {
249 static ref FIRST_RE: Regex = Regex::new(r"(?s)^(# Changelog|# CHANGELOG|# changelog)(.*)(## Unreleased|## \[Unreleased\])").unwrap();
250
251 static ref SECOND_RE: Regex = Regex::new(r"(?s)^(# Changelog|# CHANGELOG|# changelog)(.*)(\n## )").unwrap();
252 }
253 if let Some(captures) = FIRST_RE.captures(changelog) {
254 return Some(format!("{}\n", &captures[0]));
255 }
256
257 if let Some(captures) = SECOND_RE.captures(changelog) {
258 return Some(format!("{}{}", &captures[1], &captures[2]));
259 }
260
261 None
262 }
263
264 pub fn last_changes(changelog: &str) -> anyhow::Result<Option<String>> {
265 last_changes_from_str(changelog)
266 }
267
268 pub fn last_changes_from_str(changelog: &str) -> anyhow::Result<Option<String>> {
269 let parser = ChangelogParser::new(changelog)?;
270 let last_release = parser.last_release().map(|r| r.notes.to_string());
271 Ok(last_release)
272 }
273
274 pub fn last_version_from_str(changelog: &str) -> anyhow::Result<Option<String>> {
275 let parser = ChangelogParser::new(changelog)?;
276 let last_release = parser.last_release().map(|r| r.version.to_string());
277 Ok(last_release)
278 }
279
280 pub fn last_release_from_str(changelog: &str) -> anyhow::Result<Option<ChangelogRelease>> {
281 let parser = ChangelogParser::new(changelog)?;
282 let last_release = parser.last_release().map(ChangelogRelease::from_release);
283 Ok(last_release)
284 }
285
286 pub struct ChangelogRelease {
287 title: String,
288 notes: String,
289 }
290
291 impl ChangelogRelease {
292 fn from_release(release: &parse_changelog::Release) -> Self {
293 Self {
294 title: release.title.to_string(),
295 notes: release.notes.to_string(),
296 }
297 }
298
299 pub fn title(&self) -> &str {
300 &self.title
301 }
302
303 pub fn notes(&self) -> &str {
304 &self.notes
305 }
306 }
307
308 pub struct ChangelogParser<'a> {
309 changelog: parse_changelog::Changelog<'a>,
310 }
311
312 impl<'a> ChangelogParser<'a> {
313 pub fn new(changelog_text: &'a str) -> anyhow::Result<Self> {
314 let changelog =
315 parse_changelog::parse(changelog_text).context("can't parse changelog")?;
316 Ok(Self { changelog })
317 }
318
319 fn last_release(&self) -> Option<&parse_changelog::Release> {
320 let last_release = release_at(&self.changelog, 0)?;
321 let last_release = if last_release.version.to_lowercase().contains("unreleased") {
322 release_at(&self.changelog, 1)?
323 } else {
324 last_release
325 };
326 Some(last_release)
327 }
328 }
329
330 fn release_at<'a>(
331 changelog: &'a parse_changelog::Changelog,
332 index: usize,
333 ) -> Option<&'a parse_changelog::Release<'a>> {
334 let release = changelog.get_index(index)?.1;
335 Some(release)
336 }
337
338 #[cfg(test)]
339 mod tests {
340 use super::*;
341
342 fn last_changes_from_str_test(changelog: &str) -> String {
343 last_changes_from_str(changelog).unwrap().unwrap()
344 }
345
346 #[test]
347 fn changelog_header_is_parsed() {
348 let changelog = "\
349# Changelog
350
351My custom changelog header
352
353## [Unreleased]
354";
355 let header = parse_header(changelog).unwrap();
356 let expected_header = "\
357# Changelog
358
359My custom changelog header
360
361## [Unreleased]
362";
363 assert_eq!(header, expected_header);
364 }
365
366 #[test]
367 fn changelog_header_without_unreleased_is_parsed() {
368 let changelog = "\
369# Changelog
370
371My custom changelog header
372
373## [0.2.5] - 2022-12-16
374";
375 let header = parse_header(changelog).unwrap();
376 let expected_header = "\
377# Changelog
378
379My custom changelog header
380";
381 assert_eq!(header, expected_header);
382 }
383
384 #[test]
385 fn changelog_header_with_versions_is_parsed() {
386 let changelog = "\
387# Changelog
388
389My custom changelog header
390
391## [Unreleased]
392
393## [0.2.5] - 2022-12-16
394";
395 let header = parse_header(changelog).unwrap();
396 let expected_header = "\
397# Changelog
398
399My custom changelog header
400
401## [Unreleased]
402";
403 assert_eq!(header, expected_header);
404 }
405
406 #[test]
407 fn changelog_header_isnt_recognized() {
408 let changelog = "\
410# Changelog
411
412My custom changelog header
413";
414 let header = parse_header(changelog);
415 assert_eq!(header, None);
416 }
417
418 #[test]
419 fn changelog_with_unreleased_section_is_parsed() {
420 let changelog = "\
421# Changelog
422All notable changes to this project will be documented in this file.
423
424The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
425and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
426
427## [Unreleased]
428
429## [0.2.5] - 2022-12-16
430
431### Added
432- Add function to retrieve default branch (#372)
433
434## [0.2.4] - 2022-12-12
435
436### Changed
437- improved error message
438";
439 let changes = last_changes_from_str_test(changelog);
440 let expected_changes = "\
441### Added
442- Add function to retrieve default branch (#372)";
443 assert_eq!(changes, expected_changes);
444 }
445
446 #[test]
447 fn changelog_without_unreleased_section_is_parsed() {
448 let changelog = "\
449# Changelog
450All notable changes to this project will be documented in this file.
451
452The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
453and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
454
455## [0.2.5](https://github.com/MarcoIeni/release-plz/compare/git_cmd-v0.2.4...git_cmd-v0.2.5) - 2022-12-16
456
457### Added
458- Add function to retrieve default branch (#372)
459
460## [0.2.4] - 2022-12-12
461
462### Changed
463- improved error message
464";
465 let changes = last_changes_from_str_test(changelog);
466 let expected_changes = "\
467### Added
468- Add function to retrieve default branch (#372)";
469 assert_eq!(changes, expected_changes);
470 }
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn bare_release() {
480 let commits: Vec<&str> = Vec::new();
481 let changelog = ChangeLogBuilder::new(commits, "0.0.0")
482 .with_release_date(NaiveDate::from_ymd_opt(1995, 5, 15).unwrap())
483 .build();
484
485 let expected = r######"# Changelog
486All notable changes to this project will be documented in this file.
487
488The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
489and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
490
491## [Unreleased]
492"######;
493
494 pretty_assertions::assert_eq!(expected, &changelog.generate().unwrap())
495 }
496
497 #[test]
498 fn generates_changelog() {
499 let commits: Vec<&str> = vec![
500 "feat: some feature",
501 "some random commit",
502 "fix: some fix",
503 "chore(scope): some chore",
504 ];
505 let changelog = ChangeLogBuilder::new(commits, "1.0.0")
506 .with_release_date(NaiveDate::from_ymd_opt(1995, 5, 15).unwrap())
507 .build();
508
509 let expected = r######"# Changelog
510All notable changes to this project will be documented in this file.
511
512The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
513and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
514
515## [Unreleased]
516
517## [1.0.0] - 1995-05-15
518
519### Added
520- some feature
521
522### Fixed
523- some fix
524
525### Other
526- some random commit
527- *(scope)* some chore
528"######;
529
530 pretty_assertions::assert_eq!(expected, &changelog.generate().unwrap())
531 }
532}