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