#[must_use]
pub fn is_swift_safe(c: char) -> bool {
matches!(
c,
'A'..='Z'
| 'a'..='z'
| '0'..='9'
| ' '
| '/'
| '-'
| '?'
| ':'
| '('
| ')'
| '.'
| ','
| '\''
| '+'
| '{'
| '}'
| '\r'
| '\n'
)
}
pub fn to_swift_charset(s: &str) -> (String, bool) {
let mut out = String::with_capacity(s.len());
let mut had_replacements = false;
for c in s.chars() {
if is_swift_safe(c) {
out.push(c);
} else {
had_replacements = true;
let replacement = approximate(c);
out.push_str(replacement);
}
}
(out, had_replacements)
}
fn approximate(c: char) -> &'static str {
match c {
'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => "A",
'Æ' => "AE",
'Ç' => "C",
'È' | 'É' | 'Ê' | 'Ë' => "E",
'Ì' | 'Í' | 'Î' | 'Ï' => "I",
'Ð' => "D",
'Ñ' => "N",
'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ø' => "O",
'Ù' | 'Ú' | 'Û' | 'Ü' => "U",
'Ý' => "Y",
'Þ' => "TH",
'ß' => "ss",
'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => "a",
'æ' => "ae",
'ç' => "c",
'è' | 'é' | 'ê' | 'ë' => "e",
'ì' | 'í' | 'î' | 'ï' => "i",
'ð' => "d",
'ñ' => "n",
'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' => "o",
'ù' | 'ú' | 'û' | 'ü' => "u",
'ý' | 'ÿ' => "y",
'þ' => "th",
'€' => "EUR",
'£' => "GBP",
'¥' => "JPY",
'\u{2018}' | '\u{2019}' | '\u{201C}' | '\u{201D}' => "'",
'\u{2013}' | '\u{2014}' => "-", '\u{2026}' => "...", _ => " ",
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WrapError {
pub truncated: Vec<String>,
pub overflow_chars: usize,
}
pub fn wrap_lines(
text: &str,
max_line_len: usize,
max_lines: usize,
) -> Result<Vec<String>, WrapError> {
assert!(
max_line_len > 0,
"wrap_lines max_line_len must be > 0, got {max_line_len}"
);
assert!(
max_lines > 0,
"wrap_lines max_lines must be > 0, got {max_lines}"
);
let trimmed = text.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
for word in trimmed.split_ascii_whitespace() {
let mut word_remaining = word;
while word_remaining.len() > max_line_len {
if !current.is_empty() {
lines.push(std::mem::take(&mut current));
}
let (head, tail) = word_remaining.split_at(max_line_len);
lines.push(head.to_string());
word_remaining = tail;
}
let needed = if current.is_empty() {
word_remaining.len()
} else {
current.len() + 1 + word_remaining.len()
};
if needed > max_line_len {
lines.push(std::mem::take(&mut current));
current.push_str(word_remaining);
} else {
if !current.is_empty() {
current.push(' ');
}
current.push_str(word_remaining);
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.len() <= max_lines {
Ok(lines)
} else {
let truncated: Vec<String> = lines.iter().take(max_lines).cloned().collect();
let kept_chars: usize =
truncated.iter().map(String::len).sum::<usize>() + truncated.len().saturating_sub(1); let total_chars: usize = trimmed
.split_ascii_whitespace()
.map(str::len)
.sum::<usize>()
+ trimmed.split_ascii_whitespace().count().saturating_sub(1);
Err(WrapError {
truncated,
overflow_chars: total_chars.saturating_sub(kept_chars),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ascii_letters_are_safe() {
for c in 'A'..='Z' {
assert!(is_swift_safe(c));
}
for c in 'a'..='z' {
assert!(is_swift_safe(c));
}
}
#[test]
fn test_digits_are_safe() {
for c in '0'..='9' {
assert!(is_swift_safe(c));
}
}
#[test]
fn test_swift_punctuation_safe() {
for c in [
' ', '/', '-', '?', ':', '(', ')', '.', ',', '\'', '+', '{', '}',
] {
assert!(is_swift_safe(c), "expected '{c}' to be SWIFT-safe");
}
}
#[test]
fn test_non_swift_chars_not_safe() {
assert!(!is_swift_safe('€'));
assert!(!is_swift_safe('ü'));
assert!(!is_swift_safe('ñ'));
}
#[test]
fn test_pure_ascii_no_replacement() {
let (s, replaced) = to_swift_charset("HELLO WORLD 123");
assert_eq!(s, "HELLO WORLD 123");
assert!(!replaced);
}
#[test]
fn test_umlaut_replacement() {
let (s, replaced) = to_swift_charset("Müller");
assert_eq!(s, "Muller");
assert!(replaced);
}
#[test]
fn test_euro_sign_replacement() {
let (s, replaced) = to_swift_charset("100€");
assert_eq!(s, "100EUR");
assert!(replaced);
}
#[test]
fn test_empty_string() {
let (s, replaced) = to_swift_charset("");
assert_eq!(s, "");
assert!(!replaced);
}
#[test]
fn test_wrap_lines_empty() {
assert_eq!(wrap_lines("", 35, 4).unwrap(), Vec::<String>::new());
assert_eq!(wrap_lines(" ", 35, 4).unwrap(), Vec::<String>::new());
}
#[test]
fn test_wrap_lines_short_fits_one_line() {
assert_eq!(wrap_lines("HELLO", 35, 4).unwrap(), vec!["HELLO"]);
}
#[test]
fn test_wrap_lines_exact_line_length() {
let s = "A".repeat(35);
assert_eq!(wrap_lines(&s, 35, 4).unwrap(), vec![s]);
}
#[test]
fn test_wrap_lines_word_boundary() {
let lines = wrap_lines("ACME CORPORATION INTERNATIONAL LIMITED", 20, 4).unwrap();
assert_eq!(lines, vec!["ACME CORPORATION", "INTERNATIONAL", "LIMITED"]);
}
#[test]
fn test_wrap_lines_hard_cut_long_word() {
let lines = wrap_lines(&"A".repeat(40), 10, 4).unwrap();
assert_eq!(lines, vec!["A".repeat(10); 4]);
}
#[test]
fn test_wrap_lines_overflow_returns_truncated_and_chars() {
let err = wrap_lines("one two three four five six seven", 5, 2).unwrap_err();
assert_eq!(err.truncated.len(), 2);
for line in &err.truncated {
assert!(line.len() <= 5, "line over budget: {line:?}");
}
assert!(
err.overflow_chars > 0,
"expected overflow_chars > 0, got {}",
err.overflow_chars
);
}
#[test]
fn test_wrap_lines_real_mt_party_name() {
let text = "JOHN JACOB JINGLEHEIMER SCHMIDT III 1234 ELM STREET APT 12 SPRINGFIELD ILLINOIS 62701 USA";
let lines = wrap_lines(text, 35, 4).unwrap();
assert!(lines.len() <= 4);
for line in &lines {
assert!(line.len() <= 35, "line {:?} exceeds 35 cols", line);
}
}
#[test]
#[should_panic(expected = "max_line_len must be > 0")]
fn test_wrap_lines_zero_line_len_panics() {
let _ = wrap_lines("X", 0, 4);
}
#[test]
#[should_panic(expected = "max_lines must be > 0")]
fn test_wrap_lines_zero_max_lines_panics() {
let _ = wrap_lines("X", 35, 0);
}
}