Skip to main content

grit_lib/porcelain/
format_patch.rs

1//! `git format-patch` mail-rendering primitives.
2//!
3//! The full `format-patch` command in the `grit` binary parses argv, walks the
4//! revision range, reads config, runs rename detection, talks to the notes /
5//! range-diff machinery, decides on output files vs. stdout, prints the written
6//! filenames, and maps exit codes. Those responsibilities — argv parsing,
7//! revision selection, config/env resolution, file output, terminal printing,
8//! and the `crate`-internal log/range-diff dispatch — stay in the CLI.
9//!
10//! What lives here is the self-contained, presentation-free core of mbox patch
11//! assembly: the byte-exact RFC 2047 / RFC 822 header encoders and folders, the
12//! `[PATCH n/m]` subject builder, the filename sanitizer/truncator, the
13//! committer-date formatter, the threading-header writer, and the small string
14//! transforms (`mboxrd` escaping, ident formatting, reroll/version labels) that
15//! `git`'s `pretty.c`, `utf8.c`, and `builtin/log.c` use to turn a commit plus
16//! its diff into an email. Each function computes a result from plain strings,
17//! bytes, or a [`CommitData`] alone — no argv, no terminal output, and no
18//! process/filesystem state — so the CLI can call them while still owning every
19//! I/O and config decision.
20
21use crate::objects::CommitData;
22
23/// Format an identity string as "Name <email>".
24pub fn format_ident(ident: &str) -> String {
25    if let Some(bracket) = ident.find('<') {
26        if let Some(end) = ident.find('>') {
27            let name = ident[..bracket].trim();
28            let email = &ident[bracket..=end];
29            return format!("{name} {email}");
30        }
31    }
32    ident.to_owned()
33}
34
35/// Encode an email address for use in email headers.
36///
37/// Rules:
38/// - If the display name contains non-ASCII chars → RFC 2047 encode it
39/// - If the display name contains RFC 822 special chars (like `.`) → quote it
40/// - Otherwise → use as-is
41pub fn encode_email_address(addr: &str) -> String {
42    // Parse "Display Name <email@example.com>" form
43    if let (Some(lt), Some(gt)) = (addr.rfind('<'), addr.rfind('>')) {
44        if lt < gt {
45            let name = addr[..lt].trim();
46            let email_part = &addr[lt..=gt]; // "<email>"
47            if name.is_empty() {
48                return addr.to_string();
49            }
50            let encoded_name = encode_display_name(name);
51            return format!("{encoded_name} {email_part}");
52        }
53    }
54    // No angle brackets — return as-is
55    addr.to_string()
56}
57
58/// Charset token for RFC 2047 `=?charset?q?...?=` (matches Git test expectations).
59pub fn rfc2047_charset_label(log_output_encoding: &str) -> String {
60    let t = log_output_encoding.trim();
61    let lower = t.to_ascii_lowercase();
62    if lower == "utf-8" || lower == "utf8" {
63        return "UTF-8".to_owned();
64    }
65    if matches!(
66        lower.as_str(),
67        "iso-8859-1" | "iso8859-1" | "latin1" | "latin-1"
68    ) {
69        return "ISO8859-1".to_owned();
70    }
71    t.to_owned()
72}
73
74/// Like [`encode_email_address`] but uses `charset_label` for RFC 2047 when non-ASCII.
75pub fn encode_email_address_for_charset(addr: &str, charset_label: &str) -> String {
76    if charset_label.eq_ignore_ascii_case("UTF-8") {
77        return encode_email_address(addr);
78    }
79    if let (Some(lt), Some(gt)) = (addr.rfind('<'), addr.rfind('>')) {
80        if lt < gt {
81            let name = addr[..lt].trim();
82            let email_part = &addr[lt..=gt];
83            if name.is_empty() {
84                return addr.to_string();
85            }
86            let encoded_name = encode_display_name_for_charset(name, charset_label);
87            return format!("{encoded_name} {email_part}");
88        }
89    }
90    addr.to_string()
91}
92
93fn encode_display_name_for_charset(name: &str, charset_label: &str) -> String {
94    if charset_label.eq_ignore_ascii_case("UTF-8") {
95        return encode_display_name(name);
96    }
97    if name.bytes().any(|b| b > 0x7f) {
98        return rfc2047_encode_with_charset(name, charset_label);
99    }
100    let specials = |c: char| {
101        matches!(
102            c,
103            '(' | ')' | '<' | '>' | '[' | ']' | ':' | ';' | '@' | '\\' | ',' | '.' | '"'
104        )
105    };
106    if name.chars().any(specials) {
107        let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
108        return format!("\"{escaped}\"");
109    }
110    name.to_string()
111}
112
113fn rfc2047_encode_with_charset(name: &str, charset_label: &str) -> String {
114    let bytes = if charset_label.eq_ignore_ascii_case("UTF-8") {
115        name.as_bytes().to_vec()
116    } else {
117        match crate::commit_encoding::encode_unicode(charset_label, name) {
118            Some(mut raw) => {
119                while raw.last() == Some(&b'\n') {
120                    raw.pop();
121                }
122                raw
123            }
124            None => return rfc2047_encode(name),
125        }
126    };
127    let mut encoded = String::new();
128    for &byte in &bytes {
129        match byte {
130            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' => {
131                encoded.push(byte as char);
132            }
133            b' ' => encoded.push_str("=20"),
134            _ => encoded.push_str(&format!("={byte:02X}")),
135        }
136    }
137    format!("=?{charset_label}?q?{encoded}?=")
138}
139
140/// Encode a display name portion of an email address.
141///
142/// - Non-ASCII → RFC 2047 UTF-8 quoted-printable
143/// - Contains RFC 822 specials → RFC 822 quoted string
144/// - Otherwise → plain
145pub fn encode_display_name(name: &str) -> String {
146    // Check for non-ASCII
147    if name.bytes().any(|b| b > 0x7f) {
148        return rfc2047_encode(name);
149    }
150    // RFC 822 specials that require quoting
151    // Specials are: ( ) < > [ ] : ; @ \ , . "
152    let specials = |c: char| {
153        matches!(
154            c,
155            '(' | ')' | '<' | '>' | '[' | ']' | ':' | ';' | '@' | '\\' | ',' | '.' | '"'
156        )
157    };
158    if name.chars().any(specials) {
159        // Quote the name
160        let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
161        return format!("\"{escaped}\"");
162    }
163    name.to_string()
164}
165
166/// RFC 2047 UTF-8 quoted-printable encoding for an email display name.
167pub fn rfc2047_encode(name: &str) -> String {
168    let mut encoded = String::new();
169    for byte in name.as_bytes() {
170        match byte {
171            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' => {
172                encoded.push(*byte as char);
173            }
174            b' ' => {
175                encoded.push_str("=20");
176            }
177            _ => {
178                encoded.push_str(&format!("={:02X}", byte));
179            }
180        }
181    }
182    format!("=?UTF-8?q?{encoded}?=")
183}
184
185/// Write a folded email header with multiple values.
186///
187/// Emits:
188/// ```text
189/// HeaderName: value1,
190///  value2
191/// ```
192pub fn write_folded_header(out: &mut String, name: &str, values: &[String]) {
193    if values.is_empty() {
194        return;
195    }
196    out.push_str(name);
197    out.push_str(": ");
198    for (i, val) in values.iter().enumerate() {
199        if i > 0 {
200            out.push_str(",\n ");
201        }
202        out.push_str(val);
203    }
204    out.push('\n');
205}
206
207/// Extract date from identity string and format as RFC 2822-like.
208pub fn format_date_rfc2822(ident: &str) -> String {
209    // Git ident: "Name <email> timestamp offset"
210    let parts: Vec<&str> = ident.rsplitn(3, ' ').collect();
211    if parts.len() >= 2 {
212        let ts_str = parts[1];
213        let offset_str = parts[0];
214        if let Ok(ts) = ts_str.parse::<i64>() {
215            // Parse the offset string (e.g. "+0000", "-0700") into a UtcOffset
216            let tz_offset = parse_tz_offset(offset_str).unwrap_or(time::UtcOffset::UTC);
217            let dt = time::OffsetDateTime::from_unix_timestamp(ts)
218                .unwrap_or(time::OffsetDateTime::UNIX_EPOCH)
219                .to_offset(tz_offset);
220            // git uses a space-padded day-of-month (e.g. "Thu, 7 Apr 2005"), not zero-padded.
221            let format = time::format_description::parse(
222                "[weekday repr:short], [day padding:none] [month repr:short] [year] [hour]:[minute]:[second] ",
223            );
224            if let Ok(fmt) = format {
225                if let Ok(formatted) = dt.format(&fmt) {
226                    return format!("{formatted}{offset_str}");
227                }
228            }
229        }
230        format!("{ts_str} {offset_str}")
231    } else {
232        ident.to_owned()
233    }
234}
235
236fn parse_tz_offset(s: &str) -> Option<time::UtcOffset> {
237    if s.len() != 5 {
238        return None;
239    }
240    let sign: i8 = match s.as_bytes()[0] {
241        b'+' => 1,
242        b'-' => -1,
243        _ => return None,
244    };
245    let hours: i8 = s[1..3].parse::<i8>().ok()?;
246    let minutes: i8 = s[3..5].parse::<i8>().ok()?;
247    time::UtcOffset::from_hms(sign * hours, sign * minutes, 0).ok()
248}
249
250/// Build the full patch basename `<file_prefix><NNNN>-<sanitized-subject>.patch`, truncating the
251/// whole basename to `filename_max_length - 1` chars (Git's `FORMAT_PATCH_NAME_MAX`, default 64).
252pub fn build_patch_filename(
253    file_prefix: &str,
254    patch_num: usize,
255    subject: &str,
256    max_len: Option<usize>,
257    suffix: &str,
258) -> String {
259    let max = max_len.unwrap_or(64);
260    let head = format!("{file_prefix}{patch_num:04}-");
261    let sanitized = sanitize_subject(subject);
262    // Cap so that head + sanitized + suffix has length <= max - 1.
263    let budget = (max.saturating_sub(1)).saturating_sub(suffix.len());
264    let mut name = head.clone();
265    name.push_str(&sanitized);
266    let truncated = truncate_on_char_boundary(&name, budget);
267    let truncated = truncated.trim_end_matches('-');
268    format!("{truncated}{suffix}")
269}
270
271/// Truncate `s` to at most `max` bytes, on a UTF-8 char boundary (never splits a multi-byte char).
272fn truncate_on_char_boundary(s: &str, max: usize) -> &str {
273    if s.len() <= max {
274        return s;
275    }
276    let mut end = max;
277    while end > 0 && !s.is_char_boundary(end) {
278        end -= 1;
279    }
280    &s[..end]
281}
282
283/// True for the "title characters" Git keeps verbatim in a sanitized subject: ASCII alnum, `.`, `_`.
284fn is_title_char(b: u8) -> bool {
285    b.is_ascii_alphanumeric() || b == b'.' || b == b'_'
286}
287
288/// Sanitize a subject line for use as a filename, matching Git's `format_sanitized_subject`
289/// byte-for-byte: runs of non-title bytes collapse into a single `-`, consecutive `.` collapse
290/// into one, and trailing `.`/`-` are trimmed. Operates on raw bytes (non-ASCII → separators).
291pub fn sanitize_subject(subject: &str) -> String {
292    let bytes = subject.as_bytes();
293    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
294    let mut space = 2i32;
295    let mut i = 0usize;
296    while i < bytes.len() {
297        let b = bytes[i];
298        if is_title_char(b) {
299            if space == 1 {
300                out.push(b'-');
301            }
302            space = 0;
303            out.push(b);
304            if b == b'.' {
305                while i + 1 < bytes.len() && bytes[i + 1] == b'.' {
306                    i += 1;
307                }
308            }
309        } else {
310            space |= 1;
311        }
312        i += 1;
313    }
314    // Trim trailing '.' and '-'.
315    while matches!(out.last(), Some(b'.') | Some(b'-')) {
316        out.pop();
317    }
318    String::from_utf8_lossy(&out).into_owned()
319}
320
321// ---------------------------------------------------------------------------
322// Header encoding / folding (ported from git's pretty.c + utf8.c)
323// ---------------------------------------------------------------------------
324
325/// Length of the last line of `s` (bytes after the final `\n`).
326pub fn last_line_length(s: &str) -> usize {
327    match s.rfind('\n') {
328        Some(i) => s.len() - (i + 1),
329        None => s.len(),
330    }
331}
332
333/// True if `line` needs RFC2047 encoding (non-ASCII, newline, or `=?`).
334pub fn needs_rfc2047_encoding(line: &str) -> bool {
335    let b = line.as_bytes();
336    for i in 0..b.len() {
337        let c = b[i];
338        if c >= 0x80 || c == b'\n' {
339            return true;
340        }
341        if i + 1 < b.len() && c == b'=' && b[i + 1] == b'?' {
342            return true;
343        }
344    }
345    false
346}
347
348/// True for chars Git considers RFC822 special (require quoting in a display name).
349fn is_rfc822_special(c: u8) -> bool {
350    matches!(
351        c,
352        b'(' | b')' | b'<' | b'>' | b'[' | b']' | b':' | b';' | b'@' | b',' | b'.' | b'"' | b'\\'
353    )
354}
355
356pub fn needs_rfc822_quoting(s: &str) -> bool {
357    s.bytes().any(is_rfc822_special)
358}
359
360pub fn add_rfc822_quoted(s: &str) -> String {
361    let mut out = String::with_capacity(s.len() + 2);
362    out.push('"');
363    for c in s.chars() {
364        if c == '"' || c == '\\' {
365            out.push('\\');
366        }
367        out.push(c);
368    }
369    out.push('"');
370    out
371}
372
373#[derive(Clone, Copy, PartialEq, Eq)]
374pub enum Rfc2047Type {
375    Subject,
376    Address,
377}
378
379fn is_rfc2047_special(c: u8, ty: Rfc2047Type) -> bool {
380    if c >= 0x80 || !(c as char).is_ascii_graphic() && c != b' ' {
381        return true;
382    }
383    if c == b' ' || c == b'\t' || c == b'=' || c == b'?' || c == b'_' {
384        return true;
385    }
386    if ty != Rfc2047Type::Address {
387        return false;
388    }
389    !(c.is_ascii_alphanumeric() || c == b'!' || c == b'*' || c == b'+' || c == b'-' || c == b'/')
390}
391
392/// Append `line` RFC2047-Q-encoded to `out`, folding at 76 columns with continuation lines.
393pub fn add_rfc2047(out: &mut String, line: &str, encoding: &str, ty: Rfc2047Type) {
394    if !encoding.eq_ignore_ascii_case("UTF-8") {
395        if let Some(bytes) = crate::commit_encoding::encode_header_text(encoding, line) {
396            add_rfc2047_bytes(out, &bytes, encoding, ty);
397            return;
398        }
399    }
400
401    const MAX_ENCODED_LENGTH: usize = 76;
402    let mut line_len = last_line_length(out);
403    out.push_str(&format!("=?{encoding}?q?"));
404    line_len += encoding.len() + 5; // "=??q?"
405
406    // Iterate by Unicode chars (multi-octet chars must not split across encoded-words).
407    for ch in line.chars() {
408        let mut buf = [0u8; 4];
409        let bytes = ch.encode_utf8(&mut buf).as_bytes();
410        let chrlen = bytes.len();
411        let is_special = chrlen > 1 || is_rfc2047_special(bytes[0], ty);
412        let encoded_len = if is_special { 3 * chrlen } else { 1 };
413
414        if line_len + encoded_len + 2 > MAX_ENCODED_LENGTH {
415            out.push_str(&format!("?=\n =?{encoding}?q?"));
416            line_len = encoding.len() + 5 + 1; // "=??q?" plus leading SP
417        }
418
419        if is_special {
420            for b in bytes {
421                out.push_str(&format!("={b:02X}"));
422            }
423        } else {
424            out.push(bytes[0] as char);
425        }
426        line_len += encoded_len;
427    }
428    out.push_str("?=");
429}
430
431fn add_rfc2047_bytes(out: &mut String, bytes: &[u8], encoding: &str, ty: Rfc2047Type) {
432    const MAX_ENCODED_LENGTH: usize = 76;
433    let mut line_len = last_line_length(out);
434    out.push_str(&format!("=?{encoding}?q?"));
435    line_len += encoding.len() + 5; // "=??q?"
436
437    for &byte in bytes {
438        let is_special = is_rfc2047_special(byte, ty);
439        let encoded_len = if is_special { 3 } else { 1 };
440
441        if line_len + encoded_len + 2 > MAX_ENCODED_LENGTH {
442            out.push_str(&format!("?=\n =?{encoding}?q?"));
443            line_len = encoding.len() + 5 + 1; // "=??q?" plus leading SP
444        }
445
446        if is_special {
447            out.push_str(&format!("={byte:02X}"));
448        } else {
449            out.push(byte as char);
450        }
451        line_len += encoded_len;
452    }
453    out.push_str("?=");
454}
455
456/// Port of git's `strbuf_add_wrapped_text` for ASCII text (used for subject/From folding).
457/// `indent1` negative means `-indent1` columns are already consumed on the current line.
458pub fn add_wrapped_text(out: &mut String, text: &str, indent1: i32, indent2: i32, width: i32) {
459    if width <= 0 {
460        // strbuf_add_indented_text
461        let mut indent = indent1.max(0);
462        for (i, line) in split_keep_newlines(text).into_iter().enumerate() {
463            let ind = if i == 0 { indent } else { indent2.max(0) };
464            for _ in 0..ind {
465                out.push(' ');
466            }
467            out.push_str(&line);
468            indent = indent2.max(0);
469        }
470        return;
471    }
472
473    let bytes = text.as_bytes();
474    // Each char treated width 1 (ASCII path). Reproduce git's loop on byte positions.
475    let mut w: i32;
476    let mut indent: i32;
477    let mut bol: usize;
478    let mut space: Option<usize>;
479    let mut text_pos: usize = 0;
480
481    bol = 0;
482    w = indent1;
483    indent = indent1;
484    space = None;
485    if indent < 0 {
486        w = -indent;
487        space = Some(0);
488    }
489
490    loop {
491        let c = if text_pos < bytes.len() {
492            bytes[text_pos]
493        } else {
494            0
495        };
496        if c == 0 || (c as char).is_ascii_whitespace() {
497            if w <= width || space.is_none() {
498                let start = if c == 0 && text_pos == bol {
499                    return;
500                } else if let Some(sp) = space {
501                    sp
502                } else {
503                    for _ in 0..indent.max(0) {
504                        out.push(' ');
505                    }
506                    bol
507                };
508                out.push_str(&text[start..text_pos]);
509                if c == 0 {
510                    return;
511                }
512                space = Some(text_pos);
513                if c == b'\t' {
514                    w |= 0x07;
515                } else if c == b'\n' {
516                    let sp = text_pos + 1;
517                    space = Some(sp);
518                    let next = bytes.get(sp).copied().unwrap_or(0);
519                    if next == b'\n' {
520                        out.push('\n');
521                        // goto new_line
522                        out.push('\n');
523                        text_pos = bol_after_space(bytes, space);
524                        bol = text_pos;
525                        space = None;
526                        w = indent2;
527                        indent = indent2;
528                        continue;
529                    } else if !(next as char).is_ascii_alphanumeric() {
530                        out.push('\n');
531                        text_pos = bol_after_space(bytes, space);
532                        bol = text_pos;
533                        space = None;
534                        w = indent2;
535                        indent = indent2;
536                        continue;
537                    } else {
538                        out.push(' ');
539                    }
540                }
541                w += 1;
542                text_pos += 1;
543            } else {
544                // new_line
545                out.push('\n');
546                let sp = space.unwrap_or(text_pos);
547                let skip = if (bytes.get(sp).copied().unwrap_or(0) as char).is_ascii_whitespace() {
548                    1
549                } else {
550                    0
551                };
552                text_pos = sp + skip;
553                bol = text_pos;
554                space = None;
555                w = indent2;
556                indent = indent2;
557            }
558            continue;
559        }
560        w += 1;
561        text_pos += 1;
562    }
563}
564
565fn bol_after_space(bytes: &[u8], space: Option<usize>) -> usize {
566    let sp = space.unwrap_or(0);
567    if (bytes.get(sp).copied().unwrap_or(0) as char).is_ascii_whitespace() {
568        sp + 1
569    } else {
570        sp
571    }
572}
573
574fn split_keep_newlines(text: &str) -> Vec<String> {
575    let mut out = Vec::new();
576    let mut cur = String::new();
577    for c in text.chars() {
578        cur.push(c);
579        if c == '\n' {
580            out.push(std::mem::take(&mut cur));
581        }
582    }
583    if !cur.is_empty() {
584        out.push(cur);
585    }
586    out
587}
588
589/// Write the `Subject:` header (already-built subject string), encoding/folding like git.
590pub fn write_subject_header(out: &mut String, subject: &str, encode: bool, charset_label: &str) {
591    const MAX_LENGTH: i32 = 78;
592    out.push_str("Subject: ");
593    // Git keeps the bracketed subject prefix (`[PATCH N/M] `) literal and only RFC2047-encodes
594    // the title that follows it. Split off a leading `[...] ` prefix so it is emitted verbatim.
595    let (literal_prefix, title) = split_subject_prefix(subject);
596    if encode && needs_rfc2047_encoding(title) {
597        if !literal_prefix.is_empty() {
598            out.push_str(literal_prefix);
599        }
600        add_rfc2047(out, title, charset_label, Rfc2047Type::Subject);
601    } else {
602        let consumed = last_line_length(out) as i32;
603        add_wrapped_text(out, subject, -consumed, 1, MAX_LENGTH);
604    }
605    out.push('\n');
606}
607
608/// Split a subject into its literal `[...] ` prefix (kept verbatim by git) and the remaining
609/// title. Returns `("", subject)` when there is no bracketed prefix.
610fn split_subject_prefix(subject: &str) -> (&str, &str) {
611    if !subject.starts_with('[') {
612        return ("", subject);
613    }
614    if let Some(close) = subject.find(']') {
615        // Include the closing bracket and a single following space (if present) in the prefix.
616        let mut end = close + 1;
617        if subject[end..].starts_with(' ') {
618            end += 1;
619        }
620        return (&subject[..end], &subject[end..]);
621    }
622    ("", subject)
623}
624
625/// Write a `From:`/recipient address header `<Name> <mail>`, encoding/folding the display name.
626pub fn write_addr_header(
627    out: &mut String,
628    what: &str,
629    mailbox: &str,
630    encode: bool,
631    charset_label: &str,
632) {
633    let (name, mail) = split_mailbox(mailbox);
634    let mut max_length: i32 = 78;
635    out.push_str(what);
636    out.push_str(": ");
637    if name.is_empty() {
638        // No display name: just "<mail>" (or the raw mailbox if unparsable).
639        if mail.is_empty() {
640            out.push_str(mailbox);
641        } else {
642            out.push_str(&format!("<{mail}>"));
643        }
644        out.push('\n');
645        return;
646    }
647    if encode && needs_rfc2047_encoding(&name) {
648        add_rfc2047(out, &name, charset_label, Rfc2047Type::Address);
649        max_length = 76;
650    } else if needs_rfc822_quoting(&name) {
651        let quoted = add_rfc822_quoted(&name);
652        let consumed = last_line_length(out) as i32;
653        add_wrapped_text(out, &quoted, -consumed, 1, max_length);
654    } else {
655        let consumed = last_line_length(out) as i32;
656        add_wrapped_text(out, &name, -consumed, 1, max_length);
657    }
658    if (max_length as usize) < last_line_length(out) + " <".len() + mail.len() + ">".len() {
659        out.push('\n');
660    }
661    out.push_str(&format!(" <{mail}>\n"));
662}
663
664/// Split "Name <mail>" into (name, mail). If no brackets, name is the whole thing, mail empty.
665fn split_mailbox(mailbox: &str) -> (String, String) {
666    if let (Some(lt), Some(gt)) = (mailbox.rfind('<'), mailbox.rfind('>')) {
667        if lt < gt {
668            let name = mailbox[..lt].trim().to_string();
669            let mail = mailbox[lt + 1..gt].to_string();
670            return (name, mail);
671        }
672    }
673    (mailbox.trim().to_string(), String::new())
674}
675
676/// Write In-Reply-To / References / Message-ID threading headers.
677pub fn write_thread_headers(
678    out: &mut String,
679    message_id: &str,
680    in_reply_to: Option<&str>,
681    references: &[String],
682) {
683    if !message_id.is_empty() {
684        out.push_str(&format!("Message-ID: <{message_id}>\n"));
685    }
686    if let Some(irt) = in_reply_to {
687        out.push_str(&format!("In-Reply-To: <{}>\n", strip_angles(irt)));
688    }
689    if !references.is_empty() {
690        out.push_str("References: ");
691        for (i, r) in references.iter().enumerate() {
692            if i > 0 {
693                out.push_str("\n\t");
694            }
695            out.push_str(&format!("<{}>", strip_angles(r)));
696        }
697        out.push('\n');
698    }
699}
700
701pub fn strip_angles(s: &str) -> &str {
702    s.trim().trim_start_matches('<').trim_end_matches('>')
703}
704
705/// Write the trailing signature block `-- \n<sig>\n\n`, or nothing when suppressed.
706pub fn write_signature(out: &mut String, signature: Option<&str>) {
707    if let Some(sig) = signature {
708        out.push_str("-- \n");
709        out.push_str(sig);
710        out.push('\n');
711        out.push('\n');
712    }
713}
714
715// ---------------------------------------------------------------------------
716// Subject / prefix / reroll / threading helpers
717// ---------------------------------------------------------------------------
718
719/// The first physical line of the subject (used for the patch filename, matching git which stops
720/// `format_sanitized_subject` at the first newline). Returns the whole trimmed message if single-line.
721pub fn first_subject_line(message: &str) -> &str {
722    let start = message.len() - message.trim_start().len();
723    let rest = &message[start..];
724    match rest.find('\n') {
725        Some(nl) => rest[..nl].trim_end(),
726        None => rest.trim_end(),
727    }
728}
729
730/// Flatten a multi-line commit message into a single-line subject (paragraph join with spaces).
731pub fn flatten_subject(message: &str) -> String {
732    let mut out = String::new();
733    for line in message.lines() {
734        let trimmed = line.trim();
735        if trimmed.is_empty() {
736            break;
737        }
738        if !out.is_empty() {
739            out.push(' ');
740        }
741        out.push_str(trimmed);
742    }
743    out
744}
745
746/// Build a patch Subject value: `[<prefix> n/m] <subject>` with proper handling of empty prefix.
747pub fn build_patch_subject(
748    prefix: &str,
749    keep_subject: bool,
750    use_numbering: bool,
751    patch_num: usize,
752    display_total: usize,
753    subject_line: &str,
754) -> String {
755    if keep_subject {
756        return subject_line.to_string();
757    }
758    let tag = if use_numbering {
759        if prefix.is_empty() {
760            format!("[{patch_num}/{display_total}]")
761        } else {
762            format!("[{prefix} {patch_num}/{display_total}]")
763        }
764    } else if prefix.is_empty() {
765        // Git emits no bracket tag when the prefix is empty and numbering is off.
766        String::new()
767    } else {
768        format!("[{prefix}]")
769    };
770    if tag.is_empty() {
771        subject_line.to_string()
772    } else {
773        // Git always joins the tag and subject with a single space, so an empty subject yields
774        // a trailing space after the tag (`Subject: [PATCH] `).
775        format!("{tag} {subject_line}")
776    }
777}
778
779/// Apply the `--rfc[=<str>]` modifier to a subject prefix.
780/// Default `RFC` prepends "RFC "; a value starting with `-` appends `(...)`; else replaces leader.
781pub fn apply_rfc_prefix(prefix: &str, rfc: &str) -> String {
782    if let Some(rest) = rfc.strip_prefix('-') {
783        // Append form: `--rfc=-(WIP)` → "PATCH (WIP)".
784        if prefix.is_empty() {
785            rest.trim_start_matches('-').to_string()
786        } else {
787            format!("{prefix} {}", rest.trim_start())
788        }
789    } else if prefix.is_empty() {
790        rfc.to_string()
791    } else {
792        format!("{rfc} {prefix}")
793    }
794}
795
796pub fn commit_author_timestamp(commit: &CommitData) -> i64 {
797    let parts: Vec<&str> = commit.author.rsplitn(3, ' ').collect();
798    parts
799        .get(1)
800        .and_then(|s| s.parse::<i64>().ok())
801        .unwrap_or(0)
802}
803
804/// Validate a `--from=<ident>` value: must look like an email ident (contain `@`).
805pub fn is_valid_from_ident(ident: &str) -> bool {
806    ident.contains('@')
807}
808
809/// Ensure a directory prefix ends with `/`.
810pub fn ensure_trailing_slash(s: &str) -> String {
811    if s.ends_with('/') {
812        s.to_string()
813    } else {
814        format!("{s}/")
815    }
816}
817
818pub fn path_matches_spec(path: &str, spec: &str) -> bool {
819    path == spec || path.starts_with(&format!("{spec}/"))
820}
821
822/// Sanitize a reroll-count string for use in a filename prefix (`v<x>-`), like git's sanitizer.
823pub fn sanitize_reroll(v: &str) -> String {
824    sanitize_subject(v)
825}
826
827/// Map a `format.notes` / `--notes=` value to a full notes ref (`refs/notes/<x>` unless already a
828/// full `refs/...` ref). An empty value or `true` means the default `refs/notes/commits`.
829pub fn notes_value_to_ref(val: &str) -> String {
830    let v = val.trim();
831    if v.is_empty() || v == "true" {
832        "refs/notes/commits".to_string()
833    } else if v.starts_with("refs/") {
834        v.to_string()
835    } else {
836        format!("refs/notes/{v}")
837    }
838}
839
840/// `Interdiff against v<N-1>:` label, or `None` if reroll is not an integer >= 2.
841pub fn prev_version_label(reroll: &str) -> Option<String> {
842    let n: u32 = reroll.parse().ok()?;
843    if n >= 2 {
844        Some(format!("v{}", n - 1))
845    } else {
846        None
847    }
848}
849
850/// Append `body` to `out`, indenting every line by two spaces (matching `sed -e "s/^/  /"`).
851pub fn push_indented(out: &mut String, body: &str) {
852    for line in body.split_inclusive('\n') {
853        out.push_str("  ");
854        out.push_str(line);
855    }
856    if !body.is_empty() && !body.ends_with('\n') {
857        out.push('\n');
858    }
859}
860
861/// Apply mboxrd `>From ` escaping to body lines if `mboxrd` is set.
862pub fn mboxrd_escape(body: &str, mboxrd: bool) -> String {
863    if !mboxrd {
864        return body.to_string();
865    }
866    let mut out = String::with_capacity(body.len());
867    for line in split_keep_newlines(body) {
868        let content = line.strip_suffix('\n').unwrap_or(&line);
869        // Escape lines matching `>*From ` (zero or more leading '>' then `From` followed by a
870        // space). A bare `From` is never an mbox delimiter, so git leaves it unescaped.
871        let trimmed_gt = content.trim_start_matches('>');
872        if trimmed_gt.starts_with("From ") || trimmed_gt.starts_with("From\t") {
873            out.push('>');
874        }
875        out.push_str(&line);
876    }
877    out
878}