Skip to main content

rch_common/ui/progress/
celebrate.rs

1//! CompletionCelebration - Success feedback for completed builds.
2//!
3//! Renders a compact success panel with build summary, cache impact, and
4//! optional "personal best" or milestone callouts. Uses plain ASCII by
5//! default and rich_rust panels when available.
6
7#[cfg(all(feature = "rich-ui", unix))]
8use crate::ui::RchTheme;
9use crate::ui::{Icons, OutputContext};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::{Path, PathBuf};
14use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
15
16#[cfg(all(feature = "rich-ui", unix))]
17use rich_rust::r#box::HEAVY;
18#[cfg(all(feature = "rich-ui", unix))]
19use rich_rust::prelude::*;
20
21const HISTORY_LIMIT: usize = 200;
22const MAX_RENDER_WIDTH: usize = 80;
23
24/// Summary of artifacts returned from a successful build.
25#[derive(Debug, Clone)]
26pub struct ArtifactSummary {
27    pub files: u64,
28    pub bytes: u64,
29}
30
31/// Summary information for a completed build.
32#[derive(Debug, Clone)]
33pub struct CelebrationSummary {
34    pub project_id: String,
35    pub worker: Option<String>,
36    pub duration_ms: u64,
37    pub crates_compiled: Option<u32>,
38    pub artifacts: Option<ArtifactSummary>,
39    pub cache_hit: Option<bool>,
40    pub target: Option<String>,
41    pub quiet: bool,
42    pub timestamp: DateTime<Utc>,
43}
44
45impl CelebrationSummary {
46    #[must_use]
47    pub fn new(project_id: impl Into<String>, duration_ms: u64) -> Self {
48        Self {
49            project_id: project_id.into(),
50            worker: None,
51            duration_ms,
52            crates_compiled: None,
53            artifacts: None,
54            cache_hit: None,
55            target: None,
56            quiet: false,
57            timestamp: Utc::now(),
58        }
59    }
60
61    #[must_use]
62    pub fn worker(mut self, worker: impl Into<String>) -> Self {
63        self.worker = Some(worker.into());
64        self
65    }
66
67    #[must_use]
68    pub fn crates_compiled(mut self, crates: Option<u32>) -> Self {
69        self.crates_compiled = crates;
70        self
71    }
72
73    #[must_use]
74    pub fn artifacts(mut self, artifacts: Option<ArtifactSummary>) -> Self {
75        self.artifacts = artifacts;
76        self
77    }
78
79    #[must_use]
80    pub fn cache_hit(mut self, cache_hit: Option<bool>) -> Self {
81        self.cache_hit = cache_hit;
82        self
83    }
84
85    #[must_use]
86    pub fn target(mut self, target: Option<String>) -> Self {
87        self.target = target;
88        self
89    }
90
91    #[must_use]
92    pub fn quiet(mut self, quiet: bool) -> Self {
93        self.quiet = quiet;
94        self
95    }
96
97    #[must_use]
98    pub fn timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
99        self.timestamp = timestamp;
100        self
101    }
102}
103
104/// Render and record a completion celebration.
105#[derive(Debug, Clone)]
106pub struct CompletionCelebration {
107    summary: CelebrationSummary,
108}
109
110impl CompletionCelebration {
111    #[must_use]
112    pub fn new(summary: CelebrationSummary) -> Self {
113        Self { summary }
114    }
115
116    /// Record build history and render success feedback (if enabled).
117    pub fn record_and_render(&self, ctx: OutputContext) {
118        let history_path = match history_path() {
119            Some(path) => path,
120            None => {
121                if !self.summary.quiet && !ctx.is_machine() {
122                    self.render(ctx, BuildStats::default());
123                }
124                return;
125            }
126        };
127
128        let mut history = BuildHistory::load(&history_path);
129        let mut stats = history.stats_for_project(&self.summary.project_id);
130        stats = compute_stats(&self.summary, stats);
131
132        // Record current build after computing comparisons.
133        let entry = BuildHistoryEntry::from_summary(&self.summary);
134        history.record(entry);
135        let _ = history.save(&history_path);
136
137        if self.summary.quiet || ctx.is_machine() {
138            return;
139        }
140
141        self.render(ctx, stats);
142    }
143
144    fn render(&self, ctx: OutputContext, stats: BuildStats) {
145        #[cfg(all(feature = "rich-ui", unix))]
146        if ctx.supports_rich() {
147            self.render_rich(ctx, stats);
148            return;
149        }
150
151        self.render_plain(ctx, stats);
152    }
153
154    #[cfg(all(feature = "rich-ui", unix))]
155    fn render_rich(&self, ctx: OutputContext, stats: BuildStats) {
156        let title = self.title_line(ctx, &stats);
157        let content = self.render_lines(ctx, &stats).join("\n");
158
159        let border_color = Color::parse(RchTheme::SUCCESS).unwrap_or_else(|_| Color::default());
160        let border_style = Style::new().bold().color(border_color);
161
162        let panel = Panel::from_text(&content)
163            .title(title.as_str())
164            .border_style(border_style)
165            .box_style(&HEAVY);
166
167        let console = Console::builder().force_terminal(true).build();
168        console.print_renderable(&panel);
169    }
170
171    fn render_plain(&self, ctx: OutputContext, stats: BuildStats) {
172        let title = self.title_line(ctx, &stats);
173        let lines = self.render_lines(ctx, &stats);
174        let rendered = render_box(ctx, &title, &lines);
175        eprintln!("{rendered}");
176    }
177
178    fn title_line(&self, ctx: OutputContext, stats: &BuildStats) -> String {
179        let icon = if stats.is_record || stats.milestone.is_some() {
180            star_icon(ctx)
181        } else {
182            Icons::check(ctx)
183        };
184        format!("{icon} Build Successful")
185    }
186
187    fn render_lines(&self, ctx: OutputContext, stats: &BuildStats) -> Vec<String> {
188        let mut lines = Vec::new();
189        let duration_str = format_duration_ms(self.summary.duration_ms);
190
191        if stats.is_record
192            && let Some(best_ms) = stats.best_ms
193            && best_ms > 0
194        {
195            lines.push(format!(
196                "New personal best! {duration_str} (previous: {})",
197                format_duration_ms(best_ms)
198            ));
199        }
200
201        if let Some(milestone) = stats.milestone {
202            lines.push(format!(
203                "This is your {} successful RCH build!",
204                format_ordinal(milestone)
205            ));
206        }
207
208        if let Some(target) = &self.summary.target {
209            lines.push(format!("Target: {}", target));
210        }
211
212        let mut duration_line = format!("Duration: {}", duration_str);
213        if let Some(comparison) = &stats.comparison {
214            duration_line.push_str(&format!(" {}", comparison.format(ctx)));
215        }
216        lines.push(duration_line);
217
218        let crates_line = self
219            .summary
220            .crates_compiled
221            .map(|count| format!("Crates: {}", count));
222
223        let artifacts_line = self.summary.artifacts.as_ref().map(|artifacts| {
224            format!(
225                "Artifacts: {} files ({})",
226                artifacts.files,
227                format_bytes(artifacts.bytes)
228            )
229        });
230
231        match (crates_line, artifacts_line) {
232            (Some(crates), Some(artifacts)) => {
233                lines.push(format!("{} | {}", crates, artifacts));
234            }
235            (Some(line), None) | (None, Some(line)) => {
236                lines.push(line);
237            }
238            (None, None) => {}
239        }
240
241        if let Some(worker) = &self.summary.worker {
242            lines.push(format!("Worker: {}", worker));
243        }
244
245        if let Some(cache_line) = cache_line(&self.summary, stats) {
246            lines.push(cache_line);
247        }
248
249        lines.push(format!(
250            "Time: {}",
251            self.summary.timestamp.format("%Y-%m-%d %H:%M:%S")
252        ));
253
254        lines
255    }
256}
257
258// ========================================================================
259// History
260// ========================================================================
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
263struct BuildHistoryEntry {
264    project_id: String,
265    duration_ms: u64,
266    timestamp: DateTime<Utc>,
267    cache_hit: Option<bool>,
268}
269
270impl BuildHistoryEntry {
271    fn from_summary(summary: &CelebrationSummary) -> Self {
272        Self {
273            project_id: summary.project_id.clone(),
274            duration_ms: summary.duration_ms,
275            timestamp: summary.timestamp,
276            cache_hit: summary.cache_hit,
277        }
278    }
279}
280
281#[derive(Debug, Default)]
282struct BuildHistory {
283    entries: Vec<BuildHistoryEntry>,
284}
285
286impl BuildHistory {
287    fn load(path: &Path) -> Self {
288        let content = fs::read_to_string(path).ok();
289        let entries = content
290            .and_then(|text| serde_json::from_str::<Vec<BuildHistoryEntry>>(&text).ok())
291            .unwrap_or_default();
292        Self { entries }
293    }
294
295    fn save(&self, path: &Path) -> std::io::Result<()> {
296        if let Some(parent) = path.parent() {
297            fs::create_dir_all(parent)?;
298        }
299        let json = serde_json::to_string_pretty(&self.entries).unwrap_or_else(|_| "[]".into());
300        atomic_write(path, json.as_bytes())
301    }
302
303    fn record(&mut self, entry: BuildHistoryEntry) {
304        self.entries.push(entry);
305        if self.entries.len() > HISTORY_LIMIT {
306            let overflow = self.entries.len() - HISTORY_LIMIT;
307            self.entries.drain(0..overflow);
308        }
309    }
310
311    fn stats_for_project(&self, project_id: &str) -> BuildStats {
312        let mut stats = BuildStats::default();
313        let mut durations = Vec::new();
314
315        for entry in self
316            .entries
317            .iter()
318            .filter(|entry| entry.project_id == project_id)
319        {
320            durations.push(entry.duration_ms);
321        }
322
323        stats.count = durations.len() as u64;
324        stats.previous_ms = durations.last().copied();
325        stats.best_ms = durations.iter().min().copied();
326        stats.average_ms = if durations.is_empty() {
327            None
328        } else {
329            Some(durations.iter().sum::<u64>() / durations.len() as u64)
330        };
331
332        stats
333    }
334}
335
336fn history_path() -> Option<PathBuf> {
337    dirs::cache_dir().map(|dir| dir.join("rch").join("history.json"))
338}
339
340fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> {
341    let parent = path
342        .parent()
343        .map(PathBuf::from)
344        .unwrap_or_else(|| PathBuf::from("."));
345    let temp = parent.join(format!(".history.{}.tmp", std::process::id()));
346    fs::write(&temp, content)?;
347    fs::rename(temp, path)?;
348    Ok(())
349}
350
351// ========================================================================
352// Rendering helpers
353// ========================================================================
354
355#[derive(Debug, Default, Clone)]
356struct BuildStats {
357    count: u64,
358    previous_ms: Option<u64>,
359    best_ms: Option<u64>,
360    average_ms: Option<u64>,
361    comparison: Option<Comparison>,
362    milestone: Option<u64>,
363    is_record: bool,
364}
365
366#[derive(Debug, Clone)]
367struct Comparison {
368    percent: f64,
369    faster: bool,
370    baseline_ms: u64,
371}
372
373impl Comparison {
374    fn format(&self, ctx: OutputContext) -> String {
375        let arrow = if self.faster {
376            Icons::arrow_down(ctx)
377        } else {
378            Icons::arrow_up(ctx)
379        };
380        let speed = if self.faster { "faster" } else { "slower" };
381        format!(
382            "({} {:.0}% {} vs previous {})",
383            arrow,
384            self.percent,
385            speed,
386            format_duration_ms(self.baseline_ms)
387        )
388    }
389}
390
391fn cache_line(summary: &CelebrationSummary, stats: &BuildStats) -> Option<String> {
392    match summary.cache_hit {
393        Some(true) => {
394            let baseline = stats.average_ms.or(stats.previous_ms);
395            let saved = baseline
396                .and_then(|base| base.checked_sub(summary.duration_ms))
397                .filter(|saved| *saved >= 1_000);
398            if let Some(saved) = saved {
399                Some(format!("Cache: HIT (saved ~{})", format_duration_ms(saved)))
400            } else {
401                Some("Cache: HIT".to_string())
402            }
403        }
404        Some(false) => Some("Cache: MISS (warming cache)".to_string()),
405        None => None,
406    }
407}
408
409fn render_box(ctx: OutputContext, title: &str, raw_lines: &[String]) -> String {
410    let (tl, tr, bl, br, h, v) = if ctx.supports_unicode() {
411        ("╭", "╮", "╰", "╯", "─", "│")
412    } else {
413        ("+", "+", "+", "+", "-", "|")
414    };
415
416    let title = truncate_line(title, MAX_RENDER_WIDTH);
417    let mut lines: Vec<String> = raw_lines
418        .iter()
419        .map(|line| truncate_line(line, MAX_RENDER_WIDTH))
420        .collect();
421
422    let content_width = lines
423        .iter()
424        .map(|line| UnicodeWidthStr::width(line.as_str()))
425        .max()
426        .unwrap_or(0)
427        .max(UnicodeWidthStr::width(title.as_str()));
428
429    let inner_width = content_width.max(1);
430    let mut output = String::new();
431
432    let title_padding = inner_width
433        .saturating_sub(UnicodeWidthStr::width(title.as_str()))
434        .saturating_sub(2);
435    let title_left = title_padding / 2;
436    let title_right = title_padding - title_left;
437
438    output.push_str(tl);
439    output.push_str(&h.repeat(title_left + 1));
440    output.push_str(&format!(" {title} "));
441    output.push_str(&h.repeat(title_right + 1));
442    output.push_str(tr);
443    output.push('\n');
444
445    if lines.is_empty() {
446        lines.push(String::new());
447    }
448
449    for line in lines {
450        let padding = inner_width.saturating_sub(UnicodeWidthStr::width(line.as_str()));
451        output.push_str(v);
452        output.push(' ');
453        output.push_str(&line);
454        output.push_str(&" ".repeat(padding));
455        output.push(' ');
456        output.push_str(v);
457        output.push('\n');
458    }
459
460    output.push_str(bl);
461    output.push_str(&h.repeat(inner_width + 2));
462    output.push_str(br);
463    output
464}
465
466fn truncate_line(line: &str, max_width: usize) -> String {
467    if UnicodeWidthStr::width(line) <= max_width {
468        return line.to_string();
469    }
470
471    let ellipsis = "...";
472    let max_content = max_width.saturating_sub(ellipsis.len());
473    let mut out = String::new();
474    let mut width = 0;
475
476    for ch in line.chars() {
477        let w = UnicodeWidthChar::width(ch).unwrap_or(0);
478        if width + w > max_content {
479            break;
480        }
481        out.push(ch);
482        width += w;
483    }
484
485    out.push_str(ellipsis);
486    out
487}
488
489fn format_duration_ms(ms: u64) -> String {
490    if ms >= 60_000 {
491        format!("{:.1}m", ms as f64 / 60_000.0)
492    } else if ms >= 1000 {
493        format!("{:.1}s", ms as f64 / 1000.0)
494    } else {
495        format!("{}ms", ms)
496    }
497}
498
499fn format_bytes(bytes: u64) -> String {
500    const KB: f64 = 1024.0;
501    const MB: f64 = KB * 1024.0;
502    const GB: f64 = MB * 1024.0;
503
504    let bytes_f = bytes as f64;
505    if bytes_f >= GB {
506        format!("{:.1} GB", bytes_f / GB)
507    } else if bytes_f >= MB {
508        format!("{:.1} MB", bytes_f / MB)
509    } else if bytes_f >= KB {
510        format!("{:.1} KB", bytes_f / KB)
511    } else {
512        format!("{} B", bytes)
513    }
514}
515
516fn star_icon(ctx: OutputContext) -> &'static str {
517    if ctx.supports_unicode() { "★" } else { "*" }
518}
519
520fn format_ordinal(value: u64) -> String {
521    let suffix = match value % 100 {
522        11..=13 => "th",
523        _ => match value % 10 {
524            1 => "st",
525            2 => "nd",
526            3 => "rd",
527            _ => "th",
528        },
529    };
530    format!("{}{}", value, suffix)
531}
532
533// ========================================================================
534// Stats derivation
535// ========================================================================
536
537fn compute_stats(summary: &CelebrationSummary, mut stats: BuildStats) -> BuildStats {
538    if let Some(previous_ms) = stats.previous_ms
539        && previous_ms > 0
540    {
541        let diff = summary.duration_ms as f64 - previous_ms as f64;
542        let percent = (diff.abs() / previous_ms as f64) * 100.0;
543        if percent >= 1.0 {
544            stats.comparison = Some(Comparison {
545                percent,
546                faster: diff < 0.0,
547                baseline_ms: previous_ms,
548            });
549        }
550    }
551
552    if let Some(best_ms) = stats.best_ms {
553        stats.is_record = summary.duration_ms < best_ms;
554    }
555
556    let next_count = stats.count + 1;
557    if next_count > 0 && next_count.is_multiple_of(100) {
558        stats.milestone = Some(next_count);
559    }
560
561    stats
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn compute_stats_sets_comparison_when_faster() {
570        let summary = CelebrationSummary::new("proj", 9_000);
571        let stats = BuildStats {
572            previous_ms: Some(10_000),
573            ..BuildStats::default()
574        };
575
576        let stats = compute_stats(&summary, stats);
577        let comparison = stats.comparison.expect("comparison should be set");
578
579        assert!(comparison.faster);
580        assert_eq!(comparison.baseline_ms, 10_000);
581        assert!((comparison.percent - 10.0).abs() < 0.01);
582    }
583
584    #[test]
585    fn compute_stats_sets_comparison_when_slower() {
586        let summary = CelebrationSummary::new("proj", 11_000);
587        let stats = BuildStats {
588            previous_ms: Some(10_000),
589            ..BuildStats::default()
590        };
591
592        let stats = compute_stats(&summary, stats);
593        let comparison = stats.comparison.expect("comparison should be set");
594
595        assert!(!comparison.faster);
596        assert_eq!(comparison.baseline_ms, 10_000);
597        assert!((comparison.percent - 10.0).abs() < 0.01);
598    }
599
600    #[test]
601    fn compute_stats_sets_record_and_milestone() {
602        let summary = CelebrationSummary::new("proj", 7_000);
603        let stats = BuildStats {
604            count: 99,
605            best_ms: Some(8_000),
606            ..BuildStats::default()
607        };
608
609        let stats = compute_stats(&summary, stats);
610        assert!(stats.is_record);
611        assert_eq!(stats.milestone, Some(100));
612    }
613
614    #[test]
615    fn cache_line_reports_saved_time_on_hit() {
616        let summary = CelebrationSummary::new("proj", 9_000).cache_hit(Some(true));
617        let stats = BuildStats {
618            average_ms: Some(20_000),
619            ..BuildStats::default()
620        };
621
622        let line = cache_line(&summary, &stats).expect("cache line");
623        assert!(line.contains("Cache: HIT"));
624        assert!(line.contains("saved ~"));
625    }
626
627    #[test]
628    fn render_box_uses_ascii_when_unicode_not_supported() {
629        let ctx = OutputContext::plain();
630        let rendered = render_box(ctx, "Build Successful", &[String::from("Duration: 1.0s")]);
631        let first_line = rendered.lines().next().unwrap_or_default();
632        assert!(first_line.starts_with('+'));
633        assert!(rendered.contains('|'));
634        assert!(!rendered.contains('╭'));
635    }
636
637    #[test]
638    fn build_history_enforces_limit() {
639        let mut history = BuildHistory::default();
640
641        for idx in 0..(HISTORY_LIMIT + 10) {
642            history.record(BuildHistoryEntry {
643                project_id: "proj".to_string(),
644                duration_ms: idx as u64,
645                timestamp: Utc::now(),
646                cache_hit: None,
647            });
648        }
649
650        assert_eq!(history.entries.len(), HISTORY_LIMIT);
651    }
652
653    // -------------------------------------------------------------------------
654    // Format utility tests
655    // -------------------------------------------------------------------------
656
657    #[test]
658    fn format_duration_ms_milliseconds() {
659        assert_eq!(format_duration_ms(500), "500ms");
660        assert_eq!(format_duration_ms(0), "0ms");
661        assert_eq!(format_duration_ms(999), "999ms");
662    }
663
664    #[test]
665    fn format_duration_ms_seconds() {
666        assert_eq!(format_duration_ms(1000), "1.0s");
667        assert_eq!(format_duration_ms(1500), "1.5s");
668        assert_eq!(format_duration_ms(59_999), "60.0s");
669    }
670
671    #[test]
672    fn format_duration_ms_minutes() {
673        assert_eq!(format_duration_ms(60_000), "1.0m");
674        assert_eq!(format_duration_ms(90_000), "1.5m");
675        assert_eq!(format_duration_ms(120_000), "2.0m");
676    }
677
678    #[test]
679    fn format_bytes_plain_bytes() {
680        assert_eq!(format_bytes(0), "0 B");
681        assert_eq!(format_bytes(512), "512 B");
682        assert_eq!(format_bytes(1023), "1023 B");
683    }
684
685    #[test]
686    fn format_bytes_kilobytes() {
687        assert_eq!(format_bytes(1024), "1.0 KB");
688        assert_eq!(format_bytes(1536), "1.5 KB");
689        assert_eq!(format_bytes(10_240), "10.0 KB");
690    }
691
692    #[test]
693    fn format_bytes_megabytes() {
694        assert_eq!(format_bytes(1_048_576), "1.0 MB");
695        assert_eq!(format_bytes(5_242_880), "5.0 MB");
696    }
697
698    #[test]
699    fn format_bytes_gigabytes() {
700        assert_eq!(format_bytes(1_073_741_824), "1.0 GB");
701        assert_eq!(format_bytes(2_684_354_560), "2.5 GB");
702    }
703
704    #[test]
705    fn format_ordinal_first_ten() {
706        assert_eq!(format_ordinal(1), "1st");
707        assert_eq!(format_ordinal(2), "2nd");
708        assert_eq!(format_ordinal(3), "3rd");
709        assert_eq!(format_ordinal(4), "4th");
710        assert_eq!(format_ordinal(5), "5th");
711    }
712
713    #[test]
714    fn format_ordinal_teens() {
715        assert_eq!(format_ordinal(11), "11th");
716        assert_eq!(format_ordinal(12), "12th");
717        assert_eq!(format_ordinal(13), "13th");
718    }
719
720    #[test]
721    fn format_ordinal_twenties() {
722        assert_eq!(format_ordinal(21), "21st");
723        assert_eq!(format_ordinal(22), "22nd");
724        assert_eq!(format_ordinal(23), "23rd");
725        assert_eq!(format_ordinal(24), "24th");
726    }
727
728    #[test]
729    fn format_ordinal_hundreds() {
730        assert_eq!(format_ordinal(100), "100th");
731        assert_eq!(format_ordinal(101), "101st");
732        assert_eq!(format_ordinal(111), "111th");
733        assert_eq!(format_ordinal(112), "112th");
734        assert_eq!(format_ordinal(113), "113th");
735    }
736
737    #[test]
738    fn truncate_line_short_string() {
739        let line = "hello";
740        assert_eq!(truncate_line(line, 10), "hello");
741    }
742
743    #[test]
744    fn truncate_line_exact_width() {
745        let line = "hello";
746        assert_eq!(truncate_line(line, 5), "hello");
747    }
748
749    #[test]
750    fn truncate_line_long_string() {
751        let line = "this is a very long string";
752        let truncated = truncate_line(line, 15);
753        assert!(truncated.width() <= 15);
754    }
755
756    #[test]
757    fn star_icon_plain() {
758        let ctx = OutputContext::plain();
759        assert_eq!(star_icon(ctx), "*");
760    }
761
762    #[test]
763    fn celebration_summary_builder() {
764        let summary = CelebrationSummary::new("myproject", 1000)
765            .worker("worker1")
766            .crates_compiled(Some(10))
767            .cache_hit(Some(true))
768            .target(Some("x86_64".to_string()))
769            .quiet(false);
770
771        assert_eq!(summary.project_id, "myproject");
772        assert_eq!(summary.duration_ms, 1000);
773        assert_eq!(summary.worker, Some("worker1".to_string()));
774        assert_eq!(summary.crates_compiled, Some(10));
775        assert_eq!(summary.cache_hit, Some(true));
776        assert_eq!(summary.target, Some("x86_64".to_string()));
777        assert!(!summary.quiet);
778    }
779
780    #[test]
781    fn artifact_summary_stores_values() {
782        let artifact = ArtifactSummary {
783            files: 42,
784            bytes: 1_000_000,
785        };
786        assert_eq!(artifact.files, 42);
787        assert_eq!(artifact.bytes, 1_000_000);
788    }
789}