1use crate::ui::{Icons, OutputContext, ProgressContext};
11use std::time::{Duration, Instant};
12
13#[cfg(all(feature = "rich-ui", unix))]
14use crate::ui::RchTheme;
15#[cfg(all(feature = "rich-ui", unix))]
16use rich_rust::prelude::{BarStyle, ProgressBar, Style};
17
18const DEFAULT_BAR_WIDTH: usize = 28;
19const RATE_SAMPLE_WINDOW: usize = 10;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum BuildPhase {
24 #[default]
26 Starting,
27 Compiling,
29 BuildScript,
31 Linking,
33 Testing,
35 DocTest,
37 Finished,
39}
40
41impl BuildPhase {
42 fn label(self) -> &'static str {
43 match self {
44 Self::Starting => "Starting",
45 Self::Compiling => "Compiling",
46 Self::BuildScript => "Build script",
47 Self::Linking => "Linking",
48 Self::Testing => "Testing",
49 Self::DocTest => "Doc tests",
50 Self::Finished => "Finished",
51 }
52 }
53
54 fn icon(self, ctx: OutputContext) -> &'static str {
55 match self {
56 Self::Starting => Icons::hourglass(ctx),
57 Self::Compiling => Icons::gear(ctx),
58 Self::BuildScript => Icons::gear(ctx),
59 Self::Linking => Icons::transfer(ctx),
60 Self::Testing => Icons::clock(ctx),
61 Self::DocTest => Icons::clock(ctx),
62 Self::Finished => Icons::check(ctx),
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct CrateInfo {
70 pub name: String,
72 pub version: Option<String>,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78pub enum BuildProfile {
79 #[default]
80 Debug,
81 Release,
82}
83
84impl BuildProfile {
85 #[allow(dead_code)] fn label(self) -> &'static str {
87 match self {
88 Self::Debug => "debug",
89 Self::Release => "release",
90 }
91 }
92}
93
94#[derive(Debug, Default)]
96struct RateSmoother {
97 samples: Vec<f64>,
98}
99
100impl RateSmoother {
101 fn push(&mut self, value: f64) {
102 if value <= 0.0 || !value.is_finite() {
103 return;
104 }
105 self.samples.push(value);
106 if self.samples.len() > RATE_SAMPLE_WINDOW {
107 self.samples.remove(0);
108 }
109 }
110
111 fn average(&self) -> Option<f64> {
112 if self.samples.is_empty() {
113 return None;
114 }
115 let sum: f64 = self.samples.iter().sum();
116 Some(sum / self.samples.len() as f64)
117 }
118}
119
120#[derive(Debug)]
145pub struct CompilationProgress {
146 ctx: OutputContext,
147 worker: String,
148 enabled: bool,
149 progress: Option<ProgressContext>,
150 start: Instant,
151 phase: BuildPhase,
152 profile: BuildProfile,
153 crates_compiled: u32,
154 crates_total: Option<u32>,
155 current_crate: Option<CrateInfo>,
156 warnings: u32,
157 rate: RateSmoother,
158 memory_mb: Option<u32>,
159 last_crate_time: Instant,
160 linking_start: Option<Instant>,
161}
162
163impl CompilationProgress {
164 pub fn new(ctx: OutputContext, worker: impl Into<String>, quiet: bool) -> Self {
166 let enabled = !quiet && !ctx.is_machine();
167 let progress = if enabled && matches!(ctx, OutputContext::Interactive) {
168 Some(ProgressContext::new(ctx))
169 } else {
170 None
171 };
172
173 let now = Instant::now();
174 Self {
175 ctx,
176 worker: worker.into(),
177 enabled,
178 progress,
179 start: now,
180 phase: BuildPhase::Starting,
181 profile: BuildProfile::Debug,
182 crates_compiled: 0,
183 crates_total: None,
184 current_crate: None,
185 warnings: 0,
186 rate: RateSmoother::default(),
187 memory_mb: None,
188 last_crate_time: now,
189 linking_start: None,
190 }
191 }
192
193 pub fn update_from_line(&mut self, line: &str) {
195 let trimmed = line.trim();
196 if trimmed.is_empty() {
197 return;
198 }
199
200 self.parse_line(trimmed);
202 self.render();
203 }
204
205 pub fn set_total_crates(&mut self, total: u32) {
210 self.crates_total = Some(total);
211 }
212
213 pub fn set_memory_mb(&mut self, mb: u32) {
215 self.memory_mb = Some(mb);
216 }
217
218 pub fn finish(&mut self) {
220 if let Some(progress) = &self.progress {
221 progress.clear();
222 }
223
224 if !self.enabled {
225 return;
226 }
227
228 let duration = self.start.elapsed();
229 let icon = Icons::check(self.ctx);
230 let crates = self.crates_compiled;
231 let duration_str = format_duration(duration);
232 let rate = self.rate.average().unwrap_or(0.0);
233 let rate_str = if rate > 0.0 {
234 format!("{rate:.1} crates/sec")
235 } else {
236 "--".to_string()
237 };
238
239 let warnings_str = if self.warnings > 0 {
240 format!(
241 ", {} warning{}",
242 self.warnings,
243 if self.warnings == 1 { "" } else { "s" }
244 )
245 } else {
246 String::new()
247 };
248
249 eprintln!(
250 "{icon} Build complete: {crates} crates in {duration_str} ({rate_str}{warnings_str})"
251 );
252 }
253
254 pub fn finish_error(&mut self, message: &str) {
256 if let Some(progress) = &self.progress {
257 progress.clear();
258 }
259
260 if !self.enabled {
261 return;
262 }
263
264 let icon = Icons::cross(self.ctx);
265 let duration = self.start.elapsed();
266 let duration_str = format_duration(duration);
267
268 eprintln!("{icon} Build failed after {duration_str}: {message}");
269 }
270
271 #[must_use]
273 pub fn phase(&self) -> BuildPhase {
274 self.phase
275 }
276
277 #[must_use]
279 pub fn crates_compiled(&self) -> u32 {
280 self.crates_compiled
281 }
282
283 #[must_use]
285 pub fn warnings(&self) -> u32 {
286 self.warnings
287 }
288
289 fn parse_line(&mut self, line: &str) {
290 if let Some(rest) = line.strip_prefix("warning: ") {
292 if let Some(count_str) = rest.strip_suffix(" warnings emitted") {
293 if let Ok(count) = count_str.parse::<u32>() {
294 self.warnings = count;
295 return;
296 }
297 } else if rest.ends_with(" warning emitted") {
298 self.warnings = 1;
299 return;
300 }
301 }
303
304 if line.contains("warning:") && !line.starts_with("warning:") {
306 } else if line.starts_with("warning:") || line.contains(": warning") {
308 self.warnings += 1;
309 return;
310 }
311
312 if let Some(rest) = line.strip_prefix("Compiling ") {
314 self.phase = BuildPhase::Compiling;
315 self.parse_crate_info(rest);
316 self.record_crate_compiled();
317 return;
318 }
319
320 if line.starts_with("Running `") && line.contains("build-script") {
322 self.phase = BuildPhase::BuildScript;
323 return;
324 }
325
326 if line.starts_with("Linking ") || line.contains("Linking ") {
328 self.phase = BuildPhase::Linking;
329 self.linking_start = Some(Instant::now());
330 return;
331 }
332
333 if line.starts_with("Running ") && (line.contains("tests") || line.contains("test")) {
335 self.phase = BuildPhase::Testing;
336 return;
337 }
338
339 if line.contains("Doc-tests") {
341 self.phase = BuildPhase::DocTest;
342 return;
343 }
344
345 if line.starts_with("Finished ") {
347 self.phase = BuildPhase::Finished;
348 if line.contains("`release`") || line.contains("release") {
349 self.profile = BuildProfile::Release;
350 }
351 return;
352 }
353
354 if line.contains("--release") || line.contains("`release`") {
356 self.profile = BuildProfile::Release;
357 }
358
359 if let Some(rest) = line.strip_prefix("Checking ") {
361 self.phase = BuildPhase::Compiling;
362 self.parse_crate_info(rest);
363 self.record_crate_compiled();
364 }
365 }
366
367 fn parse_crate_info(&mut self, rest: &str) {
368 let parts: Vec<&str> = rest.split_whitespace().collect();
370 if parts.is_empty() {
371 return;
372 }
373
374 let name = parts[0].to_string();
375 let version = parts.get(1).map(|v| {
376 if v.starts_with('v') || v.starts_with('V') {
377 v[1..].to_string()
378 } else {
379 (*v).to_string()
380 }
381 });
382
383 self.current_crate = Some(CrateInfo { name, version });
384 }
385
386 fn record_crate_compiled(&mut self) {
387 self.crates_compiled += 1;
388
389 let now = Instant::now();
391 let elapsed_since_last = now.duration_since(self.last_crate_time).as_secs_f64();
392 if elapsed_since_last > 0.0 {
393 let rate = 1.0 / elapsed_since_last;
394 self.rate.push(rate);
395 }
396 self.last_crate_time = now;
397 }
398
399 fn render(&mut self) {
400 if !self.enabled {
401 return;
402 }
403
404 let phase_icon = self.phase.icon(self.ctx);
405 let worker = &self.worker;
406 let elapsed = format_duration(self.start.elapsed());
407
408 let (bar, percent_str) = if let Some(total) = self.crates_total {
410 let percent = if total > 0 {
411 (self.crates_compiled as f64 / total as f64).clamp(0.0, 1.0)
412 } else {
413 0.0
414 };
415 let bar = render_bar(
416 self.ctx,
417 self.crates_compiled as u64,
418 Some(total as u64),
419 Some(percent),
420 DEFAULT_BAR_WIDTH,
421 );
422 let pct = (percent * 100.0).round() as u32;
423 (bar, format!("{pct}%"))
424 } else {
425 let bar = render_bar(
427 self.ctx,
428 self.crates_compiled as u64,
429 None,
430 None,
431 DEFAULT_BAR_WIDTH,
432 );
433 (bar, "??%".to_string())
434 };
435
436 let crates_str = if let Some(total) = self.crates_total {
438 format!("{}/{} crates", self.crates_compiled, total)
439 } else {
440 format!("{} crates", self.crates_compiled)
441 };
442
443 let rate = self.rate.average().unwrap_or(0.0);
445 let rate_str = if rate > 0.0 {
446 format!("{rate:.1} crates/sec")
447 } else {
448 "--/sec".to_string()
449 };
450
451 let current = self
453 .current_crate
454 .as_ref()
455 .map(|c| {
456 if let Some(v) = &c.version {
457 format!("{} v{v}", c.name)
458 } else {
459 c.name.clone()
460 }
461 })
462 .unwrap_or_else(|| "--".to_string());
463
464 let phase_label = self.phase.label();
466
467 let warnings_str = if self.warnings > 0 {
469 let icon = Icons::warning(self.ctx);
470 format!(" {icon} {}", self.warnings)
471 } else {
472 String::new()
473 };
474
475 let _memory_str = self
477 .memory_mb
478 .map(|mb| format!(" [{mb}MB]"))
479 .unwrap_or_default();
480
481 let _linking_str = if self.phase == BuildPhase::Linking {
483 if let Some(start) = self.linking_start {
484 let dur = format_duration(start.elapsed());
485 format!(" ({dur})")
486 } else {
487 String::new()
488 }
489 } else {
490 String::new()
491 };
492
493 let compact_line = format!(
495 "{phase_icon} Building on {worker} {bar} {percent_str} | {crates_str} | {elapsed} | {rate_str}{warnings_str} | {phase_label}: {current}"
496 );
497
498 if let Some(progress) = &mut self.progress {
499 progress.render(&compact_line);
500 }
501 }
502}
503
504#[cfg(all(feature = "rich-ui", unix))]
505fn render_bar(
506 ctx: OutputContext,
507 current: u64,
508 total: Option<u64>,
509 percent: Option<f64>,
510 width: usize,
511) -> String {
512 let mut bar = if let Some(total) = total {
513 ProgressBar::with_total(total)
514 } else {
515 ProgressBar::new()
516 };
517
518 let bar_style = if ctx.supports_unicode() {
519 BarStyle::Block
520 } else {
521 BarStyle::Ascii
522 };
523
524 let completed_style = Style::new()
525 .color_str(RchTheme::SECONDARY)
526 .unwrap_or_default();
527 let remaining_style = Style::new().color_str("bright_black").unwrap_or_default();
528
529 bar = bar
530 .width(width)
531 .bar_style(bar_style)
532 .completed_style(completed_style)
533 .remaining_style(remaining_style)
534 .show_percentage(false)
535 .show_eta(false)
536 .show_speed(false)
537 .show_elapsed(false);
538
539 if total.is_some() {
540 bar.update(current);
541 } else if let Some(percent) = percent {
542 bar.set_progress(percent);
543 }
544
545 bar.render_plain(width + 2).trim_end().to_string()
546}
547
548#[cfg(not(all(feature = "rich-ui", unix)))]
549fn render_bar(
550 ctx: OutputContext,
551 _current: u64,
552 _total: Option<u64>,
553 percent: Option<f64>,
554 width: usize,
555) -> String {
556 let progress = percent.unwrap_or(0.0).clamp(0.0, 1.0);
557 let filled = (progress * width as f64).round() as usize;
558 let empty = width.saturating_sub(filled);
559 let filled_char = Icons::progress_filled(ctx);
560 let empty_char = Icons::progress_empty(ctx);
561
562 let mut bar = String::from("[");
563 bar.push_str(&filled_char.repeat(filled));
564 bar.push_str(&empty_char.repeat(empty));
565 bar.push(']');
566 bar
567}
568
569fn format_duration(duration: Duration) -> String {
570 let total_secs = duration.as_secs();
571 if total_secs < 60 {
572 format!("{:.1}s", duration.as_secs_f64())
573 } else if total_secs < 3600 {
574 let mins = total_secs / 60;
575 let secs = total_secs % 60;
576 format!("{mins}:{secs:02}")
577 } else {
578 let hours = total_secs / 3600;
579 let mins = (total_secs % 3600) / 60;
580 let secs = total_secs % 60;
581 format!("{hours}:{mins:02}:{secs:02}")
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn parse_compiling_line() {
591 let ctx = OutputContext::Plain;
592 let mut progress = CompilationProgress::new(ctx, "test-worker", false);
593
594 progress.update_from_line(" Compiling serde v1.0.193");
595
596 assert_eq!(progress.crates_compiled(), 1);
597 assert_eq!(progress.phase(), BuildPhase::Compiling);
598 assert!(progress.current_crate.is_some());
599
600 let crate_info = progress.current_crate.as_ref().unwrap();
601 assert_eq!(crate_info.name, "serde");
602 assert_eq!(crate_info.version.as_deref(), Some("1.0.193"));
603 }
604
605 #[test]
606 fn parse_checking_line() {
607 let ctx = OutputContext::Plain;
608 let mut progress = CompilationProgress::new(ctx, "test-worker", false);
609
610 progress.update_from_line(" Checking rch-common v0.1.0 (/path/to/crate)");
611
612 assert_eq!(progress.crates_compiled(), 1);
613 assert_eq!(progress.phase(), BuildPhase::Compiling);
614 }
615
616 #[test]
617 fn parse_finished_line() {
618 let ctx = OutputContext::Plain;
619 let mut progress = CompilationProgress::new(ctx, "test-worker", false);
620
621 progress.update_from_line(" Finished `release` profile [optimized] target(s) in 45.23s");
622
623 assert_eq!(progress.phase(), BuildPhase::Finished);
624 assert_eq!(progress.profile, BuildProfile::Release);
625 }
626
627 #[test]
628 fn parse_warning_count() {
629 let ctx = OutputContext::Plain;
630 let mut progress = CompilationProgress::new(ctx, "test-worker", false);
631
632 progress.update_from_line("warning: 5 warnings emitted");
633
634 assert_eq!(progress.warnings(), 5);
635 }
636
637 #[test]
638 fn parse_single_warning() {
639 let ctx = OutputContext::Plain;
640 let mut progress = CompilationProgress::new(ctx, "test-worker", false);
641
642 progress.update_from_line("warning: 1 warning emitted");
643
644 assert_eq!(progress.warnings(), 1);
645 }
646
647 #[test]
648 fn parse_linking_phase() {
649 let ctx = OutputContext::Plain;
650 let mut progress = CompilationProgress::new(ctx, "test-worker", false);
651
652 progress.update_from_line(" Linking target/release/myapp");
653
654 assert_eq!(progress.phase(), BuildPhase::Linking);
655 assert!(progress.linking_start.is_some());
656 }
657
658 #[test]
659 fn parse_testing_phase() {
660 let ctx = OutputContext::Plain;
661 let mut progress = CompilationProgress::new(ctx, "test-worker", false);
662
663 progress.update_from_line(" Running unittests src/lib.rs (target/debug/deps/rch-...)");
664
665 assert_eq!(progress.phase(), BuildPhase::Testing);
666 }
667
668 #[test]
669 fn multiple_crates_compiled() {
670 let ctx = OutputContext::Plain;
671 let mut progress = CompilationProgress::new(ctx, "test-worker", false);
672
673 progress.update_from_line(" Compiling serde v1.0.193");
674 progress.update_from_line(" Compiling serde_json v1.0.108");
675 progress.update_from_line(" Compiling tokio v1.35.1");
676
677 assert_eq!(progress.crates_compiled(), 3);
678 }
679
680 #[test]
681 fn total_crates_percentage() {
682 let ctx = OutputContext::Plain;
683 let mut progress = CompilationProgress::new(ctx, "test-worker", true); progress.set_total_crates(100);
686 progress.update_from_line(" Compiling crate1 v0.1.0");
687 progress.update_from_line(" Compiling crate2 v0.1.0");
688
689 assert_eq!(progress.crates_compiled(), 2);
690 }
692
693 #[test]
694 fn format_duration_seconds() {
695 let dur = Duration::from_secs_f64(45.7);
696 assert_eq!(format_duration(dur), "45.7s");
697 }
698
699 #[test]
700 fn format_duration_minutes() {
701 let dur = Duration::from_secs(125);
702 assert_eq!(format_duration(dur), "2:05");
703 }
704
705 #[test]
706 fn format_duration_hours() {
707 let dur = Duration::from_secs(3725);
708 assert_eq!(format_duration(dur), "1:02:05");
709 }
710
711 #[test]
712 fn rate_smoother_average() {
713 let mut smoother = RateSmoother::default();
714 smoother.push(1.0);
715 smoother.push(2.0);
716 smoother.push(3.0);
717
718 let avg = smoother.average().unwrap();
719 assert!((avg - 2.0).abs() < 0.001);
720 }
721
722 #[test]
723 fn rate_smoother_ignores_invalid() {
724 let mut smoother = RateSmoother::default();
725 smoother.push(-1.0);
726 smoother.push(f64::NAN);
727 smoother.push(0.0);
728
729 assert!(smoother.average().is_none());
730 }
731}