1#![allow(unused_assignments)]
5
6use indexmap::IndexMap;
7use miette::{Diagnostic, NamedSource, SourceSpan};
8use thiserror::Error;
9
10use crate::suggestions::{
11 AVAILABLE_FILTERS, extract_filter_name, extract_function_name, extract_variable_name,
12 suggest_iteration_fix, suggest_undefined_variable, suggest_unknown_filter,
13 suggest_unknown_function,
14};
15
16#[derive(Error, Debug)]
18pub enum EngineError {
19 #[error("Template error")]
20 Template(Box<TemplateError>),
21
22 #[error("Filter error: {message}")]
23 Filter { message: String },
24
25 #[error("IO error: {0}")]
26 Io(#[from] std::io::Error),
27
28 #[error("YAML error: {0}")]
29 Yaml(#[from] serde_yaml::Error),
30
31 #[error("JSON error: {0}")]
32 Json(#[from] serde_json::Error),
33
34 #[error("Multiple template errors occurred")]
35 MultipleErrors(Box<RenderReport>),
36}
37
38impl From<TemplateError> for EngineError {
39 fn from(e: TemplateError) -> Self {
40 EngineError::Template(Box::new(e))
41 }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum TemplateErrorKind {
50 UndefinedVariable,
51 UnknownFilter,
52 UnknownFunction,
53 SyntaxError,
54 TypeError,
55 InvalidOperation,
56 YamlParseError,
57 Other,
58}
59
60impl TemplateErrorKind {
61 pub fn to_code_string(&self) -> &'static str {
63 match self {
64 Self::UndefinedVariable => "undefined_variable",
65 Self::UnknownFilter => "unknown_filter",
66 Self::UnknownFunction => "unknown_function",
67 Self::SyntaxError => "syntax",
68 Self::TypeError => "type",
69 Self::InvalidOperation => "invalid_operation",
70 Self::YamlParseError => "yaml_parse",
71 Self::Other => "render",
72 }
73 }
74}
75
76#[derive(Error, Debug, Diagnostic, Clone)]
78#[error("{message}")]
79#[diagnostic(code(sherpack::template::render))]
80pub struct TemplateError {
81 pub message: String,
83
84 pub kind: TemplateErrorKind,
86
87 #[source_code]
89 pub src: NamedSource<String>,
90
91 #[label("error occurred here")]
93 pub span: Option<SourceSpan>,
94
95 #[help]
97 pub suggestion: Option<String>,
98
99 pub context: Option<String>,
101}
102
103impl TemplateError {
104 pub fn from_minijinja(
106 err: minijinja::Error,
107 template_name: &str,
108 template_source: &str,
109 ) -> Self {
110 let (kind, message) = categorize_minijinja_error(&err);
111 let line = err.line();
112
113 let span = line.and_then(|line_num| calculate_span(template_source, line_num));
115
116 let suggestion = generate_suggestion(&err, &kind, None);
118
119 Self {
120 message,
121 kind,
122 src: NamedSource::new(template_name, template_source.to_string()),
123 span,
124 suggestion,
125 context: None,
126 }
127 }
128
129 pub fn from_minijinja_enhanced(
131 err: minijinja::Error,
132 template_name: &str,
133 template_source: &str,
134 values: Option<&serde_json::Value>,
135 ) -> Self {
136 let (kind, message) = categorize_minijinja_error(&err);
137 let line = err.line();
138
139 let span = line.and_then(|line_num| calculate_span(template_source, line_num));
141
142 let suggestion = generate_suggestion(&err, &kind, values);
144
145 Self {
146 message,
147 kind,
148 src: NamedSource::new(template_name, template_source.to_string()),
149 span,
150 suggestion,
151 context: None,
152 }
153 }
154
155 pub fn simple(message: impl Into<String>) -> Self {
157 Self {
158 message: message.into(),
159 kind: TemplateErrorKind::Other,
160 src: NamedSource::new("<unknown>", String::new()),
161 span: None,
162 suggestion: None,
163 context: None,
164 }
165 }
166
167 pub fn with_context(mut self, context: impl Into<String>) -> Self {
169 self.context = Some(context.into());
170 self
171 }
172
173 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
175 self.suggestion = Some(suggestion.into());
176 self
177 }
178
179 pub fn kind(&self) -> TemplateErrorKind {
181 self.kind
182 }
183}
184
185fn categorize_minijinja_error(err: &minijinja::Error) -> (TemplateErrorKind, String) {
187 let msg = err.to_string();
188 let msg_lower = msg.to_lowercase();
189
190 let detailed = format!("{:#}", err);
192
193 let kind = match err.kind() {
194 minijinja::ErrorKind::UndefinedError => TemplateErrorKind::UndefinedVariable,
195 minijinja::ErrorKind::UnknownFilter => TemplateErrorKind::UnknownFilter,
196 minijinja::ErrorKind::UnknownFunction => TemplateErrorKind::UnknownFunction,
197 minijinja::ErrorKind::SyntaxError => TemplateErrorKind::SyntaxError,
198 minijinja::ErrorKind::InvalidOperation => TemplateErrorKind::InvalidOperation,
199 minijinja::ErrorKind::NonPrimitive | minijinja::ErrorKind::NonKey => {
200 TemplateErrorKind::TypeError
201 }
202 _ => {
203 if msg_lower.contains("undefined") || msg_lower.contains("unknown variable") {
205 TemplateErrorKind::UndefinedVariable
206 } else if msg_lower.contains("filter") {
207 TemplateErrorKind::UnknownFilter
208 } else if msg_lower.contains("function") {
209 TemplateErrorKind::UnknownFunction
210 } else if msg_lower.contains("syntax") || msg_lower.contains("expected") {
211 TemplateErrorKind::SyntaxError
212 } else if msg_lower.contains("not iterable") || msg_lower.contains("cannot") {
213 TemplateErrorKind::TypeError
214 } else {
215 TemplateErrorKind::Other
216 }
217 }
218 };
219
220 let enhanced_msg = match kind {
224 TemplateErrorKind::UndefinedVariable => {
225 if let Some(expr) = extract_expression_from_display(&detailed) {
226 format!("undefined variable `{}`", expr)
227 } else {
228 msg.replace("undefined value", "undefined variable")
229 }
230 }
231 TemplateErrorKind::UnknownFilter => {
232 if let Some(filter) = extract_filter_from_display(&detailed) {
233 format!("unknown filter `{}`", filter)
234 } else {
235 msg.clone()
236 }
237 }
238 _ => msg
239 .replace("invalid operation: ", "")
240 .replace("syntax error: ", "")
241 .replace("undefined value", "undefined variable"),
242 };
243
244 (kind, enhanced_msg)
245}
246
247fn extract_expression_from_display(display: &str) -> Option<String> {
249 let lines: Vec<&str> = display.lines().collect();
255
256 for (i, line) in lines.iter().enumerate() {
258 let trimmed = line.trim_start();
260 if trimmed.contains(" > ") || trimmed.starts_with("> ") {
261 if let Some(start) = line.find("{{")
263 && let Some(end) = line[start..].find("}}")
264 {
265 let expr = line[start + 2..start + end].trim();
266 let expr_part = expr.split('|').next().unwrap_or(expr).trim();
268 if !expr_part.is_empty() {
269 return Some(expr_part.to_string());
270 }
271 }
272 }
273
274 if line.contains("^^^^^") {
276 if i > 0 {
278 let prev_line = lines[i - 1];
279 if let Some(start) = prev_line.find("{{")
280 && let Some(end) = prev_line[start..].find("}}")
281 {
282 let expr = prev_line[start + 2..start + end].trim();
283 let expr_part = expr.split('|').next().unwrap_or(expr).trim();
284 if !expr_part.is_empty() {
285 return Some(expr_part.to_string());
286 }
287 }
288 }
289 }
290 }
291
292 None
293}
294
295fn extract_filter_from_display(display: &str) -> Option<String> {
297 let lines: Vec<&str> = display.lines().collect();
303
304 for line in &lines {
306 let trimmed = line.trim_start();
307 if trimmed.contains(" > ") || trimmed.starts_with("> ") {
308 if let Some(start) = line.find("{{")
310 && let Some(end) = line[start..].find("}}")
311 {
312 let expr = &line[start + 2..start + end];
313 if let Some(pipe_pos) = expr.rfind('|') {
315 let filter_part = expr[pipe_pos + 1..].trim();
316 let filter_name = filter_part.split_whitespace().next();
318 if let Some(name) = filter_name
319 && !name.is_empty()
320 {
321 return Some(name.to_string());
322 }
323 }
324 }
325 }
326 }
327
328 for line in &lines {
330 if line.contains("unknown filter") {
331 continue;
333 }
334 }
335
336 None
337}
338
339fn calculate_span(source: &str, line_num: usize) -> Option<SourceSpan> {
341 let mut offset = 0;
342
343 for (idx, line) in source.lines().enumerate() {
344 let current_line = idx + 1;
345 if current_line == line_num {
346 return Some(SourceSpan::new(offset.into(), line.len()));
348 }
349 offset += line.len() + 1; }
351
352 None
353}
354
355fn generate_suggestion(
357 err: &minijinja::Error,
358 kind: &TemplateErrorKind,
359 values: Option<&serde_json::Value>,
360) -> Option<String> {
361 let msg = err.to_string();
362 let detailed = format!("{:#}", err);
363
364 match kind {
365 TemplateErrorKind::UndefinedVariable => {
366 let var_name =
368 extract_expression_from_display(&detailed).or_else(|| extract_variable_name(&msg));
369
370 if let Some(var_name) = var_name {
371 if var_name == "value" || var_name.starts_with("value.") {
373 let corrected = var_name.replacen("value", "values", 1);
374 return Some(format!(
375 "Did you mean `{}`? Use `values` (plural) to access the values object.",
376 corrected
377 ));
378 }
379
380 if let Some(path) = var_name.strip_prefix("values.") {
382 let parts: Vec<&str> = path.split('.').collect();
383
384 if let Some(vals) = values {
385 let mut current = vals;
387 let mut valid_parts = vec![];
388
389 for part in &parts {
390 if let Some(next) = current.get(part) {
391 valid_parts.push(*part);
392 current = next;
393 } else {
394 if let Some(obj) = current.as_object() {
396 let available: Vec<&str> =
397 obj.keys().map(|s| s.as_str()).collect();
398
399 let matches = crate::suggestions::find_closest_matches(
401 part,
402 &available,
403 3,
404 crate::suggestions::SuggestionCategory::Property,
405 );
406
407 let prefix = if valid_parts.is_empty() {
408 "values".to_string()
409 } else {
410 format!("values.{}", valid_parts.join("."))
411 };
412
413 if !matches.is_empty() {
414 let suggestions: Vec<String> = matches
415 .iter()
416 .map(|m| format!("`{}.{}`", prefix, m.text))
417 .collect();
418 return Some(format!(
419 "Key `{}` not found. Did you mean {}? Available: {}",
420 part,
421 suggestions.join(" or "),
422 available.join(", ")
423 ));
424 } else {
425 return Some(format!(
426 "Key `{}` not found in `{}`. Available keys: {}",
427 part,
428 prefix,
429 available.join(", ")
430 ));
431 }
432 }
433 break;
434 }
435 }
436 }
437 }
438
439 let available = values
441 .and_then(|v| v.as_object())
442 .map(|obj| obj.keys().cloned().collect::<Vec<_>>())
443 .unwrap_or_default();
444
445 return suggest_undefined_variable(&var_name, &available).or_else(|| {
446 Some(format!(
447 "Variable `{}` is not defined. Check spelling or use `| default(\"fallback\")`.",
448 var_name
449 ))
450 });
451 }
452 Some("Variable is not defined. Check spelling or use the `default` filter.".to_string())
453 }
454
455 TemplateErrorKind::UnknownFilter => {
456 let filter_name =
458 extract_filter_from_display(&detailed).or_else(|| extract_filter_name(&msg));
459
460 if let Some(filter_name) = filter_name {
461 return suggest_unknown_filter(&filter_name);
462 }
463 Some(format!(
464 "Unknown filter. Available: {}",
465 AVAILABLE_FILTERS.join(", ")
466 ))
467 }
468
469 TemplateErrorKind::UnknownFunction => {
470 if let Some(func_name) = extract_function_name(&msg) {
471 return suggest_unknown_function(&func_name);
472 }
473 Some("Unknown function. Check the function name and arguments.".to_string())
474 }
475
476 TemplateErrorKind::SyntaxError => {
477 if msg.contains("}") || msg.contains("%") {
478 Some(
479 "Check bracket matching: `{{ }}` for expressions, `{% %}` for statements, `{# #}` for comments".to_string(),
480 )
481 } else if msg.contains("expected") {
482 Some(
483 "Syntax error. Check for missing closing tags or mismatched brackets."
484 .to_string(),
485 )
486 } else {
487 None
488 }
489 }
490
491 TemplateErrorKind::TypeError => {
492 if msg.to_lowercase().contains("not iterable") {
493 Some(suggest_iteration_fix("object"))
494 } else if msg.to_lowercase().contains("not callable") {
495 Some(
496 "Use `{{ value }}` for variables, `{{ func() }}` for function calls."
497 .to_string(),
498 )
499 } else {
500 None
501 }
502 }
503
504 _ => None,
505 }
506}
507
508#[derive(Debug, Clone, Copy, PartialEq, Eq)]
510pub enum IssueSeverity {
511 Warning,
513 Error,
515}
516
517#[derive(Debug, Clone)]
519pub struct RenderIssue {
520 pub category: String,
522 pub message: String,
524 pub severity: IssueSeverity,
526}
527
528impl RenderIssue {
529 pub fn warning(category: impl Into<String>, message: impl Into<String>) -> Self {
531 Self {
532 category: category.into(),
533 message: message.into(),
534 severity: IssueSeverity::Warning,
535 }
536 }
537
538 pub fn error(category: impl Into<String>, message: impl Into<String>) -> Self {
540 Self {
541 category: category.into(),
542 message: message.into(),
543 severity: IssueSeverity::Error,
544 }
545 }
546}
547
548#[derive(Debug, Default)]
550pub struct RenderReport {
551 pub errors_by_template: IndexMap<String, Vec<TemplateError>>,
553
554 pub successful_templates: Vec<String>,
556
557 pub total_errors: usize,
559
560 pub issues: Vec<RenderIssue>,
562}
563
564impl RenderReport {
565 pub fn new() -> Self {
567 Self::default()
568 }
569
570 pub fn add_error(&mut self, template_name: String, error: TemplateError) {
572 self.errors_by_template
573 .entry(template_name)
574 .or_default()
575 .push(error);
576 self.total_errors += 1;
577 }
578
579 pub fn add_success(&mut self, template_name: String) {
581 self.successful_templates.push(template_name);
582 }
583
584 pub fn add_issue(&mut self, issue: RenderIssue) {
586 self.issues.push(issue);
587 }
588
589 pub fn add_warning(&mut self, category: impl Into<String>, message: impl Into<String>) {
591 self.issues.push(RenderIssue::warning(category, message));
592 }
593
594 pub fn has_errors(&self) -> bool {
596 self.total_errors > 0
597 }
598
599 pub fn has_warnings(&self) -> bool {
601 self.issues
602 .iter()
603 .any(|i| i.severity == IssueSeverity::Warning)
604 }
605
606 pub fn has_issues(&self) -> bool {
608 !self.issues.is_empty()
609 }
610
611 pub fn warnings(&self) -> impl Iterator<Item = &RenderIssue> {
613 self.issues
614 .iter()
615 .filter(|i| i.severity == IssueSeverity::Warning)
616 }
617
618 pub fn templates_with_errors(&self) -> usize {
620 self.errors_by_template.len()
621 }
622
623 pub fn summary(&self) -> String {
625 let template_word = if self.templates_with_errors() == 1 {
626 "template"
627 } else {
628 "templates"
629 };
630 let error_word = if self.total_errors == 1 {
631 "error"
632 } else {
633 "errors"
634 };
635
636 let base = format!(
637 "{} {} in {} {}",
638 self.total_errors,
639 error_word,
640 self.templates_with_errors(),
641 template_word
642 );
643
644 let warning_count = self.warnings().count();
645 if warning_count > 0 {
646 let warning_word = if warning_count == 1 {
647 "warning"
648 } else {
649 "warnings"
650 };
651 format!("{}, {} {}", base, warning_count, warning_word)
652 } else {
653 base
654 }
655 }
656}
657
658#[derive(Debug)]
660pub struct RenderResultWithReport {
661 pub manifests: IndexMap<String, String>,
663
664 pub notes: Option<String>,
666
667 pub report: RenderReport,
669}
670
671impl RenderResultWithReport {
672 pub fn is_success(&self) -> bool {
674 !self.report.has_errors()
675 }
676}
677
678pub type Result<T> = std::result::Result<T, EngineError>;
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684
685 #[test]
686 fn test_render_report_new() {
687 let report = RenderReport::new();
688 assert!(!report.has_errors());
689 assert_eq!(report.total_errors, 0);
690 assert_eq!(report.templates_with_errors(), 0);
691 assert!(report.successful_templates.is_empty());
692 }
693
694 #[test]
695 fn test_render_report_add_error() {
696 let mut report = RenderReport::new();
697
698 let error = TemplateError::simple("test error");
699 report.add_error("template.yaml".to_string(), error);
700
701 assert!(report.has_errors());
702 assert_eq!(report.total_errors, 1);
703 assert_eq!(report.templates_with_errors(), 1);
704 }
705
706 #[test]
707 fn test_render_report_multiple_errors_same_template() {
708 let mut report = RenderReport::new();
709
710 report.add_error(
711 "template.yaml".to_string(),
712 TemplateError::simple("error 1"),
713 );
714 report.add_error(
715 "template.yaml".to_string(),
716 TemplateError::simple("error 2"),
717 );
718
719 assert_eq!(report.total_errors, 2);
720 assert_eq!(report.templates_with_errors(), 1);
721 assert_eq!(report.errors_by_template["template.yaml"].len(), 2);
722 }
723
724 #[test]
725 fn test_render_report_multiple_templates() {
726 let mut report = RenderReport::new();
727
728 report.add_error("a.yaml".to_string(), TemplateError::simple("error 1"));
729 report.add_error("b.yaml".to_string(), TemplateError::simple("error 2"));
730 report.add_error("c.yaml".to_string(), TemplateError::simple("error 3"));
731
732 assert_eq!(report.total_errors, 3);
733 assert_eq!(report.templates_with_errors(), 3);
734 }
735
736 #[test]
737 fn test_render_report_add_success() {
738 let mut report = RenderReport::new();
739
740 report.add_success("good.yaml".to_string());
741 report.add_success("also-good.yaml".to_string());
742
743 assert!(!report.has_errors());
744 assert_eq!(report.successful_templates.len(), 2);
745 }
746
747 #[test]
748 fn test_render_report_summary_singular() {
749 let mut report = RenderReport::new();
750 report.add_error("template.yaml".to_string(), TemplateError::simple("error"));
751
752 assert_eq!(report.summary(), "1 error in 1 template");
753 }
754
755 #[test]
756 fn test_render_report_summary_plural() {
757 let mut report = RenderReport::new();
758 report.add_error("a.yaml".to_string(), TemplateError::simple("error 1"));
759 report.add_error("a.yaml".to_string(), TemplateError::simple("error 2"));
760 report.add_error("b.yaml".to_string(), TemplateError::simple("error 3"));
761
762 assert_eq!(report.summary(), "3 errors in 2 templates");
763 }
764
765 #[test]
766 fn test_render_result_with_report_success() {
767 let result = RenderResultWithReport {
768 manifests: IndexMap::new(),
769 notes: None,
770 report: RenderReport::new(),
771 };
772 assert!(result.is_success());
773 }
774
775 #[test]
776 fn test_render_result_with_report_failure() {
777 let mut report = RenderReport::new();
778 report.add_error("test.yaml".to_string(), TemplateError::simple("error"));
779
780 let result = RenderResultWithReport {
781 manifests: IndexMap::new(),
782 notes: None,
783 report,
784 };
785 assert!(!result.is_success());
786 }
787
788 #[test]
789 fn test_template_error_simple() {
790 let error = TemplateError::simple("test message");
791 assert_eq!(error.message, "test message");
792 assert_eq!(error.kind, TemplateErrorKind::Other);
793 assert!(error.suggestion.is_none());
794 }
795
796 #[test]
797 fn test_template_error_with_suggestion() {
798 let error = TemplateError::simple("test").with_suggestion("try this");
799 assert_eq!(error.suggestion, Some("try this".to_string()));
800 }
801
802 #[test]
803 fn test_template_error_with_context() {
804 let error = TemplateError::simple("test").with_context("additional info");
805 assert_eq!(error.context, Some("additional info".to_string()));
806 }
807
808 #[test]
809 fn test_template_error_kind() {
810 let error = TemplateError {
811 message: "test".to_string(),
812 kind: TemplateErrorKind::UndefinedVariable,
813 src: NamedSource::new("test", String::new()),
814 span: None,
815 suggestion: None,
816 context: None,
817 };
818 assert_eq!(error.kind(), TemplateErrorKind::UndefinedVariable);
819 }
820
821 #[test]
822 fn test_template_error_kind_to_code_string() {
823 assert_eq!(
824 TemplateErrorKind::UndefinedVariable.to_code_string(),
825 "undefined_variable"
826 );
827 assert_eq!(
828 TemplateErrorKind::UnknownFilter.to_code_string(),
829 "unknown_filter"
830 );
831 assert_eq!(TemplateErrorKind::SyntaxError.to_code_string(), "syntax");
832 }
833
834 #[test]
835 fn test_extract_expression_from_display_with_marker() {
836 let display = r#"
837 8 > typo: {{ value.app.name }}
838 i ^^^^^^^^^ undefined value
839"#;
840 let expr = extract_expression_from_display(display);
841 assert_eq!(expr, Some("value.app.name".to_string()));
842 }
843
844 #[test]
845 fn test_extract_expression_with_filter() {
846 let display = r#"
847 8 > data: {{ values.app.name | upper }}
848 i ^^^^^ unknown filter
849"#;
850 let expr = extract_expression_from_display(display);
851 assert_eq!(expr, Some("values.app.name".to_string()));
852 }
853
854 #[test]
855 fn test_extract_filter_from_display() {
856 let display = r#"
857 8 > data: {{ values.name | toyml }}
858 i ^^^^^ unknown filter
859"#;
860 let filter = extract_filter_from_display(display);
861 assert_eq!(filter, Some("toyml".to_string()));
862 }
863
864 #[test]
865 fn test_render_issue_warning() {
866 let issue = RenderIssue::warning("files_api", "Files API unavailable");
867 assert_eq!(issue.category, "files_api");
868 assert_eq!(issue.message, "Files API unavailable");
869 assert_eq!(issue.severity, IssueSeverity::Warning);
870 }
871
872 #[test]
873 fn test_render_issue_error() {
874 let issue = RenderIssue::error("subchart", "Failed to load subchart");
875 assert_eq!(issue.category, "subchart");
876 assert_eq!(issue.severity, IssueSeverity::Error);
877 }
878
879 #[test]
880 fn test_render_report_add_warning() {
881 let mut report = RenderReport::new();
882 report.add_warning("test_category", "test warning message");
883
884 assert!(report.has_warnings());
885 assert!(report.has_issues());
886 assert!(!report.has_errors()); let warnings: Vec<_> = report.warnings().collect();
889 assert_eq!(warnings.len(), 1);
890 assert_eq!(warnings[0].category, "test_category");
891 assert_eq!(warnings[0].message, "test warning message");
892 }
893
894 #[test]
895 fn test_render_report_summary_with_warnings() {
896 let mut report = RenderReport::new();
897 report.add_error("a.yaml".to_string(), TemplateError::simple("error"));
898 report.add_warning("files_api", "Files unavailable");
899
900 let summary = report.summary();
901 assert!(summary.contains("1 error"));
902 assert!(summary.contains("1 warning"));
903 }
904
905 #[test]
906 fn test_render_report_multiple_warnings() {
907 let mut report = RenderReport::new();
908 report.add_warning("files_api", "warning 1");
909 report.add_warning("subchart", "warning 2");
910 report.add_issue(RenderIssue::error("critical", "an error"));
911
912 assert_eq!(report.warnings().count(), 2);
913 assert_eq!(report.issues.len(), 3);
914 }
915}