cli_justify/
justify_text.rs1use crate::text_utils::{char_len, split_at_char};
2
3pub(crate) fn justify_line(line: &[&str], line_width: usize) -> String {
4 let word_len: usize = line.iter().map(|s| char_len(s)).sum();
5
6 if word_len >= line_width || line.len() <= 1 {
7 return line.join(" ");
8 }
9
10 let spaces = line_width - word_len;
11 let line_len_div = if line.len() > 1 { line.len() - 1 } else { 1 };
12 let each_space = spaces / line_len_div;
13 let extra_space = spaces % line_len_div;
14
15 let mut justified = String::new();
16 for (i, word) in line.iter().enumerate() {
17 justified.push_str(word);
18 if i < line.len() - 1 {
19 let mut space = " ".repeat(each_space);
20 if i < extra_space {
21 space.push(' ');
22 }
23 justified.push_str(&space);
24 }
25 }
26
27 justified
28}
29
30pub fn justify(text: &str, line_width: usize) -> Vec<String> {
31 let paragraphs: Vec<&str> = text.split("\n\n").collect();
32 let mut lines: Vec<String> = Vec::new();
33
34 for paragraph in paragraphs {
35 let raw_words: Vec<&str> = paragraph.split_whitespace().collect();
36 let mut words = vec![];
37
38 for mut word in raw_words {
39 while let (w1, Some(w2)) = split_at_char(word, line_width) {
40 words.push(w1);
41 word = w2;
42 }
43
44 words.push(word);
45 }
46
47 let mut line: Vec<&str> = Vec::new();
48 let mut len = 0;
49
50 for word in words {
51 let word_len = char_len(word);
52 let space_len = if line.is_empty() { 0 } else { 1 };
53 let new_len = len + space_len + word_len;
54
55 if new_len > line_width && !line.is_empty() {
56 lines.push(justify_line(&line, line_width));
57 line.clear();
58 len = 0;
59 }
60
61 line.push(word);
62 len = if line.len() == 1 { word_len } else { len + space_len + word_len };
63 }
64
65 if !line.is_empty() {
66 lines.push(line.join(" "));
67 }
68
69 lines.push(String::new());
70 }
71
72 lines
73}
74
75#[cfg(test)]
76mod tests {
77 use super::justify;
78 use crate::text_utils::char_len;
79
80 #[test]
81 fn handles_long_words() {
82 let input_text = r#"some text and a very loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong word but no cause to panic"#;
83 let result = justify(input_text, 10);
84 assert!(!result.is_empty());
85 }
86
87 #[test]
88 fn handles_line_longer_than_width() {
89 let input_text =
90 "This is a line that is definitely longer than the requested width";
91 let result = justify(input_text, 20);
92 assert!(!result.is_empty());
93 }
94
95 #[test]
96 fn splits_single_word_longer_than_width() {
97 let input_text = "supercalifragilisticexpialidocious";
98 let result = justify(input_text, 10);
99 assert!(!result.is_empty());
100 assert!(result.len() > 1);
101 }
102
103 #[test]
104 fn normal_justification_produces_full_lines() {
105 let input_text = "This is a test of the justification system. It should properly justify lines that need to be wrapped.";
106 let result = justify(input_text, 20);
107 assert!(!result.is_empty());
108
109 let mut found_justified = false;
110 for (i, line) in result.iter().enumerate() {
111 if !line.is_empty() && i < result.len() - 2 && char_len(line) == 20 {
112 found_justified = true;
113 break;
114 }
115 }
116 assert!(found_justified, "Should have at least one justified line");
117 }
118
119 #[test]
120 fn keeps_unicode_justification_width_stable() {
121 let input = "Chapter “Text” introduces Unicode-aware width handling.";
122 let result = justify(input, 24);
123 assert!(
124 result
125 .iter()
126 .filter(|line| !line.is_empty())
127 .take(result.len().saturating_sub(2))
128 .all(|line| char_len(line) <= 24),
129 "expected all non-final wrapped lines to fit char width, got: {result:?}"
130 );
131 }
132}