Skip to main content

grit_lib/
am.rs

1//! Mailbox/patch parsing core for `grit am`.
2//!
3//! This is the self-contained *parse* slice of `grit am`: it turns mbox/stgit/hg
4//! patch text into structured [`MboxPatch`] values (author, date, message, diff,
5//! …) with no repository, index, filesystem, environment, or CLI dependencies.
6//! The `.git/rebase-apply` state machine, patch-to-worktree application, commit
7//! assembly, hooks, and all CLI output still live in the `grit` crate; only the
8//! text-to-structured-data layer lives here so it can be unit-tested and reused.
9//!
10//! Warnings that `git am` prints to stderr while parsing (quoted CRLF, lossy
11//! `format=flowed`) are collected into a caller-supplied `Vec<String>` rather than
12//! printed here; the CLI emits them verbatim so behavior is byte-identical.
13
14use crate::commit_encoding;
15use crate::error::{Error, Result};
16use crate::objects::ObjectId;
17
18/// A parsed patch from an mbox message.
19#[derive(Debug, Clone)]
20pub struct MboxPatch {
21    /// Author name + email (e.g. "Name <email>").
22    pub author: String,
23    /// Author date string (for the ident line).
24    pub date: String,
25    /// Commit message (subject + body).
26    pub message: String,
27    /// `charset=` from `Content-Type` when present (mbox body encoding).
28    pub content_charset: Option<String>,
29    /// The unified diff portion.
30    pub diff: String,
31    /// Message-ID from the email headers.
32    pub message_id: String,
33    /// When present, the commit OID from a `git format-patch` mbox `From <hex> Mon ...` line.
34    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
53/// Pine and similar mailers embed folder metadata messages; skip them when applying a concatenated mbox.
54pub 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
67/// Detect the patch format from file content.
68pub 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    // stgit format: first non-blank line is the subject (not a header),
74    // followed by From:/Date: headers
75    let mut lines = trimmed.lines();
76    if let Some(first) = lines.next() {
77        // Skip blanks after first line
78        let mut peeked = lines.clone();
79        // Look at lines 2-5 for From:/Date: pattern typical of stgit
80        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                    // Looks like stgit if first line isn't a standard mbox header
88                    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
105/// Detect if a file is an stgit series file.
106/// A series file has the specific comment "# This series applies on GIT commit"
107/// followed by filenames.
108pub 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    // It's a series file if it has the series header and no From:/Date: headers
124    has_series_header && !has_from_or_date
125}
126
127/// Parse an stgit-format patch into an MboxPatch.
128pub 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    // First non-blank line is the subject
140    for line in lines.by_ref() {
141        if !line.trim().is_empty() {
142            subject = line.trim().to_string();
143            break;
144        }
145    }
146
147    // Next lines are headers (From:, Date:) until blank line
148    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            // Not a header — must be body
164            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                // Skip diffstat lines between --- and diff --git
181                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
220/// Parse an hg (Mercurial) format patch into an MboxPatch.
221pub 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    // Parse HG headers (lines starting with #)
230    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            // HG date format: "epoch offset" where offset is seconds west of UTC
241            // Convert to git format: "epoch +/-HHMM"
242            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                    // HG offset is seconds west of UTC (positive = west)
248                    // Git offset is +/-HHMM (positive = east)
249                    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            // Skip other HG headers (Node ID, Parent, etc.)
265            continue;
266        }
267        // First non-header line — this is the start of the body
268        body_lines.push(line);
269        break;
270    }
271
272    // Parse remaining body + diff
273    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    // For HG patches, the first line of the body is the subject
289    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
316/// Parse patches from input, auto-detecting or using the specified format.
317///
318/// `warnings` collects stderr warnings (`format=flowed`, quoted CRLF) that `git am`
319/// prints while parsing; the caller emits them verbatim.
320pub 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
348/// Unquote mboxrd format: lines starting with >From (or >>From, etc.) are unquoted.
349/// In mboxrd, "From " lines inside messages are escaped by prepending ">".
350/// Un-flow format=flowed lines (RFC 3676).
351/// Lines ending with a trailing space are "flowed" — joined with the next line.
352/// Also handles space-unstuffing: one leading space is removed from lines
353/// that start with a space (to undo the space-stuffing required by RFC 3676).
354fn 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        // Space-unstuffing: remove one leading space
360        let unstuffed = if line.starts_with(' ') {
361            &line[1..]
362        } else {
363            line
364        };
365
366        if unstuffed.ends_with(' ') {
367            // Flowed line: keep the trailing space (it's content), join with next
368            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            // mbox separator - reset state
402            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        // In body: unquote >From lines
418        if line_no_cr.starts_with(">From ")
419            || (line_no_cr.starts_with(">>") && line_no_cr.contains("From "))
420        {
421            // Strip one leading > if the line matches >+From pattern
422            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    // Remove trailing extra newline if input didn't end with one
431    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
539/// If `line` is a `git format-patch` mbox separator (`From <40-hex> Mon Sep 17 ...`), return the
540/// commit OID; otherwise `None` (e.g. `From: user@host` in mail headers).
541fn 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
554/// Parse an mbox file into individual patches with options.
555///
556/// `warnings` collects stderr warnings (`format=flowed`, quoted CRLF) that `git am`
557/// prints while parsing; the caller emits them verbatim.
558pub 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    // Handle mboxrd: unquote >From lines
569    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        // Skip to next "From " line (mbox separator)
576        // Or if we're at the start and there's no "From " line, treat as single patch
577        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        // Look for "From " separator line
587        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(); // consume "From " line
593                break;
594            }
595            // If we haven't found any "From " line yet and we see headers, treat as raw patch
596            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(); // skip non-header lines before first message
609                continue;
610            }
611            break;
612        }
613
614        if !found_from && lines.peek().is_none() {
615            break;
616        }
617
618        // Parse headers
619        _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            // Continuation line (starts with whitespace)
633            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                // Strip [PATCH ...] prefix unless --keep
652                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        // Build message from subject + body. Subject continuation lines in
735        // mailbox headers are folded in two ways:
736        // - default (`git am`): unwrap subject continuations into one line;
737        // - keep mode (`git am -k`): preserve continuation line breaks.
738        //
739        // `Subject:` continuation lines are captured in `body_lines` by this
740        // parser, so normalize here before constructing the final message.
741        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        // Handle --scissors: trim at scissors line, potentially replace subject
770        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        // Parse author into "Name <email>" format and extract date
783        let author_ident = parse_author_ident(&author, &date);
784
785        // Un-flow format=flowed content
786        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
818/// Strip "[PATCH n/m] " or "[PATCH] " prefix from subject.
819fn 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
831/// Strip only PATCH-related bracket content, keep non-patch brackets.
832fn 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 it looks like a PATCH prefix, strip it
837            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
848/// Apply scissors to the full message (subject + body), replacing subject if needed.
849fn apply_scissors_to_message(subject: &str, body: &str) -> (String, String) {
850    // Check if scissors line is in the body
851    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        // Everything after scissors
862        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        // Look for Subject: pseudo-header after scissors
867        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                // Non-header line
882                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
900/// Check if a line is a scissors line.
901/// Git looks for lines containing ">8" or "8<" preceded by dashes/spaces.
902/// Examples: "-- >8 --", " - - >8 - - remove everything above"
903fn is_scissors_line(line: &str) -> bool {
904    // Find ">8" or "8<" in the line
905    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    // Everything before the scissors marker must be only '-' and ' '
914    let prefix = &line[..scissors_pos];
915    if prefix.is_empty() {
916        return false;
917    }
918    prefix.chars().all(|c| c == '-' || c == ' ')
919}
920
921/// Parse "Name <email>" and date string into (author_ident, epoch_offset).
922fn parse_author_ident(author: &str, date: &str) -> (String, String) {
923    // Try to parse the date into epoch format
924    let epoch_date = parse_date_to_epoch(date);
925    (author.to_string(), epoch_date)
926}
927
928/// Try to parse various date formats into "epoch offset" format.
929fn parse_date_to_epoch(date: &str) -> String {
930    if date.is_empty() {
931        return String::new();
932    }
933
934    // Already in "epoch offset" format?
935    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    // Try RFC 2822-like: "Thu, 07 Apr 2005 22:14:13 -0700"
941    if let Some(parsed) = parse_rfc2822_date(date) {
942        return parsed;
943    }
944
945    // Fall back: just use the date string as-is
946    date.to_string()
947}
948
949/// Parse an RFC 2822-style date into "epoch offset" format.
950fn parse_rfc2822_date(date: &str) -> Option<String> {
951    // Format: "Day, DD Mon YYYY HH:MM:SS +/-HHMM" or without the day prefix
952    let trimmed = date.trim();
953
954    // Extract the timezone offset (last token)
955    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    // Parse timezone offset like +0700 or -0700
964    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    // Strip leading "Day, " if present
977    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    // Parse "DD Mon YYYY HH:MM:SS"
985    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    // Convert to Unix timestamp
1020    // Days from year 0 to year, then month/day, then subtract Unix epoch
1021    let epoch = datetime_to_epoch(year, month, day, hour, min, sec, tz_offset_secs)?;
1022
1023    Some(format!("{} {}", epoch, tz_str))
1024}
1025
1026/// Convert a date to Unix epoch seconds.
1027fn 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    // Use a simple calculation
1037    let m = if month <= 2 { month + 12 } else { month };
1038    let y = if month <= 2 { year - 1 } else { year };
1039
1040    // Julian Day Number
1041    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    // Unix epoch = JDN of 1970-01-01 = 2440588
1047    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
1054/// Serialize an MboxPatch for storage in the state directory.
1055pub 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
1076/// Deserialize an MboxPatch from state directory storage.
1077pub 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}