use console::style;
use serde_json::Value;
use std::time::{Duration, SystemTime};
use hen::{
automation,
error::HenError,
report::{self, BodyReportOptions, OutputRedactor},
request,
};
use crate::cli::OutputFormat;
const PREVIEW_MAX_LINES: usize = 12;
const PREVIEW_MAX_CHARS: usize = 800;
pub(crate) const STRUCTURED_BODY_MAX_CHARS: usize = 4_000;
pub(crate) fn print_run_report(output: OutputFormat, outcome: &automation::RunOutcome) {
match output {
OutputFormat::Text => unreachable!("text output should be handled separately"),
OutputFormat::Json => print_json(&report::run_outcome_json(
outcome,
BodyReportOptions {
include_body: true,
max_body_chars: Some(STRUCTURED_BODY_MAX_CHARS),
},
)),
OutputFormat::Ndjson => println!(
"{}",
report::run_outcome_ndjson(
outcome,
BodyReportOptions {
include_body: true,
max_body_chars: Some(STRUCTURED_BODY_MAX_CHARS),
},
)
),
OutputFormat::Junit => println!("{}", report::run_outcome_junit(outcome)),
}
}
fn retry_attempt_label(attempts: usize) -> Option<String> {
if attempts > 1 {
Some(format!("{} attempts", attempts))
} else {
None
}
}
pub(crate) fn print_verification_report(
output: OutputFormat,
result: &automation::VerificationResult,
) {
match output {
OutputFormat::Text => unreachable!("text output should be handled separately"),
OutputFormat::Json => print_json(&report::verification_result_json(result)),
OutputFormat::Ndjson => println!("{}", report::verification_result_ndjson(result)),
OutputFormat::Junit => println!("{}", report::verification_result_junit(result)),
}
}
pub(crate) fn print_machine_error(
output: OutputFormat,
suite_name: &str,
case_name: &str,
error: &HenError,
) {
match output {
OutputFormat::Text => unreachable!("text errors should be printed by main"),
OutputFormat::Json => print_json(&report::hen_error_json(error)),
OutputFormat::Ndjson => println!("{}", report::hen_error_ndjson(error)),
OutputFormat::Junit => {
println!("{}", report::hen_error_junit(suite_name, case_name, error))
}
}
}
fn print_json(value: &serde_json::Value) {
let rendered = serde_json::to_string_pretty(value).expect("json output should serialize");
println!("{}", rendered);
}
pub(crate) fn print_verification_result(result: &automation::VerificationResult) {
println!("{}", style("Verification passed").green().bold());
if let Some(path) = &result.path {
println!("{} {}", style("Path:").dim(), path.display());
}
if !result.summary.name.trim().is_empty() {
println!(
"{} {}",
style("Collection:").dim(),
result.summary.name.trim()
);
}
if !result.summary.description.trim().is_empty() {
println!(
"{} {}",
style("Description:").dim(),
result.summary.description.trim()
);
}
if !result.summary.available_environments.is_empty() {
println!("{}", style("Available environments").bold());
for environment in &result.summary.available_environments {
println!(" - {}", environment);
}
}
println!("{}", style("Requests").bold());
for request in &result.summary.requests {
println!(" [{}] {} {}", request.index, request.method, request.url);
}
println!("{}", style("Required inputs").bold());
if result.required_inputs.is_empty() {
println!(" {}", style("<none>").dim());
return;
}
for prompt in &result.required_inputs {
match &prompt.default {
Some(default) => println!(" - {} (default: {})", prompt.name, default),
None => println!(" - {}", prompt.name),
}
}
}
pub(crate) fn print_status_line(record: &request::ExecutionRecord) {
let redactor = OutputRedactor::with_header_names(
&record.sensitive_values,
&record.sensitive_header_names,
);
let success_symbol = style("[ok]").green();
let index_label = style(format!("#{}", record.index)).dim();
let description_label = style(redactor.redact_text(&record.description)).bold();
let method_label = style(record.method.as_str()).cyan();
let url_label = style(redactor.redact_text(&record.url)).dim();
let target_label = format!("{} {}", method_label, url_label);
let status = record
.execution
.artifact
.http_status()
.unwrap_or(http::StatusCode::OK);
let status_text = if let Some(reason) = status.canonical_reason() {
format!("{} {}", status.as_u16(), reason)
} else {
status.as_u16().to_string()
};
let status_label = match status.as_u16() {
200..=299 => style(status_text).green(),
300..=399 => style(status_text).cyan(),
400..=499 => style(status_text).yellow(),
500..=599 => style(status_text).red(),
_ => style(status_text),
};
let duration_label = style(format_duration(record.duration)).dim();
let attempt_label = retry_attempt_label(record.execution.reliability.attempts)
.map(|label| style(label).dim().to_string());
match attempt_label {
Some(attempt_label) => println!(
"{} {} {} ({}) — {} — {} — {}",
success_symbol,
index_label,
description_label,
target_label,
status_label,
duration_label,
attempt_label,
),
None => println!(
"{} {} {} ({}) — {} — {}",
success_symbol,
index_label,
description_label,
target_label,
status_label,
duration_label,
),
}
}
pub(crate) fn print_timing_line(phases: &[request::ArtifactTimingPhase]) {
if let Some(line) = format_timing_line(phases) {
println!(" {}", style(line).dim());
}
}
pub(crate) fn format_timing_line(phases: &[request::ArtifactTimingPhase]) -> Option<String> {
if phases.is_empty() {
return None;
}
Some(format!(
"timing: {}",
phases
.iter()
.map(|phase| format!("{} {}", phase.name, format_duration(phase.duration)))
.collect::<Vec<_>>()
.join(", ")
))
}
pub(crate) fn print_body_preview(record: &request::ExecutionRecord, verbose: bool) {
let redactor = OutputRedactor::with_header_names(
&record.sensitive_values,
&record.sensitive_header_names,
);
let body = redactor.redact_text(record.execution.output.trim());
print_body_text(body.as_str(), verbose);
}
pub(crate) fn print_failure_body(failure: &request::RequestFailure, verbose: bool) {
let Some(artifact) = failure.artifact() else {
return;
};
let redactor = OutputRedactor::with_header_names(
failure.sensitive_values(),
failure.sensitive_header_names(),
);
let body = redactor.redact_text(artifact.body_text().trim());
print_body_text(body.as_str(), verbose);
}
fn print_body_text(body: &str, verbose: bool) {
let heading = if verbose { "Body:" } else { "Body preview:" };
println!(" {}", style(heading).dim());
if body.is_empty() {
println!(" {}", style("<empty>").dim());
return;
}
let (preview, truncated) = build_body_output(body, verbose);
for line in preview.lines() {
println!(" {}", line);
}
if truncated {
println!(
" {}",
style(format!(
"... truncated to {} lines / {} chars",
PREVIEW_MAX_LINES, PREVIEW_MAX_CHARS
))
.dim()
);
}
}
fn build_body_output(body: &str, verbose: bool) -> (String, bool) {
if verbose {
(body.to_string(), false)
} else {
build_preview(body, PREVIEW_MAX_LINES, PREVIEW_MAX_CHARS)
}
}
pub(crate) fn print_failure_line(failure: &request::RequestFailure) {
let redactor = OutputRedactor::with_header_names(
failure.sensitive_values(),
failure.sensitive_header_names(),
);
let failure_symbol = style("[x]").red();
let index_label = match failure.index() {
Some(idx) => style(format!("#{}", idx)).dim().to_string(),
None => style("#?").dim().to_string(),
};
let request_label = style(redactor.redact_text(failure.request())).bold();
let (detail, diff_assertion) = match failure.kind() {
request::RequestFailureKind::Execution {
message,
assertions,
reliability,
..
} => {
let diff_assertion = assertions.iter().find(|assertion| {
matches!(assertion.status, request::AssertionStatus::Failed)
&& assertion.diff.is_some()
});
let mut detail = if diff_assertion.is_some() && message.starts_with("Assertion failed:") {
"Assertion failed".to_string()
} else {
message.clone()
};
if let Some(attempt_label) = retry_attempt_label(reliability.attempts) {
detail.push_str(&format!(" ({})", attempt_label));
}
(detail, diff_assertion)
}
request::RequestFailureKind::Dependency { dependency } => {
(format!("skipped: dependency '{}' failed", dependency), None)
}
request::RequestFailureKind::MissingDependency { dependency } => {
(format!("missing dependency '{}'", dependency), None)
}
request::RequestFailureKind::Join { message } => (message.clone(), None),
request::RequestFailureKind::MapAborted { group, cause } => {
(
format!("skipped: '{}' aborted after failure in '{}'", group, cause),
None,
)
}
};
println!(
"{} {} {} — {}",
failure_symbol,
index_label,
request_label,
style(redactor.redact_text(&detail)).red()
);
if let Some(assertion) = diff_assertion {
print_assertion_diff(assertion, &redactor);
}
}
fn print_assertion_diff(assertion: &request::AssertionOutcome, redactor: &OutputRedactor) {
println!(" {}", style(format!("assertion: {}", assertion.assertion)).dim());
if let Some(mismatch) = &assertion.mismatch {
if let Some(path) = mismatch.actual_path.as_deref() {
println!(" {}", style(format!("actual path: {}", path)).dim());
}
if let Some(path) = mismatch.path.as_deref() {
if Some(path) != mismatch.actual_path.as_deref() {
println!(" {}", style(format!("mismatch path: {}", path)).dim());
}
}
println!(
" {}",
style(format!("reason: {}", mismatch.reason.as_str())).dim()
);
}
if let Some(diff) = &assertion.diff {
println!(" {}", style("diff:").dim());
let rendered = redactor.redact_text(&diff.rendered);
for line in rendered.lines() {
println!(" {}", line);
}
}
}
pub(crate) fn print_summary(
records: &[request::ExecutionRecord],
failures: &[request::RequestFailure],
interrupted: Option<request::InterruptSignal>,
planned_count: usize,
selected_environment: Option<&str>,
) {
println!("{}", style("Summary").bold());
if let Some(environment) = selected_environment {
println!(
" {}",
style(format!("environment {}", environment)).dim()
);
}
let success_text = format!("{} succeeded", records.len());
let success_label = if records.is_empty() {
style(success_text).dim()
} else {
style(success_text).green()
};
println!(" {}", success_label);
let failure_text = format!("{} failed", failures.len());
let failure_label = if failures.is_empty() {
style(failure_text).dim()
} else {
style(failure_text).red()
};
println!(" {}", failure_label);
if let Some(total) = total_elapsed(records) {
println!(
" {}",
style(format!("elapsed {}", format_duration(total))).dim()
);
}
if let Some(slowest) = records.iter().max_by_key(|record| record.duration) {
let redactor = OutputRedactor::with_header_names(
&slowest.sensitive_values,
&slowest.sensitive_header_names,
);
println!(
" {}",
style(format!(
"slowest {} ({})",
redactor.redact_text(&slowest.description),
format_duration(slowest.duration)
))
.dim()
);
}
if let Some(signal) = interrupted {
println!(
" {}",
style(format!(
"interrupted by {} after {} of {} planned requests finished",
signal.as_str(),
records.len() + failures.len(),
planned_count,
))
.yellow()
);
}
}
pub(crate) fn print_execution_trace(entries: &[request::ExecutionTraceEntry]) {
if entries.is_empty() {
return;
}
println!("{}", style("Trace").bold());
for entry in entries {
println!(" {}", format_execution_trace_line(entry));
}
}
fn format_execution_trace_line(entry: &request::ExecutionTraceEntry) -> String {
let redactor = OutputRedactor::with_header_names(
&entry.sensitive_values,
&entry.sensitive_header_names,
);
let label = match (entry.request_index, entry.request.as_deref()) {
(Some(index), Some(request)) => format!("#{} {}", index, redactor.redact_text(request)),
(Some(index), None) => format!("#{}", index),
(None, Some(request)) => redactor.redact_text(request),
(None, None) => "run".to_string(),
};
let mut details = match entry.kind {
request::ExecutionTraceKind::Waiting => {
vec![format!(
"waiting on {}",
entry.waiting_on.iter().map(|request| redactor.redact_text(request)).collect::<Vec<_>>().join(", ")
)]
}
request::ExecutionTraceKind::Started => Vec::new(),
request::ExecutionTraceKind::Completed => entry
.duration
.map(|duration| vec![format!("in {}", format_duration(duration))])
.unwrap_or_default(),
request::ExecutionTraceKind::Failed => {
let mut details = Vec::new();
if let Some(message) = entry.message.as_deref() {
details.push(redactor.redact_text(message));
} else if let Some(reason) = entry.reason.as_deref() {
details.push(reason.replace('_', " "));
}
details
}
request::ExecutionTraceKind::Skipped => {
let mut details = Vec::new();
match entry.reason.as_deref() {
Some("dependency_failed") => {
if let Some(related) = entry.related_request.as_deref() {
details.push(format!("dependency {} failed", redactor.redact_text(related)));
}
}
Some("missing_dependency") => {
if let Some(related) = entry.related_request.as_deref() {
details.push(format!("missing dependency {}", redactor.redact_text(related)));
}
}
Some("map_aborted") => {
if let (Some(group), Some(cause)) =
(entry.group.as_deref(), entry.cause.as_deref())
{
details.push(format!(
"{} aborted after {} failed",
redactor.redact_text(group),
redactor.redact_text(cause)
));
}
}
Some(reason) => details.push(reason.replace('_', " ")),
None => {}
}
details
}
request::ExecutionTraceKind::Interrupted => entry
.signal
.map(|signal| vec![signal.as_str().to_string()])
.unwrap_or_default(),
};
if matches!(
entry.kind,
request::ExecutionTraceKind::Waiting | request::ExecutionTraceKind::Started
) {
let context = trace_context(entry, &redactor);
if !context.is_empty() {
details.push(context.join(", "));
}
if !entry.dependencies.is_empty() {
details.push(format!(
"after {}",
entry.dependencies.iter().map(|dependency| redactor.redact_text(dependency)).collect::<Vec<_>>().join(", ")
));
}
}
if details.is_empty() {
format!("[{:#04}] {} {}", entry.seq, entry.kind.as_str(), label)
} else {
format!(
"[{:#04}] {} {} — {}",
entry.seq,
entry.kind.as_str(),
label,
details.join("; ")
)
}
}
fn trace_context(entry: &request::ExecutionTraceEntry, redactor: &OutputRedactor) -> Vec<String> {
let mut parts = Vec::new();
if let Some(protocol) = entry.protocol {
parts.push(protocol.as_str().to_string());
}
let Some(Value::Object(map)) = entry.protocol_context.as_ref() else {
return parts;
};
if let Some(Value::String(action)) = map.get("action") {
parts.push(redactor.redact_text(action));
}
if let Some(Value::String(call)) = map.get("call") {
parts.push(redactor.redact_text(call));
}
if let Some(Value::String(tool)) = map.get("tool") {
parts.push(format!("tool {}", redactor.redact_text(tool)));
}
if let Some(Value::String(operation)) = map.get("operationName") {
parts.push(format!("op {}", redactor.redact_text(operation)));
}
if let Some(Value::String(kind)) = map.get("kind") {
parts.push(redactor.redact_text(kind));
}
if let Some(Value::String(session)) = map.get("sessionName") {
parts.push(format!("session {}", redactor.redact_text(session)));
}
if let Some(Value::String(within)) = map.get("within") {
parts.push(format!("within {}", redactor.redact_text(within)));
}
parts
}
fn build_preview(text: &str, max_lines: usize, max_chars: usize) -> (String, bool) {
if text.is_empty() {
return (String::new(), false);
}
let mut preview = String::new();
let mut truncated = false;
let mut remaining_chars = max_chars;
let mut consumed_all = true;
for (idx, line) in text.lines().enumerate() {
if idx >= max_lines || remaining_chars == 0 {
truncated = true;
consumed_all = false;
break;
}
let mut line_segment = String::new();
for ch in line.chars() {
if remaining_chars == 0 {
truncated = true;
consumed_all = false;
break;
}
line_segment.push(ch);
remaining_chars -= 1;
}
if !preview.is_empty() {
preview.push('\n');
}
preview.push_str(&line_segment);
if remaining_chars == 0 {
truncated = true;
consumed_all = false;
break;
}
}
if preview.is_empty() {
let collected: String = text.chars().take(max_chars).collect();
let total_chars = text.chars().count();
let collected_chars = collected.chars().count();
return (collected, collected_chars < total_chars);
}
(preview, truncated || !consumed_all)
}
fn format_duration(duration: Duration) -> String {
if duration.as_secs() >= 1 {
let secs = duration.as_secs_f64();
if secs >= 10.0 {
format!("{:.1}s", secs)
} else {
format!("{:.2}s", secs)
}
} else if duration.as_millis() >= 1 {
format!("{} ms", duration.as_millis())
} else {
format!("{} us", duration.as_micros())
}
}
fn total_elapsed(records: &[request::ExecutionRecord]) -> Option<Duration> {
if records.is_empty() {
return None;
}
let mut earliest: Option<Duration> = None;
let mut latest: Option<Duration> = None;
for record in records {
let start = match record.started_at.duration_since(SystemTime::UNIX_EPOCH) {
Ok(duration) => duration,
Err(_) => continue,
};
let finish = match record
.started_at
.checked_add(record.duration)
.and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
{
Some(duration) => duration,
None => continue,
};
earliest = Some(match earliest {
Some(current) => current.min(start),
None => start,
});
latest = Some(match latest {
Some(current) => current.max(finish),
None => finish,
});
}
match (earliest, latest) {
(Some(start), Some(end)) if end >= start => Some(end - start),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_body_output_truncates_when_not_verbose() {
let body = (1..=20)
.map(|idx| format!("line {idx}"))
.collect::<Vec<_>>()
.join("\n");
let (output, truncated) = build_body_output(&body, false);
assert!(truncated);
assert!(output.lines().count() <= PREVIEW_MAX_LINES);
}
#[test]
fn build_body_output_keeps_full_text_when_verbose() {
let body = (1..=20)
.map(|idx| format!("line {idx}"))
.collect::<Vec<_>>()
.join("\n");
let (output, truncated) = build_body_output(&body, true);
assert!(!truncated);
assert_eq!(output, body);
}
#[test]
fn format_timing_line_returns_none_for_empty_phases() {
assert_eq!(format_timing_line(&[]), None);
}
#[test]
fn format_timing_line_formats_named_phases() {
let phases = vec![
request::ArtifactTimingPhase::new("dns", Duration::from_millis(3)),
request::ArtifactTimingPhase::new("responseStart", Duration::from_millis(18)),
request::ArtifactTimingPhase::new("bodyRead", Duration::from_millis(1)),
];
assert_eq!(
format_timing_line(&phases),
Some("timing: dns 3 ms, responseStart 18 ms, bodyRead 1 ms".to_string())
);
}
#[test]
fn retry_attempt_label_omits_single_attempt() {
assert_eq!(retry_attempt_label(1), None);
}
#[test]
fn retry_attempt_label_formats_multiple_attempts() {
assert_eq!(retry_attempt_label(3), Some("3 attempts".to_string()));
}
#[test]
fn format_execution_trace_line_includes_dependency_and_session_context() {
let line = format_execution_trace_line(&request::ExecutionTraceEntry {
seq: 3,
kind: request::ExecutionTraceKind::Started,
request_index: Some(1),
request: Some("Receive updates".to_string()),
dependencies: vec!["Login".to_string()],
waiting_on: vec![],
protocol: Some(request::RequestProtocol::Sse),
protocol_context: Some(serde_json::json!({
"action": "receive",
"sessionName": "prices",
"within": "1s"
})),
sensitive_values: vec![],
sensitive_header_names: vec![],
duration: None,
reason: None,
related_request: None,
group: None,
cause: None,
message: None,
signal: None,
});
assert!(line.contains("started #1 Receive updates"));
assert!(line.contains("sse"));
assert!(line.contains("session prices"));
assert!(line.contains("after Login"));
}
#[test]
fn format_execution_trace_line_redacts_sensitive_values() {
let line = format_execution_trace_line(&request::ExecutionTraceEntry {
seq: 4,
kind: request::ExecutionTraceKind::Failed,
request_index: Some(2),
request: Some("Get token super-secret-token".to_string()),
dependencies: vec!["Bootstrap super-secret-token".to_string()],
waiting_on: vec![],
protocol: Some(request::RequestProtocol::Mcp),
protocol_context: Some(serde_json::json!({
"tool": "super-secret-token",
"sessionName": "super-secret-token"
})),
sensitive_header_names: vec![],
sensitive_values: vec!["super-secret-token".to_string()],
duration: None,
reason: Some("execution_failed".to_string()),
related_request: Some("Bootstrap super-secret-token".to_string()),
group: Some("group super-secret-token".to_string()),
cause: Some("cause super-secret-token".to_string()),
message: Some("failed because super-secret-token".to_string()),
signal: None,
});
assert!(line.contains("Get token [redacted]"));
assert!(line.contains("failed because [redacted]"));
assert!(!line.contains("super-secret-token"));
}
}