runtime-checker 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/report.rs ADDED
@@ -0,0 +1,819 @@
1
+ use std::{collections::BTreeMap, path::Path, time::Duration};
2
+
3
+ use anstyle::{AnsiColor, Style};
4
+ use terminal_size::{Width, terminal_size};
5
+
6
+ use crate::{
7
+ engines::{EnginesReport, EnginesSeverity},
8
+ scanner::{DetectedFeature, ScanStats},
9
+ version::RuntimeVersion,
10
+ };
11
+
12
+ pub struct RuntimeReport {
13
+ pub runtime: String,
14
+ pub detections: Vec<DetectedFeature>,
15
+ pub minimum: RuntimeVersion,
16
+ pub engines: Option<EnginesReport>,
17
+ pub has_node_api_detections: bool,
18
+ }
19
+
20
+ pub struct Reporter {
21
+ summary: bool,
22
+ inspect: Option<String>,
23
+ parser: ParserMode,
24
+ }
25
+
26
+ impl Reporter {
27
+ pub fn new(summary: bool, inspect: Option<String>, parser: ParserMode) -> Self {
28
+ Self {
29
+ summary,
30
+ inspect,
31
+ parser,
32
+ }
33
+ }
34
+
35
+ pub fn print(
36
+ &self,
37
+ root: &Path,
38
+ reports: &[RuntimeReport],
39
+ elapsed: Duration,
40
+ stats: ScanStats,
41
+ ) {
42
+ if !self.summary {
43
+ self.print_groups(root, reports);
44
+ }
45
+
46
+ print_result_panel(root, self.parser, reports, elapsed, stats);
47
+ }
48
+
49
+ fn print_groups(&self, root: &Path, reports: &[RuntimeReport]) {
50
+ let mut printed_any = false;
51
+ for report in reports {
52
+ let printed = if let Some(feature) = &self.inspect {
53
+ self.print_inspected_groups(root, report, feature, printed_any)
54
+ } else {
55
+ self.print_grouped_runtime(root, report, printed_any)
56
+ };
57
+
58
+ printed_any |= printed;
59
+ }
60
+
61
+ if !printed_any && let Some(feature) = &self.inspect {
62
+ println!(
63
+ "{}No detections found for {}{}{}",
64
+ light_gray(),
65
+ yellow(),
66
+ feature,
67
+ reset()
68
+ );
69
+ }
70
+
71
+ if printed_any || self.inspect.is_some() {
72
+ println!();
73
+ }
74
+ }
75
+
76
+ fn print_grouped_runtime(
77
+ &self,
78
+ root: &Path,
79
+ report: &RuntimeReport,
80
+ needs_leading_blank: bool,
81
+ ) -> bool {
82
+ let mut groups: BTreeMap<u64, Vec<&DetectedFeature>> = BTreeMap::new();
83
+ for detection in &report.detections {
84
+ groups
85
+ .entry(detection.version.major)
86
+ .or_default()
87
+ .push(detection);
88
+ }
89
+
90
+ let mut printed = false;
91
+ for (_major, entries) in groups.iter_mut() {
92
+ if needs_leading_blank || printed {
93
+ println!();
94
+ }
95
+
96
+ println!(
97
+ "{}",
98
+ bold_fg_rgb(
99
+ format!(
100
+ "{} {}",
101
+ runtime_label(&report.runtime),
102
+ lowest_group_version(entries)
103
+ ),
104
+ WHITE
105
+ )
106
+ );
107
+
108
+ for entry in aggregate_entries(entries) {
109
+ print_aggregate(root, &entry);
110
+ }
111
+
112
+ printed = true;
113
+ }
114
+
115
+ printed
116
+ }
117
+
118
+ fn print_inspected_groups(
119
+ &self,
120
+ root: &Path,
121
+ report: &RuntimeReport,
122
+ feature: &str,
123
+ needs_leading_blank: bool,
124
+ ) -> bool {
125
+ let mut groups: BTreeMap<u64, Vec<&DetectedFeature>> = BTreeMap::new();
126
+ for detection in report
127
+ .detections
128
+ .iter()
129
+ .filter(|detection| detection.feature == feature)
130
+ {
131
+ groups
132
+ .entry(detection.version.major)
133
+ .or_default()
134
+ .push(detection);
135
+ }
136
+
137
+ if groups.is_empty() {
138
+ return false;
139
+ }
140
+
141
+ let mut printed = false;
142
+ for (_major, entries) in groups.iter_mut() {
143
+ if needs_leading_blank || printed {
144
+ println!();
145
+ }
146
+ entries.sort_by(|left, right| {
147
+ left.version
148
+ .cmp(&right.version)
149
+ .then_with(|| left.path.cmp(&right.path))
150
+ .then_with(|| left.line.cmp(&right.line))
151
+ .then_with(|| left.column.cmp(&right.column))
152
+ });
153
+
154
+ println!(
155
+ "{}",
156
+ bold_fg_rgb(
157
+ format!(
158
+ "{} {}",
159
+ runtime_label(&report.runtime),
160
+ lowest_group_version(entries)
161
+ ),
162
+ WHITE
163
+ )
164
+ );
165
+ for entry in entries {
166
+ print_detection(root, entry);
167
+ }
168
+ printed = true;
169
+ }
170
+
171
+ printed
172
+ }
173
+ }
174
+
175
+ #[derive(Debug, Clone, Copy)]
176
+ pub enum ParserMode {
177
+ Oxc,
178
+ Text,
179
+ }
180
+
181
+ impl ParserMode {
182
+ fn label(self) -> &'static str {
183
+ match self {
184
+ Self::Oxc => "oxc (ast parsing)",
185
+ Self::Text => "fff (text scan)",
186
+ }
187
+ }
188
+ }
189
+
190
+ fn lowest_group_version(entries: &[&DetectedFeature]) -> RuntimeVersion {
191
+ entries
192
+ .iter()
193
+ .map(|entry| entry.version)
194
+ .min()
195
+ .unwrap_or_default()
196
+ }
197
+
198
+ #[derive(Debug)]
199
+ struct AggregatedEntry<'a> {
200
+ first: &'a DetectedFeature,
201
+ count: usize,
202
+ }
203
+
204
+ fn aggregate_entries<'a>(entries: &[&'a DetectedFeature]) -> Vec<AggregatedEntry<'a>> {
205
+ let mut by_feature: BTreeMap<(String, RuntimeVersion), AggregatedEntry<'a>> = BTreeMap::new();
206
+ for entry in entries {
207
+ let key = (entry.feature.clone(), entry.version);
208
+ by_feature
209
+ .entry(key)
210
+ .and_modify(|aggregate| aggregate.count += entry.count)
211
+ .or_insert(AggregatedEntry {
212
+ first: entry,
213
+ count: entry.count,
214
+ });
215
+ }
216
+
217
+ let mut entries: Vec<_> = by_feature.into_values().collect();
218
+ entries.sort_by(|left, right| {
219
+ left.first
220
+ .version
221
+ .cmp(&right.first.version)
222
+ .then_with(|| left.first.feature.cmp(&right.first.feature))
223
+ .then_with(|| left.first.path.cmp(&right.first.path))
224
+ });
225
+ entries
226
+ }
227
+
228
+ fn print_aggregate(root: &Path, entry: &AggregatedEntry<'_>) {
229
+ let path = entry
230
+ .first
231
+ .path
232
+ .strip_prefix(root)
233
+ .unwrap_or(&entry.first.path);
234
+ let feature = feature_label(&entry.first.feature, entry.count);
235
+ let location = format!(
236
+ "({}@{}:{})",
237
+ path.display(),
238
+ entry.first.line,
239
+ entry.first.column
240
+ );
241
+
242
+ println!(
243
+ "{} {} {} {}{}",
244
+ gradient(&feature, EMERALD_500, SKY_500),
245
+ fg_rgb("•", NEUTRAL_700),
246
+ fg_rgb(format!("v{}", entry.first.version), NEUTRAL_400),
247
+ fg_rgb(location, NEUTRAL_500),
248
+ reset()
249
+ );
250
+ }
251
+
252
+ fn print_detection(root: &Path, entry: &DetectedFeature) {
253
+ let path = entry.path.strip_prefix(root).unwrap_or(&entry.path);
254
+ let feature = feature_label(&entry.feature, entry.count);
255
+ let location = format!("({}@{}:{})", path.display(), entry.line, entry.column);
256
+
257
+ println!(
258
+ "{} {} {} {}{}",
259
+ gradient(&feature, EMERALD_500, SKY_500),
260
+ fg_rgb("•", NEUTRAL_700),
261
+ fg_rgb(format!("v{}", entry.version), NEUTRAL_400),
262
+ fg_rgb(location, NEUTRAL_500),
263
+ reset()
264
+ );
265
+ }
266
+
267
+ fn feature_label(feature: &str, count: usize) -> String {
268
+ if count > 1 {
269
+ format!("{feature} (x{count})")
270
+ } else {
271
+ feature.to_string()
272
+ }
273
+ }
274
+
275
+ fn engines_notice(root: &Path, engines: &EnginesReport) -> PanelNotice {
276
+ let package = engines
277
+ .package_json
278
+ .strip_prefix(root)
279
+ .unwrap_or(&engines.package_json);
280
+ if engines.fixed {
281
+ PanelNotice {
282
+ kind: NoticeKind::Update,
283
+ message: format!(
284
+ "Updated {} engines.node to >={}.",
285
+ package.display(),
286
+ engines.required
287
+ ),
288
+ }
289
+ } else if let Some(declared) = &engines.declared {
290
+ let kind = match engines.severity {
291
+ EnginesSeverity::Info => NoticeKind::Info,
292
+ EnginesSeverity::Warning => NoticeKind::Warning,
293
+ };
294
+ PanelNotice {
295
+ kind,
296
+ message: format!(
297
+ "Detected Node.js {} but {} declares engines.node {}. Apply a fix with --fix.",
298
+ engines.required,
299
+ package.display(),
300
+ declared
301
+ ),
302
+ }
303
+ } else {
304
+ PanelNotice {
305
+ kind: NoticeKind::Warning,
306
+ message: format!(
307
+ "Detected Node.js {} but {} has no engines.node. Apply a fix with --fix.",
308
+ engines.required,
309
+ package.display()
310
+ ),
311
+ }
312
+ }
313
+ }
314
+
315
+ fn print_result_panel(
316
+ root: &Path,
317
+ parser: ParserMode,
318
+ reports: &[RuntimeReport],
319
+ elapsed: Duration,
320
+ stats: ScanStats,
321
+ ) {
322
+ let header = format!(
323
+ "{} {}",
324
+ gradient("runtime-checker", EMERALD_500, SKY_500),
325
+ badge(env!("CARGO_PKG_VERSION"))
326
+ );
327
+
328
+ println!("{}", create_streak(&header));
329
+ println!();
330
+ println!(
331
+ "{}{}{}{}{}{}{}",
332
+ fg_rgb("Finished in ", WHITE),
333
+ gradient(&format_duration(elapsed), EMERALD_500, SKY_500),
334
+ fg_rgb(" using ", WHITE),
335
+ gradient(parser.label(), EMERALD_500, SKY_500),
336
+ fg_rgb(" after scanning ", WHITE),
337
+ gradient(&format_line_count(stats.line_count), EMERALD_500, SKY_500),
338
+ fg_rgb(" lines of code.", WHITE)
339
+ );
340
+ println!();
341
+
342
+ let footnotes = compatibility_footnotes(reports);
343
+ let printed_runtimes = print_summary_group(
344
+ "Runtimes",
345
+ reports
346
+ .iter()
347
+ .filter(|report| runtime_group(&report.runtime) == RuntimeGroup::Runtime),
348
+ &footnotes,
349
+ );
350
+ let printed_browsers = print_summary_group(
351
+ "Browsers",
352
+ reports
353
+ .iter()
354
+ .filter(|report| runtime_group(&report.runtime) == RuntimeGroup::Browser),
355
+ &footnotes,
356
+ );
357
+
358
+ if printed_runtimes || printed_browsers {
359
+ println!();
360
+ }
361
+
362
+ if !footnotes.is_empty() {
363
+ for footnote in &footnotes {
364
+ println!(
365
+ "{}",
366
+ fg_rgb(
367
+ format!("{} {}", footnote_marker(footnote.number), footnote.message),
368
+ WARNING_RED
369
+ )
370
+ );
371
+ }
372
+ }
373
+
374
+ let notices = panel_notices(root, reports);
375
+ if !notices.is_empty() {
376
+ println!();
377
+ print_notice_group(&notices);
378
+ }
379
+
380
+ println!("{}", create_rule());
381
+ }
382
+
383
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
384
+ enum NoticeKind {
385
+ Info,
386
+ Warning,
387
+ Update,
388
+ }
389
+
390
+ #[derive(Debug)]
391
+ struct PanelNotice {
392
+ kind: NoticeKind,
393
+ message: String,
394
+ }
395
+
396
+ fn panel_notices(root: &Path, reports: &[RuntimeReport]) -> Vec<PanelNotice> {
397
+ reports
398
+ .iter()
399
+ .filter_map(|report| report.engines.as_ref())
400
+ .map(|engines| engines_notice(root, engines))
401
+ .collect()
402
+ }
403
+
404
+ fn print_notice_group(notices: &[PanelNotice]) {
405
+ for notice in notices {
406
+ let color = match notice.kind {
407
+ NoticeKind::Info => NEUTRAL_400,
408
+ NoticeKind::Warning => WARNING_RED,
409
+ NoticeKind::Update => NODE_GREEN,
410
+ };
411
+ let icon = match notice.kind {
412
+ NoticeKind::Info => "ⓘ",
413
+ NoticeKind::Warning => "⚠",
414
+ NoticeKind::Update => "✓",
415
+ };
416
+ println!(
417
+ "{} {}{}",
418
+ fg_rgb(icon, color),
419
+ fg_rgb(&notice.message, color),
420
+ reset()
421
+ );
422
+ }
423
+ println!();
424
+ }
425
+
426
+ fn print_summary_group<'a>(
427
+ title: &str,
428
+ reports: impl Iterator<Item = &'a RuntimeReport>,
429
+ footnotes: &[CompatibilityFootnote],
430
+ ) -> bool {
431
+ let reports: Vec<_> = reports.collect();
432
+ if reports.is_empty() {
433
+ return false;
434
+ }
435
+
436
+ println!("{}", bold_fg_rgb(title, WHITE));
437
+ for report in reports {
438
+ let marker = footnotes
439
+ .iter()
440
+ .find(|footnote| footnote.runtime == report.runtime)
441
+ .map(|footnote| fg_rgb(footnote_marker(footnote.number), WARNING_RED))
442
+ .unwrap_or_default();
443
+ println!(
444
+ "{} {}{} {}{}",
445
+ fg_rgb("-", NEUTRAL_700),
446
+ fg_rgb(runtime_label(&report.runtime), NEUTRAL_400),
447
+ marker,
448
+ fg_rgb(
449
+ report.minimum.to_string(),
450
+ runtime_version_color(&report.runtime),
451
+ ),
452
+ reset()
453
+ );
454
+ }
455
+ println!();
456
+ true
457
+ }
458
+
459
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
460
+ enum RuntimeGroup {
461
+ Runtime,
462
+ Browser,
463
+ }
464
+
465
+ fn runtime_group(runtime: &str) -> RuntimeGroup {
466
+ match runtime {
467
+ "safari" | "chrome" | "firefox" => RuntimeGroup::Browser,
468
+ _ => RuntimeGroup::Runtime,
469
+ }
470
+ }
471
+
472
+ fn footnote_marker(number: usize) -> String {
473
+ let mut marker = String::new();
474
+ for ch in number.to_string().chars() {
475
+ let superscript = match ch {
476
+ '0' => '⁰',
477
+ '1' => '¹',
478
+ '2' => '²',
479
+ '3' => '³',
480
+ '4' => '⁴',
481
+ '5' => '⁵',
482
+ '6' => '⁶',
483
+ '7' => '⁷',
484
+ '8' => '⁸',
485
+ '9' => '⁹',
486
+ _ => return format!("[{number}]"),
487
+ };
488
+ marker.push(superscript);
489
+ }
490
+ marker
491
+ }
492
+
493
+ struct CompatibilityFootnote {
494
+ runtime: String,
495
+ number: usize,
496
+ message: String,
497
+ }
498
+
499
+ fn compatibility_footnotes(reports: &[RuntimeReport]) -> Vec<CompatibilityFootnote> {
500
+ if !has_node_api_detections(reports) {
501
+ return Vec::new();
502
+ }
503
+
504
+ reports
505
+ .iter()
506
+ .filter(|report| runtime_group(&report.runtime) == RuntimeGroup::Browser)
507
+ .enumerate()
508
+ .map(|(index, report)| CompatibilityFootnote {
509
+ runtime: report.runtime.clone(),
510
+ number: index + 1,
511
+ message: format!(
512
+ "{} does not support Node APIs.",
513
+ runtime_label(&report.runtime)
514
+ ),
515
+ })
516
+ .collect()
517
+ }
518
+
519
+ fn has_node_api_detections(reports: &[RuntimeReport]) -> bool {
520
+ reports.iter().any(|report| report.has_node_api_detections)
521
+ }
522
+
523
+ pub(crate) fn is_node_api_feature(feature: &str) -> bool {
524
+ let root = feature.split('.').next().unwrap_or(feature);
525
+ matches!(
526
+ root,
527
+ "__dirname"
528
+ | "__filename"
529
+ | "assert"
530
+ | "async_hooks"
531
+ | "Buffer"
532
+ | "buffer"
533
+ | "child_process"
534
+ | "cluster"
535
+ | "diagnostics_channel"
536
+ | "dns"
537
+ | "domain"
538
+ | "events"
539
+ | "fs"
540
+ | "fsPromises"
541
+ | "http"
542
+ | "http2"
543
+ | "https"
544
+ | "module"
545
+ | "net"
546
+ | "os"
547
+ | "path"
548
+ | "perf_hooks"
549
+ | "process"
550
+ | "punycode"
551
+ | "querystring"
552
+ | "readline"
553
+ | "repl"
554
+ | "require"
555
+ | "stream"
556
+ | "string_decoder"
557
+ | "timers"
558
+ | "timersPromises"
559
+ | "tls"
560
+ | "tty"
561
+ | "url"
562
+ | "util"
563
+ | "v8"
564
+ | "vm"
565
+ | "wasi"
566
+ | "worker_threads"
567
+ | "zlib"
568
+ )
569
+ }
570
+
571
+ fn terminal_width() -> usize {
572
+ terminal_size()
573
+ .map(|(Width(width), _)| width as usize)
574
+ .unwrap_or(80)
575
+ .max(40)
576
+ }
577
+
578
+ type Rgb = (u8, u8, u8);
579
+
580
+ const EMERALD_500: Rgb = (16, 185, 129);
581
+ const SKY_500: Rgb = (14, 165, 233);
582
+ const NEUTRAL_400: Rgb = (163, 163, 163);
583
+ const NEUTRAL_500: Rgb = (115, 115, 115);
584
+ const NEUTRAL_700: Rgb = (64, 64, 64);
585
+ const NEUTRAL_600: Rgb = (82, 82, 82);
586
+ const WARNING_RED: Rgb = (248, 113, 113);
587
+ const NODE_GREEN: Rgb = (104, 160, 99);
588
+ const WHITE: Rgb = (255, 255, 255);
589
+
590
+ fn runtime_label(runtime: &str) -> &'static str {
591
+ match runtime {
592
+ "node" => "Node.js",
593
+ "deno" => "Deno",
594
+ "bun" => "Bun",
595
+ "safari" => "Safari",
596
+ "chrome" => "Chromium",
597
+ "firefox" => "Firefox",
598
+ _ => "Runtime",
599
+ }
600
+ }
601
+
602
+ fn runtime_version_color(_runtime: &str) -> Rgb {
603
+ WHITE
604
+ }
605
+
606
+ fn create_streak(content: &str) -> String {
607
+ let columns = terminal_width().saturating_sub(1).max(1);
608
+ let content_width = visible_width(content);
609
+ let right_width = columns.saturating_sub(content_width + 3).max(1);
610
+
611
+ format!(
612
+ "{} {} {}",
613
+ fg_rgb("─", NEUTRAL_700),
614
+ content,
615
+ fg_rgb("─".repeat(right_width), NEUTRAL_700)
616
+ )
617
+ }
618
+
619
+ fn create_rule() -> String {
620
+ let columns = terminal_width().saturating_sub(1).max(1);
621
+ fg_rgb("─".repeat(columns), NEUTRAL_700)
622
+ }
623
+
624
+ fn gradient(text: &str, from: Rgb, to: Rgb) -> String {
625
+ let chars: Vec<char> = text.chars().collect();
626
+ if chars.is_empty() {
627
+ return String::new();
628
+ }
629
+
630
+ let denominator = chars.len().saturating_sub(1).max(1) as f32;
631
+ chars
632
+ .into_iter()
633
+ .enumerate()
634
+ .map(|(index, ch)| {
635
+ let amount = index as f32 / denominator;
636
+ let (red, green, blue) = interpolate_rgb(from, to, amount);
637
+ format!("\x1b[38;2;{red};{green};{blue}m{ch}\x1b[0m")
638
+ })
639
+ .collect()
640
+ }
641
+
642
+ fn badge(label: &str) -> String {
643
+ format!(
644
+ "\x1b[48;2;{};{};{}m\x1b[38;2;{};{};{}m {} \x1b[0m",
645
+ NEUTRAL_600.0, NEUTRAL_600.1, NEUTRAL_600.2, WHITE.0, WHITE.1, WHITE.2, label
646
+ )
647
+ }
648
+
649
+ fn fg_rgb(text: impl AsRef<str>, color: Rgb) -> String {
650
+ format!(
651
+ "\x1b[38;2;{};{};{}m{}\x1b[0m",
652
+ color.0,
653
+ color.1,
654
+ color.2,
655
+ text.as_ref()
656
+ )
657
+ }
658
+
659
+ fn bold_fg_rgb(text: impl AsRef<str>, color: Rgb) -> String {
660
+ format!(
661
+ "\x1b[1;38;2;{};{};{}m{}\x1b[0m",
662
+ color.0,
663
+ color.1,
664
+ color.2,
665
+ text.as_ref()
666
+ )
667
+ }
668
+
669
+ fn interpolate_rgb(from: Rgb, to: Rgb, amount: f32) -> Rgb {
670
+ let red = from.0 as f32 + (to.0 as f32 - from.0 as f32) * amount;
671
+ let green = from.1 as f32 + (to.1 as f32 - from.1 as f32) * amount;
672
+ let blue = from.2 as f32 + (to.2 as f32 - from.2 as f32) * amount;
673
+ (red.round() as u8, green.round() as u8, blue.round() as u8)
674
+ }
675
+
676
+ fn visible_width(value: &str) -> usize {
677
+ let mut width = 0;
678
+ let mut in_escape = false;
679
+
680
+ for ch in value.chars() {
681
+ if in_escape {
682
+ if ch.is_ascii_alphabetic() {
683
+ in_escape = false;
684
+ }
685
+ continue;
686
+ }
687
+
688
+ if ch == '\x1b' {
689
+ in_escape = true;
690
+ continue;
691
+ }
692
+
693
+ width += 1;
694
+ }
695
+
696
+ width
697
+ }
698
+
699
+ fn format_line_count(line_count: usize) -> String {
700
+ if line_count < 1_000 {
701
+ return line_count.to_string();
702
+ }
703
+
704
+ let units = [
705
+ (1_000_000_000_000usize, "t"),
706
+ (1_000_000_000usize, "b"),
707
+ (1_000_000usize, "m"),
708
+ (1_000usize, "k"),
709
+ ];
710
+
711
+ for (index, (scale, suffix)) in units.iter().enumerate() {
712
+ if line_count < *scale {
713
+ continue;
714
+ }
715
+
716
+ let value = format_count_with_unit(line_count, *scale, suffix);
717
+ if value.starts_with("1000") && index > 0 {
718
+ let (next_scale, next_suffix) = units[index - 1];
719
+ return format_count_with_unit(line_count, next_scale, next_suffix);
720
+ }
721
+ return value;
722
+ }
723
+
724
+ line_count.to_string()
725
+ }
726
+
727
+ fn format_count_with_unit(line_count: usize, scale: usize, suffix: &str) -> String {
728
+ let value = line_count as f64 / scale as f64;
729
+ let rounded = if value < 100.0 {
730
+ (value * 10.0).round() / 10.0
731
+ } else {
732
+ value.round()
733
+ };
734
+
735
+ let text = if rounded.fract() == 0.0 {
736
+ format!("{rounded:.0}")
737
+ } else {
738
+ format!("{rounded:.1}")
739
+ };
740
+
741
+ format!("{text}{suffix}")
742
+ }
743
+
744
+ fn format_duration(duration: Duration) -> String {
745
+ let millis = duration.as_millis();
746
+ if millis < 1_000 {
747
+ return format!("{millis}ms");
748
+ }
749
+
750
+ let seconds = duration.as_secs_f64();
751
+ if seconds < 60.0 {
752
+ return format_duration_unit(seconds, "s");
753
+ }
754
+
755
+ let minutes = seconds / 60.0;
756
+ if minutes < 60.0 {
757
+ return format_duration_unit(minutes, "m");
758
+ }
759
+
760
+ format_duration_unit(minutes / 60.0, "h")
761
+ }
762
+
763
+ fn format_duration_unit(value: f64, suffix: &str) -> String {
764
+ let rounded = if value < 10.0 {
765
+ (value * 10.0).round() / 10.0
766
+ } else {
767
+ value.round()
768
+ };
769
+
770
+ let text = if rounded.fract() == 0.0 {
771
+ format!("{rounded:.0}")
772
+ } else {
773
+ format!("{rounded:.1}")
774
+ };
775
+
776
+ format!("{text}{suffix}")
777
+ }
778
+
779
+ fn yellow() -> Style {
780
+ Style::new().fg_color(Some(AnsiColor::Yellow.into()))
781
+ }
782
+
783
+ fn light_gray() -> Style {
784
+ Style::new().fg_color(Some(AnsiColor::White.into()))
785
+ }
786
+
787
+ fn reset() -> impl std::fmt::Display + Copy {
788
+ Style::new().render_reset()
789
+ }
790
+
791
+ #[cfg(test)]
792
+ mod tests {
793
+ use std::time::Duration;
794
+
795
+ use super::{format_duration, format_line_count};
796
+
797
+ #[test]
798
+ fn formats_line_counts_without_ceiling_buckets() {
799
+ assert_eq!(format_line_count(0), "0");
800
+ assert_eq!(format_line_count(2), "2");
801
+ assert_eq!(format_line_count(999), "999");
802
+ assert_eq!(format_line_count(1_000), "1k");
803
+ assert_eq!(format_line_count(1_234), "1.2k");
804
+ assert_eq!(format_line_count(12_345), "12.3k");
805
+ assert_eq!(format_line_count(82_314), "82.3k");
806
+ assert_eq!(format_line_count(100_500), "101k");
807
+ assert_eq!(format_line_count(999_500), "1m");
808
+ assert_eq!(format_line_count(1_250_000), "1.3m");
809
+ }
810
+
811
+ #[test]
812
+ fn formats_elapsed_duration_compactly() {
813
+ assert_eq!(format_duration(Duration::from_millis(39)), "39ms");
814
+ assert_eq!(format_duration(Duration::from_millis(999)), "999ms");
815
+ assert_eq!(format_duration(Duration::from_millis(2_323)), "2.3s");
816
+ assert_eq!(format_duration(Duration::from_secs(12)), "12s");
817
+ assert_eq!(format_duration(Duration::from_secs(125)), "2.1m");
818
+ }
819
+ }