supercode_frontend_tui/foundation/
text_formatting.rs1use unicode_segmentation::UnicodeSegmentation;
8use unicode_width::UnicodeWidthChar;
9use unicode_width::UnicodeWidthStr;
10
11pub fn capitalize_first(input: &str) -> String {
12 let mut chars = input.chars();
13 match chars.next() {
14 Some(first) => {
15 let mut capitalized = first.to_uppercase().collect::<String>();
16 capitalized.push_str(chars.as_str());
17 capitalized
18 }
19 None => String::new(),
20 }
21}
22
23pub fn format_and_truncate_tool_result(text: &str, max_lines: usize, line_width: usize) -> String {
26 let max_graphemes = (max_lines * line_width).saturating_sub(max_lines);
30
31 if let Some(formatted_json) = format_json_compact(text) {
32 truncate_text(&formatted_json, max_graphemes)
33 } else {
34 truncate_text(text, max_graphemes)
35 }
36}
37
38pub fn format_json_compact(text: &str) -> Option<String> {
47 let json = serde_json::from_str::<serde_json::Value>(text).ok()?;
48 let json_pretty = serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
49
50 let mut result = String::new();
52 let mut chars = json_pretty.chars().peekable();
53 let mut in_string = false;
54 let mut escape_next = false;
55
56 while let Some(ch) = chars.next() {
58 match ch {
59 '"' if !escape_next => {
60 in_string = !in_string;
61 result.push(ch);
62 }
63 '\\' if in_string => {
64 escape_next = !escape_next;
65 result.push(ch);
66 }
67 '\n' | '\r' if !in_string => {
68 }
70 ' ' | '\t' if !in_string => {
71 if let (Some(&next_ch), Some(last_ch)) = (chars.peek(), result.chars().last()) {
73 if (last_ch == ':' || last_ch == ',') && !matches!(next_ch, '}' | ']') {
74 result.push(' ');
75 }
76 }
77 }
78 _ => {
79 if escape_next && in_string {
80 escape_next = false;
81 }
82 result.push(ch);
83 }
84 }
85 }
86
87 Some(result)
88}
89
90pub fn truncate_text(text: &str, max_graphemes: usize) -> String {
92 let mut graphemes = text.grapheme_indices(true);
93
94 if let Some((byte_index, _)) = graphemes.nth(max_graphemes) {
96 if max_graphemes >= 3 {
98 let mut truncate_graphemes = text.grapheme_indices(true);
100 if let Some((truncate_byte_index, _)) = truncate_graphemes.nth(max_graphemes - 3) {
101 let truncated = &text[..truncate_byte_index];
102 format!("{truncated}...")
103 } else {
104 text.to_string()
105 }
106 } else {
107 let truncated = &text[..byte_index];
109 truncated.to_string()
110 }
111 } else {
112 text.to_string()
114 }
115}
116
117pub fn center_truncate_path(path: &str, max_width: usize) -> String {
121 if max_width == 0 {
122 return String::new();
123 }
124 if UnicodeWidthStr::width(path) <= max_width {
125 return path.to_string();
126 }
127
128 let sep = std::path::MAIN_SEPARATOR;
129 let has_leading_sep = path.starts_with(sep);
130 let has_trailing_sep = path.ends_with(sep);
131 let mut raw_segments: Vec<&str> = path.split(sep).collect();
132 if has_leading_sep && !raw_segments.is_empty() && raw_segments[0].is_empty() {
133 raw_segments.remove(0);
134 }
135 if has_trailing_sep
136 && !raw_segments.is_empty()
137 && raw_segments.last().is_some_and(|last| last.is_empty())
138 {
139 raw_segments.pop();
140 }
141
142 if raw_segments.is_empty() {
143 if has_leading_sep {
144 let root = sep.to_string();
145 if UnicodeWidthStr::width(root.as_str()) <= max_width {
146 return root;
147 }
148 }
149 return "…".to_string();
150 }
151
152 struct Segment<'a> {
153 original: &'a str,
154 text: String,
155 truncatable: bool,
156 is_suffix: bool,
157 }
158
159 let assemble = |leading: bool, segments: &[Segment<'_>]| -> String {
160 let mut result = String::new();
161 if leading {
162 result.push(sep);
163 }
164 for segment in segments {
165 if !result.is_empty() && !result.ends_with(sep) {
166 result.push(sep);
167 }
168 result.push_str(segment.text.as_str());
169 }
170 result
171 };
172
173 let front_truncate = |original: &str, allowed_width: usize| -> String {
174 if allowed_width == 0 {
175 return String::new();
176 }
177 if UnicodeWidthStr::width(original) <= allowed_width {
178 return original.to_string();
179 }
180 if allowed_width == 1 {
181 return "…".to_string();
182 }
183
184 let mut kept: Vec<char> = Vec::new();
185 let mut used_width = 1; for ch in original.chars().rev() {
187 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
188 if used_width + ch_width > allowed_width {
189 break;
190 }
191 used_width += ch_width;
192 kept.push(ch);
193 }
194 kept.reverse();
195 let mut truncated = String::from("…");
196 for ch in kept {
197 truncated.push(ch);
198 }
199 truncated
200 };
201
202 let mut combos: Vec<(usize, usize)> = Vec::new();
203 let segment_count = raw_segments.len();
204 for left in 1..=segment_count {
205 let min_right = if left == segment_count { 0 } else { 1 };
206 for right in min_right..=(segment_count - left) {
207 combos.push((left, right));
208 }
209 }
210 let desired_suffix = if segment_count > 1 {
211 std::cmp::min(2, segment_count - 1)
212 } else {
213 0
214 };
215 let mut prioritized: Vec<(usize, usize)> = Vec::new();
216 let mut fallback: Vec<(usize, usize)> = Vec::new();
217 for combo in combos {
218 if combo.1 >= desired_suffix {
219 prioritized.push(combo);
220 } else {
221 fallback.push(combo);
222 }
223 }
224 let sort_combos = |items: &mut Vec<(usize, usize)>| {
225 items.sort_by(|(left_a, right_a), (left_b, right_b)| {
226 left_b
227 .cmp(left_a)
228 .then_with(|| right_b.cmp(right_a))
229 .then_with(|| (left_b + right_b).cmp(&(left_a + right_a)))
230 });
231 };
232 sort_combos(&mut prioritized);
233 sort_combos(&mut fallback);
234
235 let fit_segments =
236 |segments: &mut Vec<Segment<'_>>, allow_front_truncate: bool| -> Option<String> {
237 loop {
238 let candidate = assemble(has_leading_sep, segments);
239 let width = UnicodeWidthStr::width(candidate.as_str());
240 if width <= max_width {
241 return Some(candidate);
242 }
243
244 if !allow_front_truncate {
245 return None;
246 }
247
248 let mut indices: Vec<usize> = Vec::new();
249 for (idx, seg) in segments.iter().enumerate().rev() {
250 if seg.truncatable && seg.is_suffix {
251 indices.push(idx);
252 }
253 }
254 for (idx, seg) in segments.iter().enumerate().rev() {
255 if seg.truncatable && !seg.is_suffix {
256 indices.push(idx);
257 }
258 }
259
260 if indices.is_empty() {
261 return None;
262 }
263
264 let mut changed = false;
265 for idx in indices {
266 let original_width = UnicodeWidthStr::width(segments[idx].original);
267 if original_width <= max_width && segment_count > 2 {
268 continue;
269 }
270 let seg_width = UnicodeWidthStr::width(segments[idx].text.as_str());
271 let other_width = width.saturating_sub(seg_width);
272 let allowed_width = max_width.saturating_sub(other_width).max(1);
273 let new_text = front_truncate(segments[idx].original, allowed_width);
274 if new_text != segments[idx].text {
275 segments[idx].text = new_text;
276 changed = true;
277 break;
278 }
279 }
280
281 if !changed {
282 return None;
283 }
284 }
285 };
286
287 for (left_count, right_count) in prioritized.into_iter().chain(fallback) {
288 let mut segments: Vec<Segment<'_>> = raw_segments[..left_count]
289 .iter()
290 .map(|seg| Segment {
291 original: seg,
292 text: (*seg).to_string(),
293 truncatable: true,
294 is_suffix: false,
295 })
296 .collect();
297
298 let need_ellipsis = left_count + right_count < segment_count;
299 if need_ellipsis {
300 segments.push(Segment {
301 original: "…",
302 text: "…".to_string(),
303 truncatable: false,
304 is_suffix: false,
305 });
306 }
307
308 if right_count > 0 {
309 segments.extend(
310 raw_segments[segment_count - right_count..]
311 .iter()
312 .map(|seg| Segment {
313 original: seg,
314 text: (*seg).to_string(),
315 truncatable: true,
316 is_suffix: true,
317 }),
318 );
319 }
320
321 let allow_front_truncate = need_ellipsis || segment_count <= 2;
322 if let Some(candidate) = fit_segments(&mut segments, allow_front_truncate) {
323 return candidate;
324 }
325 }
326
327 front_truncate(path, max_width)
328}
329
330pub fn proper_join<T: AsRef<str>>(items: &[T]) -> String {
337 match items.len() {
338 0 => String::new(),
339 1 => items[0].as_ref().to_string(),
340 2 => format!("{} and {}", items[0].as_ref(), items[1].as_ref()),
341 _ => {
342 let last = items[items.len() - 1].as_ref();
343 let mut result = String::new();
344
345 for (i, item) in items.iter().take(items.len() - 1).enumerate() {
346 if i > 0 {
347 result.push_str(", ");
348 }
349 result.push_str(item.as_ref());
350 }
351
352 format!("{result} and {last}")
353 }
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use pretty_assertions::assert_eq;
361
362 #[test]
363 fn test_truncate_text() {
364 let text = "Hello, world!";
365 let truncated = truncate_text(text, 8);
366 assert_eq!(truncated, "Hello...");
367 }
368
369 #[test]
370 fn test_truncate_empty_string() {
371 let text = "";
372 let truncated = truncate_text(text, 5);
373 assert_eq!(truncated, "");
374 }
375
376 #[test]
377 fn test_truncate_max_graphemes_zero() {
378 let text = "Hello";
379 let truncated = truncate_text(text, 0);
380 assert_eq!(truncated, "");
381 }
382
383 #[test]
384 fn test_truncate_max_graphemes_one() {
385 let text = "Hello";
386 let truncated = truncate_text(text, 1);
387 assert_eq!(truncated, "H");
388 }
389
390 #[test]
391 fn test_truncate_max_graphemes_two() {
392 let text = "Hello";
393 let truncated = truncate_text(text, 2);
394 assert_eq!(truncated, "He");
395 }
396
397 #[test]
398 fn test_truncate_max_graphemes_three_boundary() {
399 let text = "Hello";
400 let truncated = truncate_text(text, 3);
401 assert_eq!(truncated, "...");
402 }
403
404 #[test]
405 fn test_truncate_text_shorter_than_limit() {
406 let text = "Hi";
407 let truncated = truncate_text(text, 10);
408 assert_eq!(truncated, "Hi");
409 }
410
411 #[test]
412 fn test_truncate_text_exact_length() {
413 let text = "Hello";
414 let truncated = truncate_text(text, 5);
415 assert_eq!(truncated, "Hello");
416 }
417
418 #[test]
419 fn test_truncate_emoji() {
420 let text = "👋🌍🚀✨💫";
421 let truncated = truncate_text(text, 3);
422 assert_eq!(truncated, "...");
423
424 let truncated_longer = truncate_text(text, 4);
425 assert_eq!(truncated_longer, "👋...");
426 }
427
428 #[test]
429 fn test_truncate_unicode_combining_characters() {
430 let text = "é́ñ̃"; let truncated = truncate_text(text, 2);
432 assert_eq!(truncated, "é́ñ̃");
433 }
434
435 #[test]
436 fn test_truncate_very_long_text() {
437 let text = "a".repeat(1000);
438 let truncated = truncate_text(&text, 10);
439 assert_eq!(truncated, "aaaaaaa...");
440 assert_eq!(truncated.len(), 10); }
442
443 #[test]
444 fn test_format_json_compact_simple_object() {
445 let json = r#"{ "name": "John", "age": 30 }"#;
446 let result = format_json_compact(json).unwrap();
447 assert_eq!(result, r#"{"name": "John", "age": 30}"#);
448 }
449
450 #[test]
451 fn test_format_json_compact_nested_object() {
452 let json = r#"{ "user": { "name": "John", "details": { "age": 30, "city": "NYC" } } }"#;
453 let result = format_json_compact(json).unwrap();
454 assert_eq!(
455 result,
456 r#"{"user": {"name": "John", "details": {"age": 30, "city": "NYC"}}}"#
457 );
458 }
459
460 #[test]
461 fn test_center_truncate_doesnt_truncate_short_path() {
462 let sep = std::path::MAIN_SEPARATOR;
463 let path = format!("{sep}Users{sep}codex{sep}Public");
464 let truncated = center_truncate_path(&path, 40);
465
466 assert_eq!(truncated, path);
467 }
468
469 #[test]
470 fn test_center_truncate_truncates_long_path() {
471 let sep = std::path::MAIN_SEPARATOR;
472 let path = format!("~{sep}hello{sep}the{sep}fox{sep}is{sep}very{sep}fast");
473 let truncated = center_truncate_path(&path, 24);
474
475 assert_eq!(
476 truncated,
477 format!("~{sep}hello{sep}the{sep}…{sep}very{sep}fast")
478 );
479 }
480
481 #[test]
482 fn test_center_truncate_truncates_long_windows_path() {
483 let sep = std::path::MAIN_SEPARATOR;
484 let path = format!(
485 "C:{sep}Users{sep}codex{sep}Projects{sep}super{sep}long{sep}windows{sep}path{sep}file.txt"
486 );
487 let truncated = center_truncate_path(&path, 36);
488
489 let expected = format!("C:{sep}Users{sep}codex{sep}…{sep}path{sep}file.txt");
490
491 assert_eq!(truncated, expected);
492 }
493
494 #[test]
495 fn test_center_truncate_handles_long_segment() {
496 let sep = std::path::MAIN_SEPARATOR;
497 let path = format!("~{sep}supercalifragilisticexpialidocious");
498 let truncated = center_truncate_path(&path, 18);
499
500 assert_eq!(truncated, format!("~{sep}…cexpialidocious"));
501 }
502
503 #[test]
504 fn test_format_json_compact_array() {
505 let json = r#"[ 1, 2, { "key": "value" }, "string" ]"#;
506 let result = format_json_compact(json).unwrap();
507 assert_eq!(result, r#"[1, 2, {"key": "value"}, "string"]"#);
508 }
509
510 #[test]
511 fn test_format_json_compact_already_compact() {
512 let json = r#"{"compact":true}"#;
513 let result = format_json_compact(json).unwrap();
514 assert_eq!(result, r#"{"compact": true}"#);
515 }
516
517 #[test]
518 fn test_format_json_compact_with_whitespace() {
519 let json = r#"
520 {
521 "name": "John",
522 "hobbies": [
523 "reading",
524 "coding"
525 ]
526 }
527 "#;
528 let result = format_json_compact(json).unwrap();
529 assert_eq!(
530 result,
531 r#"{"name": "John", "hobbies": ["reading", "coding"]}"#
532 );
533 }
534
535 #[test]
536 fn test_format_json_compact_invalid_json() {
537 let invalid_json = r#"{"invalid": json syntax}"#;
538 let result = format_json_compact(invalid_json);
539 assert!(result.is_none());
540 }
541
542 #[test]
543 fn test_format_json_compact_empty_object() {
544 let json = r#"{}"#;
545 let result = format_json_compact(json).unwrap();
546 assert_eq!(result, "{}");
547 }
548
549 #[test]
550 fn test_format_json_compact_empty_array() {
551 let json = r#"[]"#;
552 let result = format_json_compact(json).unwrap();
553 assert_eq!(result, "[]");
554 }
555
556 #[test]
557 fn test_format_json_compact_primitive_values() {
558 assert_eq!(format_json_compact("42").unwrap(), "42");
559 assert_eq!(format_json_compact("true").unwrap(), "true");
560 assert_eq!(format_json_compact("false").unwrap(), "false");
561 assert_eq!(format_json_compact("null").unwrap(), "null");
562 assert_eq!(format_json_compact(r#""string""#).unwrap(), r#""string""#);
563 }
564
565 #[test]
566 fn test_proper_join() {
567 let empty: Vec<String> = vec![];
568 assert_eq!(proper_join(&empty), "");
569 assert_eq!(proper_join(&["apple"]), "apple");
570 assert_eq!(proper_join(&["apple", "banana"]), "apple and banana");
571 assert_eq!(
572 proper_join(&["apple", "banana", "cherry"]),
573 "apple, banana and cherry"
574 );
575 assert_eq!(
576 proper_join(&["apple", "banana", "cherry", "date"]),
577 "apple, banana, cherry and date"
578 );
579 }
580}