use crate::{Cluster, Envelope};
pub(super) fn render(envelope: &Envelope) -> String {
let mut out = String::new();
out.push_str("# face\n\n");
out.push_str(&format!("total_items: {}\n", envelope.result.input_total));
if !envelope.clusters.is_empty() {
out.push('\n');
for cluster in &envelope.clusters {
write_cluster(&mut out, cluster, 2);
}
}
if !envelope.page.items.is_empty() {
write_page_items(&mut out, &envelope.page.items);
}
out
}
fn write_cluster(out: &mut String, cluster: &Cluster, level: usize) {
let level = level.min(6);
let hashes = "#".repeat(level);
out.push_str(&format!("{hashes} {}\n", escape_label(&cluster.label)));
out.push_str(&format!("- count: {}\n", cluster.total));
if let (Some(lo), Some(hi)) = (cluster.score_min, cluster.score_max) {
out.push_str(&format!("- score: {lo:.2}\u{2013}{hi:.2}\n"));
}
out.push('\n');
for child in &cluster.clusters {
write_cluster(out, child, level + 1);
}
}
fn escape_label(label: &str) -> String {
const SPECIALS: &[char] = &[
'\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '.', '!', '|',
];
let mut out = String::with_capacity(label.len());
for c in label.chars() {
if SPECIALS.contains(&c) {
out.push('\\');
}
out.push(c);
}
out
}
fn write_page_items(out: &mut String, items: &[serde_json::Value]) {
let serialized: Vec<String> = items
.iter()
.map(|item| {
serde_json::to_string(item).expect("serializing serde_json::Value is infallible")
})
.collect();
let fence_len = pick_fence_length(&serialized);
let fence = "`".repeat(fence_len);
out.push('\n');
out.push_str(&fence);
out.push_str("json\n");
for line in &serialized {
out.push_str(line);
out.push('\n');
}
out.push_str(&fence);
out.push('\n');
}
fn pick_fence_length(serialized: &[String]) -> usize {
let mut max_run = 0usize;
for line in serialized {
let mut run = 0usize;
for c in line.chars() {
if c == '`' {
run += 1;
if run > max_run {
max_run = run;
}
} else {
run = 0;
}
}
}
(max_run + 1).max(3)
}
#[cfg(test)]
mod tests {
use crate::format::{OutputFormat, RenderOptions, render};
use crate::{Cluster, ClusterId, ClusterIdSegment, Envelope};
use serde_json::json;
fn cluster(axis: &str, value: &str, total: u64, children: Vec<Cluster>) -> Cluster {
Cluster {
id: ClusterId::new(vec![ClusterIdSegment {
axis: axis.into(),
value: value.into(),
}]),
label: value.to_string(),
axis: axis.to_string(),
value: json!(value),
total,
score_min: None,
score_max: None,
clusters: children,
}
}
fn env_with(clusters: Vec<Cluster>, total: u64) -> Envelope {
Envelope {
result: crate::ResultBlock {
input_total: total,
..crate::ResultBlock::default()
},
clusters,
..Envelope::default()
}
}
#[test]
fn headings_per_nesting_level() {
let inner = cluster("score", "excellent", 12, vec![]);
let outer = cluster("file", "src/cli.rs", 38, vec![inner]);
let env = env_with(vec![outer], 38);
let out = render(&env, OutputFormat::Markdown, &RenderOptions::default()).unwrap();
assert!(out.starts_with("# face\n"));
assert!(out.contains("total_items: 38"));
assert!(out.contains("\n## src/cli\\.rs\n"), "{out}");
assert!(out.contains("\n### excellent\n"));
assert!(out.contains("- count: 38"));
assert!(out.contains("- count: 12"));
}
#[test]
fn heading_level_caps_at_six() {
let mut leaf = cluster("a", "leaf", 1, vec![]);
for i in 0..6 {
leaf = cluster("a", &format!("level-{i}"), 1, vec![leaf]);
}
let env = env_with(vec![leaf], 1);
let out = render(&env, OutputFormat::Markdown, &RenderOptions::default()).unwrap();
assert!(!out.contains("####### "));
assert!(out.contains("###### "));
}
#[test]
fn page_items_emitted_as_fenced_json() {
let env = Envelope {
page: crate::Page {
items: vec![json!({"text": "hello"}), json!({"text": "world"})],
..crate::Page::default()
},
..env_with(vec![], 0)
};
let out = render(&env, OutputFormat::Markdown, &RenderOptions::default()).unwrap();
assert!(out.contains("```json\n"));
assert!(out.contains("{\"text\":\"hello\"}\n"));
assert!(out.contains("{\"text\":\"world\"}\n"));
assert!(out.contains("\n```\n"));
}
#[test]
fn score_range_appears_when_set() {
let env = env_with(
vec![Cluster {
score_min: Some(0.7),
score_max: Some(0.85),
..cluster("file", "src/cli.rs", 1, vec![])
}],
1,
);
let out = render(&env, OutputFormat::Markdown, &RenderOptions::default()).unwrap();
assert!(out.contains("- score: 0.70\u{2013}0.85"));
}
}