Skip to main content

thndrs_lib/core/utils/
mod.rs

1pub mod datetime;
2
3use crate::tools::MAX_LINE_LEN;
4
5use std::path::PathBuf;
6
7use unicode_segmentation::UnicodeSegmentation;
8use unicode_width::UnicodeWidthStr;
9
10/// Compute `floor(value * ratio)` without floating-point drift on whole tokens.
11pub fn ratio_of(value: u64, ratio: f64) -> u64 {
12    if value == 0 {
13        return 0;
14    }
15    let scaled = (value as f64) * ratio;
16    scaled.floor() as u64
17}
18
19/// Depth of a scope: `.` = 0, `src` = 1, `src/core` = 2.
20pub fn scope_depth(scope: &str) -> usize {
21    if scope == "." || scope.is_empty() {
22        return 0;
23    }
24    scope.matches('/').count() + 1
25}
26
27/// Return the current user's home directory from common platform env vars.
28pub fn home_dir() -> Option<PathBuf> {
29    std::env::var_os("HOME")
30        .map(PathBuf::from)
31        .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
32}
33
34/// Escape text for XML element content.
35pub fn escape_xml(value: &str) -> String {
36    value
37        .replace('&', "&amp;")
38        .replace('<', "&lt;")
39        .replace('>', "&gt;")
40        .replace('"', "&quot;")
41        .replace('\'', "&apos;")
42}
43
44/// Truncate a string to [`MAX_LINE_LEN`] chars, adding `...` if truncated.
45pub fn truncate_line(s: &str) -> String {
46    if s.chars().count() <= MAX_LINE_LEN {
47        s.to_string()
48    } else {
49        format!("{}...", s.chars().take(MAX_LINE_LEN).collect::<String>())
50    }
51}
52
53/// Truncate a string to `max_width` terminal columns, appending `…` if truncated.
54///
55/// Unlike [`truncate_line`] which uses a fixed cap and `...`, this is a
56/// general-purpose helper for width-aware UI truncation: it takes an explicit
57/// max, uses a single `…` ellipsis, and counts display columns (not bytes).
58pub fn truncate_ellipsis(s: &str, max_width: usize) -> String {
59    if max_width == 0 {
60        return String::new();
61    }
62    if text_width(s) <= max_width {
63        return s.to_string();
64    }
65    if max_width <= 1 {
66        return "…".to_string();
67    }
68    format!("{}…", take_display_width(s, max_width - 1))
69}
70
71/// Truncate from the start (keeping the end), prefixing `…` if truncated.
72///
73/// Useful for paths and URLs where the end is more informative.
74pub fn truncate_ellipsis_start(s: &str, max_width: usize) -> String {
75    if max_width == 0 {
76        return String::new();
77    }
78    if text_width(s) <= max_width {
79        return s.to_string();
80    }
81    if max_width <= 1 {
82        return "…".to_string();
83    }
84
85    format!("…{}", take_display_width_from_end(s, max_width - 1))
86}
87
88/// Display width of a string in terminal columns.
89pub fn text_width(text: &str) -> usize {
90    UnicodeWidthStr::width(text)
91}
92
93/// Display width of one grapheme cluster in terminal columns.
94pub fn grapheme_width(grapheme: &str) -> usize {
95    UnicodeWidthStr::width(grapheme)
96}
97
98fn take_display_width(text: &str, max_width: usize) -> String {
99    let mut out = String::new();
100    let mut used = 0usize;
101    for grapheme in text.graphemes(true) {
102        let width = text_width(grapheme);
103        if used + width > max_width {
104            break;
105        }
106        out.push_str(grapheme);
107        used += width;
108    }
109    out
110}
111
112fn take_display_width_from_end(text: &str, max_width: usize) -> String {
113    let mut chunks = Vec::new();
114    let mut used = 0usize;
115    for grapheme in text.graphemes(true).rev() {
116        let width = text_width(grapheme);
117        if used + width > max_width {
118            break;
119        }
120        chunks.push(grapheme);
121        used += width;
122    }
123    chunks.into_iter().rev().collect()
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn truncate_line_short_unchanged() {
132        assert_eq!(truncate_line("hello"), "hello");
133    }
134
135    #[test]
136    fn truncate_line_long_truncated() {
137        let c = MAX_LINE_LEN;
138        let long = "x".repeat(c + 100);
139        let result = truncate_line(&long);
140        assert!(result.ends_with("..."));
141        assert!(result.chars().count() <= c + 3);
142    }
143
144    #[test]
145    fn truncate_ellipsis_short_unchanged() {
146        assert_eq!(truncate_ellipsis("hello", 10), "hello");
147    }
148
149    #[test]
150    fn truncate_ellipsis_exact_fit() {
151        assert_eq!(truncate_ellipsis("hello", 5), "hello");
152    }
153
154    #[test]
155    fn truncate_ellipsis_truncates_with_ellipsis() {
156        assert_eq!(truncate_ellipsis("hello world", 8), "hello w…");
157    }
158
159    #[test]
160    fn truncate_ellipsis_uses_display_width() {
161        assert_eq!(truncate_ellipsis("ab中cd", 5), "ab中…");
162    }
163
164    #[test]
165    fn truncate_ellipsis_keeps_grapheme_together() {
166        let family = "👨\u{200d}👩\u{200d}👧";
167        assert_eq!(truncate_ellipsis(&format!("{family}abc"), 4), format!("{family}a…"));
168    }
169
170    #[test]
171    fn truncate_ellipsis_zero_max() {
172        assert_eq!(truncate_ellipsis("hello", 0), "");
173    }
174
175    #[test]
176    fn truncate_ellipsis_one_max() {
177        assert_eq!(truncate_ellipsis("hello", 1), "…");
178    }
179
180    #[test]
181    fn truncate_ellipsis_start_keeps_end() {
182        assert_eq!(truncate_ellipsis_start("/long/path/to/file.rs", 15), "…ath/to/file.rs");
183    }
184
185    #[test]
186    fn truncate_ellipsis_start_uses_display_width() {
187        assert_eq!(truncate_ellipsis_start("ab中cd", 5), "…中cd");
188    }
189
190    #[test]
191    fn truncate_ellipsis_start_short_unchanged() {
192        assert_eq!(truncate_ellipsis_start("short", 10), "short");
193    }
194
195    #[test]
196    fn scope_depth_counts_segments() {
197        assert_eq!(scope_depth("."), 0);
198        assert_eq!(scope_depth("src"), 1);
199        assert_eq!(scope_depth("src/core"), 2);
200        assert_eq!(scope_depth("src/core/context"), 3);
201    }
202}