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 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
40// Format a Unix timestamp (seconds) as "YYYY-MM-DD HH:MM:SS ±HH:MM" in the
41// local timezone. Falls back to UTC if the local offset can't be determined.
42pub(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
73/// Format a throughput value (bytes/s) into a human-readable string using
74/// decimal units (MB/s = 10^6 B/s, the network-convention) — bandwidth is
75/// almost universally reported in decimal, even when storage uses binary.
76pub 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
89/// Size of the "first chunk" window used for throughput splitting.
90pub const FIRST_CHUNK_BYTES: usize = 100 * 1024;
91/// Threshold above which we render the overall Throughput line.
92pub const THROUGHPUT_DISPLAY_THRESHOLD: usize = 1024 * 1024;
93
94struct Timeline {
95    name: String,
96    duration: Duration,
97}
98
99/// Statistics and information collected during an HTTP request.
100///
101/// This struct contains timing information for each phase of the request,
102/// connection details, TLS information, and response data.
103///
104/// # Fields
105///
106/// * `dns_lookup` - Time taken for DNS resolution
107/// * `quic_connect` - Time taken to establish QUIC connection (for HTTP/3)
108/// * `tcp_connect` - Time taken to establish TCP connection
109/// * `tls_handshake` - Time taken for TLS handshake (for HTTPS)
110/// * `request_send` - Time to send the request headers and body
111/// * `server_processing` - Time from request fully sent to first response byte
112/// * `content_transfer` - Time taken to transfer the response body
113/// * `total` - Total time taken for the entire request
114/// * `addr` - Resolved IP address and port
115/// * `status` - HTTP response status code
116/// * `tls` - TLS protocol version used
117/// * `alpn` - Application-Layer Protocol Negotiation (ALPN) protocol selected
118/// * `cert_not_before` - Certificate validity start time
119/// * `cert_not_after` - Certificate validity end time
120/// * `cert_cipher` - TLS cipher suite used
121/// * `cert_domains` - List of domains in the certificate's Subject Alternative Names
122/// * `body` - Response body content
123/// * `headers` - Response headers
124/// * `error` - Any error that occurred during the request
125#[derive(Default, Debug, Clone)]
126pub struct HttpStat {
127    pub is_grpc: bool,
128    pub request_headers: HeaderMap<HeaderValue>,
129    /// True when a real DNS resolution was attempted for this request.
130    /// Stays false for IP-literal hosts, `--resolve` pins and proxied
131    /// requests (the proxy resolves the target itself), so `exit_code()`
132    /// can tell "DNS failed" apart from "DNS was never needed".
133    pub dns_attempted: bool,
134    pub dns_lookup: Option<Duration>,
135    /// Cold connect cost (TCP + TLS) to the DNS server. Only populated when
136    /// using DoH or DoT — for plain UDP DNS this stays None. When present,
137    /// the `dns_lookup` total can be split into `dns_connect` and a derived
138    /// `dns_query = dns_lookup - dns_connect`, making it possible to tell
139    /// whether DoH/DoT latency comes from TLS handshake or query processing.
140    pub dns_connect: Option<Duration>,
141    pub quic_connect: Option<Duration>,
142    pub tcp_connect: Option<Duration>,
143    /// Kernel TCP statistics sampled right after `connect(2)`. Provides the
144    /// baseline RTT, MSS and initial cwnd before any application data flows.
145    /// Linux + macOS only — None on other platforms.
146    pub tcp_info_post_connect: Option<TcpInfo>,
147    /// Kernel TCP statistics sampled after the response body has been fully
148    /// received. The diff against `tcp_info_post_connect` reveals retransmits
149    /// during the request — the diagnostic answer to "was Content Transfer
150    /// slow because of packet loss or just slow start?".
151    pub tcp_info_final: Option<TcpInfo>,
152    pub tls_handshake: Option<Duration>,
153    pub request_send: Option<Duration>,
154    pub server_processing: Option<Duration>,
155    pub content_transfer: Option<Duration>,
156    /// Bytes actually received over the wire (pre-decompression). For an
157    /// uncompressed response this matches `body_size`; for `gzip`/`br`/`zstd`
158    /// it's smaller and is the right denominator for *network* throughput.
159    pub wire_body_size: Option<usize>,
160    /// Time from the start of `content_transfer` until the first 100 KiB of
161    /// body bytes had arrived. Combined with the overall content_transfer
162    /// duration, it splits download throughput into "first 100 KB" vs
163    /// "tail" — the former is TCP slow-start dominated, the latter is the
164    /// steady-state rate the server can sustain.
165    pub time_to_first_100k: Option<Duration>,
166    pub server_timing: Option<Vec<ServerTiming>>,
167    /// Parsed `Alt-Svc` advertisements (RFC 7838). Tells the user "this
168    /// server announced h3 on port X" without requiring a second probe.
169    pub alt_svc: Option<Vec<AltSvc>>,
170    /// Parsed `Strict-Transport-Security` policy (RFC 6797).
171    pub hsts: Option<Hsts>,
172    pub total: Option<Duration>,
173    pub addr: Option<String>,
174    pub grpc_status: Option<String>,
175    pub status: Option<StatusCode>,
176    pub tls: Option<String>,
177    pub tls_resumed: Option<bool>,
178    pub tls_early_data_accepted: Option<bool>,
179    pub tls_ocsp_stapled: Option<bool>,
180    pub alpn: Option<String>,
181    pub subject: Option<String>,
182    pub issuer: Option<String>,
183    pub cert_not_before: Option<String>,
184    pub cert_not_after: Option<String>,
185    pub cert_cipher: Option<String>,
186    pub cert_domains: Option<Vec<String>>,
187    pub certificates: Option<Vec<Certificate>>,
188    pub body: Option<Bytes>,
189    pub body_size: Option<usize>,
190    pub headers: Option<HeaderMap<HeaderValue>>,
191    pub error: Option<String>,
192    pub silent: bool,
193    pub verbose: bool,
194    pub pretty: bool,
195    pub include_headers: Option<Vec<String>>,
196    pub exclude_headers: Option<Vec<String>>,
197    pub waterfall: bool,
198    pub jq_filter: Option<String>,
199    /// When true, render the Kernel TCP block even without `--verbose`.
200    /// Driven by the CLI `--tcp-info` flag.
201    pub show_tcp_info: bool,
202    /// Display language for the terminal renderer. JSON output is always
203    /// in English (machine contract). Driven by `--lang` or auto-detected
204    /// from LC_ALL/LC_MESSAGES/LANG.
205    pub lang: Lang,
206    /// Display Response Version
207    pub version: Option<String>,
208}
209
210#[derive(Debug, Clone)]
211pub struct Certificate {
212    pub subject: String,
213    pub issuer: String,
214    pub not_before: String,
215    pub not_after: String,
216}
217
218/// A single entry parsed from the `Server-Timing` response header (RFC 8673 / W3C).
219///
220/// Format: `name[;dur=<ms>][;desc="<text>"]`, possibly multiple comma-separated entries
221/// per header, possibly multiple `Server-Timing` headers.
222#[derive(Debug, Clone)]
223pub struct ServerTiming {
224    pub name: String,
225    pub duration: Option<Duration>,
226    pub description: Option<String>,
227}
228
229/// Parse all `Server-Timing` header values into a flat list of entries.
230/// Returns `None` if the input iterator yields no entries.
231pub fn parse_server_timing<'a, I>(values: I) -> Option<Vec<ServerTiming>>
232where
233    I: IntoIterator<Item = &'a str>,
234{
235    let mut out = Vec::new();
236    for raw in values {
237        for part in split_top_level_commas(raw) {
238            let mut subparts = part.split(';').map(str::trim);
239            let name = match subparts.next() {
240                Some(n) if !n.is_empty() => n.to_string(),
241                _ => continue,
242            };
243            let mut entry = ServerTiming {
244                name,
245                duration: None,
246                description: None,
247            };
248            for kv in subparts {
249                let Some(eq) = kv.find('=') else { continue };
250                let key = kv[..eq].trim().to_ascii_lowercase();
251                let mut val = kv[eq + 1..].trim();
252                if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
253                    val = &val[1..val.len() - 1];
254                }
255                match key.as_str() {
256                    "dur" => {
257                        if let Ok(ms) = val.parse::<f64>() {
258                            if ms.is_finite() && ms >= 0.0 {
259                                entry.duration = Some(Duration::from_secs_f64(ms / 1000.0));
260                            }
261                        }
262                    }
263                    "desc" => entry.description = Some(val.to_string()),
264                    _ => {}
265                }
266            }
267            out.push(entry);
268        }
269    }
270    if out.is_empty() {
271        None
272    } else {
273        Some(out)
274    }
275}
276
277/// A single entry from the `Alt-Svc` response header (RFC 7838).
278///
279/// Format: `protocol-id=authority; ma=<seconds>; persist=<0|1>`. Multiple
280/// entries are comma-separated; the special value `clear` (handled by the
281/// parser) clears the current advertisement set.
282#[derive(Debug, Clone)]
283pub struct AltSvc {
284    pub protocol: String,
285    pub authority: String,
286    pub max_age: Option<u64>,
287}
288
289/// Parsed `Strict-Transport-Security` directive (RFC 6797).
290#[derive(Debug, Clone)]
291pub struct Hsts {
292    pub max_age: u64,
293    pub include_subdomains: bool,
294    pub preload: bool,
295}
296
297/// Parse one or more `Alt-Svc` header values into a flat list.
298///
299/// Returns `None` if no entries were found. `clear` resets accumulation.
300pub fn parse_alt_svc<'a, I>(values: I) -> Option<Vec<AltSvc>>
301where
302    I: IntoIterator<Item = &'a str>,
303{
304    let mut out: Vec<AltSvc> = Vec::new();
305    for raw in values {
306        for part in split_top_level_commas(raw) {
307            if part.eq_ignore_ascii_case("clear") {
308                out.clear();
309                continue;
310            }
311            let mut subparts = part.split(';').map(str::trim);
312            let head = match subparts.next() {
313                Some(h) if !h.is_empty() => h,
314                _ => continue,
315            };
316            let Some(eq) = head.find('=') else { continue };
317            let protocol = head[..eq].trim().to_string();
318            let mut authority = head[eq + 1..].trim().to_string();
319            if authority.starts_with('"') && authority.ends_with('"') && authority.len() >= 2 {
320                authority = authority[1..authority.len() - 1].to_string();
321            }
322            let mut entry = AltSvc {
323                protocol,
324                authority,
325                max_age: None,
326            };
327            for kv in subparts {
328                let Some(eq) = kv.find('=') else { continue };
329                let key = kv[..eq].trim().to_ascii_lowercase();
330                let val = kv[eq + 1..].trim().trim_matches('"');
331                if key == "ma" {
332                    if let Ok(n) = val.parse::<u64>() {
333                        entry.max_age = Some(n);
334                    }
335                }
336            }
337            out.push(entry);
338        }
339    }
340    if out.is_empty() {
341        None
342    } else {
343        Some(out)
344    }
345}
346
347/// Parse a single `Strict-Transport-Security` header value. Returns `None`
348/// if `max-age` is missing or unparsable (a directive without `max-age` is
349/// not a valid HSTS policy per RFC 6797 §6.1).
350pub fn parse_hsts(value: &str) -> Option<Hsts> {
351    let mut max_age: Option<u64> = None;
352    let mut include_subdomains = false;
353    let mut preload = false;
354    for part in value.split(';').map(str::trim) {
355        if part.is_empty() {
356            continue;
357        }
358        let (key, val) = match part.find('=') {
359            Some(eq) => (
360                part[..eq].trim().to_ascii_lowercase(),
361                Some(part[eq + 1..].trim().trim_matches('"')),
362            ),
363            None => (part.to_ascii_lowercase(), None),
364        };
365        match key.as_str() {
366            "max-age" => {
367                if let Some(v) = val {
368                    if let Ok(n) = v.parse::<u64>() {
369                        max_age = Some(n);
370                    }
371                }
372            }
373            "includesubdomains" => include_subdomains = true,
374            "preload" => preload = true,
375            _ => {}
376        }
377    }
378    max_age.map(|m| Hsts {
379        max_age: m,
380        include_subdomains,
381        preload,
382    })
383}
384
385/// Split on top-level commas, ignoring commas inside double-quoted strings.
386fn split_top_level_commas(s: &str) -> Vec<&str> {
387    let mut parts = Vec::new();
388    let mut start = 0usize;
389    let mut in_quotes = false;
390    let bytes = s.as_bytes();
391    let mut i = 0;
392    while i < bytes.len() {
393        match bytes[i] {
394            b'"' => in_quotes = !in_quotes,
395            b',' if !in_quotes => {
396                parts.push(s[start..i].trim());
397                start = i + 1;
398            }
399            _ => {}
400        }
401        i += 1;
402    }
403    parts.push(s[start..].trim());
404    parts
405}
406
407/// Apply a simple jq-style field selector to a JSON string.
408///
409/// Supported syntax:
410///   .                    identity (pretty-print)
411///   .field               object key access
412///   .field.sub           nested key access
413///   .[0]                 array index
414///   .[]                  iterate all array/object values
415///   combinations: .items[].name, .a.b[2].c, etc.
416///
417/// Returns `Err(reason)` when the body is not JSON or the filter uses syntax
418/// outside this subset (pipes, functions, `select()`, `?`, …) so the caller
419/// can show a clear message instead of silently printing the unfiltered body.
420fn apply_jq_filter(body: &str, filter: &str) -> Result<String, String> {
421    let root: serde_json::Value = serde_json::from_str(body)
422        .map_err(|_| "response body is not JSON, cannot apply --jq filter".to_string())?;
423    let trimmed = filter.trim();
424    // Allow omitting the leading '.' for convenience (e.g. "os" → ".os")
425    let owned;
426    let normalized = if !trimmed.starts_with('.') {
427        owned = format!(".{trimmed}");
428        owned.as_str()
429    } else {
430        trimmed
431    };
432
433    // Tokenise the filter string into a list of access steps.
434    #[derive(Debug)]
435    enum Step {
436        Key(String),
437        Index(usize),
438        Iter,
439    }
440
441    // Returns `None` for anything outside the supported subset; the caller
442    // turns that into an "unsupported filter" error.
443    fn tokenize(s: &str) -> Option<Vec<Step>> {
444        let s = s.strip_prefix('.')?;
445        if s.is_empty() {
446            return Some(vec![]);
447        }
448        let mut steps = Vec::new();
449        // Split on '.' but keep bracket expressions attached to the preceding key.
450        // We walk char-by-char to handle `key[0].next` etc.
451        let mut remaining = s;
452        while !remaining.is_empty() {
453            if remaining.starts_with('[') {
454                // bracket at the start: .[0] or .[]
455                let end = remaining.find(']')?;
456                let inner = &remaining[1..end];
457                if inner.is_empty() {
458                    steps.push(Step::Iter);
459                } else {
460                    let idx: usize = inner.parse().ok()?;
461                    steps.push(Step::Index(idx));
462                }
463                remaining = &remaining[end + 1..];
464                if remaining.starts_with('.') {
465                    remaining = &remaining[1..];
466                }
467            } else {
468                // read up to next '.' or '['
469                let end = remaining.find(['.', '[']).unwrap_or(remaining.len());
470                let key = &remaining[..end];
471                // Only plain object keys are supported. Reject anything that
472                // signals unsupported jq syntax (pipes, functions, `?`,
473                // whitespace, …) instead of silently matching nothing.
474                if key.is_empty()
475                    || !key
476                        .chars()
477                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
478                {
479                    return None;
480                }
481                steps.push(Step::Key(key.to_string()));
482                remaining = &remaining[end..];
483                if remaining.starts_with('.') {
484                    remaining = &remaining[1..];
485                }
486            }
487        }
488        Some(steps)
489    }
490
491    fn apply_steps(values: Vec<serde_json::Value>, steps: &[Step]) -> Vec<serde_json::Value> {
492        if steps.is_empty() {
493            return values;
494        }
495        let mut current = values;
496        for step in steps {
497            current = match step {
498                Step::Key(k) => current
499                    .into_iter()
500                    .filter_map(|v| v.get(k).cloned())
501                    .collect(),
502                Step::Index(i) => current
503                    .into_iter()
504                    .filter_map(|v| v.get(i).cloned())
505                    .collect(),
506                Step::Iter => current
507                    .into_iter()
508                    .flat_map(|v| match v {
509                        serde_json::Value::Array(arr) => arr,
510                        serde_json::Value::Object(map) => map.into_values().collect(),
511                        other => vec![other],
512                    })
513                    .collect(),
514            };
515        }
516        current
517    }
518
519    let steps = tokenize(normalized).ok_or_else(|| {
520        format!("unsupported --jq filter '{filter}' (supported: .a.b, .[0], .[])")
521    })?;
522    let results = apply_steps(vec![root], &steps);
523
524    if results.len() == 1 {
525        serde_json::to_string_pretty(&results[0])
526            .map_err(|e| format!("failed to format --jq result: {e}"))
527    } else {
528        Ok(results
529            .iter()
530            .filter_map(|v| serde_json::to_string_pretty(v).ok())
531            .collect::<Vec<_>>()
532            .join("\n"))
533    }
534}
535
536impl HttpStat {
537    /// Returns a semantic exit code based on the error type:
538    /// - 0: Success
539    /// - 1: General/unknown error
540    /// - 2: DNS resolution failure
541    /// - 3: TCP connection failure
542    /// - 4: TLS/SSL error
543    /// - 5: Timeout
544    /// - 6: HTTP 4xx client error
545    /// - 7: HTTP 5xx server error
546    pub fn exit_code(&self) -> i32 {
547        if self.is_success() {
548            return 0;
549        }
550        // HTTP status errors (no connection error, but bad status)
551        if self.error.is_none() {
552            if let Some(status) = &self.status {
553                let code = status.as_u16();
554                if code >= 500 {
555                    return 7;
556                }
557                if code >= 400 {
558                    return 6;
559                }
560            }
561            return 1;
562        }
563        let err = self.error.as_deref().unwrap_or_default().to_lowercase();
564        // Timeout (check before phase-based detection since timeout can happen in any phase)
565        if err.contains("timeout") || err.contains("elapsed") {
566            return 5;
567        }
568        // DNS failure: resolution was attempted but never completed. The
569        // `dns_attempted` guard keeps IP-literal / --resolve / proxied
570        // requests (which legitimately skip DNS) from being misreported
571        // as DNS failures when their TCP connect fails.
572        if self.dns_attempted && self.dns_lookup.is_none() {
573            return 2;
574        }
575        // TCP failure: tcp/quic connection phase never completed
576        if self.tcp_connect.is_none() && self.quic_connect.is_none() {
577            return 3;
578        }
579        // TLS failure
580        if err.contains("rustls")
581            || err.contains("tls")
582            || err.contains("certificate")
583            || err.contains("invalid dns name")
584        {
585            return 4;
586        }
587        1
588    }
589
590    /// Derived "DNS Query" phase: the portion of `dns_lookup` not spent on
591    /// `dns_connect`. Returns `None` when no DoH/DoT probe was performed.
592    /// Clamped to ≥ 0 because the parallel probe can race slightly ahead of
593    /// the real resolver in rare cases.
594    pub fn dns_query(&self) -> Option<Duration> {
595        match (self.dns_lookup, self.dns_connect) {
596            (Some(total), Some(connect)) => Some(total.saturating_sub(connect)),
597            _ => None,
598        }
599    }
600
601    /// Overall download throughput in bytes/sec, based on wire bytes received
602    /// over the content_transfer window. Returns `None` when either input is
603    /// unavailable or content_transfer is zero (instant local response).
604    pub fn throughput_bps(&self) -> Option<f64> {
605        let bytes = self.wire_body_size? as f64;
606        let secs = self.content_transfer?.as_secs_f64();
607        if secs <= 0.0 {
608            return None;
609        }
610        Some(bytes / secs)
611    }
612
613    /// Throughput across the first 100 KiB of body bytes — dominated by TCP
614    /// slow-start on a cold connection. Compare with [`tail_throughput_bps`]
615    /// to tell "slow start" from "server streams slowly".
616    pub fn first_chunk_throughput_bps(&self) -> Option<f64> {
617        let dur = self.time_to_first_100k?.as_secs_f64();
618        if dur <= 0.0 {
619            return None;
620        }
621        Some(FIRST_CHUNK_BYTES as f64 / dur)
622    }
623
624    /// Throughput of the remaining body after the first 100 KiB — the
625    /// steady-state rate the server actually sustains.
626    pub fn tail_throughput_bps(&self) -> Option<f64> {
627        let total = self.content_transfer?;
628        let head = self.time_to_first_100k?;
629        let bytes = self.wire_body_size?;
630        if bytes <= FIRST_CHUNK_BYTES || total <= head {
631            return None;
632        }
633        let tail_bytes = (bytes - FIRST_CHUNK_BYTES) as f64;
634        let tail_secs = (total - head).as_secs_f64();
635        if tail_secs <= 0.0 {
636            return None;
637        }
638        Some(tail_bytes / tail_secs)
639    }
640
641    pub fn is_success(&self) -> bool {
642        if self.error.is_some() {
643            return false;
644        }
645        if self.is_grpc {
646            if let Some(grpc_status) = &self.grpc_status {
647                return grpc_status == "0";
648            }
649            return false;
650        }
651        let Some(status) = &self.status else {
652            return false;
653        };
654        if status.as_u16() >= 400 {
655            return false;
656        }
657        true
658    }
659
660    /// Render a waterfall bar chart to `f`.
661    /// Each phase is one row; bars are horizontally positioned by cumulative offset.
662    fn fmt_waterfall(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
663        let total = match self.total {
664            Some(t) if t.as_nanos() > 0 => t,
665            _ => return Ok(()),
666        };
667
668        const BAR_WIDTH: usize = 50;
669        const LABEL_W: usize = 15;
670
671        let s = self.lang.strings();
672        // When a DoH/DoT probe ran, split the DNS column into Connect + Query.
673        let (dns_a, dns_b) = if self.dns_connect.is_some() {
674            (
675                (s.dns_connect, self.dns_connect),
676                (s.dns_query, self.dns_query()),
677            )
678        } else {
679            ((s.dns_lookup, self.dns_lookup), ("", None))
680        };
681        let phases_vec: Vec<(&str, Option<Duration>)> = vec![
682            dns_a,
683            dns_b,
684            (s.tcp_connect, self.tcp_connect),
685            (s.tls_handshake, self.tls_handshake),
686            (s.quic_connect, self.quic_connect),
687            (s.request_send, self.request_send),
688            (s.server_processing_short, self.server_processing),
689            (s.content_transfer_short, self.content_transfer),
690        ];
691        let phases: &[(&str, Option<Duration>)] = &phases_vec;
692
693        let total_ns = total.as_nanos() as f64;
694        let mut elapsed = Duration::ZERO;
695        let mut col_cursor: usize = 0;
696
697        for (name, dur_opt) in phases {
698            let Some(dur) = dur_opt else { continue };
699
700            let start_col = col_cursor;
701            elapsed += *dur;
702            let ideal_end = ((elapsed.as_nanos() as f64 / total_ns * BAR_WIDTH as f64).round()
703                as usize)
704                .min(BAR_WIDTH);
705            let end_col = ideal_end.min(BAR_WIDTH);
706            if end_col > start_col {
707                col_cursor = end_col;
708            }
709
710            let bar: String = (0..BAR_WIDTH)
711                .map(|i| {
712                    if i >= start_col && i < end_col {
713                        '█'
714                    } else {
715                        '░'
716                    }
717                })
718                .collect();
719
720            writeln!(
721                f,
722                " {:<LABEL_W$} [{}]  {}",
723                name,
724                LightCyan.paint(bar),
725                LightCyan.paint(format_duration(*dur))
726            )?;
727        }
728
729        writeln!(f)?;
730        writeln!(
731            f,
732            " {:LABEL_W$}  {:BAR_WIDTH$}  {}: {}",
733            "",
734            "",
735            s.total,
736            LightCyan.paint(format_duration(total))
737        )?;
738        writeln!(f)
739    }
740
741    pub fn to_json(&self) -> Value {
742        let dur_us = |d: Option<Duration>| -> Value {
743            d.map_or(Value::Null, |d| json!(d.as_micros() as u64))
744        };
745
746        let mut obj = Map::new();
747
748        // Timing (microseconds)
749        let mut timing = Map::new();
750        timing.insert("dns_lookup_us".into(), dur_us(self.dns_lookup));
751        timing.insert("dns_connect_us".into(), dur_us(self.dns_connect));
752        timing.insert("dns_query_us".into(), dur_us(self.dns_query()));
753        timing.insert("tcp_connect_us".into(), dur_us(self.tcp_connect));
754        timing.insert("tls_handshake_us".into(), dur_us(self.tls_handshake));
755        timing.insert("quic_connect_us".into(), dur_us(self.quic_connect));
756        timing.insert("request_send_us".into(), dur_us(self.request_send));
757        timing.insert(
758            "server_processing_us".into(),
759            dur_us(self.server_processing),
760        );
761        timing.insert("content_transfer_us".into(), dur_us(self.content_transfer));
762        timing.insert(
763            "time_to_first_100k_us".into(),
764            dur_us(self.time_to_first_100k),
765        );
766        timing.insert("total_us".into(), dur_us(self.total));
767        obj.insert("timing".into(), Value::Object(timing));
768
769        // Throughput block — populated whenever a body was received with a
770        // measurable content_transfer. Numerator is wire bytes (pre-decompress).
771        if self.wire_body_size.is_some() && self.content_transfer.is_some() {
772            let mut t = Map::new();
773            t.insert(
774                "wire_body_size".into(),
775                self.wire_body_size.map_or(Value::Null, |v| json!(v)),
776            );
777            let opt_f = |v: Option<f64>| -> Value { v.map_or(Value::Null, |x| json!(x)) };
778            t.insert("bps_total".into(), opt_f(self.throughput_bps()));
779            t.insert(
780                "bps_first_100k".into(),
781                opt_f(self.first_chunk_throughput_bps()),
782            );
783            t.insert("bps_tail".into(), opt_f(self.tail_throughput_bps()));
784            obj.insert("throughput".into(), Value::Object(t));
785        }
786
787        // Kernel TCP statistics (Linux + macOS): both samples plus a derived
788        // delta highlighting what changed during the request.
789        let tcp_info_json = |info: Option<&TcpInfo>| -> Value {
790            let Some(info) = info else { return Value::Null };
791            let mut m = Map::new();
792            m.insert("rtt_us".into(), dur_us(info.rtt));
793            m.insert("rttvar_us".into(), dur_us(info.rttvar));
794            m.insert(
795                "retransmits".into(),
796                info.retransmits.map_or(Value::Null, |v| json!(v)),
797            );
798            m.insert("cwnd".into(), info.cwnd.map_or(Value::Null, |v| json!(v)));
799            m.insert(
800                "snd_mss".into(),
801                info.snd_mss.map_or(Value::Null, |v| json!(v)),
802            );
803            Value::Object(m)
804        };
805        if self.tcp_info_post_connect.is_some() || self.tcp_info_final.is_some() {
806            let mut block = Map::new();
807            block.insert(
808                "post_connect".into(),
809                tcp_info_json(self.tcp_info_post_connect.as_ref()),
810            );
811            block.insert("final".into(), tcp_info_json(self.tcp_info_final.as_ref()));
812            if let Some(delta) = TcpInfoDelta::compute(
813                self.tcp_info_post_connect.as_ref(),
814                self.tcp_info_final.as_ref(),
815            ) {
816                let mut d = Map::new();
817                d.insert(
818                    "retransmits_during".into(),
819                    delta.retransmits_during.map_or(Value::Null, |v| json!(v)),
820                );
821                d.insert("rtt_final_us".into(), dur_us(delta.rtt_final));
822                d.insert(
823                    "cwnd_final".into(),
824                    delta.cwnd_final.map_or(Value::Null, |v| json!(v)),
825                );
826                block.insert("delta".into(), Value::Object(d));
827            }
828            obj.insert("tcp_info".into(), Value::Object(block));
829        }
830
831        // Server-Timing entries (RFC 8673)
832        if let Some(entries) = &self.server_timing {
833            let arr: Vec<Value> = entries
834                .iter()
835                .map(|e| {
836                    let mut m = Map::new();
837                    m.insert("name".into(), json!(e.name));
838                    m.insert(
839                        "duration_us".into(),
840                        e.duration
841                            .map_or(Value::Null, |d| json!(d.as_micros() as u64)),
842                    );
843                    m.insert(
844                        "description".into(),
845                        e.description.as_deref().map_or(Value::Null, |s| json!(s)),
846                    );
847                    Value::Object(m)
848                })
849                .collect();
850            obj.insert("server_timing".into(), Value::Array(arr));
851        }
852
853        // Alt-Svc advertisements (RFC 7838)
854        if let Some(entries) = &self.alt_svc {
855            let arr: Vec<Value> = entries
856                .iter()
857                .map(|e| {
858                    let mut m = Map::new();
859                    m.insert("protocol".into(), json!(e.protocol));
860                    m.insert("authority".into(), json!(e.authority));
861                    m.insert(
862                        "max_age_seconds".into(),
863                        e.max_age.map_or(Value::Null, |v| json!(v)),
864                    );
865                    Value::Object(m)
866                })
867                .collect();
868            obj.insert("alt_svc".into(), Value::Array(arr));
869        }
870
871        // HSTS policy (RFC 6797)
872        if let Some(h) = &self.hsts {
873            let mut m = Map::new();
874            m.insert("max_age_seconds".into(), json!(h.max_age));
875            m.insert("include_subdomains".into(), json!(h.include_subdomains));
876            m.insert("preload".into(), json!(h.preload));
877            obj.insert("hsts".into(), Value::Object(m));
878        }
879
880        // Connection
881        obj.insert(
882            "addr".into(),
883            self.addr.as_deref().map_or(Value::Null, |s| json!(s)),
884        );
885        obj.insert(
886            "status".into(),
887            self.status.map_or(Value::Null, |s| json!(s.as_u16())),
888        );
889        obj.insert(
890            "alpn".into(),
891            self.alpn.as_deref().map_or(Value::Null, |s| json!(s)),
892        );
893
894        // TLS
895        if self.tls.is_some() {
896            let mut tls = Map::new();
897            tls.insert(
898                "version".into(),
899                self.tls.as_deref().map_or(Value::Null, |s| json!(s)),
900            );
901            tls.insert(
902                "cipher".into(),
903                self.cert_cipher
904                    .as_deref()
905                    .map_or(Value::Null, |s| json!(s)),
906            );
907            tls.insert(
908                "resumed".into(),
909                self.tls_resumed.map_or(Value::Null, |b| json!(b)),
910            );
911            tls.insert(
912                "early_data_accepted".into(),
913                self.tls_early_data_accepted
914                    .map_or(Value::Null, |b| json!(b)),
915            );
916            tls.insert(
917                "ocsp_stapled".into(),
918                self.tls_ocsp_stapled.map_or(Value::Null, |b| json!(b)),
919            );
920            tls.insert(
921                "subject".into(),
922                self.subject.as_deref().map_or(Value::Null, |s| json!(s)),
923            );
924            tls.insert(
925                "issuer".into(),
926                self.issuer.as_deref().map_or(Value::Null, |s| json!(s)),
927            );
928            tls.insert(
929                "not_before".into(),
930                self.cert_not_before
931                    .as_deref()
932                    .map_or(Value::Null, |s| json!(s)),
933            );
934            tls.insert(
935                "not_after".into(),
936                self.cert_not_after
937                    .as_deref()
938                    .map_or(Value::Null, |s| json!(s)),
939            );
940            tls.insert(
941                "domains".into(),
942                self.cert_domains.as_ref().map_or(Value::Null, |d| json!(d)),
943            );
944            obj.insert("tls".into(), Value::Object(tls));
945        }
946
947        // Headers. A name can repeat (e.g. multiple Set-Cookie); emit a
948        // string for the common single-value case and an array when the
949        // header appeared more than once, so no value is silently dropped.
950        if let Some(headers) = &self.headers {
951            let mut hdr_map = Map::new();
952            for key in headers.keys() {
953                let mut values: Vec<Value> = headers
954                    .get_all(key)
955                    .iter()
956                    .map(|value| json!(value.to_str().unwrap_or_default()))
957                    .collect();
958                let v = if values.len() == 1 {
959                    values.remove(0)
960                } else {
961                    Value::Array(values)
962                };
963                hdr_map.insert(key.to_string(), v);
964            }
965            obj.insert("headers".into(), Value::Object(hdr_map));
966        }
967
968        // Body
969        obj.insert(
970            "body_size".into(),
971            self.body_size.map_or(Value::Null, |s| json!(s)),
972        );
973
974        // Error
975        obj.insert(
976            "error".into(),
977            self.error.as_deref().map_or(Value::Null, |e| json!(e)),
978        );
979        obj.insert("exit_code".into(), json!(self.exit_code()));
980
981        Value::Object(obj)
982    }
983}
984
985impl fmt::Display for HttpStat {
986    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
987        let s = self.lang.strings();
988        if let Some(addr) = &self.addr {
989            let label = if self.tcp_connect.is_some() || self.quic_connect.is_some() {
990                LightGreen.paint(s.connected_to)
991            } else {
992                LightYellow.paint(s.resolved_to)
993            };
994            let mut text = format!("{} {}", label, LightCyan.paint(addr));
995            if self.silent {
996                if let Some(status) = &self.status {
997                    let alpn = self.alpn.as_deref().unwrap_or(ALPN_HTTP1);
998                    let status_code = status.as_u16();
999                    let status = if status_code < 400 {
1000                        LightGreen.paint(status.to_string())
1001                    } else {
1002                        LightRed.paint(status.to_string())
1003                    };
1004                    text = format!(
1005                        "{text} --> {} {}",
1006                        LightCyan.paint(alpn.to_uppercase()),
1007                        status
1008                    );
1009                } else {
1010                    text = format!("{text} --> {}", LightRed.paint(s.fail));
1011                }
1012                text = format!("{text} {}", format_duration(self.total.unwrap_or_default()));
1013                // Surface TLS resumption / 0-RTT inline — most useful in
1014                // benchmark (-n) output where iterations 2+ may resume.
1015                // "0-RTT" stays as a technical token across locales.
1016                if let Some(true) = self.tls_resumed {
1017                    let tag = if matches!(self.tls_early_data_accepted, Some(true)) {
1018                        "0-RTT"
1019                    } else {
1020                        s.handshake_resumed
1021                    };
1022                    text = format!("{text} [{}]", LightYellow.paint(tag));
1023                }
1024            }
1025            writeln!(f, "{text}")?;
1026        }
1027        if let Some(error) = &self.error {
1028            writeln!(f, "{}: {}", s.error_label, LightRed.paint(error))?;
1029        }
1030        if self.silent {
1031            return Ok(());
1032        }
1033        if self.verbose {
1034            for (key, value) in self.request_headers.iter() {
1035                writeln!(
1036                    f,
1037                    "{}: {}",
1038                    key.to_string().to_train_case(),
1039                    LightCyan.paint(value.to_str().unwrap_or_default())
1040                )?;
1041            }
1042            writeln!(f)?;
1043        }
1044
1045        if let Some(status) = &self.status {
1046            let alpn = self.alpn.as_deref().unwrap_or(ALPN_HTTP1);
1047            let status_code = status.as_u16();
1048            let status = if status_code < 400 {
1049                LightGreen.paint(status.to_string())
1050            } else {
1051                LightRed.paint(status.to_string())
1052            };
1053            writeln!(f, "{} {}", LightCyan.paint(alpn.to_uppercase()), status)?;
1054        }
1055        if self.is_grpc {
1056            if self.is_success() {
1057                writeln!(f, "{}", LightGreen.paint(s.grpc_ok))?;
1058            }
1059            writeln!(f)?;
1060        }
1061
1062        if let Some(tls) = &self.tls {
1063            writeln!(f)?;
1064            writeln!(f, "{}: {}", s.tls_label, LightCyan.paint(tls))?;
1065            // Unknown for HTTP/3: quinn doesn't expose the negotiated suite.
1066            if let Some(cipher) = self.cert_cipher.as_deref() {
1067                writeln!(f, "{}: {}", s.cipher, LightCyan.paint(cipher))?;
1068            }
1069            if let Some(resumed) = self.tls_resumed {
1070                let label = if resumed {
1071                    s.handshake_resumed
1072                } else {
1073                    s.handshake_full
1074                };
1075                writeln!(f, "{}: {}", s.handshake, LightCyan.paint(label))?;
1076            }
1077            if let Some(accepted) = self.tls_early_data_accepted {
1078                let label = if accepted {
1079                    s.early_data_accepted
1080                } else {
1081                    s.early_data_not_accepted
1082                };
1083                writeln!(f, "{}: {}", s.early_data, LightCyan.paint(label))?;
1084            }
1085            if let Some(stapled) = self.tls_ocsp_stapled {
1086                let label = if stapled {
1087                    s.ocsp_stapled
1088                } else {
1089                    s.ocsp_not_stapled
1090                };
1091                writeln!(f, "{}: {}", s.ocsp, LightCyan.paint(label))?;
1092            }
1093            writeln!(
1094                f,
1095                "{}: {}",
1096                s.not_before,
1097                LightCyan.paint(self.cert_not_before.as_deref().unwrap_or_default())
1098            )?;
1099            writeln!(
1100                f,
1101                "{}: {}",
1102                s.not_after,
1103                LightCyan.paint(self.cert_not_after.as_deref().unwrap_or_default())
1104            )?;
1105            if self.verbose {
1106                writeln!(
1107                    f,
1108                    "{}: {}",
1109                    s.subject,
1110                    LightCyan.paint(self.subject.as_deref().unwrap_or_default())
1111                )?;
1112                writeln!(
1113                    f,
1114                    "{}: {}",
1115                    s.issuer,
1116                    LightCyan.paint(self.issuer.as_deref().unwrap_or_default())
1117                )?;
1118                writeln!(
1119                    f,
1120                    "{}: {}",
1121                    s.cert_domains,
1122                    LightCyan.paint(self.cert_domains.as_deref().unwrap_or_default().join(", "))
1123                )?;
1124            }
1125            writeln!(f)?;
1126
1127            if self.verbose {
1128                if let Some(certificates) = &self.certificates {
1129                    writeln!(f, "{}", s.cert_chain)?;
1130                    for (index, cert) in certificates.iter().enumerate() {
1131                        writeln!(
1132                            f,
1133                            " {index} {}: {}",
1134                            s.subject,
1135                            LightCyan.paint(cert.subject.as_str())
1136                        )?;
1137                        writeln!(
1138                            f,
1139                            "   {}: {}",
1140                            s.issuer,
1141                            LightCyan.paint(cert.issuer.as_str())
1142                        )?;
1143                        writeln!(
1144                            f,
1145                            "   {}: {}",
1146                            s.not_before,
1147                            LightCyan.paint(cert.not_before.as_str())
1148                        )?;
1149                        writeln!(
1150                            f,
1151                            "   {}: {}",
1152                            s.not_after,
1153                            LightCyan.paint(cert.not_after.as_str())
1154                        )?;
1155                        writeln!(f)?;
1156                    }
1157                }
1158            }
1159        }
1160
1161        // Kernel TCP stats — shown under `-v` or whenever `--tcp-info` is set
1162        // via `self.show_tcp_info`. The "during" retransmit count is the
1163        // diagnostic gold: it isolates packet loss to *this* request's window
1164        // rather than the connection's lifetime.
1165        if (self.verbose || self.show_tcp_info)
1166            && (self.tcp_info_post_connect.is_some() || self.tcp_info_final.is_some())
1167        {
1168            writeln!(f, "{}", LightGreen.paint(s.kernel_tcp_heading))?;
1169            // Technical field names (rtt/retrans/cwnd/mss) stay English —
1170            // they're the standard `getsockopt(TCP_INFO)` field names.
1171            let render = |label: &str, info: &TcpInfo| -> String {
1172                let rtt = info.rtt.map(format_duration).unwrap_or_else(|| "-".into());
1173                let rttvar = info
1174                    .rttvar
1175                    .map(format_duration)
1176                    .unwrap_or_else(|| "-".into());
1177                let retrans = info
1178                    .retransmits
1179                    .map(|v| v.to_string())
1180                    .unwrap_or_else(|| "-".into());
1181                let cwnd = info
1182                    .cwnd
1183                    .map(|v| v.to_string())
1184                    .unwrap_or_else(|| "-".into());
1185                let mss = info
1186                    .snd_mss
1187                    .map(|v| v.to_string())
1188                    .unwrap_or_else(|| "-".into());
1189                format!(
1190                    "  {:<14} rtt {} \u{00B1} {}  retrans {}  cwnd {}  mss {}",
1191                    label, rtt, rttvar, retrans, cwnd, mss,
1192                )
1193            };
1194            if let Some(info) = &self.tcp_info_post_connect {
1195                writeln!(
1196                    f,
1197                    "{}",
1198                    LightCyan.paint(render(s.tcp_post_connect_row, info))
1199                )?;
1200            }
1201            if let Some(info) = &self.tcp_info_final {
1202                writeln!(f, "{}", LightCyan.paint(render(s.tcp_final_row, info)))?;
1203            }
1204            if let Some(delta) = TcpInfoDelta::compute(
1205                self.tcp_info_post_connect.as_ref(),
1206                self.tcp_info_final.as_ref(),
1207            ) {
1208                if let Some(n) = delta.retransmits_during {
1209                    let label = format!("  {} {n} {}", s.tcp_during, s.tcp_retransmit_word);
1210                    let painted = if n == 0 {
1211                        LightCyan.paint(label)
1212                    } else {
1213                        LightYellow.paint(label)
1214                    };
1215                    writeln!(f, "{painted}")?;
1216                }
1217            }
1218            writeln!(f)?;
1219        }
1220
1221        let mut is_text = false;
1222        let mut is_json = false;
1223        if let Some(headers) = &self.headers {
1224            for (key, value) in headers.iter() {
1225                let value = value.to_str().unwrap_or_default();
1226                if key == http::header::CONTENT_TYPE {
1227                    if value.contains("text/") || value.contains("application/json") {
1228                        is_text = true;
1229                    }
1230                    if value.contains("application/json") {
1231                        is_json = true;
1232                    }
1233                }
1234                let key_lower = key.as_str();
1235                let show = if let Some(includes) = &self.include_headers {
1236                    includes.iter().any(|h| h == key_lower)
1237                } else if let Some(excludes) = &self.exclude_headers {
1238                    !excludes.iter().any(|h| h == key_lower)
1239                } else {
1240                    true
1241                };
1242                if show {
1243                    writeln!(
1244                        f,
1245                        "{}: {}",
1246                        key.to_string().to_train_case(),
1247                        LightCyan.paint(value)
1248                    )?;
1249                }
1250            }
1251            writeln!(f)?;
1252        }
1253
1254        // Server-Timing breakdown (what the server says happened inside Server Processing).
1255        // Each row shows: name, a sparkline-style bar sized by share of the reported total,
1256        // duration, and percent. The header line reconciles the reported sum against the
1257        // measured Server Processing time so the unaccounted gap (network/queueing) is visible.
1258        if let Some(entries) = &self.server_timing {
1259            if !entries.is_empty() {
1260                const BAR_W: usize = 34;
1261                let name_w = entries
1262                    .iter()
1263                    .map(|e| e.name.chars().count())
1264                    .max()
1265                    .unwrap_or(0)
1266                    .max(4);
1267
1268                let sum: Duration = entries.iter().filter_map(|e| e.duration).sum();
1269                let total_ns = (sum.as_nanos() as f64).max(1.0);
1270                let largest_idx: Option<usize> = entries
1271                    .iter()
1272                    .enumerate()
1273                    .filter_map(|(i, e)| e.duration.filter(|d| !d.is_zero()).map(|d| (i, d)))
1274                    .max_by_key(|(_, d)| *d)
1275                    .map(|(i, _)| i);
1276
1277                let summary = match self.server_processing {
1278                    Some(sp) if sp > sum => format!(
1279                        "(\u{03A3} {} {} {} {} \u{00B7} {} {})",
1280                        format_duration(sum),
1281                        s.st_sum_of,
1282                        format_duration(sp),
1283                        s.server_processing,
1284                        format_duration(sp - sum),
1285                        s.st_unaccounted,
1286                    ),
1287                    Some(sp) => format!(
1288                        "(\u{03A3} {} {} {} {})",
1289                        format_duration(sum),
1290                        s.st_sum_of,
1291                        format_duration(sp),
1292                        s.server_processing,
1293                    ),
1294                    None => format!("(\u{03A3} {})", format_duration(sum)),
1295                };
1296                writeln!(
1297                    f,
1298                    "{} {}",
1299                    LightGreen.paint(s.server_timing_heading),
1300                    LightCyan.paint(&summary),
1301                )?;
1302
1303                // Lay each bar out at its cumulative offset, so the sequence reads
1304                // left-to-right like a waterfall inside Server Processing.
1305                let sum_ns_u = sum.as_nanos();
1306                let mut cum_ns: u128 = 0;
1307                for (i, entry) in entries.iter().enumerate() {
1308                    let name_pad = " ".repeat(name_w.saturating_sub(entry.name.chars().count()));
1309                    let dur_ns = entry.duration.map(|d| d.as_nanos()).unwrap_or(0);
1310
1311                    let start_col = ((cum_ns as f64 / total_ns) * BAR_W as f64).round() as usize;
1312                    let start_col = start_col.min(BAR_W);
1313                    let mut end_col =
1314                        (((cum_ns + dur_ns) as f64 / total_ns) * BAR_W as f64).round() as usize;
1315                    end_col = end_col.min(BAR_W);
1316                    // Non-zero entries should always paint at least one cell so they
1317                    // don't disappear into rounding.
1318                    if dur_ns > 0 && end_col <= start_col {
1319                        end_col = (start_col + 1).min(BAR_W);
1320                    }
1321
1322                    let bar: String = (0..BAR_W)
1323                        .map(|col| {
1324                            if dur_ns == 0 {
1325                                let marker = start_col.min(BAR_W - 1);
1326                                if col == marker {
1327                                    '\u{00B7}'
1328                                } else {
1329                                    '\u{2591}'
1330                                }
1331                            } else if col >= start_col && col < end_col {
1332                                '\u{2588}'
1333                            } else {
1334                                '\u{2591}'
1335                            }
1336                        })
1337                        .collect();
1338
1339                    let (dur_str, pct_str) = if dur_ns > 0 {
1340                        let pct = if sum_ns_u > 0 {
1341                            (dur_ns as f64 / sum_ns_u as f64) * 100.0
1342                        } else {
1343                            0.0
1344                        };
1345                        (
1346                            format_duration(entry.duration.unwrap_or_default()),
1347                            format!("{pct:>5.1}%"),
1348                        )
1349                    } else {
1350                        ("\u{2014}".to_string(), "\u{2013}".to_string())
1351                    };
1352
1353                    let is_largest = Some(i) == largest_idx;
1354                    let bar_painted = if is_largest {
1355                        LightYellow.paint(&bar).to_string()
1356                    } else {
1357                        LightCyan.paint(&bar).to_string()
1358                    };
1359                    let dur_painted = if is_largest {
1360                        LightYellow.paint(format!("{dur_str:>8}")).to_string()
1361                    } else {
1362                        LightCyan.paint(format!("{dur_str:>8}")).to_string()
1363                    };
1364                    let pct_painted = LightCyan.paint(format!("{pct_str:>6}")).to_string();
1365                    let desc = entry
1366                        .description
1367                        .as_deref()
1368                        .map(|d| format!("  ({d})"))
1369                        .unwrap_or_default();
1370
1371                    writeln!(
1372                        f,
1373                        "  {}{}  {}  {}  {}{}",
1374                        LightCyan.paint(&entry.name),
1375                        name_pad,
1376                        bar_painted,
1377                        dur_painted,
1378                        pct_painted,
1379                        desc,
1380                    )?;
1381
1382                    cum_ns += dur_ns;
1383                }
1384                writeln!(f)?;
1385            }
1386        }
1387
1388        // Protocol advertisements (Alt-Svc / HSTS).
1389        // These are facts the server told us about itself — no extra
1390        // network calls, just headers we already parsed.
1391        if self.alt_svc.is_some() || self.hsts.is_some() {
1392            writeln!(f, "{}", LightGreen.paint(s.protocol_adv_heading))?;
1393            if let Some(entries) = &self.alt_svc {
1394                for entry in entries {
1395                    let ma = match entry.max_age {
1396                        Some(ma) => format!("  ma={ma}s"),
1397                        None => String::new(),
1398                    };
1399                    writeln!(
1400                        f,
1401                        "  {}  {}={}{}",
1402                        s.alt_svc_label,
1403                        LightCyan.paint(&entry.protocol),
1404                        LightCyan.paint(&entry.authority),
1405                        LightCyan.paint(ma),
1406                    )?;
1407                }
1408            }
1409            if let Some(h) = &self.hsts {
1410                let mut flags = String::new();
1411                if h.include_subdomains {
1412                    flags.push_str("; includeSubDomains");
1413                }
1414                if h.preload {
1415                    flags.push_str("; preload");
1416                }
1417                writeln!(
1418                    f,
1419                    "  {}  {}{}",
1420                    s.hsts_label,
1421                    LightCyan.paint(format!("max-age={}s", h.max_age)),
1422                    LightCyan.paint(flags),
1423                )?;
1424            }
1425            writeln!(f)?;
1426        }
1427
1428        if self.waterfall {
1429            self.fmt_waterfall(f)?;
1430        } else {
1431            let width = 20;
1432
1433            let mut timelines = vec![];
1434            // When a DoH/DoT probe ran, render DNS as two columns so the user
1435            // can see whether the cost was the TLS handshake or the query.
1436            if let Some(connect) = self.dns_connect {
1437                timelines.push(Timeline {
1438                    name: s.dns_connect.to_string(),
1439                    duration: connect,
1440                });
1441                if let Some(query) = self.dns_query() {
1442                    timelines.push(Timeline {
1443                        name: s.dns_query.to_string(),
1444                        duration: query,
1445                    });
1446                }
1447            } else if let Some(value) = self.dns_lookup {
1448                timelines.push(Timeline {
1449                    name: s.dns_lookup.to_string(),
1450                    duration: value,
1451                });
1452            }
1453            if let Some(value) = self.tcp_connect {
1454                timelines.push(Timeline {
1455                    name: s.tcp_connect.to_string(),
1456                    duration: value,
1457                });
1458            }
1459            if let Some(value) = self.tls_handshake {
1460                timelines.push(Timeline {
1461                    name: s.tls_handshake.to_string(),
1462                    duration: value,
1463                });
1464            }
1465            if let Some(value) = self.quic_connect {
1466                timelines.push(Timeline {
1467                    name: s.quic_connect.to_string(),
1468                    duration: value,
1469                });
1470            }
1471            if let Some(value) = self.request_send {
1472                timelines.push(Timeline {
1473                    name: s.request_send.to_string(),
1474                    duration: value,
1475                });
1476            }
1477            if let Some(value) = self.server_processing {
1478                timelines.push(Timeline {
1479                    name: s.server_processing.to_string(),
1480                    duration: value,
1481                });
1482            }
1483            if let Some(value) = self.content_transfer {
1484                timelines.push(Timeline {
1485                    name: s.content_transfer.to_string(),
1486                    duration: value,
1487                });
1488            }
1489
1490            if !timelines.is_empty() {
1491                write!(f, " ")?;
1492                for (i, timeline) in timelines.iter().enumerate() {
1493                    write!(
1494                        f,
1495                        "{}",
1496                        timeline.name.unicode_pad(width, Alignment::Center, true)
1497                    )?;
1498                    if i < timelines.len() - 1 {
1499                        write!(f, " ")?;
1500                    }
1501                }
1502                writeln!(f)?;
1503
1504                write!(f, "[")?;
1505                for (i, timeline) in timelines.iter().enumerate() {
1506                    write!(
1507                        f,
1508                        "{}",
1509                        LightCyan.paint(
1510                            format_duration(timeline.duration)
1511                                .unicode_pad(width, Alignment::Center, true)
1512                                .to_string(),
1513                        )
1514                    )?;
1515                    if i < timelines.len() - 1 {
1516                        write!(f, "|")?;
1517                    }
1518                }
1519                writeln!(f, "]")?;
1520            }
1521
1522            write!(f, " ")?;
1523            for _ in 0..timelines.len() {
1524                write!(f, "{}", " ".repeat(width))?;
1525                write!(f, "|")?;
1526            }
1527            writeln!(f)?;
1528            write!(f, "{}", " ".repeat(width * timelines.len()))?;
1529            write!(
1530                f,
1531                "{}:{}\n\n",
1532                s.total,
1533                LightCyan.paint(format_duration(self.total.unwrap_or_default()))
1534            )?;
1535        }
1536
1537        if let Some(body) = &self.body {
1538            let status = self.status.unwrap_or(StatusCode::OK).as_u16();
1539            let mut body = std::str::from_utf8(body.as_ref())
1540                .unwrap_or_default()
1541                .to_string();
1542            if let Some(filter) = &self.jq_filter {
1543                // On an unsupported filter or non-JSON body, show the reason in
1544                // place of the body rather than silently dumping the full body.
1545                body = match apply_jq_filter(&body, filter) {
1546                    Ok(filtered) => filtered,
1547                    Err(reason) => LightRed.paint(format!("jq: {reason}")).to_string(),
1548                };
1549            } else if self.pretty && is_json {
1550                if let Ok(json_body) = serde_json::from_str::<serde_json::Value>(&body) {
1551                    if let Ok(value) = serde_json::to_string_pretty(&json_body) {
1552                        body = value;
1553                    }
1554                }
1555            }
1556            if self.verbose || self.jq_filter.is_some() || (is_text && body.len() < 4096) {
1557                let text = format!(
1558                    "{}: {}",
1559                    s.body_size,
1560                    ByteSize(self.body_size.unwrap_or(0) as u64)
1561                );
1562                writeln!(f, "{}", LightCyan.paint(text))?;
1563                self.fmt_throughput(f)?;
1564                writeln!(f)?;
1565                if status >= 400 {
1566                    writeln!(f, "{}", LightRed.paint(body))?;
1567                } else {
1568                    writeln!(f, "{body}")?;
1569                }
1570            } else {
1571                let mut save_tips = "".to_string();
1572                if let Ok(mut file) = NamedTempFile::new() {
1573                    if let Ok(()) = file.write_all(body.as_bytes()) {
1574                        save_tips = format!("{}: {}", s.saved_to, file.path().display());
1575                        let _ = file.keep();
1576                    }
1577                }
1578                let text = format!(
1579                    "{} {}",
1580                    s.body_discarded,
1581                    ByteSize(self.body_size.unwrap_or(0) as u64)
1582                );
1583                writeln!(f, "{} {}", LightCyan.paint(text), save_tips)?;
1584                self.fmt_throughput(f)?;
1585            }
1586        }
1587
1588        Ok(())
1589    }
1590}
1591
1592impl HttpStat {
1593    /// Render the throughput line(s) under the body summary. Always shown
1594    /// when the body exceeds `THROUGHPUT_DISPLAY_THRESHOLD` (1 MiB);
1595    /// verbose mode additionally splits "first 100 KB" vs "tail" so the
1596    /// user can tell TCP slow-start from steady-state server-side slowness.
1597    fn fmt_throughput(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1598        let Some(wire) = self.wire_body_size else {
1599            return Ok(());
1600        };
1601        if wire < THROUGHPUT_DISPLAY_THRESHOLD {
1602            return Ok(());
1603        }
1604        let Some(total) = self.throughput_bps() else {
1605            return Ok(());
1606        };
1607        let s = self.lang.strings();
1608        writeln!(
1609            f,
1610            "{} {}",
1611            LightCyan.paint(s.throughput),
1612            LightCyan.paint(format_throughput(total)),
1613        )?;
1614        if self.verbose {
1615            if let (Some(first), Some(tail)) = (
1616                self.first_chunk_throughput_bps(),
1617                self.tail_throughput_bps(),
1618            ) {
1619                writeln!(
1620                    f,
1621                    "  {} {}  {}  {} {}",
1622                    LightCyan.paint(s.throughput_first_100k),
1623                    LightCyan.paint(format_throughput(first)),
1624                    LightCyan.paint("·"),
1625                    LightCyan.paint(s.throughput_then),
1626                    LightCyan.paint(format_throughput(tail)),
1627                )?;
1628            }
1629        }
1630        Ok(())
1631    }
1632}
1633
1634pub struct BenchmarkSummary {
1635    pub stats: Vec<HttpStat>,
1636    pub lang: Lang,
1637}
1638
1639impl BenchmarkSummary {
1640    fn collect_sorted(&self, f: impl Fn(&HttpStat) -> Option<Duration>) -> Vec<Duration> {
1641        let mut v: Vec<Duration> = self.stats.iter().filter_map(f).collect();
1642        v.sort();
1643        v
1644    }
1645
1646    fn percentile(sorted: &[Duration], p: f64) -> Option<Duration> {
1647        if sorted.is_empty() {
1648            return None;
1649        }
1650        let idx = ((p * sorted.len() as f64).ceil() as usize).saturating_sub(1);
1651        Some(sorted[idx.min(sorted.len() - 1)])
1652    }
1653}
1654
1655impl fmt::Display for BenchmarkSummary {
1656    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1657        let total = self.stats.len();
1658        if total == 0 {
1659            return Ok(());
1660        }
1661
1662        let strs = self.lang.strings();
1663        // When any run used DoH/DoT, render DNS as two columns; otherwise keep
1664        // the single DNS Lookup column for the existing plain-UDP path.
1665        let has_dns_connect = self.stats.iter().any(|s| s.dns_connect.is_some());
1666        let dns_cols: Vec<(&str, Vec<Duration>)> = if has_dns_connect {
1667            vec![
1668                (strs.dns_connect, self.collect_sorted(|s| s.dns_connect)),
1669                (strs.dns_query, self.collect_sorted(|s| s.dns_query())),
1670            ]
1671        } else {
1672            vec![(strs.dns_lookup, self.collect_sorted(|s| s.dns_lookup))]
1673        };
1674        let phases: Vec<(&str, Vec<Duration>)> = dns_cols
1675            .into_iter()
1676            .chain([
1677                (strs.tcp_connect, self.collect_sorted(|s| s.tcp_connect)),
1678                (strs.tls_handshake, self.collect_sorted(|s| s.tls_handshake)),
1679                (strs.quic_connect, self.collect_sorted(|s| s.quic_connect)),
1680                (strs.request_send, self.collect_sorted(|s| s.request_send)),
1681                (
1682                    strs.server_processing_short,
1683                    self.collect_sorted(|s| s.server_processing),
1684                ),
1685                (
1686                    strs.content_transfer_short,
1687                    self.collect_sorted(|s| s.content_transfer),
1688                ),
1689                (strs.total, self.collect_sorted(|s| s.total)),
1690            ])
1691            .filter(|(_, v)| !v.is_empty())
1692            .collect();
1693
1694        if phases.is_empty() {
1695            return Ok(());
1696        }
1697
1698        writeln!(f)?;
1699        writeln!(
1700            f,
1701            "{}",
1702            LightGreen.paint(format!(
1703                "{} ({total} {}) ---",
1704                strs.benchmark_results_prefix, strs.benchmark_results_requests,
1705            ))
1706        )?;
1707        writeln!(f)?;
1708
1709        let col_w = 18;
1710        let label_w = 6;
1711
1712        // Header row
1713        write!(f, "{:>label_w$} ", "")?;
1714        for (name, _) in &phases {
1715            write!(f, "{}", name.unicode_pad(col_w, Alignment::Center, true))?;
1716        }
1717        writeln!(f)?;
1718
1719        // Stats rows — p50/p95/p99 are standard percentile notation, kept
1720        // as-is across locales. Only min/max/avg get translated.
1721        let rows: [(&str, f64); 6] = [
1722            (strs.min, 0.0),
1723            (strs.max, f64::INFINITY),
1724            (strs.avg, f64::NAN),
1725            ("p50", 0.5),
1726            ("p95", 0.95),
1727            ("p99", 0.99),
1728        ];
1729
1730        for (label, p) in &rows {
1731            write!(f, "{} ", LightGreen.paint(format!("{label:>label_w$}")))?;
1732            for (_, sorted) in &phases {
1733                let val = if p.is_nan() {
1734                    // avg
1735                    if sorted.is_empty() {
1736                        None
1737                    } else {
1738                        let sum: Duration = sorted.iter().sum();
1739                        Some(sum / sorted.len() as u32)
1740                    }
1741                } else if *p == 0.0 {
1742                    sorted.first().copied()
1743                } else if p.is_infinite() {
1744                    sorted.last().copied()
1745                } else {
1746                    Self::percentile(sorted, *p)
1747                };
1748                let text = match val {
1749                    Some(d) => format_duration(d),
1750                    None => "-".to_string(),
1751                };
1752                write!(
1753                    f,
1754                    "{}",
1755                    LightCyan.paint(text.unicode_pad(col_w, Alignment::Center, true).to_string())
1756                )?;
1757            }
1758            writeln!(f)?;
1759        }
1760
1761        writeln!(f)?;
1762        let success = self.stats.iter().filter(|s| s.is_success()).count();
1763        let pct = (success as f64 / total as f64) * 100.0;
1764        let success_text = format!("{}: {success}/{total} ({pct:.1}%)", strs.success);
1765        if success == total {
1766            writeln!(f, "  {}", LightGreen.paint(success_text))?;
1767        } else {
1768            writeln!(f, "  {}", LightRed.paint(success_text))?;
1769        }
1770
1771        Ok(())
1772    }
1773}
1774
1775#[cfg(test)]
1776mod tests {
1777    use super::*;
1778
1779    // ---- format_duration ----
1780    #[test]
1781    fn format_duration_units() {
1782        assert_eq!(format_duration(Duration::from_micros(999)), "999µs");
1783        assert_eq!(format_duration(Duration::from_millis(500)), "500ms");
1784        assert_eq!(format_duration(Duration::from_secs_f64(1.5)), "1.50s");
1785    }
1786
1787    #[test]
1788    fn format_duration_boundaries() {
1789        // exactly 1ms is NOT strictly greater than 1ms, so it renders as µs
1790        assert_eq!(format_duration(Duration::from_millis(1)), "1000µs");
1791        // exactly 1s is NOT strictly greater than 1s, so it renders as ms
1792        assert_eq!(format_duration(Duration::from_secs(1)), "1000ms");
1793    }
1794
1795    // ---- format_throughput ----
1796    #[test]
1797    fn format_throughput_units() {
1798        assert_eq!(format_throughput(1_500_000.0), "1.5 MB/s");
1799        assert_eq!(format_throughput(1_000_000.0), "1.0 MB/s");
1800        assert_eq!(format_throughput(2_048.0), "2.0 KB/s");
1801        assert_eq!(format_throughput(500.0), "500 B/s");
1802    }
1803
1804    #[test]
1805    fn format_throughput_invalid_inputs_render_dash() {
1806        assert_eq!(format_throughput(0.0), "-");
1807        assert_eq!(format_throughput(-5.0), "-");
1808        assert_eq!(format_throughput(f64::NAN), "-");
1809        assert_eq!(format_throughput(f64::INFINITY), "-");
1810    }
1811
1812    // ---- format_time ----
1813    #[test]
1814    fn format_time_invalid_falls_back_to_number() {
1815        // out-of-range timestamp can't be represented; we echo the raw number
1816        assert_eq!(format_time(i64::MAX), i64::MAX.to_string());
1817    }
1818
1819    #[test]
1820    fn format_time_shape() {
1821        // "YYYY-MM-DD HH:MM:SS ±HH:MM" — 26 chars regardless of the local offset.
1822        let s = format_time(0);
1823        assert_eq!(s.len(), 26, "unexpected format: {s}");
1824        let sign = s.as_bytes()[20] as char;
1825        assert!(sign == '+' || sign == '-', "no offset sign in: {s}");
1826    }
1827
1828    // ---- parse_server_timing ----
1829    #[test]
1830    fn parse_server_timing_basic() {
1831        let st = parse_server_timing(["db;dur=53.2, app;dur=47.1;desc=\"App Server\""]).unwrap();
1832        assert_eq!(st.len(), 2);
1833        assert_eq!(st[0].name, "db");
1834        assert_eq!(st[0].duration, Some(Duration::from_secs_f64(53.2 / 1000.0)));
1835        assert_eq!(st[1].name, "app");
1836        assert_eq!(st[1].description.as_deref(), Some("App Server"));
1837    }
1838
1839    #[test]
1840    fn parse_server_timing_name_only_and_empty() {
1841        let st = parse_server_timing(["cache"]).unwrap();
1842        assert_eq!(st[0].name, "cache");
1843        assert_eq!(st[0].duration, None);
1844        assert!(parse_server_timing(Vec::<&str>::new()).is_none());
1845        assert!(parse_server_timing([""]).is_none());
1846    }
1847
1848    // ---- parse_alt_svc ----
1849    #[test]
1850    fn parse_alt_svc_basic() {
1851        let v = parse_alt_svc(["h3=\":443\"; ma=86400, h2=\"alt.example:443\""]).unwrap();
1852        assert_eq!(v.len(), 2);
1853        assert_eq!(v[0].protocol, "h3");
1854        assert_eq!(v[0].authority, ":443");
1855        assert_eq!(v[0].max_age, Some(86400));
1856        assert_eq!(v[1].protocol, "h2");
1857        assert_eq!(v[1].authority, "alt.example:443");
1858        assert_eq!(v[1].max_age, None);
1859    }
1860
1861    #[test]
1862    fn parse_alt_svc_clear_resets() {
1863        assert!(parse_alt_svc(["clear"]).is_none());
1864        let v = parse_alt_svc(["h3=\":443\", clear, h2=\":8443\""]).unwrap();
1865        assert_eq!(v.len(), 1);
1866        assert_eq!(v[0].protocol, "h2");
1867    }
1868
1869    // ---- parse_hsts ----
1870    #[test]
1871    fn parse_hsts_full() {
1872        let h = parse_hsts("max-age=31536000; includeSubDomains; preload").unwrap();
1873        assert_eq!(h.max_age, 31536000);
1874        assert!(h.include_subdomains);
1875        assert!(h.preload);
1876    }
1877
1878    #[test]
1879    fn parse_hsts_minimal_and_invalid() {
1880        let h = parse_hsts("max-age=0").unwrap();
1881        assert_eq!(h.max_age, 0);
1882        assert!(!h.include_subdomains);
1883        assert!(!h.preload);
1884        // a policy without max-age is not valid per RFC 6797
1885        assert!(parse_hsts("includeSubDomains; preload").is_none());
1886        // quoted value is accepted
1887        assert_eq!(parse_hsts("max-age=\"100\"").unwrap().max_age, 100);
1888    }
1889
1890    // ---- apply_jq_filter ----
1891    const JQ_BODY: &str = r#"{"name":"x","items":[{"name":"a"},{"name":"b"}]}"#;
1892
1893    #[test]
1894    fn jq_identity_and_key() {
1895        assert!(apply_jq_filter(JQ_BODY, ".").unwrap().contains("\"name\""));
1896        assert_eq!(apply_jq_filter(JQ_BODY, ".name"), Ok("\"x\"".to_string()));
1897        // the leading dot is optional
1898        assert_eq!(apply_jq_filter(JQ_BODY, "name"), Ok("\"x\"".to_string()));
1899    }
1900
1901    #[test]
1902    fn jq_iterate_and_index() {
1903        assert_eq!(
1904            apply_jq_filter(JQ_BODY, ".items[].name"),
1905            Ok("\"a\"\n\"b\"".to_string())
1906        );
1907        assert_eq!(
1908            apply_jq_filter(JQ_BODY, ".items[0].name"),
1909            Ok("\"a\"".to_string())
1910        );
1911    }
1912
1913    #[test]
1914    fn jq_missing_key_and_bad_json() {
1915        // a path that matches nothing yields an empty result (not an error)
1916        assert_eq!(apply_jq_filter(JQ_BODY, ".nope"), Ok(String::new()));
1917        // non-JSON input is now a clear error instead of a silent fallback
1918        assert!(apply_jq_filter("{not json", ".x").is_err());
1919    }
1920
1921    #[test]
1922    fn jq_unsupported_syntax_is_rejected() {
1923        // pipes, functions, select(), and `?` are outside the supported subset
1924        for f in [
1925            ".items | length",
1926            ".items[] | select(.name)",
1927            ".name?",
1928            "keys | length",
1929        ] {
1930            assert!(
1931                apply_jq_filter(JQ_BODY, f).is_err(),
1932                "expected '{f}' to be rejected"
1933            );
1934        }
1935    }
1936
1937    // ---- to_json ----
1938    #[test]
1939    fn to_json_keeps_duplicate_headers() {
1940        let mut headers = HeaderMap::new();
1941        headers.append("set-cookie", "a=1".parse().unwrap());
1942        headers.append("set-cookie", "b=2".parse().unwrap());
1943        headers.insert("content-type", "text/html".parse().unwrap());
1944        let stat = HttpStat {
1945            headers: Some(headers),
1946            ..Default::default()
1947        };
1948        let json = stat.to_json();
1949        let hdrs = json.get("headers").unwrap();
1950        assert_eq!(
1951            hdrs.get("set-cookie").unwrap(),
1952            &serde_json::json!(["a=1", "b=2"])
1953        );
1954        assert_eq!(
1955            hdrs.get("content-type").unwrap(),
1956            &serde_json::json!("text/html")
1957        );
1958    }
1959
1960    // ---- is_success / exit_code ----
1961    #[test]
1962    fn success_and_status_exit_codes() {
1963        assert!(!HttpStat::default().is_success());
1964        assert_eq!(HttpStat::default().exit_code(), 1);
1965
1966        let ok = HttpStat {
1967            status: Some(StatusCode::OK),
1968            ..Default::default()
1969        };
1970        assert!(ok.is_success());
1971        assert_eq!(ok.exit_code(), 0);
1972
1973        let c404 = HttpStat {
1974            status: Some(StatusCode::NOT_FOUND),
1975            ..Default::default()
1976        };
1977        assert!(!c404.is_success());
1978        assert_eq!(c404.exit_code(), 6);
1979
1980        let c500 = HttpStat {
1981            status: Some(StatusCode::INTERNAL_SERVER_ERROR),
1982            ..Default::default()
1983        };
1984        assert_eq!(c500.exit_code(), 7);
1985    }
1986
1987    #[test]
1988    fn error_exit_codes_by_phase() {
1989        let timeout = HttpStat {
1990            error: Some("operation timeout".into()),
1991            ..Default::default()
1992        };
1993        assert_eq!(timeout.exit_code(), 5);
1994
1995        let dns = HttpStat {
1996            error: Some("no such host".into()),
1997            dns_attempted: true,
1998            ..Default::default()
1999        };
2000        assert_eq!(dns.exit_code(), 2);
2001
2002        let tcp = HttpStat {
2003            error: Some("connection refused".into()),
2004            dns_attempted: true,
2005            dns_lookup: Some(Duration::from_millis(1)),
2006            ..Default::default()
2007        };
2008        assert_eq!(tcp.exit_code(), 3);
2009
2010        // IP-literal / --resolve / proxy targets skip DNS entirely: a refused
2011        // connection must map to the TCP code, not the DNS one.
2012        let refused_no_dns = HttpStat {
2013            error: Some("connection refused".into()),
2014            ..Default::default()
2015        };
2016        assert_eq!(refused_no_dns.exit_code(), 3);
2017
2018        let tls = HttpStat {
2019            error: Some("rustls: bad certificate".into()),
2020            dns_lookup: Some(Duration::from_millis(1)),
2021            tcp_connect: Some(Duration::from_millis(1)),
2022            ..Default::default()
2023        };
2024        assert_eq!(tls.exit_code(), 4);
2025    }
2026
2027    #[test]
2028    fn grpc_success_and_failure() {
2029        let ok = HttpStat {
2030            is_grpc: true,
2031            grpc_status: Some("0".into()),
2032            ..Default::default()
2033        };
2034        assert!(ok.is_success());
2035        assert_eq!(ok.exit_code(), 0);
2036
2037        let bad = HttpStat {
2038            is_grpc: true,
2039            grpc_status: Some("5".into()),
2040            ..Default::default()
2041        };
2042        assert!(!bad.is_success());
2043        assert_eq!(bad.exit_code(), 1);
2044    }
2045
2046    // ---- throughput / dns_query ----
2047    #[test]
2048    fn throughput_overall() {
2049        let s = HttpStat {
2050            wire_body_size: Some(1_000_000),
2051            content_transfer: Some(Duration::from_secs(1)),
2052            ..Default::default()
2053        };
2054        assert!((s.throughput_bps().unwrap() - 1_000_000.0).abs() < 1.0);
2055
2056        // zero transfer time → undefined
2057        let z = HttpStat {
2058            wire_body_size: Some(100),
2059            content_transfer: Some(Duration::ZERO),
2060            ..Default::default()
2061        };
2062        assert!(z.throughput_bps().is_none());
2063        assert!(HttpStat::default().throughput_bps().is_none());
2064    }
2065
2066    #[test]
2067    fn throughput_split_first_chunk_and_tail() {
2068        let s = HttpStat {
2069            wire_body_size: Some(200 * 1024),
2070            content_transfer: Some(Duration::from_secs(2)),
2071            time_to_first_100k: Some(Duration::from_secs(1)),
2072            ..Default::default()
2073        };
2074        // first 100 KiB in 1s
2075        assert!((s.first_chunk_throughput_bps().unwrap() - FIRST_CHUNK_BYTES as f64).abs() < 1.0);
2076        // remaining 100 KiB over the remaining 1s
2077        assert!((s.tail_throughput_bps().unwrap() - 100.0 * 1024.0).abs() < 1.0);
2078
2079        // a body that never crosses the first-chunk threshold has no tail
2080        let small = HttpStat {
2081            wire_body_size: Some(50_000),
2082            content_transfer: Some(Duration::from_secs(2)),
2083            time_to_first_100k: Some(Duration::from_secs(1)),
2084            ..Default::default()
2085        };
2086        assert!(small.tail_throughput_bps().is_none());
2087    }
2088
2089    #[test]
2090    fn dns_query_derivation() {
2091        let s = HttpStat {
2092            dns_lookup: Some(Duration::from_millis(50)),
2093            dns_connect: Some(Duration::from_millis(20)),
2094            ..Default::default()
2095        };
2096        assert_eq!(s.dns_query(), Some(Duration::from_millis(30)));
2097
2098        // probe racing ahead clamps to zero rather than underflowing
2099        let racy = HttpStat {
2100            dns_lookup: Some(Duration::from_millis(10)),
2101            dns_connect: Some(Duration::from_millis(20)),
2102            ..Default::default()
2103        };
2104        assert_eq!(racy.dns_query(), Some(Duration::ZERO));
2105
2106        // no probe → no derived query phase
2107        let plain = HttpStat {
2108            dns_lookup: Some(Duration::from_millis(5)),
2109            ..Default::default()
2110        };
2111        assert!(plain.dns_query().is_none());
2112    }
2113
2114    // ---- to_json ----
2115    #[test]
2116    fn to_json_blocks_are_conditional() {
2117        let with_body = HttpStat {
2118            status: Some(StatusCode::OK),
2119            wire_body_size: Some(1_000_000),
2120            content_transfer: Some(Duration::from_secs(1)),
2121            ..Default::default()
2122        };
2123        let v = with_body.to_json();
2124        assert!(v.is_object());
2125        assert!(v.get("timing").is_some());
2126        assert!(v.get("throughput").is_some());
2127
2128        // no body → throughput block omitted
2129        let no_body = HttpStat {
2130            status: Some(StatusCode::OK),
2131            ..Default::default()
2132        };
2133        assert!(no_body.to_json().get("throughput").is_none());
2134    }
2135}