1#![allow(clippy::disallowed_methods, reason = "This is only used in xtasks")]
2use std::{
3 fmt::{self, Debug},
4 ops::Not,
5 process::Command,
6 str::FromStr,
7 sync::LazyLock,
8};
9
10use anyhow::{Context, Result, anyhow};
11use derive_more::{Deref, DerefMut, FromStr};
12
13use itertools::Itertools;
14use regex::Regex;
15use semver::Version;
16use serde::Deserialize;
17
18pub(crate) const ZED_ZIPPY_LOGIN: &str = "zed-zippy[bot]";
19pub(crate) const ZED_ZIPPY_EMAIL: &str = "234243425+zed-zippy[bot]@users.noreply.github.com";
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum AutomatedChangeKind {
23 VersionBump,
24 ReleaseChannelUpdate,
25}
26
27impl AutomatedChangeKind {
28 pub(crate) fn expected_files(&self) -> &'static [&'static str] {
29 match self {
30 Self::VersionBump => &["Cargo.lock", "crates/zed/Cargo.toml"],
31 Self::ReleaseChannelUpdate => &["crates/zed/RELEASE_CHANNEL"],
32 }
33 }
34
35 pub(crate) fn expected_loc(&self) -> u64 {
36 match self {
37 Self::VersionBump => 2,
38 Self::ReleaseChannelUpdate => 1,
39 }
40 }
41}
42
43impl fmt::Display for AutomatedChangeKind {
44 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45 match self {
46 Self::VersionBump => formatter.write_str("version bump"),
47 Self::ReleaseChannelUpdate => formatter.write_str("release channel update"),
48 }
49 }
50}
51
52pub trait Subcommand {
53 type ParsedOutput: FromStr<Err = anyhow::Error>;
54
55 fn args(&self) -> impl IntoIterator<Item = String>;
56}
57
58#[derive(Deref, DerefMut)]
59pub struct GitCommand<G: Subcommand> {
60 #[deref]
61 #[deref_mut]
62 subcommand: G,
63}
64
65impl<G: Subcommand> GitCommand<G> {
66 #[must_use]
67 pub fn run(subcommand: G) -> Result<G::ParsedOutput> {
68 Self { subcommand }.run_impl()
69 }
70
71 fn run_impl(self) -> Result<G::ParsedOutput> {
72 let command_output = Command::new("git")
73 .args(self.subcommand.args())
74 .output()
75 .context("Failed to spawn command")?;
76
77 if command_output.status.success() {
78 String::from_utf8(command_output.stdout)
79 .map_err(|_| anyhow!("Invalid UTF8"))
80 .and_then(|s| {
81 G::ParsedOutput::from_str(s.trim())
82 .map_err(|e| anyhow!("Failed to parse from string: {e:?}"))
83 })
84 } else {
85 anyhow::bail!(
86 "Command failed with exit code {}, stderr: {}",
87 command_output.status.code().unwrap_or_default(),
88 String::from_utf8(command_output.stderr).unwrap_or_default()
89 )
90 }
91 }
92}
93
94#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
95pub enum ReleaseChannel {
96 Stable,
97 Preview,
98}
99
100impl ReleaseChannel {
101 pub(crate) fn tag_suffix(&self) -> &'static str {
102 match self {
103 ReleaseChannel::Stable => "",
104 ReleaseChannel::Preview => "-pre",
105 }
106 }
107}
108
109#[derive(Debug, Clone)]
110pub struct VersionTag(Version, ReleaseChannel);
111
112impl VersionTag {
113 pub fn parse(input: &str) -> Result<Self, anyhow::Error> {
114 // Being a bit more lenient for human inputs
115 let version = input.strip_prefix('v').unwrap_or(input);
116
117 let (version_str, channel) = version
118 .strip_suffix("-pre")
119 .map_or((version, ReleaseChannel::Stable), |version_str| {
120 (version_str, ReleaseChannel::Preview)
121 });
122
123 Version::parse(version_str)
124 .map(|version| Self(version, channel))
125 .map_err(|_| anyhow::anyhow!("Failed to parse version from tag!"))
126 }
127
128 pub fn version(&self) -> &Version {
129 &self.0
130 }
131}
132
133impl ToString for VersionTag {
134 fn to_string(&self) -> String {
135 format!(
136 "v{version}{channel_suffix}",
137 version = self.0,
138 channel_suffix = self.1.tag_suffix()
139 )
140 }
141}
142
143#[derive(Debug, Deref, FromStr, PartialEq, Eq, Hash, Deserialize)]
144pub struct CommitSha(pub(crate) String);
145
146impl CommitSha {
147 pub fn new(sha: String) -> Self {
148 Self(sha)
149 }
150
151 pub fn short(&self) -> &str {
152 self.0.as_str().split_at(8).0
153 }
154}
155
156#[derive(Debug)]
157pub struct CommitDetails {
158 sha: CommitSha,
159 author: Committer,
160 title: String,
161 body: String,
162}
163
164impl CommitDetails {
165 pub fn new(sha: CommitSha, author: Committer, title: String, body: String) -> Self {
166 Self {
167 sha,
168 author,
169 title,
170 body,
171 }
172 }
173}
174
175impl FromStr for CommitDetails {
176 type Err = anyhow::Error;
177
178 fn from_str(s: &str) -> Result<Self, Self::Err> {
179 CommitList::from_str(s).and_then(|list| {
180 list.into_iter()
181 .next()
182 .ok_or_else(|| anyhow!("No commit found"))
183 })
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct Committer {
189 name: String,
190 email: String,
191}
192
193impl Committer {
194 pub fn new(name: &str, email: &str) -> Self {
195 Self {
196 name: name.to_owned(),
197 email: email.to_owned(),
198 }
199 }
200
201 pub(crate) fn is_zed_zippy(&self) -> bool {
202 self.email == ZED_ZIPPY_EMAIL
203 }
204}
205
206impl fmt::Display for Committer {
207 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
208 write!(formatter, "{} ({})", self.name, self.email)
209 }
210}
211
212impl CommitDetails {
213 const BODY_DELIMITER: &str = "|body-delimiter|";
214 const COMMIT_DELIMITER: &str = "|commit-delimiter|";
215 const FIELD_DELIMITER: &str = "|field-delimiter|";
216 const FORMAT_STRING: &str = "%H|field-delimiter|%an|field-delimiter|%ae|field-delimiter|%s|body-delimiter|%b|commit-delimiter|";
217
218 fn parse(line: &str, body: &str) -> Result<Self, anyhow::Error> {
219 let Some([sha, author_name, author_email, title]) =
220 line.splitn(4, Self::FIELD_DELIMITER).collect_array()
221 else {
222 return Err(anyhow!("Failed to parse commit fields from input {line}"));
223 };
224
225 Ok(CommitDetails {
226 sha: CommitSha(sha.to_owned()),
227 author: Committer::new(author_name, author_email),
228 title: title.to_owned(),
229 body: body.to_owned(),
230 })
231 }
232
233 pub fn pr_number(&self) -> Option<u64> {
234 // Since we use squash merge, all commit titles end with the '(#12345)' pattern.
235 // While we could strictly speaking index into this directly, go for a slightly
236 // less prone approach to errors
237 const PATTERN: &str = " (#";
238 self.title
239 .rfind(PATTERN)
240 .and_then(|location| {
241 self.title[location..]
242 .find(')')
243 .map(|relative_end| location + PATTERN.len()..location + relative_end)
244 })
245 .and_then(|range| self.title[range].parse().ok())
246 }
247
248 pub(crate) fn co_authors(&self) -> Option<Vec<Committer>> {
249 static CO_AUTHOR_REGEX: LazyLock<Regex> =
250 LazyLock::new(|| Regex::new(r"Co-authored-by: (.+) <(.+)>").unwrap());
251
252 let mut co_authors = Vec::new();
253
254 for cap in CO_AUTHOR_REGEX.captures_iter(&self.body.as_ref()) {
255 let Some((name, email)) = cap
256 .get(1)
257 .map(|m| m.as_str())
258 .zip(cap.get(2).map(|m| m.as_str()))
259 else {
260 continue;
261 };
262 co_authors.push(Committer::new(name, email));
263 }
264
265 co_authors.is_empty().not().then_some(co_authors)
266 }
267
268 pub(crate) fn author(&self) -> &Committer {
269 &self.author
270 }
271
272 pub(crate) fn title(&self) -> &str {
273 &self.title
274 }
275
276 pub fn sha(&self) -> &CommitSha {
277 &self.sha
278 }
279
280 pub(crate) fn detect_automated_change(&self) -> Option<(AutomatedChangeKind, &str)> {
281 static VERSION_BUMP_REGEX: LazyLock<Regex> = LazyLock::new(|| {
282 Regex::new(r"^Bump to [0-9]+\.[0-9]+\.[0-9]+ for @([a-zA-Z0-9][a-zA-Z0-9-]*)$").unwrap()
283 });
284 static RELEASE_CHANNEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
285 Regex::new(r"^v[0-9]+\.[0-9]+\.x (?:stable|preview) for @([a-zA-Z0-9][a-zA-Z0-9-]*)$")
286 .unwrap()
287 });
288
289 VERSION_BUMP_REGEX
290 .captures(&self.title)
291 .and_then(|capture| capture.get(1))
292 .map(|r#match| (AutomatedChangeKind::VersionBump, r#match.as_str()))
293 .or_else(|| {
294 RELEASE_CHANNEL_REGEX
295 .captures(&self.title)
296 .and_then(|capture| capture.get(1))
297 .map(|r#match| (AutomatedChangeKind::ReleaseChannelUpdate, r#match.as_str()))
298 })
299 }
300}
301
302#[derive(Debug, Deref, Default, DerefMut)]
303pub struct CommitList(Vec<CommitDetails>);
304
305impl CommitList {
306 pub fn range(&self) -> Option<String> {
307 self.0
308 .first()
309 .zip(self.0.last())
310 .map(|(first, last)| format!("{}..{}", last.sha().0, first.sha().0))
311 }
312}
313
314impl IntoIterator for CommitList {
315 type IntoIter = std::vec::IntoIter<CommitDetails>;
316 type Item = CommitDetails;
317
318 fn into_iter(self) -> std::vec::IntoIter<Self::Item> {
319 self.0.into_iter()
320 }
321}
322
323impl FromStr for CommitList {
324 type Err = anyhow::Error;
325
326 fn from_str(input: &str) -> Result<Self, Self::Err> {
327 Ok(CommitList(
328 input
329 .split(CommitDetails::COMMIT_DELIMITER)
330 .filter(|commit_details| !commit_details.is_empty())
331 .map(|commit_details| {
332 let (line, body) = commit_details
333 .trim()
334 .split_once(CommitDetails::BODY_DELIMITER)
335 .expect("Missing body delimiter");
336 CommitDetails::parse(line, body)
337 .expect("Parsing from the output should succeed")
338 })
339 .collect(),
340 ))
341 }
342}
343
344pub struct GetVersionTags;
345
346impl Subcommand for GetVersionTags {
347 type ParsedOutput = VersionTagList;
348
349 fn args(&self) -> impl IntoIterator<Item = String> {
350 ["tag", "-l", "v*"].map(ToOwned::to_owned)
351 }
352}
353
354pub struct VersionTagList(Vec<VersionTag>);
355
356impl VersionTagList {
357 pub fn sorted(mut self) -> Self {
358 self.0.sort_by(|a, b| a.version().cmp(b.version()));
359 self
360 }
361
362 pub fn find_previous_minor_version(&self, version_tag: &VersionTag) -> Option<&VersionTag> {
363 self.0
364 .iter()
365 .take_while(|tag| tag.version() < version_tag.version())
366 .collect_vec()
367 .into_iter()
368 .rev()
369 .find(|tag| {
370 (tag.version().major < version_tag.version().major
371 || (tag.version().major == version_tag.version().major
372 && tag.version().minor < version_tag.version().minor))
373 && tag.version().patch == 0
374 })
375 }
376}
377
378impl FromStr for VersionTagList {
379 type Err = anyhow::Error;
380
381 fn from_str(s: &str) -> Result<Self, Self::Err> {
382 let version_tags = s.lines().flat_map(VersionTag::parse).collect_vec();
383
384 version_tags
385 .is_empty()
386 .not()
387 .then_some(Self(version_tags))
388 .ok_or_else(|| anyhow::anyhow!("No version tags found"))
389 }
390}
391
392pub struct InfoForCommit {
393 sha: String,
394}
395
396impl InfoForCommit {
397 pub fn new(sha: impl ToString) -> Self {
398 Self {
399 sha: sha.to_string(),
400 }
401 }
402}
403
404impl Subcommand for InfoForCommit {
405 type ParsedOutput = CommitDetails;
406
407 fn args(&self) -> impl IntoIterator<Item = String> {
408 [
409 "log".to_string(),
410 format!("--pretty=format:{}", CommitDetails::FORMAT_STRING),
411 format!("{sha}~1..{sha}", sha = self.sha),
412 ]
413 }
414}
415
416pub struct CommitsFromVersionToVersion {
417 version_tag: VersionTag,
418 branch: String,
419}
420
421impl CommitsFromVersionToVersion {
422 pub fn new(version_tag: VersionTag, branch: String) -> Self {
423 Self {
424 version_tag,
425 branch,
426 }
427 }
428}
429
430impl Subcommand for CommitsFromVersionToVersion {
431 type ParsedOutput = CommitList;
432
433 fn args(&self) -> impl IntoIterator<Item = String> {
434 [
435 "log".to_string(),
436 format!("--pretty=format:{}", CommitDetails::FORMAT_STRING),
437 format!(
438 "{version}..{branch}",
439 version = self.version_tag.to_string(),
440 branch = self.branch
441 ),
442 ]
443 }
444}
445
446pub struct NoOutput;
447
448impl FromStr for NoOutput {
449 type Err = anyhow::Error;
450
451 fn from_str(_: &str) -> Result<Self, Self::Err> {
452 Ok(NoOutput)
453 }
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use indoc::indoc;
460
461 #[test]
462 fn parse_stable_version_tag() {
463 let tag = VersionTag::parse("v0.172.8").unwrap();
464 assert_eq!(tag.version().major, 0);
465 assert_eq!(tag.version().minor, 172);
466 assert_eq!(tag.version().patch, 8);
467 assert_eq!(tag.1, ReleaseChannel::Stable);
468 }
469
470 #[test]
471 fn parse_preview_version_tag() {
472 let tag = VersionTag::parse("v0.172.1-pre").unwrap();
473 assert_eq!(tag.version().major, 0);
474 assert_eq!(tag.version().minor, 172);
475 assert_eq!(tag.version().patch, 1);
476 assert_eq!(tag.1, ReleaseChannel::Preview);
477 }
478
479 #[test]
480 fn parse_version_tag_without_v_prefix() {
481 let tag = VersionTag::parse("0.172.8").unwrap();
482 assert_eq!(tag.version().major, 0);
483 assert_eq!(tag.version().minor, 172);
484 assert_eq!(tag.version().patch, 8);
485 }
486
487 #[test]
488 fn parse_invalid_version_tag() {
489 let result = VersionTag::parse("vConradTest");
490 assert!(result.is_err());
491 }
492
493 #[test]
494 fn version_tag_stable_roundtrip() {
495 let tag = VersionTag::parse("v0.172.8").unwrap();
496 assert_eq!(tag.to_string(), "v0.172.8");
497 }
498
499 #[test]
500 fn version_tag_preview_roundtrip() {
501 let tag = VersionTag::parse("v0.172.1-pre").unwrap();
502 assert_eq!(tag.to_string(), "v0.172.1-pre");
503 }
504
505 #[test]
506 fn sorted_orders_by_semver() {
507 let input = indoc! {"
508 v0.172.8
509 v0.170.1
510 v0.171.4
511 v0.170.2
512 v0.172.11
513 v0.171.3
514 v0.172.9
515 "};
516 let list = VersionTagList::from_str(input).unwrap().sorted();
517 for window in list.0.windows(2) {
518 assert!(
519 window[0].version() <= window[1].version(),
520 "{} should come before {}",
521 window[0].to_string(),
522 window[1].to_string()
523 );
524 }
525 assert_eq!(list.0[0].to_string(), "v0.170.1");
526 assert_eq!(list.0[list.0.len() - 1].to_string(), "v0.172.11");
527 }
528
529 #[test]
530 fn find_previous_minor_for_173_returns_172() {
531 let input = indoc! {"
532 v0.170.1
533 v0.170.2
534 v0.171.3
535 v0.171.4
536 v0.172.0
537 v0.172.8
538 v0.172.9
539 v0.172.11
540 "};
541 let list = VersionTagList::from_str(input).unwrap().sorted();
542 let target = VersionTag::parse("v0.173.0").unwrap();
543 let previous = list.find_previous_minor_version(&target).unwrap();
544 assert_eq!(previous.version().major, 0);
545 assert_eq!(previous.version().minor, 172);
546 assert_eq!(previous.version().patch, 0);
547 }
548
549 #[test]
550 fn find_previous_minor_skips_same_minor() {
551 let input = indoc! {"
552 v0.172.8
553 v0.172.9
554 v0.172.11
555 "};
556 let list = VersionTagList::from_str(input).unwrap().sorted();
557 let target = VersionTag::parse("v0.172.8").unwrap();
558 assert!(list.find_previous_minor_version(&target).is_none());
559 }
560
561 #[test]
562 fn find_previous_minor_with_major_version_gap() {
563 let input = indoc! {"
564 v0.172.0
565 v0.172.9
566 v0.172.11
567 "};
568 let list = VersionTagList::from_str(input).unwrap().sorted();
569 let target = VersionTag::parse("v1.0.0").unwrap();
570 let previous = list.find_previous_minor_version(&target).unwrap();
571 assert_eq!(previous.to_string(), "v0.172.0");
572 }
573
574 #[test]
575 fn find_previous_minor_requires_zero_patch_version() {
576 let input = indoc! {"
577 v0.172.1
578 v0.172.9
579 v0.172.11
580 "};
581 let list = VersionTagList::from_str(input).unwrap().sorted();
582 let target = VersionTag::parse("v1.0.0").unwrap();
583 assert!(list.find_previous_minor_version(&target).is_none());
584 }
585
586 #[test]
587 fn parse_tag_list_from_real_tags() {
588 let input = indoc! {"
589 v0.9999-temporary
590 vConradTest
591 v0.172.8
592 "};
593 let list = VersionTagList::from_str(input).unwrap();
594 assert_eq!(list.0.len(), 1);
595 assert_eq!(list.0[0].to_string(), "v0.172.8");
596 }
597
598 #[test]
599 fn parse_empty_tag_list_fails() {
600 let result = VersionTagList::from_str("");
601 assert!(result.is_err());
602 }
603
604 #[test]
605 fn pr_number_from_squash_merge_title() {
606 let line = format!(
607 "abc123{d}Author Name{d}author@email.com{d}Add cool feature (#12345)",
608 d = CommitDetails::FIELD_DELIMITER
609 );
610 let commit = CommitDetails::parse(&line, "").unwrap();
611 assert_eq!(commit.pr_number(), Some(12345));
612 }
613
614 #[test]
615 fn pr_number_missing() {
616 let line = format!(
617 "abc123{d}Author Name{d}author@email.com{d}Some commit without PR ref",
618 d = CommitDetails::FIELD_DELIMITER
619 );
620 let commit = CommitDetails::parse(&line, "").unwrap();
621 assert_eq!(commit.pr_number(), None);
622 }
623
624 #[test]
625 fn pr_number_takes_last_match() {
626 let line = format!(
627 "abc123{d}Author Name{d}author@email.com{d}Fix (#123) and refactor (#456)",
628 d = CommitDetails::FIELD_DELIMITER
629 );
630 let commit = CommitDetails::parse(&line, "").unwrap();
631 assert_eq!(commit.pr_number(), Some(456));
632 }
633
634 #[test]
635 fn co_authors_parsed_from_body() {
636 let line = format!(
637 "abc123{d}Author Name{d}author@email.com{d}Some title",
638 d = CommitDetails::FIELD_DELIMITER
639 );
640 let body = indoc! {"
641 Co-authored-by: Alice Smith <alice@example.com>
642 Co-authored-by: Bob Jones <bob@example.com>
643 "};
644 let commit = CommitDetails::parse(&line, body).unwrap();
645 let co_authors = commit.co_authors().unwrap();
646 assert_eq!(co_authors.len(), 2);
647 assert_eq!(
648 co_authors[0],
649 Committer::new("Alice Smith", "alice@example.com")
650 );
651 assert_eq!(
652 co_authors[1],
653 Committer::new("Bob Jones", "bob@example.com")
654 );
655 }
656
657 #[test]
658 fn no_co_authors_returns_none() {
659 let line = format!(
660 "abc123{d}Author Name{d}author@email.com{d}Some title",
661 d = CommitDetails::FIELD_DELIMITER
662 );
663 let commit = CommitDetails::parse(&line, "").unwrap();
664 assert!(commit.co_authors().is_none());
665 }
666
667 #[test]
668 fn commit_sha_short_returns_first_8_chars() {
669 let sha = CommitSha("abcdef1234567890abcdef1234567890abcdef12".into());
670 assert_eq!(sha.short(), "abcdef12");
671 }
672
673 #[test]
674 fn automated_change_detects_version_bump() {
675 let line = format!(
676 "abc123{d}Zed Zippy{d}bot@test.com{d}Bump to 0.230.2 for @cole-miller",
677 d = CommitDetails::FIELD_DELIMITER
678 );
679 let commit = CommitDetails::parse(&line, "").unwrap();
680 let (kind, actor) = commit.detect_automated_change().unwrap();
681 assert_eq!(kind, AutomatedChangeKind::VersionBump);
682 assert_eq!(actor, "cole-miller");
683 }
684
685 #[test]
686 fn automated_change_detects_stable_release_channel() {
687 let line = format!(
688 "abc123{d}Zed Zippy{d}bot@test.com{d}v0.233.x stable for @cole-miller",
689 d = CommitDetails::FIELD_DELIMITER
690 );
691 let commit = CommitDetails::parse(&line, "").unwrap();
692 let (kind, actor) = commit.detect_automated_change().unwrap();
693 assert_eq!(kind, AutomatedChangeKind::ReleaseChannelUpdate);
694 assert_eq!(actor, "cole-miller");
695 }
696
697 #[test]
698 fn automated_change_detects_preview_release_channel() {
699 let line = format!(
700 "abc123{d}Zed Zippy{d}bot@test.com{d}v0.234.x preview for @cole-miller",
701 d = CommitDetails::FIELD_DELIMITER
702 );
703 let commit = CommitDetails::parse(&line, "").unwrap();
704 let (kind, actor) = commit.detect_automated_change().unwrap();
705 assert_eq!(kind, AutomatedChangeKind::ReleaseChannelUpdate);
706 assert_eq!(actor, "cole-miller");
707 }
708
709 #[test]
710 fn automated_change_returns_none_for_regular_commit() {
711 let line = format!(
712 "abc123{d}Alice{d}alice@test.com{d}Fix a bug",
713 d = CommitDetails::FIELD_DELIMITER
714 );
715 let commit = CommitDetails::parse(&line, "").unwrap();
716 assert!(commit.detect_automated_change().is_none());
717 }
718
719 #[test]
720 fn automated_change_rejects_wrong_prefix() {
721 let line = format!(
722 "abc123{d}Zed Zippy{d}bot@test.com{d}Fix thing for @cole-miller",
723 d = CommitDetails::FIELD_DELIMITER
724 );
725 let commit = CommitDetails::parse(&line, "").unwrap();
726 assert!(commit.detect_automated_change().is_none());
727 }
728
729 #[test]
730 fn automated_change_rejects_trailing_text() {
731 let line = format!(
732 "abc123{d}Zed Zippy{d}bot@test.com{d}Bump to 0.230.2 for @cole-miller extra",
733 d = CommitDetails::FIELD_DELIMITER
734 );
735 let commit = CommitDetails::parse(&line, "").unwrap();
736 assert!(commit.detect_automated_change().is_none());
737 }
738
739 #[test]
740 fn committer_is_zed_zippy() {
741 let committer = Committer::new("Zed Zippy", ZED_ZIPPY_EMAIL);
742 assert!(committer.is_zed_zippy());
743 }
744
745 #[test]
746 fn committer_is_not_zed_zippy() {
747 let committer = Committer::new("Alice", "alice@test.com");
748 assert!(!committer.is_zed_zippy());
749 }
750
751 #[test]
752 fn parse_commit_list_from_git_log_format() {
753 let fd = CommitDetails::FIELD_DELIMITER;
754 let bd = CommitDetails::BODY_DELIMITER;
755 let cd = CommitDetails::COMMIT_DELIMITER;
756
757 let input = format!(
758 "sha111{fd}Alice{fd}alice@test.com{fd}First commit (#100){bd}First body{cd}sha222{fd}Bob{fd}bob@test.com{fd}Second commit (#200){bd}Second body{cd}"
759 );
760
761 let list = CommitList::from_str(&input).unwrap();
762 assert_eq!(list.0.len(), 2);
763
764 assert_eq!(list.0[0].sha().0, "sha111");
765 assert_eq!(
766 list.0[0].author(),
767 &Committer::new("Alice", "alice@test.com")
768 );
769 assert_eq!(list.0[0].title(), "First commit (#100)");
770 assert_eq!(list.0[0].pr_number(), Some(100));
771 assert_eq!(list.0[0].body, "First body");
772
773 assert_eq!(list.0[1].sha().0, "sha222");
774 assert_eq!(list.0[1].author(), &Committer::new("Bob", "bob@test.com"));
775 assert_eq!(list.0[1].title(), "Second commit (#200)");
776 assert_eq!(list.0[1].pr_number(), Some(200));
777 assert_eq!(list.0[1].body, "Second body");
778 }
779}