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!(
335            truncate("hello world", 8, TruncateStrategy::End),
336            "hello w\u{2026}"
337        );
338    }
339
340    #[test]
341    fn test_truncate_start() {
342        assert_eq!(
343            truncate("hello world", 8, TruncateStrategy::Start),
344            "\u{2026}o world"
345        );
346    }
347
348    #[test]
349    fn test_truncate_middle() {
350        assert_eq!(
351            truncate("hello world", 8, TruncateStrategy::Middle),
352            "hel\u{2026}orld"
353        );
354    }
355
356    #[test]
357    fn test_truncate_zero_width() {
358        assert_eq!(truncate("anything", 0, TruncateStrategy::End), "");
359    }
360
361    #[test]
362    fn test_truncate_width_one() {
363        assert_eq!(truncate("hello", 1, TruncateStrategy::End), "\u{2026}");
364    }
365
366    #[test]
367    fn test_truncate_path_preserves_filename() {
368        assert_eq!(
369            truncate_path("/home/user/documents/file.txt", 20),
370            "/home/user\u{2026}/file.txt"
371        );
372    }
373
374    #[test]
375    fn test_truncate_path_short_enough() {
376        assert_eq!(truncate_path("/a/b/c.txt", 20), "/a/b/c.txt");
377    }
378
379    // --- Column formatting ---
380
381    #[test]
382    fn test_format_column_left() {
383        assert_eq!(
384            format_column("test", 8, ColumnAlign::Left, TruncateStrategy::End),
385            "test    "
386        );
387    }
388
389    #[test]
390    fn test_format_column_right() {
391        assert_eq!(
392            format_column("test", 8, ColumnAlign::Right, TruncateStrategy::End),
393            "    test"
394        );
395    }
396
397    #[test]
398    fn test_format_column_center() {
399        assert_eq!(
400            format_column("test", 8, ColumnAlign::Center, TruncateStrategy::End),
401            "  test  "
402        );
403    }
404
405    #[test]
406    fn test_format_column_truncates() {
407        assert_eq!(
408            format_column(
409                "very long text",
410                8,
411                ColumnAlign::Left,
412                TruncateStrategy::End
413            ),
414            "very lo\u{2026}"
415        );
416    }
417
418    #[test]
419    fn test_format_bytes_column() {
420        assert_eq!(format_bytes_column(1500, 6), " 1.50K");
421    }
422
423    #[test]
424    fn test_format_percent_column() {
425        assert_eq!(format_percent_column(45.3, 7), "  45.3%");
426    }
427
428    // --- truncate_str ---
429
430    #[test]
431    fn test_truncate_str_short_unchanged() {
432        assert_eq!(truncate_str("hello", 10), "hello");
433    }
434
435    #[test]
436    fn test_truncate_str_exact_fit() {
437        assert_eq!(truncate_str("hello", 5), "hello");
438    }
439
440    #[test]
441    fn test_truncate_str_with_ellipsis() {
442        assert_eq!(truncate_str("hello world", 8), "hello...");
443    }
444
445    #[test]
446    fn test_truncate_str_min_len() {
447        assert_eq!(truncate_str("abcdef", 3), "...");
448    }
449
450    #[test]
451    fn test_truncate_str_len_4() {
452        assert_eq!(truncate_str("abcdef", 4), "a...");
453    }
454
455    #[test]
456    fn test_truncate_str_empty() {
457        assert_eq!(truncate_str("", 5), "");
458    }
459
460    // --- WithDimensions trait ---
461
462    #[test]
463    fn test_with_dimensions_trait() {
464        struct TestWidget {
465            width: u32,
466            height: u32,
467        }
468
469        impl WithDimensions for TestWidget {
470            fn set_dimensions(&mut self, width: u32, height: u32) {
471                self.width = width;
472                self.height = height;
473            }
474        }
475
476        let w = TestWidget {
477            width: 0,
478            height: 0,
479        }
480        .dimensions(120, 40);
481        assert_eq!(w.width, 120);
482        assert_eq!(w.height, 40);
483    }
484}