Skip to main content

smart_format/combinator/
truncate.rs

1use core::fmt;
2
3use smart_string::DisplayExt;
4
5/// Truncates formatted output to at most `max_chars` Unicode scalar values.
6///
7/// # Mechanism
8///
9/// Uses `DisplayExt::format_with` to intercept output chunks as they stream through
10/// `fmt::Write::write_str`. A char counter tracks how many characters have been written.
11/// When the limit is reached mid-chunk, the chunk is sliced at the char boundary before
12/// the limit. After the limit, subsequent chunks are silently discarded (the counting
13/// wrapper returns `Ok(())` without writing, so the inner `Display::fmt` runs to
14/// completion without error).
15pub struct Truncate<T> {
16    inner: T,
17    max_chars: usize,
18}
19
20impl<T> Truncate<T> {
21    pub(crate) fn new(inner: T, max_chars: usize) -> Self {
22        Truncate { inner, max_chars }
23    }
24}
25
26impl<T: fmt::Display> fmt::Display for Truncate<T> {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        let max = self.max_chars;
29        let mut remaining = max;
30
31        self.inner.format_with(|s: Option<&str>| {
32            let Some(s) = s else { return Ok(()) };
33            if remaining == 0 {
34                return Ok(());
35            }
36
37            let char_count = s.chars().count();
38            if char_count <= remaining {
39                remaining -= char_count;
40                f.write_str(s)
41            } else {
42                // Slice at the char boundary before the limit.
43                let byte_end = s
44                    .char_indices()
45                    .nth(remaining)
46                    .map(|(i, _)| i)
47                    .unwrap_or(s.len());
48                remaining = 0;
49                f.write_str(&s[..byte_end])
50            }
51        })
52    }
53}
54
55/// Truncates with a tail indicator (e.g., "...").
56///
57/// `max_chars` is the **total output budget** including the tail. If the inner content
58/// fits within the budget, it passes through unchanged (no tail shown). If it exceeds
59/// the budget, inner content is truncated at `max_chars - tail_chars` and the tail is
60/// appended. Total output is always `<= max_chars`.
61pub struct TruncateWith<T> {
62    inner: T,
63    max_chars: usize,
64    tail: &'static str,
65}
66
67impl<T> TruncateWith<T> {
68    pub(crate) fn new(inner: T, max_chars: usize, tail: &'static str) -> Self {
69        TruncateWith {
70            inner,
71            max_chars,
72            tail,
73        }
74    }
75}
76
77impl<T: fmt::Display> fmt::Display for TruncateWith<T> {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        if self.max_chars == 0 {
80            return Ok(());
81        }
82
83        let tail_chars = self.tail.chars().count();
84
85        // If the tail alone exceeds the budget, fall back to plain truncation
86        // (no tail appended) to honor the <= max_chars guarantee.
87        if tail_chars >= self.max_chars {
88            return Truncate::new(&self.inner, self.max_chars).fmt(f);
89        }
90
91        let content_budget = self.max_chars - tail_chars;
92
93        // First pass: count total chars to decide whether truncation is needed.
94        let mut total_chars: usize = 0;
95        self.inner.format_with(|s: Option<&str>| {
96            if let Some(s) = s {
97                total_chars += s.chars().count();
98            }
99            Ok(())
100        })?;
101
102        if total_chars <= self.max_chars {
103            // Fits without truncation — no tail needed.
104            self.inner.fmt(f)
105        } else {
106            // Truncate inner to content_budget, then append tail.
107            let mut remaining = content_budget;
108            self.inner.format_with(|s: Option<&str>| {
109                let Some(s) = s else { return Ok(()) };
110                if remaining == 0 {
111                    return Ok(());
112                }
113
114                let char_count = s.chars().count();
115                if char_count <= remaining {
116                    remaining -= char_count;
117                    f.write_str(s)
118                } else {
119                    let byte_end = s
120                        .char_indices()
121                        .nth(remaining)
122                        .map(|(i, _)| i)
123                        .unwrap_or(s.len());
124                    remaining = 0;
125                    f.write_str(&s[..byte_end])
126                }
127            })?;
128            f.write_str(self.tail)
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use crate::combinator::SmartFormat;
136
137    #[test]
138    fn truncate_shorter_than_limit() {
139        assert_eq!("hello", "hello".display_truncate(10).to_string());
140    }
141
142    #[test]
143    fn truncate_exact_limit() {
144        assert_eq!("hello", "hello".display_truncate(5).to_string());
145    }
146
147    #[test]
148    fn truncate_over_limit() {
149        assert_eq!("hel", "hello".display_truncate(3).to_string());
150    }
151
152    #[test]
153    fn truncate_zero() {
154        assert_eq!("", "hello".display_truncate(0).to_string());
155    }
156
157    #[test]
158    fn truncate_empty() {
159        assert_eq!("", "".display_truncate(5).to_string());
160    }
161
162    #[test]
163    fn truncate_unicode() {
164        // "АБВ" = 3 chars, each 2 bytes
165        assert_eq!("АБ", "АБВ".display_truncate(2).to_string());
166    }
167
168    #[test]
169    fn truncate_unicode_exact() {
170        assert_eq!("АБВ", "АБВ".display_truncate(3).to_string());
171    }
172
173    #[test]
174    fn truncate_with_no_truncation() {
175        assert_eq!("hi", "hi".display_truncate_with(10, "...").to_string());
176    }
177
178    #[test]
179    fn truncate_with_truncation() {
180        assert_eq!(
181            "hel...",
182            "hello world".display_truncate_with(6, "...").to_string()
183        );
184    }
185
186    #[test]
187    fn truncate_with_exact_fit() {
188        // "hello" = 5 chars, budget = 5 → no truncation
189        assert_eq!("hello", "hello".display_truncate_with(5, "...").to_string());
190    }
191
192    #[test]
193    fn truncate_with_tail_fills_budget() {
194        // budget = 3, tail = "..." (3 chars) → tail >= budget, fall back to
195        // plain truncation (no tail) to honor <= max_chars guarantee.
196        assert_eq!("hel", "hello".display_truncate_with(3, "...").to_string());
197    }
198
199    #[test]
200    fn truncate_with_tail_exceeds_budget() {
201        // budget = 2, tail = "..." (3 chars) → tail > budget, plain truncation
202        assert_eq!("he", "hello".display_truncate_with(2, "...").to_string());
203    }
204
205    #[test]
206    fn truncate_with_zero_budget() {
207        assert_eq!("", "hello".display_truncate_with(0, "...").to_string());
208    }
209
210    #[test]
211    fn truncate_with_unicode() {
212        // "Привіт" = 6 chars; budget 4, tail "…" (1 char) → 3 content chars + "…"
213        assert_eq!("При…", "Привіт".display_truncate_with(4, "…").to_string());
214    }
215
216    #[test]
217    fn truncate_with_number() {
218        assert_eq!(
219            "123...",
220            123456789.display_truncate_with(6, "...").to_string()
221        );
222    }
223}