use miette::miette;
use mq_lang::RuntimeValue;
#[cfg(test)]
use mq_lang::Shared;
use mq_markdown::ColorTheme;
use std::borrow::Cow;
use std::collections::BTreeSet;
fn cell_value(value: &RuntimeValue) -> String {
match value {
RuntimeValue::Dict(_) | RuntimeValue::Array(_) => {
serde_json::to_string(&value.clone().to_json_value()).unwrap_or_default()
}
RuntimeValue::Markdown(node, _) => node.to_string(),
_ => value.to_string(),
}
}
fn paint(text: String, pair: &(Cow<'_, str>, Cow<'_, str>)) -> String {
if text.is_empty() {
text
} else {
format!("{}{}{}", pair.0, text, pair.1)
}
}
fn data_cell(value: &RuntimeValue, theme: Option<&ColorTheme<'_>>) -> String {
let text = cell_value(value);
match (theme, value) {
(Some(theme), RuntimeValue::Number(_)) => paint(text, &theme.emphasis),
(Some(theme), RuntimeValue::Boolean(_)) => paint(text, &theme.link_url),
_ => text,
}
}
fn header_cell(name: &str, theme: Option<&ColorTheme<'_>>) -> String {
match theme {
Some(theme) => paint(name.to_string(), &theme.heading),
None => name.to_string(),
}
}
pub(crate) fn runtime_values_to_csv(
runtime_values: &[RuntimeValue],
theme: Option<&ColorTheme<'_>>,
) -> miette::Result<String> {
let non_none: Vec<&RuntimeValue> = runtime_values.iter().filter(|v| !v.is_none()).collect();
let expanded: Option<Vec<&RuntimeValue>> = if let [RuntimeValue::Array(items)] = non_none.as_slice() {
Some(items.iter().collect())
} else {
None
};
let candidates: &[&RuntimeValue] = expanded.as_deref().unwrap_or(&non_none);
let mut writer = csv::WriterBuilder::new().from_writer(vec![]);
let all_dicts = !candidates.is_empty() && candidates.iter().all(|v| matches!(*v, RuntimeValue::Dict(_)));
let all_arrays = !candidates.is_empty() && candidates.iter().all(|v| matches!(*v, RuntimeValue::Array(_)));
let write_err = |e: csv::Error| miette!("Failed to write CSV record: {}", e);
if all_dicts {
let mut header_set: BTreeSet<String> = BTreeSet::new();
for val in candidates.iter() {
if let RuntimeValue::Dict(map) = *val {
for key in map.keys() {
header_set.insert(key.to_string());
}
}
}
let headers: Vec<String> = header_set.into_iter().collect();
let header_row: Vec<String> = headers.iter().map(|h| header_cell(h, theme)).collect();
writer.write_record(&header_row).map_err(write_err)?;
for val in candidates.iter() {
if let RuntimeValue::Dict(map) = *val {
let row: Vec<String> = headers
.iter()
.map(|h| {
map.get(&mq_lang::Ident::new(h.as_str()))
.map(|v| data_cell(v, theme))
.unwrap_or_default()
})
.collect();
writer.write_record(&row).map_err(write_err)?;
}
}
} else if all_arrays {
for (i, val) in candidates.iter().enumerate() {
if let RuntimeValue::Array(items) = *val {
let row: Vec<String> = items
.iter()
.map(|v| match (i, theme) {
(0, Some(_)) => header_cell(&cell_value(v), theme),
_ => data_cell(v, theme),
})
.collect();
writer.write_record(&row).map_err(write_err)?;
}
}
} else {
writer.write_record([header_cell("value", theme)]).map_err(write_err)?;
for val in candidates.iter() {
writer.write_record([data_cell(val, theme)]).map_err(write_err)?;
}
}
let bytes = writer.into_inner().map_err(|e| miette!("Failed to write CSV: {}", e))?;
String::from_utf8(bytes).map_err(|e| miette!("Failed to convert CSV output to UTF-8: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_array_of_dicts() {
let mut m1 = mq_lang::DictMap::default();
m1.insert(
mq_lang::Ident::new("name"),
RuntimeValue::String(Shared::new("Alice".to_string())),
);
m1.insert(
mq_lang::Ident::new("age"),
RuntimeValue::String(Shared::new("30".to_string())),
);
let mut m2 = mq_lang::DictMap::default();
m2.insert(
mq_lang::Ident::new("name"),
RuntimeValue::String(Shared::new("Bob".to_string())),
);
m2.insert(
mq_lang::Ident::new("age"),
RuntimeValue::String(Shared::new("25".to_string())),
);
let values = vec![RuntimeValue::Array(Shared::new(vec![
RuntimeValue::Dict(Shared::new(m1)),
RuntimeValue::Dict(Shared::new(m2)),
]))];
let result = runtime_values_to_csv(&values, None).unwrap();
assert_eq!(result, "age,name\n30,Alice\n25,Bob\n");
}
#[test]
fn test_single_dict() {
let mut map = mq_lang::DictMap::default();
map.insert(
mq_lang::Ident::new("a"),
RuntimeValue::String(Shared::new("1".to_string())),
);
let values = vec![RuntimeValue::Dict(Shared::new(map))];
let result = runtime_values_to_csv(&values, None).unwrap();
assert_eq!(result, "a\n1\n");
}
#[test]
fn test_needs_quoting() {
let mut map = mq_lang::DictMap::default();
map.insert(
mq_lang::Ident::new("a"),
RuntimeValue::String(Shared::new("has,comma".to_string())),
);
let values = vec![RuntimeValue::Dict(Shared::new(map))];
let result = runtime_values_to_csv(&values, None).unwrap();
assert_eq!(result, "a\n\"has,comma\"\n");
}
#[test]
fn test_scalars_single_column() {
let values = vec![
RuntimeValue::String(Shared::new("x".to_string())),
RuntimeValue::String(Shared::new("y".to_string())),
];
let result = runtime_values_to_csv(&values, None).unwrap();
assert_eq!(result, "value\nx\ny\n");
}
#[test]
fn test_missing_key_is_empty() {
let mut m1 = mq_lang::DictMap::default();
m1.insert(
mq_lang::Ident::new("a"),
RuntimeValue::String(Shared::new("1".to_string())),
);
m1.insert(
mq_lang::Ident::new("b"),
RuntimeValue::String(Shared::new("2".to_string())),
);
let mut m2 = mq_lang::DictMap::default();
m2.insert(
mq_lang::Ident::new("a"),
RuntimeValue::String(Shared::new("3".to_string())),
);
let values = vec![RuntimeValue::Array(Shared::new(vec![
RuntimeValue::Dict(Shared::new(m1)),
RuntimeValue::Dict(Shared::new(m2)),
]))];
let result = runtime_values_to_csv(&values, None).unwrap();
assert_eq!(result, "a,b\n1,2\n3,\n");
}
#[test]
fn test_array_of_arrays() {
let values = vec![RuntimeValue::Array(Shared::new(vec![
RuntimeValue::Array(Shared::new(vec![
RuntimeValue::String(Shared::new("h1".to_string())),
RuntimeValue::String(Shared::new("h2".to_string())),
])),
RuntimeValue::Array(Shared::new(vec![
RuntimeValue::String(Shared::new("v1".to_string())),
RuntimeValue::String(Shared::new("v2".to_string())),
])),
]))];
let result = runtime_values_to_csv(&values, None).unwrap();
assert_eq!(result, "h1,h2\nv1,v2\n");
}
#[test]
fn test_none_filtered_out() {
let values = vec![
RuntimeValue::None,
RuntimeValue::String(Shared::new("visible".to_string())),
];
let result = runtime_values_to_csv(&values, None).unwrap();
assert_eq!(result, "value\nvisible\n");
}
fn colored_theme() -> ColorTheme<'static> {
ColorTheme::COLORED
}
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
for c in chars.by_ref() {
if c == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[test]
fn test_colored_header_and_typed_cells() {
let mut map = mq_lang::DictMap::default();
map.insert(mq_lang::Ident::new("n"), RuntimeValue::Number(3i64.into()));
map.insert(mq_lang::Ident::new("ok"), RuntimeValue::Boolean(true));
map.insert(
mq_lang::Ident::new("s"),
RuntimeValue::String(Shared::new("a,b".to_string())),
);
let values = vec![RuntimeValue::Dict(Shared::new(map))];
let theme = colored_theme();
let colored = runtime_values_to_csv(&values, Some(&theme)).unwrap();
let plain = runtime_values_to_csv(&values, None).unwrap();
assert!(colored.starts_with(&format!("{}n{}", theme.heading.0, theme.heading.1)));
assert!(colored.contains(&format!("{}3{}", theme.emphasis.0, theme.emphasis.1)));
assert!(colored.contains(&format!("{}true{}", theme.link_url.0, theme.link_url.1)));
assert_eq!(strip_ansi(&colored), plain);
}
#[test]
fn test_colored_array_of_arrays_header_row() {
let values = vec![RuntimeValue::Array(Shared::new(vec![
RuntimeValue::Array(Shared::new(vec![RuntimeValue::String(Shared::new("h".to_string()))])),
RuntimeValue::Array(Shared::new(vec![RuntimeValue::String(Shared::new("v".to_string()))])),
]))];
let theme = colored_theme();
let colored = runtime_values_to_csv(&values, Some(&theme)).unwrap();
assert_eq!(colored, format!("{}h{}\nv\n", theme.heading.0, theme.heading.1));
}
}