standout_render/
util.rs

1//! Utility functions for text processing and color conversion.
2
3use serde_json::Value;
4use std::collections::{BTreeMap, BTreeSet};
5
6/// Converts an RGB triplet to the nearest ANSI 256-color palette index.
7///
8/// # Example
9///
10/// ```rust
11/// use standout::rgb_to_ansi256;
12///
13/// // Pure red maps to ANSI 196
14/// assert_eq!(rgb_to_ansi256((255, 0, 0)), 196);
15///
16/// // Pure green maps to ANSI 46
17/// assert_eq!(rgb_to_ansi256((0, 255, 0)), 46);
18/// ```
19pub fn rgb_to_ansi256((r, g, b): (u8, u8, u8)) -> u8 {
20    if r == g && g == b {
21        if r < 8 {
22            16
23        } else if r > 248 {
24            231
25        } else {
26            232 + ((r as u16 - 8) * 24 / 247) as u8
27        }
28    } else {
29        let red = (r as u16 * 5 / 255) as u8;
30        let green = (g as u16 * 5 / 255) as u8;
31        let blue = (b as u16 * 5 / 255) as u8;
32        16 + 36 * red + 6 * green + blue
33    }
34}
35
36/// Placeholder helper for true-color output.
37///
38/// Currently returns the RGB triplet unchanged so it can be handed
39/// to future true-color aware APIs.
40pub fn rgb_to_truecolor(rgb: (u8, u8, u8)) -> (u8, u8, u8) {
41    rgb
42}
43
44/// Truncates a string to fit within a maximum display width, adding ellipsis if needed.
45///
46/// Uses Unicode width calculations for proper handling of CJK and other wide characters.
47/// If the string fits within `max_width`, it is returned unchanged. If truncation is
48/// needed, characters are removed from the end and replaced with `…` (ellipsis).
49///
50/// # Arguments
51///
52/// * `s` - The string to truncate
53/// * `max_width` - Maximum display width (in terminal columns)
54///
55/// # Example
56///
57/// ```rust
58/// use standout::truncate_to_width;
59///
60/// assert_eq!(truncate_to_width("Hello", 10), "Hello");
61/// assert_eq!(truncate_to_width("Hello World", 6), "Hello…");
62/// ```
63pub fn truncate_to_width(s: &str, max_width: usize) -> String {
64    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
65
66    // If the string fits, return it unchanged
67    if s.width() <= max_width {
68        return s.to_string();
69    }
70
71    let mut result = String::new();
72    let mut current_width = 0;
73    // Reserve 1 char for ellipsis
74    let limit = max_width.saturating_sub(1);
75
76    for c in s.chars() {
77        let char_width = c.width().unwrap_or(0);
78        if current_width + char_width > limit {
79            result.push('…');
80            return result;
81        }
82        result.push(c);
83        current_width += char_width;
84    }
85
86    result
87}
88
89/// Flattens a JSON Value into a list of records for CSV export.
90///
91/// Returns a tuple of `(headers, rows)`, where rows are vectors of strings corresponding to headers.
92///
93/// - If `value` is an Array, each element becomes a row.
94/// - If `value` is an Object, it becomes a single row.
95/// - Nested objects are flattened with dot notation.
96/// - Arrays inside objects are serialized as JSON strings.
97pub fn flatten_json_for_csv(value: &Value) -> (Vec<String>, Vec<Vec<String>>) {
98    let mut rows: Vec<BTreeMap<String, String>> = Vec::new();
99
100    match value {
101        Value::Array(arr) => {
102            for item in arr {
103                rows.push(flatten_single_item(item));
104            }
105        }
106        _ => {
107            rows.push(flatten_single_item(value));
108        }
109    }
110
111    // Collect all unique keys
112    let mut headers_set = BTreeSet::new();
113    for row in &rows {
114        for key in row.keys() {
115            headers_set.insert(key.clone());
116        }
117    }
118    let headers: Vec<String> = headers_set.into_iter().collect();
119
120    // Map rows to value lists based on headers
121    let mut data = Vec::new();
122    for row in rows {
123        let mut row_data = Vec::new();
124        for header in &headers {
125            row_data.push(row.get(header).cloned().unwrap_or_default());
126        }
127        data.push(row_data);
128    }
129
130    (headers, data)
131}
132
133fn flatten_single_item(value: &Value) -> BTreeMap<String, String> {
134    let mut acc = BTreeMap::new();
135    flatten_recursive(value, "", &mut acc);
136    acc
137}
138
139fn flatten_recursive(value: &Value, prefix: &str, acc: &mut BTreeMap<String, String>) {
140    match value {
141        Value::Null => {}
142        Value::Bool(b) => {
143            let key = if prefix.is_empty() { "value" } else { prefix };
144            acc.insert(key.to_string(), b.to_string());
145        }
146        Value::Number(n) => {
147            let key = if prefix.is_empty() { "value" } else { prefix };
148            acc.insert(key.to_string(), n.to_string());
149        }
150        Value::String(s) => {
151            let key = if prefix.is_empty() { "value" } else { prefix };
152            acc.insert(key.to_string(), s.clone());
153        }
154        Value::Array(_) => {
155            // Serialize array as JSON string
156            let key = if prefix.is_empty() { "value" } else { prefix };
157            acc.insert(key.to_string(), value.to_string());
158        }
159        Value::Object(map) => {
160            if map.is_empty() {
161                let key = if prefix.is_empty() { "value" } else { prefix };
162                acc.insert(key.to_string(), "{}".to_string());
163            } else {
164                for (k, v) in map {
165                    let new_key = if prefix.is_empty() {
166                        k.clone()
167                    } else {
168                        format!("{}.{}", prefix, k)
169                    };
170                    flatten_recursive(v, &new_key, acc);
171                }
172            }
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_rgb_to_ansi256_grayscale() {
183        assert_eq!(rgb_to_ansi256((0, 0, 0)), 16);
184        assert_eq!(rgb_to_ansi256((255, 255, 255)), 231);
185        let mid = rgb_to_ansi256((128, 128, 128));
186        assert!((232..=255).contains(&mid));
187    }
188
189    #[test]
190    fn test_rgb_to_ansi256_color_cube() {
191        assert_eq!(rgb_to_ansi256((255, 0, 0)), 196);
192        assert_eq!(rgb_to_ansi256((0, 255, 0)), 46);
193        assert_eq!(rgb_to_ansi256((0, 0, 255)), 21);
194    }
195
196    #[test]
197    fn test_truncate_to_width_no_truncation() {
198        assert_eq!(truncate_to_width("Hello", 10), "Hello");
199        assert_eq!(truncate_to_width("Hello", 5), "Hello");
200    }
201
202    #[test]
203    fn test_truncate_to_width_with_truncation() {
204        assert_eq!(truncate_to_width("Hello World", 6), "Hello…");
205        assert_eq!(truncate_to_width("Hello World", 7), "Hello …");
206    }
207
208    #[test]
209    fn test_truncate_to_width_empty() {
210        assert_eq!(truncate_to_width("", 5), "");
211    }
212
213    #[test]
214    fn test_truncate_to_width_exact_fit() {
215        assert_eq!(truncate_to_width("12345", 5), "12345");
216    }
217
218    #[test]
219    fn test_truncate_to_width_one_over() {
220        assert_eq!(truncate_to_width("123456", 5), "1234…");
221    }
222
223    #[test]
224    fn test_truncate_to_width_zero_width() {
225        assert_eq!(truncate_to_width("Hello", 0), "…");
226    }
227
228    #[test]
229    fn test_truncate_to_width_one_width() {
230        assert_eq!(truncate_to_width("Hello", 1), "…");
231    }
232}