1use crate::fmt;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum TruncateStrategy {
14 #[default]
16 End,
17 Start,
19 Middle,
21 Path,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum ColumnAlign {
28 #[default]
30 Left,
31 Right,
33 Center,
35}
36
37#[must_use]
54pub fn truncate(s: &str, max_width: usize, strategy: TruncateStrategy) -> String {
55 if max_width == 0 {
56 return String::new();
57 }
58
59 let char_count = s.chars().count();
60
61 if char_count <= max_width {
62 return s.to_string();
63 }
64
65 if max_width == 1 {
66 return "\u{2026}".to_string();
67 }
68
69 match strategy {
70 TruncateStrategy::End => {
71 let chars: String = s.chars().take(max_width - 1).collect();
72 format!("{chars}\u{2026}")
73 }
74 TruncateStrategy::Start => {
75 let chars: String = s.chars().skip(char_count - max_width + 1).collect();
76 format!("\u{2026}{chars}")
77 }
78 TruncateStrategy::Middle => {
79 let left_len = (max_width - 1) / 2;
80 let right_len = max_width - 1 - left_len;
81 let left: String = s.chars().take(left_len).collect();
82 let right: String = s.chars().skip(char_count - right_len).collect();
83 format!("{left}\u{2026}{right}")
84 }
85 TruncateStrategy::Path => truncate_path(s, max_width),
86 }
87}
88
89#[must_use]
100pub fn truncate_path(path: &str, max_width: usize) -> String {
101 if max_width == 0 {
102 return String::new();
103 }
104
105 let char_count = path.chars().count();
106
107 if char_count <= max_width {
108 return path.to_string();
109 }
110
111 if let Some(last_sep) = path.rfind('/') {
112 let filename = &path[last_sep..];
113 let filename_len = filename.chars().count();
114
115 if filename_len >= max_width {
116 return truncate(path, max_width, TruncateStrategy::End);
117 }
118
119 let dir_space = max_width.saturating_sub(filename_len).saturating_sub(1);
120
121 if dir_space == 0 {
122 let result = format!("\u{2026}{filename}");
123 if result.chars().count() <= max_width {
124 return result;
125 }
126 return truncate(path, max_width, TruncateStrategy::End);
127 }
128
129 let dir = &path[..last_sep];
130 let dir_chars: Vec<char> = dir.chars().collect();
131
132 if dir_chars.len() <= dir_space {
133 return path.to_string();
134 }
135
136 let truncated_dir: String = dir_chars.iter().take(dir_space).collect();
137 let result = format!("{truncated_dir}\u{2026}{filename}");
138
139 if result.chars().count() <= max_width {
140 result
141 } else {
142 truncate(path, max_width, TruncateStrategy::End)
143 }
144 } else {
145 truncate(path, max_width, TruncateStrategy::End)
146 }
147}
148
149#[must_use]
165pub fn format_column(
166 text: &str,
167 width: usize,
168 align: ColumnAlign,
169 truncate_strategy: TruncateStrategy,
170) -> String {
171 let char_count = text.chars().count();
172
173 let truncated = if char_count > width {
174 truncate(text, width, truncate_strategy)
175 } else {
176 text.to_string()
177 };
178
179 let truncated_len = truncated.chars().count();
180 let padding = width.saturating_sub(truncated_len);
181
182 match align {
183 ColumnAlign::Left => {
184 let mut result = truncated;
185 for _ in 0..padding {
186 result.push(' ');
187 }
188 result
189 }
190 ColumnAlign::Right => {
191 let mut result = String::with_capacity(width);
192 for _ in 0..padding {
193 result.push(' ');
194 }
195 result.push_str(&truncated);
196 result
197 }
198 ColumnAlign::Center => {
199 let left_pad = padding / 2;
200 let right_pad = padding - left_pad;
201 let mut result = String::with_capacity(width);
202 for _ in 0..left_pad {
203 result.push(' ');
204 }
205 result.push_str(&truncated);
206 for _ in 0..right_pad {
207 result.push(' ');
208 }
209 result
210 }
211 }
212}
213
214#[must_use]
222pub fn format_bytes_column(bytes: u64, width: usize) -> String {
223 let formatted = fmt::format_bytes_si(bytes);
224 format_column(&formatted, width, ColumnAlign::Right, TruncateStrategy::End)
225}
226
227#[must_use]
235pub fn format_percent_column(value: f64, width: usize) -> String {
236 let formatted = fmt::format_percent(value);
237 format_column(&formatted, width, ColumnAlign::Right, TruncateStrategy::End)
238}
239
240#[must_use]
242pub fn format_number_column(n: u64, width: usize) -> String {
243 let formatted = fmt::format_number(n);
244 format_column(&formatted, width, ColumnAlign::Right, TruncateStrategy::End)
245}
246
247pub trait WithDimensions: Sized {
274 fn set_dimensions(&mut self, width: u32, height: u32);
276
277 #[must_use]
279 fn dimensions(mut self, width: u32, height: u32) -> Self {
280 self.set_dimensions(width, height);
281 self
282 }
283}
284
285#[must_use]
303pub fn truncate_str(s: &str, max_len: usize) -> String {
304 if s.len() <= max_len {
305 return s.to_string();
306 }
307 if max_len <= 3 {
308 return ".".repeat(max_len);
309 }
310 let end = s
311 .char_indices()
312 .nth(max_len - 3)
313 .map_or(max_len - 3, |(i, _)| i);
314 format!("{}...", &s[..end])
315}
316
317#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
328 fn test_truncate_short_string_unchanged() {
329 assert_eq!(truncate("hello", 10, TruncateStrategy::End), "hello");
330 }
331
332 #[test]
333 fn test_truncate_end() {
334 assert_eq!(truncate("hello world", 8, TruncateStrategy::End), "hello w\u{2026}");
335 }
336
337 #[test]
338 fn test_truncate_start() {
339 assert_eq!(
340 truncate("hello world", 8, TruncateStrategy::Start),
341 "\u{2026}o world"
342 );
343 }
344
345 #[test]
346 fn test_truncate_middle() {
347 assert_eq!(
348 truncate("hello world", 8, TruncateStrategy::Middle),
349 "hel\u{2026}orld"
350 );
351 }
352
353 #[test]
354 fn test_truncate_zero_width() {
355 assert_eq!(truncate("anything", 0, TruncateStrategy::End), "");
356 }
357
358 #[test]
359 fn test_truncate_width_one() {
360 assert_eq!(truncate("hello", 1, TruncateStrategy::End), "\u{2026}");
361 }
362
363 #[test]
364 fn test_truncate_path_preserves_filename() {
365 assert_eq!(
366 truncate_path("/home/user/documents/file.txt", 20),
367 "/home/user\u{2026}/file.txt"
368 );
369 }
370
371 #[test]
372 fn test_truncate_path_short_enough() {
373 assert_eq!(truncate_path("/a/b/c.txt", 20), "/a/b/c.txt");
374 }
375
376 #[test]
379 fn test_format_column_left() {
380 assert_eq!(
381 format_column("test", 8, ColumnAlign::Left, TruncateStrategy::End),
382 "test "
383 );
384 }
385
386 #[test]
387 fn test_format_column_right() {
388 assert_eq!(
389 format_column("test", 8, ColumnAlign::Right, TruncateStrategy::End),
390 " test"
391 );
392 }
393
394 #[test]
395 fn test_format_column_center() {
396 assert_eq!(
397 format_column("test", 8, ColumnAlign::Center, TruncateStrategy::End),
398 " test "
399 );
400 }
401
402 #[test]
403 fn test_format_column_truncates() {
404 assert_eq!(
405 format_column("very long text", 8, ColumnAlign::Left, TruncateStrategy::End),
406 "very lo\u{2026}"
407 );
408 }
409
410 #[test]
411 fn test_format_bytes_column() {
412 assert_eq!(format_bytes_column(1500, 6), " 1.50K");
413 }
414
415 #[test]
416 fn test_format_percent_column() {
417 assert_eq!(format_percent_column(45.3, 7), " 45.3%");
418 }
419
420 #[test]
423 fn test_truncate_str_short_unchanged() {
424 assert_eq!(truncate_str("hello", 10), "hello");
425 }
426
427 #[test]
428 fn test_truncate_str_exact_fit() {
429 assert_eq!(truncate_str("hello", 5), "hello");
430 }
431
432 #[test]
433 fn test_truncate_str_with_ellipsis() {
434 assert_eq!(truncate_str("hello world", 8), "hello...");
435 }
436
437 #[test]
438 fn test_truncate_str_min_len() {
439 assert_eq!(truncate_str("abcdef", 3), "...");
440 }
441
442 #[test]
443 fn test_truncate_str_len_4() {
444 assert_eq!(truncate_str("abcdef", 4), "a...");
445 }
446
447 #[test]
448 fn test_truncate_str_empty() {
449 assert_eq!(truncate_str("", 5), "");
450 }
451
452 #[test]
455 fn test_with_dimensions_trait() {
456 struct TestWidget {
457 width: u32,
458 height: u32,
459 }
460
461 impl WithDimensions for TestWidget {
462 fn set_dimensions(&mut self, width: u32, height: u32) {
463 self.width = width;
464 self.height = height;
465 }
466 }
467
468 let w = TestWidget {
469 width: 0,
470 height: 0,
471 }
472 .dimensions(120, 40);
473 assert_eq!(w.width, 120);
474 assert_eq!(w.height, 40);
475 }
476}