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}