use serde_json::Value;
use crate::error::AppError;
use crate::hints::{Hint, HintContext, generate_hints};
use crate::output;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Json,
Text,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HintsMode {
On,
Off,
}
pub struct OutputPipeline {
jq_filter: Option<String>,
format: OutputFormat,
hints_mode: HintsMode,
}
impl OutputPipeline {
#[allow(dead_code)]
pub fn new(jq_filter: Option<String>) -> Self {
Self {
jq_filter,
format: OutputFormat::Json,
hints_mode: HintsMode::Off,
}
}
pub fn from_cli(cli: &crate::cli::args::Cli) -> Result<Self, AppError> {
let format = match cli.format.as_str() {
"json" => OutputFormat::Json,
"text" => OutputFormat::Text,
other => {
return Err(AppError::User(format!(
"invalid --format value '{other}': must be 'json' or 'text'"
)));
}
};
if format == OutputFormat::Text && cli.jq.is_some() {
return Err(AppError::User(
"--format text and --jq are mutually exclusive".to_string(),
));
}
let hints_mode = if cli.no_hints || cli.jq.is_some() {
HintsMode::Off
} else if cli.hints {
HintsMode::On
} else {
match format {
OutputFormat::Text => HintsMode::On,
OutputFormat::Json => HintsMode::Off,
}
};
Ok(Self {
jq_filter: cli.jq.clone(),
format,
hints_mode,
})
}
pub fn finalize_with_hints(
&self,
envelope: &Value,
hint_ctx: Option<&HintContext>,
) -> anyhow::Result<()> {
let mut envelope = envelope.clone();
let hints = if self.hints_mode == HintsMode::On {
let h = hint_ctx.map(generate_hints).unwrap_or_default();
output::inject_hints(&mut envelope, &h)?;
h
} else {
vec![]
};
match &self.jq_filter {
Some(filter) => {
let output = output::apply_jq_filter(&envelope, filter)?;
for value in output {
println!("{}", serde_json::to_string(&value)?);
}
}
None => match self.format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&envelope)?);
}
OutputFormat::Text => {
render_text(&envelope);
render_hints(&hints);
}
},
}
Ok(())
}
pub fn finalize(&self, envelope: &Value) -> anyhow::Result<()> {
self.finalize_with_hints(envelope, None::<&HintContext>)
}
}
fn render_text(envelope: &Value) {
let results = envelope.get("results").unwrap_or(&Value::Null);
match results {
Value::Array(arr) if arr.iter().all(Value::is_object) && !arr.is_empty() => {
render_table(arr);
}
Value::Object(map) if map.values().all(|v| !v.is_object() && !v.is_array()) => {
render_kv(map);
}
_ => {
if let Ok(pretty) = serde_json::to_string_pretty(results) {
println!("{pretty}");
}
}
}
if let Some(hint) = envelope.get("hint").and_then(|h| h.as_str()) {
println!();
println!("{hint}");
} else if let Some(total) = envelope.get("total").and_then(Value::as_u64)
&& let Some(Value::Array(arr)) = envelope.get("results")
{
let shown = arr.len() as u64;
if shown < total {
println!();
println!("Showing {shown} of {total} results");
}
}
}
fn render_hints(hints: &[Hint]) {
if hints.is_empty() {
return;
}
println!();
for hint in hints {
println!(" -> {} # {}", hint.cmd, hint.description);
}
}
fn render_table(rows: &[Value]) {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut columns: Vec<String> = Vec::new();
for row in rows {
if let Value::Object(map) = row {
for key in map.keys() {
if seen.insert(key.clone()) {
columns.push(key.clone());
}
}
}
}
if columns.is_empty() {
return;
}
let mut widths: Vec<usize> = columns.iter().map(String::len).collect();
for row in rows {
for (i, col) in columns.iter().enumerate() {
let cell = value_to_cell(row.get(col).unwrap_or(&Value::Null));
widths[i] = widths[i].max(cell.len());
}
}
let header: Vec<String> = columns
.iter()
.enumerate()
.map(|(i, col)| format!("{col:<width$}", width = widths[i]))
.collect();
println!("{}", header.join(" "));
let sep: Vec<String> = widths.iter().map(|w| "-".repeat(*w)).collect();
println!("{}", sep.join(" "));
for row in rows {
let cells: Vec<String> = columns
.iter()
.enumerate()
.map(|(i, col)| {
let cell = value_to_cell(row.get(col).unwrap_or(&Value::Null));
format!("{cell:<width$}", width = widths[i])
})
.collect();
println!("{}", cells.join(" "));
}
}
fn render_kv(map: &serde_json::Map<String, Value>) {
let max_key = map.keys().map(String::len).max().unwrap_or(0);
for (key, val) in map {
let cell = value_to_cell(val);
println!("{key:<max_key$} {cell}");
}
}
fn value_to_cell(val: &Value) -> String {
match val {
Value::String(s) => s.clone(),
Value::Null => String::new(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn text_array_of_objects_renders_table() {
let pipeline = OutputPipeline {
jq_filter: None,
format: OutputFormat::Text,
hints_mode: HintsMode::Off,
};
let rows = vec![
json!({"url": "https://a.com/app.js", "duration_ms": 42.5}),
json!({"url": "https://b.com/style.css", "duration_ms": 15.3}),
];
if let Value::Array(arr) = json!([
{"url": "https://a.com/app.js", "duration_ms": 42.5},
{"url": "https://b.com/style.css", "duration_ms": 15.3}
]) {
render_table(&arr);
}
let envelope = json!({
"results": rows,
"total": 2,
"meta": {}
});
assert!(pipeline.finalize(&envelope).is_ok());
}
#[test]
fn text_flat_object_renders_kv() {
let pipeline = OutputPipeline {
jq_filter: None,
format: OutputFormat::Text,
hints_mode: HintsMode::Off,
};
let envelope = json!({
"results": {"ttfb_ms": 42.5, "fcp_ms": 150.0, "lcp_ms": 300.0},
"total": 1,
"meta": {}
});
assert!(pipeline.finalize(&envelope).is_ok());
}
#[test]
fn text_renders_truncation_hint() {
let pipeline = OutputPipeline {
jq_filter: None,
format: OutputFormat::Text,
hints_mode: HintsMode::Off,
};
let envelope = json!({
"results": [{"url": "https://a.com"}],
"total": 10,
"truncated": true,
"hint": "showing 1 of 10, use --all for complete list",
"meta": {}
});
assert!(pipeline.finalize(&envelope).is_ok());
}
#[test]
fn json_format_unchanged() {
let pipeline = OutputPipeline::new(None);
let envelope = json!({"results": [], "total": 0, "meta": {}});
assert!(pipeline.finalize(&envelope).is_ok());
}
#[test]
fn from_cli_invalid_format_returns_error() {
let result: Result<OutputFormat, AppError> = match "badvalue" {
"json" => Ok(OutputFormat::Json),
"text" => Ok(OutputFormat::Text),
other => Err(AppError::User(format!(
"invalid --format value '{other}': must be 'json' or 'text'"
))),
};
assert!(result.is_err());
if let Err(AppError::User(msg)) = result {
assert!(msg.contains("badvalue"));
}
}
#[test]
fn from_cli_text_with_jq_is_error() {
let format = OutputFormat::Text;
let jq: Option<String> = Some(".results".to_string());
let result: Result<(), AppError> = if format == OutputFormat::Text && jq.is_some() {
Err(AppError::User(
"--format text and --jq are mutually exclusive".to_string(),
))
} else {
Ok(())
};
assert!(result.is_err());
if let Err(AppError::User(msg)) = result {
assert!(msg.contains("mutually exclusive"));
}
}
}