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 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 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 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 ); }
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#[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 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 std::fs::remove_dir_all(temp_dir)?;
667
668 Ok(())
669 }
670}