Skip to main content

rch_common/ui/progress/
compile.rs

1//! CompilationProgress - Cargo build status visualization.
2//!
3//! Parses cargo output and renders compilation progress with:
4//! - Crate count tracking (X/Y crates)
5//! - Build phase indication (Compiling, Linking, Running tests)
6//! - Rate calculation (crates/sec)
7//! - Warning accumulation
8//! - Optional memory usage display
9
10use 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/// Build phases during compilation.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum BuildPhase {
24    /// Initial state before any output.
25    #[default]
26    Starting,
27    /// Compiling crates.
28    Compiling,
29    /// Running build scripts (build.rs).
30    BuildScript,
31    /// Linking final binary.
32    Linking,
33    /// Running tests.
34    Testing,
35    /// Running documentation tests.
36    DocTest,
37    /// Finished (success or failure).
38    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/// Parsed information from a single cargo output line.
68#[derive(Debug, Clone)]
69pub struct CrateInfo {
70    /// Crate name.
71    pub name: String,
72    /// Crate version (if available).
73    pub version: Option<String>,
74}
75
76/// Build configuration detected from cargo output.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78pub enum BuildProfile {
79    #[default]
80    Debug,
81    Release,
82}
83
84impl BuildProfile {
85    #[allow(dead_code)] // Part of public API, may be used by consumers
86    fn label(self) -> &'static str {
87        match self {
88            Self::Debug => "debug",
89            Self::Release => "release",
90        }
91    }
92}
93
94/// Smoothed rate calculation for crates/sec.
95#[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/// Progress display for cargo compilation.
121///
122/// Parses cargo output lines and renders a compact progress display showing:
123/// - Crate count (X/Y crates)
124/// - Current crate being compiled
125/// - Elapsed time and compilation rate
126/// - Build phase (Compiling, Linking, Testing)
127/// - Warning count accumulator
128///
129/// # Example
130///
131/// ```ignore
132/// use rch_common::ui::{OutputContext, CompilationProgress};
133///
134/// let ctx = OutputContext::detect();
135/// let mut progress = CompilationProgress::new(ctx, "worker1", false);
136///
137/// // Feed cargo output lines
138/// progress.update_from_line("   Compiling serde v1.0.193");
139/// progress.update_from_line("   Compiling serde_json v1.0.108");
140/// progress.update_from_line("    Finished `release` profile [optimized] target(s) in 45.23s");
141///
142/// progress.finish();
143/// ```
144#[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    /// Create a new compilation progress display.
165    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    /// Update progress from a raw cargo output line.
194    pub fn update_from_line(&mut self, line: &str) {
195        let trimmed = line.trim();
196        if trimmed.is_empty() {
197            return;
198        }
199
200        // Parse the line and update state
201        self.parse_line(trimmed);
202        self.render();
203    }
204
205    /// Set the total number of crates to compile.
206    ///
207    /// If known ahead of time (e.g., from cargo metadata),
208    /// this enables percentage display.
209    pub fn set_total_crates(&mut self, total: u32) {
210        self.crates_total = Some(total);
211    }
212
213    /// Set memory usage (in MB) for display.
214    pub fn set_memory_mb(&mut self, mb: u32) {
215        self.memory_mb = Some(mb);
216    }
217
218    /// Finish the progress display and print a summary line.
219    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    /// Finish with a failure message.
255    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    /// Get the current build phase.
272    #[must_use]
273    pub fn phase(&self) -> BuildPhase {
274        self.phase
275    }
276
277    /// Get the number of crates compiled so far.
278    #[must_use]
279    pub fn crates_compiled(&self) -> u32 {
280        self.crates_compiled
281    }
282
283    /// Get the warning count.
284    #[must_use]
285    pub fn warnings(&self) -> u32 {
286        self.warnings
287    }
288
289    fn parse_line(&mut self, line: &str) {
290        // Check for summary warning count FIRST (before individual warning check)
291        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            // Not a summary line, fall through to individual warning check
302        }
303
304        // Check for individual warning lines
305        if line.contains("warning:") && !line.starts_with("warning:") {
306            // Inline warning in crate output (e.g., "src/lib.rs:10: warning: ..."), don't count
307        } else if line.starts_with("warning:") || line.contains(": warning") {
308            self.warnings += 1;
309            return;
310        }
311
312        // Detect "Compiling crate v1.2.3"
313        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        // Detect build script running
321        if line.starts_with("Running `") && line.contains("build-script") {
322            self.phase = BuildPhase::BuildScript;
323            return;
324        }
325
326        // Detect linking phase
327        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        // Detect test running
334        if line.starts_with("Running ") && (line.contains("tests") || line.contains("test")) {
335            self.phase = BuildPhase::Testing;
336            return;
337        }
338
339        // Detect doc tests
340        if line.contains("Doc-tests") {
341            self.phase = BuildPhase::DocTest;
342            return;
343        }
344
345        // Detect finished
346        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        // Detect profile from early output
355        if line.contains("--release") || line.contains("`release`") {
356            self.profile = BuildProfile::Release;
357        }
358
359        // Parse fresh/dirty crate messages (cargo check/clippy)
360        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        // Format: "crate_name v1.2.3" or "crate_name v1.2.3 (path+...)"
369        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        // Calculate rate
390        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        // Build progress bar
409        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            // Unknown total - show indeterminate progress
426            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        // Crate counts
437        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        // Rate
444        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        // Current crate
452        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        // Phase label
465        let phase_label = self.phase.label();
466
467        // Warning indicator
468        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        // Memory indicator (reserved for future multi-line display)
476        let _memory_str = self
477            .memory_mb
478            .map(|mb| format!(" [{mb}MB]"))
479            .unwrap_or_default();
480
481        // Linking duration (reserved for future multi-line display)
482        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        // For single-line rendering, compress to one line
494        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); // quiet mode
684
685        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        // 2/100 = 2%
691    }
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}