chie_shared/utils/
formatting.rs

1//! Formatting and sanitization utility functions.
2
3use crate::Points;
4
5/// Format bytes as human-readable string (KB, MB, GB, TB).
6///
7/// # Examples
8///
9/// ```
10/// use chie_shared::format_bytes;
11///
12/// assert_eq!(format_bytes(500), "500 B");
13/// assert_eq!(format_bytes(1024), "1.00 KB");
14/// assert_eq!(format_bytes(1_048_576), "1.00 MB");
15/// assert_eq!(format_bytes(1_073_741_824), "1.00 GB");
16/// assert_eq!(format_bytes(1_099_511_627_776), "1.00 TB");
17///
18/// // Partial units
19/// assert_eq!(format_bytes(1536), "1.50 KB");
20/// assert_eq!(format_bytes(2_621_440), "2.50 MB");
21/// ```
22#[inline]
23#[must_use]
24pub fn format_bytes(bytes: u64) -> String {
25    const KB: u64 = 1024;
26    const MB: u64 = KB * 1024;
27    const GB: u64 = MB * 1024;
28    const TB: u64 = GB * 1024;
29
30    if bytes >= TB {
31        format!("{:.2} TB", bytes as f64 / TB as f64)
32    } else if bytes >= GB {
33        format!("{:.2} GB", bytes as f64 / GB as f64)
34    } else if bytes >= MB {
35        format!("{:.2} MB", bytes as f64 / MB as f64)
36    } else if bytes >= KB {
37        format!("{:.2} KB", bytes as f64 / KB as f64)
38    } else {
39        format!("{} B", bytes)
40    }
41}
42
43/// Format duration in milliseconds as human-readable string.
44///
45/// # Examples
46///
47/// ```
48/// use chie_shared::format_duration_ms;
49///
50/// assert_eq!(format_duration_ms(500), "500 ms");
51/// assert_eq!(format_duration_ms(1_000), "1.0 seconds");
52/// assert_eq!(format_duration_ms(60_000), "1.0 minutes");
53/// assert_eq!(format_duration_ms(3_600_000), "1.0 hours");
54/// assert_eq!(format_duration_ms(86_400_000), "1.0 days");
55///
56/// // Partial units
57/// assert_eq!(format_duration_ms(1_500), "1.5 seconds");
58/// assert_eq!(format_duration_ms(90_000), "1.5 minutes");
59/// ```
60#[inline]
61#[must_use]
62pub fn format_duration_ms(ms: u64) -> String {
63    const SEC: u64 = 1000;
64    const MIN: u64 = SEC * 60;
65    const HOUR: u64 = MIN * 60;
66    const DAY: u64 = HOUR * 24;
67
68    if ms >= DAY {
69        format!("{:.1} days", ms as f64 / DAY as f64)
70    } else if ms >= HOUR {
71        format!("{:.1} hours", ms as f64 / HOUR as f64)
72    } else if ms >= MIN {
73        format!("{:.1} minutes", ms as f64 / MIN as f64)
74    } else if ms >= SEC {
75        format!("{:.1} seconds", ms as f64 / SEC as f64)
76    } else {
77        format!("{} ms", ms)
78    }
79}
80
81/// Truncate a string to a maximum length, adding "..." if truncated.
82#[inline]
83#[must_use]
84pub fn truncate_string(s: &str, max_len: usize) -> String {
85    if s.len() <= max_len {
86        s.to_string()
87    } else {
88        let mut truncated = s
89            .chars()
90            .take(max_len.saturating_sub(3))
91            .collect::<String>();
92        truncated.push_str("...");
93        truncated
94    }
95}
96
97/// Sanitize a tag by trimming whitespace and converting to lowercase.
98#[inline]
99#[must_use]
100pub fn sanitize_tag(tag: &str) -> String {
101    tag.trim().to_lowercase()
102}
103
104/// Validate and sanitize a list of tags.
105#[must_use]
106pub fn sanitize_tags(tags: &[String]) -> Vec<String> {
107    tags.iter()
108        .map(|t| sanitize_tag(t))
109        .filter(|t| !t.is_empty())
110        .collect()
111}
112
113/// Format points with thousands separator.
114#[inline]
115#[must_use]
116pub fn format_points(points: Points) -> String {
117    let s = points.to_string();
118    let mut result = String::new();
119    let chars: Vec<char> = s.chars().collect();
120
121    for (i, c) in chars.iter().enumerate() {
122        if i > 0 && (chars.len() - i) % 3 == 0 {
123            result.push(',');
124        }
125        result.push(*c);
126    }
127
128    result
129}
130
131/// Sanitize string for display (remove control characters).
132#[inline]
133#[must_use]
134pub fn sanitize_string(s: &str) -> String {
135    s.chars()
136        .filter(|c| !c.is_control() || c.is_whitespace())
137        .collect()
138}
139
140/// Normalize tag for search/comparison.
141pub fn normalize_tag(tag: &str) -> String {
142    tag.trim()
143        .to_lowercase()
144        .chars()
145        .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
146        .collect()
147}
148
149/// Format bandwidth as string.
150pub fn format_bandwidth(bps: u64) -> String {
151    if bps >= 1_000_000_000 {
152        format!("{:.2} Gbps", bps as f64 / 1_000_000_000.0)
153    } else if bps >= 1_000_000 {
154        format!("{:.2} Mbps", bps as f64 / 1_000_000.0)
155    } else if bps >= 1_000 {
156        format!("{:.2} Kbps", bps as f64 / 1_000.0)
157    } else {
158        format!("{} bps", bps)
159    }
160}
161
162/// Format a ratio as a percentage string.
163pub fn format_ratio_as_percentage(numerator: u64, denominator: u64) -> String {
164    if denominator == 0 {
165        return "N/A".to_string();
166    }
167    format!("{:.2}%", (numerator as f64 / denominator as f64) * 100.0)
168}
169
170/// Generate URL-friendly slug from a string.
171/// Converts to lowercase, replaces spaces/special chars with hyphens, removes consecutive hyphens.
172pub fn generate_slug(text: &str, max_len: usize) -> String {
173    let mut slug = text
174        .to_lowercase()
175        .chars()
176        .map(|c| {
177            if c.is_ascii_alphanumeric() {
178                c
179            } else if c.is_whitespace() || c == '_' || c == '-' {
180                '-'
181            } else {
182                '\0' // Mark for removal
183            }
184        })
185        .filter(|&c| c != '\0')
186        .collect::<String>();
187
188    // Remove consecutive hyphens
189    while slug.contains("--") {
190        slug = slug.replace("--", "-");
191    }
192
193    // Trim hyphens from ends
194    slug = slug.trim_matches('-').to_string();
195
196    // Truncate if needed
197    if slug.len() > max_len {
198        slug.truncate(max_len);
199        slug = slug.trim_end_matches('-').to_string();
200    }
201
202    slug
203}
204
205/// Generate a short identifier (8 chars) from a CID for display purposes.
206/// Takes first 8 characters after "Qm" prefix if present, otherwise first 8 chars.
207pub fn cid_to_short_id(cid: &str) -> String {
208    if cid.starts_with("Qm") && cid.len() > 10 {
209        cid[2..10].to_string()
210    } else if cid.len() >= 8 {
211        cid[..8].to_string()
212    } else {
213        cid.to_string()
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_format_bytes() {
223        assert_eq!(format_bytes(512), "512 B");
224        assert_eq!(format_bytes(1024), "1.00 KB");
225        assert_eq!(format_bytes(1024 * 1024), "1.00 MB");
226        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB");
227        assert_eq!(format_bytes(1024_u64 * 1024 * 1024 * 1024), "1.00 TB");
228    }
229
230    #[test]
231    fn test_format_duration() {
232        assert_eq!(format_duration_ms(500), "500 ms");
233        assert_eq!(format_duration_ms(1000), "1.0 seconds");
234        assert_eq!(format_duration_ms(60_000), "1.0 minutes");
235        assert_eq!(format_duration_ms(3_600_000), "1.0 hours");
236        assert_eq!(format_duration_ms(86_400_000), "1.0 days");
237    }
238
239    #[test]
240    fn test_truncate_string() {
241        assert_eq!(truncate_string("hello", 10), "hello");
242        assert_eq!(truncate_string("hello world", 8), "hello...");
243        assert_eq!(truncate_string("hi", 5), "hi");
244    }
245
246    #[test]
247    fn test_sanitize_tag() {
248        assert_eq!(sanitize_tag("  Hello World  "), "hello world");
249        assert_eq!(sanitize_tag("Rust"), "rust");
250    }
251
252    #[test]
253    fn test_sanitize_tags() {
254        let tags = vec![
255            "  Rust  ".to_string(),
256            "GAME".to_string(),
257            "  ".to_string(),
258            "3D".to_string(),
259        ];
260        let sanitized = sanitize_tags(&tags);
261        assert_eq!(sanitized, vec!["rust", "game", "3d"]);
262    }
263
264    #[test]
265    fn test_format_points() {
266        assert_eq!(format_points(0), "0");
267        assert_eq!(format_points(999), "999");
268        assert_eq!(format_points(1000), "1,000");
269        assert_eq!(format_points(1_000_000), "1,000,000");
270    }
271
272    #[test]
273    fn test_sanitize_string() {
274        assert_eq!(sanitize_string("hello world"), "hello world");
275        assert_eq!(sanitize_string("hello\nworld"), "hello\nworld");
276        assert_eq!(sanitize_string("hello\x00world"), "helloworld");
277    }
278
279    #[test]
280    fn test_normalize_tag() {
281        assert_eq!(normalize_tag("  Hello World  "), "helloworld");
282        assert_eq!(normalize_tag("Rust-2024"), "rust-2024");
283        assert_eq!(normalize_tag("Tag_Name"), "tag_name");
284        assert_eq!(normalize_tag("Tag@#$Name"), "tagname");
285    }
286
287    #[test]
288    fn test_format_bandwidth() {
289        assert_eq!(format_bandwidth(500), "500 bps");
290        assert_eq!(format_bandwidth(10_000), "10.00 Kbps");
291        assert_eq!(format_bandwidth(10_000_000), "10.00 Mbps");
292        assert_eq!(format_bandwidth(1_000_000_000), "1.00 Gbps");
293    }
294
295    #[test]
296    fn test_format_ratio_as_percentage() {
297        assert_eq!(format_ratio_as_percentage(1, 4), "25.00%");
298        assert_eq!(format_ratio_as_percentage(3, 4), "75.00%");
299        assert_eq!(format_ratio_as_percentage(5, 0), "N/A");
300    }
301
302    #[test]
303    fn test_generate_slug() {
304        assert_eq!(generate_slug("Hello World", 100), "hello-world");
305        assert_eq!(generate_slug("Rust Programming!", 100), "rust-programming");
306        assert_eq!(
307            generate_slug("Test_With_Underscores", 100),
308            "test-with-underscores"
309        );
310        assert_eq!(generate_slug("  Trim   Spaces  ", 100), "trim-spaces");
311        assert_eq!(
312            generate_slug("Remove@Special#Chars$", 100),
313            "removespecialchars"
314        );
315        assert_eq!(generate_slug("Multiple---Hyphens", 100), "multiple-hyphens");
316        assert_eq!(
317            generate_slug("Very Long Title That Exceeds Maximum Length", 20),
318            "very-long-title-that"
319        );
320        assert_eq!(
321            generate_slug("---Leading-Trailing---", 100),
322            "leading-trailing"
323        );
324    }
325
326    #[test]
327    fn test_cid_to_short_id() {
328        assert_eq!(cid_to_short_id("QmExampleCID123456"), "ExampleC");
329        assert_eq!(cid_to_short_id("bafybeigdyrzt"), "bafybeig");
330        assert_eq!(cid_to_short_id("short"), "short");
331        assert_eq!(cid_to_short_id("QmTest"), "QmTest");
332    }
333}