use crate::format::RenderOptions;
use crate::{Cluster, Envelope};
const BAR_BUDGET: usize = 12;
const BAR_GUTTER: usize = 2;
pub(super) fn render(envelope: &Envelope, opts: &RenderOptions) -> String {
let layout = decide_layout(envelope, opts);
let mut out = String::new();
write_header(&mut out, envelope, opts);
if !envelope.clusters.is_empty() {
out.push('\n');
write_cluster_group(
&mut out,
&envelope.clusters,
1,
opts,
&layout,
envelope.result.detection.score_path.as_deref(),
);
}
if !envelope.page.items.is_empty() {
write_page(&mut out, envelope);
}
out
}
#[derive(Debug, Clone, Copy)]
struct Layout {
show_bars: bool,
show_ranges: bool,
}
#[derive(Debug, Clone, Copy, Default)]
struct ColumnWidths {
ordinal: usize,
label: usize,
count: usize,
range: usize,
}
#[derive(Debug, Clone, Copy)]
struct LineParts<'a> {
indent: &'a str,
ordinal: Option<&'a str>,
label: &'a str,
count: &'a str,
score_range: Option<&'a str>,
bar: Option<&'a str>,
}
fn decide_layout(envelope: &Envelope, opts: &RenderOptions) -> Layout {
let Some(width) = opts.width else {
return Layout {
show_bars: true,
show_ranges: true,
};
};
let longest_with_bars = max_line_width(envelope, true, true);
let longest_no_bars = max_line_width(envelope, false, true);
let longest_no_extras = max_line_width(envelope, false, false);
let show_bars = width.saturating_sub(longest_with_bars) >= BAR_BUDGET + BAR_GUTTER;
let show_ranges = if show_bars {
true
} else if longest_no_bars > width {
let _ = longest_no_extras;
false
} else {
true
};
Layout {
show_bars,
show_ranges,
}
}
fn max_line_width(envelope: &Envelope, with_bars: bool, with_ranges: bool) -> usize {
let mut max = 0usize;
walk_group_widths(
&envelope.clusters,
1,
with_bars,
with_ranges,
envelope.result.detection.score_path.as_deref(),
&mut max,
);
max
}
fn walk_group_widths(
clusters: &[Cluster],
depth: usize,
with_bars: bool,
with_ranges: bool,
score_path: Option<&str>,
max: &mut usize,
) {
if clusters.is_empty() {
return;
}
let show_ordinals = depth == 1;
let widths = column_widths(clusters, with_ranges, score_path, show_ordinals);
let sibling_max_total = clusters.iter().map(|c| c.total).max().unwrap_or(0);
let indent = " ".repeat(depth);
for cluster in clusters {
let bar = if with_bars {
format_bar(cluster.total, sibling_max_total)
} else {
String::new()
};
let bar_opt = if bar.is_empty() {
None
} else {
Some(bar.as_str())
};
let line_width = visible_width_of_assembled(&indent, &widths, bar_opt);
if line_width > *max {
*max = line_width;
}
if !cluster.clusters.is_empty() {
walk_group_widths(
&cluster.clusters,
depth + 1,
with_bars,
with_ranges,
score_path,
max,
);
}
}
}
fn write_header(out: &mut String, envelope: &Envelope, opts: &RenderOptions) {
let total = envelope.result.input_total;
let header = format!("face: {total} items");
if opts.color {
out.push_str(BOLD_ON);
out.push_str(&header);
out.push_str(BOLD_OFF);
} else {
out.push_str(&header);
}
out.push('\n');
if let Some(grouping) = grouping_header(envelope) {
out.push_str(&colorize(&grouping, DIM_ON, DIM_OFF, opts.color));
out.push('\n');
}
}
fn grouping_header(envelope: &Envelope) -> Option<String> {
if envelope.result.axes.is_empty() {
return None;
}
let axes = envelope
.result
.axes
.iter()
.map(|axis| format!("{} ({})", axis.field, axis.strategy.name()))
.collect::<Vec<_>>()
.join(" -> ");
Some(format!("grouped by: {axes}"))
}
fn write_cluster_group(
out: &mut String,
clusters: &[Cluster],
depth: usize,
opts: &RenderOptions,
layout: &Layout,
score_path: Option<&str>,
) {
if clusters.is_empty() {
return;
}
let show_ordinals = depth == 1;
let widths = column_widths(clusters, layout.show_ranges, score_path, show_ordinals);
let sibling_max_total = clusters.iter().map(|c| c.total).max().unwrap_or(0);
let indent = " ".repeat(depth);
for (index, cluster) in clusters.iter().enumerate() {
let count = cluster.total.to_string();
let score_range = if layout.show_ranges {
cluster_score_range(cluster, score_path)
} else {
None
};
let ordinal = show_ordinals.then(|| format_ordinal(index));
let bar = if layout.show_bars {
format_bar(cluster.total, sibling_max_total)
} else {
String::new()
};
let bar_opt = if bar.is_empty() {
None
} else {
Some(bar.as_str())
};
let line = assemble(
LineParts {
indent: &indent,
ordinal: ordinal.as_deref(),
label: &cluster.label,
count: &count,
score_range: score_range.as_deref(),
bar: bar_opt,
},
opts,
&widths,
);
out.push_str(&line);
out.push('\n');
if !cluster.clusters.is_empty() {
write_cluster_group(out, &cluster.clusters, depth + 1, opts, layout, score_path);
}
}
}
fn column_widths(
clusters: &[Cluster],
with_ranges: bool,
score_path: Option<&str>,
show_ordinals: bool,
) -> ColumnWidths {
let mut widths = ColumnWidths::default();
for (index, cluster) in clusters.iter().enumerate() {
if show_ordinals {
widths.ordinal = widths.ordinal.max(format_ordinal(index).chars().count());
}
widths.label = widths.label.max(cluster.label.chars().count());
widths.count = widths.count.max(cluster.total.to_string().chars().count());
if with_ranges {
let range = cluster_score_range(cluster, score_path);
widths.range = widths.range.max(
range
.as_deref()
.map(str::chars)
.map(Iterator::count)
.unwrap_or(0),
);
}
}
widths
}
fn format_ordinal(index: usize) -> String {
format!("[{}]", index + 1)
}
fn cluster_score_range(cluster: &Cluster, score_path: Option<&str>) -> Option<String> {
if score_path.is_some_and(|path| path_matches_axis(path, &cluster.axis)) {
None
} else {
format_score_range(cluster.score_min, cluster.score_max)
}
}
fn path_matches_axis(path: &str, axis: &str) -> bool {
path.trim_start_matches('.') == axis.trim_start_matches('.')
}
fn visible_width_of_assembled(indent: &str, widths: &ColumnWidths, bar: Option<&str>) -> usize {
let mut w = 0;
w += indent.chars().count();
if widths.ordinal > 0 {
w += widths.ordinal;
w += 2;
}
w += widths.label;
w += 2; w += widths.count;
if widths.range > 0 {
w += 2;
w += widths.range;
}
if let Some(bar) = bar
&& !bar.is_empty()
{
w += 2;
w += bar.chars().count();
}
w
}
fn assemble(parts: LineParts<'_>, opts: &RenderOptions, widths: &ColumnWidths) -> String {
let mut s = String::new();
s.push_str(parts.indent);
if widths.ordinal > 0 {
push_padded(
&mut s,
parts.ordinal.unwrap_or(""),
widths.ordinal,
Align::Right,
None,
);
s.push_str(" ");
}
push_padded(
&mut s,
parts.label,
widths.label,
Align::Left,
opts.color.then_some((BOLD_ON, BOLD_OFF)),
);
s.push_str(" ");
push_padded(&mut s, parts.count, widths.count, Align::Right, None);
if widths.range > 0 {
s.push_str(" ");
push_padded(
&mut s,
parts.score_range.unwrap_or(""),
widths.range,
Align::Left,
opts.color.then_some((DIM_ON, DIM_OFF)),
);
}
if let Some(bar) = parts.bar
&& !bar.is_empty()
{
s.push_str(" ");
s.push_str(&colorize(bar, CYAN_ON, CYAN_OFF, opts.color));
}
s
}
#[derive(Debug, Clone, Copy)]
enum Align {
Left,
Right,
}
fn push_padded(
out: &mut String,
value: &str,
width: usize,
align: Align,
color: Option<(&str, &str)>,
) {
let value_width = value.chars().count();
let pad = width.saturating_sub(value_width);
if matches!(align, Align::Right) {
out.push_str(&" ".repeat(pad));
}
if let Some((on, off)) = color {
out.push_str(on);
out.push_str(value);
out.push_str(off);
} else {
out.push_str(value);
}
if matches!(align, Align::Left) {
out.push_str(&" ".repeat(pad));
}
}
fn format_score_range(min: Option<f64>, max: Option<f64>) -> Option<String> {
match (min, max) {
(None, None) => None,
(Some(lo), Some(hi)) => Some(format!("{lo:.2}\u{2013}{hi:.2}")),
(Some(lo), None) => Some(format!("{lo:.2}\u{2013}")),
(None, Some(hi)) => Some(format!("\u{2013}{hi:.2}")),
}
}
fn format_bar(total: u64, sibling_max_total: u64) -> String {
if sibling_max_total == 0 || total == 0 {
return String::new();
}
const EIGHTHS_PER_CELL: u64 = 8;
let eighths_total = (BAR_BUDGET as u64) * EIGHTHS_PER_CELL;
let eighths = (total * eighths_total) / sibling_max_total;
let full = eighths / EIGHTHS_PER_CELL;
let partial = eighths % EIGHTHS_PER_CELL;
let mut bar = String::new();
for _ in 0..full {
bar.push('\u{2588}');
}
if partial > 0 {
let ch = match partial {
1 => '\u{258F}',
2 => '\u{258E}',
3 => '\u{258D}',
4 => '\u{258C}',
5 => '\u{258B}',
6 => '\u{258A}',
7 => '\u{2589}',
_ => '\u{2588}',
};
bar.push(ch);
}
bar
}
fn write_page(out: &mut String, envelope: &Envelope) {
let cluster_id_label = envelope
.page
.cluster_id
.as_ref()
.map(|id| id.to_string())
.unwrap_or_else(|| "<root>".to_string());
out.push('\n');
out.push_str(&format!(
"Page {cluster_id_label} (page {} of items {}):\n",
envelope.page.page, envelope.page.total_items
));
for item in &envelope.page.items {
let line = serde_json::to_string_pretty(item)
.expect("serializing serde_json::Value is infallible");
out.push_str(&line);
out.push('\n');
}
}
fn colorize(s: &str, on: &str, off: &str, color: bool) -> String {
if color {
format!("{on}{s}{off}")
} else {
s.to_string()
}
}
const BOLD_ON: &str = "\x1b[1m";
const BOLD_OFF: &str = "\x1b[22m";
const DIM_ON: &str = "\x1b[2m";
const DIM_OFF: &str = "\x1b[22m";
const CYAN_ON: &str = "\x1b[36m";
const CYAN_OFF: &str = "\x1b[39m";
#[cfg(test)]
mod tests {
use super::*;
use crate::format::OutputFormat;
use crate::{Cluster, ClusterId, ClusterIdSegment, Envelope};
use serde_json::json;
fn cluster(id_seg: (&str, &str), label: &str, total: u64, children: Vec<Cluster>) -> Cluster {
let id = ClusterId::new(vec![ClusterIdSegment {
axis: id_seg.0.into(),
value: id_seg.1.into(),
}]);
Cluster {
id,
label: label.to_string(),
axis: id_seg.0.to_string(),
value: json!(id_seg.1),
total,
score_min: None,
score_max: None,
clusters: children,
}
}
fn envelope_with(clusters: Vec<Cluster>, total: u64) -> Envelope {
Envelope {
result: crate::ResultBlock {
input_total: total,
..crate::ResultBlock::default()
},
clusters,
..Envelope::default()
}
}
#[test]
fn renders_simple_two_cluster_tree() {
let env = envelope_with(
vec![
cluster(("file", "src/cli.rs"), "src/cli.rs", 38, vec![]),
cluster(("file", "src/core.rs"), "src/core.rs", 27, vec![]),
],
65,
);
let out = super::render(&env, &RenderOptions::default());
assert!(out.starts_with("face: 65 items\n"));
assert!(out.contains("src/cli.rs"));
assert!(out.contains("src/core.rs"));
assert!(out.contains(" 38 "));
assert!(out.contains(" 27 "));
assert!(out.contains('\u{2588}'));
}
#[test]
fn respects_width_drops_bars_first() {
let env = envelope_with(
vec![Cluster {
score_min: Some(0.7),
score_max: Some(0.85),
..cluster(("score", "strong"), "strong", 18, vec![])
}],
18,
);
let wide = super::render(
&env,
&RenderOptions {
color: false,
width: Some(120),
},
);
assert!(wide.contains('\u{2588}'), "wide should keep bars");
assert!(wide.contains("0.70\u{2013}0.85"), "wide should keep range");
let medium = super::render(
&env,
&RenderOptions {
color: false,
width: Some(28),
},
);
assert!(
!medium.contains('\u{2588}'),
"narrow drops bars first: {medium:?}"
);
assert!(
medium.contains("0.70\u{2013}0.85"),
"narrow still has range: {medium:?}"
);
let very_narrow = super::render(
&env,
&RenderOptions {
color: false,
width: Some(15),
},
);
assert!(!very_narrow.contains('\u{2588}'));
assert!(!very_narrow.contains('\u{2013}'));
assert!(very_narrow.contains("strong"));
}
#[test]
fn color_off_emits_no_ansi() {
let env = envelope_with(
vec![Cluster {
score_min: Some(0.7),
score_max: Some(0.85),
..cluster(("file", "src/cli.rs"), "src/cli.rs", 38, vec![])
}],
38,
);
let out = super::render(
&env,
&RenderOptions {
color: false,
width: None,
},
);
assert!(!out.contains('\x1b'), "no ANSI when color=false: {out:?}");
}
#[test]
fn color_on_emits_ansi() {
let env = envelope_with(
vec![cluster(("file", "src/cli.rs"), "src/cli.rs", 1, vec![])],
1,
);
let out = super::render(
&env,
&RenderOptions {
color: true,
width: None,
},
);
assert!(out.contains("\x1b["));
assert!(out.contains(BOLD_ON));
}
#[test]
fn nested_clusters_indent_two_per_level() {
let inner = cluster(("score", "excellent"), "excellent", 12, vec![]);
let outer = cluster(("file", "src/cli.rs"), "src/cli.rs", 38, vec![inner]);
let env = envelope_with(vec![outer], 38);
let out = super::render(&env, &RenderOptions::default());
assert!(out.contains("\n [1] src/cli.rs"));
assert!(out.contains("\n excellent"));
}
#[test]
fn dispatch_through_top_level_render() {
use crate::format::render;
let env = Envelope::default();
let out = render(&env, OutputFormat::Human, &RenderOptions::default()).unwrap();
assert!(out.starts_with("face: 0 items"));
}
}