Skip to main content

http_stat/
stats.rs

1// See the License for the specific language governing permissions and
2// limitations under the License.
3
4// Copyright 2025 Tree xie.
5//
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10// http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18use crate::i18n::Lang;
19use crate::tcp_info::{TcpInfo, TcpInfoDelta};
20use bytes::Bytes;
21use bytesize::ByteSize;
22use chrono::{Local, TimeZone};
23use heck::ToTrainCase;
24use http::HeaderMap;
25use http::HeaderValue;
26use http::StatusCode;
27use nu_ansi_term::Color::{LightCyan, LightGreen, LightRed, LightYellow};
28use serde_json::{json, Map, Value};
29use std::fmt;
30use std::io::Write;
31use std::time::Duration;
32use tempfile::NamedTempFile;
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
40// Format timestamp to human-readable string
41pub(crate) fn format_time(timestamp_seconds: i64) -> String {
42    Local
43        .timestamp_nanos(timestamp_seconds * 1_000_000_000)
44        .to_string()
45}
46
47pub fn format_duration(duration: Duration) -> String {
48    if duration > Duration::from_secs(1) {
49        return format!("{:.2}s", duration.as_secs_f64());
50    }
51    if duration > Duration::from_millis(1) {
52        return format!("{}ms", duration.as_millis());
53    }
54    format!("{}µs", duration.as_micros())
55}
56
57/// Format a throughput value (bytes/s) into a human-readable string using
58/// decimal units (MB/s = 10^6 B/s, the network-convention) — bandwidth is
59/// almost universally reported in decimal, even when storage uses binary.
60pub fn format_throughput(bytes_per_sec: f64) -> String {
61    if !bytes_per_sec.is_finite() || bytes_per_sec <= 0.0 {
62        return "-".to_string();
63    }
64    if bytes_per_sec >= 1_000_000.0 {
65        format!("{:.1} MB/s", bytes_per_sec / 1_000_000.0)
66    } else if bytes_per_sec >= 1_000.0 {
67        format!("{:.1} KB/s", bytes_per_sec / 1_000.0)
68    } else {
69        format!("{bytes_per_sec:.0} B/s")
70    }
71}
72
73/// Size of the "first chunk" window used for throughput splitting.
74pub const FIRST_CHUNK_BYTES: usize = 100 * 1024;
75/// Threshold above which we render the overall Throughput line.
76pub const THROUGHPUT_DISPLAY_THRESHOLD: usize = 1024 * 1024;
77
78struct Timeline {
79    name: String,
80    duration: Duration,
81}
82
83/// Statistics and information collected during an HTTP request.
84///
85/// This struct contains timing information for each phase of the request,
86/// connection details, TLS information, and response data.
87///
88/// # Fields
89///
90/// * `dns_lookup` - Time taken for DNS resolution
91/// * `quic_connect` - Time taken to establish QUIC connection (for HTTP/3)
92/// * `tcp_connect` - Time taken to establish TCP connection
93/// * `tls_handshake` - Time taken for TLS handshake (for HTTPS)
94/// * `request_send` - Time to send the request headers and body
95/// * `server_processing` - Time from request fully sent to first response byte
96/// * `content_transfer` - Time taken to transfer the response body
97/// * `total` - Total time taken for the entire request
98/// * `addr` - Resolved IP address and port
99/// * `status` - HTTP response status code
100/// * `tls` - TLS protocol version used
101/// * `alpn` - Application-Layer Protocol Negotiation (ALPN) protocol selected
102/// * `cert_not_before` - Certificate validity start time
103/// * `cert_not_after` - Certificate validity end time
104/// * `cert_cipher` - TLS cipher suite used
105/// * `cert_domains` - List of domains in the certificate's Subject Alternative Names
106/// * `body` - Response body content
107/// * `headers` - Response headers
108/// * `error` - Any error that occurred during the request
109#[derive(Default, Debug, Clone)]
110pub struct HttpStat {
111    pub is_grpc: bool,
112    pub request_headers: HeaderMap<HeaderValue>,
113    pub dns_lookup: Option<Duration>,
114    /// Cold connect cost (TCP + TLS) to the DNS server. Only populated when
115    /// using DoH or DoT — for plain UDP DNS this stays None. When present,
116    /// the `dns_lookup` total can be split into `dns_connect` and a derived
117    /// `dns_query = dns_lookup - dns_connect`, making it possible to tell
118    /// whether DoH/DoT latency comes from TLS handshake or query processing.
119    pub dns_connect: Option<Duration>,
120    pub quic_connect: Option<Duration>,
121    pub tcp_connect: Option<Duration>,
122    /// Kernel TCP statistics sampled right after `connect(2)`. Provides the
123    /// baseline RTT, MSS and initial cwnd before any application data flows.
124    /// Linux + macOS only — None on other platforms.
125    pub tcp_info_post_connect: Option<TcpInfo>,
126    /// Kernel TCP statistics sampled after the response body has been fully
127    /// received. The diff against `tcp_info_post_connect` reveals retransmits
128    /// during the request — the diagnostic answer to "was Content Transfer
129    /// slow because of packet loss or just slow start?".
130    pub tcp_info_final: Option<TcpInfo>,
131    pub tls_handshake: Option<Duration>,
132    pub request_send: Option<Duration>,
133    pub server_processing: Option<Duration>,
134    pub content_transfer: Option<Duration>,
135    /// Bytes actually received over the wire (pre-decompression). For an
136    /// uncompressed response this matches `body_size`; for `gzip`/`br`/`zstd`
137    /// it's smaller and is the right denominator for *network* throughput.
138    pub wire_body_size: Option<usize>,
139    /// Time from the start of `content_transfer` until the first 100 KiB of
140    /// body bytes had arrived. Combined with the overall content_transfer
141    /// duration, it splits download throughput into "first 100 KB" vs
142    /// "tail" — the former is TCP slow-start dominated, the latter is the
143    /// steady-state rate the server can sustain.
144    pub time_to_first_100k: Option<Duration>,
145    pub server_timing: Option<Vec<ServerTiming>>,
146    pub total: Option<Duration>,
147    pub addr: Option<String>,
148    pub grpc_status: Option<String>,
149    pub status: Option<StatusCode>,
150    pub tls: Option<String>,
151    pub tls_resumed: Option<bool>,
152    pub tls_early_data_accepted: Option<bool>,
153    pub tls_ocsp_stapled: Option<bool>,
154    pub alpn: Option<String>,
155    pub subject: Option<String>,
156    pub issuer: Option<String>,
157    pub cert_not_before: Option<String>,
158    pub cert_not_after: Option<String>,
159    pub cert_cipher: Option<String>,
160    pub cert_domains: Option<Vec<String>>,
161    pub certificates: Option<Vec<Certificate>>,
162    pub body: Option<Bytes>,
163    pub body_size: Option<usize>,
164    pub headers: Option<HeaderMap<HeaderValue>>,
165    pub error: Option<String>,
166    pub silent: bool,
167    pub verbose: bool,
168    pub pretty: bool,
169    pub include_headers: Option<Vec<String>>,
170    pub exclude_headers: Option<Vec<String>>,
171    pub waterfall: bool,
172    pub jq_filter: Option<String>,
173    /// When true, render the Kernel TCP block even without `--verbose`.
174    /// Driven by the CLI `--tcp-info` flag.
175    pub show_tcp_info: bool,
176    /// Display language for the terminal renderer. JSON output is always
177    /// in English (machine contract). Driven by `--lang` or auto-detected
178    /// from LC_ALL/LC_MESSAGES/LANG.
179    pub lang: Lang,
180}
181
182#[derive(Debug, Clone)]
183pub struct Certificate {
184    pub subject: String,
185    pub issuer: String,
186    pub not_before: String,
187    pub not_after: String,
188}
189
190/// A single entry parsed from the `Server-Timing` response header (RFC 8673 / W3C).
191///
192/// Format: `name[;dur=<ms>][;desc="<text>"]`, possibly multiple comma-separated entries
193/// per header, possibly multiple `Server-Timing` headers.
194#[derive(Debug, Clone)]
195pub struct ServerTiming {
196    pub name: String,
197    pub duration: Option<Duration>,
198    pub description: Option<String>,
199}
200
201/// Parse all `Server-Timing` header values into a flat list of entries.
202/// Returns `None` if the input iterator yields no entries.
203pub fn parse_server_timing<'a, I>(values: I) -> Option<Vec<ServerTiming>>
204where
205    I: IntoIterator<Item = &'a str>,
206{
207    let mut out = Vec::new();
208    for raw in values {
209        for part in split_top_level_commas(raw) {
210            let mut subparts = part.split(';').map(str::trim);
211            let name = match subparts.next() {
212                Some(n) if !n.is_empty() => n.to_string(),
213                _ => continue,
214            };
215            let mut entry = ServerTiming {
216                name,
217                duration: None,
218                description: None,
219            };
220            for kv in subparts {
221                let Some(eq) = kv.find('=') else { continue };
222                let key = kv[..eq].trim().to_ascii_lowercase();
223                let mut val = kv[eq + 1..].trim();
224                if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
225                    val = &val[1..val.len() - 1];
226                }
227                match key.as_str() {
228                    "dur" => {
229                        if let Ok(ms) = val.parse::<f64>() {
230                            if ms.is_finite() && ms >= 0.0 {
231                                entry.duration = Some(Duration::from_secs_f64(ms / 1000.0));
232                            }
233                        }
234                    }
235                    "desc" => entry.description = Some(val.to_string()),
236                    _ => {}
237                }
238            }
239            out.push(entry);
240        }
241    }
242    if out.is_empty() {
243        None
244    } else {
245        Some(out)
246    }
247}
248
249/// Split on top-level commas, ignoring commas inside double-quoted strings.
250fn split_top_level_commas(s: &str) -> Vec<&str> {
251    let mut parts = Vec::new();
252    let mut start = 0usize;
253    let mut in_quotes = false;
254    let bytes = s.as_bytes();
255    let mut i = 0;
256    while i < bytes.len() {
257        match bytes[i] {
258            b'"' => in_quotes = !in_quotes,
259            b',' if !in_quotes => {
260                parts.push(s[start..i].trim());
261                start = i + 1;
262            }
263            _ => {}
264        }
265        i += 1;
266    }
267    parts.push(s[start..].trim());
268    parts
269}
270
271/// Apply a simple jq-style field selector to a JSON string.
272/// Supported syntax:
273///   .                    identity (pretty-print)
274///   .field               object key access
275///   .field.sub           nested key access
276///   .[0]                 array index
277///   .[]                  iterate all array/object values
278///   combinations: .items[].name, .a.b[2].c, etc.
279fn apply_jq_filter(body: &str, filter: &str) -> Option<String> {
280    let root: serde_json::Value = serde_json::from_str(body).ok()?;
281    let filter = filter.trim();
282    // Allow omitting the leading '.' for convenience (e.g. "os" → ".os")
283    let owned;
284    let filter = if !filter.starts_with('.') {
285        owned = format!(".{filter}");
286        owned.as_str()
287    } else {
288        filter
289    };
290
291    // Tokenise the filter string into a list of access steps.
292    #[derive(Debug)]
293    enum Step {
294        Key(String),
295        Index(usize),
296        Iter,
297    }
298
299    fn tokenize(s: &str) -> Option<Vec<Step>> {
300        let s = s.strip_prefix('.')?;
301        if s.is_empty() {
302            return Some(vec![]);
303        }
304        let mut steps = Vec::new();
305        // Split on '.' but keep bracket expressions attached to the preceding key.
306        // We walk char-by-char to handle `key[0].next` etc.
307        let mut remaining = s;
308        while !remaining.is_empty() {
309            if remaining.starts_with('[') {
310                // bracket at the start: .[0] or .[]
311                let end = remaining.find(']')?;
312                let inner = &remaining[1..end];
313                if inner.is_empty() {
314                    steps.push(Step::Iter);
315                } else {
316                    let idx: usize = inner.parse().ok()?;
317                    steps.push(Step::Index(idx));
318                }
319                remaining = &remaining[end + 1..];
320                if remaining.starts_with('.') {
321                    remaining = &remaining[1..];
322                }
323            } else {
324                // read up to next '.' or '['
325                let end = remaining.find(['.', '[']).unwrap_or(remaining.len());
326                let key = &remaining[..end];
327                if !key.is_empty() {
328                    steps.push(Step::Key(key.to_string()));
329                }
330                remaining = &remaining[end..];
331                if remaining.starts_with('.') {
332                    remaining = &remaining[1..];
333                }
334            }
335        }
336        Some(steps)
337    }
338
339    fn apply_steps(values: Vec<serde_json::Value>, steps: &[Step]) -> Vec<serde_json::Value> {
340        if steps.is_empty() {
341            return values;
342        }
343        let mut current = values;
344        for step in steps {
345            current = match step {
346                Step::Key(k) => current
347                    .into_iter()
348                    .filter_map(|v| v.get(k).cloned())
349                    .collect(),
350                Step::Index(i) => current
351                    .into_iter()
352                    .filter_map(|v| v.get(i).cloned())
353                    .collect(),
354                Step::Iter => current
355                    .into_iter()
356                    .flat_map(|v| match v {
357                        serde_json::Value::Array(arr) => arr,
358                        serde_json::Value::Object(map) => map.into_values().collect(),
359                        other => vec![other],
360                    })
361                    .collect(),
362            };
363        }
364        current
365    }
366
367    let steps = tokenize(filter)?;
368    let results = apply_steps(vec![root], &steps);
369
370    if results.len() == 1 {
371        serde_json::to_string_pretty(&results[0]).ok()
372    } else {
373        Some(
374            results
375                .iter()
376                .filter_map(|v| serde_json::to_string_pretty(v).ok())
377                .collect::<Vec<_>>()
378                .join("\n"),
379        )
380    }
381}
382
383impl HttpStat {
384    /// Returns a semantic exit code based on the error type:
385    /// - 0: Success
386    /// - 1: General/unknown error
387    /// - 2: DNS resolution failure
388    /// - 3: TCP connection failure
389    /// - 4: TLS/SSL error
390    /// - 5: Timeout
391    /// - 6: HTTP 4xx client error
392    /// - 7: HTTP 5xx server error
393    pub fn exit_code(&self) -> i32 {
394        if self.is_success() {
395            return 0;
396        }
397        // HTTP status errors (no connection error, but bad status)
398        if self.error.is_none() {
399            if let Some(status) = &self.status {
400                let code = status.as_u16();
401                if code >= 500 {
402                    return 7;
403                }
404                if code >= 400 {
405                    return 6;
406                }
407            }
408            return 1;
409        }
410        let err = self.error.as_deref().unwrap_or_default().to_lowercase();
411        // Timeout (check before phase-based detection since timeout can happen in any phase)
412        if err.contains("timeout") || err.contains("elapsed") {
413            return 5;
414        }
415        // DNS failure: dns_lookup phase never completed
416        if self.dns_lookup.is_none() {
417            return 2;
418        }
419        // TCP failure: tcp/quic connection phase never completed
420        if self.tcp_connect.is_none() && self.quic_connect.is_none() {
421            return 3;
422        }
423        // TLS failure
424        if err.contains("rustls")
425            || err.contains("tls")
426            || err.contains("certificate")
427            || err.contains("invalid dns name")
428        {
429            return 4;
430        }
431        1
432    }
433
434    /// Derived "DNS Query" phase: the portion of `dns_lookup` not spent on
435    /// `dns_connect`. Returns `None` when no DoH/DoT probe was performed.
436    /// Clamped to ≥ 0 because the parallel probe can race slightly ahead of
437    /// the real resolver in rare cases.
438    pub fn dns_query(&self) -> Option<Duration> {
439        match (self.dns_lookup, self.dns_connect) {
440            (Some(total), Some(connect)) => Some(total.saturating_sub(connect)),
441            _ => None,
442        }
443    }
444
445    /// Overall download throughput in bytes/sec, based on wire bytes received
446    /// over the content_transfer window. Returns `None` when either input is
447    /// unavailable or content_transfer is zero (instant local response).
448    pub fn throughput_bps(&self) -> Option<f64> {
449        let bytes = self.wire_body_size? as f64;
450        let secs = self.content_transfer?.as_secs_f64();
451        if secs <= 0.0 {
452            return None;
453        }
454        Some(bytes / secs)
455    }
456
457    /// Throughput across the first 100 KiB of body bytes — dominated by TCP
458    /// slow-start on a cold connection. Compare with [`tail_throughput_bps`]
459    /// to tell "slow start" from "server streams slowly".
460    pub fn first_chunk_throughput_bps(&self) -> Option<f64> {
461        let dur = self.time_to_first_100k?.as_secs_f64();
462        if dur <= 0.0 {
463            return None;
464        }
465        Some(FIRST_CHUNK_BYTES as f64 / dur)
466    }
467
468    /// Throughput of the remaining body after the first 100 KiB — the
469    /// steady-state rate the server actually sustains.
470    pub fn tail_throughput_bps(&self) -> Option<f64> {
471        let total = self.content_transfer?;
472        let head = self.time_to_first_100k?;
473        let bytes = self.wire_body_size?;
474        if bytes <= FIRST_CHUNK_BYTES || total <= head {
475            return None;
476        }
477        let tail_bytes = (bytes - FIRST_CHUNK_BYTES) as f64;
478        let tail_secs = (total - head).as_secs_f64();
479        if tail_secs <= 0.0 {
480            return None;
481        }
482        Some(tail_bytes / tail_secs)
483    }
484
485    pub fn is_success(&self) -> bool {
486        if self.error.is_some() {
487            return false;
488        }
489        if self.is_grpc {
490            if let Some(grpc_status) = &self.grpc_status {
491                return grpc_status == "0";
492            }
493            return false;
494        }
495        let Some(status) = &self.status else {
496            return false;
497        };
498        if status.as_u16() >= 400 {
499            return false;
500        }
501        true
502    }
503
504    /// Render a waterfall bar chart to `f`.
505    /// Each phase is one row; bars are horizontally positioned by cumulative offset.
506    fn fmt_waterfall(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
507        let total = match self.total {
508            Some(t) if t.as_nanos() > 0 => t,
509            _ => return Ok(()),
510        };
511
512        const BAR_WIDTH: usize = 50;
513        const LABEL_W: usize = 15;
514
515        let s = self.lang.strings();
516        // When a DoH/DoT probe ran, split the DNS column into Connect + Query.
517        let (dns_a, dns_b) = if self.dns_connect.is_some() {
518            (
519                (s.dns_connect, self.dns_connect),
520                (s.dns_query, self.dns_query()),
521            )
522        } else {
523            ((s.dns_lookup, self.dns_lookup), ("", None))
524        };
525        let phases_vec: Vec<(&str, Option<Duration>)> = vec![
526            dns_a,
527            dns_b,
528            (s.tcp_connect, self.tcp_connect),
529            (s.tls_handshake, self.tls_handshake),
530            (s.quic_connect, self.quic_connect),
531            (s.request_send, self.request_send),
532            (s.server_processing_short, self.server_processing),
533            (s.content_transfer_short, self.content_transfer),
534        ];
535        let phases: &[(&str, Option<Duration>)] = &phases_vec;
536
537        let total_ns = total.as_nanos() as f64;
538        let mut elapsed = Duration::ZERO;
539        let mut col_cursor: usize = 0;
540
541        for (name, dur_opt) in phases {
542            let Some(dur) = dur_opt else { continue };
543
544            let start_col = col_cursor;
545            elapsed += *dur;
546            let ideal_end = ((elapsed.as_nanos() as f64 / total_ns * BAR_WIDTH as f64).round()
547                as usize)
548                .min(BAR_WIDTH);
549            let end_col = ideal_end.min(BAR_WIDTH);
550            if end_col > start_col {
551                col_cursor = end_col;
552            }
553
554            let bar: String = (0..BAR_WIDTH)
555                .map(|i| {
556                    if i >= start_col && i < end_col {
557                        '█'
558                    } else {
559                        '░'
560                    }
561                })
562                .collect();
563
564            writeln!(
565                f,
566                " {:<LABEL_W$} [{}]  {}",
567                name,
568                LightCyan.paint(bar),
569                LightCyan.paint(format_duration(*dur))
570            )?;
571        }
572
573        writeln!(f)?;
574        writeln!(
575            f,
576            " {:LABEL_W$}  {:BAR_WIDTH$}  {}: {}",
577            "",
578            "",
579            s.total,
580            LightCyan.paint(format_duration(total))
581        )?;
582        writeln!(f)
583    }
584
585    pub fn to_json(&self) -> Value {
586        let dur_us = |d: Option<Duration>| -> Value {
587            d.map_or(Value::Null, |d| json!(d.as_micros() as u64))
588        };
589
590        let mut obj = Map::new();
591
592        // Timing (microseconds)
593        let mut timing = Map::new();
594        timing.insert("dns_lookup_us".into(), dur_us(self.dns_lookup));
595        timing.insert("dns_connect_us".into(), dur_us(self.dns_connect));
596        timing.insert("dns_query_us".into(), dur_us(self.dns_query()));
597        timing.insert("tcp_connect_us".into(), dur_us(self.tcp_connect));
598        timing.insert("tls_handshake_us".into(), dur_us(self.tls_handshake));
599        timing.insert("quic_connect_us".into(), dur_us(self.quic_connect));
600        timing.insert("request_send_us".into(), dur_us(self.request_send));
601        timing.insert(
602            "server_processing_us".into(),
603            dur_us(self.server_processing),
604        );
605        timing.insert("content_transfer_us".into(), dur_us(self.content_transfer));
606        timing.insert(
607            "time_to_first_100k_us".into(),
608            dur_us(self.time_to_first_100k),
609        );
610        timing.insert("total_us".into(), dur_us(self.total));
611        obj.insert("timing".into(), Value::Object(timing));
612
613        // Throughput block — populated whenever a body was received with a
614        // measurable content_transfer. Numerator is wire bytes (pre-decompress).
615        if self.wire_body_size.is_some() && self.content_transfer.is_some() {
616            let mut t = Map::new();
617            t.insert(
618                "wire_body_size".into(),
619                self.wire_body_size.map_or(Value::Null, |v| json!(v)),
620            );
621            let opt_f = |v: Option<f64>| -> Value { v.map_or(Value::Null, |x| json!(x)) };
622            t.insert("bps_total".into(), opt_f(self.throughput_bps()));
623            t.insert(
624                "bps_first_100k".into(),
625                opt_f(self.first_chunk_throughput_bps()),
626            );
627            t.insert("bps_tail".into(), opt_f(self.tail_throughput_bps()));
628            obj.insert("throughput".into(), Value::Object(t));
629        }
630
631        // Kernel TCP statistics (Linux + macOS): both samples plus a derived
632        // delta highlighting what changed during the request.
633        let tcp_info_json = |info: Option<&TcpInfo>| -> Value {
634            let Some(info) = info else { return Value::Null };
635            let mut m = Map::new();
636            m.insert("rtt_us".into(), dur_us(info.rtt));
637            m.insert("rttvar_us".into(), dur_us(info.rttvar));
638            m.insert(
639                "retransmits".into(),
640                info.retransmits.map_or(Value::Null, |v| json!(v)),
641            );
642            m.insert("cwnd".into(), info.cwnd.map_or(Value::Null, |v| json!(v)));
643            m.insert(
644                "snd_mss".into(),
645                info.snd_mss.map_or(Value::Null, |v| json!(v)),
646            );
647            Value::Object(m)
648        };
649        if self.tcp_info_post_connect.is_some() || self.tcp_info_final.is_some() {
650            let mut block = Map::new();
651            block.insert(
652                "post_connect".into(),
653                tcp_info_json(self.tcp_info_post_connect.as_ref()),
654            );
655            block.insert("final".into(), tcp_info_json(self.tcp_info_final.as_ref()));
656            if let Some(delta) = TcpInfoDelta::compute(
657                self.tcp_info_post_connect.as_ref(),
658                self.tcp_info_final.as_ref(),
659            ) {
660                let mut d = Map::new();
661                d.insert(
662                    "retransmits_during".into(),
663                    delta.retransmits_during.map_or(Value::Null, |v| json!(v)),
664                );
665                d.insert("rtt_final_us".into(), dur_us(delta.rtt_final));
666                d.insert(
667                    "cwnd_final".into(),
668                    delta.cwnd_final.map_or(Value::Null, |v| json!(v)),
669                );
670                block.insert("delta".into(), Value::Object(d));
671            }
672            obj.insert("tcp_info".into(), Value::Object(block));
673        }
674
675        // Server-Timing entries (RFC 8673)
676        if let Some(entries) = &self.server_timing {
677            let arr: Vec<Value> = entries
678                .iter()
679                .map(|e| {
680                    let mut m = Map::new();
681                    m.insert("name".into(), json!(e.name));
682                    m.insert(
683                        "duration_us".into(),
684                        e.duration
685                            .map_or(Value::Null, |d| json!(d.as_micros() as u64)),
686                    );
687                    m.insert(
688                        "description".into(),
689                        e.description.as_deref().map_or(Value::Null, |s| json!(s)),
690                    );
691                    Value::Object(m)
692                })
693                .collect();
694            obj.insert("server_timing".into(), Value::Array(arr));
695        }
696
697        // Connection
698        obj.insert(
699            "addr".into(),
700            self.addr.as_deref().map_or(Value::Null, |s| json!(s)),
701        );
702        obj.insert(
703            "status".into(),
704            self.status.map_or(Value::Null, |s| json!(s.as_u16())),
705        );
706        obj.insert(
707            "alpn".into(),
708            self.alpn.as_deref().map_or(Value::Null, |s| json!(s)),
709        );
710
711        // TLS
712        if self.tls.is_some() {
713            let mut tls = Map::new();
714            tls.insert(
715                "version".into(),
716                self.tls.as_deref().map_or(Value::Null, |s| json!(s)),
717            );
718            tls.insert(
719                "cipher".into(),
720                self.cert_cipher
721                    .as_deref()
722                    .map_or(Value::Null, |s| json!(s)),
723            );
724            tls.insert(
725                "resumed".into(),
726                self.tls_resumed.map_or(Value::Null, |b| json!(b)),
727            );
728            tls.insert(
729                "early_data_accepted".into(),
730                self.tls_early_data_accepted
731                    .map_or(Value::Null, |b| json!(b)),
732            );
733            tls.insert(
734                "ocsp_stapled".into(),
735                self.tls_ocsp_stapled.map_or(Value::Null, |b| json!(b)),
736            );
737            tls.insert(
738                "subject".into(),
739                self.subject.as_deref().map_or(Value::Null, |s| json!(s)),
740            );
741            tls.insert(
742                "issuer".into(),
743                self.issuer.as_deref().map_or(Value::Null, |s| json!(s)),
744            );
745            tls.insert(
746                "not_before".into(),
747                self.cert_not_before
748                    .as_deref()
749                    .map_or(Value::Null, |s| json!(s)),
750            );
751            tls.insert(
752                "not_after".into(),
753                self.cert_not_after
754                    .as_deref()
755                    .map_or(Value::Null, |s| json!(s)),
756            );
757            tls.insert(
758                "domains".into(),
759                self.cert_domains.as_ref().map_or(Value::Null, |d| json!(d)),
760            );
761            obj.insert("tls".into(), Value::Object(tls));
762        }
763
764        // Headers
765        if let Some(headers) = &self.headers {
766            let mut hdr_map = Map::new();
767            for (key, value) in headers.iter() {
768                let v = value.to_str().unwrap_or_default().to_string();
769                hdr_map.insert(key.to_string(), json!(v));
770            }
771            obj.insert("headers".into(), Value::Object(hdr_map));
772        }
773
774        // Body
775        obj.insert(
776            "body_size".into(),
777            self.body_size.map_or(Value::Null, |s| json!(s)),
778        );
779
780        // Error
781        obj.insert(
782            "error".into(),
783            self.error.as_deref().map_or(Value::Null, |e| json!(e)),
784        );
785        obj.insert("exit_code".into(), json!(self.exit_code()));
786
787        Value::Object(obj)
788    }
789}
790
791impl fmt::Display for HttpStat {
792    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
793        let s = self.lang.strings();
794        if let Some(addr) = &self.addr {
795            let label = if self.tcp_connect.is_some() || self.quic_connect.is_some() {
796                LightGreen.paint(s.connected_to)
797            } else {
798                LightYellow.paint(s.resolved_to)
799            };
800            let mut text = format!("{} {}", label, LightCyan.paint(addr));
801            if self.silent {
802                if let Some(status) = &self.status {
803                    let alpn = self.alpn.as_deref().unwrap_or(ALPN_HTTP1);
804                    let status_code = status.as_u16();
805                    let status = if status_code < 400 {
806                        LightGreen.paint(status.to_string())
807                    } else {
808                        LightRed.paint(status.to_string())
809                    };
810                    text = format!(
811                        "{text} --> {} {}",
812                        LightCyan.paint(alpn.to_uppercase()),
813                        status
814                    );
815                } else {
816                    text = format!("{text} --> {}", LightRed.paint(s.fail));
817                }
818                text = format!("{text} {}", format_duration(self.total.unwrap_or_default()));
819                // Surface TLS resumption / 0-RTT inline — most useful in
820                // benchmark (-n) output where iterations 2+ may resume.
821                // "0-RTT" stays as a technical token across locales.
822                if let Some(true) = self.tls_resumed {
823                    let tag = if matches!(self.tls_early_data_accepted, Some(true)) {
824                        "0-RTT"
825                    } else {
826                        s.handshake_resumed
827                    };
828                    text = format!("{text} [{}]", LightYellow.paint(tag));
829                }
830            }
831            writeln!(f, "{text}")?;
832        }
833        if let Some(error) = &self.error {
834            writeln!(f, "{}: {}", s.error_label, LightRed.paint(error))?;
835        }
836        if self.silent {
837            return Ok(());
838        }
839        if self.verbose {
840            for (key, value) in self.request_headers.iter() {
841                writeln!(
842                    f,
843                    "{}: {}",
844                    key.to_string().to_train_case(),
845                    LightCyan.paint(value.to_str().unwrap_or_default())
846                )?;
847            }
848            writeln!(f)?;
849        }
850
851        if let Some(status) = &self.status {
852            let alpn = self.alpn.as_deref().unwrap_or(ALPN_HTTP1);
853            let status_code = status.as_u16();
854            let status = if status_code < 400 {
855                LightGreen.paint(status.to_string())
856            } else {
857                LightRed.paint(status.to_string())
858            };
859            writeln!(f, "{} {}", LightCyan.paint(alpn.to_uppercase()), status)?;
860        }
861        if self.is_grpc {
862            if self.is_success() {
863                writeln!(f, "{}", LightGreen.paint(s.grpc_ok))?;
864            }
865            writeln!(f)?;
866        }
867
868        if let Some(tls) = &self.tls {
869            writeln!(f)?;
870            writeln!(f, "{}: {}", s.tls_label, LightCyan.paint(tls))?;
871            writeln!(
872                f,
873                "{}: {}",
874                s.cipher,
875                LightCyan.paint(self.cert_cipher.as_deref().unwrap_or_default())
876            )?;
877            if let Some(resumed) = self.tls_resumed {
878                let label = if resumed {
879                    s.handshake_resumed
880                } else {
881                    s.handshake_full
882                };
883                writeln!(f, "{}: {}", s.handshake, LightCyan.paint(label))?;
884            }
885            if let Some(accepted) = self.tls_early_data_accepted {
886                let label = if accepted {
887                    s.early_data_accepted
888                } else {
889                    s.early_data_not_accepted
890                };
891                writeln!(f, "{}: {}", s.early_data, LightCyan.paint(label))?;
892            }
893            if let Some(stapled) = self.tls_ocsp_stapled {
894                let label = if stapled {
895                    s.ocsp_stapled
896                } else {
897                    s.ocsp_not_stapled
898                };
899                writeln!(f, "{}: {}", s.ocsp, LightCyan.paint(label))?;
900            }
901            writeln!(
902                f,
903                "{}: {}",
904                s.not_before,
905                LightCyan.paint(self.cert_not_before.as_deref().unwrap_or_default())
906            )?;
907            writeln!(
908                f,
909                "{}: {}",
910                s.not_after,
911                LightCyan.paint(self.cert_not_after.as_deref().unwrap_or_default())
912            )?;
913            if self.verbose {
914                writeln!(
915                    f,
916                    "{}: {}",
917                    s.subject,
918                    LightCyan.paint(self.subject.as_deref().unwrap_or_default())
919                )?;
920                writeln!(
921                    f,
922                    "{}: {}",
923                    s.issuer,
924                    LightCyan.paint(self.issuer.as_deref().unwrap_or_default())
925                )?;
926                writeln!(
927                    f,
928                    "{}: {}",
929                    s.cert_domains,
930                    LightCyan.paint(self.cert_domains.as_deref().unwrap_or_default().join(", "))
931                )?;
932            }
933            writeln!(f)?;
934
935            if self.verbose {
936                if let Some(certificates) = &self.certificates {
937                    writeln!(f, "{}", s.cert_chain)?;
938                    for (index, cert) in certificates.iter().enumerate() {
939                        writeln!(
940                            f,
941                            " {index} {}: {}",
942                            s.subject,
943                            LightCyan.paint(cert.subject.as_str())
944                        )?;
945                        writeln!(
946                            f,
947                            "   {}: {}",
948                            s.issuer,
949                            LightCyan.paint(cert.issuer.as_str())
950                        )?;
951                        writeln!(
952                            f,
953                            "   {}: {}",
954                            s.not_before,
955                            LightCyan.paint(cert.not_before.as_str())
956                        )?;
957                        writeln!(
958                            f,
959                            "   {}: {}",
960                            s.not_after,
961                            LightCyan.paint(cert.not_after.as_str())
962                        )?;
963                        writeln!(f)?;
964                    }
965                }
966            }
967        }
968
969        // Kernel TCP stats — shown under `-v` or whenever `--tcp-info` is set
970        // via `self.show_tcp_info`. The "during" retransmit count is the
971        // diagnostic gold: it isolates packet loss to *this* request's window
972        // rather than the connection's lifetime.
973        if (self.verbose || self.show_tcp_info)
974            && (self.tcp_info_post_connect.is_some() || self.tcp_info_final.is_some())
975        {
976            writeln!(f, "{}", LightGreen.paint(s.kernel_tcp_heading))?;
977            // Technical field names (rtt/retrans/cwnd/mss) stay English —
978            // they're the standard `getsockopt(TCP_INFO)` field names.
979            let render = |label: &str, info: &TcpInfo| -> String {
980                let rtt = info.rtt.map(format_duration).unwrap_or_else(|| "-".into());
981                let rttvar = info
982                    .rttvar
983                    .map(format_duration)
984                    .unwrap_or_else(|| "-".into());
985                let retrans = info
986                    .retransmits
987                    .map(|v| v.to_string())
988                    .unwrap_or_else(|| "-".into());
989                let cwnd = info
990                    .cwnd
991                    .map(|v| v.to_string())
992                    .unwrap_or_else(|| "-".into());
993                let mss = info
994                    .snd_mss
995                    .map(|v| v.to_string())
996                    .unwrap_or_else(|| "-".into());
997                format!(
998                    "  {:<14} rtt {} \u{00B1} {}  retrans {}  cwnd {}  mss {}",
999                    label, rtt, rttvar, retrans, cwnd, mss,
1000                )
1001            };
1002            if let Some(info) = &self.tcp_info_post_connect {
1003                writeln!(
1004                    f,
1005                    "{}",
1006                    LightCyan.paint(render(s.tcp_post_connect_row, info))
1007                )?;
1008            }
1009            if let Some(info) = &self.tcp_info_final {
1010                writeln!(f, "{}", LightCyan.paint(render(s.tcp_final_row, info)))?;
1011            }
1012            if let Some(delta) = TcpInfoDelta::compute(
1013                self.tcp_info_post_connect.as_ref(),
1014                self.tcp_info_final.as_ref(),
1015            ) {
1016                if let Some(n) = delta.retransmits_during {
1017                    let label = format!("  {} {n} {}", s.tcp_during, s.tcp_retransmit_word);
1018                    let painted = if n == 0 {
1019                        LightCyan.paint(label)
1020                    } else {
1021                        LightYellow.paint(label)
1022                    };
1023                    writeln!(f, "{painted}")?;
1024                }
1025            }
1026            writeln!(f)?;
1027        }
1028
1029        let mut is_text = false;
1030        let mut is_json = false;
1031        if let Some(headers) = &self.headers {
1032            for (key, value) in headers.iter() {
1033                let value = value.to_str().unwrap_or_default();
1034                if key == http::header::CONTENT_TYPE {
1035                    if value.contains("text/") || value.contains("application/json") {
1036                        is_text = true;
1037                    }
1038                    if value.contains("application/json") {
1039                        is_json = true;
1040                    }
1041                }
1042                let key_lower = key.as_str();
1043                let show = if let Some(includes) = &self.include_headers {
1044                    includes.iter().any(|h| h == key_lower)
1045                } else if let Some(excludes) = &self.exclude_headers {
1046                    !excludes.iter().any(|h| h == key_lower)
1047                } else {
1048                    true
1049                };
1050                if show {
1051                    writeln!(
1052                        f,
1053                        "{}: {}",
1054                        key.to_string().to_train_case(),
1055                        LightCyan.paint(value)
1056                    )?;
1057                }
1058            }
1059            writeln!(f)?;
1060        }
1061
1062        // Server-Timing breakdown (what the server says happened inside Server Processing).
1063        // Each row shows: name, a sparkline-style bar sized by share of the reported total,
1064        // duration, and percent. The header line reconciles the reported sum against the
1065        // measured Server Processing time so the unaccounted gap (network/queueing) is visible.
1066        if let Some(entries) = &self.server_timing {
1067            if !entries.is_empty() {
1068                const BAR_W: usize = 34;
1069                let name_w = entries
1070                    .iter()
1071                    .map(|e| e.name.chars().count())
1072                    .max()
1073                    .unwrap_or(0)
1074                    .max(4);
1075
1076                let sum: Duration = entries.iter().filter_map(|e| e.duration).sum();
1077                let total_ns = (sum.as_nanos() as f64).max(1.0);
1078                let largest_idx: Option<usize> = entries
1079                    .iter()
1080                    .enumerate()
1081                    .filter_map(|(i, e)| e.duration.filter(|d| !d.is_zero()).map(|d| (i, d)))
1082                    .max_by_key(|(_, d)| *d)
1083                    .map(|(i, _)| i);
1084
1085                let summary = match self.server_processing {
1086                    Some(sp) if sp > sum => format!(
1087                        "(\u{03A3} {} {} {} {} \u{00B7} {} {})",
1088                        format_duration(sum),
1089                        s.st_sum_of,
1090                        format_duration(sp),
1091                        s.server_processing,
1092                        format_duration(sp - sum),
1093                        s.st_unaccounted,
1094                    ),
1095                    Some(sp) => format!(
1096                        "(\u{03A3} {} {} {} {})",
1097                        format_duration(sum),
1098                        s.st_sum_of,
1099                        format_duration(sp),
1100                        s.server_processing,
1101                    ),
1102                    None => format!("(\u{03A3} {})", format_duration(sum)),
1103                };
1104                writeln!(
1105                    f,
1106                    "{} {}",
1107                    LightGreen.paint(s.server_timing_heading),
1108                    LightCyan.paint(&summary),
1109                )?;
1110
1111                // Lay each bar out at its cumulative offset, so the sequence reads
1112                // left-to-right like a waterfall inside Server Processing.
1113                let sum_ns_u = sum.as_nanos();
1114                let mut cum_ns: u128 = 0;
1115                for (i, entry) in entries.iter().enumerate() {
1116                    let name_pad = " ".repeat(name_w.saturating_sub(entry.name.chars().count()));
1117                    let dur_ns = entry.duration.map(|d| d.as_nanos()).unwrap_or(0);
1118
1119                    let start_col = ((cum_ns as f64 / total_ns) * BAR_W as f64).round() as usize;
1120                    let start_col = start_col.min(BAR_W);
1121                    let mut end_col =
1122                        (((cum_ns + dur_ns) as f64 / total_ns) * BAR_W as f64).round() as usize;
1123                    end_col = end_col.min(BAR_W);
1124                    // Non-zero entries should always paint at least one cell so they
1125                    // don't disappear into rounding.
1126                    if dur_ns > 0 && end_col <= start_col {
1127                        end_col = (start_col + 1).min(BAR_W);
1128                    }
1129
1130                    let bar: String = (0..BAR_W)
1131                        .map(|col| {
1132                            if dur_ns == 0 {
1133                                let marker = start_col.min(BAR_W - 1);
1134                                if col == marker {
1135                                    '\u{00B7}'
1136                                } else {
1137                                    '\u{2591}'
1138                                }
1139                            } else if col >= start_col && col < end_col {
1140                                '\u{2588}'
1141                            } else {
1142                                '\u{2591}'
1143                            }
1144                        })
1145                        .collect();
1146
1147                    let (dur_str, pct_str) = if dur_ns > 0 {
1148                        let pct = if sum_ns_u > 0 {
1149                            (dur_ns as f64 / sum_ns_u as f64) * 100.0
1150                        } else {
1151                            0.0
1152                        };
1153                        (
1154                            format_duration(entry.duration.unwrap_or_default()),
1155                            format!("{pct:>5.1}%"),
1156                        )
1157                    } else {
1158                        ("\u{2014}".to_string(), "\u{2013}".to_string())
1159                    };
1160
1161                    let is_largest = Some(i) == largest_idx;
1162                    let bar_painted = if is_largest {
1163                        LightYellow.paint(&bar).to_string()
1164                    } else {
1165                        LightCyan.paint(&bar).to_string()
1166                    };
1167                    let dur_painted = if is_largest {
1168                        LightYellow.paint(format!("{dur_str:>8}")).to_string()
1169                    } else {
1170                        LightCyan.paint(format!("{dur_str:>8}")).to_string()
1171                    };
1172                    let pct_painted = LightCyan.paint(format!("{pct_str:>6}")).to_string();
1173                    let desc = entry
1174                        .description
1175                        .as_deref()
1176                        .map(|d| format!("  ({d})"))
1177                        .unwrap_or_default();
1178
1179                    writeln!(
1180                        f,
1181                        "  {}{}  {}  {}  {}{}",
1182                        LightCyan.paint(&entry.name),
1183                        name_pad,
1184                        bar_painted,
1185                        dur_painted,
1186                        pct_painted,
1187                        desc,
1188                    )?;
1189
1190                    cum_ns += dur_ns;
1191                }
1192                writeln!(f)?;
1193            }
1194        }
1195
1196        if self.waterfall {
1197            self.fmt_waterfall(f)?;
1198        } else {
1199            let width = 20;
1200
1201            let mut timelines = vec![];
1202            // When a DoH/DoT probe ran, render DNS as two columns so the user
1203            // can see whether the cost was the TLS handshake or the query.
1204            if let Some(connect) = self.dns_connect {
1205                timelines.push(Timeline {
1206                    name: s.dns_connect.to_string(),
1207                    duration: connect,
1208                });
1209                if let Some(query) = self.dns_query() {
1210                    timelines.push(Timeline {
1211                        name: s.dns_query.to_string(),
1212                        duration: query,
1213                    });
1214                }
1215            } else if let Some(value) = self.dns_lookup {
1216                timelines.push(Timeline {
1217                    name: s.dns_lookup.to_string(),
1218                    duration: value,
1219                });
1220            }
1221            if let Some(value) = self.tcp_connect {
1222                timelines.push(Timeline {
1223                    name: s.tcp_connect.to_string(),
1224                    duration: value,
1225                });
1226            }
1227            if let Some(value) = self.tls_handshake {
1228                timelines.push(Timeline {
1229                    name: s.tls_handshake.to_string(),
1230                    duration: value,
1231                });
1232            }
1233            if let Some(value) = self.quic_connect {
1234                timelines.push(Timeline {
1235                    name: s.quic_connect.to_string(),
1236                    duration: value,
1237                });
1238            }
1239            if let Some(value) = self.request_send {
1240                timelines.push(Timeline {
1241                    name: s.request_send.to_string(),
1242                    duration: value,
1243                });
1244            }
1245            if let Some(value) = self.server_processing {
1246                timelines.push(Timeline {
1247                    name: s.server_processing.to_string(),
1248                    duration: value,
1249                });
1250            }
1251            if let Some(value) = self.content_transfer {
1252                timelines.push(Timeline {
1253                    name: s.content_transfer.to_string(),
1254                    duration: value,
1255                });
1256            }
1257
1258            if !timelines.is_empty() {
1259                write!(f, " ")?;
1260                for (i, timeline) in timelines.iter().enumerate() {
1261                    write!(
1262                        f,
1263                        "{}",
1264                        timeline.name.unicode_pad(width, Alignment::Center, true)
1265                    )?;
1266                    if i < timelines.len() - 1 {
1267                        write!(f, " ")?;
1268                    }
1269                }
1270                writeln!(f)?;
1271
1272                write!(f, "[")?;
1273                for (i, timeline) in timelines.iter().enumerate() {
1274                    write!(
1275                        f,
1276                        "{}",
1277                        LightCyan.paint(
1278                            format_duration(timeline.duration)
1279                                .unicode_pad(width, Alignment::Center, true)
1280                                .to_string(),
1281                        )
1282                    )?;
1283                    if i < timelines.len() - 1 {
1284                        write!(f, "|")?;
1285                    }
1286                }
1287                writeln!(f, "]")?;
1288            }
1289
1290            write!(f, " ")?;
1291            for _ in 0..timelines.len() {
1292                write!(f, "{}", " ".repeat(width))?;
1293                write!(f, "|")?;
1294            }
1295            writeln!(f)?;
1296            write!(f, "{}", " ".repeat(width * timelines.len()))?;
1297            write!(
1298                f,
1299                "{}:{}\n\n",
1300                s.total,
1301                LightCyan.paint(format_duration(self.total.unwrap_or_default()))
1302            )?;
1303        }
1304
1305        if let Some(body) = &self.body {
1306            let status = self.status.unwrap_or(StatusCode::OK).as_u16();
1307            let mut body = std::str::from_utf8(body.as_ref())
1308                .unwrap_or_default()
1309                .to_string();
1310            if let Some(filter) = &self.jq_filter {
1311                if let Some(filtered) = apply_jq_filter(&body, filter) {
1312                    body = filtered;
1313                }
1314            } else if self.pretty && is_json {
1315                if let Ok(json_body) = serde_json::from_str::<serde_json::Value>(&body) {
1316                    if let Ok(value) = serde_json::to_string_pretty(&json_body) {
1317                        body = value;
1318                    }
1319                }
1320            }
1321            if self.verbose || self.jq_filter.is_some() || (is_text && body.len() < 4096) {
1322                let text = format!(
1323                    "{}: {}",
1324                    s.body_size,
1325                    ByteSize(self.body_size.unwrap_or(0) as u64)
1326                );
1327                writeln!(f, "{}", LightCyan.paint(text))?;
1328                self.fmt_throughput(f)?;
1329                writeln!(f)?;
1330                if status >= 400 {
1331                    writeln!(f, "{}", LightRed.paint(body))?;
1332                } else {
1333                    writeln!(f, "{body}")?;
1334                }
1335            } else {
1336                let mut save_tips = "".to_string();
1337                if let Ok(mut file) = NamedTempFile::new() {
1338                    if let Ok(()) = file.write_all(body.as_bytes()) {
1339                        save_tips = format!("{}: {}", s.saved_to, file.path().display());
1340                        let _ = file.keep();
1341                    }
1342                }
1343                let text = format!(
1344                    "{} {}",
1345                    s.body_discarded,
1346                    ByteSize(self.body_size.unwrap_or(0) as u64)
1347                );
1348                writeln!(f, "{} {}", LightCyan.paint(text), save_tips)?;
1349                self.fmt_throughput(f)?;
1350            }
1351        }
1352
1353        Ok(())
1354    }
1355}
1356
1357impl HttpStat {
1358    /// Render the throughput line(s) under the body summary. Always shown
1359    /// when the body exceeds `THROUGHPUT_DISPLAY_THRESHOLD` (1 MiB);
1360    /// verbose mode additionally splits "first 100 KB" vs "tail" so the
1361    /// user can tell TCP slow-start from steady-state server-side slowness.
1362    fn fmt_throughput(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1363        let Some(wire) = self.wire_body_size else {
1364            return Ok(());
1365        };
1366        if wire < THROUGHPUT_DISPLAY_THRESHOLD {
1367            return Ok(());
1368        }
1369        let Some(total) = self.throughput_bps() else {
1370            return Ok(());
1371        };
1372        let s = self.lang.strings();
1373        writeln!(
1374            f,
1375            "{} {}",
1376            LightCyan.paint(s.throughput),
1377            LightCyan.paint(format_throughput(total)),
1378        )?;
1379        if self.verbose {
1380            if let (Some(first), Some(tail)) = (
1381                self.first_chunk_throughput_bps(),
1382                self.tail_throughput_bps(),
1383            ) {
1384                writeln!(
1385                    f,
1386                    "  {} {}  {}  {} {}",
1387                    LightCyan.paint(s.throughput_first_100k),
1388                    LightCyan.paint(format_throughput(first)),
1389                    LightCyan.paint("·"),
1390                    LightCyan.paint(s.throughput_then),
1391                    LightCyan.paint(format_throughput(tail)),
1392                )?;
1393            }
1394        }
1395        Ok(())
1396    }
1397}
1398
1399pub struct BenchmarkSummary {
1400    pub stats: Vec<HttpStat>,
1401    pub lang: Lang,
1402}
1403
1404impl BenchmarkSummary {
1405    fn collect_sorted(&self, f: impl Fn(&HttpStat) -> Option<Duration>) -> Vec<Duration> {
1406        let mut v: Vec<Duration> = self.stats.iter().filter_map(f).collect();
1407        v.sort();
1408        v
1409    }
1410
1411    fn percentile(sorted: &[Duration], p: f64) -> Option<Duration> {
1412        if sorted.is_empty() {
1413            return None;
1414        }
1415        let idx = ((p * sorted.len() as f64).ceil() as usize).saturating_sub(1);
1416        Some(sorted[idx.min(sorted.len() - 1)])
1417    }
1418}
1419
1420impl fmt::Display for BenchmarkSummary {
1421    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1422        let total = self.stats.len();
1423        if total == 0 {
1424            return Ok(());
1425        }
1426
1427        let strs = self.lang.strings();
1428        // When any run used DoH/DoT, render DNS as two columns; otherwise keep
1429        // the single DNS Lookup column for the existing plain-UDP path.
1430        let has_dns_connect = self.stats.iter().any(|s| s.dns_connect.is_some());
1431        let dns_cols: Vec<(&str, Vec<Duration>)> = if has_dns_connect {
1432            vec![
1433                (strs.dns_connect, self.collect_sorted(|s| s.dns_connect)),
1434                (strs.dns_query, self.collect_sorted(|s| s.dns_query())),
1435            ]
1436        } else {
1437            vec![(strs.dns_lookup, self.collect_sorted(|s| s.dns_lookup))]
1438        };
1439        let phases: Vec<(&str, Vec<Duration>)> = dns_cols
1440            .into_iter()
1441            .chain([
1442                (strs.tcp_connect, self.collect_sorted(|s| s.tcp_connect)),
1443                (strs.tls_handshake, self.collect_sorted(|s| s.tls_handshake)),
1444                (strs.quic_connect, self.collect_sorted(|s| s.quic_connect)),
1445                (strs.request_send, self.collect_sorted(|s| s.request_send)),
1446                (
1447                    strs.server_processing_short,
1448                    self.collect_sorted(|s| s.server_processing),
1449                ),
1450                (
1451                    strs.content_transfer_short,
1452                    self.collect_sorted(|s| s.content_transfer),
1453                ),
1454                (strs.total, self.collect_sorted(|s| s.total)),
1455            ])
1456            .filter(|(_, v)| !v.is_empty())
1457            .collect();
1458
1459        if phases.is_empty() {
1460            return Ok(());
1461        }
1462
1463        writeln!(f)?;
1464        writeln!(
1465            f,
1466            "{}",
1467            LightGreen.paint(format!(
1468                "{} ({total} {}) ---",
1469                strs.benchmark_results_prefix, strs.benchmark_results_requests,
1470            ))
1471        )?;
1472        writeln!(f)?;
1473
1474        let col_w = 18;
1475        let label_w = 6;
1476
1477        // Header row
1478        write!(f, "{:>label_w$} ", "")?;
1479        for (name, _) in &phases {
1480            write!(f, "{}", name.unicode_pad(col_w, Alignment::Center, true))?;
1481        }
1482        writeln!(f)?;
1483
1484        // Stats rows — p50/p95/p99 are standard percentile notation, kept
1485        // as-is across locales. Only min/max/avg get translated.
1486        let rows: [(&str, f64); 6] = [
1487            (strs.min, 0.0),
1488            (strs.max, f64::INFINITY),
1489            (strs.avg, f64::NAN),
1490            ("p50", 0.5),
1491            ("p95", 0.95),
1492            ("p99", 0.99),
1493        ];
1494
1495        for (label, p) in &rows {
1496            write!(f, "{} ", LightGreen.paint(format!("{label:>label_w$}")))?;
1497            for (_, sorted) in &phases {
1498                let val = if p.is_nan() {
1499                    // avg
1500                    if sorted.is_empty() {
1501                        None
1502                    } else {
1503                        let sum: Duration = sorted.iter().sum();
1504                        Some(sum / sorted.len() as u32)
1505                    }
1506                } else if *p == 0.0 {
1507                    sorted.first().copied()
1508                } else if p.is_infinite() {
1509                    sorted.last().copied()
1510                } else {
1511                    Self::percentile(sorted, *p)
1512                };
1513                let text = match val {
1514                    Some(d) => format_duration(d),
1515                    None => "-".to_string(),
1516                };
1517                write!(
1518                    f,
1519                    "{}",
1520                    LightCyan.paint(text.unicode_pad(col_w, Alignment::Center, true).to_string())
1521                )?;
1522            }
1523            writeln!(f)?;
1524        }
1525
1526        writeln!(f)?;
1527        let success = self.stats.iter().filter(|s| s.is_success()).count();
1528        let pct = (success as f64 / total as f64) * 100.0;
1529        let success_text = format!("{}: {success}/{total} ({pct:.1}%)", strs.success);
1530        if success == total {
1531            writeln!(f, "  {}", LightGreen.paint(success_text))?;
1532        } else {
1533            writeln!(f, "  {}", LightRed.paint(success_text))?;
1534        }
1535
1536        Ok(())
1537    }
1538}