sherpack_engine/
error.rs

1//! Engine error types with beautiful formatting
2
3// Allow unused_assignments for derive macros (miette/thiserror use fields via attributes)
4#![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/// Main engine error type
17#[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/// Error kind for categorizing template errors
45///
46/// Note: This enum is non-exhaustive - new variants may be added in future versions.
47#[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    /// Convert to a code string for diagnostics
62    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/// Template-specific error with source information
77#[derive(Error, Debug, Diagnostic, Clone)]
78#[error("{message}")]
79#[diagnostic(code(sherpack::template::render))]
80pub struct TemplateError {
81    /// Error message
82    pub message: String,
83
84    /// Error kind for categorization
85    pub kind: TemplateErrorKind,
86
87    /// Template source code
88    #[source_code]
89    pub src: NamedSource<String>,
90
91    /// Error location in source
92    #[label("error occurred here")]
93    pub span: Option<SourceSpan>,
94
95    /// Suggestion for fixing the error
96    #[help]
97    pub suggestion: Option<String>,
98
99    /// Additional context (available values, etc.)
100    pub context: Option<String>,
101}
102
103impl TemplateError {
104    /// Create a new template error from a MiniJinja error
105    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        // Calculate source span from line number
114        let span = line.and_then(|line_num| calculate_span(template_source, line_num));
115
116        // Generate suggestion based on error kind
117        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    /// Create a new template error from a MiniJinja error with enhanced context-aware suggestions
130    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        // Calculate source span from line number
140        let span = line.and_then(|line_num| calculate_span(template_source, line_num));
141
142        // Generate context-aware suggestion
143        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    /// Create a simple error without source mapping
156    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    /// Add context information
168    pub fn with_context(mut self, context: impl Into<String>) -> Self {
169        self.context = Some(context.into());
170        self
171    }
172
173    /// Add a suggestion
174    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
175        self.suggestion = Some(suggestion.into());
176        self
177    }
178
179    /// Get the error kind
180    pub fn kind(&self) -> TemplateErrorKind {
181        self.kind
182    }
183}
184
185/// Categorize a MiniJinja error into our error kinds
186fn categorize_minijinja_error(err: &minijinja::Error) -> (TemplateErrorKind, String) {
187    let msg = err.to_string();
188    let msg_lower = msg.to_lowercase();
189
190    // Get the detailed display which contains more info
191    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            // Fallback to string matching for other cases
204            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    // Extract the actual expression from the detailed error if possible
221    // MiniJinja format shows: "   8 >   typo: {{ value.app.name }}"
222    // followed by: "     i            ^^^^^^^^^ undefined value"
223    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
247/// Extract the problematic expression from MiniJinja's detailed display
248fn extract_expression_from_display(display: &str) -> Option<String> {
249    // MiniJinja format:
250    //    8 >   typo: {{ value.app.name }}
251    //      i            ^^^^^^^^^ undefined value
252    // The `>` marker shows the error line
253
254    let lines: Vec<&str> = display.lines().collect();
255
256    // First, find the line with the `>` marker (error line)
257    for (i, line) in lines.iter().enumerate() {
258        // Look for pattern like "   8 >   " at the start
259        let trimmed = line.trim_start();
260        if trimmed.contains(" > ") || trimmed.starts_with("> ") {
261            // This is the error line - extract expression
262            if let Some(start) = line.find("{{")
263                && let Some(end) = line[start..].find("}}")
264            {
265                let expr = line[start + 2..start + end].trim();
266                // Get the first part before any filter (for undefined var)
267                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        // Also check the line after a ^^^^^ marker for the error line
275        if line.contains("^^^^^") {
276            // The line with ^^^^^ follows the error line, so check i-1
277            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
295/// Extract the filter name from MiniJinja's detailed display
296fn extract_filter_from_display(display: &str) -> Option<String> {
297    // Look for the error line (marked with >) and find the filter
298    // MiniJinja format:
299    //    8 >   badFilter: {{ values.app.name | toyml }}
300    //      i                                   ^^^^^ unknown filter
301
302    let lines: Vec<&str> = display.lines().collect();
303
304    // Find the error line
305    for line in &lines {
306        let trimmed = line.trim_start();
307        if trimmed.contains(" > ") || trimmed.starts_with("> ") {
308            // Look for {{ ... | filter }} pattern
309            if let Some(start) = line.find("{{")
310                && let Some(end) = line[start..].find("}}")
311            {
312                let expr = &line[start + 2..start + end];
313                // Find the pipe and get the filter name
314                if let Some(pipe_pos) = expr.rfind('|') {
315                    let filter_part = expr[pipe_pos + 1..].trim();
316                    // Filter name is the first word
317                    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    // Fallback: look for pattern "unknown filter" in error message
329    for line in &lines {
330        if line.contains("unknown filter") {
331            // Try to extract from the ^^^^^ marker line
332            continue;
333        }
334    }
335
336    None
337}
338
339/// Calculate the source span for a given line number
340fn 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 span for the entire line
347            return Some(SourceSpan::new(offset.into(), line.len()));
348        }
349        offset += line.len() + 1; // +1 for newline
350        current_line += 1;
351    }
352
353    None
354}
355
356/// Generate context-aware suggestions based on error kind
357fn 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            // Try to extract variable name from detailed display first
368            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                // Check for common typo: "value" instead of "values"
373                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                // Check for property access on values
382                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                        // Navigate to find where the path breaks
387                        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                                // This part doesn't exist - suggest alternatives
396                                if let Some(obj) = current.as_object() {
397                                    let available: Vec<&str> =
398                                        obj.keys().map(|s| s.as_str()).collect();
399
400                                    // Find closest match
401                                    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                // General undefined variable suggestion
441                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            // Try to extract filter name from detailed display first
458            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/// Severity level for render issues
510#[derive(Debug, Clone, Copy, PartialEq, Eq)]
511pub enum IssueSeverity {
512    /// Warning - doesn't prevent rendering but may cause issues
513    Warning,
514    /// Error - prevents successful rendering
515    Error,
516}
517
518/// A render issue (warning or infrastructure problem)
519#[derive(Debug, Clone)]
520pub struct RenderIssue {
521    /// Issue category (e.g., "files_api", "subchart")
522    pub category: String,
523    /// Human-readable message
524    pub message: String,
525    /// Severity level
526    pub severity: IssueSeverity,
527}
528
529impl RenderIssue {
530    /// Create a new warning
531    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    /// Create a new error
540    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/// A collection of errors from rendering multiple templates
550#[derive(Debug, Default)]
551pub struct RenderReport {
552    /// Errors grouped by template file (IndexMap preserves insertion order)
553    pub errors_by_template: IndexMap<String, Vec<TemplateError>>,
554
555    /// Successfully rendered templates
556    pub successful_templates: Vec<String>,
557
558    /// Total error count
559    pub total_errors: usize,
560
561    /// Infrastructure issues (warnings/errors not tied to specific templates)
562    pub issues: Vec<RenderIssue>,
563}
564
565impl RenderReport {
566    /// Create a new empty report
567    pub fn new() -> Self {
568        Self::default()
569    }
570
571    /// Add an error for a specific template
572    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    /// Mark a template as successfully rendered
581    pub fn add_success(&mut self, template_name: String) {
582        self.successful_templates.push(template_name);
583    }
584
585    /// Add an infrastructure issue (warning or error not tied to a template)
586    pub fn add_issue(&mut self, issue: RenderIssue) {
587        self.issues.push(issue);
588    }
589
590    /// Add a warning
591    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    /// Check if there are any errors
596    pub fn has_errors(&self) -> bool {
597        self.total_errors > 0
598    }
599
600    /// Check if there are any warnings
601    pub fn has_warnings(&self) -> bool {
602        self.issues
603            .iter()
604            .any(|i| i.severity == IssueSeverity::Warning)
605    }
606
607    /// Check if there are any issues (warnings or errors)
608    pub fn has_issues(&self) -> bool {
609        !self.issues.is_empty()
610    }
611
612    /// Get warnings only
613    pub fn warnings(&self) -> impl Iterator<Item = &RenderIssue> {
614        self.issues
615            .iter()
616            .filter(|i| i.severity == IssueSeverity::Warning)
617    }
618
619    /// Get count of templates with errors
620    pub fn templates_with_errors(&self) -> usize {
621        self.errors_by_template.len()
622    }
623
624    /// Generate summary message: "5 errors in 3 templates"
625    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/// Result type that includes both successful renders and collected errors
660#[derive(Debug)]
661pub struct RenderResultWithReport {
662    /// Rendered manifests (may be partial if errors occurred, IndexMap preserves order)
663    pub manifests: IndexMap<String, String>,
664
665    /// Post-install notes
666    pub notes: Option<String>,
667
668    /// Error report (empty if all templates rendered successfully)
669    pub report: RenderReport,
670}
671
672impl RenderResultWithReport {
673    /// Check if rendering was fully successful (no errors)
674    pub fn is_success(&self) -> bool {
675        !self.report.has_errors()
676    }
677}
678
679/// Result type for engine operations
680pub 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()); // Template errors, not issues
888
889        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}