Skip to main content

rch_common/ui/errors/
build.rs

1//! BuildErrorDisplay - Specialized display for compilation and build errors.
2//!
3//! This module provides rich error display for build-related issues,
4//! including compilation failures, timeouts, signal kills, and artifact errors.
5//!
6//! # Key Design Principle
7//!
8//! For compilation errors, we preserve and enhance rustc/cargo error formatting
9//! rather than overriding it. RCH adds context (worker info, command, timing)
10//! around the compiler output, not replacing it.
11//!
12//! # Features
13//!
14//! - Compilation errors with compiler output passthrough
15//! - Build timeout with resource usage at timeout
16//! - Signal kills with OOM killer detection
17//! - Artifact retrieval failures with path details
18//! - Worker resource state display
19//! - JSON serialization for structured output
20//!
21//! # Example
22//!
23//! ```ignore
24//! use rch_common::ui::errors::BuildErrorDisplay;
25//! use rch_common::ui::OutputContext;
26//!
27//! let display = BuildErrorDisplay::build_timeout("cargo build --release")
28//!     .worker("build1.internal")
29//!     .duration_secs(300)
30//!     .timeout_secs(300)
31//!     .last_output("Compiling serde_derive v1.0.152")
32//!     .cpu_usage(98.0)
33//!     .memory_usage(14.2, 16.0)
34//!     .load_average(8.5);
35//!
36//! display.render(OutputContext::detect());
37//! ```
38
39use serde::{Deserialize, Serialize};
40use std::time::Duration;
41
42use crate::errors::catalog::ErrorCode;
43#[cfg(all(feature = "rich-ui", unix))]
44use crate::ui::RchTheme;
45use crate::ui::{ErrorPanel, Icons, OutputContext};
46
47#[cfg(all(feature = "rich-ui", unix))]
48use rich_rust::r#box::HEAVY;
49#[cfg(all(feature = "rich-ui", unix))]
50use rich_rust::prelude::*;
51
52/// Worker resource state at the time of error.
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
54pub struct WorkerResourceState {
55    /// CPU usage percentage (0-100).
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub cpu_percent: Option<f64>,
58    /// Memory used in GB.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub memory_used_gb: Option<f64>,
61    /// Total memory in GB.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub memory_total_gb: Option<f64>,
64    /// System load average (1 minute).
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub load_average: Option<f64>,
67    /// Disk usage percentage.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub disk_percent: Option<f64>,
70}
71
72impl WorkerResourceState {
73    /// Check if any resource data is available.
74    #[must_use]
75    pub fn has_data(&self) -> bool {
76        self.cpu_percent.is_some()
77            || self.memory_used_gb.is_some()
78            || self.load_average.is_some()
79            || self.disk_percent.is_some()
80    }
81
82    /// Format for display.
83    #[must_use]
84    pub fn format_line(&self) -> String {
85        let mut parts = Vec::new();
86
87        if let Some(cpu) = self.cpu_percent {
88            parts.push(format!("CPU: {cpu:.0}%"));
89        }
90
91        if let (Some(used), Some(total)) = (self.memory_used_gb, self.memory_total_gb) {
92            parts.push(format!("Memory: {used:.1}/{total:.1} GB"));
93        } else if let Some(used) = self.memory_used_gb {
94            parts.push(format!("Memory: {used:.1} GB"));
95        }
96
97        if let Some(load) = self.load_average {
98            parts.push(format!("Load: {load:.1}"));
99        }
100
101        if let Some(disk) = self.disk_percent {
102            parts.push(format!("Disk: {disk:.0}%"));
103        }
104
105        parts.join(" │ ")
106    }
107}
108
109/// Signal information for killed builds.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct SignalInfo {
112    /// Signal number (e.g., 9 for SIGKILL, 15 for SIGTERM).
113    pub signal_number: i32,
114    /// Signal name (e.g., "SIGKILL", "SIGTERM").
115    pub signal_name: String,
116    /// Whether this was likely an OOM kill.
117    pub likely_oom: bool,
118    /// Additional details about the kill.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub details: Option<String>,
121}
122
123impl SignalInfo {
124    /// Create from a signal number.
125    #[must_use]
126    pub fn from_signal(signal: i32) -> Self {
127        let signal_name = match signal {
128            1 => "SIGHUP",
129            2 => "SIGINT",
130            3 => "SIGQUIT",
131            6 => "SIGABRT",
132            9 => "SIGKILL",
133            11 => "SIGSEGV",
134            13 => "SIGPIPE",
135            14 => "SIGALRM",
136            15 => "SIGTERM",
137            _ => "UNKNOWN",
138        };
139
140        // SIGKILL is the most common OOM signal
141        let likely_oom = signal == 9;
142
143        Self {
144            signal_number: signal,
145            signal_name: signal_name.to_string(),
146            likely_oom,
147            details: None,
148        }
149    }
150
151    /// Create from an exit code (128 + signal).
152    #[must_use]
153    pub fn from_exit_code(exit_code: i32) -> Option<Self> {
154        if exit_code > 128 && exit_code <= 128 + 64 {
155            Some(Self::from_signal(exit_code - 128))
156        } else {
157            None
158        }
159    }
160
161    /// Mark as OOM kill with details.
162    #[must_use]
163    pub fn with_oom_details(mut self, details: impl Into<String>) -> Self {
164        self.likely_oom = true;
165        self.details = Some(details.into());
166        self
167    }
168}
169
170/// BuildErrorDisplay - Rich error display for build/compilation errors.
171///
172/// Builds on [`ErrorPanel`] with build-specific context:
173/// - Command that was executed
174/// - Worker and timing information
175/// - Compiler output passthrough
176/// - Resource usage at error time
177/// - Signal/kill information
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct BuildErrorDisplay {
180    /// The underlying error code.
181    pub error_code: ErrorCode,
182
183    /// Build command that was executed.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub command: Option<String>,
186
187    /// Worker where build ran.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub worker_name: Option<String>,
190
191    /// Build duration.
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub duration: Option<Duration>,
194
195    /// Configured timeout.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub timeout: Option<Duration>,
198
199    /// Last line(s) of build output.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub last_output: Option<String>,
202
203    /// Full compiler output (for passthrough).
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub compiler_output: Option<String>,
206
207    /// Worker resource state at error time.
208    #[serde(default, skip_serializing_if = "is_default_resources")]
209    pub resources: WorkerResourceState,
210
211    /// Signal information for killed builds.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub signal_info: Option<SignalInfo>,
214
215    /// Exit code from build process.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub exit_code: Option<i32>,
218
219    /// Artifact path (for missing artifact errors).
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub artifact_path: Option<String>,
222
223    /// Working directory on remote.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub workdir: Option<String>,
226
227    /// Toolchain being used.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub toolchain: Option<String>,
230
231    /// Whether this was a local or remote build.
232    #[serde(default)]
233    pub is_remote: bool,
234
235    /// Custom message override.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub custom_message: Option<String>,
238
239    /// Error chain (caused by).
240    #[serde(default, skip_serializing_if = "Vec::is_empty")]
241    pub caused_by: Vec<String>,
242}
243
244fn is_default_resources(r: &WorkerResourceState) -> bool {
245    !r.has_data()
246}
247
248impl BuildErrorDisplay {
249    // ========================================================================
250    // CONSTRUCTORS FOR SPECIFIC ERROR TYPES
251    // ========================================================================
252
253    /// Create display for compilation failure (E300).
254    #[must_use]
255    pub fn compilation_failed(command: impl Into<String>) -> Self {
256        Self::new(ErrorCode::BuildCompilationFailed).command(command)
257    }
258
259    /// Create display for unknown build command (E301).
260    #[must_use]
261    pub fn unknown_command(command: impl Into<String>) -> Self {
262        Self::new(ErrorCode::BuildUnknownCommand).command(command)
263    }
264
265    /// Create display for build killed by signal (E302).
266    #[must_use]
267    pub fn killed_by_signal(signal: i32) -> Self {
268        let signal_info = SignalInfo::from_signal(signal);
269        let mut display = Self::new(ErrorCode::BuildKilledBySignal);
270        display.signal_info = Some(signal_info);
271        display
272    }
273
274    /// Create display for build killed by signal from exit code.
275    #[must_use]
276    pub fn killed_from_exit_code(exit_code: i32) -> Self {
277        if let Some(signal_info) = SignalInfo::from_exit_code(exit_code) {
278            let mut display = Self::new(ErrorCode::BuildKilledBySignal);
279            display.signal_info = Some(signal_info);
280            display.exit_code = Some(exit_code);
281            display
282        } else {
283            let mut display = Self::new(ErrorCode::BuildCompilationFailed);
284            display.exit_code = Some(exit_code);
285            display
286        }
287    }
288
289    /// Create display for build timeout (E303).
290    #[must_use]
291    pub fn build_timeout(command: impl Into<String>) -> Self {
292        Self::new(ErrorCode::BuildTimeout).command(command)
293    }
294
295    /// Create display for build output capture error (E304).
296    #[must_use]
297    pub fn output_error() -> Self {
298        Self::new(ErrorCode::BuildOutputError)
299    }
300
301    /// Create display for working directory error (E305).
302    #[must_use]
303    pub fn workdir_error(workdir: impl Into<String>) -> Self {
304        let mut display = Self::new(ErrorCode::BuildWorkdirError);
305        display.workdir = Some(workdir.into());
306        display
307    }
308
309    /// Create display for toolchain error (E306).
310    #[must_use]
311    pub fn toolchain_error(toolchain: impl Into<String>) -> Self {
312        let mut display = Self::new(ErrorCode::BuildToolchainError);
313        display.toolchain = Some(toolchain.into());
314        display
315    }
316
317    /// Create display for environment setup error (E307).
318    #[must_use]
319    pub fn env_error() -> Self {
320        Self::new(ErrorCode::BuildEnvError)
321    }
322
323    /// Create display for incremental build corruption (E308).
324    #[must_use]
325    pub fn incremental_error() -> Self {
326        Self::new(ErrorCode::BuildIncrementalError)
327    }
328
329    /// Create display for missing artifact (E309).
330    #[must_use]
331    pub fn artifact_missing(artifact_path: impl Into<String>) -> Self {
332        let mut display = Self::new(ErrorCode::BuildArtifactMissing);
333        display.artifact_path = Some(artifact_path.into());
334        display
335    }
336
337    // ========================================================================
338    // CORE CONSTRUCTOR
339    // ========================================================================
340
341    /// Create a new BuildErrorDisplay with error code.
342    #[must_use]
343    fn new(error_code: ErrorCode) -> Self {
344        Self {
345            error_code,
346            command: None,
347            worker_name: None,
348            duration: None,
349            timeout: None,
350            last_output: None,
351            compiler_output: None,
352            resources: WorkerResourceState::default(),
353            signal_info: None,
354            exit_code: None,
355            artifact_path: None,
356            workdir: None,
357            toolchain: None,
358            is_remote: true,
359            custom_message: None,
360            caused_by: Vec::new(),
361        }
362    }
363
364    // ========================================================================
365    // BUILDER METHODS
366    // ========================================================================
367
368    /// Set the build command.
369    #[must_use]
370    pub fn command(mut self, command: impl Into<String>) -> Self {
371        self.command = Some(command.into());
372        self
373    }
374
375    /// Set the worker name.
376    #[must_use]
377    pub fn worker(mut self, worker: impl Into<String>) -> Self {
378        self.worker_name = Some(worker.into());
379        self
380    }
381
382    /// Set the build duration.
383    #[must_use]
384    pub fn duration(mut self, duration: Duration) -> Self {
385        self.duration = Some(duration);
386        self
387    }
388
389    /// Set the build duration in seconds.
390    #[must_use]
391    pub fn duration_secs(self, secs: u64) -> Self {
392        self.duration(Duration::from_secs(secs))
393    }
394
395    /// Set the configured timeout.
396    #[must_use]
397    pub fn timeout(mut self, timeout: Duration) -> Self {
398        self.timeout = Some(timeout);
399        self
400    }
401
402    /// Set the configured timeout in seconds.
403    #[must_use]
404    pub fn timeout_secs(self, secs: u64) -> Self {
405        self.timeout(Duration::from_secs(secs))
406    }
407
408    /// Set the last line(s) of build output.
409    #[must_use]
410    pub fn last_output(mut self, output: impl Into<String>) -> Self {
411        self.last_output = Some(output.into());
412        self
413    }
414
415    /// Set the full compiler output for passthrough.
416    #[must_use]
417    pub fn compiler_output(mut self, output: impl Into<String>) -> Self {
418        self.compiler_output = Some(output.into());
419        self
420    }
421
422    /// Set CPU usage percentage.
423    #[must_use]
424    pub fn cpu_usage(mut self, percent: f64) -> Self {
425        self.resources.cpu_percent = Some(percent);
426        self
427    }
428
429    /// Set memory usage.
430    #[must_use]
431    pub fn memory_usage(mut self, used_gb: f64, total_gb: f64) -> Self {
432        self.resources.memory_used_gb = Some(used_gb);
433        self.resources.memory_total_gb = Some(total_gb);
434        self
435    }
436
437    /// Set load average.
438    #[must_use]
439    pub fn load_average(mut self, load: f64) -> Self {
440        self.resources.load_average = Some(load);
441        self
442    }
443
444    /// Set disk usage percentage.
445    #[must_use]
446    pub fn disk_usage(mut self, percent: f64) -> Self {
447        self.resources.disk_percent = Some(percent);
448        self
449    }
450
451    /// Set the exit code.
452    #[must_use]
453    pub fn exit_code(mut self, code: i32) -> Self {
454        self.exit_code = Some(code);
455        self
456    }
457
458    /// Mark as local build (not remote).
459    #[must_use]
460    pub fn local(mut self) -> Self {
461        self.is_remote = false;
462        self
463    }
464
465    /// Set working directory.
466    #[must_use]
467    pub fn workdir(mut self, path: impl Into<String>) -> Self {
468        self.workdir = Some(path.into());
469        self
470    }
471
472    /// Set toolchain.
473    #[must_use]
474    pub fn toolchain(mut self, tc: impl Into<String>) -> Self {
475        self.toolchain = Some(tc.into());
476        self
477    }
478
479    /// Set a custom message.
480    #[must_use]
481    pub fn message(mut self, message: impl Into<String>) -> Self {
482        self.custom_message = Some(message.into());
483        self
484    }
485
486    /// Add a caused-by entry.
487    #[must_use]
488    pub fn caused_by(mut self, cause: impl Into<String>) -> Self {
489        self.caused_by.push(cause.into());
490        self
491    }
492
493    /// Mark as OOM kill.
494    #[must_use]
495    pub fn mark_oom(mut self, details: impl Into<String>) -> Self {
496        if let Some(ref mut info) = self.signal_info {
497            info.likely_oom = true;
498            info.details = Some(details.into());
499        } else {
500            self.signal_info = Some(SignalInfo::from_signal(9).with_oom_details(details));
501        }
502        self
503    }
504
505    // ========================================================================
506    // CONVERSION TO ErrorPanel
507    // ========================================================================
508
509    /// Convert to an ErrorPanel for rendering.
510    #[must_use]
511    pub fn to_error_panel(&self) -> ErrorPanel {
512        let entry = self.error_code.entry();
513
514        let mut panel = ErrorPanel::error(&entry.code, &entry.message);
515
516        // Set custom message if provided
517        if let Some(ref msg) = self.custom_message {
518            panel = panel.message(msg.clone());
519        }
520
521        // Add command
522        if let Some(ref cmd) = self.command {
523            panel = panel.context("Command", cmd.clone());
524        }
525
526        // Add worker info
527        if let Some(ref worker) = self.worker_name {
528            let location = if self.is_remote { "remote" } else { "local" };
529            panel = panel.context("Worker", format!("{worker} ({location})"));
530        }
531
532        // Add timing
533        if let (Some(dur), Some(timeout)) = (self.duration, self.timeout) {
534            panel = panel.context(
535                "Duration",
536                format!("{}s (timeout: {}s)", dur.as_secs(), timeout.as_secs()),
537            );
538        } else if let Some(dur) = self.duration {
539            panel = panel.context("Duration", format!("{}s", dur.as_secs()));
540        }
541
542        // Add exit code
543        if let Some(code) = self.exit_code {
544            panel = panel.context("Exit code", code.to_string());
545        }
546
547        // Add signal info
548        if let Some(ref sig) = self.signal_info {
549            let sig_text = if sig.likely_oom {
550                format!(
551                    "{} (signal {}) - likely OOM",
552                    sig.signal_name, sig.signal_number
553                )
554            } else {
555                format!("{} (signal {})", sig.signal_name, sig.signal_number)
556            };
557            panel = panel.context("Signal", sig_text);
558        }
559
560        // Add toolchain
561        if let Some(ref tc) = self.toolchain {
562            panel = panel.context("Toolchain", tc.clone());
563        }
564
565        // Add workdir
566        if let Some(ref wd) = self.workdir {
567            panel = panel.context("Working directory", wd.clone());
568        }
569
570        // Add artifact path
571        if let Some(ref path) = self.artifact_path {
572            panel = panel.context("Artifact path", path.clone());
573        }
574
575        // Add caused-by chain
576        for cause in &self.caused_by {
577            panel = panel.caused_by(cause.clone(), None);
578        }
579
580        // Add remediation from catalog
581        for step in entry.remediation {
582            panel = panel.suggestion(step);
583        }
584
585        panel
586    }
587
588    // ========================================================================
589    // RENDERING
590    // ========================================================================
591
592    /// Render the error to stderr.
593    ///
594    /// For compilation errors, this preserves and enhances rustc/cargo output
595    /// rather than replacing it.
596    pub fn render(&self, ctx: OutputContext) {
597        if ctx.is_machine() {
598            // Machine mode - caller should use to_json()
599            return;
600        }
601
602        // If we have compiler output, render it with RCH header/footer
603        if let Some(ref compiler_output) = self.compiler_output {
604            self.render_with_compiler_output(ctx, compiler_output);
605            return;
606        }
607
608        #[cfg(all(feature = "rich-ui", unix))]
609        if ctx.supports_rich() {
610            self.render_rich(ctx);
611            return;
612        }
613
614        self.render_plain(ctx);
615    }
616
617    /// Render with compiler output passthrough.
618    fn render_with_compiler_output(&self, ctx: OutputContext, compiler_output: &str) {
619        let entry = self.error_code.entry();
620        let icon = Icons::cross(ctx);
621
622        // Header with RCH context
623        eprintln!();
624        eprintln!(
625            "{icon} [RCH] Build failed on {}",
626            self.worker_name.as_deref().unwrap_or("remote")
627        );
628
629        if let Some(ref cmd) = self.command {
630            eprintln!("    Command: {cmd}");
631        }
632        if let Some(dur) = self.duration {
633            eprintln!("    Duration: {}s", dur.as_secs());
634        }
635        eprintln!();
636
637        // Pass through compiler output unchanged
638        eprint!("{compiler_output}");
639
640        // Footer with suggestions
641        eprintln!();
642        eprintln!("{icon} {} - {}", entry.code, entry.message);
643
644        if self.resources.has_data() {
645            eprintln!("    Worker state: {}", self.resources.format_line());
646        }
647
648        if !entry.remediation.is_empty() {
649            eprintln!();
650            eprintln!("Suggestions:");
651            for (i, step) in entry.remediation.iter().enumerate() {
652                eprintln!("  {}. {step}", i + 1);
653            }
654        }
655    }
656
657    /// Render using rich_rust Panel.
658    #[cfg(all(feature = "rich-ui", unix))]
659    fn render_rich(&self, ctx: OutputContext) {
660        let content = self.build_rich_content(ctx);
661        let entry = self.error_code.entry();
662        let icon = Icons::cross(ctx);
663        let title_text = format!("{icon} {}: {}", entry.code, entry.message);
664
665        let border_color = Color::parse(RchTheme::ERROR).unwrap_or_else(|_| Color::default());
666        let border_style = Style::new().bold().color(border_color);
667
668        let panel = Panel::from_text(&content)
669            .title(title_text.as_str())
670            .border_style(border_style)
671            .box_style(&HEAVY);
672
673        let console = Console::builder().force_terminal(true).build();
674        console.print_renderable(&panel);
675    }
676
677    /// Build rich content string.
678    #[cfg(all(feature = "rich-ui", unix))]
679    fn build_rich_content(&self, _ctx: OutputContext) -> String {
680        let mut lines = Vec::new();
681
682        // Custom or signal message
683        if let Some(ref msg) = self.custom_message {
684            lines.push(msg.clone());
685        } else if let Some(ref sig) = self.signal_info {
686            if sig.likely_oom {
687                lines.push(format!(
688                    "Build process was killed by {} - likely out of memory",
689                    sig.signal_name
690                ));
691            } else {
692                lines.push(format!("Build process was killed by {}", sig.signal_name));
693            }
694        }
695
696        // Command
697        if let Some(ref cmd) = self.command {
698            lines.push(String::new());
699            lines.push(format!("[{}]Command:[/] {cmd}", RchTheme::DIM));
700        }
701
702        // Worker and timing
703        if let Some(ref worker) = self.worker_name {
704            let location = if self.is_remote { "remote" } else { "local" };
705            lines.push(format!(
706                "[{}]Worker:[/] {worker} ({location})",
707                RchTheme::DIM
708            ));
709        }
710
711        if let (Some(dur), Some(timeout)) = (self.duration, self.timeout) {
712            lines.push(format!(
713                "[{}]Duration:[/] {}s (timeout: {}s)",
714                RchTheme::DIM,
715                dur.as_secs(),
716                timeout.as_secs()
717            ));
718        } else if let Some(dur) = self.duration {
719            lines.push(format!(
720                "[{}]Duration:[/] {}s",
721                RchTheme::DIM,
722                dur.as_secs()
723            ));
724        }
725
726        // Last output
727        if let Some(ref output) = self.last_output {
728            lines.push(String::new());
729            lines.push(format!("[{}]Last output:[/]", RchTheme::DIM));
730            lines.push(format!("  {output}"));
731        }
732
733        // Worker resources
734        if self.resources.has_data() {
735            lines.push(String::new());
736            lines.push(format!("[{}]Worker state at error:[/]", RchTheme::DIM));
737            lines.push(format!("  {}", self.resources.format_line()));
738        }
739
740        // Signal details
741        if let Some(ref sig) = self.signal_info
742            && let Some(ref details) = sig.details
743        {
744            lines.push(String::new());
745            lines.push(format!("[{}]Signal details:[/]", RchTheme::DIM));
746            lines.push(format!("  {details}"));
747        }
748
749        // Error chain
750        if !self.caused_by.is_empty() {
751            lines.push(String::new());
752            lines.push(format!("[{}]Caused by:[/]", RchTheme::DIM));
753            for cause in &self.caused_by {
754                lines.push(format!("  {cause}"));
755            }
756        }
757
758        // Remediation from catalog
759        let entry = self.error_code.entry();
760        if !entry.remediation.is_empty() {
761            lines.push(String::new());
762            lines.push(format!("[{}]Suggestions:[/]", RchTheme::SECONDARY));
763            for (i, step) in entry.remediation.iter().enumerate() {
764                lines.push(format!("  [{}]{}.[/] {step}", RchTheme::SECONDARY, i + 1));
765            }
766        }
767
768        lines.join("\n")
769    }
770
771    /// Render plain text to stderr.
772    fn render_plain(&self, ctx: OutputContext) {
773        let entry = self.error_code.entry();
774        let icon = Icons::cross(ctx);
775
776        // Header line
777        eprintln!("{icon} [ERROR] {}: {}", entry.code, entry.message);
778
779        // Custom or signal message
780        if let Some(ref msg) = self.custom_message {
781            eprintln!();
782            eprintln!("{msg}");
783        } else if let Some(ref sig) = self.signal_info {
784            eprintln!();
785            if sig.likely_oom {
786                eprintln!(
787                    "Build process was killed by {} (signal {}) - likely out of memory",
788                    sig.signal_name, sig.signal_number
789                );
790            } else {
791                eprintln!(
792                    "Build process was killed by {} (signal {})",
793                    sig.signal_name, sig.signal_number
794                );
795            }
796        }
797
798        // Command
799        if let Some(ref cmd) = self.command {
800            eprintln!();
801            eprintln!("Command: {cmd}");
802        }
803
804        // Worker and timing
805        if let Some(ref worker) = self.worker_name {
806            let location = if self.is_remote { "remote" } else { "local" };
807            eprintln!("Worker: {worker} ({location})");
808        }
809
810        if let (Some(dur), Some(timeout)) = (self.duration, self.timeout) {
811            eprintln!(
812                "Duration: {}s (timeout: {}s)",
813                dur.as_secs(),
814                timeout.as_secs()
815            );
816        } else if let Some(dur) = self.duration {
817            eprintln!("Duration: {}s", dur.as_secs());
818        }
819
820        // Exit code
821        if let Some(code) = self.exit_code {
822            eprintln!("Exit code: {code}");
823        }
824
825        // Toolchain and workdir
826        if let Some(ref tc) = self.toolchain {
827            eprintln!("Toolchain: {tc}");
828        }
829        if let Some(ref wd) = self.workdir {
830            eprintln!("Working directory: {wd}");
831        }
832        if let Some(ref path) = self.artifact_path {
833            eprintln!("Artifact path: {path}");
834        }
835
836        // Last output
837        if let Some(ref output) = self.last_output {
838            eprintln!();
839            eprintln!("Last output:");
840            eprintln!("  {output}");
841        }
842
843        // Worker resources
844        if self.resources.has_data() {
845            eprintln!();
846            eprintln!("Worker state at error:");
847            eprintln!("  {}", self.resources.format_line());
848        }
849
850        // Signal details
851        if let Some(ref sig) = self.signal_info
852            && let Some(ref details) = sig.details
853        {
854            eprintln!();
855            eprintln!("Signal details: {details}");
856        }
857
858        // Error chain
859        if !self.caused_by.is_empty() {
860            eprintln!();
861            eprintln!("Caused by:");
862            for cause in &self.caused_by {
863                eprintln!("  {cause}");
864            }
865        }
866
867        // Remediation
868        if !entry.remediation.is_empty() {
869            eprintln!();
870            eprintln!("Suggestions:");
871            for (i, step) in entry.remediation.iter().enumerate() {
872                eprintln!("  {}. {step}", i + 1);
873            }
874        }
875    }
876
877    // ========================================================================
878    // JSON SERIALIZATION
879    // ========================================================================
880
881    /// Serialize to JSON string.
882    pub fn to_json(&self) -> serde_json::Result<String> {
883        serde_json::to_string_pretty(self)
884    }
885
886    /// Serialize to compact JSON string.
887    pub fn to_json_compact(&self) -> serde_json::Result<String> {
888        serde_json::to_string(self)
889    }
890}
891
892impl std::fmt::Display for BuildErrorDisplay {
893    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
894        let entry = self.error_code.entry();
895        write!(f, "[ERROR] {}: {}", entry.code, entry.message)?;
896        if let Some(ref msg) = self.custom_message {
897            write!(f, " - {msg}")?;
898        }
899        Ok(())
900    }
901}
902
903impl std::error::Error for BuildErrorDisplay {}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908
909    #[test]
910    fn test_compilation_failed_creation() {
911        let display = BuildErrorDisplay::compilation_failed("cargo build");
912        assert_eq!(display.error_code, ErrorCode::BuildCompilationFailed);
913        assert_eq!(display.command, Some("cargo build".to_string()));
914    }
915
916    #[test]
917    fn test_build_timeout_creation() {
918        let display = BuildErrorDisplay::build_timeout("cargo build --release")
919            .worker("build1")
920            .duration_secs(300)
921            .timeout_secs(300);
922
923        assert_eq!(display.error_code, ErrorCode::BuildTimeout);
924        assert_eq!(display.command, Some("cargo build --release".to_string()));
925        assert_eq!(display.worker_name, Some("build1".to_string()));
926        assert_eq!(display.duration, Some(Duration::from_secs(300)));
927        assert_eq!(display.timeout, Some(Duration::from_secs(300)));
928    }
929
930    #[test]
931    fn test_killed_by_signal() {
932        let display = BuildErrorDisplay::killed_by_signal(9);
933        assert_eq!(display.error_code, ErrorCode::BuildKilledBySignal);
934        assert!(display.signal_info.is_some());
935
936        let sig = display.signal_info.unwrap();
937        assert_eq!(sig.signal_number, 9);
938        assert_eq!(sig.signal_name, "SIGKILL");
939        assert!(sig.likely_oom);
940    }
941
942    #[test]
943    fn test_killed_from_exit_code() {
944        // Exit code 137 = 128 + 9 (SIGKILL)
945        let display = BuildErrorDisplay::killed_from_exit_code(137);
946        assert_eq!(display.error_code, ErrorCode::BuildKilledBySignal);
947        assert_eq!(display.exit_code, Some(137));
948
949        let sig = display.signal_info.unwrap();
950        assert_eq!(sig.signal_number, 9);
951    }
952
953    #[test]
954    fn test_killed_from_regular_exit_code() {
955        // Regular exit code (not signal-based)
956        let display = BuildErrorDisplay::killed_from_exit_code(1);
957        assert_eq!(display.error_code, ErrorCode::BuildCompilationFailed);
958        assert!(display.signal_info.is_none());
959    }
960
961    #[test]
962    fn test_worker_resources() {
963        let display = BuildErrorDisplay::build_timeout("cargo build")
964            .cpu_usage(98.0)
965            .memory_usage(14.2, 16.0)
966            .load_average(8.5)
967            .disk_usage(75.0);
968
969        assert!(display.resources.has_data());
970        assert_eq!(display.resources.cpu_percent, Some(98.0));
971        assert_eq!(display.resources.memory_used_gb, Some(14.2));
972        assert_eq!(display.resources.memory_total_gb, Some(16.0));
973        assert_eq!(display.resources.load_average, Some(8.5));
974        assert_eq!(display.resources.disk_percent, Some(75.0));
975    }
976
977    #[test]
978    fn test_resource_format_line() {
979        let resources = WorkerResourceState {
980            cpu_percent: Some(98.0),
981            memory_used_gb: Some(14.2),
982            memory_total_gb: Some(16.0),
983            load_average: Some(8.5),
984            ..Default::default()
985        };
986
987        let line = resources.format_line();
988        assert!(line.contains("CPU: 98%"));
989        assert!(line.contains("Memory: 14.2/16.0 GB"));
990        assert!(line.contains("Load: 8.5"));
991    }
992
993    #[test]
994    fn test_artifact_missing() {
995        let display = BuildErrorDisplay::artifact_missing("target/release/myapp");
996        assert_eq!(display.error_code, ErrorCode::BuildArtifactMissing);
997        assert_eq!(
998            display.artifact_path,
999            Some("target/release/myapp".to_string())
1000        );
1001    }
1002
1003    #[test]
1004    fn test_toolchain_error() {
1005        let display = BuildErrorDisplay::toolchain_error("nightly-2024-01-15");
1006        assert_eq!(display.error_code, ErrorCode::BuildToolchainError);
1007        assert_eq!(display.toolchain, Some("nightly-2024-01-15".to_string()));
1008    }
1009
1010    #[test]
1011    fn test_workdir_error() {
1012        let display = BuildErrorDisplay::workdir_error("/tmp/rch/project");
1013        assert_eq!(display.error_code, ErrorCode::BuildWorkdirError);
1014        assert_eq!(display.workdir, Some("/tmp/rch/project".to_string()));
1015    }
1016
1017    #[test]
1018    fn test_builder_chain() {
1019        let display = BuildErrorDisplay::compilation_failed("cargo test")
1020            .worker("build2")
1021            .duration_secs(45)
1022            .exit_code(101)
1023            .last_output("test result: FAILED. 3 passed; 2 failed;")
1024            .caused_by("Test assertion failed")
1025            .message("Tests failed on remote worker");
1026
1027        assert_eq!(display.worker_name, Some("build2".to_string()));
1028        assert_eq!(display.duration, Some(Duration::from_secs(45)));
1029        assert_eq!(display.exit_code, Some(101));
1030        assert!(display.last_output.is_some());
1031        assert_eq!(display.caused_by.len(), 1);
1032        assert!(display.custom_message.is_some());
1033    }
1034
1035    #[test]
1036    fn test_local_build() {
1037        let display = BuildErrorDisplay::compilation_failed("cargo build").local();
1038        assert!(!display.is_remote);
1039    }
1040
1041    #[test]
1042    fn test_mark_oom() {
1043        let display = BuildErrorDisplay::killed_by_signal(9)
1044            .mark_oom("OOM killer triggered at 15.9GB memory usage");
1045
1046        let sig = display.signal_info.unwrap();
1047        assert!(sig.likely_oom);
1048        assert!(sig.details.is_some());
1049        assert!(sig.details.unwrap().contains("OOM killer"));
1050    }
1051
1052    #[test]
1053    fn test_to_error_panel() {
1054        let display = BuildErrorDisplay::build_timeout("cargo build --release")
1055            .worker("build1")
1056            .duration_secs(300)
1057            .timeout_secs(300);
1058
1059        let panel = display.to_error_panel();
1060        assert_eq!(panel.code, "RCH-E303");
1061    }
1062
1063    #[test]
1064    fn test_json_serialization() {
1065        let display = BuildErrorDisplay::build_timeout("cargo build")
1066            .worker("build1")
1067            .cpu_usage(95.0);
1068
1069        let json = display.to_json().expect("JSON serialization failed");
1070        assert!(json.contains("cargo build"));
1071        assert!(json.contains("build1"));
1072        assert!(json.contains("95"));
1073    }
1074
1075    #[test]
1076    fn test_json_compact() {
1077        let display = BuildErrorDisplay::compilation_failed("cargo test");
1078        let json = display
1079            .to_json_compact()
1080            .expect("JSON serialization failed");
1081        assert!(!json.contains('\n'));
1082    }
1083
1084    #[test]
1085    fn test_display_implementation() {
1086        let display =
1087            BuildErrorDisplay::build_timeout("cargo build").message("Custom timeout message");
1088
1089        let output = format!("{display}");
1090        assert!(output.contains("RCH-E303"));
1091        assert!(output.contains("Custom timeout message"));
1092    }
1093
1094    #[test]
1095    fn test_render_plain_no_panic() {
1096        let display = BuildErrorDisplay::build_timeout("cargo build --release")
1097            .worker("build1")
1098            .duration_secs(300)
1099            .timeout_secs(300)
1100            .last_output("Compiling serde_derive v1.0.152")
1101            .cpu_usage(98.0)
1102            .memory_usage(14.2, 16.0);
1103
1104        // Should not panic
1105        display.render(OutputContext::Plain);
1106    }
1107
1108    #[test]
1109    fn test_render_machine_silent() {
1110        let display = BuildErrorDisplay::compilation_failed("cargo build");
1111        // Should not output anything in machine mode
1112        display.render(OutputContext::Machine);
1113    }
1114
1115    #[test]
1116    fn test_signal_info_from_exit_code_invalid() {
1117        // Too low
1118        assert!(SignalInfo::from_exit_code(128).is_none());
1119        // Too high
1120        assert!(SignalInfo::from_exit_code(200).is_none());
1121        // Valid range
1122        assert!(SignalInfo::from_exit_code(137).is_some()); // 128 + 9
1123        assert!(SignalInfo::from_exit_code(143).is_some()); // 128 + 15
1124    }
1125
1126    #[test]
1127    fn test_all_error_constructors() {
1128        assert_eq!(
1129            BuildErrorDisplay::compilation_failed("cmd").error_code,
1130            ErrorCode::BuildCompilationFailed
1131        );
1132        assert_eq!(
1133            BuildErrorDisplay::unknown_command("cmd").error_code,
1134            ErrorCode::BuildUnknownCommand
1135        );
1136        assert_eq!(
1137            BuildErrorDisplay::killed_by_signal(9).error_code,
1138            ErrorCode::BuildKilledBySignal
1139        );
1140        assert_eq!(
1141            BuildErrorDisplay::build_timeout("cmd").error_code,
1142            ErrorCode::BuildTimeout
1143        );
1144        assert_eq!(
1145            BuildErrorDisplay::output_error().error_code,
1146            ErrorCode::BuildOutputError
1147        );
1148        assert_eq!(
1149            BuildErrorDisplay::workdir_error("path").error_code,
1150            ErrorCode::BuildWorkdirError
1151        );
1152        assert_eq!(
1153            BuildErrorDisplay::toolchain_error("nightly").error_code,
1154            ErrorCode::BuildToolchainError
1155        );
1156        assert_eq!(
1157            BuildErrorDisplay::env_error().error_code,
1158            ErrorCode::BuildEnvError
1159        );
1160        assert_eq!(
1161            BuildErrorDisplay::incremental_error().error_code,
1162            ErrorCode::BuildIncrementalError
1163        );
1164        assert_eq!(
1165            BuildErrorDisplay::artifact_missing("path").error_code,
1166            ErrorCode::BuildArtifactMissing
1167        );
1168    }
1169
1170    #[test]
1171    fn test_error_trait() {
1172        let display: Box<dyn std::error::Error> =
1173            Box::new(BuildErrorDisplay::compilation_failed("cargo build"));
1174        let _ = format!("{display}");
1175    }
1176
1177    #[test]
1178    fn test_empty_resources() {
1179        let resources = WorkerResourceState::default();
1180        assert!(!resources.has_data());
1181        assert!(resources.format_line().is_empty());
1182    }
1183
1184    #[test]
1185    fn test_partial_resources() {
1186        let resources = WorkerResourceState {
1187            cpu_percent: Some(50.0),
1188            ..Default::default()
1189        };
1190        assert!(resources.has_data());
1191
1192        let line = resources.format_line();
1193        assert!(line.contains("CPU: 50%"));
1194        assert!(!line.contains("Memory"));
1195    }
1196
1197    #[test]
1198    fn test_compiler_output_passthrough() {
1199        let display = BuildErrorDisplay::compilation_failed("cargo build")
1200            .worker("build1")
1201            .compiler_output("error[E0308]: mismatched types\n  --> src/main.rs:5:5");
1202
1203        assert!(display.compiler_output.is_some());
1204    }
1205}