upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/git/mod.rs184
-rw-r--r--src/lib/git_events.rs50
-rw-r--r--src/lib/mbox_parser.rs452
-rw-r--r--src/lib/mod.rs1
4 files changed, 651 insertions, 36 deletions
diff --git a/src/lib/git/mod.rs b/src/lib/git/mod.rs
index 516d9e2..57e8403 100644
--- a/src/lib/git/mod.rs
+++ b/src/lib/git/mod.rs
@@ -62,6 +62,8 @@ pub trait RepoActions {
62 /// returns vector ["name", "email", "unixtime", "offset"] 62 /// returns vector ["name", "email", "unixtime", "offset"]
63 /// eg ["joe bloggs", "joe@pm.me", "12176","-300"] 63 /// eg ["joe bloggs", "joe@pm.me", "12176","-300"]
64 fn get_commit_comitter(&self, commit: &Sha1Hash) -> Result<Vec<String>>; 64 fn get_commit_comitter(&self, commit: &Sha1Hash) -> Result<Vec<String>>;
65 fn get_commit_committer_time(&self, commit: &Sha1Hash) -> Result<i64>;
66 fn find_best_guess_parent_commit(&self, patch_timestamp: i64) -> Result<Option<Sha1Hash>>;
65 fn get_commits_ahead_behind( 67 fn get_commits_ahead_behind(
66 &self, 68 &self,
67 base_commit: &Sha1Hash, 69 base_commit: &Sha1Hash,
@@ -339,6 +341,50 @@ impl RepoActions for Repo {
339 Ok(git_sig_to_tag_vec(&sig)) 341 Ok(git_sig_to_tag_vec(&sig))
340 } 342 }
341 343
344 fn get_commit_committer_time(&self, commit_hash: &Sha1Hash) -> Result<i64> {
345 let commit = self
346 .git_repo
347 .find_commit(sha1_to_oid(commit_hash)?)
348 .context(format!("could not find commit {commit_hash}"))?;
349 let time = commit.committer().when().seconds();
350 Ok(time)
351 }
352
353 fn find_best_guess_parent_commit(&self, patch_timestamp: i64) -> Result<Option<Sha1Hash>> {
354 let (main_branch_name, _) = self
355 .get_main_or_master_branch()
356 .context("failed to get main/master branch")?;
357
358 let mut revwalk = self
359 .git_repo
360 .revwalk()
361 .context("failed to create revwalk")?;
362
363 revwalk
364 .push_ref(&format!("refs/heads/{}", main_branch_name))
365 .context("failed to push main branch to revwalk")?;
366
367 let mut best_commit: Option<(i64, Sha1Hash)> = None;
368
369 for oid_result in revwalk {
370 let oid = oid_result.context("failed to get oid from revwalk")?;
371 let commit = self
372 .git_repo
373 .find_commit(oid)
374 .context("failed to find commit")?;
375
376 let committer_time = commit.committer().when().seconds();
377
378 if committer_time < patch_timestamp
379 && (best_commit.is_none() || committer_time > best_commit.as_ref().unwrap().0)
380 {
381 best_commit = Some((committer_time, oid_to_sha1(&oid)));
382 }
383 }
384
385 Ok(best_commit.map(|(_, sha1)| sha1))
386 }
387
342 fn get_refs(&self, commit: &Sha1Hash) -> Result<Vec<String>> { 388 fn get_refs(&self, commit: &Sha1Hash) -> Result<Vec<String>> {
343 Ok(self 389 Ok(self
344 .git_repo 390 .git_repo
@@ -542,16 +588,16 @@ impl RepoActions for Repo {
542 }) 588 })
543 .collect(); 589 .collect();
544 590
545 let parent_commit_id = tag_value( 591 let parent_commit_id = match patches_to_apply.last() {
546 if let Ok(last_patch) = patches_to_apply.last().context("no patches") { 592 Some(last_patch) => {
547 last_patch 593 crate::git_events::get_parent_commit_from_patch(last_patch, Some(self))?
548 } else { 594 }
595 None => {
549 self.checkout(branch_name) 596 self.checkout(branch_name)
550 .context("no patches and so failed to create a proposal branch")?; 597 .context("no patches and so failed to create a proposal branch")?;
551 return Ok(vec![]); 598 return Ok(vec![]);
552 }, 599 }
553 "parent-commit", 600 };
554 )?;
555 601
556 // check patches can be applied 602 // check patches can be applied
557 if !self.does_commit_exist(&parent_commit_id)? { 603 if !self.does_commit_exist(&parent_commit_id)? {
@@ -590,8 +636,23 @@ impl RepoActions for Repo {
590 636
591 let parent_commit_id = if let Some(commit_id) = parent_commit_id_override.clone() { 637 let parent_commit_id = if let Some(commit_id) = parent_commit_id_override.clone() {
592 commit_id 638 commit_id
639 } else if let Ok(parent) = tag_value(patch, "parent-commit") {
640 parent
593 } else { 641 } else {
594 tag_value(patch, "parent-commit")? 642 let metadata = crate::mbox_parser::parse_mbox_patch(&patch.content)
643 .context("failed to parse patch for timestamp")?;
644 let timestamp = metadata.committer_timestamp.unwrap_or(metadata.author_timestamp);
645
646 let best_guess = self
647 .find_best_guess_parent_commit(timestamp)
648 .context("failed to find best guess parent commit")?;
649
650 match best_guess {
651 Some(sha1) => sha1.to_string(),
652 None => bail!(
653 "no parent-commit tag and could not determine best guess parent from patch timestamp"
654 ),
655 }
595 }; 656 };
596 657
597 let parent_commit = self 658 let parent_commit = self
@@ -623,10 +684,15 @@ impl RepoActions for Repo {
623 None 684 None
624 }; 685 };
625 686
687 let author_data = extract_signature_data_with_fallback(&patch.tags, "author", &patch.content)?;
688 let committer_data = extract_signature_data_with_fallback(&patch.tags, "committer", &patch.content)?;
689 let author_sig = author_data.to_signature()?;
690 let committer_sig = committer_data.to_signature()?;
691
626 let commit_buff = self.git_repo.commit_create_buffer( 692 let commit_buff = self.git_repo.commit_create_buffer(
627 &extract_sig_from_patch_tags(&patch.tags, "author")?, 693 &author_sig,
628 &extract_sig_from_patch_tags(&patch.tags, "committer")?, 694 &committer_sig,
629 tag_value(patch, "description")?.as_str(), 695 extract_description_from_patch(patch)?.as_str(),
630 &tree, 696 &tree,
631 &[&parent_commit], 697 &[&parent_commit],
632 )?; 698 )?;
@@ -897,7 +963,14 @@ fn git_sig_to_tag_vec(sig: &git2::Signature) -> Vec<String> {
897 ] 963 ]
898} 964}
899 965
900fn extract_sig_from_patch_tags<'a>(tags: &'a Tags, tag_name: &str) -> Result<git2::Signature<'a>> { 966struct SignatureData {
967 name: String,
968 email: String,
969 timestamp: i64,
970 offset_minutes: i32,
971}
972
973fn extract_signature_data_from_tags(tags: &Tags, tag_name: &str) -> Result<SignatureData> {
901 let v = tags 974 let v = tags
902 .iter() 975 .iter()
903 .find(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq(tag_name)) 976 .find(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq(tag_name))
@@ -906,16 +979,64 @@ fn extract_sig_from_patch_tags<'a>(tags: &'a Tags, tag_name: &str) -> Result<git
906 if v.len() != 5 { 979 if v.len() != 5 {
907 bail!("tag '{tag_name}' is incorrectly formatted") 980 bail!("tag '{tag_name}' is incorrectly formatted")
908 } 981 }
909 git2::Signature::new( 982 Ok(SignatureData {
910 v[1].as_str(), 983 name: v[1].clone(),
911 v[2].as_str(), 984 email: v[2].clone(),
912 &git2::Time::new( 985 timestamp: v[3].parse().context("tag time is incorrectly formatted")?,
913 v[3].parse().context("tag time is incorrectly formatted")?, 986 offset_minutes: v[4].parse().context("tag time offset is incorrectly formatted")?,
914 v[4].parse() 987 })
915 .context("tag time offset is incorrectly formatted")?, 988}
916 ), 989
917 ) 990fn extract_signature_data_with_fallback(
918 .context("failed to create git signature") 991 tags: &Tags,
992 tag_name: &str,
993 patch_content: &str,
994) -> Result<SignatureData> {
995 if let Ok(data) = extract_signature_data_from_tags(tags, tag_name) {
996 return Ok(data);
997 }
998
999 let metadata = crate::mbox_parser::parse_mbox_patch(patch_content)
1000 .context("failed to parse patch content for fallback metadata")?;
1001
1002 if tag_name == "author" {
1003 Ok(SignatureData {
1004 name: metadata.author_name,
1005 email: metadata.author_email,
1006 timestamp: metadata.author_timestamp,
1007 offset_minutes: metadata.author_offset_minutes,
1008 })
1009 } else if tag_name == "committer" {
1010 let timestamp = metadata.committer_timestamp.unwrap_or(metadata.author_timestamp);
1011 Ok(SignatureData {
1012 name: metadata.author_name,
1013 email: metadata.author_email,
1014 timestamp,
1015 offset_minutes: metadata.author_offset_minutes,
1016 })
1017 } else {
1018 bail!("unknown tag name for signature extraction: {}", tag_name)
1019 }
1020}
1021
1022impl SignatureData {
1023 fn to_signature(&self) -> Result<git2::Signature<'_>> {
1024 git2::Signature::new(
1025 &self.name,
1026 &self.email,
1027 &git2::Time::new(self.timestamp, self.offset_minutes),
1028 )
1029 .context("failed to create git signature")
1030 }
1031}
1032
1033fn extract_description_from_patch(patch: &nostr::Event) -> Result<String> {
1034 if let Ok(desc) = tag_value(patch, "description") {
1035 return Ok(desc);
1036 }
1037
1038 crate::mbox_parser::extract_description_from_patch(&patch.content)
1039 .context("failed to extract description from patch content")
919} 1040}
920 1041
921pub fn get_git_config_item(git_repo: &Option<&Repo>, item: &str) -> Result<Option<String>> { 1042pub fn get_git_config_item(git_repo: &Option<&Repo>, item: &str) -> Result<Option<String>> {
@@ -1182,19 +1303,20 @@ mod tests {
1182 } 1303 }
1183 } 1304 }
1184 1305
1185 mod extract_sig_from_patch_tags { 1306 mod extract_signature_data_from_tags {
1186 use super::*; 1307 use super::*;
1187 1308
1188 fn test(time: git2::Time) -> Result<()> { 1309 fn test(time: git2::Time) -> Result<()> {
1310 let data = extract_signature_data_from_tags(
1311 &Tags::from_list(vec![nostr::Tag::custom(
1312 nostr::TagKind::Custom("author".to_string().into()),
1313 prep(&time)?,
1314 )]),
1315 "author",
1316 )?;
1317 let sig = data.to_signature()?;
1189 assert_eq!( 1318 assert_eq!(
1190 extract_sig_from_patch_tags( 1319 sig.to_string(),
1191 &Tags::from_list(vec![nostr::Tag::custom(
1192 nostr::TagKind::Custom("author".to_string().into()),
1193 prep(&time)?,
1194 )]),
1195 "author",
1196 )?
1197 .to_string(),
1198 git2::Signature::new(NAME, EMAIL, &time)?.to_string(), 1320 git2::Signature::new(NAME, EMAIL, &time)?.to_string(),
1199 ); 1321 );
1200 Ok(()) 1322 Ok(())
diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs
index e907a9b..b39e797 100644
--- a/src/lib/git_events.rs
+++ b/src/lib/git_events.rs
@@ -40,6 +40,30 @@ pub fn get_commit_id_from_patch(event: &Event) -> Result<String> {
40 } 40 }
41} 41}
42 42
43pub fn get_parent_commit_from_patch(
44 event: &Event,
45 git_repo: Option<&Repo>,
46) -> Result<String> {
47 if let Ok(parent) = tag_value(event, "parent-commit") {
48 return Ok(parent);
49 }
50
51 let metadata = crate::mbox_parser::parse_mbox_patch(&event.content)
52 .context("failed to parse patch for timestamp")?;
53 let timestamp = metadata.committer_timestamp.unwrap_or(metadata.author_timestamp);
54
55 if let Some(repo) = git_repo {
56 if let Some(best_guess) = repo
57 .find_best_guess_parent_commit(timestamp)
58 .context("failed to find best guess parent commit")?
59 {
60 return Ok(best_guess.to_string());
61 }
62 }
63
64 bail!("no parent-commit tag and could not determine best guess parent")
65}
66
43pub fn get_event_root(event: &nostr::Event) -> Result<EventId> { 67pub fn get_event_root(event: &nostr::Event) -> Result<EventId> {
44 Ok(EventId::parse( 68 Ok(EventId::parse(
45 event 69 event
@@ -88,11 +112,27 @@ pub fn event_is_revision_root(event: &Event) -> bool {
88} 112}
89 113
90pub fn patch_supports_commit_ids(event: &Event) -> bool { 114pub fn patch_supports_commit_ids(event: &Event) -> bool {
91 event.kind.eq(&Kind::GitPatch) 115 if !event.kind.eq(&Kind::GitPatch) {
92 && event 116 return false;
93 .tags 117 }
94 .iter() 118
95 .any(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq("commit-pgp-sig")) 119 if event
120 .tags
121 .iter()
122 .any(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq("commit-pgp-sig"))
123 {
124 return true;
125 }
126
127 if event
128 .tags
129 .iter()
130 .any(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq("parent-commit"))
131 {
132 return true;
133 }
134
135 crate::mbox_parser::parse_mbox_patch(&event.content).is_ok()
96} 136}
97 137
98pub fn event_is_valid_pr_or_pr_update(event: &Event) -> bool { 138pub fn event_is_valid_pr_or_pr_update(event: &Event) -> bool {
diff --git a/src/lib/mbox_parser.rs b/src/lib/mbox_parser.rs
new file mode 100644
index 0000000..40603b1
--- /dev/null
+++ b/src/lib/mbox_parser.rs
@@ -0,0 +1,452 @@
1use anyhow::{Context, Result, bail};
2use chrono::{DateTime, Datelike};
3
4#[derive(Debug, Clone, PartialEq)]
5pub struct PatchMetadata {
6 pub commit_id: String,
7 pub author_name: String,
8 pub author_email: String,
9 pub author_timestamp: i64,
10 pub author_offset_minutes: i32,
11 pub committer_timestamp: Option<i64>,
12 pub subject: String,
13 pub body: String,
14}
15
16pub fn parse_mbox_patch(content: &str) -> Result<PatchMetadata> {
17 let commit_id = extract_commit_id_from_mbox(content)?;
18 let (author_name, author_email) = extract_author_from_from_header(content)?;
19 let (author_timestamp, author_offset_minutes) = extract_date_from_header(content)?;
20 let committer_timestamp = extract_committer_date_from_mbox(content)?;
21 let subject = extract_subject(content)?;
22 let body = extract_commit_message_body(content)?;
23
24 Ok(PatchMetadata {
25 commit_id,
26 author_name,
27 author_email,
28 author_timestamp,
29 author_offset_minutes,
30 committer_timestamp,
31 subject,
32 body,
33 })
34}
35
36fn extract_commit_id_from_mbox(content: &str) -> Result<String> {
37 if !content.starts_with("From ") {
38 bail!("patch does not start with 'From ' - not a valid mbox format");
39 }
40
41 let first_line = content.lines().next().context("patch content is empty")?;
42
43 let parts: Vec<&str> = first_line.split_whitespace().collect();
44 if parts.len() < 2 {
45 bail!("mbox 'From ' line does not contain a commit id");
46 }
47
48 Ok(parts[1].to_string())
49}
50
51fn extract_author_from_from_header(content: &str) -> Result<(String, String)> {
52 let from_line = content
53 .lines()
54 .find(|line| line.starts_with("From:"))
55 .context("patch does not contain a 'From:' header")?;
56
57 let from_value = from_line
58 .strip_prefix("From:")
59 .context("failed to strip 'From:' prefix")?
60 .trim();
61
62 parse_from_header_value(from_value)
63}
64
65fn parse_from_header_value(value: &str) -> Result<(String, String)> {
66 if let Some(start) = value.find('<') {
67 if let Some(end) = value.find('>') {
68 let email = value[start + 1..end].to_string();
69 let name_part = value[..start].trim();
70 let name = name_part.trim_matches('"').trim().to_string();
71 return Ok((name, email));
72 }
73 }
74
75 if value.contains('@') {
76 let email = value.trim().to_string();
77 let name = email.split('@').next().unwrap_or("unknown").to_string();
78 return Ok((name, email));
79 }
80
81 bail!("could not parse From header: {}", value)
82}
83
84fn extract_date_from_header(content: &str) -> Result<(i64, i32)> {
85 let date_line = content
86 .lines()
87 .find(|line| line.starts_with("Date:"))
88 .context("patch does not contain a 'Date:' header")?;
89
90 let date_value = date_line
91 .strip_prefix("Date:")
92 .context("failed to strip 'Date:' prefix")?
93 .trim();
94
95 parse_rfc2822_date(date_value)
96}
97
98fn parse_rfc2822_date(value: &str) -> Result<(i64, i32)> {
99 let parsed = DateTime::parse_from_rfc2822(value)
100 .context(format!("failed to parse RFC2822 date: {}", value))?;
101
102 let timestamp = parsed.timestamp();
103 let offset_minutes = parsed.offset().local_minus_utc() / 60;
104
105 Ok((timestamp, offset_minutes))
106}
107
108fn extract_committer_date_from_mbox(content: &str) -> Result<Option<i64>> {
109 let first_line = content.lines().next().context("patch content is empty")?;
110
111 let parts: Vec<&str> = first_line.split_whitespace().collect();
112
113 if parts.len() >= 6 {
114 let date_str = parts[3..6].join(" ");
115 if let Ok(dt) = DateTime::parse_from_rfc2822(&date_str) {
116 return Ok(Some(dt.timestamp()));
117 }
118 }
119
120 if parts.len() >= 7 {
121 let date_str = format!("{} {} {}", parts[3], parts[4], parts[5]);
122 if let Ok(dt) = chrono::DateTime::parse_from_str(&date_str, "%a %b %d") {
123 if let Ok(year) = parts[6].parse::<i32>() {
124 let with_year = dt.with_year(year);
125 if let Some(dt_with_year) = with_year {
126 return Ok(Some(dt_with_year.timestamp()));
127 }
128 }
129 }
130 }
131
132 Ok(None)
133}
134
135fn extract_subject(content: &str) -> Result<String> {
136 let subject_line = content
137 .lines()
138 .find(|line| line.starts_with("Subject:"))
139 .context("patch does not contain a 'Subject:' header")?;
140
141 let subject_value = subject_line
142 .strip_prefix("Subject:")
143 .context("failed to strip 'Subject:' prefix")?
144 .trim();
145
146 Ok(cleanup_subject(subject_value))
147}
148
149fn cleanup_subject(subject: &str) -> String {
150 let mut result = subject.to_string();
151
152 loop {
153 let trimmed = result.trim();
154
155 if trimmed.starts_with("Re:") || trimmed.starts_with("re:") {
156 result = trimmed[3..].trim().to_string();
157 continue;
158 }
159
160 if let Some(stripped) = trimmed.strip_prefix(':') {
161 result = stripped.trim().to_string();
162 continue;
163 }
164
165 if trimmed.starts_with('[') {
166 if let Some(end) = trimmed.find(']') {
167 result = trimmed[end + 1..].trim().to_string();
168 continue;
169 }
170 }
171
172 break;
173 }
174
175 result
176}
177
178fn extract_commit_message_body(content: &str) -> Result<String> {
179 let mut in_body = false;
180 let mut body_lines: Vec<String> = Vec::new();
181 let mut found_first_content = false;
182
183 for line in content.lines() {
184 if !in_body {
185 if line.is_empty() {
186 in_body = true;
187 }
188 continue;
189 }
190
191 if line.starts_with("diff --git ")
192 || line.starts_with("Index: ")
193 || line.starts_with("--- ")
194 || line.starts_with("From ")
195 {
196 break;
197 }
198
199 if line.starts_with("---") && line.trim().eq("---") {
200 break;
201 }
202
203 if line.starts_with("-- ") || line.starts_with("--\n") {
204 break;
205 }
206
207 if !found_first_content && line.trim().is_empty() {
208 continue;
209 }
210
211 found_first_content = true;
212 body_lines.push(line.to_string());
213 }
214
215 while body_lines.last().is_some_and(|l| l.trim().is_empty()) {
216 body_lines.pop();
217 }
218
219 Ok(body_lines.join("\n").trim().to_string())
220}
221
222pub fn extract_description_from_patch(content: &str) -> Result<String> {
223 let subject = extract_subject(content)?;
224 let body = extract_commit_message_body(content)?;
225
226 if body.is_empty() {
227 Ok(subject)
228 } else {
229 Ok(format!("{}\n\n{}", subject, body))
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 fn sample_patch() -> String {
238 "\
239From 431b84edc0d2fa118d63faa3c2db9c73d630a5ae Mon Sep 17 00:00:00 2001
240From: Joe Bloggs <joe.bloggs@pm.me>
241Date: Thu, 1 Jan 1970 00:00:00 +0000
242Subject: [PATCH] add t2.md
243
244This is the commit message body.
245
246It can have multiple lines.
247
248---
249 t2.md | 1 +
250 1 file changed, 1 insertion(+)
251 create mode 100644 t2.md
252
253diff --git a/t2.md b/t2.md
254new file mode 100644
255index 0000000..a66525d
256--- /dev/null
257+++ b/t2.md
258@@ -0,0 +1 @@
259+some content1
260\\ No newline at end of file
261--
262libgit2 1.9.1
263
264"
265 .to_string()
266 }
267
268 #[test]
269 fn parse_commit_id() {
270 let patch = sample_patch();
271 let result = extract_commit_id_from_mbox(&patch).unwrap();
272 assert_eq!(result, "431b84edc0d2fa118d63faa3c2db9c73d630a5ae");
273 }
274
275 #[test]
276 fn parse_author() {
277 let patch = sample_patch();
278 let (name, email) = extract_author_from_from_header(&patch).unwrap();
279 assert_eq!(name, "Joe Bloggs");
280 assert_eq!(email, "joe.bloggs@pm.me");
281 }
282
283 #[test]
284 fn parse_author_with_quoted_name() {
285 let patch = "\
286From abc123 Mon Sep 17 00:00:00 2001
287From: \"John (nickname) Doe\" <john.doe@example.com>
288Date: Thu, 1 Jan 1970 00:00:00 +0000
289Subject: test
290
291Body
292";
293 let (name, email) = extract_author_from_from_header(patch).unwrap();
294 assert_eq!(name, "John (nickname) Doe");
295 assert_eq!(email, "john.doe@example.com");
296 }
297
298 #[test]
299 fn parse_author_email_only() {
300 let patch = "\
301From abc123 Mon Sep 17 00:00:00 2001
302From: john.doe@example.com
303Date: Thu, 1 Jan 1970 00:00:00 +0000
304Subject: test
305
306Body
307";
308 let (name, email) = extract_author_from_from_header(patch).unwrap();
309 assert_eq!(name, "john.doe");
310 assert_eq!(email, "john.doe@example.com");
311 }
312
313 #[test]
314 fn parse_date() {
315 let patch = sample_patch();
316 let (timestamp, offset) = extract_date_from_header(&patch).unwrap();
317 assert_eq!(timestamp, 0);
318 assert_eq!(offset, 0);
319 }
320
321 #[test]
322 fn parse_date_with_timezone() {
323 let patch = "\
324From abc123 Mon Sep 17 00:00:00 2001
325From: Joe <joe@example.com>
326Date: Thu, 1 Jan 1970 00:00:00 +0500
327Subject: test
328
329Body
330";
331 let (timestamp, offset) = extract_date_from_header(patch).unwrap();
332 assert_eq!(timestamp, -18000);
333 assert_eq!(offset, 300);
334 }
335
336 #[test]
337 fn parse_subject() {
338 let patch = sample_patch();
339 let subject = extract_subject(&patch).unwrap();
340 assert_eq!(subject, "add t2.md");
341 }
342
343 #[test]
344 fn parse_subject_with_patch_prefix() {
345 let patch = "\
346From abc123 Mon Sep 17 00:00:00 2001
347From: Joe <joe@example.com>
348Date: Thu, 1 Jan 1970 00:00:00 +0000
349Subject: [PATCH v2 3/5] fix: important bug
350
351Body
352";
353 let subject = extract_subject(patch).unwrap();
354 assert_eq!(subject, "fix: important bug");
355 }
356
357 #[test]
358 fn parse_subject_with_re_prefix() {
359 let patch = "\
360From abc123 Mon Sep 17 00:00:00 2001
361From: Joe <joe@example.com>
362Date: Thu, 1 Jan 1970 00:00:00 +0000
363Subject: Re: [PATCH] fix: important bug
364
365Body
366";
367 let subject = extract_subject(patch).unwrap();
368 assert_eq!(subject, "fix: important bug");
369 }
370
371 #[test]
372 fn parse_body() {
373 let patch = sample_patch();
374 let body = extract_commit_message_body(&patch).unwrap();
375 assert_eq!(
376 body,
377 "This is the commit message body.\n\nIt can have multiple lines."
378 );
379 }
380
381 #[test]
382 fn parse_body_empty() {
383 let patch = "\
384From abc123 Mon Sep 17 00:00:00 2001
385From: Joe <joe@example.com>
386Date: Thu, 1 Jan 1970 00:00:00 +0000
387Subject: test
388
389---
390 file.txt | 1 +
391diff --git a/file.txt b/file.txt
392";
393 let body = extract_commit_message_body(patch).unwrap();
394 assert_eq!(body, "");
395 }
396
397 #[test]
398 fn parse_full_metadata() {
399 let patch = sample_patch();
400 let metadata = parse_mbox_patch(&patch).unwrap();
401
402 assert_eq!(
403 metadata.commit_id,
404 "431b84edc0d2fa118d63faa3c2db9c73d630a5ae"
405 );
406 assert_eq!(metadata.author_name, "Joe Bloggs");
407 assert_eq!(metadata.author_email, "joe.bloggs@pm.me");
408 assert_eq!(metadata.author_timestamp, 0);
409 assert_eq!(metadata.author_offset_minutes, 0);
410 assert_eq!(metadata.subject, "add t2.md");
411 assert_eq!(
412 metadata.body,
413 "This is the commit message body.\n\nIt can have multiple lines."
414 );
415 }
416
417 #[test]
418 fn extract_description_combines_subject_and_body() {
419 let patch = sample_patch();
420 let description = extract_description_from_patch(&patch).unwrap();
421 assert_eq!(
422 description,
423 "add t2.md\n\nThis is the commit message body.\n\nIt can have multiple lines."
424 );
425 }
426
427 #[test]
428 fn extract_description_subject_only() {
429 let patch = "\
430From abc123 Mon Sep 17 00:00:00 2001
431From: Joe <joe@example.com>
432Date: Thu, 1 Jan 1970 00:00:00 +0000
433Subject: [PATCH] simple fix
434
435---
436 file.txt | 1 +
437";
438 let description = extract_description_from_patch(patch).unwrap();
439 assert_eq!(description, "simple fix");
440 }
441
442 #[test]
443 fn cleanup_subject_strips_patch_prefixes() {
444 assert_eq!(cleanup_subject("[PATCH] test"), "test");
445 assert_eq!(cleanup_subject("[PATCH v2] test"), "test");
446 assert_eq!(cleanup_subject("[PATCH 1/3] test"), "test");
447 assert_eq!(cleanup_subject("[PATCH v2 1/3] test"), "test");
448 assert_eq!(cleanup_subject("Re: [PATCH] test"), "test");
449 assert_eq!(cleanup_subject("re: test"), "test");
450 assert_eq!(cleanup_subject(":test"), "test");
451 }
452}
diff --git a/src/lib/mod.rs b/src/lib/mod.rs
index a09f866..b388b23 100644
--- a/src/lib/mod.rs
+++ b/src/lib/mod.rs
@@ -5,6 +5,7 @@ pub mod git;
5pub mod git_events; 5pub mod git_events;
6pub mod list; 6pub mod list;
7pub mod login; 7pub mod login;
8pub mod mbox_parser;
8pub mod push; 9pub mod push;
9pub mod repo_ref; 10pub mod repo_ref;
10pub mod repo_state; 11pub mod repo_state;