Skip to main content

standout_render/
util.rs

1//! Utility functions for text processing and color conversion.
2
3use serde::Serialize;
4use serde_json::Value;
5use std::collections::{BTreeMap, BTreeSet};
6
7/// Converts an RGB triplet to the nearest ANSI 256-color palette index.
8///
9/// # Example
10///
11/// ```rust
12/// use standout_render::rgb_to_ansi256;
13///
14/// // Pure red maps to ANSI 196
15/// assert_eq!(rgb_to_ansi256((255, 0, 0)), 196);
16///
17/// // Pure green maps to ANSI 46
18/// assert_eq!(rgb_to_ansi256((0, 255, 0)), 46);
19/// ```
20pub fn rgb_to_ansi256((r, g, b): (u8, u8, u8)) -> u8 {
21    if r == g && g == b {
22        if r < 8 {
23            16
24        } else if r > 248 {
25            231
26        } else {
27            232 + ((r as u16 - 8) * 24 / 247) as u8
28        }
29    } else {
30        let red = (r as u16 * 5 / 255) as u8;
31        let green = (g as u16 * 5 / 255) as u8;
32        let blue = (b as u16 * 5 / 255) as u8;
33        16 + 36 * red + 6 * green + blue
34    }
35}
36
37/// Placeholder helper for true-color output.
38///
39/// Currently returns the RGB triplet unchanged so it can be handed
40/// to future true-color aware APIs.
41pub fn rgb_to_truecolor(rgb: (u8, u8, u8)) -> (u8, u8, u8) {
42    rgb
43}
44
45/// Truncates a string to fit within a maximum display width, adding ellipsis if needed.
46///
47/// Uses Unicode width calculations for proper handling of CJK and other wide characters.
48/// If the string fits within `max_width`, it is returned unchanged. If truncation is
49/// needed, characters are removed from the end and replaced with `…` (ellipsis).
50///
51/// # Arguments
52///
53/// * `s` - The string to truncate
54/// * `max_width` - Maximum display width (in terminal columns)
55///
56/// # Example
57///
58/// ```rust
59/// use standout_render::truncate_to_width;
60///
61/// assert_eq!(truncate_to_width("Hello", 10), "Hello");
62/// assert_eq!(truncate_to_width("Hello World", 6), "Hello…");
63/// ```
64pub fn truncate_to_width(s: &str, max_width: usize) -> String {
65    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
66
67    // If the string fits, return it unchanged
68    if s.width() <= max_width {
69        return s.to_string();
70    }
71
72    let mut result = String::new();
73    let mut current_width = 0;
74    // Reserve 1 char for ellipsis
75    let limit = max_width.saturating_sub(1);
76
77    for c in s.chars() {
78        let char_width = c.width().unwrap_or(0);
79        if current_width + char_width > limit {
80            result.push('…');
81            return result;
82        }
83        result.push(c);
84        current_width += char_width;
85    }
86
87    result
88}
89
90/// Serializes data to XML, handling all serializable types.
91///
92/// Named structs serialize directly (using the struct name as root element).
93/// Map-like types are wrapped in a `<data>` root tag with keys sanitized to
94/// valid XML element names. Primitive types (strings, numbers, booleans) are
95/// wrapped as `<data><value>...</value></data>`. Null values produce an empty
96/// `<data/>` element.
97pub fn serialize_to_xml<T: Serialize + ?Sized>(data: &T) -> Result<String, quick_xml::DeError> {
98    // Direct serialization works for named structs (keys are known valid)
99    if let Ok(xml) = quick_xml::se::to_string(data) {
100        return Ok(xml);
101    }
102    // For types that need a root element (maps, primitives, arrays),
103    // convert to JSON Value, sanitize keys, and serialize with root tag
104    let value = serde_json::to_value(data).unwrap_or(serde_json::Value::Null);
105    let sanitized = sanitize_xml_keys(&value);
106    match sanitized {
107        serde_json::Value::Object(_) => quick_xml::se::to_string_with_root("data", &sanitized),
108        serde_json::Value::Null => quick_xml::se::to_string_with_root(
109            "data",
110            &serde_json::Value::Object(serde_json::Map::new()),
111        ),
112        other => {
113            let mut map = serde_json::Map::new();
114            map.insert("value".to_string(), other);
115            quick_xml::se::to_string_with_root("data", &serde_json::Value::Object(map))
116        }
117    }
118}
119
120/// Recursively sanitizes JSON object keys to be valid XML element names.
121fn sanitize_xml_keys(value: &serde_json::Value) -> serde_json::Value {
122    match value {
123        serde_json::Value::Object(map) => {
124            let mut new_map = serde_json::Map::new();
125            for (key, val) in map {
126                let safe_key = sanitize_xml_name(key);
127                new_map.insert(safe_key, sanitize_xml_keys(val));
128            }
129            serde_json::Value::Object(new_map)
130        }
131        serde_json::Value::Array(arr) => {
132            serde_json::Value::Array(arr.iter().map(sanitize_xml_keys).collect())
133        }
134        other => other.clone(),
135    }
136}
137
138/// Ensures a string is a valid XML element name.
139///
140/// XML names must start with a letter or underscore. Subsequent characters
141/// may be letters, digits, hyphens, underscores, or periods. Invalid
142/// characters are replaced with underscores.
143fn sanitize_xml_name(name: &str) -> String {
144    if name.is_empty() {
145        return "_".to_string();
146    }
147    let mut result = String::with_capacity(name.len() + 1);
148    for (i, c) in name.chars().enumerate() {
149        if i == 0 {
150            if c.is_ascii_alphabetic() || c == '_' {
151                result.push(c);
152            } else {
153                result.push('_');
154                if c.is_ascii_alphanumeric() {
155                    result.push(c);
156                }
157            }
158        } else if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' {
159            result.push(c);
160        } else {
161            result.push('_');
162        }
163    }
164    result
165}
166
167/// Flattens a JSON Value into a list of records for CSV export.
168///
169/// Returns a tuple of `(headers, rows)`, where rows are vectors of strings corresponding to headers.
170///
171/// - If `value` is an Array, each element becomes a row.
172/// - If `value` is an Object, it becomes a single row.
173/// - Nested objects are flattened with dot notation.
174/// - Arrays inside objects are flattened with indexed keys (e.g., items.0, items.1).
175pub fn flatten_json_for_csv(value: &Value) -> (Vec<String>, Vec<Vec<String>>) {
176    let mut rows: Vec<BTreeMap<String, String>> = Vec::new();
177
178    match value {
179        Value::Array(arr) => {
180            for item in arr {
181                rows.push(flatten_single_item(item));
182            }
183        }
184        _ => {
185            rows.push(flatten_single_item(value));
186        }
187    }
188
189    // Collect all unique keys
190    let mut headers_set = BTreeSet::new();
191    for row in &rows {
192        for key in row.keys() {
193            headers_set.insert(key.clone());
194        }
195    }
196    let headers: Vec<String> = headers_set.into_iter().collect();
197
198    // Map rows to value lists based on headers
199    let mut data = Vec::new();
200    for row in rows {
201        let mut row_data = Vec::new();
202        for header in &headers {
203            row_data.push(row.get(header).cloned().unwrap_or_default());
204        }
205        data.push(row_data);
206    }
207
208    (headers, data)
209}
210
211fn flatten_single_item(value: &Value) -> BTreeMap<String, String> {
212    let mut acc = BTreeMap::new();
213    flatten_recursive(value, "", &mut acc);
214    acc
215}
216
217fn flatten_recursive(value: &Value, prefix: &str, acc: &mut BTreeMap<String, String>) {
218    match value {
219        Value::Null => {}
220        Value::Bool(b) => {
221            let key = if prefix.is_empty() { "value" } else { prefix };
222            acc.insert(key.to_string(), b.to_string());
223        }
224        Value::Number(n) => {
225            let key = if prefix.is_empty() { "value" } else { prefix };
226            acc.insert(key.to_string(), n.to_string());
227        }
228        Value::String(s) => {
229            let key = if prefix.is_empty() { "value" } else { prefix };
230            acc.insert(key.to_string(), s.clone());
231        }
232        Value::Array(arr) => {
233            let key_prefix = if prefix.is_empty() { "value" } else { prefix };
234            for (i, item) in arr.iter().enumerate() {
235                let indexed_key = format!("{}.{}", key_prefix, i);
236                flatten_recursive(item, &indexed_key, acc);
237            }
238        }
239        Value::Object(map) => {
240            if map.is_empty() {
241                let key = if prefix.is_empty() { "value" } else { prefix };
242                acc.insert(key.to_string(), "{}".to_string());
243            } else {
244                for (k, v) in map {
245                    let new_key = if prefix.is_empty() {
246                        k.clone()
247                    } else {
248                        format!("{}.{}", prefix, k)
249                    };
250                    flatten_recursive(v, &new_key, acc);
251                }
252            }
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn test_rgb_to_ansi256_grayscale() {
263        assert_eq!(rgb_to_ansi256((0, 0, 0)), 16);
264        assert_eq!(rgb_to_ansi256((255, 255, 255)), 231);
265        let mid = rgb_to_ansi256((128, 128, 128));
266        assert!((232..=255).contains(&mid));
267    }
268
269    #[test]
270    fn test_rgb_to_ansi256_color_cube() {
271        assert_eq!(rgb_to_ansi256((255, 0, 0)), 196);
272        assert_eq!(rgb_to_ansi256((0, 255, 0)), 46);
273        assert_eq!(rgb_to_ansi256((0, 0, 255)), 21);
274    }
275
276    #[test]
277    fn test_truncate_to_width_no_truncation() {
278        assert_eq!(truncate_to_width("Hello", 10), "Hello");
279        assert_eq!(truncate_to_width("Hello", 5), "Hello");
280    }
281
282    #[test]
283    fn test_truncate_to_width_with_truncation() {
284        assert_eq!(truncate_to_width("Hello World", 6), "Hello…");
285        assert_eq!(truncate_to_width("Hello World", 7), "Hello …");
286    }
287
288    #[test]
289    fn test_truncate_to_width_empty() {
290        assert_eq!(truncate_to_width("", 5), "");
291    }
292
293    #[test]
294    fn test_truncate_to_width_exact_fit() {
295        assert_eq!(truncate_to_width("12345", 5), "12345");
296    }
297
298    #[test]
299    fn test_truncate_to_width_one_over() {
300        assert_eq!(truncate_to_width("123456", 5), "1234…");
301    }
302
303    #[test]
304    fn test_truncate_to_width_zero_width() {
305        assert_eq!(truncate_to_width("Hello", 0), "…");
306    }
307
308    #[test]
309    fn test_truncate_to_width_one_width() {
310        assert_eq!(truncate_to_width("Hello", 1), "…");
311    }
312
313    #[test]
314    fn test_serialize_to_xml_named_struct() {
315        #[derive(serde::Serialize)]
316        struct User {
317            name: String,
318            age: u32,
319        }
320
321        let data = User {
322            name: "Alice".into(),
323            age: 30,
324        };
325        let xml = serialize_to_xml(&data).unwrap();
326        assert!(xml.contains("<User>"));
327        assert!(xml.contains("<name>Alice</name>"));
328        assert!(xml.contains("<age>30</age>"));
329    }
330
331    #[test]
332    fn test_serialize_to_xml_json_object() {
333        let data = serde_json::json!({"name": "test", "count": 42});
334        let xml = serialize_to_xml(&data).unwrap();
335        assert!(xml.contains("<data>"));
336        assert!(xml.contains("<name>test</name>"));
337        assert!(xml.contains("<count>42</count>"));
338    }
339
340    #[test]
341    fn test_serialize_to_xml_nested_object() {
342        let data = serde_json::json!({"user": {"name": "Bob", "age": 25}});
343        let xml = serialize_to_xml(&data).unwrap();
344        assert!(xml.contains("<data>"));
345        assert!(xml.contains("<user>"));
346        assert!(xml.contains("<name>Bob</name>"));
347    }
348
349    #[test]
350    fn test_serialize_to_xml_with_array_field() {
351        let data = serde_json::json!({"tags": ["a", "b", "c"]});
352        let xml = serialize_to_xml(&data).unwrap();
353        assert!(xml.contains("<data>"));
354        assert!(xml.contains("<tags>a</tags>"));
355    }
356
357    #[test]
358    fn test_serialize_to_xml_empty_object() {
359        let data = serde_json::json!({});
360        let xml = serialize_to_xml(&data).unwrap();
361        assert!(xml.contains("<data"));
362    }
363
364    #[test]
365    fn test_serialize_to_xml_hashmap() {
366        let mut data = std::collections::HashMap::new();
367        data.insert("key", "value");
368        let xml = serialize_to_xml(&data).unwrap();
369        assert!(xml.contains("<data>"));
370        assert!(xml.contains("<key>value</key>"));
371    }
372
373    #[test]
374    fn test_serialize_to_xml_null() {
375        let data = serde_json::Value::Null;
376        let xml = serialize_to_xml(&data).unwrap();
377        assert!(xml.contains("<data"));
378    }
379
380    #[test]
381    fn test_serialize_to_xml_bare_string() {
382        let data = serde_json::json!("hello");
383        let xml = serialize_to_xml(&data).unwrap();
384        assert!(xml.contains("<data>"));
385        assert!(xml.contains("<value>hello</value>"));
386    }
387
388    #[test]
389    fn test_serialize_to_xml_bare_number() {
390        let data = serde_json::json!(42);
391        let xml = serialize_to_xml(&data).unwrap();
392        assert!(xml.contains("<data>"));
393        assert!(xml.contains("<value>42</value>"));
394    }
395
396    #[test]
397    fn test_serialize_to_xml_bare_bool() {
398        let data = serde_json::json!(true);
399        let xml = serialize_to_xml(&data).unwrap();
400        assert!(xml.contains("<data>"));
401        assert!(xml.contains("<value>true</value>"));
402    }
403
404    #[test]
405    fn test_serialize_to_xml_bare_array() {
406        let data = serde_json::json!(["a", "b", "c"]);
407        let xml = serialize_to_xml(&data).unwrap();
408        assert!(xml.contains("<data>"));
409        assert!(xml.contains("<value>"));
410    }
411
412    #[test]
413    fn test_serialize_to_xml_numeric_keys() {
414        let data = serde_json::json!({"0": "zero", "1": "one"});
415        let xml = serialize_to_xml(&data).unwrap();
416        assert!(xml.contains("<data>"));
417        // Keys starting with digits get prefixed with underscore
418        assert!(xml.contains("<_0>zero</_0>"));
419        assert!(xml.contains("<_1>one</_1>"));
420    }
421
422    #[test]
423    fn test_sanitize_xml_name_valid() {
424        assert_eq!(sanitize_xml_name("name"), "name");
425        assert_eq!(sanitize_xml_name("_private"), "_private");
426        assert_eq!(sanitize_xml_name("item-1"), "item-1");
427        assert_eq!(sanitize_xml_name("a.b"), "a.b");
428    }
429
430    #[test]
431    fn test_sanitize_xml_name_digit_start() {
432        assert_eq!(sanitize_xml_name("0"), "_0");
433        assert_eq!(sanitize_xml_name("1abc"), "_1abc");
434        assert_eq!(sanitize_xml_name("42"), "_42");
435    }
436
437    #[test]
438    fn test_sanitize_xml_name_empty() {
439        assert_eq!(sanitize_xml_name(""), "_");
440    }
441
442    #[test]
443    fn test_sanitize_xml_name_special_chars() {
444        assert_eq!(sanitize_xml_name("a b"), "a_b");
445        assert_eq!(sanitize_xml_name("a@b"), "a_b");
446    }
447
448    // =========================================================================
449    // flatten_json_for_csv tests
450    // =========================================================================
451
452    #[test]
453    fn test_flatten_csv_simple_object() {
454        let data = serde_json::json!({"name": "Alice", "age": 30});
455        let (headers, rows) = flatten_json_for_csv(&data);
456        assert_eq!(headers, vec!["age", "name"]);
457        assert_eq!(rows, vec![vec!["30", "Alice"]]);
458    }
459
460    #[test]
461    fn test_flatten_csv_array_of_objects() {
462        let data = serde_json::json!([
463            {"name": "Alice", "age": 30},
464            {"name": "Bob", "age": 25}
465        ]);
466        let (headers, rows) = flatten_json_for_csv(&data);
467        assert_eq!(headers, vec!["age", "name"]);
468        assert_eq!(rows.len(), 2);
469        assert_eq!(rows[0], vec!["30", "Alice"]);
470        assert_eq!(rows[1], vec!["25", "Bob"]);
471    }
472
473    #[test]
474    fn test_flatten_csv_nested_objects() {
475        let data = serde_json::json!({"user": {"name": "Alice", "age": 30}});
476        let (headers, rows) = flatten_json_for_csv(&data);
477        assert_eq!(headers, vec!["user.age", "user.name"]);
478        assert_eq!(rows, vec![vec!["30", "Alice"]]);
479    }
480
481    #[test]
482    fn test_flatten_csv_array_field() {
483        let data = serde_json::json!({"name": "Alice", "tags": ["a", "b", "c"]});
484        let (headers, rows) = flatten_json_for_csv(&data);
485        assert!(headers.contains(&"name".to_string()));
486        assert!(headers.contains(&"tags.0".to_string()));
487        assert!(headers.contains(&"tags.1".to_string()));
488        assert!(headers.contains(&"tags.2".to_string()));
489        // Check values
490        let name_idx = headers.iter().position(|h| h == "name").unwrap();
491        let t0_idx = headers.iter().position(|h| h == "tags.0").unwrap();
492        let t1_idx = headers.iter().position(|h| h == "tags.1").unwrap();
493        let t2_idx = headers.iter().position(|h| h == "tags.2").unwrap();
494        assert_eq!(rows[0][name_idx], "Alice");
495        assert_eq!(rows[0][t0_idx], "a");
496        assert_eq!(rows[0][t1_idx], "b");
497        assert_eq!(rows[0][t2_idx], "c");
498    }
499
500    #[test]
501    fn test_flatten_csv_nested_array_of_objects() {
502        let data = serde_json::json!({
503            "items": [
504                {"name": "x", "value": 1},
505                {"name": "y", "value": 2}
506            ]
507        });
508        let (headers, rows) = flatten_json_for_csv(&data);
509        assert!(headers.contains(&"items.0.name".to_string()));
510        assert!(headers.contains(&"items.0.value".to_string()));
511        assert!(headers.contains(&"items.1.name".to_string()));
512        assert!(headers.contains(&"items.1.value".to_string()));
513    }
514
515    #[test]
516    fn test_flatten_csv_empty_array_field() {
517        let data = serde_json::json!({"name": "Alice", "tags": []});
518        let (headers, rows) = flatten_json_for_csv(&data);
519        assert_eq!(headers, vec!["name"]);
520        assert_eq!(rows, vec![vec!["Alice"]]);
521    }
522
523    #[test]
524    fn test_flatten_csv_mixed_array_rows() {
525        // Array of objects where some have arrays and some don't
526        let data = serde_json::json!([
527            {"name": "Alice", "tags": ["x"]},
528            {"name": "Bob"}
529        ]);
530        let (headers, rows) = flatten_json_for_csv(&data);
531        assert!(headers.contains(&"name".to_string()));
532        assert!(headers.contains(&"tags.0".to_string()));
533        assert_eq!(rows.len(), 2);
534        // Bob's tags.0 should be empty
535        let t0_idx = headers.iter().position(|h| h == "tags.0").unwrap();
536        assert_eq!(rows[1][t0_idx], "");
537    }
538
539    #[test]
540    fn test_flatten_csv_bare_primitive() {
541        let data = serde_json::json!(42);
542        let (headers, rows) = flatten_json_for_csv(&data);
543        assert_eq!(headers, vec!["value"]);
544        assert_eq!(rows, vec![vec!["42"]]);
545    }
546}