Skip to main content

inkferro_core/text/
cli_truncate.rs

1//! Port of [`cli-truncate@6`](https://github.com/sindresorhus/cli-truncate) to Rust.
2//!
3//! Truncates a string to a given number of terminal columns, inserting a
4//! truncation character (default `โ€ฆ`) and preserving ANSI styling around the
5//! cut via [`slice_ansi`](crate::text::slice_ansi). Width is measured with
6//! [`string_width`].
7//!
8//! # Known divergences from JS
9//!
10//! - **Index model (astral + prefer-on-space).** `getIndexOfNearestSpace` and
11//!   the leading/trailing SGR span scanners index the raw string by *char*
12//!   (Unicode scalar) rather than JS's UTF-16 code units; this is exact for all
13//!   BMP input but diverges when non-BMP (astral) text reaches a
14//!   `prefer_truncation_on_space` path, because the width-derived index resolves
15//!   to different positions in the two models. Evidence: for `"๐Ÿฆ„๐Ÿฆ„ ๐Ÿฆ„๐Ÿฆ„"` with
16//!   `columns=5`, `wantedIndex = columns-1 = 4`; JS UTF-16 index 4 is the space
17//!   (each emoji occupies two code units: indices 0-1, 2-3; space at 4), so JS
18//!   takes `sliceAnsi(s, 0, 4)` = `"๐Ÿฆ„๐Ÿฆ„"` โ†’ `"๐Ÿฆ„๐Ÿฆ„โ€ฆ"`; Rust scalar index 4 is
19//!   the fifth scalar (second trailing unicorn, not the space), so
20//!   `getIndexOfNearestSpace` searches left and finds the space at scalar index 2,
21//!   yielding `slice_ansi(s, 0, 2)` = `"๐Ÿฆ„"` โ†’ `"๐Ÿฆ„โ€ฆ"` (pinned by
22//!   `prefer_space_end_astral_divergence` test). The divergence affects all
23//!   three positions (`Start`, `Middle`, `End`) โ€” any prefer-on-space path
24//!   that walks astral text, not just `End` (confirmed by the M0 oracle run).
25//! - **Colon-delimited SGR width** and **generic OSC control-string width**
26//!   previously diverged from Node due to the pre-re-port `ANSI_RE` missing
27//!   colon-parameter coverage and generic OSC strings; both were resolved by
28//!   the string-width@8.2.1 re-port (ccfcfb3 + 2c97272), which re-derived
29//!   `ANSI_RE` from ansi-regex@6.2.2. Both probes now match Node exactly.
30//! - **Type checks.** JS throws `TypeError` for non-string / non-number inputs;
31//!   Rust's type system makes those unrepresentable, so there is no error path.
32//! - **`.trim()`.** JS `String#trim` strips the Unicode White_Space set; this
33//!   port uses [`str::trim`] (Unicode `White_Space`), which matches for all
34//!   whitespace this is used with.
35
36use crate::text::slice_ansi::slice_ansi;
37use crate::text::string_width::string_width;
38
39/// Where to remove characters when truncating.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum TruncatePosition {
42    /// Truncate at the start: `"โ€ฆld"`.
43    Start,
44    /// Truncate in the middle: `"heโ€ฆld"`.
45    Middle,
46    /// Truncate at the end (default): `"heโ€ฆ"`.
47    End,
48}
49
50/// Options for [`cli_truncate_with`]. Defaults match cli-truncate@6.
51#[derive(Debug, Clone)]
52pub struct TruncateOptions {
53    /// Where to truncate. Default [`TruncatePosition::End`].
54    pub position: TruncatePosition,
55    /// Add a space between the text and the truncation character. Default `false`.
56    pub space: bool,
57    /// Prefer truncating at a nearby space over a hard cut. Default `false`.
58    pub prefer_truncation_on_space: bool,
59    /// The truncation character. Default `"โ€ฆ"`.
60    pub truncation_character: String,
61}
62
63impl Default for TruncateOptions {
64    fn default() -> Self {
65        Self {
66            position: TruncatePosition::End,
67            space: false,
68            prefer_truncation_on_space: false,
69            truncation_character: "\u{2026}".to_owned(),
70        }
71    }
72}
73
74/// `getIndexOfNearestSpace(string, wantedIndex, shouldSearchRight)`.
75///
76/// Indexes `string` by char (JS uses UTF-16 code units; identical for BMP).
77/// `wanted_index` may be out of bounds โ€” `char_at` returns `None`, matching JS
78/// `charAt` returning `''` for out-of-range indexes (never equal to `' '`).
79fn get_index_of_nearest_space(chars: &[char], wanted_index: isize, search_right: bool) -> isize {
80    if char_at(chars, wanted_index) == Some(' ') {
81        return wanted_index;
82    }
83
84    let direction: isize = if search_right { 1 } else { -1 };
85
86    for index in 0..=3 {
87        let final_index = wanted_index + index * direction;
88        if char_at(chars, final_index) == Some(' ') {
89            return final_index;
90        }
91    }
92
93    wanted_index
94}
95
96/// `string.charAt(index)` as a `char`: `None` for out-of-range (JS `''`).
97fn char_at(chars: &[char], index: isize) -> Option<char> {
98    if index < 0 {
99        return None;
100    }
101    chars.get(index as usize).copied()
102}
103
104const ANSI_ESC: u32 = 27;
105const ANSI_LEFT_BRACKET: u32 = 91;
106const ANSI_LETTER_M: u32 = 109;
107
108/// `isSgrParameter(code)`: `0`โ€“`9` or `;` (codes 48โ€“57 or 59).
109fn is_sgr_parameter(code: u32) -> bool {
110    (48..=57).contains(&code) || code == 59
111}
112
113/// `leadingSgrSpanEndIndex(string)`: char index just past a run of leading SGR
114/// sequences (`ESC [ params m`).
115fn leading_sgr_span_end_index(chars: &[char]) -> usize {
116    let cp = |i: usize| chars.get(i).map(|c| *c as u32);
117    let len = chars.len();
118    let mut index = 0;
119
120    while index + 2 < len && cp(index) == Some(ANSI_ESC) && cp(index + 1) == Some(ANSI_LEFT_BRACKET)
121    {
122        let mut j = index + 2;
123        while j < len && cp(j).is_some_and(is_sgr_parameter) {
124            j += 1;
125        }
126
127        if j < len && cp(j) == Some(ANSI_LETTER_M) {
128            index = j + 1;
129            continue;
130        }
131
132        break;
133    }
134
135    index
136}
137
138/// `trailingSgrSpanStartIndex(string)`: char index where a run of trailing SGR
139/// sequences begins.
140fn trailing_sgr_span_start_index(chars: &[char]) -> usize {
141    let cp = |i: usize| chars.get(i).map(|c| *c as u32);
142    let mut start = chars.len();
143
144    while start > 1 && cp(start - 1) == Some(ANSI_LETTER_M) {
145        // j walks left over SGR parameter bytes; it may go below 0, so use isize.
146        let mut j: isize = start as isize - 2;
147        while j >= 0 && cp(j as usize).is_some_and(is_sgr_parameter) {
148            j -= 1;
149        }
150
151        if j >= 1
152            && cp((j - 1) as usize) == Some(ANSI_ESC)
153            && cp(j as usize) == Some(ANSI_LEFT_BRACKET)
154        {
155            start = (j - 1) as usize;
156            continue;
157        }
158
159        break;
160    }
161
162    start
163}
164
165/// `appendWithInheritedStyleFromEnd(visible, suffix)`: insert `suffix` before
166/// any trailing SGR span so the inserted character inherits the visible style.
167fn append_with_inherited_style_from_end(visible: &str, suffix: &str) -> String {
168    let chars: Vec<char> = visible.chars().collect();
169    let start = trailing_sgr_span_start_index(&chars);
170    if start == chars.len() {
171        return format!("{visible}{suffix}");
172    }
173
174    let before: String = chars[..start].iter().collect();
175    let after: String = chars[start..].iter().collect();
176    format!("{before}{suffix}{after}")
177}
178
179/// `prependWithInheritedStyleFromStart(prefix, visible)`: insert `prefix` after
180/// any leading SGR span.
181fn prepend_with_inherited_style_from_start(prefix: &str, visible: &str) -> String {
182    let chars: Vec<char> = visible.chars().collect();
183    let end = leading_sgr_span_end_index(&chars);
184    if end == 0 {
185        return format!("{prefix}{visible}");
186    }
187
188    let before: String = chars[..end].iter().collect();
189    let after: String = chars[end..].iter().collect();
190    format!("{before}{prefix}{after}")
191}
192
193/// Truncates `text` to `columns` columns using default [`TruncateOptions`].
194///
195/// # Examples
196///
197/// ```
198/// use inkferro_core::text::cli_truncate::cli_truncate;
199///
200/// assert_eq!(cli_truncate("unicorn", 4), "uni\u{2026}");
201/// assert_eq!(cli_truncate("hello", 10), "hello");
202/// ```
203pub fn cli_truncate(text: &str, columns: usize) -> String {
204    cli_truncate_with(text, columns, &TruncateOptions::default())
205}
206
207/// Truncates `text` to `columns` columns using the given [`TruncateOptions`].
208///
209/// `opts` is taken by reference so callers can reuse a single options struct
210/// across many calls without transferring ownership.
211pub fn cli_truncate_with(text: &str, columns: usize, opts: &TruncateOptions) -> String {
212    if columns < 1 {
213        return String::new();
214    }
215
216    let length = string_width(text);
217
218    if length <= columns {
219        return text.to_owned();
220    }
221
222    if columns == 1 {
223        return opts.truncation_character.clone();
224    }
225
226    // text indexed by char for getIndexOfNearestSpace (BMP-faithful to UTF-16).
227    let text_chars: Vec<char> = text.chars().collect();
228
229    match opts.position {
230        TruncatePosition::Start => truncate_start(text, &text_chars, columns, length, opts),
231        TruncatePosition::Middle => truncate_middle(text, &text_chars, columns, length, opts),
232        TruncatePosition::End => truncate_end(text, &text_chars, columns, opts),
233    }
234}
235
236/// `position === 'start'` branch.
237fn truncate_start(
238    text: &str,
239    text_chars: &[char],
240    columns: usize,
241    length: usize,
242    opts: &TruncateOptions,
243) -> String {
244    if opts.prefer_truncation_on_space {
245        // getIndexOfNearestSpace(text, length - columns + 1, true)
246        let wanted = length as isize - columns as isize + 1;
247        let nearest_space = get_index_of_nearest_space(text_chars, wanted, true);
248        let right = slice_ansi(text, clamp_index(nearest_space), Some(length))
249            .trim()
250            .to_owned();
251        return prepend_with_inherited_style_from_start(&opts.truncation_character, &right);
252    }
253
254    let truncation_character = if opts.space {
255        format!("{} ", opts.truncation_character)
256    } else {
257        opts.truncation_character.clone()
258    };
259
260    // length - columns + stringWidth(tc) โ€” may go negative in JS (โ†’ slice
261    // start 0). Compute signed, clamp to 0.
262    let start = length as isize - columns as isize + string_width(&truncation_character) as isize;
263    let right = slice_ansi(text, clamp_index(start), Some(length));
264    prepend_with_inherited_style_from_start(&truncation_character, &right)
265}
266
267/// `position === 'middle'` branch.
268fn truncate_middle(
269    text: &str,
270    text_chars: &[char],
271    columns: usize,
272    length: usize,
273    opts: &TruncateOptions,
274) -> String {
275    let truncation_character = if opts.space {
276        format!(" {} ", opts.truncation_character)
277    } else {
278        opts.truncation_character.clone()
279    };
280
281    let half = columns / 2;
282
283    if opts.prefer_truncation_on_space {
284        let space_near_first = get_index_of_nearest_space(text_chars, half as isize, false);
285        let wanted_second = length as isize - (columns as isize - half as isize) + 1;
286        let space_near_second = get_index_of_nearest_space(text_chars, wanted_second, true);
287        let left = slice_ansi(text, 0, Some(clamp_index(space_near_first)));
288        let right = slice_ansi(text, clamp_index(space_near_second), Some(length))
289            .trim()
290            .to_owned();
291        return format!("{left}{truncation_character}{right}");
292    }
293
294    let left = slice_ansi(text, 0, Some(half));
295    // length - (columns - half) + stringWidth(tc) โ€” may go negative in JS.
296    let right_start = length as isize - (columns as isize - half as isize)
297        + string_width(&truncation_character) as isize;
298    let right = slice_ansi(text, clamp_index(right_start), Some(length));
299    format!("{left}{truncation_character}{right}")
300}
301
302/// `position === 'end'` branch.
303fn truncate_end(text: &str, text_chars: &[char], columns: usize, opts: &TruncateOptions) -> String {
304    if opts.prefer_truncation_on_space {
305        let nearest_space = get_index_of_nearest_space(text_chars, columns as isize - 1, false);
306        let left = slice_ansi(text, 0, Some(clamp_index(nearest_space)));
307        return append_with_inherited_style_from_end(&left, &opts.truncation_character);
308    }
309
310    let truncation_character = if opts.space {
311        format!(" {}", opts.truncation_character)
312    } else {
313        opts.truncation_character.clone()
314    };
315
316    // columns - stringWidth(tc) โ€” may go negative in JS, where sliceAnsi with a
317    // negative end yields "". Compute signed, clamp to 0 (which yields "").
318    let end = columns as isize - string_width(&truncation_character) as isize;
319    let left = slice_ansi(text, 0, Some(clamp_index(end)));
320    append_with_inherited_style_from_end(&left, &truncation_character)
321}
322
323/// Clamp a possibly-negative `getIndexOfNearestSpace` result to a `usize` slice
324/// index. JS `sliceAnsi(text, negative, โ€ฆ)` treats `start < 0` like `0` (the
325/// position loop never reaches a negative target), so clamping to 0 matches.
326fn clamp_index(index: isize) -> usize {
327    index.max(0) as usize
328}
329
330#[cfg(test)]
331mod tests {
332    //! Parity tests for [`cli_truncate`], pinned against cli-truncate@6 in Node.
333    //! Each `// node:` comment cites the verified JS output for the assertion.
334
335    use super::*;
336
337    fn start() -> TruncateOptions {
338        TruncateOptions {
339            position: TruncatePosition::Start,
340            ..Default::default()
341        }
342    }
343    fn middle() -> TruncateOptions {
344        TruncateOptions {
345            position: TruncatePosition::Middle,
346            ..Default::default()
347        }
348    }
349    fn with_space(mut o: TruncateOptions) -> TruncateOptions {
350        o.space = true;
351        o
352    }
353    fn with_prefer(mut o: TruncateOptions) -> TruncateOptions {
354        o.prefer_truncation_on_space = true;
355        o
356    }
357
358    // โ”€โ”€ 1. No truncation needed (length <= columns) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
359    // node: cliTruncate("the quick brown fox", 20) === "the quick brown fox"
360    #[test]
361    fn no_truncation() {
362        assert_eq!(
363            cli_truncate("the quick brown fox", 20),
364            "the quick brown fox"
365        );
366    }
367
368    // โ”€โ”€ 2. columns < 1 โ†’ "" โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
369    // node: cliTruncate("unicorn", 0) === ""
370    #[test]
371    fn columns_zero() {
372        assert_eq!(cli_truncate("unicorn", 0), "");
373    }
374
375    // โ”€โ”€ 3. columns == 1 โ†’ "โ€ฆ" โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
376    // node: cliTruncate("unicorn", 1) === "โ€ฆ"
377    #[test]
378    fn columns_one() {
379        assert_eq!(cli_truncate("unicorn", 1), "\u{2026}");
380    }
381
382    // โ”€โ”€ 4. End position basic + with space โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
383    // node: cliTruncate("unicorn", 4) === "uniโ€ฆ"
384    #[test]
385    fn end_basic() {
386        assert_eq!(cli_truncate("unicorn", 4), "uni\u{2026}");
387    }
388
389    // node: cliTruncate("unicorn", 5, {space: true}) === "uni โ€ฆ"
390    #[test]
391    fn end_with_space() {
392        assert_eq!(
393            cli_truncate_with("unicorn", 5, &with_space(TruncateOptions::default())),
394            "uni \u{2026}"
395        );
396    }
397
398    // โ”€โ”€ 5. Start position basic + with space โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
399    // node: cliTruncate("the quick brown fox", 10, {position: 'start'}) === "โ€ฆbrown fox"
400    #[test]
401    fn start_basic() {
402        assert_eq!(
403            cli_truncate_with("the quick brown fox", 10, &start()),
404            "\u{2026}brown fox"
405        );
406    }
407
408    // node: cliTruncate("the quick brown fox", 10, {position:'start', space:true})
409    //   === "โ€ฆ rown fox"
410    #[test]
411    fn start_with_space() {
412        assert_eq!(
413            cli_truncate_with("the quick brown fox", 10, &with_space(start())),
414            "\u{2026} rown fox"
415        );
416    }
417
418    // โ”€โ”€ 6. Middle position basic + with space โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
419    // node: cliTruncate("the quick brown fox", 10, {position:'middle'}) === "the qโ€ฆ fox"
420    #[test]
421    fn middle_basic() {
422        assert_eq!(
423            cli_truncate_with("the quick brown fox", 10, &middle()),
424            "the q\u{2026} fox"
425        );
426    }
427
428    // node: cliTruncate("the quick brown fox", 10, {position:'middle', space:true})
429    //   === "the q โ€ฆ ox"
430    #[test]
431    fn middle_with_space() {
432        assert_eq!(
433            cli_truncate_with("the quick brown fox", 10, &with_space(middle())),
434            "the q \u{2026} ox"
435        );
436    }
437
438    // โ”€โ”€ 7. preferTruncationOnSpace for each position โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
439    // node: cliTruncate("the quick brown fox", 10, {position:'end', preferTruncationOnSpace:true})
440    //   === "the quickโ€ฆ"
441    #[test]
442    fn prefer_space_end() {
443        assert_eq!(
444            cli_truncate_with(
445                "the quick brown fox",
446                10,
447                &with_prefer(TruncateOptions::default())
448            ),
449            "the quick\u{2026}"
450        );
451    }
452
453    // node: ...{position:'start', preferTruncationOnSpace:true} === "โ€ฆbrown fox"
454    #[test]
455    fn prefer_space_start() {
456        assert_eq!(
457            cli_truncate_with("the quick brown fox", 10, &with_prefer(start())),
458            "\u{2026}brown fox"
459        );
460    }
461
462    // node: ...{position:'middle', preferTruncationOnSpace:true} === "theโ€ฆfox"
463    #[test]
464    fn prefer_space_middle() {
465        assert_eq!(
466            cli_truncate_with("the quick brown fox", 10, &with_prefer(middle())),
467            "the\u{2026}fox"
468        );
469    }
470
471    // โ”€โ”€ 8. ANSI-colored end-truncate: char BEFORE trailing SGR span โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
472    // node: cliTruncate("\x1b[31municorn\x1b[39m", 4) === "\x1b[31muniโ€ฆ\x1b[39m"
473    #[test]
474    fn ansi_end_truncate() {
475        assert_eq!(
476            cli_truncate("\x1b[31municorn\x1b[39m", 4),
477            "\x1b[31muni\u{2026}\x1b[39m"
478        );
479    }
480
481    // โ”€โ”€ 9. ANSI-colored start-truncate: char AFTER leading SGR span โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
482    // node: cliTruncate("\x1b[31municorn\x1b[39m", 4, {position:'start'})
483    //   === "\x1b[31mโ€ฆorn\x1b[39m"
484    #[test]
485    fn ansi_start_truncate() {
486        assert_eq!(
487            cli_truncate_with("\x1b[31municorn\x1b[39m", 4, &start()),
488            "\x1b[31m\u{2026}orn\x1b[39m"
489        );
490    }
491
492    // โ”€โ”€ 10. CJK truncation (width-2 accounting) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
493    // node: cliTruncate("ๅคๆฑ ใ‚„่›™้ฃ›ใณ่พผใ‚€ๆฐดใฎ้Ÿณ", 10) === "ๅคๆฑ ใ‚„่›™โ€ฆ"
494    #[test]
495    fn cjk_truncation() {
496        assert_eq!(
497            cli_truncate("ๅคๆฑ ใ‚„่›™้ฃ›ใณ่พผใ‚€ๆฐดใฎ้Ÿณ", 10),
498            "ๅคๆฑ ใ‚„่›™\u{2026}"
499        );
500    }
501
502    // โ”€โ”€ 11. Custom truncation_character โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
503    // node: cliTruncate("unicorn", 5, {truncationCharacter: '.'}) === "unic."
504    #[test]
505    fn custom_truncation_character() {
506        let opts = TruncateOptions {
507            truncation_character: ".".to_owned(),
508            ..Default::default()
509        };
510        assert_eq!(cli_truncate_with("unicorn", 5, &opts), "unic.");
511    }
512
513    // โ”€โ”€ 12. Emoji truncation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
514    // node: cliTruncate("๐Ÿ˜€๐Ÿ˜๐Ÿ˜‚๐Ÿ˜ƒ๐Ÿ˜„", 4) === "๐Ÿ˜€โ€ฆ"  (each emoji is width 2)
515    #[test]
516    fn emoji_truncation() {
517        assert_eq!(cli_truncate("๐Ÿ˜€๐Ÿ˜๐Ÿ˜‚๐Ÿ˜ƒ๐Ÿ˜„", 4), "๐Ÿ˜€\u{2026}");
518    }
519
520    // โ”€โ”€ Extra pinned cases โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
521    // node: cliTruncate("hello world", 8) === "hello wโ€ฆ"
522    #[test]
523    fn end_plain() {
524        assert_eq!(cli_truncate("hello world", 8), "hello w\u{2026}");
525    }
526
527    // node: cliTruncate("hello world", 8, {position:'start'}) === "โ€ฆo world"
528    #[test]
529    fn start_plain() {
530        assert_eq!(
531            cli_truncate_with("hello world", 8, &start()),
532            "\u{2026}o world"
533        );
534    }
535
536    // node: cliTruncate("hello world", 8, {position:'middle'}) === "hellโ€ฆrld"
537    #[test]
538    fn middle_plain() {
539        assert_eq!(
540            cli_truncate_with("hello world", 8, &middle()),
541            "hell\u{2026}rld"
542        );
543    }
544
545    // Default options match documented JS defaults.
546    #[test]
547    fn default_options() {
548        let d = TruncateOptions::default();
549        assert_eq!(d.position, TruncatePosition::End);
550        assert!(!d.space);
551        assert!(!d.prefer_truncation_on_space);
552        assert_eq!(d.truncation_character, "\u{2026}");
553    }
554
555    // โ”€โ”€ 13. Astral + preferTruncationOnSpace divergence (documented in module docs) โ”€
556    // DIVERGENCE (documented in module docs): JS indexes by UTF-16 code unit.
557    // node: cli-truncate@6 returns "๐Ÿฆ„๐Ÿฆ„โ€ฆ" โ€” Rust scalar indexing yields "๐Ÿฆ„โ€ฆ".
558    // For "๐Ÿฆ„๐Ÿฆ„ ๐Ÿฆ„๐Ÿฆ„" columns=5: wantedIndex=4. JS UTF-16[4] = ' ' (space is at
559    // index 4 because each emoji takes two code units 0-1 and 2-3); JS slices
560    // width-4 โ†’ "๐Ÿฆ„๐Ÿฆ„" โ†’ "๐Ÿฆ„๐Ÿฆ„โ€ฆ". Rust scalar[4] = '๐Ÿฆ„' (fifth scalar, second
561    // trailing unicorn); searches left, finds space at scalar[2], slices
562    // width-2 โ†’ "๐Ÿฆ„" โ†’ "๐Ÿฆ„โ€ฆ".
563    #[test]
564    fn prefer_space_end_astral_divergence() {
565        // DIVERGENCE (documented in module docs): JS indexes by UTF-16 code unit.
566        // node: cli-truncate@6 returns "๐Ÿฆ„๐Ÿฆ„โ€ฆ" โ€” Rust scalar indexing yields "๐Ÿฆ„โ€ฆ".
567        assert_eq!(
568            cli_truncate_with("๐Ÿฆ„๐Ÿฆ„ ๐Ÿฆ„๐Ÿฆ„", 5, &with_prefer(TruncateOptions::default())),
569            "๐Ÿฆ„\u{2026}"
570        );
571    }
572
573    // โ”€โ”€ Adversarial: truncation-character width / cut-math boundaries โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
574
575    fn tc(s: &str) -> TruncateOptions {
576        TruncateOptions {
577            truncation_character: s.to_owned(),
578            ..Default::default()
579        }
580    }
581
582    // A middle-truncate of CJK with space:true keeps a real trailing space โ€” a
583    // future "trim stray trailing whitespace" cleanup would silently break parity.
584    // node: cliTruncate("ๅคๆฑ ใ‚„่›™้ฃ›ใณ", 8, {position:'middle', space:true}) === "ๅคๆฑ  โ€ฆ "
585    #[test]
586    fn truncate_end_cjk_middle_space_keeps_trailing_space() {
587        let opts = with_space(middle());
588        assert_eq!(
589            cli_truncate_with("ๅคๆฑ ใ‚„่›™้ฃ›ใณ", 8, &opts),
590            "ๅคๆฑ  \u{2026} "
591        );
592    }
593
594    // truncation_character wider than columns: Node returns the bare truncation
595    // char (width 3) even though columns is 2 โ€” `columns - width(tc)` goes
596    // negative, clamps the source slice to empty, leaving just the char. A
597    // "more sensible" clamp/cap would diverge.
598    // node: cliTruncate("abcdef", 2, {truncationCharacter:'...'}) === "..."
599    #[test]
600    fn truncate_end_overlong_truncation_char_exceeds_columns() {
601        assert_eq!(cli_truncate_with("abcdef", 2, &tc("...")), "...");
602    }
603
604    // A width-2 truncation char with columns==2 yields just the char (no source
605    // text). Mishandling the wide-char width accounting would drop or duplicate.
606    // node: cliTruncate("abcdef", 2, {truncationCharacter:'ๅค'}) === "ๅค"
607    #[test]
608    fn truncate_end_wide_truncation_char_col_two() {
609        assert_eq!(cli_truncate_with("abcdef", 2, &tc("ๅค")), "ๅค");
610    }
611
612    // Empty truncation char (visible width 0) is a degenerate boundary; an
613    // off-by-one in the `columns - width(tc)` / `length - columns + width(tc)`
614    // math would shift the cut. Pins all three positions.
615    // node: cliTruncate("unicorn", 4, {truncationCharacter:''}) === 'unic' / 'corn' / 'unrn'
616    #[test]
617    fn truncate_empty_truncation_char_all_positions() {
618        assert_eq!(cli_truncate_with("unicorn", 4, &tc("")), "unic"); // End
619        let mut start_empty = start();
620        start_empty.truncation_character = String::new();
621        assert_eq!(cli_truncate_with("unicorn", 4, &start_empty), "corn");
622        let mut mid_empty = middle();
623        mid_empty.truncation_character = String::new();
624        assert_eq!(cli_truncate_with("unicorn", 4, &mid_empty), "unrn");
625    }
626
627    // An ANSI-wrapped '.' as the truncation char has visible width 1 (ANSI
628    // stripped by string_width). If tc width were measured by byte/char length
629    // instead, the cut would shift.
630    // node: cliTruncate("unicorn", 4, {truncationCharacter:'\x1b[31m.\x1b[39m'}) === "uni\x1b[31m.\x1b[39m"
631    #[test]
632    fn truncate_end_ansi_inside_truncation_char_measured_by_visible_width() {
633        assert_eq!(
634            cli_truncate_with("unicorn", 4, &tc("\x1b[31m.\x1b[39m")),
635            "uni\x1b[31m.\x1b[39m"
636        );
637    }
638
639    // prefer_truncation_on_space with NO space in range must fall back to a hard
640    // cut (getIndexOfNearestSpace returns wantedIndex unchanged). A regression in
641    // the ยฑ3 search loop or the fallback would shift or drop the cut. All three
642    // positions on space-free input.
643    // node: cliTruncate("abcdefghij", 5, {preferTruncationOnSpace:true}) === 'abcdโ€ฆ' / 'โ€ฆghij' / 'abโ€ฆij'
644    #[test]
645    fn truncate_prefer_on_space_no_space_present_hard_cut() {
646        assert_eq!(
647            cli_truncate_with("abcdefghij", 5, &with_prefer(TruncateOptions::default())),
648            "abcd\u{2026}"
649        );
650        assert_eq!(
651            cli_truncate_with("abcdefghij", 5, &with_prefer(start())),
652            "\u{2026}ghij"
653        );
654        assert_eq!(
655            cli_truncate_with("abcdefghij", 5, &with_prefer(middle())),
656            "ab\u{2026}ij"
657        );
658    }
659
660    // Astral text WITHOUT a space does NOT diverge from Node under prefer (unlike
661    // the documented astral+space divergence above). Guards the boundary of the
662    // known class-1 divergence: a future "fix" that scalar-shifts all astral
663    // prefer paths could wrongly break these matching cases.
664    // node: cliTruncate("๐Ÿฆ„๐Ÿฆ„๐Ÿฆ„๐Ÿฆ„๐Ÿฆ„", 5, {preferTruncationOnSpace:true}) === '๐Ÿฆ„๐Ÿฆ„โ€ฆ' / 'โ€ฆ๐Ÿฆ„๐Ÿฆ„' / '๐Ÿฆ„โ€ฆ๐Ÿฆ„'
665    #[test]
666    fn truncate_astral_no_space_prefer_matches_node_all_positions() {
667        assert_eq!(
668            cli_truncate_with("๐Ÿฆ„๐Ÿฆ„๐Ÿฆ„๐Ÿฆ„๐Ÿฆ„", 5, &with_prefer(TruncateOptions::default())),
669            "๐Ÿฆ„๐Ÿฆ„\u{2026}"
670        );
671        assert_eq!(
672            cli_truncate_with("๐Ÿฆ„๐Ÿฆ„๐Ÿฆ„๐Ÿฆ„๐Ÿฆ„", 5, &with_prefer(start())),
673            "\u{2026}๐Ÿฆ„๐Ÿฆ„"
674        );
675        assert_eq!(
676            cli_truncate_with("๐Ÿฆ„๐Ÿฆ„๐Ÿฆ„๐Ÿฆ„๐Ÿฆ„", 5, &with_prefer(middle())),
677            "๐Ÿฆ„\u{2026}๐Ÿฆ„"
678        );
679    }
680}