1use crate::i18n::Lang;
19use crate::tcp_info::{TcpInfo, TcpInfoDelta};
20use bytes::Bytes;
21use bytesize::ByteSize;
22use heck::ToTrainCase;
23use http::HeaderMap;
24use http::HeaderValue;
25use http::StatusCode;
26use nu_ansi_term::Color::{LightCyan, LightGreen, LightRed, LightYellow};
27use serde_json::{json, Map, Value};
28use std::fmt;
29use std::io::Write;
30use std::time::Duration;
31use tempfile::NamedTempFile;
32use time::{OffsetDateTime, UtcOffset};
33use unicode_truncate::Alignment;
34use unicode_truncate::UnicodeTruncateStr;
35
36pub static ALPN_HTTP2: &str = "h2";
37pub static ALPN_HTTP1: &str = "http/1.1";
38pub static ALPN_HTTP3: &str = "h3";
39
40pub(crate) fn format_time(timestamp_seconds: i64) -> String {
43 let Ok(utc) = OffsetDateTime::from_unix_timestamp(timestamp_seconds) else {
44 return timestamp_seconds.to_string();
45 };
46 let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
47 let dt = utc.to_offset(offset);
48 let (h, m, _) = offset.as_hms();
49 format!(
50 "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {}{:02}:{:02}",
51 dt.year(),
52 dt.month() as u8,
53 dt.day(),
54 dt.hour(),
55 dt.minute(),
56 dt.second(),
57 if h < 0 || m < 0 { '-' } else { '+' },
58 h.unsigned_abs(),
59 m.unsigned_abs(),
60 )
61}
62
63pub fn format_duration(duration: Duration) -> String {
64 if duration > Duration::from_secs(1) {
65 return format!("{:.2}s", duration.as_secs_f64());
66 }
67 if duration > Duration::from_millis(1) {
68 return format!("{}ms", duration.as_millis());
69 }
70 format!("{}µs", duration.as_micros())
71}
72
73pub fn format_throughput(bytes_per_sec: f64) -> String {
77 if !bytes_per_sec.is_finite() || bytes_per_sec <= 0.0 {
78 return "-".to_string();
79 }
80 if bytes_per_sec >= 1_000_000.0 {
81 format!("{:.1} MB/s", bytes_per_sec / 1_000_000.0)
82 } else if bytes_per_sec >= 1_000.0 {
83 format!("{:.1} KB/s", bytes_per_sec / 1_000.0)
84 } else {
85 format!("{bytes_per_sec:.0} B/s")
86 }
87}
88
89pub const FIRST_CHUNK_BYTES: usize = 100 * 1024;
91pub const THROUGHPUT_DISPLAY_THRESHOLD: usize = 1024 * 1024;
93
94struct Timeline {
95 name: String,
96 duration: Duration,
97}
98
99#[derive(Default, Debug, Clone)]
126pub struct HttpStat {
127 pub is_grpc: bool,
128 pub request_headers: HeaderMap<HeaderValue>,
129 pub dns_attempted: bool,
134 pub dns_lookup: Option<Duration>,
135 pub dns_connect: Option<Duration>,
141 pub quic_connect: Option<Duration>,
142 pub tcp_connect: Option<Duration>,
143 pub tcp_info_post_connect: Option<TcpInfo>,
147 pub tcp_info_final: Option<TcpInfo>,
152 pub tls_handshake: Option<Duration>,
153 pub request_send: Option<Duration>,
154 pub server_processing: Option<Duration>,
155 pub content_transfer: Option<Duration>,
156 pub wire_body_size: Option<usize>,
160 pub time_to_first_100k: Option<Duration>,
166 pub server_timing: Option<Vec<ServerTiming>>,
167 pub alt_svc: Option<Vec<AltSvc>>,
170 pub hsts: Option<Hsts>,
172 pub total: Option<Duration>,
173 pub addr: Option<String>,
174 pub grpc_status: Option<String>,
175 pub status: Option<StatusCode>,
176 pub tls: Option<String>,
177 pub tls_resumed: Option<bool>,
178 pub tls_early_data_accepted: Option<bool>,
179 pub tls_ocsp_stapled: Option<bool>,
180 pub alpn: Option<String>,
181 pub subject: Option<String>,
182 pub issuer: Option<String>,
183 pub cert_not_before: Option<String>,
184 pub cert_not_after: Option<String>,
185 pub cert_cipher: Option<String>,
186 pub cert_domains: Option<Vec<String>>,
187 pub certificates: Option<Vec<Certificate>>,
188 pub body: Option<Bytes>,
189 pub body_size: Option<usize>,
190 pub headers: Option<HeaderMap<HeaderValue>>,
191 pub error: Option<String>,
192 pub silent: bool,
193 pub verbose: bool,
194 pub pretty: bool,
195 pub include_headers: Option<Vec<String>>,
196 pub exclude_headers: Option<Vec<String>>,
197 pub waterfall: bool,
198 pub jq_filter: Option<String>,
199 pub show_tcp_info: bool,
202 pub lang: Lang,
206 pub version: Option<String>,
208}
209
210#[derive(Debug, Clone)]
211pub struct Certificate {
212 pub subject: String,
213 pub issuer: String,
214 pub not_before: String,
215 pub not_after: String,
216}
217
218#[derive(Debug, Clone)]
223pub struct ServerTiming {
224 pub name: String,
225 pub duration: Option<Duration>,
226 pub description: Option<String>,
227}
228
229pub fn parse_server_timing<'a, I>(values: I) -> Option<Vec<ServerTiming>>
232where
233 I: IntoIterator<Item = &'a str>,
234{
235 let mut out = Vec::new();
236 for raw in values {
237 for part in split_top_level_commas(raw) {
238 let mut subparts = part.split(';').map(str::trim);
239 let name = match subparts.next() {
240 Some(n) if !n.is_empty() => n.to_string(),
241 _ => continue,
242 };
243 let mut entry = ServerTiming {
244 name,
245 duration: None,
246 description: None,
247 };
248 for kv in subparts {
249 let Some(eq) = kv.find('=') else { continue };
250 let key = kv[..eq].trim().to_ascii_lowercase();
251 let mut val = kv[eq + 1..].trim();
252 if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
253 val = &val[1..val.len() - 1];
254 }
255 match key.as_str() {
256 "dur" => {
257 if let Ok(ms) = val.parse::<f64>() {
258 if ms.is_finite() && ms >= 0.0 {
259 entry.duration = Some(Duration::from_secs_f64(ms / 1000.0));
260 }
261 }
262 }
263 "desc" => entry.description = Some(val.to_string()),
264 _ => {}
265 }
266 }
267 out.push(entry);
268 }
269 }
270 if out.is_empty() {
271 None
272 } else {
273 Some(out)
274 }
275}
276
277#[derive(Debug, Clone)]
283pub struct AltSvc {
284 pub protocol: String,
285 pub authority: String,
286 pub max_age: Option<u64>,
287}
288
289#[derive(Debug, Clone)]
291pub struct Hsts {
292 pub max_age: u64,
293 pub include_subdomains: bool,
294 pub preload: bool,
295}
296
297pub fn parse_alt_svc<'a, I>(values: I) -> Option<Vec<AltSvc>>
301where
302 I: IntoIterator<Item = &'a str>,
303{
304 let mut out: Vec<AltSvc> = Vec::new();
305 for raw in values {
306 for part in split_top_level_commas(raw) {
307 if part.eq_ignore_ascii_case("clear") {
308 out.clear();
309 continue;
310 }
311 let mut subparts = part.split(';').map(str::trim);
312 let head = match subparts.next() {
313 Some(h) if !h.is_empty() => h,
314 _ => continue,
315 };
316 let Some(eq) = head.find('=') else { continue };
317 let protocol = head[..eq].trim().to_string();
318 let mut authority = head[eq + 1..].trim().to_string();
319 if authority.starts_with('"') && authority.ends_with('"') && authority.len() >= 2 {
320 authority = authority[1..authority.len() - 1].to_string();
321 }
322 let mut entry = AltSvc {
323 protocol,
324 authority,
325 max_age: None,
326 };
327 for kv in subparts {
328 let Some(eq) = kv.find('=') else { continue };
329 let key = kv[..eq].trim().to_ascii_lowercase();
330 let val = kv[eq + 1..].trim().trim_matches('"');
331 if key == "ma" {
332 if let Ok(n) = val.parse::<u64>() {
333 entry.max_age = Some(n);
334 }
335 }
336 }
337 out.push(entry);
338 }
339 }
340 if out.is_empty() {
341 None
342 } else {
343 Some(out)
344 }
345}
346
347pub fn parse_hsts(value: &str) -> Option<Hsts> {
351 let mut max_age: Option<u64> = None;
352 let mut include_subdomains = false;
353 let mut preload = false;
354 for part in value.split(';').map(str::trim) {
355 if part.is_empty() {
356 continue;
357 }
358 let (key, val) = match part.find('=') {
359 Some(eq) => (
360 part[..eq].trim().to_ascii_lowercase(),
361 Some(part[eq + 1..].trim().trim_matches('"')),
362 ),
363 None => (part.to_ascii_lowercase(), None),
364 };
365 match key.as_str() {
366 "max-age" => {
367 if let Some(v) = val {
368 if let Ok(n) = v.parse::<u64>() {
369 max_age = Some(n);
370 }
371 }
372 }
373 "includesubdomains" => include_subdomains = true,
374 "preload" => preload = true,
375 _ => {}
376 }
377 }
378 max_age.map(|m| Hsts {
379 max_age: m,
380 include_subdomains,
381 preload,
382 })
383}
384
385fn split_top_level_commas(s: &str) -> Vec<&str> {
387 let mut parts = Vec::new();
388 let mut start = 0usize;
389 let mut in_quotes = false;
390 let bytes = s.as_bytes();
391 let mut i = 0;
392 while i < bytes.len() {
393 match bytes[i] {
394 b'"' => in_quotes = !in_quotes,
395 b',' if !in_quotes => {
396 parts.push(s[start..i].trim());
397 start = i + 1;
398 }
399 _ => {}
400 }
401 i += 1;
402 }
403 parts.push(s[start..].trim());
404 parts
405}
406
407fn apply_jq_filter(body: &str, filter: &str) -> Result<String, String> {
421 let root: serde_json::Value = serde_json::from_str(body)
422 .map_err(|_| "response body is not JSON, cannot apply --jq filter".to_string())?;
423 let trimmed = filter.trim();
424 let owned;
426 let normalized = if !trimmed.starts_with('.') {
427 owned = format!(".{trimmed}");
428 owned.as_str()
429 } else {
430 trimmed
431 };
432
433 #[derive(Debug)]
435 enum Step {
436 Key(String),
437 Index(usize),
438 Iter,
439 }
440
441 fn tokenize(s: &str) -> Option<Vec<Step>> {
444 let s = s.strip_prefix('.')?;
445 if s.is_empty() {
446 return Some(vec![]);
447 }
448 let mut steps = Vec::new();
449 let mut remaining = s;
452 while !remaining.is_empty() {
453 if remaining.starts_with('[') {
454 let end = remaining.find(']')?;
456 let inner = &remaining[1..end];
457 if inner.is_empty() {
458 steps.push(Step::Iter);
459 } else {
460 let idx: usize = inner.parse().ok()?;
461 steps.push(Step::Index(idx));
462 }
463 remaining = &remaining[end + 1..];
464 if remaining.starts_with('.') {
465 remaining = &remaining[1..];
466 }
467 } else {
468 let end = remaining.find(['.', '[']).unwrap_or(remaining.len());
470 let key = &remaining[..end];
471 if key.is_empty()
475 || !key
476 .chars()
477 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
478 {
479 return None;
480 }
481 steps.push(Step::Key(key.to_string()));
482 remaining = &remaining[end..];
483 if remaining.starts_with('.') {
484 remaining = &remaining[1..];
485 }
486 }
487 }
488 Some(steps)
489 }
490
491 fn apply_steps(values: Vec<serde_json::Value>, steps: &[Step]) -> Vec<serde_json::Value> {
492 if steps.is_empty() {
493 return values;
494 }
495 let mut current = values;
496 for step in steps {
497 current = match step {
498 Step::Key(k) => current
499 .into_iter()
500 .filter_map(|v| v.get(k).cloned())
501 .collect(),
502 Step::Index(i) => current
503 .into_iter()
504 .filter_map(|v| v.get(i).cloned())
505 .collect(),
506 Step::Iter => current
507 .into_iter()
508 .flat_map(|v| match v {
509 serde_json::Value::Array(arr) => arr,
510 serde_json::Value::Object(map) => map.into_values().collect(),
511 other => vec![other],
512 })
513 .collect(),
514 };
515 }
516 current
517 }
518
519 let steps = tokenize(normalized).ok_or_else(|| {
520 format!("unsupported --jq filter '{filter}' (supported: .a.b, .[0], .[])")
521 })?;
522 let results = apply_steps(vec![root], &steps);
523
524 if results.len() == 1 {
525 serde_json::to_string_pretty(&results[0])
526 .map_err(|e| format!("failed to format --jq result: {e}"))
527 } else {
528 Ok(results
529 .iter()
530 .filter_map(|v| serde_json::to_string_pretty(v).ok())
531 .collect::<Vec<_>>()
532 .join("\n"))
533 }
534}
535
536impl HttpStat {
537 pub fn exit_code(&self) -> i32 {
547 if self.is_success() {
548 return 0;
549 }
550 if self.error.is_none() {
552 if let Some(status) = &self.status {
553 let code = status.as_u16();
554 if code >= 500 {
555 return 7;
556 }
557 if code >= 400 {
558 return 6;
559 }
560 }
561 return 1;
562 }
563 let err = self.error.as_deref().unwrap_or_default().to_lowercase();
564 if err.contains("timeout") || err.contains("elapsed") {
566 return 5;
567 }
568 if self.dns_attempted && self.dns_lookup.is_none() {
573 return 2;
574 }
575 if self.tcp_connect.is_none() && self.quic_connect.is_none() {
577 return 3;
578 }
579 if err.contains("rustls")
581 || err.contains("tls")
582 || err.contains("certificate")
583 || err.contains("invalid dns name")
584 {
585 return 4;
586 }
587 1
588 }
589
590 pub fn dns_query(&self) -> Option<Duration> {
595 match (self.dns_lookup, self.dns_connect) {
596 (Some(total), Some(connect)) => Some(total.saturating_sub(connect)),
597 _ => None,
598 }
599 }
600
601 pub fn throughput_bps(&self) -> Option<f64> {
605 let bytes = self.wire_body_size? as f64;
606 let secs = self.content_transfer?.as_secs_f64();
607 if secs <= 0.0 {
608 return None;
609 }
610 Some(bytes / secs)
611 }
612
613 pub fn first_chunk_throughput_bps(&self) -> Option<f64> {
617 let dur = self.time_to_first_100k?.as_secs_f64();
618 if dur <= 0.0 {
619 return None;
620 }
621 Some(FIRST_CHUNK_BYTES as f64 / dur)
622 }
623
624 pub fn tail_throughput_bps(&self) -> Option<f64> {
627 let total = self.content_transfer?;
628 let head = self.time_to_first_100k?;
629 let bytes = self.wire_body_size?;
630 if bytes <= FIRST_CHUNK_BYTES || total <= head {
631 return None;
632 }
633 let tail_bytes = (bytes - FIRST_CHUNK_BYTES) as f64;
634 let tail_secs = (total - head).as_secs_f64();
635 if tail_secs <= 0.0 {
636 return None;
637 }
638 Some(tail_bytes / tail_secs)
639 }
640
641 pub fn is_success(&self) -> bool {
642 if self.error.is_some() {
643 return false;
644 }
645 if self.is_grpc {
646 if let Some(grpc_status) = &self.grpc_status {
647 return grpc_status == "0";
648 }
649 return false;
650 }
651 let Some(status) = &self.status else {
652 return false;
653 };
654 if status.as_u16() >= 400 {
655 return false;
656 }
657 true
658 }
659
660 fn fmt_waterfall(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
663 let total = match self.total {
664 Some(t) if t.as_nanos() > 0 => t,
665 _ => return Ok(()),
666 };
667
668 const BAR_WIDTH: usize = 50;
669 const LABEL_W: usize = 15;
670
671 let s = self.lang.strings();
672 let (dns_a, dns_b) = if self.dns_connect.is_some() {
674 (
675 (s.dns_connect, self.dns_connect),
676 (s.dns_query, self.dns_query()),
677 )
678 } else {
679 ((s.dns_lookup, self.dns_lookup), ("", None))
680 };
681 let phases_vec: Vec<(&str, Option<Duration>)> = vec![
682 dns_a,
683 dns_b,
684 (s.tcp_connect, self.tcp_connect),
685 (s.tls_handshake, self.tls_handshake),
686 (s.quic_connect, self.quic_connect),
687 (s.request_send, self.request_send),
688 (s.server_processing_short, self.server_processing),
689 (s.content_transfer_short, self.content_transfer),
690 ];
691 let phases: &[(&str, Option<Duration>)] = &phases_vec;
692
693 let total_ns = total.as_nanos() as f64;
694 let mut elapsed = Duration::ZERO;
695 let mut col_cursor: usize = 0;
696
697 for (name, dur_opt) in phases {
698 let Some(dur) = dur_opt else { continue };
699
700 let start_col = col_cursor;
701 elapsed += *dur;
702 let ideal_end = ((elapsed.as_nanos() as f64 / total_ns * BAR_WIDTH as f64).round()
703 as usize)
704 .min(BAR_WIDTH);
705 let end_col = ideal_end.min(BAR_WIDTH);
706 if end_col > start_col {
707 col_cursor = end_col;
708 }
709
710 let bar: String = (0..BAR_WIDTH)
711 .map(|i| {
712 if i >= start_col && i < end_col {
713 '█'
714 } else {
715 '░'
716 }
717 })
718 .collect();
719
720 writeln!(
721 f,
722 " {:<LABEL_W$} [{}] {}",
723 name,
724 LightCyan.paint(bar),
725 LightCyan.paint(format_duration(*dur))
726 )?;
727 }
728
729 writeln!(f)?;
730 writeln!(
731 f,
732 " {:LABEL_W$} {:BAR_WIDTH$} {}: {}",
733 "",
734 "",
735 s.total,
736 LightCyan.paint(format_duration(total))
737 )?;
738 writeln!(f)
739 }
740
741 pub fn to_json(&self) -> Value {
742 let dur_us = |d: Option<Duration>| -> Value {
743 d.map_or(Value::Null, |d| json!(d.as_micros() as u64))
744 };
745
746 let mut obj = Map::new();
747
748 let mut timing = Map::new();
750 timing.insert("dns_lookup_us".into(), dur_us(self.dns_lookup));
751 timing.insert("dns_connect_us".into(), dur_us(self.dns_connect));
752 timing.insert("dns_query_us".into(), dur_us(self.dns_query()));
753 timing.insert("tcp_connect_us".into(), dur_us(self.tcp_connect));
754 timing.insert("tls_handshake_us".into(), dur_us(self.tls_handshake));
755 timing.insert("quic_connect_us".into(), dur_us(self.quic_connect));
756 timing.insert("request_send_us".into(), dur_us(self.request_send));
757 timing.insert(
758 "server_processing_us".into(),
759 dur_us(self.server_processing),
760 );
761 timing.insert("content_transfer_us".into(), dur_us(self.content_transfer));
762 timing.insert(
763 "time_to_first_100k_us".into(),
764 dur_us(self.time_to_first_100k),
765 );
766 timing.insert("total_us".into(), dur_us(self.total));
767 obj.insert("timing".into(), Value::Object(timing));
768
769 if self.wire_body_size.is_some() && self.content_transfer.is_some() {
772 let mut t = Map::new();
773 t.insert(
774 "wire_body_size".into(),
775 self.wire_body_size.map_or(Value::Null, |v| json!(v)),
776 );
777 let opt_f = |v: Option<f64>| -> Value { v.map_or(Value::Null, |x| json!(x)) };
778 t.insert("bps_total".into(), opt_f(self.throughput_bps()));
779 t.insert(
780 "bps_first_100k".into(),
781 opt_f(self.first_chunk_throughput_bps()),
782 );
783 t.insert("bps_tail".into(), opt_f(self.tail_throughput_bps()));
784 obj.insert("throughput".into(), Value::Object(t));
785 }
786
787 let tcp_info_json = |info: Option<&TcpInfo>| -> Value {
790 let Some(info) = info else { return Value::Null };
791 let mut m = Map::new();
792 m.insert("rtt_us".into(), dur_us(info.rtt));
793 m.insert("rttvar_us".into(), dur_us(info.rttvar));
794 m.insert(
795 "retransmits".into(),
796 info.retransmits.map_or(Value::Null, |v| json!(v)),
797 );
798 m.insert("cwnd".into(), info.cwnd.map_or(Value::Null, |v| json!(v)));
799 m.insert(
800 "snd_mss".into(),
801 info.snd_mss.map_or(Value::Null, |v| json!(v)),
802 );
803 Value::Object(m)
804 };
805 if self.tcp_info_post_connect.is_some() || self.tcp_info_final.is_some() {
806 let mut block = Map::new();
807 block.insert(
808 "post_connect".into(),
809 tcp_info_json(self.tcp_info_post_connect.as_ref()),
810 );
811 block.insert("final".into(), tcp_info_json(self.tcp_info_final.as_ref()));
812 if let Some(delta) = TcpInfoDelta::compute(
813 self.tcp_info_post_connect.as_ref(),
814 self.tcp_info_final.as_ref(),
815 ) {
816 let mut d = Map::new();
817 d.insert(
818 "retransmits_during".into(),
819 delta.retransmits_during.map_or(Value::Null, |v| json!(v)),
820 );
821 d.insert("rtt_final_us".into(), dur_us(delta.rtt_final));
822 d.insert(
823 "cwnd_final".into(),
824 delta.cwnd_final.map_or(Value::Null, |v| json!(v)),
825 );
826 block.insert("delta".into(), Value::Object(d));
827 }
828 obj.insert("tcp_info".into(), Value::Object(block));
829 }
830
831 if let Some(entries) = &self.server_timing {
833 let arr: Vec<Value> = entries
834 .iter()
835 .map(|e| {
836 let mut m = Map::new();
837 m.insert("name".into(), json!(e.name));
838 m.insert(
839 "duration_us".into(),
840 e.duration
841 .map_or(Value::Null, |d| json!(d.as_micros() as u64)),
842 );
843 m.insert(
844 "description".into(),
845 e.description.as_deref().map_or(Value::Null, |s| json!(s)),
846 );
847 Value::Object(m)
848 })
849 .collect();
850 obj.insert("server_timing".into(), Value::Array(arr));
851 }
852
853 if let Some(entries) = &self.alt_svc {
855 let arr: Vec<Value> = entries
856 .iter()
857 .map(|e| {
858 let mut m = Map::new();
859 m.insert("protocol".into(), json!(e.protocol));
860 m.insert("authority".into(), json!(e.authority));
861 m.insert(
862 "max_age_seconds".into(),
863 e.max_age.map_or(Value::Null, |v| json!(v)),
864 );
865 Value::Object(m)
866 })
867 .collect();
868 obj.insert("alt_svc".into(), Value::Array(arr));
869 }
870
871 if let Some(h) = &self.hsts {
873 let mut m = Map::new();
874 m.insert("max_age_seconds".into(), json!(h.max_age));
875 m.insert("include_subdomains".into(), json!(h.include_subdomains));
876 m.insert("preload".into(), json!(h.preload));
877 obj.insert("hsts".into(), Value::Object(m));
878 }
879
880 obj.insert(
882 "addr".into(),
883 self.addr.as_deref().map_or(Value::Null, |s| json!(s)),
884 );
885 obj.insert(
886 "status".into(),
887 self.status.map_or(Value::Null, |s| json!(s.as_u16())),
888 );
889 obj.insert(
890 "alpn".into(),
891 self.alpn.as_deref().map_or(Value::Null, |s| json!(s)),
892 );
893
894 if self.tls.is_some() {
896 let mut tls = Map::new();
897 tls.insert(
898 "version".into(),
899 self.tls.as_deref().map_or(Value::Null, |s| json!(s)),
900 );
901 tls.insert(
902 "cipher".into(),
903 self.cert_cipher
904 .as_deref()
905 .map_or(Value::Null, |s| json!(s)),
906 );
907 tls.insert(
908 "resumed".into(),
909 self.tls_resumed.map_or(Value::Null, |b| json!(b)),
910 );
911 tls.insert(
912 "early_data_accepted".into(),
913 self.tls_early_data_accepted
914 .map_or(Value::Null, |b| json!(b)),
915 );
916 tls.insert(
917 "ocsp_stapled".into(),
918 self.tls_ocsp_stapled.map_or(Value::Null, |b| json!(b)),
919 );
920 tls.insert(
921 "subject".into(),
922 self.subject.as_deref().map_or(Value::Null, |s| json!(s)),
923 );
924 tls.insert(
925 "issuer".into(),
926 self.issuer.as_deref().map_or(Value::Null, |s| json!(s)),
927 );
928 tls.insert(
929 "not_before".into(),
930 self.cert_not_before
931 .as_deref()
932 .map_or(Value::Null, |s| json!(s)),
933 );
934 tls.insert(
935 "not_after".into(),
936 self.cert_not_after
937 .as_deref()
938 .map_or(Value::Null, |s| json!(s)),
939 );
940 tls.insert(
941 "domains".into(),
942 self.cert_domains.as_ref().map_or(Value::Null, |d| json!(d)),
943 );
944 obj.insert("tls".into(), Value::Object(tls));
945 }
946
947 if let Some(headers) = &self.headers {
951 let mut hdr_map = Map::new();
952 for key in headers.keys() {
953 let mut values: Vec<Value> = headers
954 .get_all(key)
955 .iter()
956 .map(|value| json!(value.to_str().unwrap_or_default()))
957 .collect();
958 let v = if values.len() == 1 {
959 values.remove(0)
960 } else {
961 Value::Array(values)
962 };
963 hdr_map.insert(key.to_string(), v);
964 }
965 obj.insert("headers".into(), Value::Object(hdr_map));
966 }
967
968 obj.insert(
970 "body_size".into(),
971 self.body_size.map_or(Value::Null, |s| json!(s)),
972 );
973
974 obj.insert(
976 "error".into(),
977 self.error.as_deref().map_or(Value::Null, |e| json!(e)),
978 );
979 obj.insert("exit_code".into(), json!(self.exit_code()));
980
981 Value::Object(obj)
982 }
983}
984
985impl fmt::Display for HttpStat {
986 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
987 let s = self.lang.strings();
988 if let Some(addr) = &self.addr {
989 let label = if self.tcp_connect.is_some() || self.quic_connect.is_some() {
990 LightGreen.paint(s.connected_to)
991 } else {
992 LightYellow.paint(s.resolved_to)
993 };
994 let mut text = format!("{} {}", label, LightCyan.paint(addr));
995 if self.silent {
996 if let Some(status) = &self.status {
997 let alpn = self.alpn.as_deref().unwrap_or(ALPN_HTTP1);
998 let status_code = status.as_u16();
999 let status = if status_code < 400 {
1000 LightGreen.paint(status.to_string())
1001 } else {
1002 LightRed.paint(status.to_string())
1003 };
1004 text = format!(
1005 "{text} --> {} {}",
1006 LightCyan.paint(alpn.to_uppercase()),
1007 status
1008 );
1009 } else {
1010 text = format!("{text} --> {}", LightRed.paint(s.fail));
1011 }
1012 text = format!("{text} {}", format_duration(self.total.unwrap_or_default()));
1013 if let Some(true) = self.tls_resumed {
1017 let tag = if matches!(self.tls_early_data_accepted, Some(true)) {
1018 "0-RTT"
1019 } else {
1020 s.handshake_resumed
1021 };
1022 text = format!("{text} [{}]", LightYellow.paint(tag));
1023 }
1024 }
1025 writeln!(f, "{text}")?;
1026 }
1027 if let Some(error) = &self.error {
1028 writeln!(f, "{}: {}", s.error_label, LightRed.paint(error))?;
1029 }
1030 if self.silent {
1031 return Ok(());
1032 }
1033 if self.verbose {
1034 for (key, value) in self.request_headers.iter() {
1035 writeln!(
1036 f,
1037 "{}: {}",
1038 key.to_string().to_train_case(),
1039 LightCyan.paint(value.to_str().unwrap_or_default())
1040 )?;
1041 }
1042 writeln!(f)?;
1043 }
1044
1045 if let Some(status) = &self.status {
1046 let alpn = self.alpn.as_deref().unwrap_or(ALPN_HTTP1);
1047 let status_code = status.as_u16();
1048 let status = if status_code < 400 {
1049 LightGreen.paint(status.to_string())
1050 } else {
1051 LightRed.paint(status.to_string())
1052 };
1053 writeln!(f, "{} {}", LightCyan.paint(alpn.to_uppercase()), status)?;
1054 }
1055 if self.is_grpc {
1056 if self.is_success() {
1057 writeln!(f, "{}", LightGreen.paint(s.grpc_ok))?;
1058 }
1059 writeln!(f)?;
1060 }
1061
1062 if let Some(tls) = &self.tls {
1063 writeln!(f)?;
1064 writeln!(f, "{}: {}", s.tls_label, LightCyan.paint(tls))?;
1065 if let Some(cipher) = self.cert_cipher.as_deref() {
1067 writeln!(f, "{}: {}", s.cipher, LightCyan.paint(cipher))?;
1068 }
1069 if let Some(resumed) = self.tls_resumed {
1070 let label = if resumed {
1071 s.handshake_resumed
1072 } else {
1073 s.handshake_full
1074 };
1075 writeln!(f, "{}: {}", s.handshake, LightCyan.paint(label))?;
1076 }
1077 if let Some(accepted) = self.tls_early_data_accepted {
1078 let label = if accepted {
1079 s.early_data_accepted
1080 } else {
1081 s.early_data_not_accepted
1082 };
1083 writeln!(f, "{}: {}", s.early_data, LightCyan.paint(label))?;
1084 }
1085 if let Some(stapled) = self.tls_ocsp_stapled {
1086 let label = if stapled {
1087 s.ocsp_stapled
1088 } else {
1089 s.ocsp_not_stapled
1090 };
1091 writeln!(f, "{}: {}", s.ocsp, LightCyan.paint(label))?;
1092 }
1093 writeln!(
1094 f,
1095 "{}: {}",
1096 s.not_before,
1097 LightCyan.paint(self.cert_not_before.as_deref().unwrap_or_default())
1098 )?;
1099 writeln!(
1100 f,
1101 "{}: {}",
1102 s.not_after,
1103 LightCyan.paint(self.cert_not_after.as_deref().unwrap_or_default())
1104 )?;
1105 if self.verbose {
1106 writeln!(
1107 f,
1108 "{}: {}",
1109 s.subject,
1110 LightCyan.paint(self.subject.as_deref().unwrap_or_default())
1111 )?;
1112 writeln!(
1113 f,
1114 "{}: {}",
1115 s.issuer,
1116 LightCyan.paint(self.issuer.as_deref().unwrap_or_default())
1117 )?;
1118 writeln!(
1119 f,
1120 "{}: {}",
1121 s.cert_domains,
1122 LightCyan.paint(self.cert_domains.as_deref().unwrap_or_default().join(", "))
1123 )?;
1124 }
1125 writeln!(f)?;
1126
1127 if self.verbose {
1128 if let Some(certificates) = &self.certificates {
1129 writeln!(f, "{}", s.cert_chain)?;
1130 for (index, cert) in certificates.iter().enumerate() {
1131 writeln!(
1132 f,
1133 " {index} {}: {}",
1134 s.subject,
1135 LightCyan.paint(cert.subject.as_str())
1136 )?;
1137 writeln!(
1138 f,
1139 " {}: {}",
1140 s.issuer,
1141 LightCyan.paint(cert.issuer.as_str())
1142 )?;
1143 writeln!(
1144 f,
1145 " {}: {}",
1146 s.not_before,
1147 LightCyan.paint(cert.not_before.as_str())
1148 )?;
1149 writeln!(
1150 f,
1151 " {}: {}",
1152 s.not_after,
1153 LightCyan.paint(cert.not_after.as_str())
1154 )?;
1155 writeln!(f)?;
1156 }
1157 }
1158 }
1159 }
1160
1161 if (self.verbose || self.show_tcp_info)
1166 && (self.tcp_info_post_connect.is_some() || self.tcp_info_final.is_some())
1167 {
1168 writeln!(f, "{}", LightGreen.paint(s.kernel_tcp_heading))?;
1169 let render = |label: &str, info: &TcpInfo| -> String {
1172 let rtt = info.rtt.map(format_duration).unwrap_or_else(|| "-".into());
1173 let rttvar = info
1174 .rttvar
1175 .map(format_duration)
1176 .unwrap_or_else(|| "-".into());
1177 let retrans = info
1178 .retransmits
1179 .map(|v| v.to_string())
1180 .unwrap_or_else(|| "-".into());
1181 let cwnd = info
1182 .cwnd
1183 .map(|v| v.to_string())
1184 .unwrap_or_else(|| "-".into());
1185 let mss = info
1186 .snd_mss
1187 .map(|v| v.to_string())
1188 .unwrap_or_else(|| "-".into());
1189 format!(
1190 " {:<14} rtt {} \u{00B1} {} retrans {} cwnd {} mss {}",
1191 label, rtt, rttvar, retrans, cwnd, mss,
1192 )
1193 };
1194 if let Some(info) = &self.tcp_info_post_connect {
1195 writeln!(
1196 f,
1197 "{}",
1198 LightCyan.paint(render(s.tcp_post_connect_row, info))
1199 )?;
1200 }
1201 if let Some(info) = &self.tcp_info_final {
1202 writeln!(f, "{}", LightCyan.paint(render(s.tcp_final_row, info)))?;
1203 }
1204 if let Some(delta) = TcpInfoDelta::compute(
1205 self.tcp_info_post_connect.as_ref(),
1206 self.tcp_info_final.as_ref(),
1207 ) {
1208 if let Some(n) = delta.retransmits_during {
1209 let label = format!(" {} {n} {}", s.tcp_during, s.tcp_retransmit_word);
1210 let painted = if n == 0 {
1211 LightCyan.paint(label)
1212 } else {
1213 LightYellow.paint(label)
1214 };
1215 writeln!(f, "{painted}")?;
1216 }
1217 }
1218 writeln!(f)?;
1219 }
1220
1221 let mut is_text = false;
1222 let mut is_json = false;
1223 if let Some(headers) = &self.headers {
1224 for (key, value) in headers.iter() {
1225 let value = value.to_str().unwrap_or_default();
1226 if key == http::header::CONTENT_TYPE {
1227 if value.contains("text/") || value.contains("application/json") {
1228 is_text = true;
1229 }
1230 if value.contains("application/json") {
1231 is_json = true;
1232 }
1233 }
1234 let key_lower = key.as_str();
1235 let show = if let Some(includes) = &self.include_headers {
1236 includes.iter().any(|h| h == key_lower)
1237 } else if let Some(excludes) = &self.exclude_headers {
1238 !excludes.iter().any(|h| h == key_lower)
1239 } else {
1240 true
1241 };
1242 if show {
1243 writeln!(
1244 f,
1245 "{}: {}",
1246 key.to_string().to_train_case(),
1247 LightCyan.paint(value)
1248 )?;
1249 }
1250 }
1251 writeln!(f)?;
1252 }
1253
1254 if let Some(entries) = &self.server_timing {
1259 if !entries.is_empty() {
1260 const BAR_W: usize = 34;
1261 let name_w = entries
1262 .iter()
1263 .map(|e| e.name.chars().count())
1264 .max()
1265 .unwrap_or(0)
1266 .max(4);
1267
1268 let sum: Duration = entries.iter().filter_map(|e| e.duration).sum();
1269 let total_ns = (sum.as_nanos() as f64).max(1.0);
1270 let largest_idx: Option<usize> = entries
1271 .iter()
1272 .enumerate()
1273 .filter_map(|(i, e)| e.duration.filter(|d| !d.is_zero()).map(|d| (i, d)))
1274 .max_by_key(|(_, d)| *d)
1275 .map(|(i, _)| i);
1276
1277 let summary = match self.server_processing {
1278 Some(sp) if sp > sum => format!(
1279 "(\u{03A3} {} {} {} {} \u{00B7} {} {})",
1280 format_duration(sum),
1281 s.st_sum_of,
1282 format_duration(sp),
1283 s.server_processing,
1284 format_duration(sp - sum),
1285 s.st_unaccounted,
1286 ),
1287 Some(sp) => format!(
1288 "(\u{03A3} {} {} {} {})",
1289 format_duration(sum),
1290 s.st_sum_of,
1291 format_duration(sp),
1292 s.server_processing,
1293 ),
1294 None => format!("(\u{03A3} {})", format_duration(sum)),
1295 };
1296 writeln!(
1297 f,
1298 "{} {}",
1299 LightGreen.paint(s.server_timing_heading),
1300 LightCyan.paint(&summary),
1301 )?;
1302
1303 let sum_ns_u = sum.as_nanos();
1306 let mut cum_ns: u128 = 0;
1307 for (i, entry) in entries.iter().enumerate() {
1308 let name_pad = " ".repeat(name_w.saturating_sub(entry.name.chars().count()));
1309 let dur_ns = entry.duration.map(|d| d.as_nanos()).unwrap_or(0);
1310
1311 let start_col = ((cum_ns as f64 / total_ns) * BAR_W as f64).round() as usize;
1312 let start_col = start_col.min(BAR_W);
1313 let mut end_col =
1314 (((cum_ns + dur_ns) as f64 / total_ns) * BAR_W as f64).round() as usize;
1315 end_col = end_col.min(BAR_W);
1316 if dur_ns > 0 && end_col <= start_col {
1319 end_col = (start_col + 1).min(BAR_W);
1320 }
1321
1322 let bar: String = (0..BAR_W)
1323 .map(|col| {
1324 if dur_ns == 0 {
1325 let marker = start_col.min(BAR_W - 1);
1326 if col == marker {
1327 '\u{00B7}'
1328 } else {
1329 '\u{2591}'
1330 }
1331 } else if col >= start_col && col < end_col {
1332 '\u{2588}'
1333 } else {
1334 '\u{2591}'
1335 }
1336 })
1337 .collect();
1338
1339 let (dur_str, pct_str) = if dur_ns > 0 {
1340 let pct = if sum_ns_u > 0 {
1341 (dur_ns as f64 / sum_ns_u as f64) * 100.0
1342 } else {
1343 0.0
1344 };
1345 (
1346 format_duration(entry.duration.unwrap_or_default()),
1347 format!("{pct:>5.1}%"),
1348 )
1349 } else {
1350 ("\u{2014}".to_string(), "\u{2013}".to_string())
1351 };
1352
1353 let is_largest = Some(i) == largest_idx;
1354 let bar_painted = if is_largest {
1355 LightYellow.paint(&bar).to_string()
1356 } else {
1357 LightCyan.paint(&bar).to_string()
1358 };
1359 let dur_painted = if is_largest {
1360 LightYellow.paint(format!("{dur_str:>8}")).to_string()
1361 } else {
1362 LightCyan.paint(format!("{dur_str:>8}")).to_string()
1363 };
1364 let pct_painted = LightCyan.paint(format!("{pct_str:>6}")).to_string();
1365 let desc = entry
1366 .description
1367 .as_deref()
1368 .map(|d| format!(" ({d})"))
1369 .unwrap_or_default();
1370
1371 writeln!(
1372 f,
1373 " {}{} {} {} {}{}",
1374 LightCyan.paint(&entry.name),
1375 name_pad,
1376 bar_painted,
1377 dur_painted,
1378 pct_painted,
1379 desc,
1380 )?;
1381
1382 cum_ns += dur_ns;
1383 }
1384 writeln!(f)?;
1385 }
1386 }
1387
1388 if self.alt_svc.is_some() || self.hsts.is_some() {
1392 writeln!(f, "{}", LightGreen.paint(s.protocol_adv_heading))?;
1393 if let Some(entries) = &self.alt_svc {
1394 for entry in entries {
1395 let ma = match entry.max_age {
1396 Some(ma) => format!(" ma={ma}s"),
1397 None => String::new(),
1398 };
1399 writeln!(
1400 f,
1401 " {} {}={}{}",
1402 s.alt_svc_label,
1403 LightCyan.paint(&entry.protocol),
1404 LightCyan.paint(&entry.authority),
1405 LightCyan.paint(ma),
1406 )?;
1407 }
1408 }
1409 if let Some(h) = &self.hsts {
1410 let mut flags = String::new();
1411 if h.include_subdomains {
1412 flags.push_str("; includeSubDomains");
1413 }
1414 if h.preload {
1415 flags.push_str("; preload");
1416 }
1417 writeln!(
1418 f,
1419 " {} {}{}",
1420 s.hsts_label,
1421 LightCyan.paint(format!("max-age={}s", h.max_age)),
1422 LightCyan.paint(flags),
1423 )?;
1424 }
1425 writeln!(f)?;
1426 }
1427
1428 if self.waterfall {
1429 self.fmt_waterfall(f)?;
1430 } else {
1431 let width = 20;
1432
1433 let mut timelines = vec![];
1434 if let Some(connect) = self.dns_connect {
1437 timelines.push(Timeline {
1438 name: s.dns_connect.to_string(),
1439 duration: connect,
1440 });
1441 if let Some(query) = self.dns_query() {
1442 timelines.push(Timeline {
1443 name: s.dns_query.to_string(),
1444 duration: query,
1445 });
1446 }
1447 } else if let Some(value) = self.dns_lookup {
1448 timelines.push(Timeline {
1449 name: s.dns_lookup.to_string(),
1450 duration: value,
1451 });
1452 }
1453 if let Some(value) = self.tcp_connect {
1454 timelines.push(Timeline {
1455 name: s.tcp_connect.to_string(),
1456 duration: value,
1457 });
1458 }
1459 if let Some(value) = self.tls_handshake {
1460 timelines.push(Timeline {
1461 name: s.tls_handshake.to_string(),
1462 duration: value,
1463 });
1464 }
1465 if let Some(value) = self.quic_connect {
1466 timelines.push(Timeline {
1467 name: s.quic_connect.to_string(),
1468 duration: value,
1469 });
1470 }
1471 if let Some(value) = self.request_send {
1472 timelines.push(Timeline {
1473 name: s.request_send.to_string(),
1474 duration: value,
1475 });
1476 }
1477 if let Some(value) = self.server_processing {
1478 timelines.push(Timeline {
1479 name: s.server_processing.to_string(),
1480 duration: value,
1481 });
1482 }
1483 if let Some(value) = self.content_transfer {
1484 timelines.push(Timeline {
1485 name: s.content_transfer.to_string(),
1486 duration: value,
1487 });
1488 }
1489
1490 if !timelines.is_empty() {
1491 write!(f, " ")?;
1492 for (i, timeline) in timelines.iter().enumerate() {
1493 write!(
1494 f,
1495 "{}",
1496 timeline.name.unicode_pad(width, Alignment::Center, true)
1497 )?;
1498 if i < timelines.len() - 1 {
1499 write!(f, " ")?;
1500 }
1501 }
1502 writeln!(f)?;
1503
1504 write!(f, "[")?;
1505 for (i, timeline) in timelines.iter().enumerate() {
1506 write!(
1507 f,
1508 "{}",
1509 LightCyan.paint(
1510 format_duration(timeline.duration)
1511 .unicode_pad(width, Alignment::Center, true)
1512 .to_string(),
1513 )
1514 )?;
1515 if i < timelines.len() - 1 {
1516 write!(f, "|")?;
1517 }
1518 }
1519 writeln!(f, "]")?;
1520 }
1521
1522 write!(f, " ")?;
1523 for _ in 0..timelines.len() {
1524 write!(f, "{}", " ".repeat(width))?;
1525 write!(f, "|")?;
1526 }
1527 writeln!(f)?;
1528 write!(f, "{}", " ".repeat(width * timelines.len()))?;
1529 write!(
1530 f,
1531 "{}:{}\n\n",
1532 s.total,
1533 LightCyan.paint(format_duration(self.total.unwrap_or_default()))
1534 )?;
1535 }
1536
1537 if let Some(body) = &self.body {
1538 let status = self.status.unwrap_or(StatusCode::OK).as_u16();
1539 let mut body = std::str::from_utf8(body.as_ref())
1540 .unwrap_or_default()
1541 .to_string();
1542 if let Some(filter) = &self.jq_filter {
1543 body = match apply_jq_filter(&body, filter) {
1546 Ok(filtered) => filtered,
1547 Err(reason) => LightRed.paint(format!("jq: {reason}")).to_string(),
1548 };
1549 } else if self.pretty && is_json {
1550 if let Ok(json_body) = serde_json::from_str::<serde_json::Value>(&body) {
1551 if let Ok(value) = serde_json::to_string_pretty(&json_body) {
1552 body = value;
1553 }
1554 }
1555 }
1556 if self.verbose || self.jq_filter.is_some() || (is_text && body.len() < 4096) {
1557 let text = format!(
1558 "{}: {}",
1559 s.body_size,
1560 ByteSize(self.body_size.unwrap_or(0) as u64)
1561 );
1562 writeln!(f, "{}", LightCyan.paint(text))?;
1563 self.fmt_throughput(f)?;
1564 writeln!(f)?;
1565 if status >= 400 {
1566 writeln!(f, "{}", LightRed.paint(body))?;
1567 } else {
1568 writeln!(f, "{body}")?;
1569 }
1570 } else {
1571 let mut save_tips = "".to_string();
1572 if let Ok(mut file) = NamedTempFile::new() {
1573 if let Ok(()) = file.write_all(body.as_bytes()) {
1574 save_tips = format!("{}: {}", s.saved_to, file.path().display());
1575 let _ = file.keep();
1576 }
1577 }
1578 let text = format!(
1579 "{} {}",
1580 s.body_discarded,
1581 ByteSize(self.body_size.unwrap_or(0) as u64)
1582 );
1583 writeln!(f, "{} {}", LightCyan.paint(text), save_tips)?;
1584 self.fmt_throughput(f)?;
1585 }
1586 }
1587
1588 Ok(())
1589 }
1590}
1591
1592impl HttpStat {
1593 fn fmt_throughput(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1598 let Some(wire) = self.wire_body_size else {
1599 return Ok(());
1600 };
1601 if wire < THROUGHPUT_DISPLAY_THRESHOLD {
1602 return Ok(());
1603 }
1604 let Some(total) = self.throughput_bps() else {
1605 return Ok(());
1606 };
1607 let s = self.lang.strings();
1608 writeln!(
1609 f,
1610 "{} {}",
1611 LightCyan.paint(s.throughput),
1612 LightCyan.paint(format_throughput(total)),
1613 )?;
1614 if self.verbose {
1615 if let (Some(first), Some(tail)) = (
1616 self.first_chunk_throughput_bps(),
1617 self.tail_throughput_bps(),
1618 ) {
1619 writeln!(
1620 f,
1621 " {} {} {} {} {}",
1622 LightCyan.paint(s.throughput_first_100k),
1623 LightCyan.paint(format_throughput(first)),
1624 LightCyan.paint("·"),
1625 LightCyan.paint(s.throughput_then),
1626 LightCyan.paint(format_throughput(tail)),
1627 )?;
1628 }
1629 }
1630 Ok(())
1631 }
1632}
1633
1634pub struct BenchmarkSummary {
1635 pub stats: Vec<HttpStat>,
1636 pub lang: Lang,
1637}
1638
1639impl BenchmarkSummary {
1640 fn collect_sorted(&self, f: impl Fn(&HttpStat) -> Option<Duration>) -> Vec<Duration> {
1641 let mut v: Vec<Duration> = self.stats.iter().filter_map(f).collect();
1642 v.sort();
1643 v
1644 }
1645
1646 fn percentile(sorted: &[Duration], p: f64) -> Option<Duration> {
1647 if sorted.is_empty() {
1648 return None;
1649 }
1650 let idx = ((p * sorted.len() as f64).ceil() as usize).saturating_sub(1);
1651 Some(sorted[idx.min(sorted.len() - 1)])
1652 }
1653}
1654
1655impl fmt::Display for BenchmarkSummary {
1656 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1657 let total = self.stats.len();
1658 if total == 0 {
1659 return Ok(());
1660 }
1661
1662 let strs = self.lang.strings();
1663 let has_dns_connect = self.stats.iter().any(|s| s.dns_connect.is_some());
1666 let dns_cols: Vec<(&str, Vec<Duration>)> = if has_dns_connect {
1667 vec![
1668 (strs.dns_connect, self.collect_sorted(|s| s.dns_connect)),
1669 (strs.dns_query, self.collect_sorted(|s| s.dns_query())),
1670 ]
1671 } else {
1672 vec![(strs.dns_lookup, self.collect_sorted(|s| s.dns_lookup))]
1673 };
1674 let phases: Vec<(&str, Vec<Duration>)> = dns_cols
1675 .into_iter()
1676 .chain([
1677 (strs.tcp_connect, self.collect_sorted(|s| s.tcp_connect)),
1678 (strs.tls_handshake, self.collect_sorted(|s| s.tls_handshake)),
1679 (strs.quic_connect, self.collect_sorted(|s| s.quic_connect)),
1680 (strs.request_send, self.collect_sorted(|s| s.request_send)),
1681 (
1682 strs.server_processing_short,
1683 self.collect_sorted(|s| s.server_processing),
1684 ),
1685 (
1686 strs.content_transfer_short,
1687 self.collect_sorted(|s| s.content_transfer),
1688 ),
1689 (strs.total, self.collect_sorted(|s| s.total)),
1690 ])
1691 .filter(|(_, v)| !v.is_empty())
1692 .collect();
1693
1694 if phases.is_empty() {
1695 return Ok(());
1696 }
1697
1698 writeln!(f)?;
1699 writeln!(
1700 f,
1701 "{}",
1702 LightGreen.paint(format!(
1703 "{} ({total} {}) ---",
1704 strs.benchmark_results_prefix, strs.benchmark_results_requests,
1705 ))
1706 )?;
1707 writeln!(f)?;
1708
1709 let col_w = 18;
1710 let label_w = 6;
1711
1712 write!(f, "{:>label_w$} ", "")?;
1714 for (name, _) in &phases {
1715 write!(f, "{}", name.unicode_pad(col_w, Alignment::Center, true))?;
1716 }
1717 writeln!(f)?;
1718
1719 let rows: [(&str, f64); 6] = [
1722 (strs.min, 0.0),
1723 (strs.max, f64::INFINITY),
1724 (strs.avg, f64::NAN),
1725 ("p50", 0.5),
1726 ("p95", 0.95),
1727 ("p99", 0.99),
1728 ];
1729
1730 for (label, p) in &rows {
1731 write!(f, "{} ", LightGreen.paint(format!("{label:>label_w$}")))?;
1732 for (_, sorted) in &phases {
1733 let val = if p.is_nan() {
1734 if sorted.is_empty() {
1736 None
1737 } else {
1738 let sum: Duration = sorted.iter().sum();
1739 Some(sum / sorted.len() as u32)
1740 }
1741 } else if *p == 0.0 {
1742 sorted.first().copied()
1743 } else if p.is_infinite() {
1744 sorted.last().copied()
1745 } else {
1746 Self::percentile(sorted, *p)
1747 };
1748 let text = match val {
1749 Some(d) => format_duration(d),
1750 None => "-".to_string(),
1751 };
1752 write!(
1753 f,
1754 "{}",
1755 LightCyan.paint(text.unicode_pad(col_w, Alignment::Center, true).to_string())
1756 )?;
1757 }
1758 writeln!(f)?;
1759 }
1760
1761 writeln!(f)?;
1762 let success = self.stats.iter().filter(|s| s.is_success()).count();
1763 let pct = (success as f64 / total as f64) * 100.0;
1764 let success_text = format!("{}: {success}/{total} ({pct:.1}%)", strs.success);
1765 if success == total {
1766 writeln!(f, " {}", LightGreen.paint(success_text))?;
1767 } else {
1768 writeln!(f, " {}", LightRed.paint(success_text))?;
1769 }
1770
1771 Ok(())
1772 }
1773}
1774
1775#[cfg(test)]
1776mod tests {
1777 use super::*;
1778
1779 #[test]
1781 fn format_duration_units() {
1782 assert_eq!(format_duration(Duration::from_micros(999)), "999µs");
1783 assert_eq!(format_duration(Duration::from_millis(500)), "500ms");
1784 assert_eq!(format_duration(Duration::from_secs_f64(1.5)), "1.50s");
1785 }
1786
1787 #[test]
1788 fn format_duration_boundaries() {
1789 assert_eq!(format_duration(Duration::from_millis(1)), "1000µs");
1791 assert_eq!(format_duration(Duration::from_secs(1)), "1000ms");
1793 }
1794
1795 #[test]
1797 fn format_throughput_units() {
1798 assert_eq!(format_throughput(1_500_000.0), "1.5 MB/s");
1799 assert_eq!(format_throughput(1_000_000.0), "1.0 MB/s");
1800 assert_eq!(format_throughput(2_048.0), "2.0 KB/s");
1801 assert_eq!(format_throughput(500.0), "500 B/s");
1802 }
1803
1804 #[test]
1805 fn format_throughput_invalid_inputs_render_dash() {
1806 assert_eq!(format_throughput(0.0), "-");
1807 assert_eq!(format_throughput(-5.0), "-");
1808 assert_eq!(format_throughput(f64::NAN), "-");
1809 assert_eq!(format_throughput(f64::INFINITY), "-");
1810 }
1811
1812 #[test]
1814 fn format_time_invalid_falls_back_to_number() {
1815 assert_eq!(format_time(i64::MAX), i64::MAX.to_string());
1817 }
1818
1819 #[test]
1820 fn format_time_shape() {
1821 let s = format_time(0);
1823 assert_eq!(s.len(), 26, "unexpected format: {s}");
1824 let sign = s.as_bytes()[20] as char;
1825 assert!(sign == '+' || sign == '-', "no offset sign in: {s}");
1826 }
1827
1828 #[test]
1830 fn parse_server_timing_basic() {
1831 let st = parse_server_timing(["db;dur=53.2, app;dur=47.1;desc=\"App Server\""]).unwrap();
1832 assert_eq!(st.len(), 2);
1833 assert_eq!(st[0].name, "db");
1834 assert_eq!(st[0].duration, Some(Duration::from_secs_f64(53.2 / 1000.0)));
1835 assert_eq!(st[1].name, "app");
1836 assert_eq!(st[1].description.as_deref(), Some("App Server"));
1837 }
1838
1839 #[test]
1840 fn parse_server_timing_name_only_and_empty() {
1841 let st = parse_server_timing(["cache"]).unwrap();
1842 assert_eq!(st[0].name, "cache");
1843 assert_eq!(st[0].duration, None);
1844 assert!(parse_server_timing(Vec::<&str>::new()).is_none());
1845 assert!(parse_server_timing([""]).is_none());
1846 }
1847
1848 #[test]
1850 fn parse_alt_svc_basic() {
1851 let v = parse_alt_svc(["h3=\":443\"; ma=86400, h2=\"alt.example:443\""]).unwrap();
1852 assert_eq!(v.len(), 2);
1853 assert_eq!(v[0].protocol, "h3");
1854 assert_eq!(v[0].authority, ":443");
1855 assert_eq!(v[0].max_age, Some(86400));
1856 assert_eq!(v[1].protocol, "h2");
1857 assert_eq!(v[1].authority, "alt.example:443");
1858 assert_eq!(v[1].max_age, None);
1859 }
1860
1861 #[test]
1862 fn parse_alt_svc_clear_resets() {
1863 assert!(parse_alt_svc(["clear"]).is_none());
1864 let v = parse_alt_svc(["h3=\":443\", clear, h2=\":8443\""]).unwrap();
1865 assert_eq!(v.len(), 1);
1866 assert_eq!(v[0].protocol, "h2");
1867 }
1868
1869 #[test]
1871 fn parse_hsts_full() {
1872 let h = parse_hsts("max-age=31536000; includeSubDomains; preload").unwrap();
1873 assert_eq!(h.max_age, 31536000);
1874 assert!(h.include_subdomains);
1875 assert!(h.preload);
1876 }
1877
1878 #[test]
1879 fn parse_hsts_minimal_and_invalid() {
1880 let h = parse_hsts("max-age=0").unwrap();
1881 assert_eq!(h.max_age, 0);
1882 assert!(!h.include_subdomains);
1883 assert!(!h.preload);
1884 assert!(parse_hsts("includeSubDomains; preload").is_none());
1886 assert_eq!(parse_hsts("max-age=\"100\"").unwrap().max_age, 100);
1888 }
1889
1890 const JQ_BODY: &str = r#"{"name":"x","items":[{"name":"a"},{"name":"b"}]}"#;
1892
1893 #[test]
1894 fn jq_identity_and_key() {
1895 assert!(apply_jq_filter(JQ_BODY, ".").unwrap().contains("\"name\""));
1896 assert_eq!(apply_jq_filter(JQ_BODY, ".name"), Ok("\"x\"".to_string()));
1897 assert_eq!(apply_jq_filter(JQ_BODY, "name"), Ok("\"x\"".to_string()));
1899 }
1900
1901 #[test]
1902 fn jq_iterate_and_index() {
1903 assert_eq!(
1904 apply_jq_filter(JQ_BODY, ".items[].name"),
1905 Ok("\"a\"\n\"b\"".to_string())
1906 );
1907 assert_eq!(
1908 apply_jq_filter(JQ_BODY, ".items[0].name"),
1909 Ok("\"a\"".to_string())
1910 );
1911 }
1912
1913 #[test]
1914 fn jq_missing_key_and_bad_json() {
1915 assert_eq!(apply_jq_filter(JQ_BODY, ".nope"), Ok(String::new()));
1917 assert!(apply_jq_filter("{not json", ".x").is_err());
1919 }
1920
1921 #[test]
1922 fn jq_unsupported_syntax_is_rejected() {
1923 for f in [
1925 ".items | length",
1926 ".items[] | select(.name)",
1927 ".name?",
1928 "keys | length",
1929 ] {
1930 assert!(
1931 apply_jq_filter(JQ_BODY, f).is_err(),
1932 "expected '{f}' to be rejected"
1933 );
1934 }
1935 }
1936
1937 #[test]
1939 fn to_json_keeps_duplicate_headers() {
1940 let mut headers = HeaderMap::new();
1941 headers.append("set-cookie", "a=1".parse().unwrap());
1942 headers.append("set-cookie", "b=2".parse().unwrap());
1943 headers.insert("content-type", "text/html".parse().unwrap());
1944 let stat = HttpStat {
1945 headers: Some(headers),
1946 ..Default::default()
1947 };
1948 let json = stat.to_json();
1949 let hdrs = json.get("headers").unwrap();
1950 assert_eq!(
1951 hdrs.get("set-cookie").unwrap(),
1952 &serde_json::json!(["a=1", "b=2"])
1953 );
1954 assert_eq!(
1955 hdrs.get("content-type").unwrap(),
1956 &serde_json::json!("text/html")
1957 );
1958 }
1959
1960 #[test]
1962 fn success_and_status_exit_codes() {
1963 assert!(!HttpStat::default().is_success());
1964 assert_eq!(HttpStat::default().exit_code(), 1);
1965
1966 let ok = HttpStat {
1967 status: Some(StatusCode::OK),
1968 ..Default::default()
1969 };
1970 assert!(ok.is_success());
1971 assert_eq!(ok.exit_code(), 0);
1972
1973 let c404 = HttpStat {
1974 status: Some(StatusCode::NOT_FOUND),
1975 ..Default::default()
1976 };
1977 assert!(!c404.is_success());
1978 assert_eq!(c404.exit_code(), 6);
1979
1980 let c500 = HttpStat {
1981 status: Some(StatusCode::INTERNAL_SERVER_ERROR),
1982 ..Default::default()
1983 };
1984 assert_eq!(c500.exit_code(), 7);
1985 }
1986
1987 #[test]
1988 fn error_exit_codes_by_phase() {
1989 let timeout = HttpStat {
1990 error: Some("operation timeout".into()),
1991 ..Default::default()
1992 };
1993 assert_eq!(timeout.exit_code(), 5);
1994
1995 let dns = HttpStat {
1996 error: Some("no such host".into()),
1997 dns_attempted: true,
1998 ..Default::default()
1999 };
2000 assert_eq!(dns.exit_code(), 2);
2001
2002 let tcp = HttpStat {
2003 error: Some("connection refused".into()),
2004 dns_attempted: true,
2005 dns_lookup: Some(Duration::from_millis(1)),
2006 ..Default::default()
2007 };
2008 assert_eq!(tcp.exit_code(), 3);
2009
2010 let refused_no_dns = HttpStat {
2013 error: Some("connection refused".into()),
2014 ..Default::default()
2015 };
2016 assert_eq!(refused_no_dns.exit_code(), 3);
2017
2018 let tls = HttpStat {
2019 error: Some("rustls: bad certificate".into()),
2020 dns_lookup: Some(Duration::from_millis(1)),
2021 tcp_connect: Some(Duration::from_millis(1)),
2022 ..Default::default()
2023 };
2024 assert_eq!(tls.exit_code(), 4);
2025 }
2026
2027 #[test]
2028 fn grpc_success_and_failure() {
2029 let ok = HttpStat {
2030 is_grpc: true,
2031 grpc_status: Some("0".into()),
2032 ..Default::default()
2033 };
2034 assert!(ok.is_success());
2035 assert_eq!(ok.exit_code(), 0);
2036
2037 let bad = HttpStat {
2038 is_grpc: true,
2039 grpc_status: Some("5".into()),
2040 ..Default::default()
2041 };
2042 assert!(!bad.is_success());
2043 assert_eq!(bad.exit_code(), 1);
2044 }
2045
2046 #[test]
2048 fn throughput_overall() {
2049 let s = HttpStat {
2050 wire_body_size: Some(1_000_000),
2051 content_transfer: Some(Duration::from_secs(1)),
2052 ..Default::default()
2053 };
2054 assert!((s.throughput_bps().unwrap() - 1_000_000.0).abs() < 1.0);
2055
2056 let z = HttpStat {
2058 wire_body_size: Some(100),
2059 content_transfer: Some(Duration::ZERO),
2060 ..Default::default()
2061 };
2062 assert!(z.throughput_bps().is_none());
2063 assert!(HttpStat::default().throughput_bps().is_none());
2064 }
2065
2066 #[test]
2067 fn throughput_split_first_chunk_and_tail() {
2068 let s = HttpStat {
2069 wire_body_size: Some(200 * 1024),
2070 content_transfer: Some(Duration::from_secs(2)),
2071 time_to_first_100k: Some(Duration::from_secs(1)),
2072 ..Default::default()
2073 };
2074 assert!((s.first_chunk_throughput_bps().unwrap() - FIRST_CHUNK_BYTES as f64).abs() < 1.0);
2076 assert!((s.tail_throughput_bps().unwrap() - 100.0 * 1024.0).abs() < 1.0);
2078
2079 let small = HttpStat {
2081 wire_body_size: Some(50_000),
2082 content_transfer: Some(Duration::from_secs(2)),
2083 time_to_first_100k: Some(Duration::from_secs(1)),
2084 ..Default::default()
2085 };
2086 assert!(small.tail_throughput_bps().is_none());
2087 }
2088
2089 #[test]
2090 fn dns_query_derivation() {
2091 let s = HttpStat {
2092 dns_lookup: Some(Duration::from_millis(50)),
2093 dns_connect: Some(Duration::from_millis(20)),
2094 ..Default::default()
2095 };
2096 assert_eq!(s.dns_query(), Some(Duration::from_millis(30)));
2097
2098 let racy = HttpStat {
2100 dns_lookup: Some(Duration::from_millis(10)),
2101 dns_connect: Some(Duration::from_millis(20)),
2102 ..Default::default()
2103 };
2104 assert_eq!(racy.dns_query(), Some(Duration::ZERO));
2105
2106 let plain = HttpStat {
2108 dns_lookup: Some(Duration::from_millis(5)),
2109 ..Default::default()
2110 };
2111 assert!(plain.dns_query().is_none());
2112 }
2113
2114 #[test]
2116 fn to_json_blocks_are_conditional() {
2117 let with_body = HttpStat {
2118 status: Some(StatusCode::OK),
2119 wire_body_size: Some(1_000_000),
2120 content_transfer: Some(Duration::from_secs(1)),
2121 ..Default::default()
2122 };
2123 let v = with_body.to_json();
2124 assert!(v.is_object());
2125 assert!(v.get("timing").is_some());
2126 assert!(v.get("throughput").is_some());
2127
2128 let no_body = HttpStat {
2130 status: Some(StatusCode::OK),
2131 ..Default::default()
2132 };
2133 assert!(no_body.to_json().get("throughput").is_none());
2134 }
2135}