use crate::{Cluster, Envelope};
const HEADER_COLS: [&str; 6] = [
"id",
"label",
"count",
"score_min",
"score_max",
"axis_depth",
];
pub(super) fn render_tsv(envelope: &Envelope) -> String {
let mut out = String::new();
out.push_str(&HEADER_COLS.join("\t"));
out.push('\n');
for cluster in &envelope.clusters {
write_tsv_rows(&mut out, cluster, 0);
}
out
}
pub(super) fn render_csv(envelope: &Envelope) -> String {
let mut out = String::new();
out.push_str(
&HEADER_COLS
.iter()
.map(|c| csv_field(c))
.collect::<Vec<_>>()
.join(","),
);
out.push('\n');
for cluster in &envelope.clusters {
write_csv_rows(&mut out, cluster, 0);
}
out
}
fn write_tsv_rows(out: &mut String, cluster: &Cluster, depth: usize) {
let row = [
cluster.id.to_string(),
tsv_label(&cluster.label),
cluster.total.to_string(),
format_score(cluster.score_min),
format_score(cluster.score_max),
depth.to_string(),
];
out.push_str(&row.join("\t"));
out.push('\n');
for child in &cluster.clusters {
write_tsv_rows(out, child, depth + 1);
}
}
fn write_csv_rows(out: &mut String, cluster: &Cluster, depth: usize) {
let row = [
csv_field(&cluster.id.to_string()),
csv_field(&cluster.label),
csv_field(&cluster.total.to_string()),
csv_field(&format_score(cluster.score_min)),
csv_field(&format_score(cluster.score_max)),
csv_field(&depth.to_string()),
];
out.push_str(&row.join(","));
out.push('\n');
for child in &cluster.clusters {
write_csv_rows(out, child, depth + 1);
}
}
fn tsv_label(s: &str) -> String {
s.chars()
.map(|c| match c {
'\t' | '\n' | '\r' => ' ',
other => other,
})
.collect()
}
fn csv_field(s: &str) -> String {
let needs_quoting = s.contains([',', '"', '\n', '\r']);
if !needs_quoting {
return s.to_string();
}
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
if c == '"' {
out.push_str("\"\"");
} else {
out.push(c);
}
}
out.push('"');
out
}
fn format_score(score: Option<f64>) -> String {
match score {
None => String::new(),
Some(v) => format!("{v:.4}"),
}
}
#[cfg(test)]
mod tests {
use crate::format::{OutputFormat, RenderOptions, render};
use crate::{Cluster, ClusterId, ClusterIdSegment, Envelope};
use serde_json::json;
fn cluster_with(
axis: &str,
value: &str,
label: &str,
total: u64,
score: Option<(f64, f64)>,
children: Vec<Cluster>,
) -> Cluster {
let (score_min, score_max) = match score {
Some((lo, hi)) => (Some(lo), Some(hi)),
None => (None, None),
};
Cluster {
id: ClusterId::new(vec![ClusterIdSegment {
axis: axis.into(),
value: value.into(),
}]),
label: label.to_string(),
axis: axis.to_string(),
value: json!(value),
total,
score_min,
score_max,
clusters: children,
}
}
fn env_with(clusters: Vec<Cluster>) -> Envelope {
Envelope {
clusters,
..Envelope::default()
}
}
#[test]
fn tsv_header_and_rows() {
let env = env_with(vec![cluster_with(
"file",
"src/cli.rs",
"src/cli.rs",
38,
Some((0.7, 0.95)),
vec![],
)]);
let out = render(&env, OutputFormat::Tsv, &RenderOptions::default()).unwrap();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(
lines[0], "id\tlabel\tcount\tscore_min\tscore_max\taxis_depth",
"header line"
);
let cells: Vec<&str> = lines[1].split('\t').collect();
assert_eq!(cells.len(), 6);
assert_eq!(cells[0], "file:src/cli.rs");
assert_eq!(cells[1], "src/cli.rs");
assert_eq!(cells[2], "38");
assert_eq!(cells[3], "0.7000");
assert_eq!(cells[4], "0.9500");
assert_eq!(cells[5], "0");
}
#[test]
fn tsv_strips_embedded_tabs_in_label() {
let env = env_with(vec![cluster_with(
"k",
"v",
"before\tafter",
1,
None,
vec![],
)]);
let out = render(&env, OutputFormat::Tsv, &RenderOptions::default()).unwrap();
let row = out.lines().nth(1).unwrap();
let cells: Vec<&str> = row.split('\t').collect();
assert_eq!(cells.len(), 6);
assert_eq!(cells[1], "before after");
}
#[test]
fn csv_quotes_special_chars() {
let env = env_with(vec![cluster_with(
"k",
"v",
"a, b \"quoted\"\nrow",
1,
None,
vec![],
)]);
let out = render(&env, OutputFormat::Csv, &RenderOptions::default()).unwrap();
assert!(out.starts_with("id,label,count,score_min,score_max,axis_depth\n"));
let body = out
.strip_prefix("id,label,count,score_min,score_max,axis_depth\n")
.unwrap();
assert!(
body.contains("\"a, b \"\"quoted\"\"\nrow\""),
"expected RFC-4180 quoting in body: {body:?}"
);
}
#[test]
fn csv_score_empty_when_none() {
let env = env_with(vec![cluster_with("k", "v", "label", 7, None, vec![])]);
let out = render(&env, OutputFormat::Csv, &RenderOptions::default()).unwrap();
let row = out.lines().nth(1).unwrap();
let cells: Vec<&str> = row.split(',').collect();
assert_eq!(cells.len(), 6);
assert_eq!(cells[3], "");
assert_eq!(cells[4], "");
}
#[test]
fn axis_depth_increases_with_nesting() {
let inner = cluster_with("score", "excellent", "excellent", 12, None, vec![]);
let outer = cluster_with("file", "src/cli.rs", "src/cli.rs", 38, None, vec![inner]);
let env = env_with(vec![outer]);
let out = render(&env, OutputFormat::Tsv, &RenderOptions::default()).unwrap();
let depths: Vec<&str> = out
.lines()
.skip(1)
.map(|l| l.split('\t').nth(5).unwrap())
.collect();
assert_eq!(depths, vec!["0", "1"]);
}
}