1use crate::commit_encoding;
15use crate::error::{Error, Result};
16use crate::objects::ObjectId;
17
18#[derive(Debug, Clone)]
20pub struct MboxPatch {
21 pub author: String,
23 pub date: String,
25 pub message: String,
27 pub content_charset: Option<String>,
29 pub diff: String,
31 pub message_id: String,
33 pub format_patch_commit: Option<ObjectId>,
35}
36
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub enum QuotedCrAction {
39 Warn,
40 Strip,
41 Nowarn,
42}
43
44pub fn parse_quoted_cr_action(value: &str) -> QuotedCrAction {
45 match value.trim().to_ascii_lowercase().as_str() {
46 "strip" => QuotedCrAction::Strip,
47 "nowarn" => QuotedCrAction::Nowarn,
48 "warn" => QuotedCrAction::Warn,
49 _ => QuotedCrAction::Warn,
50 }
51}
52
53pub fn is_skippable_mail_folder_message(patch: &MboxPatch) -> bool {
55 let subj = patch
56 .message
57 .lines()
58 .next()
59 .unwrap_or("")
60 .to_ascii_lowercase();
61 if subj.contains("folder internal data") || subj.contains("don't delete this message") {
62 return true;
63 }
64 patch.author.to_ascii_lowercase().contains("mailer-daemon")
65}
66
67pub fn detect_patch_format(input: &str) -> &'static str {
69 let trimmed = input.trim_start();
70 if trimmed.starts_with("# HG changeset patch") {
71 return "hg";
72 }
73 let mut lines = trimmed.lines();
76 if let Some(first) = lines.next() {
77 let mut peeked = lines.clone();
79 for _ in 0..5 {
81 if let Some(l) = peeked.next() {
82 let lt = l.trim();
83 if lt.is_empty() {
84 continue;
85 }
86 if lt.starts_with("From:") || lt.starts_with("Date:") {
87 if !first.starts_with("From ")
89 && !first.starts_with("From:")
90 && !first.starts_with("Subject:")
91 && !first.starts_with("Date:")
92 && !first.starts_with("Message-ID:")
93 && !first.starts_with("X-")
94 {
95 return "stgit";
96 }
97 }
98 break;
99 }
100 }
101 }
102 "mbox"
103}
104
105pub fn is_stgit_series(input: &str) -> bool {
109 let mut has_series_header = false;
110 let mut has_from_or_date = false;
111 for line in input.lines() {
112 let trimmed = line.trim();
113 if trimmed.is_empty() {
114 continue;
115 }
116 if trimmed.starts_with("# This series applies on GIT commit") {
117 has_series_header = true;
118 }
119 if trimmed.starts_with("From:") || trimmed.starts_with("Date:") {
120 has_from_or_date = true;
121 }
122 }
123 has_series_header && !has_from_or_date
125}
126
127pub fn parse_stgit_patch(input: &str) -> Result<Vec<MboxPatch>> {
129 let mut lines = input.lines();
130 let mut subject = String::new();
131 let mut author = String::new();
132 let mut date = String::new();
133 let mut body_lines = Vec::new();
134 let mut diff_lines = Vec::new();
135 let mut in_diff = false;
136 let mut in_headers;
137 let mut past_separator = false;
138
139 for line in lines.by_ref() {
141 if !line.trim().is_empty() {
142 subject = line.trim().to_string();
143 break;
144 }
145 }
146
147 in_headers = true;
149 for line in lines.by_ref() {
150 if in_headers {
151 if line.trim().is_empty() {
152 in_headers = false;
153 continue;
154 }
155 if let Some(val) = line.strip_prefix("From:") {
156 author = val.trim().to_string();
157 continue;
158 }
159 if let Some(val) = line.strip_prefix("Date:") {
160 date = val.trim().to_string();
161 continue;
162 }
163 in_headers = false;
165 body_lines.push(line);
166 continue;
167 }
168
169 if !in_diff {
170 if line == "---" {
171 past_separator = true;
172 continue;
173 }
174 if past_separator && line.starts_with("diff --git ") {
175 in_diff = true;
176 diff_lines.push(line);
177 continue;
178 }
179 if past_separator {
180 continue;
182 }
183 if line.starts_with("diff --git ") {
184 in_diff = true;
185 diff_lines.push(line);
186 continue;
187 }
188 body_lines.push(line);
189 } else {
190 if line == "-- " {
191 break;
192 }
193 diff_lines.push(line);
194 }
195 }
196
197 let author_ident = parse_author_ident(&author, &date);
198 let body = body_lines.join("\n").trim().to_string();
199 let message = if body.is_empty() {
200 format!("{}\n", subject)
201 } else {
202 format!("{}\n\n{}\n", subject, body)
203 };
204 let mut diff = diff_lines.join("\n");
205 if !diff.is_empty() {
206 diff.push('\n');
207 }
208
209 Ok(vec![MboxPatch {
210 author: author_ident.0,
211 date: author_ident.1,
212 message,
213 content_charset: None,
214 diff,
215 message_id: String::new(),
216 format_patch_commit: None,
217 }])
218}
219
220pub fn parse_hg_patch(input: &str) -> Result<Vec<MboxPatch>> {
222 let mut lines = input.lines();
223 let mut author = String::new();
224 let mut date = String::new();
225 let mut body_lines = Vec::new();
226 let mut diff_lines = Vec::new();
227 let mut in_diff = false;
228
229 for line in lines.by_ref() {
231 let trimmed = line.trim();
232 if trimmed == "# HG changeset patch" {
233 continue;
234 }
235 if let Some(val) = trimmed.strip_prefix("# User ") {
236 author = val.to_string();
237 continue;
238 }
239 if let Some(val) = trimmed.strip_prefix("# Date ") {
240 let parts: Vec<&str> = val.split_whitespace().collect();
243 if parts.len() >= 2 {
244 if let (Ok(epoch), Ok(offset_secs)) =
245 (parts[0].parse::<i64>(), parts[1].parse::<i64>())
246 {
247 let git_offset_secs = -offset_secs;
250 let sign = if git_offset_secs >= 0 { '+' } else { '-' };
251 let abs_secs = git_offset_secs.unsigned_abs();
252 let hours = abs_secs / 3600;
253 let mins = (abs_secs % 3600) / 60;
254 date = format!("{} {}{:02}{:02}", epoch, sign, hours, mins);
255 } else {
256 date = val.to_string();
257 }
258 } else {
259 date = val.to_string();
260 }
261 continue;
262 }
263 if trimmed.starts_with("# ") || trimmed == "#" {
264 continue;
266 }
267 body_lines.push(line);
269 break;
270 }
271
272 for line in lines {
274 if !in_diff {
275 if line.starts_with("diff --git ") || line.starts_with("diff -r ") {
276 in_diff = true;
277 diff_lines.push(line);
278 continue;
279 }
280 body_lines.push(line);
281 } else {
282 diff_lines.push(line);
283 }
284 }
285
286 let author_ident = parse_author_ident(&author, &date);
287 let body = body_lines.join("\n").trim().to_string();
288 let (subject, rest) = if let Some(idx) = body.find('\n') {
290 (body[..idx].to_string(), body[idx + 1..].trim().to_string())
291 } else {
292 (body.clone(), String::new())
293 };
294
295 let message = if rest.is_empty() {
296 format!("{}\n", subject)
297 } else {
298 format!("{}\n\n{}\n", subject, rest)
299 };
300 let mut diff = diff_lines.join("\n");
301 if !diff.is_empty() {
302 diff.push('\n');
303 }
304
305 Ok(vec![MboxPatch {
306 author: author_ident.0,
307 date: author_ident.1,
308 message,
309 content_charset: None,
310 diff,
311 message_id: String::new(),
312 format_patch_commit: None,
313 }])
314}
315
316pub fn parse_patches(
321 input: &str,
322 format: Option<&str>,
323 keep: bool,
324 keep_non_patch: bool,
325 scissors: bool,
326 no_scissors: bool,
327 keep_cr: bool,
328 quoted_cr_action: QuotedCrAction,
329 warnings: &mut Vec<String>,
330) -> Result<Vec<MboxPatch>> {
331 let fmt = format.unwrap_or_else(|| detect_patch_format(input));
332 match fmt {
333 "stgit" => parse_stgit_patch(input),
334 "hg" => parse_hg_patch(input),
335 _ => parse_mbox_with_opts(
336 input,
337 keep,
338 keep_non_patch,
339 scissors,
340 no_scissors,
341 keep_cr,
342 quoted_cr_action,
343 warnings,
344 ),
345 }
346}
347
348fn unflow_format_flowed(lines: &[&str]) -> Vec<String> {
355 let mut result = Vec::new();
356 let mut current = String::new();
357
358 for line in lines {
359 let unstuffed = if line.starts_with(' ') {
361 &line[1..]
362 } else {
363 line
364 };
365
366 if unstuffed.ends_with(' ') {
367 current.push_str(unstuffed);
369 } else if !current.is_empty() {
370 current.push_str(unstuffed);
371 result.push(current.clone());
372 current.clear();
373 } else {
374 result.push(unstuffed.to_string());
375 }
376 }
377 if !current.is_empty() {
378 result.push(current);
379 }
380 result
381}
382
383fn split_lines_preserve_cr(input: &str) -> Vec<&str> {
384 if input.is_empty() {
385 return Vec::new();
386 }
387 let mut lines: Vec<&str> = input.split('\n').collect();
388 if input.ends_with('\n') {
389 lines.pop();
390 }
391 lines
392}
393
394fn unquote_mboxrd(input: &str) -> String {
395 let mut result = String::with_capacity(input.len());
396 let mut in_body = false;
397
398 for line in split_lines_preserve_cr(input) {
399 let line_no_cr = line.strip_suffix('\r').unwrap_or(line);
400 if line_no_cr.starts_with("From ") && line_no_cr.len() > 5 {
401 in_body = false;
403 result.push_str(line);
404 result.push('\n');
405 continue;
406 }
407
408 if !in_body {
409 if line_no_cr.is_empty() {
410 in_body = true;
411 }
412 result.push_str(line);
413 result.push('\n');
414 continue;
415 }
416
417 if line_no_cr.starts_with(">From ")
419 || (line_no_cr.starts_with(">>") && line_no_cr.contains("From "))
420 {
421 let stripped = line.strip_prefix(">").unwrap_or(line);
423 result.push_str(stripped);
424 } else {
425 result.push_str(line);
426 }
427 result.push('\n');
428 }
429
430 if !input.ends_with('\n') && result.ends_with('\n') {
432 result.pop();
433 }
434
435 result
436}
437
438fn base64_decode(input: &str) -> Result<Vec<u8>> {
439 const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
440 let mut output = Vec::new();
441 let mut buf: u32 = 0;
442 let mut bits: u32 = 0;
443
444 for &byte in input.as_bytes() {
445 if byte == b'=' {
446 break;
447 }
448 if byte.is_ascii_whitespace() {
449 continue;
450 }
451 let val = TABLE
452 .iter()
453 .position(|&c| c == byte)
454 .ok_or_else(|| Error::Message("invalid base64 payload in mbox".to_string()))?;
455 buf = (buf << 6) | val as u32;
456 bits += 6;
457 if bits >= 8 {
458 bits -= 8;
459 output.push((buf >> bits) as u8);
460 buf &= (1 << bits) - 1;
461 }
462 }
463
464 Ok(output)
465}
466
467fn decode_transfer_payload(
468 payload: &str,
469 transfer_encoding: &str,
470 keep_cr: bool,
471 quoted_cr_action: QuotedCrAction,
472 warnings: &mut Vec<String>,
473) -> Result<String> {
474 if transfer_encoding != "base64" {
475 if keep_cr {
476 return Ok(payload.to_string());
477 }
478 return Ok(payload.replace('\r', ""));
479 }
480
481 let decoded = base64_decode(payload)?;
482 let mut text = String::from_utf8_lossy(&decoded).into_owned();
483 if !keep_cr && text.contains('\r') {
484 match quoted_cr_action {
485 QuotedCrAction::Strip => {
486 text = text.replace('\r', "");
487 }
488 QuotedCrAction::Warn => {
489 warnings.push("warning: quoted CRLF detected".to_string());
490 }
491 QuotedCrAction::Nowarn => {}
492 }
493 }
494 Ok(text)
495}
496
497fn split_message_body_and_diff(payload_lines: &[String]) -> (Vec<String>, Vec<String>) {
498 let mut body_lines = Vec::new();
499 let mut diff_lines = Vec::new();
500 let mut i = 0usize;
501 let mut in_diff = false;
502
503 while i < payload_lines.len() {
504 let line = payload_lines[i].as_str();
505 let line_no_cr = line.strip_suffix('\r').unwrap_or(line);
506 if !in_diff {
507 if line_no_cr == "---" {
508 i += 1;
509 while i < payload_lines.len() {
510 let stat_line = payload_lines[i].as_str();
511 let stat_line_no_cr = stat_line.strip_suffix('\r').unwrap_or(stat_line);
512 if stat_line_no_cr.starts_with("diff --git ") {
513 in_diff = true;
514 break;
515 }
516 i += 1;
517 }
518 continue;
519 }
520 if line_no_cr.starts_with("diff --git ") {
521 in_diff = true;
522 } else {
523 body_lines.push(payload_lines[i].clone());
524 i += 1;
525 continue;
526 }
527 }
528
529 if line_no_cr == "-- " {
530 break;
531 }
532 diff_lines.push(payload_lines[i].clone());
533 i += 1;
534 }
535
536 (body_lines, diff_lines)
537}
538
539fn parse_format_patch_commit_oid_from_mbox_line(line: &str) -> Option<ObjectId> {
542 let after_from = line.strip_prefix("From")?;
543 if after_from.starts_with(':') {
544 return None;
545 }
546 let rest = after_from.trim_start();
547 let (token, tail) = rest.split_once(char::is_whitespace)?;
548 if token.len() != 40 || !tail.trim_start().starts_with("Mon ") {
549 return None;
550 }
551 ObjectId::from_hex(token).ok()
552}
553
554pub fn parse_mbox_with_opts(
559 input: &str,
560 keep: bool,
561 keep_non_patch: bool,
562 scissors: bool,
563 no_scissors: bool,
564 keep_cr: bool,
565 quoted_cr_action: QuotedCrAction,
566 warnings: &mut Vec<String>,
567) -> Result<Vec<MboxPatch>> {
568 let input = unquote_mboxrd(input);
570 let mut patches = Vec::new();
571 let line_storage = split_lines_preserve_cr(&input);
572 let mut lines = line_storage.iter().copied().peekable();
573
574 while lines.peek().is_some() {
575 let mut _in_headers = false;
578 let mut author = String::new();
579 let mut date = String::new();
580 let mut subject = String::new();
581 let mut message_id = String::new();
582 let _body = String::new();
583 let mut found_from = false;
584 let mut format_patch_commit: Option<ObjectId> = None;
585
586 while let Some(&line) = lines.peek() {
588 let line_no_cr = line.strip_suffix('\r').unwrap_or(line);
589 if line_no_cr.starts_with("From ") && line_no_cr.len() > 5 {
590 found_from = true;
591 format_patch_commit = parse_format_patch_commit_oid_from_mbox_line(line_no_cr);
592 lines.next(); break;
594 }
595 if !found_from
597 && (line_no_cr.starts_with("From:")
598 || line_no_cr.starts_with("Subject:")
599 || line_no_cr.starts_with("Date:")
600 || line_no_cr.starts_with("Message-ID:")
601 || line_no_cr.starts_with("Message-Id:")
602 || line_no_cr.starts_with("X-"))
603 {
604 found_from = true;
605 break;
606 }
607 if !found_from {
608 lines.next(); continue;
610 }
611 break;
612 }
613
614 if !found_from && lines.peek().is_none() {
615 break;
616 }
617
618 _in_headers = true;
620 let mut last_header = String::new();
621 let mut is_format_flowed = false;
622 let mut content_transfer_encoding = String::new();
623 let mut content_charset: Option<String> = None;
624
625 while let Some(&line) = lines.peek() {
626 let line_no_cr = line.strip_suffix('\r').unwrap_or(line);
627 if line_no_cr.is_empty() {
628 lines.next();
629 _in_headers = false;
630 break;
631 }
632 if (line_no_cr.starts_with(' ') || line_no_cr.starts_with('\t'))
634 && !last_header.is_empty()
635 {
636 if last_header == "subject" {
637 subject.push(' ');
638 subject.push_str(line_no_cr.trim());
639 }
640 lines.next();
641 continue;
642 }
643
644 if let Some(value) = line_no_cr.strip_prefix("From: ") {
645 author = commit_encoding::decode_rfc2047_mailbox_from_line(value.trim());
646 last_header = "from".to_string();
647 } else if let Some(value) = line_no_cr.strip_prefix("Date: ") {
648 date = value.trim().to_string();
649 last_header = "date".to_string();
650 } else if let Some(value) = line_no_cr.strip_prefix("Subject: ") {
651 let subj = if keep {
653 value.trim().to_string()
654 } else if keep_non_patch {
655 strip_patch_prefix_keep_non_patch(value.trim())
656 } else {
657 strip_patch_prefix(value.trim())
658 };
659 subject = subj;
660 last_header = "subject".to_string();
661 } else if let Some(value) = line_no_cr
662 .strip_prefix("Message-ID: ")
663 .or_else(|| line_no_cr.strip_prefix("Message-Id: "))
664 .or_else(|| line_no_cr.strip_prefix("Message-id: "))
665 {
666 message_id = value.trim().to_string();
667 last_header = "message-id".to_string();
668 } else if let Some(value) = line_no_cr
669 .strip_prefix("Content-Type: ")
670 .or_else(|| line_no_cr.strip_prefix("Content-type: "))
671 {
672 for part in value.split(';').skip(1) {
673 let p = part.trim();
674 let lower = p.to_ascii_lowercase();
675 if let Some(rest) = lower.strip_prefix("charset=") {
676 let mut cs = rest.trim().trim_matches('"').trim_matches('\'');
677 if let Some((a, _)) = cs.split_once(';') {
678 cs = a.trim();
679 }
680 if !cs.is_empty() {
681 content_charset = Some(cs.to_owned());
682 }
683 }
684 }
685 if value.to_lowercase().contains("format=flowed") {
686 is_format_flowed = true;
687 }
688 last_header = "content-type".to_string();
689 } else if let Some(value) = line_no_cr
690 .strip_prefix("Content-Transfer-Encoding: ")
691 .or_else(|| line_no_cr.strip_prefix("Content-transfer-encoding: "))
692 {
693 content_transfer_encoding = value.trim().to_ascii_lowercase();
694 last_header = "content-transfer-encoding".to_string();
695 } else {
696 last_header = String::new();
697 }
698 lines.next();
699 }
700
701 let mut raw_payload_lines = Vec::new();
702 while let Some(&line) = lines.peek() {
703 let line_no_cr = line.strip_suffix('\r').unwrap_or(line);
704 if line_no_cr.starts_with("From ") && line_no_cr.len() > 5 {
705 break;
706 }
707 raw_payload_lines.push(line.to_string());
708 lines.next();
709 }
710
711 let raw_payload = raw_payload_lines.join("\n");
712 let decoded_payload = decode_transfer_payload(
713 &raw_payload,
714 &content_transfer_encoding,
715 keep_cr,
716 quoted_cr_action,
717 warnings,
718 )?;
719 let mut payload_lines: Vec<String> = decoded_payload
720 .split('\n')
721 .map(|l| {
722 if keep_cr {
723 l.to_string()
724 } else {
725 l.strip_suffix('\r').unwrap_or(l).to_string()
726 }
727 })
728 .collect();
729 if payload_lines.last().is_some_and(String::is_empty) {
730 payload_lines.pop();
731 }
732 let (body_lines, diff_lines) = split_message_body_and_diff(&payload_lines);
733
734 let mut effective_body_lines: Vec<String> = if is_format_flowed {
742 let body_refs: Vec<&str> = body_lines.iter().map(String::as_str).collect();
743 unflow_format_flowed(&body_refs)
744 } else {
745 body_lines.clone()
746 };
747 let mut body_str = effective_body_lines.join("\n").trim().to_string();
748 if !body_str.is_empty() && !subject.is_empty() {
749 let mut consumed = 0usize;
750 let mut continuation = Vec::new();
751 for line in &effective_body_lines {
752 if line.trim().is_empty() {
753 break;
754 }
755 continuation.push(line.trim().to_string());
756 consumed += 1;
757 }
758 if !continuation.is_empty() {
759 if keep {
760 subject = format!("{subject}\n{}", continuation.join("\n"));
761 } else {
762 subject = format!("{subject} {}", continuation.join(" "));
763 }
764 effective_body_lines.drain(0..consumed);
765 body_str = effective_body_lines.join("\n").trim().to_string();
766 }
767 }
768
769 if scissors && !no_scissors {
771 let (new_subject, new_body) = apply_scissors_to_message(&subject, &body_str);
772 subject = new_subject;
773 body_str = new_body;
774 }
775
776 let message = if body_str.is_empty() {
777 format!("{}\n", subject)
778 } else {
779 format!("{}\n\n{}\n", subject, body_str)
780 };
781
782 let author_ident = parse_author_ident(&author, &date);
784
785 let effective_diff_lines: Vec<String> = if is_format_flowed {
787 warnings.push(
788 "warning: Patch sent with format=flowed; space at the end of lines might be lost."
789 .to_string(),
790 );
791 let diff_refs: Vec<&str> = diff_lines.iter().map(String::as_str).collect();
792 unflow_format_flowed(&diff_refs)
793 } else {
794 diff_lines.clone()
795 };
796
797 let mut diff_section = effective_diff_lines.join("\n");
798 if !diff_section.is_empty() {
799 diff_section.push('\n');
800 }
801
802 if !subject.is_empty() || !diff_section.is_empty() {
803 patches.push(MboxPatch {
804 author: author_ident.0,
805 date: author_ident.1,
806 message,
807 content_charset,
808 diff: diff_section,
809 message_id: message_id.clone(),
810 format_patch_commit,
811 });
812 }
813 }
814
815 Ok(patches)
816}
817
818fn strip_patch_prefix(subject: &str) -> String {
820 if subject.starts_with('[') {
821 if let Some(end) = subject.find(']') {
822 let rest = subject[end + 1..].trim();
823 if !rest.is_empty() {
824 return rest.to_string();
825 }
826 }
827 }
828 subject.to_string()
829}
830
831fn strip_patch_prefix_keep_non_patch(subject: &str) -> String {
833 if subject.starts_with('[') {
834 if let Some(end) = subject.find(']') {
835 let bracket_content = &subject[1..end];
836 if bracket_content.contains("PATCH") {
838 let rest = subject[end + 1..].trim();
839 if !rest.is_empty() {
840 return rest.to_string();
841 }
842 }
843 }
844 }
845 subject.to_string()
846}
847
848fn apply_scissors_to_message(subject: &str, body: &str) -> (String, String) {
850 let mut scissors_idx = None;
852 let body_lines: Vec<&str> = body.lines().collect();
853 for (i, line) in body_lines.iter().enumerate() {
854 if is_scissors_line(line.trim()) {
855 scissors_idx = Some(i);
856 break;
857 }
858 }
859
860 if let Some(idx) = scissors_idx {
861 let after: Vec<&str> = body_lines[idx + 1..].to_vec();
863 let after_text = after.join("\n");
864 let after_trimmed = after_text.trim();
865
866 let mut new_subject = String::new();
868 let mut new_body_lines = Vec::new();
869 let mut in_headers = true;
870
871 for line in after_trimmed.lines() {
872 if in_headers {
873 if line.is_empty() {
874 in_headers = false;
875 continue;
876 }
877 if let Some(val) = line.strip_prefix("Subject: ") {
878 new_subject = val.trim().to_string();
879 continue;
880 }
881 in_headers = false;
883 new_body_lines.push(line);
884 } else {
885 new_body_lines.push(line);
886 }
887 }
888
889 if new_subject.is_empty() {
890 new_subject = subject.to_string();
891 }
892
893 let new_body = new_body_lines.join("\n").trim().to_string();
894 (new_subject, new_body)
895 } else {
896 (subject.to_string(), body.to_string())
897 }
898}
899
900fn is_scissors_line(line: &str) -> bool {
904 let scissors_pos = if let Some(pos) = line.find(">8") {
906 pos
907 } else if let Some(pos) = line.find("8<") {
908 pos
909 } else {
910 return false;
911 };
912
913 let prefix = &line[..scissors_pos];
915 if prefix.is_empty() {
916 return false;
917 }
918 prefix.chars().all(|c| c == '-' || c == ' ')
919}
920
921fn parse_author_ident(author: &str, date: &str) -> (String, String) {
923 let epoch_date = parse_date_to_epoch(date);
925 (author.to_string(), epoch_date)
926}
927
928fn parse_date_to_epoch(date: &str) -> String {
930 if date.is_empty() {
931 return String::new();
932 }
933
934 let parts: Vec<&str> = date.split_whitespace().collect();
936 if parts.len() == 2 && parts[0].parse::<i64>().is_ok() {
937 return date.to_string();
938 }
939
940 if let Some(parsed) = parse_rfc2822_date(date) {
942 return parsed;
943 }
944
945 date.to_string()
947}
948
949fn parse_rfc2822_date(date: &str) -> Option<String> {
951 let trimmed = date.trim();
953
954 let (date_part, tz_str) = {
956 let parts: Vec<&str> = trimmed.rsplitn(2, ' ').collect();
957 if parts.len() != 2 {
958 return None;
959 }
960 (parts[1], parts[0])
961 };
962
963 if tz_str.len() != 5 {
965 return None;
966 }
967 let tz_sign = match tz_str.chars().next()? {
968 '+' => 1i32,
969 '-' => -1i32,
970 _ => return None,
971 };
972 let tz_hours: i32 = tz_str[1..3].parse().ok()?;
973 let tz_mins: i32 = tz_str[3..5].parse().ok()?;
974 let tz_offset_secs = tz_sign * (tz_hours * 3600 + tz_mins * 60);
975
976 let date_str = if date_part.contains(',') {
978 let (_, rest) = date_part.split_once(',')?;
979 rest.trim()
980 } else {
981 date_part.trim()
982 };
983
984 let tokens: Vec<&str> = date_str.split_whitespace().collect();
986 if tokens.len() < 4 {
987 return None;
988 }
989
990 let day: u32 = tokens[0].parse().ok()?;
991 let month = match tokens[1].to_lowercase().as_str() {
992 "jan" => 1u32,
993 "feb" => 2,
994 "mar" => 3,
995 "apr" => 4,
996 "may" => 5,
997 "jun" => 6,
998 "jul" => 7,
999 "aug" => 8,
1000 "sep" => 9,
1001 "oct" => 10,
1002 "nov" => 11,
1003 "dec" => 12,
1004 _ => return None,
1005 };
1006 let year: i32 = tokens[2].parse().ok()?;
1007 let time_parts: Vec<&str> = tokens[3].split(':').collect();
1008 if time_parts.len() < 2 {
1009 return None;
1010 }
1011 let hour: u32 = time_parts[0].parse().ok()?;
1012 let min: u32 = time_parts[1].parse().ok()?;
1013 let sec: u32 = if time_parts.len() > 2 {
1014 time_parts[2].parse().ok()?
1015 } else {
1016 0
1017 };
1018
1019 let epoch = datetime_to_epoch(year, month, day, hour, min, sec, tz_offset_secs)?;
1022
1023 Some(format!("{} {}", epoch, tz_str))
1024}
1025
1026fn datetime_to_epoch(
1028 year: i32,
1029 month: u32,
1030 day: u32,
1031 hour: u32,
1032 min: u32,
1033 sec: u32,
1034 tz_offset_secs: i32,
1035) -> Option<i64> {
1036 let m = if month <= 2 { month + 12 } else { month };
1038 let y = if month <= 2 { year - 1 } else { year };
1039
1040 let jdn = (day as i64) + (153 * (m as i64 - 3) + 2) / 5 + 365 * (y as i64) + (y as i64) / 4
1042 - (y as i64) / 100
1043 + (y as i64) / 400
1044 + 1721119;
1045
1046 let days_since_epoch = jdn - 2440588;
1048 let secs = days_since_epoch * 86400 + (hour as i64) * 3600 + (min as i64) * 60 + (sec as i64);
1049 let utc_secs = secs - (tz_offset_secs as i64);
1050
1051 Some(utc_secs)
1052}
1053
1054pub fn serialize_mbox_patch(patch: &MboxPatch) -> String {
1056 let mut out = String::new();
1057 out.push_str(&format!("Author: {}\n", patch.author));
1058 out.push_str(&format!("Date: {}\n", patch.date));
1059 if let Some(oid) = patch.format_patch_commit {
1060 out.push_str(&format!("Format-Patch-Commit: {}\n", oid.to_hex()));
1061 }
1062 if let Some(ref cs) = patch.content_charset {
1063 out.push_str(&format!("Content-Charset: {cs}\n"));
1064 }
1065 if !patch.message_id.is_empty() {
1066 out.push_str(&format!("Message-ID: {}\n", patch.message_id));
1067 }
1068 out.push_str(&format!("Message-Length: {}\n", patch.message.len()));
1069 out.push_str(&format!("Diff-Length: {}\n", patch.diff.len()));
1070 out.push('\n');
1071 out.push_str(&patch.message);
1072 out.push_str(&patch.diff);
1073 out
1074}
1075
1076pub fn deserialize_mbox_patch(data: &str) -> Result<MboxPatch> {
1078 let mut author = String::new();
1079 let mut date = String::new();
1080 let mut message_id = String::new();
1081 let mut content_charset: Option<String> = None;
1082 let mut format_patch_commit: Option<ObjectId> = None;
1083 let mut msg_len = 0usize;
1084 let mut diff_len = 0usize;
1085
1086 let split_at = data.find("\n\n").unwrap_or(data.len());
1087 let header = &data[..split_at];
1088 let remaining = if split_at < data.len() {
1089 &data[split_at + 2..]
1090 } else {
1091 ""
1092 };
1093
1094 for line in header.split('\n') {
1095 let line = line.trim_end_matches('\r');
1096 if let Some(v) = line.strip_prefix("Author: ") {
1097 author = v.to_string();
1098 } else if let Some(v) = line.strip_prefix("Date: ") {
1099 date = v.to_string();
1100 } else if let Some(v) = line.strip_prefix("Message-ID: ") {
1101 message_id = v.to_string();
1102 } else if let Some(v) = line.strip_prefix("Format-Patch-Commit: ") {
1103 format_patch_commit = ObjectId::from_hex(v.trim()).ok();
1104 } else if let Some(v) = line.strip_prefix("Content-Charset: ") {
1105 content_charset = Some(v.to_string());
1106 } else if let Some(v) = line.strip_prefix("Message-Length: ") {
1107 msg_len = v.parse().unwrap_or(0);
1108 } else if let Some(v) = line.strip_prefix("Diff-Length: ") {
1109 diff_len = v.parse().unwrap_or(0);
1110 }
1111 }
1112
1113 let message = if msg_len > 0 && msg_len <= remaining.len() {
1114 remaining[..msg_len].to_string()
1115 } else {
1116 remaining.to_string()
1117 };
1118
1119 let diff = if diff_len > 0 && msg_len.saturating_add(diff_len) <= remaining.len() {
1120 remaining[msg_len..msg_len + diff_len].to_string()
1121 } else if msg_len < remaining.len() {
1122 remaining[msg_len..].to_string()
1123 } else {
1124 String::new()
1125 };
1126
1127 Ok(MboxPatch {
1128 author,
1129 date,
1130 message,
1131 content_charset,
1132 diff,
1133 message_id,
1134 format_patch_commit,
1135 })
1136}