1use crate::objects::CommitData;
22
23pub 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
35pub fn encode_email_address(addr: &str) -> String {
42 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]; 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 addr.to_string()
56}
57
58pub 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
74pub 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
140pub fn encode_display_name(name: &str) -> String {
146 if name.bytes().any(|b| b > 0x7f) {
148 return rfc2047_encode(name);
149 }
150 let specials = |c: char| {
153 matches!(
154 c,
155 '(' | ')' | '<' | '>' | '[' | ']' | ':' | ';' | '@' | '\\' | ',' | '.' | '"'
156 )
157 };
158 if name.chars().any(specials) {
159 let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
161 return format!("\"{escaped}\"");
162 }
163 name.to_string()
164}
165
166pub 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
185pub 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
207pub fn format_date_rfc2822(ident: &str) -> String {
209 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 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 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
250pub 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 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
271fn 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
283fn is_title_char(b: u8) -> bool {
285 b.is_ascii_alphanumeric() || b == b'.' || b == b'_'
286}
287
288pub 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 while matches!(out.last(), Some(b'.') | Some(b'-')) {
316 out.pop();
317 }
318 String::from_utf8_lossy(&out).into_owned()
319}
320
321pub 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
333pub 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
348fn 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
392pub 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; 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; }
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; 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; }
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
456pub fn add_wrapped_text(out: &mut String, text: &str, indent1: i32, indent2: i32, width: i32) {
459 if width <= 0 {
460 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 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 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 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
589pub 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 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
608fn split_subject_prefix(subject: &str) -> (&str, &str) {
611 if !subject.starts_with('[') {
612 return ("", subject);
613 }
614 if let Some(close) = subject.find(']') {
615 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
625pub 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 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, "ed, -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
664fn 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
676pub 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
705pub 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
715pub 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
730pub 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
746pub 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 String::new()
767 } else {
768 format!("[{prefix}]")
769 };
770 if tag.is_empty() {
771 subject_line.to_string()
772 } else {
773 format!("{tag} {subject_line}")
776 }
777}
778
779pub fn apply_rfc_prefix(prefix: &str, rfc: &str) -> String {
782 if let Some(rest) = rfc.strip_prefix('-') {
783 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
804pub fn is_valid_from_ident(ident: &str) -> bool {
806 ident.contains('@')
807}
808
809pub 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
822pub fn sanitize_reroll(v: &str) -> String {
824 sanitize_subject(v)
825}
826
827pub 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
840pub 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
850pub 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
861pub 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 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}