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 let mut current_line = 1;
343
344 for line in source.lines() {
345 if current_line == line_num {
346 return Some(SourceSpan::new(offset.into(), line.len()));
348 }
349 offset += line.len() + 1; current_line += 1;
351 }
352
353 None
354}
355
356fn generate_suggestion(
358 err: &minijinja::Error,
359 kind: &TemplateErrorKind,
360 values: Option<&serde_json::Value>,
361) -> Option<String> {
362 let msg = err.to_string();
363 let detailed = format!("{:#}", err);
364
365 match kind {
366 TemplateErrorKind::UndefinedVariable => {
367 let var_name =
369 extract_expression_from_display(&detailed).or_else(|| extract_variable_name(&msg));
370
371 if let Some(var_name) = var_name {
372 if var_name == "value" || var_name.starts_with("value.") {
374 let corrected = var_name.replacen("value", "values", 1);
375 return Some(format!(
376 "Did you mean `{}`? Use `values` (plural) to access the values object.",
377 corrected
378 ));
379 }
380
381 if let Some(path) = var_name.strip_prefix("values.") {
383 let parts: Vec<&str> = path.split('.').collect();
384
385 if let Some(vals) = values {
386 let mut current = vals;
388 let mut valid_parts = vec![];
389
390 for part in &parts {
391 if let Some(next) = current.get(part) {
392 valid_parts.push(*part);
393 current = next;
394 } else {
395 if let Some(obj) = current.as_object() {
397 let available: Vec<&str> =
398 obj.keys().map(|s| s.as_str()).collect();
399
400 let matches = crate::suggestions::find_closest_matches(
402 part,
403 &available,
404 3,
405 crate::suggestions::SuggestionCategory::Property,
406 );
407
408 let prefix = if valid_parts.is_empty() {
409 "values".to_string()
410 } else {
411 format!("values.{}", valid_parts.join("."))
412 };
413
414 if !matches.is_empty() {
415 let suggestions: Vec<String> = matches
416 .iter()
417 .map(|m| format!("`{}.{}`", prefix, m.text))
418 .collect();
419 return Some(format!(
420 "Key `{}` not found. Did you mean {}? Available: {}",
421 part,
422 suggestions.join(" or "),
423 available.join(", ")
424 ));
425 } else {
426 return Some(format!(
427 "Key `{}` not found in `{}`. Available keys: {}",
428 part,
429 prefix,
430 available.join(", ")
431 ));
432 }
433 }
434 break;
435 }
436 }
437 }
438 }
439
440 let available = values
442 .and_then(|v| v.as_object())
443 .map(|obj| obj.keys().cloned().collect::<Vec<_>>())
444 .unwrap_or_default();
445
446 return suggest_undefined_variable(&var_name, &available).or_else(|| {
447 Some(format!(
448 "Variable `{}` is not defined. Check spelling or use `| default(\"fallback\")`.",
449 var_name
450 ))
451 });
452 }
453 Some("Variable is not defined. Check spelling or use the `default` filter.".to_string())
454 }
455
456 TemplateErrorKind::UnknownFilter => {
457 let filter_name =
459 extract_filter_from_display(&detailed).or_else(|| extract_filter_name(&msg));
460
461 if let Some(filter_name) = filter_name {
462 return suggest_unknown_filter(&filter_name);
463 }
464 Some(format!(
465 "Unknown filter. Available: {}",
466 AVAILABLE_FILTERS.join(", ")
467 ))
468 }
469
470 TemplateErrorKind::UnknownFunction => {
471 if let Some(func_name) = extract_function_name(&msg) {
472 return suggest_unknown_function(&func_name);
473 }
474 Some("Unknown function. Check the function name and arguments.".to_string())
475 }
476
477 TemplateErrorKind::SyntaxError => {
478 if msg.contains("}") || msg.contains("%") {
479 Some(
480 "Check bracket matching: `{{ }}` for expressions, `{% %}` for statements, `{# #}` for comments".to_string(),
481 )
482 } else if msg.contains("expected") {
483 Some(
484 "Syntax error. Check for missing closing tags or mismatched brackets."
485 .to_string(),
486 )
487 } else {
488 None
489 }
490 }
491
492 TemplateErrorKind::TypeError => {
493 if msg.to_lowercase().contains("not iterable") {
494 Some(suggest_iteration_fix("object"))
495 } else if msg.to_lowercase().contains("not callable") {
496 Some(
497 "Use `{{ value }}` for variables, `{{ func() }}` for function calls."
498 .to_string(),
499 )
500 } else {
501 None
502 }
503 }
504
505 _ => None,
506 }
507}
508
509#[derive(Debug, Clone, Copy, PartialEq, Eq)]
511pub enum IssueSeverity {
512 Warning,
514 Error,
516}
517
518#[derive(Debug, Clone)]
520pub struct RenderIssue {
521 pub category: String,
523 pub message: String,
525 pub severity: IssueSeverity,
527}
528
529impl RenderIssue {
530 pub fn warning(category: impl Into<String>, message: impl Into<String>) -> Self {
532 Self {
533 category: category.into(),
534 message: message.into(),
535 severity: IssueSeverity::Warning,
536 }
537 }
538
539 pub fn error(category: impl Into<String>, message: impl Into<String>) -> Self {
541 Self {
542 category: category.into(),
543 message: message.into(),
544 severity: IssueSeverity::Error,
545 }
546 }
547}
548
549#[derive(Debug, Default)]
551pub struct RenderReport {
552 pub errors_by_template: IndexMap<String, Vec<TemplateError>>,
554
555 pub successful_templates: Vec<String>,
557
558 pub total_errors: usize,
560
561 pub issues: Vec<RenderIssue>,
563}
564
565impl RenderReport {
566 pub fn new() -> Self {
568 Self::default()
569 }
570
571 pub fn add_error(&mut self, template_name: String, error: TemplateError) {
573 self.errors_by_template
574 .entry(template_name)
575 .or_default()
576 .push(error);
577 self.total_errors += 1;
578 }
579
580 pub fn add_success(&mut self, template_name: String) {
582 self.successful_templates.push(template_name);
583 }
584
585 pub fn add_issue(&mut self, issue: RenderIssue) {
587 self.issues.push(issue);
588 }
589
590 pub fn add_warning(&mut self, category: impl Into<String>, message: impl Into<String>) {
592 self.issues.push(RenderIssue::warning(category, message));
593 }
594
595 pub fn has_errors(&self) -> bool {
597 self.total_errors > 0
598 }
599
600 pub fn has_warnings(&self) -> bool {
602 self.issues
603 .iter()
604 .any(|i| i.severity == IssueSeverity::Warning)
605 }
606
607 pub fn has_issues(&self) -> bool {
609 !self.issues.is_empty()
610 }
611
612 pub fn warnings(&self) -> impl Iterator<Item = &RenderIssue> {
614 self.issues
615 .iter()
616 .filter(|i| i.severity == IssueSeverity::Warning)
617 }
618
619 pub fn templates_with_errors(&self) -> usize {
621 self.errors_by_template.len()
622 }
623
624 pub fn summary(&self) -> String {
626 let template_word = if self.templates_with_errors() == 1 {
627 "template"
628 } else {
629 "templates"
630 };
631 let error_word = if self.total_errors == 1 {
632 "error"
633 } else {
634 "errors"
635 };
636
637 let base = format!(
638 "{} {} in {} {}",
639 self.total_errors,
640 error_word,
641 self.templates_with_errors(),
642 template_word
643 );
644
645 let warning_count = self.warnings().count();
646 if warning_count > 0 {
647 let warning_word = if warning_count == 1 {
648 "warning"
649 } else {
650 "warnings"
651 };
652 format!("{}, {} {}", base, warning_count, warning_word)
653 } else {
654 base
655 }
656 }
657}
658
659#[derive(Debug)]
661pub struct RenderResultWithReport {
662 pub manifests: IndexMap<String, String>,
664
665 pub notes: Option<String>,
667
668 pub report: RenderReport,
670}
671
672impl RenderResultWithReport {
673 pub fn is_success(&self) -> bool {
675 !self.report.has_errors()
676 }
677}
678
679pub type Result<T> = std::result::Result<T, EngineError>;
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 #[test]
687 fn test_render_report_new() {
688 let report = RenderReport::new();
689 assert!(!report.has_errors());
690 assert_eq!(report.total_errors, 0);
691 assert_eq!(report.templates_with_errors(), 0);
692 assert!(report.successful_templates.is_empty());
693 }
694
695 #[test]
696 fn test_render_report_add_error() {
697 let mut report = RenderReport::new();
698
699 let error = TemplateError::simple("test error");
700 report.add_error("template.yaml".to_string(), error);
701
702 assert!(report.has_errors());
703 assert_eq!(report.total_errors, 1);
704 assert_eq!(report.templates_with_errors(), 1);
705 }
706
707 #[test]
708 fn test_render_report_multiple_errors_same_template() {
709 let mut report = RenderReport::new();
710
711 report.add_error(
712 "template.yaml".to_string(),
713 TemplateError::simple("error 1"),
714 );
715 report.add_error(
716 "template.yaml".to_string(),
717 TemplateError::simple("error 2"),
718 );
719
720 assert_eq!(report.total_errors, 2);
721 assert_eq!(report.templates_with_errors(), 1);
722 assert_eq!(report.errors_by_template["template.yaml"].len(), 2);
723 }
724
725 #[test]
726 fn test_render_report_multiple_templates() {
727 let mut report = RenderReport::new();
728
729 report.add_error("a.yaml".to_string(), TemplateError::simple("error 1"));
730 report.add_error("b.yaml".to_string(), TemplateError::simple("error 2"));
731 report.add_error("c.yaml".to_string(), TemplateError::simple("error 3"));
732
733 assert_eq!(report.total_errors, 3);
734 assert_eq!(report.templates_with_errors(), 3);
735 }
736
737 #[test]
738 fn test_render_report_add_success() {
739 let mut report = RenderReport::new();
740
741 report.add_success("good.yaml".to_string());
742 report.add_success("also-good.yaml".to_string());
743
744 assert!(!report.has_errors());
745 assert_eq!(report.successful_templates.len(), 2);
746 }
747
748 #[test]
749 fn test_render_report_summary_singular() {
750 let mut report = RenderReport::new();
751 report.add_error("template.yaml".to_string(), TemplateError::simple("error"));
752
753 assert_eq!(report.summary(), "1 error in 1 template");
754 }
755
756 #[test]
757 fn test_render_report_summary_plural() {
758 let mut report = RenderReport::new();
759 report.add_error("a.yaml".to_string(), TemplateError::simple("error 1"));
760 report.add_error("a.yaml".to_string(), TemplateError::simple("error 2"));
761 report.add_error("b.yaml".to_string(), TemplateError::simple("error 3"));
762
763 assert_eq!(report.summary(), "3 errors in 2 templates");
764 }
765
766 #[test]
767 fn test_render_result_with_report_success() {
768 let result = RenderResultWithReport {
769 manifests: IndexMap::new(),
770 notes: None,
771 report: RenderReport::new(),
772 };
773 assert!(result.is_success());
774 }
775
776 #[test]
777 fn test_render_result_with_report_failure() {
778 let mut report = RenderReport::new();
779 report.add_error("test.yaml".to_string(), TemplateError::simple("error"));
780
781 let result = RenderResultWithReport {
782 manifests: IndexMap::new(),
783 notes: None,
784 report,
785 };
786 assert!(!result.is_success());
787 }
788
789 #[test]
790 fn test_template_error_simple() {
791 let error = TemplateError::simple("test message");
792 assert_eq!(error.message, "test message");
793 assert_eq!(error.kind, TemplateErrorKind::Other);
794 assert!(error.suggestion.is_none());
795 }
796
797 #[test]
798 fn test_template_error_with_suggestion() {
799 let error = TemplateError::simple("test").with_suggestion("try this");
800 assert_eq!(error.suggestion, Some("try this".to_string()));
801 }
802
803 #[test]
804 fn test_template_error_with_context() {
805 let error = TemplateError::simple("test").with_context("additional info");
806 assert_eq!(error.context, Some("additional info".to_string()));
807 }
808
809 #[test]
810 fn test_template_error_kind() {
811 let error = TemplateError {
812 message: "test".to_string(),
813 kind: TemplateErrorKind::UndefinedVariable,
814 src: NamedSource::new("test", String::new()),
815 span: None,
816 suggestion: None,
817 context: None,
818 };
819 assert_eq!(error.kind(), TemplateErrorKind::UndefinedVariable);
820 }
821
822 #[test]
823 fn test_template_error_kind_to_code_string() {
824 assert_eq!(
825 TemplateErrorKind::UndefinedVariable.to_code_string(),
826 "undefined_variable"
827 );
828 assert_eq!(
829 TemplateErrorKind::UnknownFilter.to_code_string(),
830 "unknown_filter"
831 );
832 assert_eq!(TemplateErrorKind::SyntaxError.to_code_string(), "syntax");
833 }
834
835 #[test]
836 fn test_extract_expression_from_display_with_marker() {
837 let display = r#"
838 8 > typo: {{ value.app.name }}
839 i ^^^^^^^^^ undefined value
840"#;
841 let expr = extract_expression_from_display(display);
842 assert_eq!(expr, Some("value.app.name".to_string()));
843 }
844
845 #[test]
846 fn test_extract_expression_with_filter() {
847 let display = r#"
848 8 > data: {{ values.app.name | upper }}
849 i ^^^^^ unknown filter
850"#;
851 let expr = extract_expression_from_display(display);
852 assert_eq!(expr, Some("values.app.name".to_string()));
853 }
854
855 #[test]
856 fn test_extract_filter_from_display() {
857 let display = r#"
858 8 > data: {{ values.name | toyml }}
859 i ^^^^^ unknown filter
860"#;
861 let filter = extract_filter_from_display(display);
862 assert_eq!(filter, Some("toyml".to_string()));
863 }
864
865 #[test]
866 fn test_render_issue_warning() {
867 let issue = RenderIssue::warning("files_api", "Files API unavailable");
868 assert_eq!(issue.category, "files_api");
869 assert_eq!(issue.message, "Files API unavailable");
870 assert_eq!(issue.severity, IssueSeverity::Warning);
871 }
872
873 #[test]
874 fn test_render_issue_error() {
875 let issue = RenderIssue::error("subchart", "Failed to load subchart");
876 assert_eq!(issue.category, "subchart");
877 assert_eq!(issue.severity, IssueSeverity::Error);
878 }
879
880 #[test]
881 fn test_render_report_add_warning() {
882 let mut report = RenderReport::new();
883 report.add_warning("test_category", "test warning message");
884
885 assert!(report.has_warnings());
886 assert!(report.has_issues());
887 assert!(!report.has_errors()); let warnings: Vec<_> = report.warnings().collect();
890 assert_eq!(warnings.len(), 1);
891 assert_eq!(warnings[0].category, "test_category");
892 assert_eq!(warnings[0].message, "test warning message");
893 }
894
895 #[test]
896 fn test_render_report_summary_with_warnings() {
897 let mut report = RenderReport::new();
898 report.add_error("a.yaml".to_string(), TemplateError::simple("error"));
899 report.add_warning("files_api", "Files unavailable");
900
901 let summary = report.summary();
902 assert!(summary.contains("1 error"));
903 assert!(summary.contains("1 warning"));
904 }
905
906 #[test]
907 fn test_render_report_multiple_warnings() {
908 let mut report = RenderReport::new();
909 report.add_warning("files_api", "warning 1");
910 report.add_warning("subchart", "warning 2");
911 report.add_issue(RenderIssue::error("critical", "an error"));
912
913 assert_eq!(report.warnings().count(), 2);
914 assert_eq!(report.issues.len(), 3);
915 }
916}