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