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