Skip to main content

rch_common/ui/errors/
config.rs

1//! ConfigErrorDisplay - Specialized display for configuration errors.
2//!
3//! This module provides rich error display for configuration-related issues,
4//! including TOML parse errors, missing files, invalid values, and permission
5//! issues.
6//!
7//! # Features
8//!
9//! - Parses `toml::de::Error` for location extraction
10//! - Shows file content snippets with error highlighting
11//! - Displays expected vs actual values for type mismatches
12//! - Includes config file search paths when file not found
13//! - Suggests `rch config init` for missing configs
14//! - Supports JSON serialization for structured output
15//!
16//! # Example
17//!
18//! ```ignore
19//! use rch_common::ui::errors::ConfigErrorDisplay;
20//! use rch_common::ui::OutputContext;
21//!
22//! let display = ConfigErrorDisplay::parse_error("/home/user/.config/rch/config.toml")
23//!     .line(13)
24//!     .column(15)
25//!     .snippet("timeout = \"thirty\"")
26//!     .expected("integer")
27//!     .actual("string");
28//!
29//! display.render(OutputContext::detect());
30//! ```
31
32use serde::{Deserialize, Serialize};
33use std::path::PathBuf;
34
35use crate::errors::catalog::ErrorCode;
36use crate::ui::{ErrorPanel, Icons, OutputContext};
37
38#[cfg(all(feature = "rich-ui", unix))]
39use crate::ui::RchTheme;
40
41#[cfg(all(feature = "rich-ui", unix))]
42use rich_rust::r#box::HEAVY;
43#[cfg(all(feature = "rich-ui", unix))]
44use rich_rust::prelude::*;
45
46/// Location within a configuration file.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ConfigLocation {
49    /// Path to the config file.
50    pub file_path: PathBuf,
51    /// Line number (1-indexed).
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub line: Option<usize>,
54    /// Column number (1-indexed).
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub column: Option<usize>,
57    /// Key path within the config (e.g., "workers.0.host").
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub key_path: Option<String>,
60}
61
62impl ConfigLocation {
63    /// Create a new config location.
64    #[must_use]
65    pub fn new(file_path: impl Into<PathBuf>) -> Self {
66        Self {
67            file_path: file_path.into(),
68            line: None,
69            column: None,
70            key_path: None,
71        }
72    }
73
74    /// Set line number.
75    #[must_use]
76    pub fn at_line(mut self, line: usize) -> Self {
77        self.line = Some(line);
78        self
79    }
80
81    /// Set column number.
82    #[must_use]
83    pub fn at_column(mut self, column: usize) -> Self {
84        self.column = Some(column);
85        self
86    }
87
88    /// Set key path.
89    #[must_use]
90    pub fn at_key(mut self, key_path: impl Into<String>) -> Self {
91        self.key_path = Some(key_path.into());
92        self
93    }
94
95    /// Format the location for display.
96    #[must_use]
97    pub fn format(&self) -> String {
98        let path = self.file_path.display();
99        match (self.line, self.column) {
100            (Some(line), Some(col)) => format!("{path}:{line}:{col}"),
101            (Some(line), None) => format!("{path}:{line}"),
102            _ => format!("{path}"),
103        }
104    }
105}
106
107/// A snippet of config file content with context.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ConfigSnippet {
110    /// Lines of the snippet with their line numbers.
111    pub lines: Vec<SnippetLine>,
112    /// The line number that contains the error.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub error_line: Option<usize>,
115    /// Column range to highlight on the error line.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub highlight_range: Option<(usize, usize)>,
118}
119
120/// A single line in a config snippet.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct SnippetLine {
123    /// Line number (1-indexed).
124    pub line_num: usize,
125    /// Line content.
126    pub content: String,
127    /// Whether this is the error line.
128    #[serde(default)]
129    pub is_error_line: bool,
130}
131
132impl ConfigSnippet {
133    /// Create a snippet from file content around a specific line.
134    #[must_use]
135    pub fn from_content(content: &str, error_line: usize, context_lines: usize) -> Self {
136        let all_lines: Vec<&str> = content.lines().collect();
137        let start = error_line.saturating_sub(context_lines + 1);
138        let end = (error_line + context_lines).min(all_lines.len());
139
140        let lines = all_lines[start..end]
141            .iter()
142            .enumerate()
143            .map(|(i, line)| {
144                let line_num = start + i + 1;
145                SnippetLine {
146                    line_num,
147                    content: (*line).to_string(),
148                    is_error_line: line_num == error_line,
149                }
150            })
151            .collect();
152
153        Self {
154            lines,
155            error_line: Some(error_line),
156            highlight_range: None,
157        }
158    }
159
160    /// Create a snippet from a single line.
161    #[must_use]
162    pub fn single_line(line_num: usize, content: impl Into<String>) -> Self {
163        Self {
164            lines: vec![SnippetLine {
165                line_num,
166                content: content.into(),
167                is_error_line: true,
168            }],
169            error_line: Some(line_num),
170            highlight_range: None,
171        }
172    }
173
174    /// Set the highlight range on the error line.
175    #[must_use]
176    pub fn with_highlight(mut self, start: usize, end: usize) -> Self {
177        self.highlight_range = Some((start, end));
178        self
179    }
180
181    /// Check if the snippet has content.
182    #[must_use]
183    pub fn is_empty(&self) -> bool {
184        self.lines.is_empty()
185    }
186}
187
188/// Type mismatch information for validation errors.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct TypeMismatch {
191    /// Expected type/format.
192    pub expected: String,
193    /// Actual type/value found.
194    pub actual: String,
195    /// Example of a valid value.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub example: Option<String>,
198}
199
200/// Config search paths for "file not found" errors.
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct ConfigSearchPaths {
203    /// Paths that were searched.
204    pub searched: Vec<PathBuf>,
205    /// First path found (if any).
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub found: Option<PathBuf>,
208}
209
210impl ConfigSearchPaths {
211    /// Create with a list of searched paths.
212    #[must_use]
213    pub fn new(paths: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
214        Self {
215            searched: paths.into_iter().map(Into::into).collect(),
216            found: None,
217        }
218    }
219
220    /// Mark one of the paths as found.
221    #[must_use]
222    pub fn with_found(mut self, path: impl Into<PathBuf>) -> Self {
223        self.found = Some(path.into());
224        self
225    }
226}
227
228/// ConfigErrorDisplay - Rich error display for configuration errors.
229///
230/// Builds on [`ErrorPanel`] with config-specific context:
231/// - File location (path, line, column)
232/// - Content snippets with highlighting
233/// - Expected vs actual values
234/// - Config search paths
235/// - Environment variable issues
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ConfigErrorDisplay {
238    /// The underlying error code.
239    pub error_code: ErrorCode,
240
241    /// Location in the config file.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub location: Option<ConfigLocation>,
244
245    /// Code snippet showing the error.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub snippet: Option<ConfigSnippet>,
248
249    /// Type mismatch details.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub type_mismatch: Option<TypeMismatch>,
252
253    /// Search paths for "not found" errors.
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub search_paths: Option<ConfigSearchPaths>,
256
257    /// Environment variable name (for env errors).
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub env_var_name: Option<String>,
260
261    /// Environment variable value (for env errors).
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub env_var_value: Option<String>,
264
265    /// Profile name (for profile not found).
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub profile_name: Option<String>,
268
269    /// Worker ID (for worker config errors).
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub worker_id: Option<String>,
272
273    /// SSH key path (for key errors).
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub ssh_key_path: Option<PathBuf>,
276
277    /// Socket path (for socket errors).
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub socket_path: Option<PathBuf>,
280
281    /// Permission mode (for permission errors).
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub permission_mode: Option<String>,
284
285    /// Required permission mode.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub required_mode: Option<String>,
288
289    /// Raw error message from TOML parser or IO.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub raw_error: Option<String>,
292
293    /// Error chain (caused by).
294    #[serde(default, skip_serializing_if = "Vec::is_empty")]
295    pub caused_by: Vec<String>,
296
297    /// Custom message override.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub custom_message: Option<String>,
300}
301
302impl ConfigErrorDisplay {
303    // ========================================================================
304    // CONSTRUCTORS FOR SPECIFIC ERROR TYPES
305    // ========================================================================
306
307    /// Create display for config file not found (E001).
308    #[must_use]
309    pub fn not_found(searched_path: impl Into<PathBuf>) -> Self {
310        let path: PathBuf = searched_path.into();
311        let mut display = Self::new(ErrorCode::ConfigNotFound);
312        display.location = Some(ConfigLocation::new(&path));
313        display
314    }
315
316    /// Create display for config read error (E002).
317    #[must_use]
318    pub fn read_error(file_path: impl Into<PathBuf>) -> Self {
319        let path: PathBuf = file_path.into();
320        let mut display = Self::new(ErrorCode::ConfigReadError);
321        display.location = Some(ConfigLocation::new(&path));
322        display
323    }
324
325    /// Create display for TOML parse error (E003).
326    #[must_use]
327    pub fn parse_error(file_path: impl Into<PathBuf>) -> Self {
328        let path: PathBuf = file_path.into();
329        let mut display = Self::new(ErrorCode::ConfigParseError);
330        display.location = Some(ConfigLocation::new(&path));
331        display
332    }
333
334    /// Create display for validation error (E004).
335    #[must_use]
336    pub fn validation_error(file_path: impl Into<PathBuf>) -> Self {
337        let path: PathBuf = file_path.into();
338        let mut display = Self::new(ErrorCode::ConfigValidationError);
339        display.location = Some(ConfigLocation::new(&path));
340        display
341    }
342
343    /// Create display for environment variable error (E005).
344    #[must_use]
345    pub fn env_error(var_name: impl Into<String>) -> Self {
346        let mut display = Self::new(ErrorCode::ConfigEnvError);
347        display.env_var_name = Some(var_name.into());
348        display
349    }
350
351    /// Create display for profile not found error (E006).
352    #[must_use]
353    pub fn profile_not_found(profile_name: impl Into<String>) -> Self {
354        let mut display = Self::new(ErrorCode::ConfigProfileNotFound);
355        display.profile_name = Some(profile_name.into());
356        display
357    }
358
359    /// Create display for no workers configured error (E007).
360    #[must_use]
361    pub fn no_workers(config_path: impl Into<PathBuf>) -> Self {
362        let path: PathBuf = config_path.into();
363        let mut display = Self::new(ErrorCode::ConfigNoWorkers);
364        display.location = Some(ConfigLocation::new(&path));
365        display
366    }
367
368    /// Create display for invalid worker error (E008).
369    #[must_use]
370    pub fn invalid_worker(worker_id: impl Into<String>) -> Self {
371        let mut display = Self::new(ErrorCode::ConfigInvalidWorker);
372        display.worker_id = Some(worker_id.into());
373        display
374    }
375
376    /// Create display for SSH key error (E009).
377    #[must_use]
378    pub fn ssh_key_error(key_path: impl Into<PathBuf>) -> Self {
379        let path: PathBuf = key_path.into();
380        let mut display = Self::new(ErrorCode::ConfigSshKeyError);
381        display.ssh_key_path = Some(path);
382        display
383    }
384
385    /// Create display for socket path error (E010).
386    #[must_use]
387    pub fn socket_path_error(socket_path: impl Into<PathBuf>) -> Self {
388        let path: PathBuf = socket_path.into();
389        let mut display = Self::new(ErrorCode::ConfigSocketPathError);
390        display.socket_path = Some(path);
391        display
392    }
393
394    // ========================================================================
395    // CORE CONSTRUCTOR
396    // ========================================================================
397
398    /// Create a new ConfigErrorDisplay with error code.
399    #[must_use]
400    fn new(error_code: ErrorCode) -> Self {
401        Self {
402            error_code,
403            location: None,
404            snippet: None,
405            type_mismatch: None,
406            search_paths: None,
407            env_var_name: None,
408            env_var_value: None,
409            profile_name: None,
410            worker_id: None,
411            ssh_key_path: None,
412            socket_path: None,
413            permission_mode: None,
414            required_mode: None,
415            raw_error: None,
416            caused_by: Vec::new(),
417            custom_message: None,
418        }
419    }
420
421    // ========================================================================
422    // BUILDER METHODS
423    // ========================================================================
424
425    /// Set the line number.
426    #[must_use]
427    pub fn line(mut self, line: usize) -> Self {
428        if let Some(ref mut loc) = self.location {
429            loc.line = Some(line);
430        }
431        self
432    }
433
434    /// Set the column number.
435    #[must_use]
436    pub fn column(mut self, column: usize) -> Self {
437        if let Some(ref mut loc) = self.location {
438            loc.column = Some(column);
439        }
440        self
441    }
442
443    /// Set the key path within the config.
444    #[must_use]
445    pub fn key_path(mut self, key: impl Into<String>) -> Self {
446        if let Some(ref mut loc) = self.location {
447            loc.key_path = Some(key.into());
448        }
449        self
450    }
451
452    /// Set a single-line snippet.
453    #[must_use]
454    pub fn snippet(mut self, content: impl Into<String>) -> Self {
455        let line_num = self.location.as_ref().and_then(|l| l.line).unwrap_or(1);
456        self.snippet = Some(ConfigSnippet::single_line(line_num, content));
457        self
458    }
459
460    /// Set a multi-line snippet from file content.
461    #[must_use]
462    pub fn snippet_from_content(mut self, content: &str, context_lines: usize) -> Self {
463        if let Some(line) = self.location.as_ref().and_then(|l| l.line) {
464            self.snippet = Some(ConfigSnippet::from_content(content, line, context_lines));
465        }
466        self
467    }
468
469    /// Set the expected type/value.
470    #[must_use]
471    pub fn expected(mut self, expected: impl Into<String>) -> Self {
472        if let Some(ref mut tm) = self.type_mismatch {
473            tm.expected = expected.into();
474        } else {
475            self.type_mismatch = Some(TypeMismatch {
476                expected: expected.into(),
477                actual: String::new(),
478                example: None,
479            });
480        }
481        self
482    }
483
484    /// Set the actual type/value found.
485    #[must_use]
486    pub fn actual(mut self, actual: impl Into<String>) -> Self {
487        if let Some(ref mut tm) = self.type_mismatch {
488            tm.actual = actual.into();
489        } else {
490            self.type_mismatch = Some(TypeMismatch {
491                expected: String::new(),
492                actual: actual.into(),
493                example: None,
494            });
495        }
496        self
497    }
498
499    /// Set an example of a valid value.
500    #[must_use]
501    pub fn example(mut self, example: impl Into<String>) -> Self {
502        if let Some(ref mut tm) = self.type_mismatch {
503            tm.example = Some(example.into());
504        }
505        self
506    }
507
508    /// Set config search paths.
509    #[must_use]
510    pub fn search_paths(mut self, paths: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
511        self.search_paths = Some(ConfigSearchPaths::new(paths));
512        self
513    }
514
515    /// Set the environment variable value.
516    #[must_use]
517    pub fn env_value(mut self, value: impl Into<String>) -> Self {
518        self.env_var_value = Some(value.into());
519        self
520    }
521
522    /// Set permission mode info.
523    #[must_use]
524    pub fn permission(mut self, current: impl Into<String>, required: impl Into<String>) -> Self {
525        self.permission_mode = Some(current.into());
526        self.required_mode = Some(required.into());
527        self
528    }
529
530    /// Set the raw error message.
531    #[must_use]
532    pub fn raw_error(mut self, error: impl Into<String>) -> Self {
533        self.raw_error = Some(error.into());
534        self
535    }
536
537    /// Set a custom message.
538    #[must_use]
539    pub fn message(mut self, message: impl Into<String>) -> Self {
540        self.custom_message = Some(message.into());
541        self
542    }
543
544    /// Add a caused-by entry.
545    #[must_use]
546    pub fn caused_by(mut self, cause: impl Into<String>) -> Self {
547        self.caused_by.push(cause.into());
548        self
549    }
550
551    /// Set location from a toml parse error.
552    #[must_use]
553    pub fn from_toml_error(mut self, error: &toml::de::Error) -> Self {
554        if let Some(span) = error.span() {
555            // TOML spans are byte offsets; we need line/col info
556            // For now, just capture the raw error message
557            if let Some(ref mut loc) = self.location {
558                loc.line = Some(span.start);
559            }
560        }
561        self.raw_error = Some(error.message().to_string());
562        self
563    }
564
565    /// Set location from an IO error.
566    #[must_use]
567    pub fn from_io_error(mut self, error: &std::io::Error) -> Self {
568        self.raw_error = Some(error.to_string());
569        if let std::io::ErrorKind::PermissionDenied = error.kind() {
570            // Add permission context
571            self.permission_mode = Some("unknown".to_string());
572        }
573        self
574    }
575
576    // ========================================================================
577    // CONVERSION TO ErrorPanel
578    // ========================================================================
579
580    /// Convert to an ErrorPanel for rendering.
581    #[must_use]
582    pub fn to_error_panel(&self) -> ErrorPanel {
583        let entry = self.error_code.entry();
584
585        let mut panel = ErrorPanel::error(&entry.code, &entry.message);
586
587        // Set custom message if provided
588        if let Some(ref msg) = self.custom_message {
589            panel = panel.message(msg.clone());
590        }
591
592        // Add file location
593        if let Some(ref loc) = self.location {
594            panel = panel.context("File", loc.format());
595            if let Some(ref key) = loc.key_path {
596                panel = panel.context("Key", key.clone());
597            }
598        }
599
600        // Add type mismatch info
601        if let Some(ref tm) = self.type_mismatch {
602            if !tm.expected.is_empty() {
603                panel = panel.context("Expected", tm.expected.clone());
604            }
605            if !tm.actual.is_empty() {
606                panel = panel.context("Found", tm.actual.clone());
607            }
608            if let Some(ref example) = tm.example {
609                panel = panel.context("Example", example.clone());
610            }
611        }
612
613        // Add env var info
614        if let Some(ref name) = self.env_var_name {
615            panel = panel.context("Variable", name.clone());
616            if let Some(ref value) = self.env_var_value {
617                panel = panel.context("Value", value.clone());
618            }
619        }
620
621        // Add profile info
622        if let Some(ref profile) = self.profile_name {
623            panel = panel.context("Profile", profile.clone());
624        }
625
626        // Add worker info
627        if let Some(ref worker) = self.worker_id {
628            panel = panel.context("Worker", worker.clone());
629        }
630
631        // Add SSH key path
632        if let Some(ref path) = self.ssh_key_path {
633            panel = panel.context("SSH Key", path.display().to_string());
634        }
635
636        // Add socket path
637        if let Some(ref path) = self.socket_path {
638            panel = panel.context("Socket", path.display().to_string());
639        }
640
641        // Add permission info
642        if let (Some(current), Some(required)) = (&self.permission_mode, &self.required_mode) {
643            panel = panel.context("Permissions", format!("{current} (need {required})"));
644        }
645
646        // Add caused-by chain
647        for cause in &self.caused_by {
648            panel = panel.caused_by(cause.clone(), None);
649        }
650
651        // Add remediation from catalog
652        for step in entry.remediation {
653            panel = panel.suggestion(step);
654        }
655
656        panel
657    }
658
659    // ========================================================================
660    // RENDERING
661    // ========================================================================
662
663    /// Render the error to stderr.
664    pub fn render(&self, ctx: OutputContext) {
665        if ctx.is_machine() {
666            // Machine mode - caller should use to_json()
667            return;
668        }
669
670        #[cfg(all(feature = "rich-ui", unix))]
671        if ctx.supports_rich() {
672            self.render_rich(ctx);
673            return;
674        }
675
676        self.render_plain(ctx);
677    }
678
679    /// Render using rich_rust Panel.
680    #[cfg(all(feature = "rich-ui", unix))]
681    fn render_rich(&self, ctx: OutputContext) {
682        let content = self.build_rich_content(ctx);
683        let entry = self.error_code.entry();
684        let icon = Icons::cross(ctx);
685        let title_text = format!("{icon} {}: {}", entry.code, entry.message);
686
687        let border_color = Color::parse(RchTheme::ERROR).unwrap_or_else(|_| Color::default());
688        let border_style = Style::new().bold().color(border_color);
689
690        let panel = Panel::from_text(&content)
691            .title(title_text.as_str())
692            .border_style(border_style)
693            .box_style(&HEAVY);
694
695        let console = Console::builder().force_terminal(true).build();
696        console.print_renderable(&panel);
697    }
698
699    /// Build rich content string.
700    #[cfg(all(feature = "rich-ui", unix))]
701    fn build_rich_content(&self, _ctx: OutputContext) -> String {
702        let mut lines = Vec::new();
703
704        // Custom message
705        if let Some(ref msg) = self.custom_message {
706            lines.push(msg.clone());
707        }
708
709        // File location
710        if let Some(ref loc) = self.location {
711            lines.push(String::new());
712            lines.push(format!("[{}]File:[/] {}", RchTheme::DIM, loc.format()));
713            if let Some(ref key) = loc.key_path {
714                lines.push(format!("[{}]Key:[/] {key}", RchTheme::DIM));
715            }
716        }
717
718        // Code snippet
719        if let Some(ref snippet) = self.snippet {
720            lines.push(String::new());
721            for line in &snippet.lines {
722                let prefix = if line.is_error_line {
723                    format!("[{}]→ {:>4} │[/] ", RchTheme::ERROR, line.line_num)
724                } else {
725                    format!("[{}]  {:>4} │[/] ", RchTheme::DIM, line.line_num)
726                };
727                lines.push(format!("{prefix}{}", line.content));
728            }
729        }
730
731        // Type mismatch
732        if let Some(ref tm) = self.type_mismatch {
733            lines.push(String::new());
734            if !tm.expected.is_empty() {
735                lines.push(format!("[{}]Expected:[/] {}", RchTheme::DIM, tm.expected));
736            }
737            if !tm.actual.is_empty() {
738                lines.push(format!("[{}]Found:[/] {}", RchTheme::ERROR, tm.actual));
739            }
740            if let Some(ref example) = tm.example {
741                lines.push(format!("[{}]Example:[/] {example}", RchTheme::SUCCESS));
742            }
743        }
744
745        // Search paths
746        if let Some(ref sp) = self.search_paths {
747            lines.push(String::new());
748            lines.push(format!("[{}]Searched locations:[/]", RchTheme::DIM));
749            for path in &sp.searched {
750                lines.push(format!("  • {}", path.display()));
751            }
752        }
753
754        // Environment variable
755        if let Some(ref name) = self.env_var_name {
756            lines.push(String::new());
757            lines.push(format!(
758                "[{}]Environment variable:[/] {name}",
759                RchTheme::DIM
760            ));
761            if let Some(ref value) = self.env_var_value {
762                lines.push(format!("[{}]Current value:[/] {value}", RchTheme::DIM));
763            }
764        }
765
766        // Permission info
767        if let (Some(current), Some(required)) = (&self.permission_mode, &self.required_mode) {
768            lines.push(String::new());
769            lines.push(format!(
770                "[{}]Permissions:[/] {current} (need {required})",
771                RchTheme::DIM
772            ));
773        }
774
775        // Raw error
776        if let Some(ref raw) = self.raw_error {
777            lines.push(String::new());
778            lines.push(format!("[{}]Parser message:[/]", RchTheme::DIM));
779            lines.push(format!("  {raw}"));
780        }
781
782        // Error chain
783        if !self.caused_by.is_empty() {
784            lines.push(String::new());
785            lines.push(format!("[{}]Caused by:[/]", RchTheme::DIM));
786            for cause in &self.caused_by {
787                lines.push(format!("  {cause}"));
788            }
789        }
790
791        // Remediation from catalog
792        let entry = self.error_code.entry();
793        if !entry.remediation.is_empty() {
794            lines.push(String::new());
795            lines.push(format!("[{}]Suggestions:[/]", RchTheme::SECONDARY));
796            for (i, step) in entry.remediation.iter().enumerate() {
797                lines.push(format!("  [{}]{}.[/] {step}", RchTheme::SECONDARY, i + 1));
798            }
799        }
800
801        lines.join("\n")
802    }
803
804    /// Render plain text to stderr.
805    fn render_plain(&self, ctx: OutputContext) {
806        let entry = self.error_code.entry();
807        let icon = Icons::cross(ctx);
808
809        // Header line
810        eprintln!("{icon} [ERROR] {}: {}", entry.code, entry.message);
811
812        // Custom message
813        if let Some(ref msg) = self.custom_message {
814            eprintln!();
815            eprintln!("{msg}");
816        }
817
818        // File location
819        if let Some(ref loc) = self.location {
820            eprintln!();
821            eprintln!("File: {}", loc.format());
822            if let Some(ref key) = loc.key_path {
823                eprintln!("Key: {key}");
824            }
825        }
826
827        // Code snippet
828        if let Some(ref snippet) = self.snippet {
829            eprintln!();
830            for line in &snippet.lines {
831                let prefix = if line.is_error_line {
832                    format!("→ {:>4} │ ", line.line_num)
833                } else {
834                    format!("  {:>4} │ ", line.line_num)
835                };
836                eprintln!("{prefix}{}", line.content);
837            }
838        }
839
840        // Type mismatch
841        if let Some(ref tm) = self.type_mismatch {
842            eprintln!();
843            if !tm.expected.is_empty() {
844                eprintln!("Expected: {}", tm.expected);
845            }
846            if !tm.actual.is_empty() {
847                eprintln!("Found: {}", tm.actual);
848            }
849            if let Some(ref example) = tm.example {
850                eprintln!("Example: {example}");
851            }
852        }
853
854        // Search paths
855        if let Some(ref sp) = self.search_paths {
856            eprintln!();
857            eprintln!("Searched locations:");
858            for path in &sp.searched {
859                eprintln!("  • {}", path.display());
860            }
861        }
862
863        // Environment variable
864        if let Some(ref name) = self.env_var_name {
865            eprintln!();
866            eprintln!("Environment variable: {name}");
867            if let Some(ref value) = self.env_var_value {
868                eprintln!("Current value: {value}");
869            }
870        }
871
872        // Profile info
873        if let Some(ref profile) = self.profile_name {
874            eprintln!();
875            eprintln!("Profile: {profile}");
876        }
877
878        // Worker info
879        if let Some(ref worker) = self.worker_id {
880            eprintln!();
881            eprintln!("Worker: {worker}");
882        }
883
884        // SSH key path
885        if let Some(ref path) = self.ssh_key_path {
886            eprintln!();
887            eprintln!("SSH Key: {}", path.display());
888        }
889
890        // Socket path
891        if let Some(ref path) = self.socket_path {
892            eprintln!();
893            eprintln!("Socket: {}", path.display());
894        }
895
896        // Permission info
897        if let (Some(current), Some(required)) = (&self.permission_mode, &self.required_mode) {
898            eprintln!();
899            eprintln!("Permissions: {current} (need {required})");
900        }
901
902        // Raw error
903        if let Some(ref raw) = self.raw_error {
904            eprintln!();
905            eprintln!("Parser message:");
906            eprintln!("  {raw}");
907        }
908
909        // Error chain
910        if !self.caused_by.is_empty() {
911            eprintln!();
912            eprintln!("Caused by:");
913            for cause in &self.caused_by {
914                eprintln!("  {cause}");
915            }
916        }
917
918        // Remediation
919        if !entry.remediation.is_empty() {
920            eprintln!();
921            eprintln!("Suggestions:");
922            for (i, step) in entry.remediation.iter().enumerate() {
923                eprintln!("  {}. {step}", i + 1);
924            }
925        }
926    }
927
928    // ========================================================================
929    // JSON SERIALIZATION
930    // ========================================================================
931
932    /// Serialize to JSON string.
933    pub fn to_json(&self) -> serde_json::Result<String> {
934        serde_json::to_string_pretty(self)
935    }
936
937    /// Serialize to compact JSON string.
938    pub fn to_json_compact(&self) -> serde_json::Result<String> {
939        serde_json::to_string(self)
940    }
941}
942
943impl std::fmt::Display for ConfigErrorDisplay {
944    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
945        let entry = self.error_code.entry();
946        write!(f, "[ERROR] {}: {}", entry.code, entry.message)?;
947        if let Some(ref loc) = self.location {
948            write!(f, " at {}", loc.format())?;
949        }
950        if let Some(ref msg) = self.custom_message {
951            write!(f, " - {msg}")?;
952        }
953        Ok(())
954    }
955}
956
957impl std::error::Error for ConfigErrorDisplay {}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962
963    #[test]
964    fn test_config_not_found() {
965        let display = ConfigErrorDisplay::not_found("/home/user/.config/rch/config.toml");
966        assert_eq!(display.error_code, ErrorCode::ConfigNotFound);
967        assert!(display.location.is_some());
968    }
969
970    #[test]
971    fn test_parse_error_with_location() {
972        let display = ConfigErrorDisplay::parse_error("/home/user/.config/rch/config.toml")
973            .line(13)
974            .column(15)
975            .snippet("timeout = \"thirty\"")
976            .expected("integer")
977            .actual("string");
978
979        assert_eq!(display.error_code, ErrorCode::ConfigParseError);
980        let loc = display.location.as_ref().unwrap();
981        assert_eq!(loc.line, Some(13));
982        assert_eq!(loc.column, Some(15));
983        assert!(display.snippet.is_some());
984        assert!(display.type_mismatch.is_some());
985    }
986
987    #[test]
988    fn test_validation_error() {
989        let display = ConfigErrorDisplay::validation_error("/home/user/.config/rch/workers.toml")
990            .key_path("workers.0.host")
991            .expected("non-empty string")
992            .actual("empty string")
993            .example("192.168.1.100");
994
995        assert_eq!(display.error_code, ErrorCode::ConfigValidationError);
996        let tm = display.type_mismatch.as_ref().unwrap();
997        assert_eq!(tm.expected, "non-empty string");
998        assert_eq!(tm.actual, "empty string");
999        assert_eq!(tm.example, Some("192.168.1.100".to_string()));
1000    }
1001
1002    #[test]
1003    fn test_env_error() {
1004        let display = ConfigErrorDisplay::env_error("RCH_WORKERS")
1005            .env_value("not a valid path")
1006            .message("Environment variable must be a valid file path");
1007
1008        assert_eq!(display.error_code, ErrorCode::ConfigEnvError);
1009        assert_eq!(display.env_var_name, Some("RCH_WORKERS".to_string()));
1010        assert_eq!(display.env_var_value, Some("not a valid path".to_string()));
1011    }
1012
1013    #[test]
1014    fn test_profile_not_found() {
1015        let display = ConfigErrorDisplay::profile_not_found("production");
1016        assert_eq!(display.error_code, ErrorCode::ConfigProfileNotFound);
1017        assert_eq!(display.profile_name, Some("production".to_string()));
1018    }
1019
1020    #[test]
1021    fn test_no_workers() {
1022        let display = ConfigErrorDisplay::no_workers("/home/user/.config/rch/workers.toml");
1023        assert_eq!(display.error_code, ErrorCode::ConfigNoWorkers);
1024    }
1025
1026    #[test]
1027    fn test_invalid_worker() {
1028        let display = ConfigErrorDisplay::invalid_worker("build-server-1")
1029            .message("Worker is missing required 'host' field");
1030        assert_eq!(display.error_code, ErrorCode::ConfigInvalidWorker);
1031        assert_eq!(display.worker_id, Some("build-server-1".to_string()));
1032    }
1033
1034    #[test]
1035    fn test_ssh_key_error() {
1036        let display = ConfigErrorDisplay::ssh_key_error("/home/user/.ssh/id_rsa")
1037            .permission("0644", "0600")
1038            .message("SSH key has incorrect permissions");
1039        assert_eq!(display.error_code, ErrorCode::ConfigSshKeyError);
1040        assert_eq!(display.permission_mode, Some("0644".to_string()));
1041        assert_eq!(display.required_mode, Some("0600".to_string()));
1042    }
1043
1044    #[test]
1045    fn test_socket_path_error() {
1046        let display = ConfigErrorDisplay::socket_path_error("/tmp/rch/socket")
1047            .message("Socket path parent directory does not exist");
1048        assert_eq!(display.error_code, ErrorCode::ConfigSocketPathError);
1049        assert!(display.socket_path.is_some());
1050    }
1051
1052    #[test]
1053    fn test_search_paths() {
1054        let display = ConfigErrorDisplay::not_found("/home/user/.config/rch/config.toml")
1055            .search_paths([
1056                "/home/user/.config/rch/config.toml",
1057                "/etc/rch/config.toml",
1058                "./.rch/config.toml",
1059            ]);
1060
1061        let sp = display.search_paths.as_ref().unwrap();
1062        assert_eq!(sp.searched.len(), 3);
1063    }
1064
1065    #[test]
1066    fn test_config_snippet_from_content() {
1067        let content = "[general]\nenabled = true\n\n[workers]\ntimeout = \"thirty\"\nretry = 3";
1068        let snippet = ConfigSnippet::from_content(content, 5, 1);
1069
1070        assert!(!snippet.is_empty());
1071        assert_eq!(snippet.error_line, Some(5));
1072        // Should have lines 4, 5, 6
1073        assert!(snippet.lines.len() >= 2);
1074    }
1075
1076    #[test]
1077    fn test_config_snippet_single_line() {
1078        let snippet = ConfigSnippet::single_line(13, "timeout = \"thirty\"");
1079        assert_eq!(snippet.lines.len(), 1);
1080        assert_eq!(snippet.lines[0].line_num, 13);
1081        assert!(snippet.lines[0].is_error_line);
1082    }
1083
1084    #[test]
1085    fn test_location_format() {
1086        let loc = ConfigLocation::new("/home/user/config.toml")
1087            .at_line(42)
1088            .at_column(10);
1089        assert_eq!(loc.format(), "/home/user/config.toml:42:10");
1090
1091        let loc2 = ConfigLocation::new("/home/user/config.toml").at_line(42);
1092        assert_eq!(loc2.format(), "/home/user/config.toml:42");
1093
1094        let loc3 = ConfigLocation::new("/home/user/config.toml");
1095        assert_eq!(loc3.format(), "/home/user/config.toml");
1096    }
1097
1098    #[test]
1099    fn test_to_error_panel() {
1100        let display = ConfigErrorDisplay::parse_error("/home/user/.config/rch/config.toml")
1101            .line(13)
1102            .expected("integer")
1103            .actual("string");
1104
1105        let panel = display.to_error_panel();
1106        assert_eq!(panel.code, "RCH-E003");
1107    }
1108
1109    #[test]
1110    fn test_json_serialization() {
1111        let display = ConfigErrorDisplay::parse_error("/home/user/config.toml")
1112            .line(10)
1113            .raw_error("expected '=' after key");
1114
1115        let json = display.to_json().expect("JSON serialization failed");
1116        assert!(
1117            json.contains("RCH-E003")
1118                || json.contains("ConfigParseError")
1119                || json.contains("CONFIG_PARSE_ERROR")
1120        );
1121        assert!(json.contains("config.toml"));
1122    }
1123
1124    #[test]
1125    fn test_json_compact() {
1126        let display = ConfigErrorDisplay::not_found("/path/to/config.toml");
1127        let json = display
1128            .to_json_compact()
1129            .expect("JSON serialization failed");
1130        assert!(!json.contains('\n'));
1131    }
1132
1133    #[test]
1134    fn test_display_implementation() {
1135        let display = ConfigErrorDisplay::parse_error("/home/user/config.toml")
1136            .line(42)
1137            .message("Unexpected character");
1138
1139        let output = format!("{display}");
1140        assert!(output.contains("RCH-E003"));
1141        assert!(output.contains("config.toml:42"));
1142        assert!(output.contains("Unexpected character"));
1143    }
1144
1145    #[test]
1146    fn test_error_trait() {
1147        let display: Box<dyn std::error::Error> =
1148            Box::new(ConfigErrorDisplay::not_found("/config.toml"));
1149        let _ = format!("{display}");
1150    }
1151
1152    #[test]
1153    fn test_render_plain_no_panic() {
1154        let display = ConfigErrorDisplay::parse_error("/home/user/.config/rch/config.toml")
1155            .line(13)
1156            .column(15)
1157            .snippet("timeout = \"thirty\"")
1158            .expected("integer")
1159            .actual("string")
1160            .raw_error("expected integer, found string");
1161
1162        // Should not panic
1163        display.render(OutputContext::Plain);
1164    }
1165
1166    #[test]
1167    fn test_render_machine_silent() {
1168        let display = ConfigErrorDisplay::not_found("/config.toml");
1169        // Should not output anything in machine mode
1170        display.render(OutputContext::Machine);
1171    }
1172
1173    #[test]
1174    fn test_caused_by_chain() {
1175        let display = ConfigErrorDisplay::read_error("/config.toml")
1176            .caused_by("IO error: file not found")
1177            .caused_by("Path does not exist");
1178
1179        assert_eq!(display.caused_by.len(), 2);
1180    }
1181
1182    #[test]
1183    fn test_all_error_constructors() {
1184        assert_eq!(
1185            ConfigErrorDisplay::not_found("path").error_code,
1186            ErrorCode::ConfigNotFound
1187        );
1188        assert_eq!(
1189            ConfigErrorDisplay::read_error("path").error_code,
1190            ErrorCode::ConfigReadError
1191        );
1192        assert_eq!(
1193            ConfigErrorDisplay::parse_error("path").error_code,
1194            ErrorCode::ConfigParseError
1195        );
1196        assert_eq!(
1197            ConfigErrorDisplay::validation_error("path").error_code,
1198            ErrorCode::ConfigValidationError
1199        );
1200        assert_eq!(
1201            ConfigErrorDisplay::env_error("VAR").error_code,
1202            ErrorCode::ConfigEnvError
1203        );
1204        assert_eq!(
1205            ConfigErrorDisplay::profile_not_found("profile").error_code,
1206            ErrorCode::ConfigProfileNotFound
1207        );
1208        assert_eq!(
1209            ConfigErrorDisplay::no_workers("path").error_code,
1210            ErrorCode::ConfigNoWorkers
1211        );
1212        assert_eq!(
1213            ConfigErrorDisplay::invalid_worker("id").error_code,
1214            ErrorCode::ConfigInvalidWorker
1215        );
1216        assert_eq!(
1217            ConfigErrorDisplay::ssh_key_error("path").error_code,
1218            ErrorCode::ConfigSshKeyError
1219        );
1220        assert_eq!(
1221            ConfigErrorDisplay::socket_path_error("path").error_code,
1222            ErrorCode::ConfigSocketPathError
1223        );
1224    }
1225
1226    #[test]
1227    fn test_read_error_with_io_error() {
1228        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
1229        let display = ConfigErrorDisplay::read_error("/etc/rch/config.toml").from_io_error(&io_err);
1230
1231        assert!(display.raw_error.is_some());
1232        assert!(display.raw_error.as_ref().unwrap().contains("permission"));
1233    }
1234
1235    #[test]
1236    fn test_snippet_highlight_range() {
1237        let snippet = ConfigSnippet::single_line(10, "timeout = \"thirty\"").with_highlight(10, 18);
1238        assert_eq!(snippet.highlight_range, Some((10, 18)));
1239    }
1240}