#![allow(unused_assignments)]
use indexmap::IndexMap;
use miette::{Diagnostic, NamedSource, SourceSpan};
use thiserror::Error;
use crate::suggestions::{
AVAILABLE_FILTERS, extract_filter_name, extract_function_name, extract_variable_name,
suggest_iteration_fix, suggest_undefined_variable, suggest_unknown_filter,
suggest_unknown_function,
};
#[derive(Error, Debug)]
pub enum EngineError {
#[error("Template error")]
Template(Box<TemplateError>),
#[error("Filter error: {message}")]
Filter { message: String },
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("YAML error: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Multiple template errors occurred")]
MultipleErrors(Box<RenderReport>),
}
impl From<TemplateError> for EngineError {
fn from(e: TemplateError) -> Self {
EngineError::Template(Box::new(e))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TemplateErrorKind {
UndefinedVariable,
UnknownFilter,
UnknownFunction,
SyntaxError,
TypeError,
InvalidOperation,
YamlParseError,
Other,
}
impl TemplateErrorKind {
pub fn to_code_string(&self) -> &'static str {
match self {
Self::UndefinedVariable => "undefined_variable",
Self::UnknownFilter => "unknown_filter",
Self::UnknownFunction => "unknown_function",
Self::SyntaxError => "syntax",
Self::TypeError => "type",
Self::InvalidOperation => "invalid_operation",
Self::YamlParseError => "yaml_parse",
Self::Other => "render",
}
}
}
#[derive(Error, Debug, Diagnostic, Clone)]
#[error("{message}")]
#[diagnostic(code(sherpack::template::render))]
pub struct TemplateError {
pub message: String,
pub kind: TemplateErrorKind,
#[source_code]
pub src: NamedSource<String>,
#[label("error occurred here")]
pub span: Option<SourceSpan>,
#[help]
pub suggestion: Option<String>,
pub context: Option<String>,
}
impl TemplateError {
pub fn from_minijinja(
err: minijinja::Error,
template_name: &str,
template_source: &str,
) -> Self {
let (kind, message) = categorize_minijinja_error(&err);
let line = err.line();
let span = line.and_then(|line_num| calculate_span(template_source, line_num));
let suggestion = generate_suggestion(&err, &kind, None);
Self {
message,
kind,
src: NamedSource::new(template_name, template_source.to_string()),
span,
suggestion,
context: None,
}
}
pub fn from_minijinja_enhanced(
err: minijinja::Error,
template_name: &str,
template_source: &str,
values: Option<&serde_json::Value>,
) -> Self {
let (kind, message) = categorize_minijinja_error(&err);
let line = err.line();
let span = line.and_then(|line_num| calculate_span(template_source, line_num));
let suggestion = generate_suggestion(&err, &kind, values);
Self {
message,
kind,
src: NamedSource::new(template_name, template_source.to_string()),
span,
suggestion,
context: None,
}
}
pub fn simple(message: impl Into<String>) -> Self {
Self {
message: message.into(),
kind: TemplateErrorKind::Other,
src: NamedSource::new("<unknown>", String::new()),
span: None,
suggestion: None,
context: None,
}
}
pub fn with_context(mut self, context: impl Into<String>) -> Self {
self.context = Some(context.into());
self
}
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
self.suggestion = Some(suggestion.into());
self
}
pub fn kind(&self) -> TemplateErrorKind {
self.kind
}
}
fn categorize_minijinja_error(err: &minijinja::Error) -> (TemplateErrorKind, String) {
let msg = err.to_string();
let msg_lower = msg.to_lowercase();
let detailed = format!("{:#}", err);
let kind = match err.kind() {
minijinja::ErrorKind::UndefinedError => TemplateErrorKind::UndefinedVariable,
minijinja::ErrorKind::UnknownFilter => TemplateErrorKind::UnknownFilter,
minijinja::ErrorKind::UnknownFunction => TemplateErrorKind::UnknownFunction,
minijinja::ErrorKind::SyntaxError => TemplateErrorKind::SyntaxError,
minijinja::ErrorKind::InvalidOperation => TemplateErrorKind::InvalidOperation,
minijinja::ErrorKind::NonPrimitive | minijinja::ErrorKind::NonKey => {
TemplateErrorKind::TypeError
}
_ => {
if msg_lower.contains("undefined") || msg_lower.contains("unknown variable") {
TemplateErrorKind::UndefinedVariable
} else if msg_lower.contains("filter") {
TemplateErrorKind::UnknownFilter
} else if msg_lower.contains("function") {
TemplateErrorKind::UnknownFunction
} else if msg_lower.contains("syntax") || msg_lower.contains("expected") {
TemplateErrorKind::SyntaxError
} else if msg_lower.contains("not iterable") || msg_lower.contains("cannot") {
TemplateErrorKind::TypeError
} else {
TemplateErrorKind::Other
}
}
};
let enhanced_msg = match kind {
TemplateErrorKind::UndefinedVariable => {
if let Some(expr) = extract_expression_from_display(&detailed) {
format!("undefined variable `{}`", expr)
} else {
msg.replace("undefined value", "undefined variable")
}
}
TemplateErrorKind::UnknownFilter => {
if let Some(filter) = extract_filter_from_display(&detailed) {
format!("unknown filter `{}`", filter)
} else {
msg.clone()
}
}
_ => msg
.replace("invalid operation: ", "")
.replace("syntax error: ", "")
.replace("undefined value", "undefined variable"),
};
(kind, enhanced_msg)
}
fn extract_expression_from_display(display: &str) -> Option<String> {
let lines: Vec<&str> = display.lines().collect();
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim_start();
if trimmed.contains(" > ") || trimmed.starts_with("> ") {
if let Some(start) = line.find("{{")
&& let Some(end) = line[start..].find("}}")
{
let expr = line[start + 2..start + end].trim();
let expr_part = expr.split('|').next().unwrap_or(expr).trim();
if !expr_part.is_empty() {
return Some(expr_part.to_string());
}
}
}
if line.contains("^^^^^") {
if i > 0 {
let prev_line = lines[i - 1];
if let Some(start) = prev_line.find("{{")
&& let Some(end) = prev_line[start..].find("}}")
{
let expr = prev_line[start + 2..start + end].trim();
let expr_part = expr.split('|').next().unwrap_or(expr).trim();
if !expr_part.is_empty() {
return Some(expr_part.to_string());
}
}
}
}
}
None
}
fn extract_filter_from_display(display: &str) -> Option<String> {
let lines: Vec<&str> = display.lines().collect();
for line in &lines {
let trimmed = line.trim_start();
if trimmed.contains(" > ") || trimmed.starts_with("> ") {
if let Some(start) = line.find("{{")
&& let Some(end) = line[start..].find("}}")
{
let expr = &line[start + 2..start + end];
if let Some(pipe_pos) = expr.rfind('|') {
let filter_part = expr[pipe_pos + 1..].trim();
let filter_name = filter_part.split_whitespace().next();
if let Some(name) = filter_name
&& !name.is_empty()
{
return Some(name.to_string());
}
}
}
}
}
for line in &lines {
if line.contains("unknown filter") {
continue;
}
}
None
}
fn calculate_span(source: &str, line_num: usize) -> Option<SourceSpan> {
let mut offset = 0;
for (idx, line) in source.lines().enumerate() {
let current_line = idx + 1;
if current_line == line_num {
return Some(SourceSpan::new(offset.into(), line.len()));
}
offset += line.len() + 1; }
None
}
fn generate_suggestion(
err: &minijinja::Error,
kind: &TemplateErrorKind,
values: Option<&serde_json::Value>,
) -> Option<String> {
let msg = err.to_string();
let detailed = format!("{:#}", err);
match kind {
TemplateErrorKind::UndefinedVariable => {
let var_name =
extract_expression_from_display(&detailed).or_else(|| extract_variable_name(&msg));
if let Some(var_name) = var_name {
if var_name == "value" || var_name.starts_with("value.") {
let corrected = var_name.replacen("value", "values", 1);
return Some(format!(
"Did you mean `{}`? Use `values` (plural) to access the values object.",
corrected
));
}
if let Some(path) = var_name.strip_prefix("values.") {
let parts: Vec<&str> = path.split('.').collect();
if let Some(vals) = values {
let mut current = vals;
let mut valid_parts = vec![];
for part in &parts {
if let Some(next) = current.get(part) {
valid_parts.push(*part);
current = next;
} else {
if let Some(obj) = current.as_object() {
let available: Vec<&str> =
obj.keys().map(|s| s.as_str()).collect();
let matches = crate::suggestions::find_closest_matches(
part,
&available,
3,
crate::suggestions::SuggestionCategory::Property,
);
let prefix = if valid_parts.is_empty() {
"values".to_string()
} else {
format!("values.{}", valid_parts.join("."))
};
if !matches.is_empty() {
let suggestions: Vec<String> = matches
.iter()
.map(|m| format!("`{}.{}`", prefix, m.text))
.collect();
return Some(format!(
"Key `{}` not found. Did you mean {}? Available: {}",
part,
suggestions.join(" or "),
available.join(", ")
));
} else {
return Some(format!(
"Key `{}` not found in `{}`. Available keys: {}",
part,
prefix,
available.join(", ")
));
}
}
break;
}
}
}
}
let available = values
.and_then(|v| v.as_object())
.map(|obj| obj.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
return suggest_undefined_variable(&var_name, &available).or_else(|| {
Some(format!(
"Variable `{}` is not defined. Check spelling or use `| default(\"fallback\")`.",
var_name
))
});
}
Some("Variable is not defined. Check spelling or use the `default` filter.".to_string())
}
TemplateErrorKind::UnknownFilter => {
let filter_name =
extract_filter_from_display(&detailed).or_else(|| extract_filter_name(&msg));
if let Some(filter_name) = filter_name {
return suggest_unknown_filter(&filter_name);
}
Some(format!(
"Unknown filter. Available: {}",
AVAILABLE_FILTERS.join(", ")
))
}
TemplateErrorKind::UnknownFunction => {
if let Some(func_name) = extract_function_name(&msg) {
return suggest_unknown_function(&func_name);
}
Some("Unknown function. Check the function name and arguments.".to_string())
}
TemplateErrorKind::SyntaxError => {
if msg.contains("}") || msg.contains("%") {
Some(
"Check bracket matching: `{{ }}` for expressions, `{% %}` for statements, `{# #}` for comments".to_string(),
)
} else if msg.contains("expected") {
Some(
"Syntax error. Check for missing closing tags or mismatched brackets."
.to_string(),
)
} else {
None
}
}
TemplateErrorKind::TypeError => {
if msg.to_lowercase().contains("not iterable") {
Some(suggest_iteration_fix("object"))
} else if msg.to_lowercase().contains("not callable") {
Some(
"Use `{{ value }}` for variables, `{{ func() }}` for function calls."
.to_string(),
)
} else {
None
}
}
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IssueSeverity {
Warning,
Error,
}
#[derive(Debug, Clone)]
pub struct RenderIssue {
pub category: String,
pub message: String,
pub severity: IssueSeverity,
}
impl RenderIssue {
pub fn warning(category: impl Into<String>, message: impl Into<String>) -> Self {
Self {
category: category.into(),
message: message.into(),
severity: IssueSeverity::Warning,
}
}
pub fn error(category: impl Into<String>, message: impl Into<String>) -> Self {
Self {
category: category.into(),
message: message.into(),
severity: IssueSeverity::Error,
}
}
}
#[derive(Debug, Default)]
pub struct RenderReport {
pub errors_by_template: IndexMap<String, Vec<TemplateError>>,
pub successful_templates: Vec<String>,
pub total_errors: usize,
pub issues: Vec<RenderIssue>,
}
impl RenderReport {
pub fn new() -> Self {
Self::default()
}
pub fn add_error(&mut self, template_name: String, error: TemplateError) {
self.errors_by_template
.entry(template_name)
.or_default()
.push(error);
self.total_errors += 1;
}
pub fn add_success(&mut self, template_name: String) {
self.successful_templates.push(template_name);
}
pub fn add_issue(&mut self, issue: RenderIssue) {
self.issues.push(issue);
}
pub fn add_warning(&mut self, category: impl Into<String>, message: impl Into<String>) {
self.issues.push(RenderIssue::warning(category, message));
}
pub fn has_errors(&self) -> bool {
self.total_errors > 0
}
pub fn has_warnings(&self) -> bool {
self.issues
.iter()
.any(|i| i.severity == IssueSeverity::Warning)
}
pub fn has_issues(&self) -> bool {
!self.issues.is_empty()
}
pub fn warnings(&self) -> impl Iterator<Item = &RenderIssue> {
self.issues
.iter()
.filter(|i| i.severity == IssueSeverity::Warning)
}
pub fn templates_with_errors(&self) -> usize {
self.errors_by_template.len()
}
pub fn summary(&self) -> String {
let template_word = if self.templates_with_errors() == 1 {
"template"
} else {
"templates"
};
let error_word = if self.total_errors == 1 {
"error"
} else {
"errors"
};
let base = format!(
"{} {} in {} {}",
self.total_errors,
error_word,
self.templates_with_errors(),
template_word
);
let warning_count = self.warnings().count();
if warning_count > 0 {
let warning_word = if warning_count == 1 {
"warning"
} else {
"warnings"
};
format!("{}, {} {}", base, warning_count, warning_word)
} else {
base
}
}
}
#[derive(Debug)]
pub struct RenderResultWithReport {
pub manifests: IndexMap<String, String>,
pub notes: Option<String>,
pub report: RenderReport,
}
impl RenderResultWithReport {
pub fn is_success(&self) -> bool {
!self.report.has_errors()
}
}
pub type Result<T> = std::result::Result<T, EngineError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_report_new() {
let report = RenderReport::new();
assert!(!report.has_errors());
assert_eq!(report.total_errors, 0);
assert_eq!(report.templates_with_errors(), 0);
assert!(report.successful_templates.is_empty());
}
#[test]
fn test_render_report_add_error() {
let mut report = RenderReport::new();
let error = TemplateError::simple("test error");
report.add_error("template.yaml".to_string(), error);
assert!(report.has_errors());
assert_eq!(report.total_errors, 1);
assert_eq!(report.templates_with_errors(), 1);
}
#[test]
fn test_render_report_multiple_errors_same_template() {
let mut report = RenderReport::new();
report.add_error(
"template.yaml".to_string(),
TemplateError::simple("error 1"),
);
report.add_error(
"template.yaml".to_string(),
TemplateError::simple("error 2"),
);
assert_eq!(report.total_errors, 2);
assert_eq!(report.templates_with_errors(), 1);
assert_eq!(report.errors_by_template["template.yaml"].len(), 2);
}
#[test]
fn test_render_report_multiple_templates() {
let mut report = RenderReport::new();
report.add_error("a.yaml".to_string(), TemplateError::simple("error 1"));
report.add_error("b.yaml".to_string(), TemplateError::simple("error 2"));
report.add_error("c.yaml".to_string(), TemplateError::simple("error 3"));
assert_eq!(report.total_errors, 3);
assert_eq!(report.templates_with_errors(), 3);
}
#[test]
fn test_render_report_add_success() {
let mut report = RenderReport::new();
report.add_success("good.yaml".to_string());
report.add_success("also-good.yaml".to_string());
assert!(!report.has_errors());
assert_eq!(report.successful_templates.len(), 2);
}
#[test]
fn test_render_report_summary_singular() {
let mut report = RenderReport::new();
report.add_error("template.yaml".to_string(), TemplateError::simple("error"));
assert_eq!(report.summary(), "1 error in 1 template");
}
#[test]
fn test_render_report_summary_plural() {
let mut report = RenderReport::new();
report.add_error("a.yaml".to_string(), TemplateError::simple("error 1"));
report.add_error("a.yaml".to_string(), TemplateError::simple("error 2"));
report.add_error("b.yaml".to_string(), TemplateError::simple("error 3"));
assert_eq!(report.summary(), "3 errors in 2 templates");
}
#[test]
fn test_render_result_with_report_success() {
let result = RenderResultWithReport {
manifests: IndexMap::new(),
notes: None,
report: RenderReport::new(),
};
assert!(result.is_success());
}
#[test]
fn test_render_result_with_report_failure() {
let mut report = RenderReport::new();
report.add_error("test.yaml".to_string(), TemplateError::simple("error"));
let result = RenderResultWithReport {
manifests: IndexMap::new(),
notes: None,
report,
};
assert!(!result.is_success());
}
#[test]
fn test_template_error_simple() {
let error = TemplateError::simple("test message");
assert_eq!(error.message, "test message");
assert_eq!(error.kind, TemplateErrorKind::Other);
assert!(error.suggestion.is_none());
}
#[test]
fn test_template_error_with_suggestion() {
let error = TemplateError::simple("test").with_suggestion("try this");
assert_eq!(error.suggestion, Some("try this".to_string()));
}
#[test]
fn test_template_error_with_context() {
let error = TemplateError::simple("test").with_context("additional info");
assert_eq!(error.context, Some("additional info".to_string()));
}
#[test]
fn test_template_error_kind() {
let error = TemplateError {
message: "test".to_string(),
kind: TemplateErrorKind::UndefinedVariable,
src: NamedSource::new("test", String::new()),
span: None,
suggestion: None,
context: None,
};
assert_eq!(error.kind(), TemplateErrorKind::UndefinedVariable);
}
#[test]
fn test_template_error_kind_to_code_string() {
assert_eq!(
TemplateErrorKind::UndefinedVariable.to_code_string(),
"undefined_variable"
);
assert_eq!(
TemplateErrorKind::UnknownFilter.to_code_string(),
"unknown_filter"
);
assert_eq!(TemplateErrorKind::SyntaxError.to_code_string(), "syntax");
}
#[test]
fn test_extract_expression_from_display_with_marker() {
let display = r#"
8 > typo: {{ value.app.name }}
i ^^^^^^^^^ undefined value
"#;
let expr = extract_expression_from_display(display);
assert_eq!(expr, Some("value.app.name".to_string()));
}
#[test]
fn test_extract_expression_with_filter() {
let display = r#"
8 > data: {{ values.app.name | upper }}
i ^^^^^ unknown filter
"#;
let expr = extract_expression_from_display(display);
assert_eq!(expr, Some("values.app.name".to_string()));
}
#[test]
fn test_extract_filter_from_display() {
let display = r#"
8 > data: {{ values.name | toyml }}
i ^^^^^ unknown filter
"#;
let filter = extract_filter_from_display(display);
assert_eq!(filter, Some("toyml".to_string()));
}
#[test]
fn test_render_issue_warning() {
let issue = RenderIssue::warning("files_api", "Files API unavailable");
assert_eq!(issue.category, "files_api");
assert_eq!(issue.message, "Files API unavailable");
assert_eq!(issue.severity, IssueSeverity::Warning);
}
#[test]
fn test_render_issue_error() {
let issue = RenderIssue::error("subchart", "Failed to load subchart");
assert_eq!(issue.category, "subchart");
assert_eq!(issue.severity, IssueSeverity::Error);
}
#[test]
fn test_render_report_add_warning() {
let mut report = RenderReport::new();
report.add_warning("test_category", "test warning message");
assert!(report.has_warnings());
assert!(report.has_issues());
assert!(!report.has_errors());
let warnings: Vec<_> = report.warnings().collect();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].category, "test_category");
assert_eq!(warnings[0].message, "test warning message");
}
#[test]
fn test_render_report_summary_with_warnings() {
let mut report = RenderReport::new();
report.add_error("a.yaml".to_string(), TemplateError::simple("error"));
report.add_warning("files_api", "Files unavailable");
let summary = report.summary();
assert!(summary.contains("1 error"));
assert!(summary.contains("1 warning"));
}
#[test]
fn test_render_report_multiple_warnings() {
let mut report = RenderReport::new();
report.add_warning("files_api", "warning 1");
report.add_warning("subchart", "warning 2");
report.add_issue(RenderIssue::error("critical", "an error"));
assert_eq!(report.warnings().count(), 2);
assert_eq!(report.issues.len(), 3);
}
}