Skip to main content

batuta_common/
display.rs

1//! Display utilities: column alignment, truncation, and builder traits.
2//!
3//! Provides consistent text formatting for terminal output across the Batuta stack.
4
5use crate::fmt;
6
7// =============================================================================
8// ENUMS
9// =============================================================================
10
11/// Truncation strategy for text that exceeds column width.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum TruncateStrategy {
14    /// Truncate from the end with ellipsis: "very long te…"
15    #[default]
16    End,
17    /// Truncate from the start with ellipsis: "…ong text here"
18    Start,
19    /// Truncate in the middle: "hel…orld"
20    Middle,
21    /// Smart path truncation: keeps filename, truncates directories
22    Path,
23}
24
25/// Column alignment for formatted output.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum ColumnAlign {
28    /// Left-align text.
29    #[default]
30    Left,
31    /// Right-align text (common for numbers).
32    Right,
33    /// Center-align text.
34    Center,
35}
36
37// =============================================================================
38// TRUNCATION
39// =============================================================================
40
41/// Truncate a string to fit within a maximum width.
42///
43/// **Guarantee**: Output length will NEVER exceed `max_width` characters.
44///
45/// # Examples
46/// ```
47/// use batuta_common::display::{truncate, TruncateStrategy};
48/// assert_eq!(truncate("hello world", 8, TruncateStrategy::End), "hello w…");
49/// assert_eq!(truncate("hello world", 8, TruncateStrategy::Start), "…o world");
50/// assert_eq!(truncate("hello world", 8, TruncateStrategy::Middle), "hel…orld");
51/// assert_eq!(truncate("short", 10, TruncateStrategy::End), "short");
52/// ```
53#[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/// Smart path truncation that preserves the filename.
90///
91/// **Guarantee**: Output length will NEVER exceed `max_width` characters.
92///
93/// # Examples
94/// ```
95/// use batuta_common::display::truncate_path;
96/// assert_eq!(truncate_path("/home/user/documents/file.txt", 20), "/home/user…/file.txt");
97/// assert_eq!(truncate_path("/a/b/c.txt", 20), "/a/b/c.txt");
98/// ```
99#[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// =============================================================================
150// COLUMN FORMATTING
151// =============================================================================
152
153/// Format text into a fixed-width column with alignment and truncation.
154///
155/// **Guarantee**: Output length will NEVER exceed `width` characters.
156///
157/// # Examples
158/// ```
159/// use batuta_common::display::{format_column, ColumnAlign, TruncateStrategy};
160/// assert_eq!(format_column("test", 8, ColumnAlign::Left, TruncateStrategy::End), "test    ");
161/// assert_eq!(format_column("test", 8, ColumnAlign::Right, TruncateStrategy::End), "    test");
162/// assert_eq!(format_column("test", 8, ColumnAlign::Center, TruncateStrategy::End), "  test  ");
163/// ```
164#[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/// Format bytes into a fixed-width column (SI units, right-aligned).
215///
216/// # Examples
217/// ```
218/// use batuta_common::display::format_bytes_column;
219/// assert_eq!(format_bytes_column(1500, 6), " 1.50K");
220/// ```
221#[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/// Format a percentage into a fixed-width column (right-aligned).
228///
229/// # Examples
230/// ```
231/// use batuta_common::display::format_percent_column;
232/// assert_eq!(format_percent_column(45.3, 7), "  45.3%");
233/// ```
234#[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/// Format a number into a fixed-width column (right-aligned, with commas).
241#[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
247// =============================================================================
248// BUILDER TRAIT
249// =============================================================================
250
251/// Trait for types that have configurable width/height dimensions.
252///
253/// Eliminates the 14+ identical `dimensions()` builder methods duplicated across
254/// pmat visualization and trueno-viz chart types.
255///
256/// # Examples
257/// ```
258/// use batuta_common::display::WithDimensions;
259///
260/// struct Chart { width: u32, height: u32 }
261///
262/// impl WithDimensions for Chart {
263///     fn set_dimensions(&mut self, width: u32, height: u32) {
264///         self.width = width;
265///         self.height = height;
266///     }
267/// }
268///
269/// let chart = Chart { width: 80, height: 24 }.dimensions(120, 40);
270/// assert_eq!(chart.width, 120);
271/// assert_eq!(chart.height, 40);
272/// ```
273pub trait WithDimensions: Sized {
274    /// Set the width and height on this type.
275    fn set_dimensions(&mut self, width: u32, height: u32);
276
277    /// Builder method: set dimensions and return self.
278    #[must_use]
279    fn dimensions(mut self, width: u32, height: u32) -> Self {
280        self.set_dimensions(width, height);
281        self
282    }
283}
284
285// =============================================================================
286// CONVENIENCE: truncate_str (ASCII "..." suffix)
287// =============================================================================
288
289/// Truncate a string to `max_len` with ASCII `"..."` suffix.
290///
291/// This is a convenience wrapper for CLI output where ASCII ellipsis is
292/// preferred over Unicode ellipsis. If the string fits, it is returned as-is.
293///
294/// # Examples
295/// ```
296/// use batuta_common::display::truncate_str;
297/// assert_eq!(truncate_str("hello world", 8), "hello...");
298/// assert_eq!(truncate_str("short", 10), "short");
299/// assert_eq!(truncate_str("ab", 3), "ab");
300/// assert_eq!(truncate_str("abcdef", 3), "...");
301/// ```
302#[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// =============================================================================
318// TESTS
319// =============================================================================
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    // --- Truncation ---
326
327    #[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    // --- Column formatting ---
377
378    #[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    // --- truncate_str ---
421
422    #[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    // --- WithDimensions trait ---
453
454    #[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}