Skip to main content

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
343    for (idx, line) in source.lines().enumerate() {
344        let current_line = idx + 1;
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    }
351
352    None
353}
354
355/// Generate context-aware suggestions based on error kind
356fn 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            // Try to extract variable name from detailed display first
367            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                // Check for common typo: "value" instead of "values"
372                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                // Check for property access on values
381                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                        // Navigate to find where the path breaks
386                        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                                // This part doesn't exist - suggest alternatives
395                                if let Some(obj) = current.as_object() {
396                                    let available: Vec<&str> =
397                                        obj.keys().map(|s| s.as_str()).collect();
398
399                                    // Find closest match
400                                    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                // General undefined variable suggestion
440                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            // Try to extract filter name from detailed display first
457            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/// Severity level for render issues
509#[derive(Debug, Clone, Copy, PartialEq, Eq)]
510pub enum IssueSeverity {
511    /// Warning - doesn't prevent rendering but may cause issues
512    Warning,
513    /// Error - prevents successful rendering
514    Error,
515}
516
517/// A render issue (warning or infrastructure problem)
518#[derive(Debug, Clone)]
519pub struct RenderIssue {
520    /// Issue category (e.g., "files_api", "subchart")
521    pub category: String,
522    /// Human-readable message
523    pub message: String,
524    /// Severity level
525    pub severity: IssueSeverity,
526}
527
528impl RenderIssue {
529    /// Create a new warning
530    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    /// Create a new error
539    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/// A collection of errors from rendering multiple templates
549#[derive(Debug, Default)]
550pub struct RenderReport {
551    /// Errors grouped by template file (IndexMap preserves insertion order)
552    pub errors_by_template: IndexMap<String, Vec<TemplateError>>,
553
554    /// Successfully rendered templates
555    pub successful_templates: Vec<String>,
556
557    /// Total error count
558    pub total_errors: usize,
559
560    /// Infrastructure issues (warnings/errors not tied to specific templates)
561    pub issues: Vec<RenderIssue>,
562}
563
564impl RenderReport {
565    /// Create a new empty report
566    pub fn new() -> Self {
567        Self::default()
568    }
569
570    /// Add an error for a specific template
571    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    /// Mark a template as successfully rendered
580    pub fn add_success(&mut self, template_name: String) {
581        self.successful_templates.push(template_name);
582    }
583
584    /// Add an infrastructure issue (warning or error not tied to a template)
585    pub fn add_issue(&mut self, issue: RenderIssue) {
586        self.issues.push(issue);
587    }
588
589    /// Add a warning
590    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    /// Check if there are any errors
595    pub fn has_errors(&self) -> bool {
596        self.total_errors > 0
597    }
598
599    /// Check if there are any warnings
600    pub fn has_warnings(&self) -> bool {
601        self.issues
602            .iter()
603            .any(|i| i.severity == IssueSeverity::Warning)
604    }
605
606    /// Check if there are any issues (warnings or errors)
607    pub fn has_issues(&self) -> bool {
608        !self.issues.is_empty()
609    }
610
611    /// Get warnings only
612    pub fn warnings(&self) -> impl Iterator<Item = &RenderIssue> {
613        self.issues
614            .iter()
615            .filter(|i| i.severity == IssueSeverity::Warning)
616    }
617
618    /// Get count of templates with errors
619    pub fn templates_with_errors(&self) -> usize {
620        self.errors_by_template.len()
621    }
622
623    /// Generate summary message: "5 errors in 3 templates"
624    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/// Result type that includes both successful renders and collected errors
659#[derive(Debug)]
660pub struct RenderResultWithReport {
661    /// Rendered manifests (may be partial if errors occurred, IndexMap preserves order)
662    pub manifests: IndexMap<String, String>,
663
664    /// Post-install notes
665    pub notes: Option<String>,
666
667    /// Error report (empty if all templates rendered successfully)
668    pub report: RenderReport,
669}
670
671impl RenderResultWithReport {
672    /// Check if rendering was fully successful (no errors)
673    pub fn is_success(&self) -> bool {
674        !self.report.has_errors()
675    }
676}
677
678/// Result type for engine operations
679pub 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()); // Template errors, not issues
887
888        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}