1#[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#[derive(Debug, Clone)]
26pub struct ArtifactSummary {
27 pub files: u64,
28 pub bytes: u64,
29}
30
31#[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#[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 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 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#[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#[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
533fn 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 #[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}