Skip to main content

gitkraft_core/utils/
text.rs

1//! Text manipulation utilities shared across the crate.
2
3/// Truncate `s` to at most `max_chars` Unicode characters, appending "…" if shortened.
4///
5/// This is a framework-agnostic utility shared by both the GUI and TUI.
6pub fn truncate_str(s: &str, max_chars: usize) -> String {
7    let char_count = s.chars().count();
8    if char_count <= max_chars {
9        s.to_string()
10    } else if max_chars <= 1 {
11        "…".to_string()
12    } else {
13        let mut out: String = s.chars().take(max_chars - 1).collect();
14        out.push('…');
15        out
16    }
17}
18
19/// Extract the filename (last component) from a `/`-separated path string.
20///
21/// Returns the entire input when there is no `/` separator.
22///
23/// # Examples
24/// ```
25/// assert_eq!(gitkraft_core::path_basename("src/main.rs"), "main.rs");
26/// assert_eq!(gitkraft_core::path_basename("hello.txt"), "hello.txt");
27/// assert_eq!(gitkraft_core::path_basename(""), "");
28/// ```
29pub fn path_basename(path: &str) -> &str {
30    path.rsplit('/').next().unwrap_or(path)
31}
32
33/// Truncate `s` to fit within `available_px` pixels at the given average
34/// `px_per_char` rate, appending "…" when the string is shortened.
35///
36/// * If `available_px` is zero or negative the string is truncated to `""`.
37/// * If the string already fits it is returned unchanged.
38/// * The "…" counts as one character in the budget.
39pub fn truncate_to_fit(s: &str, available_px: f32, px_per_char: f32) -> String {
40    if available_px <= 0.0 || px_per_char <= 0.0 {
41        return String::new();
42    }
43    let max_chars = (available_px / px_per_char).floor() as usize;
44    truncate_str(s, max_chars)
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50
51    #[test]
52    fn no_truncation_when_short() {
53        assert_eq!(truncate_str("hello", 10), "hello");
54    }
55
56    #[test]
57    fn exact_length_no_truncation() {
58        assert_eq!(truncate_str("hello", 5), "hello");
59    }
60
61    #[test]
62    fn truncation_adds_ellipsis() {
63        assert_eq!(truncate_str("hello world", 5), "hell…");
64    }
65
66    #[test]
67    fn max_one_gives_ellipsis() {
68        assert_eq!(truncate_str("hello", 1), "…");
69    }
70
71    #[test]
72    fn max_zero_gives_ellipsis() {
73        assert_eq!(truncate_str("hello", 0), "…");
74    }
75
76    #[test]
77    fn unicode_chars_counted_correctly() {
78        // 4 chars: each emoji is 1 char
79        assert_eq!(truncate_str("😀😁😂😃", 3), "😀😁…");
80    }
81
82    #[test]
83    fn empty_string() {
84        assert_eq!(truncate_str("", 5), "");
85    }
86
87    #[test]
88    fn path_basename_with_slashes() {
89        assert_eq!(path_basename("src/main.rs"), "main.rs");
90    }
91
92    #[test]
93    fn path_basename_no_slash() {
94        assert_eq!(path_basename("hello.txt"), "hello.txt");
95    }
96
97    #[test]
98    fn path_basename_trailing_slash() {
99        assert_eq!(path_basename("dir/"), "");
100    }
101
102    #[test]
103    fn path_basename_empty() {
104        assert_eq!(path_basename(""), "");
105    }
106
107    #[test]
108    fn path_basename_deep_path() {
109        assert_eq!(path_basename("a/b/c/d/file.rs"), "file.rs");
110    }
111
112    #[test]
113    fn truncate_to_fit_short_string() {
114        assert_eq!(truncate_to_fit("hi", 100.0, 7.0), "hi");
115    }
116
117    #[test]
118    fn truncate_to_fit_long_string() {
119        let result = truncate_to_fit("hello world", 30.0, 7.0);
120        assert!(result.ends_with('…'));
121        assert!(result.chars().count() <= 5);
122    }
123
124    #[test]
125    fn truncate_to_fit_zero_px() {
126        assert_eq!(truncate_to_fit("hello", 0.0, 7.0), "");
127    }
128
129    #[test]
130    fn truncate_to_fit_negative_px() {
131        assert_eq!(truncate_to_fit("hello", -10.0, 7.0), "");
132    }
133
134    #[test]
135    fn truncate_to_fit_zero_px_per_char() {
136        assert_eq!(truncate_to_fit("hello", 100.0, 0.0), "");
137    }
138}