1use 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
38pub(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#[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
129fn 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 let owned;
142 let filter = if !filter.starts_with('.') {
143 owned = format!(".{filter}");
144 owned.as_str()
145 } else {
146 filter
147 };
148
149 #[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 let mut remaining = s;
166 while !remaining.is_empty() {
167 if remaining.starts_with('[') {
168 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 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 pub fn exit_code(&self) -> i32 {
252 if self.is_success() {
253 return 0;
254 }
255 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 if err.contains("timeout") || err.contains("elapsed") {
271 return 5;
272 }
273 if self.dns_lookup.is_none() {
275 return 2;
276 }
277 if self.tcp_connect.is_none() && self.quic_connect.is_none() {
279 return 3;
280 }
281 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 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 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 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 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 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 obj.insert(
465 "body_size".into(),
466 self.body_size.map_or(Value::Null, |s| json!(s)),
467 );
468
469 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 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 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 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}