1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ConfigLocation {
49 pub file_path: PathBuf,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub line: Option<usize>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub column: Option<usize>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub key_path: Option<String>,
60}
61
62impl ConfigLocation {
63 #[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 #[must_use]
76 pub fn at_line(mut self, line: usize) -> Self {
77 self.line = Some(line);
78 self
79 }
80
81 #[must_use]
83 pub fn at_column(mut self, column: usize) -> Self {
84 self.column = Some(column);
85 self
86 }
87
88 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ConfigSnippet {
110 pub lines: Vec<SnippetLine>,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub error_line: Option<usize>,
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub highlight_range: Option<(usize, usize)>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct SnippetLine {
123 pub line_num: usize,
125 pub content: String,
127 #[serde(default)]
129 pub is_error_line: bool,
130}
131
132impl ConfigSnippet {
133 #[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 #[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 #[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 #[must_use]
183 pub fn is_empty(&self) -> bool {
184 self.lines.is_empty()
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct TypeMismatch {
191 pub expected: String,
193 pub actual: String,
195 #[serde(skip_serializing_if = "Option::is_none")]
197 pub example: Option<String>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct ConfigSearchPaths {
203 pub searched: Vec<PathBuf>,
205 #[serde(skip_serializing_if = "Option::is_none")]
207 pub found: Option<PathBuf>,
208}
209
210impl ConfigSearchPaths {
211 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ConfigErrorDisplay {
238 pub error_code: ErrorCode,
240
241 #[serde(skip_serializing_if = "Option::is_none")]
243 pub location: Option<ConfigLocation>,
244
245 #[serde(skip_serializing_if = "Option::is_none")]
247 pub snippet: Option<ConfigSnippet>,
248
249 #[serde(skip_serializing_if = "Option::is_none")]
251 pub type_mismatch: Option<TypeMismatch>,
252
253 #[serde(skip_serializing_if = "Option::is_none")]
255 pub search_paths: Option<ConfigSearchPaths>,
256
257 #[serde(skip_serializing_if = "Option::is_none")]
259 pub env_var_name: Option<String>,
260
261 #[serde(skip_serializing_if = "Option::is_none")]
263 pub env_var_value: Option<String>,
264
265 #[serde(skip_serializing_if = "Option::is_none")]
267 pub profile_name: Option<String>,
268
269 #[serde(skip_serializing_if = "Option::is_none")]
271 pub worker_id: Option<String>,
272
273 #[serde(skip_serializing_if = "Option::is_none")]
275 pub ssh_key_path: Option<PathBuf>,
276
277 #[serde(skip_serializing_if = "Option::is_none")]
279 pub socket_path: Option<PathBuf>,
280
281 #[serde(skip_serializing_if = "Option::is_none")]
283 pub permission_mode: Option<String>,
284
285 #[serde(skip_serializing_if = "Option::is_none")]
287 pub required_mode: Option<String>,
288
289 #[serde(skip_serializing_if = "Option::is_none")]
291 pub raw_error: Option<String>,
292
293 #[serde(default, skip_serializing_if = "Vec::is_empty")]
295 pub caused_by: Vec<String>,
296
297 #[serde(skip_serializing_if = "Option::is_none")]
299 pub custom_message: Option<String>,
300}
301
302impl ConfigErrorDisplay {
303 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
553 pub fn from_toml_error(mut self, error: &toml::de::Error) -> Self {
554 if let Some(span) = error.span() {
555 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 #[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 self.permission_mode = Some("unknown".to_string());
572 }
573 self
574 }
575
576 #[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 if let Some(ref msg) = self.custom_message {
589 panel = panel.message(msg.clone());
590 }
591
592 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 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 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 if let Some(ref profile) = self.profile_name {
623 panel = panel.context("Profile", profile.clone());
624 }
625
626 if let Some(ref worker) = self.worker_id {
628 panel = panel.context("Worker", worker.clone());
629 }
630
631 if let Some(ref path) = self.ssh_key_path {
633 panel = panel.context("SSH Key", path.display().to_string());
634 }
635
636 if let Some(ref path) = self.socket_path {
638 panel = panel.context("Socket", path.display().to_string());
639 }
640
641 if let (Some(current), Some(required)) = (&self.permission_mode, &self.required_mode) {
643 panel = panel.context("Permissions", format!("{current} (need {required})"));
644 }
645
646 for cause in &self.caused_by {
648 panel = panel.caused_by(cause.clone(), None);
649 }
650
651 for step in entry.remediation {
653 panel = panel.suggestion(step);
654 }
655
656 panel
657 }
658
659 pub fn render(&self, ctx: OutputContext) {
665 if ctx.is_machine() {
666 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 #[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 #[cfg(all(feature = "rich-ui", unix))]
701 fn build_rich_content(&self, _ctx: OutputContext) -> String {
702 let mut lines = Vec::new();
703
704 if let Some(ref msg) = self.custom_message {
706 lines.push(msg.clone());
707 }
708
709 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 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 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 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 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 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 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 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 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 fn render_plain(&self, ctx: OutputContext) {
806 let entry = self.error_code.entry();
807 let icon = Icons::cross(ctx);
808
809 eprintln!("{icon} [ERROR] {}: {}", entry.code, entry.message);
811
812 if let Some(ref msg) = self.custom_message {
814 eprintln!();
815 eprintln!("{msg}");
816 }
817
818 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 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 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 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 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 if let Some(ref profile) = self.profile_name {
874 eprintln!();
875 eprintln!("Profile: {profile}");
876 }
877
878 if let Some(ref worker) = self.worker_id {
880 eprintln!();
881 eprintln!("Worker: {worker}");
882 }
883
884 if let Some(ref path) = self.ssh_key_path {
886 eprintln!();
887 eprintln!("SSH Key: {}", path.display());
888 }
889
890 if let Some(ref path) = self.socket_path {
892 eprintln!();
893 eprintln!("Socket: {}", path.display());
894 }
895
896 if let (Some(current), Some(required)) = (&self.permission_mode, &self.required_mode) {
898 eprintln!();
899 eprintln!("Permissions: {current} (need {required})");
900 }
901
902 if let Some(ref raw) = self.raw_error {
904 eprintln!();
905 eprintln!("Parser message:");
906 eprintln!(" {raw}");
907 }
908
909 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 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 pub fn to_json(&self) -> serde_json::Result<String> {
934 serde_json::to_string_pretty(self)
935 }
936
937 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 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 display.render(OutputContext::Plain);
1164 }
1165
1166 #[test]
1167 fn test_render_machine_silent() {
1168 let display = ConfigErrorDisplay::not_found("/config.toml");
1169 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}