use console::style;
use std::time::{Duration, SystemTime};
use hen::{
automation,
error::HenError,
report::{self, BodyReportOptions},
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)),
}
}
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()
);
}
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 success_symbol = style("[ok]").green();
let index_label = style(format!("#{}", record.index)).dim();
let description_label = style(&record.description).bold();
let method_label = style(record.method.as_str()).cyan();
let url_label = style(&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();
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 heading = if verbose { "Body:" } else { "Body preview:" };
println!(" {}", style(heading).dim());
let body = record.execution.output.trim();
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 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(failure.request()).bold();
let detail = match failure.kind() {
request::RequestFailureKind::Execution { message, .. } => message.clone(),
request::RequestFailureKind::Dependency { dependency } => {
format!("skipped: dependency '{}' failed", dependency)
}
request::RequestFailureKind::MissingDependency { dependency } => {
format!("missing dependency '{}'", dependency)
}
request::RequestFailureKind::Join { message } => message.clone(),
request::RequestFailureKind::MapAborted { group, cause } => {
format!("skipped: '{}' aborted after failure in '{}'", group, cause)
}
};
println!(
"{} {} {} — {}",
failure_symbol,
index_label,
request_label,
style(detail).red()
);
}
pub(crate) fn print_summary(
records: &[request::ExecutionRecord],
failures: &[request::RequestFailure],
interrupted: Option<request::InterruptSignal>,
planned_count: usize,
) {
println!("{}", style("Summary").bold());
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) {
println!(
" {}",
style(format!(
"slowest {} ({})",
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()
);
}
}
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())
);
}
}