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 bytes::Bytes;
19use bytesize::ByteSize;
20use chrono::{Local, TimeZone};
21use heck::ToTrainCase;
22use http::HeaderMap;
23use http::HeaderValue;
24use http::StatusCode;
25use nu_ansi_term::Color::{LightCyan, LightGreen, LightRed};
26use serde_json::{json, Map, Value};
27use std::fmt;
28use std::io::Write;
29use std::time::Duration;
30use tempfile::NamedTempFile;
31use unicode_truncate::Alignment;
32use unicode_truncate::UnicodeTruncateStr;
33
34pub static ALPN_HTTP2: &str = "h2";
35pub static ALPN_HTTP1: &str = "http/1.1";
36pub static ALPN_HTTP3: &str = "h3";
37
38// Format timestamp to human-readable string
39pub(crate) fn format_time(timestamp_seconds: i64) -> String {
40    Local
41        .timestamp_nanos(timestamp_seconds * 1_000_000_000)
42        .to_string()
43}
44
45pub fn format_duration(duration: Duration) -> String {
46    if duration > Duration::from_secs(1) {
47        return format!("{:.2}s", duration.as_secs_f64());
48    }
49    if duration > Duration::from_millis(1) {
50        return format!("{}ms", duration.as_millis());
51    }
52    format!("{}µs", duration.as_micros())
53}
54
55struct Timeline {
56    name: String,
57    duration: Duration,
58}
59
60/// Statistics and information collected during an HTTP request.
61///
62/// This struct contains timing information for each phase of the request,
63/// connection details, TLS information, and response data.
64///
65/// # Fields
66///
67/// * `dns_lookup` - Time taken for DNS resolution
68/// * `quic_connect` - Time taken to establish QUIC connection (for HTTP/3)
69/// * `tcp_connect` - Time taken to establish TCP connection
70/// * `tls_handshake` - Time taken for TLS handshake (for HTTPS)
71/// * `server_processing` - Time taken for server to process the request
72/// * `content_transfer` - Time taken to transfer the response body
73/// * `total` - Total time taken for the entire request
74/// * `addr` - Resolved IP address and port
75/// * `status` - HTTP response status code
76/// * `tls` - TLS protocol version used
77/// * `alpn` - Application-Layer Protocol Negotiation (ALPN) protocol selected
78/// * `cert_not_before` - Certificate validity start time
79/// * `cert_not_after` - Certificate validity end time
80/// * `cert_cipher` - TLS cipher suite used
81/// * `cert_domains` - List of domains in the certificate's Subject Alternative Names
82/// * `body` - Response body content
83/// * `headers` - Response headers
84/// * `error` - Any error that occurred during the request
85#[derive(Default, Debug, Clone)]
86pub struct HttpStat {
87    pub is_grpc: bool,
88    pub request_headers: HeaderMap<HeaderValue>,
89    pub dns_lookup: Option<Duration>,
90    pub quic_connect: Option<Duration>,
91    pub tcp_connect: Option<Duration>,
92    pub tls_handshake: Option<Duration>,
93    pub server_processing: Option<Duration>,
94    pub content_transfer: Option<Duration>,
95    pub total: Option<Duration>,
96    pub addr: Option<String>,
97    pub grpc_status: Option<String>,
98    pub status: Option<StatusCode>,
99    pub tls: Option<String>,
100    pub alpn: Option<String>,
101    pub subject: Option<String>,
102    pub issuer: Option<String>,
103    pub cert_not_before: Option<String>,
104    pub cert_not_after: Option<String>,
105    pub cert_cipher: Option<String>,
106    pub cert_domains: Option<Vec<String>>,
107    pub certificates: Option<Vec<Certificate>>,
108    pub body: Option<Bytes>,
109    pub body_size: Option<usize>,
110    pub headers: Option<HeaderMap<HeaderValue>>,
111    pub error: Option<String>,
112    pub silent: bool,
113    pub verbose: bool,
114    pub pretty: bool,
115    pub include_headers: Option<Vec<String>>,
116    pub exclude_headers: Option<Vec<String>>,
117    pub waterfall: bool,
118    pub jq_filter: Option<String>,
119}
120
121#[derive(Debug, Clone)]
122pub struct Certificate {
123    pub subject: String,
124    pub issuer: String,
125    pub not_before: String,
126    pub not_after: String,
127}
128
129/// Apply a simple jq-style field selector to a JSON string.
130/// Supported syntax:
131///   .                    identity (pretty-print)
132///   .field               object key access
133///   .field.sub           nested key access
134///   .[0]                 array index
135///   .[]                  iterate all array/object values
136///   combinations: .items[].name, .a.b[2].c, etc.
137fn apply_jq_filter(body: &str, filter: &str) -> Option<String> {
138    let root: serde_json::Value = serde_json::from_str(body).ok()?;
139    let filter = filter.trim();
140    // Allow omitting the leading '.' for convenience (e.g. "os" → ".os")
141    let owned;
142    let filter = if !filter.starts_with('.') {
143        owned = format!(".{filter}");
144        owned.as_str()
145    } else {
146        filter
147    };
148
149    // Tokenise the filter string into a list of access steps.
150    #[derive(Debug)]
151    enum Step {
152        Key(String),
153        Index(usize),
154        Iter,
155    }
156
157    fn tokenize(s: &str) -> Option<Vec<Step>> {
158        let s = s.strip_prefix('.')?;
159        if s.is_empty() {
160            return Some(vec![]);
161        }
162        let mut steps = Vec::new();
163        // Split on '.' but keep bracket expressions attached to the preceding key.
164        // We walk char-by-char to handle `key[0].next` etc.
165        let mut remaining = s;
166        while !remaining.is_empty() {
167            if remaining.starts_with('[') {
168                // bracket at the start: .[0] or .[]
169                let end = remaining.find(']')?;
170                let inner = &remaining[1..end];
171                if inner.is_empty() {
172                    steps.push(Step::Iter);
173                } else {
174                    let idx: usize = inner.parse().ok()?;
175                    steps.push(Step::Index(idx));
176                }
177                remaining = &remaining[end + 1..];
178                if remaining.starts_with('.') {
179                    remaining = &remaining[1..];
180                }
181            } else {
182                // read up to next '.' or '['
183                let end = remaining.find(['.', '[']).unwrap_or(remaining.len());
184                let key = &remaining[..end];
185                if !key.is_empty() {
186                    steps.push(Step::Key(key.to_string()));
187                }
188                remaining = &remaining[end..];
189                if remaining.starts_with('.') {
190                    remaining = &remaining[1..];
191                }
192            }
193        }
194        Some(steps)
195    }
196
197    fn apply_steps(values: Vec<serde_json::Value>, steps: &[Step]) -> Vec<serde_json::Value> {
198        if steps.is_empty() {
199            return values;
200        }
201        let mut current = values;
202        for step in steps {
203            current = match step {
204                Step::Key(k) => current
205                    .into_iter()
206                    .filter_map(|v| v.get(k).cloned())
207                    .collect(),
208                Step::Index(i) => current
209                    .into_iter()
210                    .filter_map(|v| v.get(i).cloned())
211                    .collect(),
212                Step::Iter => current
213                    .into_iter()
214                    .flat_map(|v| match v {
215                        serde_json::Value::Array(arr) => arr,
216                        serde_json::Value::Object(map) => map.into_values().collect(),
217                        other => vec![other],
218                    })
219                    .collect(),
220            };
221        }
222        current
223    }
224
225    let steps = tokenize(filter)?;
226    let results = apply_steps(vec![root], &steps);
227
228    if results.len() == 1 {
229        serde_json::to_string_pretty(&results[0]).ok()
230    } else {
231        Some(
232            results
233                .iter()
234                .filter_map(|v| serde_json::to_string_pretty(v).ok())
235                .collect::<Vec<_>>()
236                .join("\n"),
237        )
238    }
239}
240
241impl HttpStat {
242    /// Returns a semantic exit code based on the error type:
243    /// - 0: Success
244    /// - 1: General/unknown error
245    /// - 2: DNS resolution failure
246    /// - 3: TCP connection failure
247    /// - 4: TLS/SSL error
248    /// - 5: Timeout
249    /// - 6: HTTP 4xx client error
250    /// - 7: HTTP 5xx server error
251    pub fn exit_code(&self) -> i32 {
252        if self.is_success() {
253            return 0;
254        }
255        // HTTP status errors (no connection error, but bad status)
256        if self.error.is_none() {
257            if let Some(status) = &self.status {
258                let code = status.as_u16();
259                if code >= 500 {
260                    return 7;
261                }
262                if code >= 400 {
263                    return 6;
264                }
265            }
266            return 1;
267        }
268        let err = self.error.as_deref().unwrap_or_default().to_lowercase();
269        // Timeout (check before phase-based detection since timeout can happen in any phase)
270        if err.contains("timeout") || err.contains("elapsed") {
271            return 5;
272        }
273        // DNS failure: dns_lookup phase never completed
274        if self.dns_lookup.is_none() {
275            return 2;
276        }
277        // TCP failure: tcp/quic connection phase never completed
278        if self.tcp_connect.is_none() && self.quic_connect.is_none() {
279            return 3;
280        }
281        // TLS failure
282        if err.contains("rustls")
283            || err.contains("tls")
284            || err.contains("certificate")
285            || err.contains("invalid dns name")
286        {
287            return 4;
288        }
289        1
290    }
291
292    pub fn is_success(&self) -> bool {
293        if self.error.is_some() {
294            return false;
295        }
296        if self.is_grpc {
297            if let Some(grpc_status) = &self.grpc_status {
298                return grpc_status == "0";
299            }
300            return false;
301        }
302        let Some(status) = &self.status else {
303            return false;
304        };
305        if status.as_u16() >= 400 {
306            return false;
307        }
308        true
309    }
310
311    /// Render a waterfall bar chart to `f`.
312    /// Each phase is one row; bars are horizontally positioned by cumulative offset.
313    fn fmt_waterfall(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314        let total = match self.total {
315            Some(t) if t.as_nanos() > 0 => t,
316            _ => return Ok(()),
317        };
318
319        const BAR_WIDTH: usize = 50;
320        const LABEL_W: usize = 15;
321
322        let phases: &[(&str, Option<Duration>)] = &[
323            ("DNS Lookup", self.dns_lookup),
324            ("TCP Connect", self.tcp_connect),
325            ("TLS Handshake", self.tls_handshake),
326            ("QUIC Connect", self.quic_connect),
327            ("Server Process", self.server_processing),
328            ("Content Xfer", self.content_transfer),
329        ];
330
331        let total_ns = total.as_nanos() as f64;
332        let mut elapsed = Duration::ZERO;
333        let mut col_cursor: usize = 0;
334
335        for (name, dur_opt) in phases {
336            let Some(dur) = dur_opt else { continue };
337
338            let start_col = col_cursor;
339            elapsed += *dur;
340            let ideal_end = ((elapsed.as_nanos() as f64 / total_ns * BAR_WIDTH as f64).round()
341                as usize)
342                .min(BAR_WIDTH);
343            let end_col = ideal_end.min(BAR_WIDTH);
344            if end_col > start_col {
345                col_cursor = end_col;
346            }
347
348            let bar: String = (0..BAR_WIDTH)
349                .map(|i| {
350                    if i >= start_col && i < end_col {
351                        '█'
352                    } else {
353                        '░'
354                    }
355                })
356                .collect();
357
358            writeln!(
359                f,
360                " {:<LABEL_W$} [{}]  {}",
361                name,
362                LightCyan.paint(bar),
363                LightCyan.paint(format_duration(*dur))
364            )?;
365        }
366
367        writeln!(f)?;
368        writeln!(
369            f,
370            " {:LABEL_W$}  {:BAR_WIDTH$}  Total: {}",
371            "",
372            "",
373            LightCyan.paint(format_duration(total))
374        )?;
375        writeln!(f)
376    }
377
378    pub fn to_json(&self) -> Value {
379        let dur_us = |d: Option<Duration>| -> Value {
380            d.map_or(Value::Null, |d| json!(d.as_micros() as u64))
381        };
382
383        let mut obj = Map::new();
384
385        // Timing (microseconds)
386        let mut timing = Map::new();
387        timing.insert("dns_lookup_us".into(), dur_us(self.dns_lookup));
388        timing.insert("tcp_connect_us".into(), dur_us(self.tcp_connect));
389        timing.insert("tls_handshake_us".into(), dur_us(self.tls_handshake));
390        timing.insert("quic_connect_us".into(), dur_us(self.quic_connect));
391        timing.insert(
392            "server_processing_us".into(),
393            dur_us(self.server_processing),
394        );
395        timing.insert("content_transfer_us".into(), dur_us(self.content_transfer));
396        timing.insert("total_us".into(), dur_us(self.total));
397        obj.insert("timing".into(), Value::Object(timing));
398
399        // Connection
400        obj.insert(
401            "addr".into(),
402            self.addr.as_deref().map_or(Value::Null, |s| json!(s)),
403        );
404        obj.insert(
405            "status".into(),
406            self.status.map_or(Value::Null, |s| json!(s.as_u16())),
407        );
408        obj.insert(
409            "alpn".into(),
410            self.alpn.as_deref().map_or(Value::Null, |s| json!(s)),
411        );
412
413        // TLS
414        if self.tls.is_some() {
415            let mut tls = Map::new();
416            tls.insert(
417                "version".into(),
418                self.tls.as_deref().map_or(Value::Null, |s| json!(s)),
419            );
420            tls.insert(
421                "cipher".into(),
422                self.cert_cipher
423                    .as_deref()
424                    .map_or(Value::Null, |s| json!(s)),
425            );
426            tls.insert(
427                "subject".into(),
428                self.subject.as_deref().map_or(Value::Null, |s| json!(s)),
429            );
430            tls.insert(
431                "issuer".into(),
432                self.issuer.as_deref().map_or(Value::Null, |s| json!(s)),
433            );
434            tls.insert(
435                "not_before".into(),
436                self.cert_not_before
437                    .as_deref()
438                    .map_or(Value::Null, |s| json!(s)),
439            );
440            tls.insert(
441                "not_after".into(),
442                self.cert_not_after
443                    .as_deref()
444                    .map_or(Value::Null, |s| json!(s)),
445            );
446            tls.insert(
447                "domains".into(),
448                self.cert_domains.as_ref().map_or(Value::Null, |d| json!(d)),
449            );
450            obj.insert("tls".into(), Value::Object(tls));
451        }
452
453        // Headers
454        if let Some(headers) = &self.headers {
455            let mut hdr_map = Map::new();
456            for (key, value) in headers.iter() {
457                let v = value.to_str().unwrap_or_default().to_string();
458                hdr_map.insert(key.to_string(), json!(v));
459            }
460            obj.insert("headers".into(), Value::Object(hdr_map));
461        }
462
463        // Body
464        obj.insert(
465            "body_size".into(),
466            self.body_size.map_or(Value::Null, |s| json!(s)),
467        );
468
469        // Error
470        obj.insert(
471            "error".into(),
472            self.error.as_deref().map_or(Value::Null, |e| json!(e)),
473        );
474        obj.insert("exit_code".into(), json!(self.exit_code()));
475
476        Value::Object(obj)
477    }
478}
479
480impl fmt::Display for HttpStat {
481    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
482        if let Some(addr) = &self.addr {
483            let mut text = format!(
484                "{} {}",
485                LightGreen.paint("Connected to"),
486                LightCyan.paint(addr)
487            );
488            if self.silent {
489                if let Some(status) = &self.status {
490                    let alpn = self.alpn.as_deref().unwrap_or(ALPN_HTTP1);
491                    let status_code = status.as_u16();
492                    let status = if status_code < 400 {
493                        LightGreen.paint(status.to_string())
494                    } else {
495                        LightRed.paint(status.to_string())
496                    };
497                    text = format!(
498                        "{text} --> {} {}",
499                        LightCyan.paint(alpn.to_uppercase()),
500                        status
501                    );
502                } else {
503                    text = format!("{text} --> {}", LightRed.paint("FAIL"));
504                }
505                text = format!("{text} {}", format_duration(self.total.unwrap_or_default()));
506            }
507            writeln!(f, "{text}")?;
508        }
509        if let Some(error) = &self.error {
510            writeln!(f, "Error: {}", LightRed.paint(error))?;
511        }
512        if self.silent {
513            return Ok(());
514        }
515        if self.verbose {
516            for (key, value) in self.request_headers.iter() {
517                writeln!(
518                    f,
519                    "{}: {}",
520                    key.to_string().to_train_case(),
521                    LightCyan.paint(value.to_str().unwrap_or_default())
522                )?;
523            }
524            writeln!(f)?;
525        }
526
527        if let Some(status) = &self.status {
528            let alpn = self.alpn.as_deref().unwrap_or(ALPN_HTTP1);
529            let status_code = status.as_u16();
530            let status = if status_code < 400 {
531                LightGreen.paint(status.to_string())
532            } else {
533                LightRed.paint(status.to_string())
534            };
535            writeln!(f, "{} {}", LightCyan.paint(alpn.to_uppercase()), status)?;
536        }
537        if self.is_grpc {
538            if self.is_success() {
539                writeln!(f, "{}", LightGreen.paint("GRPC OK"))?;
540            }
541            writeln!(f)?;
542        }
543
544        if let Some(tls) = &self.tls {
545            writeln!(f)?;
546            writeln!(f, "Tls: {}", LightCyan.paint(tls))?;
547            writeln!(
548                f,
549                "Cipher: {}",
550                LightCyan.paint(self.cert_cipher.as_deref().unwrap_or_default())
551            )?;
552            writeln!(
553                f,
554                "Not Before: {}",
555                LightCyan.paint(self.cert_not_before.as_deref().unwrap_or_default())
556            )?;
557            writeln!(
558                f,
559                "Not After: {}",
560                LightCyan.paint(self.cert_not_after.as_deref().unwrap_or_default())
561            )?;
562            if self.verbose {
563                writeln!(
564                    f,
565                    "Subject: {}",
566                    LightCyan.paint(self.subject.as_deref().unwrap_or_default())
567                )?;
568                writeln!(
569                    f,
570                    "Issuer: {}",
571                    LightCyan.paint(self.issuer.as_deref().unwrap_or_default())
572                )?;
573                writeln!(
574                    f,
575                    "Certificate Domains: {}",
576                    LightCyan.paint(self.cert_domains.as_deref().unwrap_or_default().join(", "))
577                )?;
578            }
579            writeln!(f)?;
580
581            if self.verbose {
582                if let Some(certificates) = &self.certificates {
583                    writeln!(f, "Certificate Chain")?;
584                    for (index, cert) in certificates.iter().enumerate() {
585                        writeln!(
586                            f,
587                            " {index} Subject: {}",
588                            LightCyan.paint(cert.subject.as_str())
589                        )?;
590                        writeln!(f, "   Issuer: {}", LightCyan.paint(cert.issuer.as_str()))?;
591                        writeln!(
592                            f,
593                            "   Not Before: {}",
594                            LightCyan.paint(cert.not_before.as_str())
595                        )?;
596                        writeln!(
597                            f,
598                            "   Not After: {}",
599                            LightCyan.paint(cert.not_after.as_str())
600                        )?;
601                        writeln!(f)?;
602                    }
603                }
604            }
605        }
606
607        let mut is_text = false;
608        let mut is_json = false;
609        if let Some(headers) = &self.headers {
610            for (key, value) in headers.iter() {
611                let value = value.to_str().unwrap_or_default();
612                if key == http::header::CONTENT_TYPE {
613                    if value.contains("text/") || value.contains("application/json") {
614                        is_text = true;
615                    }
616                    if value.contains("application/json") {
617                        is_json = true;
618                    }
619                }
620                let key_lower = key.as_str();
621                let show = if let Some(includes) = &self.include_headers {
622                    includes.iter().any(|h| h == key_lower)
623                } else if let Some(excludes) = &self.exclude_headers {
624                    !excludes.iter().any(|h| h == key_lower)
625                } else {
626                    true
627                };
628                if show {
629                    writeln!(
630                        f,
631                        "{}: {}",
632                        key.to_string().to_train_case(),
633                        LightCyan.paint(value)
634                    )?;
635                }
636            }
637            writeln!(f)?;
638        }
639
640        if self.waterfall {
641            self.fmt_waterfall(f)?;
642        } else {
643            let width = 20;
644
645            let mut timelines = vec![];
646            if let Some(value) = self.dns_lookup {
647                timelines.push(Timeline {
648                    name: "DNS Lookup".to_string(),
649                    duration: value,
650                });
651            }
652            if let Some(value) = self.tcp_connect {
653                timelines.push(Timeline {
654                    name: "TCP Connect".to_string(),
655                    duration: value,
656                });
657            }
658            if let Some(value) = self.tls_handshake {
659                timelines.push(Timeline {
660                    name: "TLS Handshake".to_string(),
661                    duration: value,
662                });
663            }
664            if let Some(value) = self.quic_connect {
665                timelines.push(Timeline {
666                    name: "QUIC Connect".to_string(),
667                    duration: value,
668                });
669            }
670            if let Some(value) = self.server_processing {
671                timelines.push(Timeline {
672                    name: "Server Processing".to_string(),
673                    duration: value,
674                });
675            }
676            if let Some(value) = self.content_transfer {
677                timelines.push(Timeline {
678                    name: "Content Transfer".to_string(),
679                    duration: value,
680                });
681            }
682
683            if !timelines.is_empty() {
684                write!(f, " ")?;
685                for (i, timeline) in timelines.iter().enumerate() {
686                    write!(
687                        f,
688                        "{}",
689                        timeline.name.unicode_pad(width, Alignment::Center, true)
690                    )?;
691                    if i < timelines.len() - 1 {
692                        write!(f, " ")?;
693                    }
694                }
695                writeln!(f)?;
696
697                write!(f, "[")?;
698                for (i, timeline) in timelines.iter().enumerate() {
699                    write!(
700                        f,
701                        "{}",
702                        LightCyan.paint(
703                            format_duration(timeline.duration)
704                                .unicode_pad(width, Alignment::Center, true)
705                                .to_string(),
706                        )
707                    )?;
708                    if i < timelines.len() - 1 {
709                        write!(f, "|")?;
710                    }
711                }
712                writeln!(f, "]")?;
713            }
714
715            write!(f, " ")?;
716            for _ in 0..timelines.len() {
717                write!(f, "{}", " ".repeat(width))?;
718                write!(f, "|")?;
719            }
720            writeln!(f)?;
721            write!(f, "{}", " ".repeat(width * timelines.len()))?;
722            write!(
723                f,
724                "Total:{}\n\n",
725                LightCyan.paint(format_duration(self.total.unwrap_or_default()))
726            )?;
727        }
728
729        if let Some(body) = &self.body {
730            let status = self.status.unwrap_or(StatusCode::OK).as_u16();
731            let mut body = std::str::from_utf8(body.as_ref())
732                .unwrap_or_default()
733                .to_string();
734            if let Some(filter) = &self.jq_filter {
735                if let Some(filtered) = apply_jq_filter(&body, filter) {
736                    body = filtered;
737                }
738            } else if self.pretty && is_json {
739                if let Ok(json_body) = serde_json::from_str::<serde_json::Value>(&body) {
740                    if let Ok(value) = serde_json::to_string_pretty(&json_body) {
741                        body = value;
742                    }
743                }
744            }
745            if self.verbose || self.jq_filter.is_some() || (is_text && body.len() < 4096) {
746                let text = format!(
747                    "Body size: {}",
748                    ByteSize(self.body_size.unwrap_or(0) as u64)
749                );
750                writeln!(f, "{}\n", LightCyan.paint(text))?;
751                if status >= 400 {
752                    writeln!(f, "{}", LightRed.paint(body))?;
753                } else {
754                    writeln!(f, "{body}")?;
755                }
756            } else {
757                let mut save_tips = "".to_string();
758                if let Ok(mut file) = NamedTempFile::new() {
759                    if let Ok(()) = file.write_all(body.as_bytes()) {
760                        save_tips = format!("saved to: {}", file.path().display());
761                        let _ = file.keep();
762                    }
763                }
764                let text = format!(
765                    "Body discarded {}",
766                    ByteSize(self.body_size.unwrap_or(0) as u64)
767                );
768                writeln!(f, "{} {}", LightCyan.paint(text), save_tips)?;
769            }
770        }
771
772        Ok(())
773    }
774}
775
776pub struct BenchmarkSummary {
777    pub stats: Vec<HttpStat>,
778}
779
780impl BenchmarkSummary {
781    fn collect_sorted(&self, f: impl Fn(&HttpStat) -> Option<Duration>) -> Vec<Duration> {
782        let mut v: Vec<Duration> = self.stats.iter().filter_map(f).collect();
783        v.sort();
784        v
785    }
786
787    fn percentile(sorted: &[Duration], p: f64) -> Option<Duration> {
788        if sorted.is_empty() {
789            return None;
790        }
791        let idx = ((p * sorted.len() as f64).ceil() as usize).saturating_sub(1);
792        Some(sorted[idx.min(sorted.len() - 1)])
793    }
794}
795
796impl fmt::Display for BenchmarkSummary {
797    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
798        let total = self.stats.len();
799        if total == 0 {
800            return Ok(());
801        }
802
803        let phases: Vec<(&str, Vec<Duration>)> = [
804            ("DNS Lookup", self.collect_sorted(|s| s.dns_lookup)),
805            ("TCP Connect", self.collect_sorted(|s| s.tcp_connect)),
806            ("TLS Handshake", self.collect_sorted(|s| s.tls_handshake)),
807            ("QUIC Connect", self.collect_sorted(|s| s.quic_connect)),
808            (
809                "Server Process",
810                self.collect_sorted(|s| s.server_processing),
811            ),
812            ("Content Xfer", self.collect_sorted(|s| s.content_transfer)),
813            ("Total", self.collect_sorted(|s| s.total)),
814        ]
815        .into_iter()
816        .filter(|(_, v)| !v.is_empty())
817        .collect();
818
819        if phases.is_empty() {
820            return Ok(());
821        }
822
823        writeln!(f)?;
824        writeln!(
825            f,
826            "{}",
827            LightGreen.paint(format!("--- Benchmark Results ({total} requests) ---"))
828        )?;
829        writeln!(f)?;
830
831        let col_w = 18;
832        let label_w = 6;
833
834        // Header row
835        write!(f, "{:>label_w$} ", "")?;
836        for (name, _) in &phases {
837            write!(f, "{}", name.unicode_pad(col_w, Alignment::Center, true))?;
838        }
839        writeln!(f)?;
840
841        // Stats rows
842        let rows: [(&str, f64); 6] = [
843            ("min", 0.0),
844            ("max", f64::INFINITY),
845            ("avg", f64::NAN),
846            ("p50", 0.5),
847            ("p95", 0.95),
848            ("p99", 0.99),
849        ];
850
851        for (label, p) in &rows {
852            write!(f, "{} ", LightGreen.paint(format!("{label:>label_w$}")))?;
853            for (_, sorted) in &phases {
854                let val = if p.is_nan() {
855                    // avg
856                    if sorted.is_empty() {
857                        None
858                    } else {
859                        let sum: Duration = sorted.iter().sum();
860                        Some(sum / sorted.len() as u32)
861                    }
862                } else if *p == 0.0 {
863                    sorted.first().copied()
864                } else if p.is_infinite() {
865                    sorted.last().copied()
866                } else {
867                    Self::percentile(sorted, *p)
868                };
869                let text = match val {
870                    Some(d) => format_duration(d),
871                    None => "-".to_string(),
872                };
873                write!(
874                    f,
875                    "{}",
876                    LightCyan.paint(text.unicode_pad(col_w, Alignment::Center, true).to_string())
877                )?;
878            }
879            writeln!(f)?;
880        }
881
882        writeln!(f)?;
883        let success = self.stats.iter().filter(|s| s.is_success()).count();
884        let pct = (success as f64 / total as f64) * 100.0;
885        let success_text = format!("Success: {success}/{total} ({pct:.1}%)");
886        if success == total {
887            writeln!(f, "  {}", LightGreen.paint(success_text))?;
888        } else {
889            writeln!(f, "  {}", LightRed.paint(success_text))?;
890        }
891
892        Ok(())
893    }
894}