cuddle_please_misc/cliff/
mod.rs

1use 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    /// Parse the header from a changelog.
243    /// The changelog header is a string at the begin of the changelog that:
244    /// - Starts with `# Changelog`, `# CHANGELOG`, or `# changelog`
245    /// - ends with `## Unreleased`, `## [Unreleased]` or `## ..anything..`
246    ///   (in the ..anything.. case, `## ..anything..` is not included in the header)
247    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            // A two-level header similar to `## [Unreleased]` is missing
409            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}