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 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 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 ); }
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#[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 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 std::fs::remove_dir_all(temp_dir)?;
586
587 Ok(())
588 }
589}