use serde_json::{Map, Value};
use crate::error::RenderError;
pub fn rgb_to_ansi256((r, g, b): (u8, u8, u8)) -> u8 {
if r == g && g == b {
if r < 8 {
16
} else if r > 248 {
231
} else {
232 + ((r as u16 - 8) * 24 / 247) as u8
}
} else {
let red = (r as u16 * 5 / 255) as u8;
let green = (g as u16 * 5 / 255) as u8;
let blue = (b as u16 * 5 / 255) as u8;
16 + 36 * red + 6 * green + blue
}
}
pub fn rgb_to_truecolor(rgb: (u8, u8, u8)) -> (u8, u8, u8) {
rgb
}
pub fn truncate_to_width(s: &str, max_width: usize) -> String {
truncate_to_width_with_policy(s, max_width, crate::AmbiguousWidth::Narrow)
}
pub fn truncate_to_width_with_policy(
s: &str,
max_width: usize,
policy: crate::AmbiguousWidth,
) -> String {
let calculator = crate::WidthCalculator::new(policy);
if max_width == 0 && calculator.visible_width(s) > 0 {
return "…".to_string();
}
calculator.truncate_visible(s, max_width, "…", crate::width::VisibleTruncateAt::End)
}
pub fn csv_records(value: &Value) -> Result<(Vec<String>, Vec<Vec<String>>), RenderError> {
let records = match value {
Value::Array(items) => items
.iter()
.enumerate()
.map(|(index, item)| flat_record(item, &format!("[{index}]")))
.collect::<Result<Vec<_>, _>>()?,
record => vec![flat_record(record, "")?],
};
let mut headers: Vec<String> = Vec::new();
for record in &records {
for key in record.keys() {
if !headers.contains(key) {
headers.push(key.clone());
}
}
}
let rows = records
.iter()
.map(|record| {
headers
.iter()
.map(|header| record.get(header).map(scalar_cell).unwrap_or_default())
.collect()
})
.collect();
Ok((headers, rows))
}
pub fn write_csv(value: &Value) -> Result<String, RenderError> {
let (headers, rows) = csv_records(value)?;
let mut writer = csv::Writer::from_writer(Vec::new());
if !headers.is_empty() {
writer.write_record(&headers)?;
}
for row in rows {
writer.write_record(&row)?;
}
Ok(String::from_utf8(writer.into_inner()?)?)
}
fn flat_record<'a>(value: &'a Value, at: &str) -> Result<&'a Map<String, Value>, RenderError> {
let Value::Object(record) = value else {
return Err(not_a_flat_record(at, value));
};
for (key, field) in record {
if matches!(field, Value::Array(_) | Value::Object(_)) {
let path = if at.is_empty() {
key.clone()
} else {
format!("{at}.{key}")
};
return Err(not_a_flat_record(&path, field));
}
}
Ok(record)
}
fn not_a_flat_record(path: &str, value: &Value) -> RenderError {
let kind = match value {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Object(_) => "an object",
};
let subject = if path.is_empty() {
"the document".to_string()
} else {
format!("`{path}`")
};
RenderError::SerializationError(format!(
"CSV output takes a flat record or an array of flat records, and {subject} is {kind}; \
declare the columns with a CsvProjection"
))
}
fn scalar_cell(value: &Value) -> String {
match value {
Value::Null => String::new(),
Value::String(text) => text.clone(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rgb_to_ansi256_grayscale() {
assert_eq!(rgb_to_ansi256((0, 0, 0)), 16);
assert_eq!(rgb_to_ansi256((255, 255, 255)), 231);
let mid = rgb_to_ansi256((128, 128, 128));
assert!((232..=255).contains(&mid));
}
#[test]
fn test_rgb_to_ansi256_color_cube() {
assert_eq!(rgb_to_ansi256((255, 0, 0)), 196);
assert_eq!(rgb_to_ansi256((0, 255, 0)), 46);
assert_eq!(rgb_to_ansi256((0, 0, 255)), 21);
}
#[test]
fn test_truncate_to_width_no_truncation() {
assert_eq!(truncate_to_width("Hello", 10), "Hello");
assert_eq!(truncate_to_width("Hello", 5), "Hello");
}
#[test]
fn test_truncate_to_width_with_truncation() {
assert_eq!(truncate_to_width("Hello World", 6), "Hello…");
assert_eq!(truncate_to_width("Hello World", 7), "Hello …");
}
#[test]
fn test_truncate_to_width_empty() {
assert_eq!(truncate_to_width("", 5), "");
}
#[test]
fn test_truncate_to_width_exact_fit() {
assert_eq!(truncate_to_width("12345", 5), "12345");
}
#[test]
fn test_truncate_to_width_one_over() {
assert_eq!(truncate_to_width("123456", 5), "1234…");
}
#[test]
fn test_truncate_to_width_zero_width() {
assert_eq!(truncate_to_width("Hello", 0), "…");
}
#[test]
fn test_truncate_to_width_one_width() {
assert_eq!(truncate_to_width("Hello", 1), "…");
}
#[test]
fn test_truncate_to_width_preserves_semantic_style() {
assert_eq!(
truncate_to_width("[match]Hello World[/match]", 6),
"[match]Hello[/match]…"
);
}
#[test]
fn csv_records_keeps_a_records_declared_column_order() {
let data = serde_json::json!({"name": "Alice", "age": 30, "admin": true, "note": null});
let (headers, rows) = csv_records(&data).unwrap();
assert_eq!(headers, vec!["name", "age", "admin", "note"]);
assert_eq!(rows, vec![vec!["Alice", "30", "true", ""]]);
}
#[test]
fn csv_records_unions_the_columns_of_an_array_of_records() {
let data = serde_json::json!([
{"name": "Alice", "age": 30},
{"name": "Bob", "email": "bob@example.com"}
]);
let (headers, rows) = csv_records(&data).unwrap();
assert_eq!(headers, vec!["name", "age", "email"]);
assert_eq!(rows[0], vec!["Alice", "30", ""]);
assert_eq!(rows[1], vec!["Bob", "", "bob@example.com"]);
}
#[test]
fn csv_records_accepts_an_empty_array_as_a_headerless_document() {
let (headers, rows) = csv_records(&serde_json::json!([])).unwrap();
assert!(headers.is_empty());
assert!(rows.is_empty());
assert_eq!(write_csv(&serde_json::json!([])).unwrap(), "");
}
#[test]
fn write_csv_quotes_cells_the_way_a_reader_expects() {
let data = serde_json::json!([
{"name": "a, \"quoted\"", "count": 2},
{"name": "plain", "count": null}
]);
assert_eq!(
write_csv(&data).unwrap(),
"name,count\n\"a, \"\"quoted\"\"\",2\nplain,\n"
);
}
#[test]
fn a_nested_value_is_a_render_error_naming_csv_projection() {
let cases = [
(
serde_json::json!({"name": "Alice", "tags": ["a"]}),
"`tags` is an array",
),
(
serde_json::json!({"user": {"name": "Bob"}}),
"`user` is an object",
),
(
serde_json::json!([{"name": "x"}, {"name": "y", "items": [1]}]),
"`[1].items` is an array",
),
(serde_json::json!([{"ok": 1}, 2]), "`[1]` is a number"),
(serde_json::json!(42), "the document is a number"),
(serde_json::json!("text"), "the document is a string"),
(serde_json::json!(null), "the document is null"),
];
for (data, subject) in cases {
let error = csv_records(&data).unwrap_err().to_string();
assert!(error.contains(subject), "{data}: {error}");
assert!(error.contains("CsvProjection"), "{data}: {error}");
}
}
}