pcu_lib/
pr_title.rs

1use std::{ffi::OsStr, fs, path};
2
3use keep_a_changelog::{
4    changelog::ChangelogBuilder, ChangeKind, Changelog, ChangelogParseOptions, Release,
5};
6use url::Url;
7
8use crate::Error;
9
10#[derive(Debug)]
11pub struct PrTitle {
12    pub title: String,
13    pub pr_id: Option<i64>,
14    pub pr_url: Option<Url>,
15    pub commit_emoji: Option<String>,
16    pub commit_type: Option<String>,
17    pub commit_scope: Option<String>,
18    pub commit_breaking: bool,
19    pub section: Option<ChangeKind>,
20    pub entry: String,
21}
22
23impl PrTitle {
24    pub fn parse(title: &str) -> Result<Self, Error> {
25        let re = regex::Regex::new(
26            r"^(?P<emoji>.+\s)?(?P<type>[a-z]+)(?:\((?P<scope>.+)\))?(?P<breaking>!)?: (?P<description>.*)$$",
27        )?;
28
29        log::debug!("String to parse: `{}`", title);
30
31        let pr_title = if let Some(captures) = re.captures(title) {
32            log::debug!("Captures: {:#?}", captures);
33            let commit_emoji = captures.name("emoji").map(|m| m.as_str().to_string());
34            let commit_type = captures.name("type").map(|m| m.as_str().to_string());
35            let commit_scope = captures.name("scope").map(|m| m.as_str().to_string());
36            let commit_breaking = captures.name("breaking").is_some();
37            let title = captures
38                .name("description")
39                .map(|m| m.as_str().to_string())
40                .unwrap();
41
42            Self {
43                title,
44                pr_id: None,
45                pr_url: None,
46                commit_emoji,
47                commit_type,
48                commit_scope,
49                commit_breaking,
50                section: None,
51                entry: String::new(),
52            }
53        } else {
54            Self {
55                title: title.to_string(),
56                pr_id: None,
57                pr_url: None,
58                commit_emoji: None,
59                commit_type: None,
60                commit_scope: None,
61                commit_breaking: false,
62                section: None,
63                entry: String::new(),
64            }
65        };
66
67        log::debug!("Parsed title: {:?}", pr_title);
68
69        Ok(pr_title)
70    }
71
72    pub fn set_pr_id(&mut self, id: i64) {
73        self.pr_id = Some(id);
74    }
75
76    pub fn set_pr_url(&mut self, url: Url) {
77        self.pr_url = Some(url);
78    }
79
80    pub fn calculate_section_and_entry(&mut self) {
81        log::trace!("Calculating section and entry for `{:#?}`", self);
82        let mut section = ChangeKind::Changed;
83        let mut entry = self.title.clone();
84
85        log::debug!("Initial description `{}`", entry);
86
87        if let Some(commit_type) = &self.commit_type {
88            match commit_type.as_str() {
89                "feat" => section = ChangeKind::Added,
90                "fix" => {
91                    section = ChangeKind::Fixed;
92                    if let Some(commit_scope) = &self.commit_scope {
93                        log::trace!("Found scope `{}`", commit_scope);
94                        entry = format!("{}: {}", commit_scope, self.title);
95                    }
96                }
97                _ => {
98                    section = ChangeKind::Changed;
99                    entry = format!("{}-{}", self.commit_type.as_ref().unwrap(), entry);
100
101                    log::debug!("After checking for `feat` or `fix` type: `{}`", entry);
102
103                    if let Some(commit_scope) = &self.commit_scope {
104                        log::trace!("Checking scope `{}`", commit_scope);
105                        match commit_scope.as_str() {
106                            "security" => {
107                                section = ChangeKind::Security;
108                                entry = format!("Security: {}", self.title);
109                            }
110                            "deps" => {
111                                section = ChangeKind::Security;
112                                entry = format!("Dependencies: {}", self.title);
113                            }
114                            "remove" => {
115                                section = ChangeKind::Removed;
116                                entry = format!("Removed: {}", self.title);
117                            }
118                            "deprecate" => {
119                                section = ChangeKind::Deprecated;
120                                entry = format!("Deprecated: {}", self.title);
121                            }
122                            _ => {
123                                section = ChangeKind::Changed;
124                                let split_description = entry.splitn(2, '-').collect::<Vec<&str>>();
125                                log::trace!("Split description: {:#?}", split_description);
126                                entry = format!(
127                                    "{}({})-{}",
128                                    split_description[0], commit_scope, split_description[1]
129                                );
130                            }
131                        }
132                    }
133                }
134            }
135        }
136        log::debug!("After checking scope `{}`", entry);
137
138        if self.commit_breaking {
139            entry = format!("BREAKING: {}", entry);
140        }
141
142        if let Some(id) = self.pr_id {
143            if self.pr_url.is_some() {
144                entry = format!("{}(pr [#{}])", entry, id);
145            } else {
146                entry = format!("{}(pr #{})", entry, id);
147            }
148
149            log::debug!("After checking pr id `{}`", entry);
150        };
151
152        // Prepend the emoji to the entry
153        if let Some(emoji) = &self.commit_emoji {
154            entry = format!("{}{}", emoji, entry);
155        }
156
157        log::debug!("Final entry `{}`", entry);
158        self.section = Some(section);
159        self.entry = entry;
160    }
161
162    fn section(&self) -> ChangeKind {
163        match &self.section {
164            Some(kind) => kind.clone(),
165            None => ChangeKind::Changed,
166        }
167    }
168
169    fn entry(&self) -> String {
170        if self.entry.as_str() == "" {
171            self.title.clone()
172        } else {
173            self.entry.clone()
174        }
175    }
176
177    pub fn update_changelog(
178        &mut self,
179        log_file: &OsStr,
180        opts: ChangelogParseOptions,
181    ) -> Result<Option<(ChangeKind, String)>, Error> {
182        let Some(log_file) = log_file.to_str() else {
183            return Err(Error::InvalidPath(log_file.to_owned()));
184        };
185
186        let repo_url = match &self.pr_url {
187            Some(pr_url) => {
188                let url_string = pr_url.to_string();
189                let components = url_string.split('/').collect::<Vec<&str>>();
190                let url = format!("https://github.com/{}/{}", components[3], components[4]);
191                Some(url)
192            }
193            None => None,
194        };
195
196        self.calculate_section_and_entry();
197
198        log::trace!("Changelog entry:\n\n---\n{}\n---\n\n", self.entry());
199
200        let mut change_log = if path::Path::new(log_file).exists() {
201            let file_contents = fs::read_to_string(path::Path::new(log_file))?;
202            log::trace!(
203                "file contents:\n---\n{}\n---\n\n",
204                file_contents
205                    .lines()
206                    .take(20)
207                    .collect::<Vec<&str>>()
208                    .join("\n")
209            );
210            if file_contents.contains(&self.entry) {
211                log::trace!("The changelog exists and already contains the entry!");
212                return Ok(None);
213            } else {
214                log::trace!("The changelog exists but does not contain the entry!");
215            }
216
217            Changelog::parse_from_file(log_file, Some(opts))
218                .map_err(|e| Error::KeepAChangelog(e.to_string()))?
219        } else {
220            log::trace!("The changelog does not exist! Create a default changelog.");
221            let mut changelog = ChangelogBuilder::default()
222                .url(repo_url)
223                .build()
224                .map_err(|e| Error::KeepAChangelog(e.to_string()))?;
225            log::debug!("Changelog: {:#?}", changelog);
226            let release = Release::builder()
227                .build()
228                .map_err(|e| Error::KeepAChangelog(e.to_string()))?;
229            changelog.add_release(release);
230            log::debug!("Changelog: {:#?}", changelog);
231
232            changelog
233                .save_to_file(log_file)
234                .map_err(|e| Error::KeepAChangelog(e.to_string()))?;
235            changelog
236        };
237
238        // Get the unreleased section from the Changelog.
239        // If there is no unreleased section create it and add it to the changelog
240        let unreleased = if let Some(unreleased) = change_log.get_unreleased_mut() {
241            unreleased
242        } else {
243            let release = Release::builder()
244                .build()
245                .map_err(|e| Error::KeepAChangelog(e.to_string()))?;
246            change_log.add_release(release);
247            let unreleased = change_log.get_unreleased_mut().unwrap();
248            unreleased
249        };
250
251        match self.section() {
252            ChangeKind::Added => {
253                unreleased.added(self.entry());
254            }
255            ChangeKind::Fixed => {
256                unreleased.fixed(self.entry());
257            }
258            ChangeKind::Security => {
259                unreleased.security(self.entry());
260            }
261            ChangeKind::Removed => {
262                unreleased.removed(self.entry());
263            }
264            ChangeKind::Deprecated => {
265                unreleased.deprecated(self.entry());
266            }
267            ChangeKind::Changed => {
268                unreleased.changed(self.entry());
269            }
270        }
271
272        // add link to the url if it exists
273        if self.pr_url.is_some() {
274            change_log.add_link(
275                &format!("[#{}]:", self.pr_id.unwrap()),
276                &self.pr_url.clone().unwrap().to_string(),
277            ); // TODO: Add the PR link to the changelog.
278        }
279
280        change_log
281            .save_to_file(log_file)
282            .map_err(|e| Error::KeepAChangelog(e.to_string()))?;
283
284        Ok(Some((self.section(), self.entry())))
285    }
286}
287
288//test module
289#[cfg(test)]
290mod tests {
291    use std::{
292        fs::{self, File},
293        io::Write,
294        path::Path,
295    };
296
297    use super::*;
298    use log::LevelFilter;
299    use rstest::rstest;
300    use uuid::Uuid;
301
302    fn get_test_logger() {
303        let mut builder = env_logger::Builder::new();
304        builder.filter(None, LevelFilter::Debug);
305        builder.format_timestamp_secs().format_module_path(false);
306        let _ = builder.try_init();
307    }
308
309    #[test]
310    fn test_pr_title_parse() {
311        let pr_title = PrTitle::parse("feat: add new feature").unwrap();
312
313        assert_eq!(pr_title.title, "add new feature");
314        assert_eq!(pr_title.commit_type, Some("feat".to_string()));
315        assert_eq!(pr_title.commit_scope, None);
316        assert!(!pr_title.commit_breaking);
317
318        let pr_title = PrTitle::parse("feat(core): add new feature").unwrap();
319        assert_eq!(pr_title.title, "add new feature");
320        assert_eq!(pr_title.commit_type, Some("feat".to_string()));
321        assert_eq!(pr_title.commit_scope, Some("core".to_string()));
322        assert!(!pr_title.commit_breaking);
323
324        let pr_title = PrTitle::parse("feat(core)!: add new feature").unwrap();
325        assert_eq!(pr_title.title, "add new feature");
326        assert_eq!(pr_title.commit_type, Some("feat".to_string()));
327        assert_eq!(pr_title.commit_scope, Some("core".to_string()));
328        assert!(pr_title.commit_breaking);
329    }
330
331    #[test]
332    fn test_pr_title_parse_with_breaking_scope() {
333        let pr_title = PrTitle::parse("feat(core)!: add new feature").unwrap();
334        assert_eq!(pr_title.title, "add new feature");
335        assert_eq!(pr_title.commit_type, Some("feat".to_string()));
336        assert_eq!(pr_title.commit_scope, Some("core".to_string()));
337        assert!(pr_title.commit_breaking);
338    }
339
340    #[test]
341    fn test_pr_title_parse_with_security_scope() {
342        let pr_title = PrTitle::parse("fix(security): fix security vulnerability").unwrap();
343        assert_eq!(pr_title.title, "fix security vulnerability");
344        assert_eq!(pr_title.commit_type, Some("fix".to_string()));
345        assert_eq!(pr_title.commit_scope, Some("security".to_string()));
346        assert!(!pr_title.commit_breaking);
347    }
348
349    #[test]
350    fn test_pr_title_parse_with_deprecate_scope() {
351        let pr_title = PrTitle::parse("chore(deprecate): deprecate old feature").unwrap();
352        assert_eq!(pr_title.title, "deprecate old feature");
353        assert_eq!(pr_title.commit_type, Some("chore".to_string()));
354        assert_eq!(pr_title.commit_scope, Some("deprecate".to_string()));
355        assert!(!pr_title.commit_breaking);
356    }
357
358    #[test]
359    fn test_pr_title_parse_without_scope() {
360        let pr_title = PrTitle::parse("docs: update documentation").unwrap();
361        assert_eq!(pr_title.title, "update documentation");
362        assert_eq!(pr_title.commit_type, Some("docs".to_string()));
363        assert_eq!(pr_title.commit_scope, None);
364        assert!(!pr_title.commit_breaking);
365    }
366
367    #[test]
368    fn test_pr_title_parse_issue_172() {
369        let pr_title = PrTitle::parse(
370            "chore(config.yml): update jerus-org/circleci-toolkit orb version to 0.4.0",
371        )
372        .unwrap();
373        assert_eq!(
374            pr_title.title,
375            "update jerus-org/circleci-toolkit orb version to 0.4.0"
376        );
377        assert_eq!(pr_title.commit_type, Some("chore".to_string()));
378        assert_eq!(pr_title.commit_scope, Some("config.yml".to_string()));
379        assert!(!pr_title.commit_breaking);
380    }
381
382    #[rstest]
383    #[case(
384        "feat: add new feature",
385        Some(5),
386        Some("https://github.com/jerus-org/pcu/pull/5"),
387        ChangeKind::Added,
388        "add new feature(pr [#5])"
389    )]
390    #[case(
391        "✨ feat: add new feature",
392        Some(5),
393        Some("https://github.com/jerus-org/pcu/pull/5"),
394        ChangeKind::Added,
395        "✨ add new feature(pr [#5])"
396    )]
397    #[case(
398        "feat: add new feature",
399        Some(5),
400        None,
401        ChangeKind::Added,
402        "add new feature(pr #5)"
403    )]
404    #[case(
405        "feat: add new feature",
406        None,
407        Some("https://github.com/jerus-org/pcu/pull/5"),
408        ChangeKind::Added,
409        "add new feature"
410    )]
411    #[case(
412        "feat: add new feature",
413        None,
414        None,
415        ChangeKind::Added,
416        "add new feature"
417    )]
418    #[case(
419        "✨ feat: add new feature",
420        None,
421        None,
422        ChangeKind::Added,
423        "✨ add new feature"
424    )]
425    #[case(
426        "fix: fix an existing feature",
427        None,
428        None,
429        ChangeKind::Fixed,
430        "fix an existing feature"
431    )]
432    #[case(
433        "🐛 fix: fix an existing feature",
434        None,
435        None,
436        ChangeKind::Fixed,
437        "🐛 fix an existing feature"
438    )]
439    #[case(
440        "style: fix typo and lint issues",
441        None,
442        None,
443        ChangeKind::Changed,
444        "style-fix typo and lint issues"
445    )]
446    #[case(
447        "💄 style: fix typo and lint issues",
448        None,
449        None,
450        ChangeKind::Changed,
451        "💄 style-fix typo and lint issues"
452    )]
453    #[case(
454        "test: update tests",
455        None,
456        None,
457        ChangeKind::Changed,
458        "test-update tests"
459    )]
460    #[case(
461        "fix(security): Fix security vulnerability",
462        None,
463        None,
464        ChangeKind::Fixed,
465        "security: Fix security vulnerability"
466    )]
467    #[case(
468        "chore(deps): Update dependencies",
469        None,
470        None,
471        ChangeKind::Security,
472        "Dependencies: Update dependencies"
473    )]
474    #[case(
475        "🔧 chore(deps): Update dependencies",
476        None,
477        None,
478        ChangeKind::Security,
479        "🔧 Dependencies: Update dependencies"
480    )]
481    #[case(
482        "refactor(remove): Remove unused code",
483        None,
484        None,
485        ChangeKind::Removed,
486        "Removed: Remove unused code"
487    )]
488    #[case(
489        "♻️ refactor(remove): Remove unused code",
490        None,
491        None,
492        ChangeKind::Removed,
493        "♻️ Removed: Remove unused code"
494    )]
495    #[case(
496        "docs(deprecate): Deprecate old API",
497        None,
498        None,
499        ChangeKind::Deprecated,
500        "Deprecated: Deprecate old API"
501    )]
502    #[case(
503        "📚 docs(deprecate): Deprecate old API",
504        None,
505        None,
506        ChangeKind::Deprecated,
507        "📚 Deprecated: Deprecate old API"
508    )]
509    #[case(
510        "ci(other-scope): Update CI configuration",
511        None,
512        None,
513        ChangeKind::Changed,
514        "ci(other-scope)-Update CI configuration"
515    )]
516    #[case(
517        "👷 ci(other-scope): Update CI configuration",
518        None,
519        None,
520        ChangeKind::Changed,
521        "👷 ci(other-scope)-Update CI configuration"
522    )]
523    #[case(
524        "test!: Update test cases",
525        None,
526        None,
527        ChangeKind::Changed,
528        "BREAKING: test-Update test cases"
529    )]
530    #[case::issue_172(
531        "chore(config.yml): update jerus-org/circleci-toolkit orb version to 0.4.0",
532        Some(6),
533        Some("https://github.com/jerus-org/pcu/pull/6"),
534        ChangeKind::Changed,
535        "chore(config.yml)-update jerus-org/circleci-toolkit orb version to 0.4.0(pr [#6])"
536    )]
537    #[case::with_emoji(
538        "✨ feat(ci): add optional flag for push failure handling",
539        Some(6),
540        Some("https://github.com/jerus-org/pcu/pull/6"),
541        ChangeKind::Added,
542        "✨ add optional flag for push failure handling(pr [#6])"
543    )]
544    fn test_calculate_kind_and_description(
545        #[case] title: &str,
546        #[case] pr_id: Option<i64>,
547        #[case] pr_url: Option<&str>,
548        #[case] expected_kind: ChangeKind,
549        #[case] expected_description: &str,
550    ) -> Result<()> {
551        get_test_logger();
552
553        let mut pr_title = PrTitle::parse(title).unwrap();
554        if let Some(id) = pr_id {
555            pr_title.set_pr_id(id);
556        }
557        if let Some(url) = pr_url {
558            let url = Url::parse(url)?;
559            pr_title.set_pr_url(url);
560        }
561        pr_title.calculate_section_and_entry();
562        assert_eq!(expected_kind, pr_title.section());
563        assert_eq!(expected_description, pr_title.entry);
564
565        Ok(())
566    }
567
568    use color_eyre::Result;
569
570    #[rstest]
571    fn test_update_change_log_added() -> Result<()> {
572        get_test_logger();
573
574        let initial_content = fs::read_to_string("tests/data/initial_changelog.md")?;
575        let expected_content = fs::read_to_string("tests/data/expected_changelog.md")?;
576
577        let temp_dir_string = format!("tests/tmp/test-{}", Uuid::new_v4());
578        let temp_dir = Path::new(&temp_dir_string);
579        fs::create_dir_all(temp_dir)?;
580
581        let file_name = temp_dir.join("CHANGELOG.md");
582        log::debug!("filename : {:?}", file_name);
583
584        let mut file = File::create(&file_name)?;
585        file.write_all(initial_content.as_bytes())?;
586
587        let mut pr_title = PrTitle {
588            title: "add new feature".to_string(),
589            pr_id: Some(5),
590            pr_url: Some(Url::parse("https://github.com/jerus-org/pcu/pull/5")?),
591            commit_emoji: None,
592            commit_type: Some("feat".to_string()),
593            commit_scope: None,
594            commit_breaking: false,
595            section: Some(ChangeKind::Added),
596            entry: "add new feature".to_string(),
597        };
598
599        let file_name = &file_name.into_os_string();
600
601        let opts = ChangelogParseOptions::default();
602
603        pr_title.update_changelog(file_name, opts)?;
604
605        let actual_content = fs::read_to_string(file_name)?;
606
607        assert_eq!(actual_content, expected_content);
608
609        // tidy up the test environment
610        std::fs::remove_dir_all(temp_dir)?;
611
612        Ok(())
613    }
614
615    #[rstest]
616    fn test_update_change_log_added_issue_172() -> Result<()> {
617        get_test_logger();
618
619        let initial_content = fs::read_to_string("tests/data/initial_changelog.md")?;
620        let expected_content = fs::read_to_string("tests/data/expected_changelog_issue_172.md")?;
621
622        let temp_dir_string = format!("tests/tmp/test-{}", Uuid::new_v4());
623        let temp_dir = Path::new(&temp_dir_string);
624        fs::create_dir_all(temp_dir)?;
625
626        let file_name = temp_dir.join("CHANGELOG.md");
627        log::debug!("filename : {:?}", file_name);
628
629        let mut file = File::create(&file_name)?;
630        file.write_all(initial_content.as_bytes())?;
631
632        let mut pr_title = PrTitle {
633            title: "add new feature".to_string(),
634            pr_id: Some(5),
635            pr_url: Some(Url::parse("https://github.com/jerus-org/pcu/pull/5")?),
636            commit_emoji: None,
637            commit_type: Some("feat".to_string()),
638            commit_scope: None,
639            commit_breaking: false,
640            section: Some(ChangeKind::Added),
641            entry: "add new feature".to_string(),
642        };
643
644        let file_name = &file_name.into_os_string();
645        let opts = ChangelogParseOptions::default();
646
647        pr_title.update_changelog(file_name, opts)?;
648
649        let mut pr_title = PrTitle::parse(
650            "chore(config.yml): update jerus-org/circleci-toolkit orb version to 0.4.0",
651        )?;
652        pr_title.set_pr_id(6);
653        pr_title.set_pr_url(Url::parse("https://github.com/jerus-org/pcu/pull/6")?);
654        pr_title.calculate_section_and_entry();
655
656        let file_name = &file_name.to_os_string();
657        let opts = ChangelogParseOptions::default();
658
659        pr_title.update_changelog(file_name, opts)?;
660
661        let actual_content = fs::read_to_string(file_name)?;
662
663        assert_eq!(actual_content, expected_content);
664
665        // tidy up the test environment
666        std::fs::remove_dir_all(temp_dir)?;
667
668        Ok(())
669    }
670}