agpm_cli/templating/
error.rs

1//! Enhanced template error handling for AGPM
2//!
3//! This module provides structured error types for template rendering with detailed
4//! context information and user-friendly formatting.
5
6use std::path::PathBuf;
7
8use super::renderer::DependencyChainEntry;
9use crate::core::ResourceType;
10
11/// Maximum number of variable groups to display in error messages.
12/// This prevents overwhelming users with too many variables when showing available options.
13const MAX_VARIABLE_GROUPS_TO_DISPLAY: usize = 5;
14
15/// Enhanced template errors with detailed context
16#[derive(Debug)]
17pub enum TemplateError {
18    /// Variable referenced in template was not found in context
19    VariableNotFound {
20        /// The name of the variable that was not found in the template context
21        variable: String,
22        /// Complete list of variables available in the current template context
23        available_variables: Box<Vec<String>>,
24        /// Suggested similar variable names based on Levenshtein distance analysis
25        suggestions: Box<Vec<String>>,
26        /// Location information including resource, file path, and dependency chain
27        location: Box<ErrorLocation>,
28    },
29
30    /// Circular dependency detected in template rendering
31    CircularDependency {
32        /// Complete dependency chain showing the circular reference path
33        chain: Box<Vec<DependencyChainEntry>>,
34    },
35
36    /// Template syntax parsing or validation error
37    SyntaxError {
38        /// Human-readable description of the syntax error
39        message: String,
40        /// Location information including resource, file path, and dependency chain
41        location: Box<ErrorLocation>,
42    },
43
44    /// Failed to render a dependency template
45    DependencyRenderFailed {
46        /// Name/identifier of the dependency that failed to render
47        dependency: String,
48        /// Underlying error that caused the render failure
49        source: Box<dyn std::error::Error + Send + Sync>,
50        /// Location information including resource, file path, and dependency chain
51        location: Box<ErrorLocation>,
52    },
53
54    /// Error occurred during content filter processing
55    ContentFilterError {
56        /// Recursion depth when the error occurred (for debugging infinite loops)
57        depth: usize,
58        /// Underlying error that caused the content filter failure
59        source: Box<dyn std::error::Error + Send + Sync>,
60        /// Location information including resource, file path, and dependency chain
61        location: Box<ErrorLocation>,
62    },
63}
64
65/// Location information for template errors
66#[derive(Debug, Clone)]
67pub struct ErrorLocation {
68    /// Resource where error occurred
69    pub resource_name: String,
70    pub resource_type: ResourceType,
71    /// Full dependency chain to this resource
72    pub dependency_chain: Vec<DependencyChainEntry>,
73    /// File path if known
74    pub file_path: Option<PathBuf>,
75    /// Line number if available from Tera
76    pub line_number: Option<usize>,
77    /// Context lines around the error (line_number, content)
78    pub context_lines: Option<Vec<(usize, String)>>,
79}
80
81impl std::fmt::Display for TemplateError {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            TemplateError::VariableNotFound {
85                variable,
86                ..
87            } => {
88                write!(f, "Template variable not found: '{}'", variable)
89            }
90            TemplateError::SyntaxError {
91                message,
92                ..
93            } => {
94                write!(f, "Template syntax error: {}", message)
95            }
96            TemplateError::CircularDependency {
97                chain,
98            } => {
99                if let Some(first) = chain.first() {
100                    write!(f, "Circular dependency detected: {}", first.name)
101                } else {
102                    write!(f, "Circular dependency detected")
103                }
104            }
105            TemplateError::DependencyRenderFailed {
106                dependency,
107                source,
108                ..
109            } => {
110                write!(f, "Failed to render dependency '{}': {}", dependency, source)
111            }
112            TemplateError::ContentFilterError {
113                source,
114                ..
115            } => {
116                write!(f, "Content filter error: {}", source)
117            }
118        }
119    }
120}
121
122impl std::error::Error for TemplateError {
123    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
124        match self {
125            TemplateError::DependencyRenderFailed {
126                source,
127                ..
128            } => Some(source.as_ref()),
129            TemplateError::ContentFilterError {
130                source,
131                ..
132            } => Some(source.as_ref()),
133            _ => None,
134        }
135    }
136}
137
138impl TemplateError {
139    /// Generate user-friendly error message with context and suggestions
140    pub fn format_with_context(&self) -> String {
141        match self {
142            TemplateError::VariableNotFound {
143                variable,
144                available_variables,
145                suggestions,
146                location,
147            } => format_variable_not_found_error(
148                variable,
149                available_variables,
150                suggestions,
151                location,
152            ),
153            TemplateError::CircularDependency {
154                chain,
155            } => format_circular_dependency_error(chain),
156            TemplateError::SyntaxError {
157                message,
158                location,
159            } => format_syntax_error(message, location),
160            TemplateError::DependencyRenderFailed {
161                dependency,
162                source,
163                location: _,
164            } => format_dependency_render_error(dependency, source.as_ref()),
165            TemplateError::ContentFilterError {
166                depth,
167                source,
168                location: _,
169            } => format_content_filter_error(*depth, source.as_ref()),
170        }
171    }
172}
173
174/// Format a detailed "variable not found" error message
175fn format_variable_not_found_error(
176    variable: &str,
177    available_variables: &[String],
178    suggestions: &[String],
179    location: &ErrorLocation,
180) -> String {
181    let mut msg = String::new();
182
183    // Header
184    msg.push_str("ERROR: Template Variable Not Found\n\n");
185
186    // Variable info
187    msg.push_str(&format!("Variable: {}\n\n", variable));
188
189    // Dependency chain
190    if !location.dependency_chain.is_empty() {
191        msg.push_str("Dependency chain:\n");
192        for (i, entry) in location.dependency_chain.iter().enumerate() {
193            let indent = "  ".repeat(i);
194            let arrow = if i > 0 {
195                "└─ "
196            } else {
197                ""
198            };
199            let warning = if i == location.dependency_chain.len() - 1 {
200                " ⚠️ Error occurred here"
201            } else {
202                ""
203            };
204
205            msg.push_str(&format!(
206                "{}{}{}: {}{}\n",
207                indent,
208                arrow,
209                format_resource_type(&entry.resource_type),
210                entry.name,
211                warning
212            ));
213        }
214        msg.push('\n');
215    }
216
217    // Suggestions based on variable name analysis
218    if variable.starts_with("agpm.deps.") {
219        msg.push_str(&format_missing_dependency_suggestion(variable, location));
220    } else if !suggestions.is_empty() {
221        msg.push_str("Did you mean one of these?\n");
222        for suggestion in suggestions.iter() {
223            msg.push_str(&format!("  - {}\n", suggestion));
224        }
225        msg.push('\n');
226    }
227
228    // Available variables (truncated list)
229    if !available_variables.is_empty() {
230        msg.push_str("Available variables in this context:\n");
231
232        // Group by prefix
233        let mut grouped = std::collections::BTreeMap::new();
234        for var in available_variables.iter() {
235            let prefix = var.split('.').next().unwrap_or(var);
236            grouped.entry(prefix).or_insert_with(Vec::new).push(var.clone());
237        }
238
239        for (prefix, vars) in grouped.iter().take(5) {
240            if vars.len() <= 3 {
241                for var in vars {
242                    msg.push_str(&format!("  {}\n", var));
243                }
244            } else {
245                msg.push_str(&format!("  {}.*  ({} variables)\n", prefix, vars.len()));
246            }
247        }
248
249        if grouped.len() > MAX_VARIABLE_GROUPS_TO_DISPLAY {
250            msg.push_str(&format!("  ... and {} more\n", grouped.len() - 5));
251        }
252        msg.push('\n');
253    }
254
255    msg
256}
257
258/// Format suggestion for missing dependency declaration
259fn format_missing_dependency_suggestion(variable: &str, location: &ErrorLocation) -> String {
260    // Parse variable name: agpm.deps.<type>.<name>.<property>
261    let parts: Vec<&str> = variable.split('.').collect();
262    if parts.len() < 4 || parts[0] != "agpm" || parts[1] != "deps" {
263        return String::new();
264    }
265
266    let dep_type = parts[2]; // "snippets", "agents", etc.
267    let dep_name = parts[3]; // "plugin_lifecycle_guide", etc.
268
269    // Convert snake_case back to potential file name
270    // (heuristic: replace _ with -)
271    let suggested_filename = dep_name.replace('_', "-");
272
273    let mut msg = String::new();
274    msg.push_str(&format!(
275        "Suggestion: '{}' references '{}' but doesn't declare it as a dependency.\n\n",
276        location.resource_name, dep_name
277    ));
278
279    msg.push_str(&format!("Fix: Add this to {} frontmatter:\n\n", location.resource_name));
280    msg.push_str("---\n");
281    msg.push_str("agpm:\n");
282    msg.push_str("  templating: true\n");
283    msg.push_str("dependencies:\n");
284    msg.push_str(&format!("  {}:\n", dep_type));
285    msg.push_str(&format!("    - path: ./{}.md\n", suggested_filename));
286    msg.push_str("      install: false\n");
287    msg.push_str("---\n\n");
288
289    msg.push_str("Note: Adjust the path based on actual file location.\n\n");
290
291    msg
292}
293
294/// Format circular dependency error
295fn format_circular_dependency_error(chain: &[DependencyChainEntry]) -> String {
296    let mut msg = String::new();
297
298    msg.push_str("ERROR: Circular Dependency Detected\n\n");
299    msg.push_str("A resource is attempting to include itself through a chain of dependencies.\n\n");
300
301    msg.push_str("Circular chain:\n");
302    for entry in chain.iter() {
303        msg.push_str(&format!(
304            "  {} ({})\n",
305            entry.name,
306            format_resource_type(&entry.resource_type)
307        ));
308        msg.push_str("  ↓\n");
309    }
310    msg.push_str(&format!("  {} (circular reference)\n\n", chain[0].name));
311
312    msg.push_str("Suggestion: Remove the dependency that creates the cycle.\n");
313    msg.push_str("Consider refactoring shared content into a separate resource.\n\n");
314
315    msg
316}
317
318/// Format syntax error
319fn format_syntax_error(message: &str, location: &ErrorLocation) -> String {
320    let mut msg = String::new();
321
322    msg.push_str("ERROR: Template syntax error\n\n");
323    msg.push_str(&format!("Error: {}\n", message));
324
325    // Display context lines if available
326    if let Some(ref context_lines) = location.context_lines {
327        if !context_lines.is_empty() {
328            msg.push('\n');
329            let error_line = location.line_number;
330
331            for (line_num, content) in context_lines {
332                let is_error_line = error_line == Some(*line_num);
333
334                if is_error_line {
335                    msg.push_str(&format!("→ {:4} | {}\n", line_num, content));
336                } else {
337                    msg.push_str(&format!("  {:4} | {}\n", line_num, content));
338                }
339            }
340            msg.push('\n');
341        }
342    }
343
344    if !location.dependency_chain.is_empty() {
345        msg.push_str("\nDependency chain:\n");
346        for entry in &location.dependency_chain {
347            msg.push_str(&format!(
348                "  {} ({})\n",
349                entry.name,
350                format_resource_type(&entry.resource_type)
351            ));
352        }
353    }
354
355    msg.push_str("\nSuggestion: Check template syntax for unclosed tags or invalid expressions.\n");
356    msg.push_str("Common issues:\n");
357    msg.push_str("  - Unclosed {{ }} or {% %} delimiters\n");
358    msg.push_str("  - Invalid filter names\n");
359    msg.push_str("  - Missing quotes around string values\n\n");
360
361    msg
362}
363
364/// Format dependency render error
365fn format_dependency_render_error(
366    dependency: &str,
367    source: &(dyn std::error::Error + Send + Sync),
368) -> String {
369    let mut msg = String::new();
370
371    msg.push_str("ERROR: Dependency Render Failed\n\n");
372    msg.push_str(&format!("Dependency: {}\n", dependency));
373    msg.push_str(&format!("Error: {}\n\n", source));
374
375    msg.push_str("Suggestion: Check the dependency file for template errors.\n");
376    msg.push_str("The dependency may contain invalid template syntax or missing variables.\n\n");
377
378    msg
379}
380
381/// Format content filter error
382fn format_content_filter_error(
383    depth: usize,
384    source: &(dyn std::error::Error + Send + Sync),
385) -> String {
386    let mut msg = String::new();
387
388    msg.push_str("ERROR: Content Filter Error\n\n");
389    msg.push_str(&format!("Depth: {}\n", depth));
390    msg.push_str(&format!("Error: {}\n\n", source));
391
392    msg.push_str("Suggestion: Check the file being included by the content filter.\n");
393    msg.push_str("The included file may contain template errors or circular dependencies.\n\n");
394
395    msg
396}
397
398/// Format a resource type as a human-readable string.
399///
400/// Converts the ResourceType enum to lowercase string representation
401/// for use in error messages and user-facing output.
402///
403/// # Arguments
404///
405/// * `rt` - The ResourceType enum value to format
406///
407/// # Returns
408///
409/// String representation of the resource type in lowercase:
410/// - "agent" for ResourceType::Agent
411/// - "command" for ResourceType::Command
412/// - "snippet" for ResourceType::Snippet
413/// - "hook" for ResourceType::Hook
414/// - "script" for ResourceType::Script
415/// - "mcp-server" for ResourceType::McpServer
416///
417/// # Examples
418///
419/// ```rust
420/// use agpm_cli::core::ResourceType;
421/// use agpm_cli::templating::error::format_resource_type;
422///
423/// let agent_type = ResourceType::Agent;
424/// assert_eq!(format_resource_type(&agent_type), "agent");
425/// ```
426pub fn format_resource_type(rt: &ResourceType) -> String {
427    match rt {
428        ResourceType::Agent => "agent",
429        ResourceType::Command => "command",
430        ResourceType::Snippet => "snippet",
431        ResourceType::Hook => "hook",
432        ResourceType::Script => "script",
433        ResourceType::McpServer => "mcp-server",
434        ResourceType::Skill => "skill",
435    }
436    .to_string()
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use crate::core::ResourceType;
443    use crate::templating::renderer::DependencyChainEntry;
444    use std::error::Error;
445
446    #[test]
447    fn test_template_error_variable_not_found() {
448        let error = TemplateError::VariableNotFound {
449            variable: "missing_var".to_string(),
450            available_variables: Box::new(vec![
451                "var1".to_string(),
452                "var2".to_string(),
453                "similar_var".to_string(),
454            ]),
455            suggestions: Box::new(vec![
456                "Did you mean 'similar_var'?".to_string(),
457                "Check variable spelling".to_string(),
458            ]),
459            location: Box::new(ErrorLocation {
460                resource_name: "test-agent".to_string(),
461                resource_type: ResourceType::Agent,
462                dependency_chain: vec![DependencyChainEntry {
463                    name: "agent1".to_string(),
464                    resource_type: ResourceType::Agent,
465                    path: Some("agents/agent1.md".to_string()),
466                }],
467                file_path: None,
468                line_number: Some(10),
469                context_lines: None,
470            }),
471        };
472
473        let formatted = error.format_with_context();
474
475        // Check error-specific content (resource context shown in installer header)
476        assert!(formatted.contains("Template Variable Not Found"));
477        assert!(formatted.contains("missing_var"));
478        assert!(formatted.contains("agent1")); // Dependency chain is shown
479        assert!(formatted.contains("similar_var")); // Available variable
480    }
481
482    #[test]
483    fn test_template_error_circular_dependency() {
484        let error = TemplateError::CircularDependency {
485            chain: Box::new(vec![
486                DependencyChainEntry {
487                    name: "agent-a".to_string(),
488                    resource_type: ResourceType::Agent,
489                    path: Some("agents/agent-a.md".to_string()),
490                },
491                DependencyChainEntry {
492                    name: "agent-b".to_string(),
493                    resource_type: ResourceType::Agent,
494                    path: Some("agents/agent-b.md".to_string()),
495                },
496                DependencyChainEntry {
497                    name: "agent-a".to_string(),
498                    resource_type: ResourceType::Agent,
499                    path: Some("agents/agent-a.md".to_string()),
500                },
501            ]),
502        };
503
504        let formatted = error.format_with_context();
505
506        assert!(formatted.contains("Circular Dependency"));
507        assert!(formatted.contains("agent-a"));
508        assert!(formatted.contains("agent-b"));
509    }
510
511    #[test]
512    fn test_template_error_syntax_error() {
513        let error = TemplateError::SyntaxError {
514            message: "Unexpected end of template".to_string(),
515            location: Box::new(ErrorLocation {
516                resource_name: "test-snippet".to_string(),
517                resource_type: ResourceType::Snippet,
518                dependency_chain: vec![],
519                file_path: None,
520                line_number: Some(25),
521                context_lines: None,
522            }),
523        };
524
525        let formatted = error.format_with_context();
526
527        // Check error-specific content (resource name/line shown in installer header)
528        assert!(formatted.contains("Template syntax error"));
529        assert!(formatted.contains("Unexpected end of template"));
530        assert!(formatted.contains("Suggestion"));
531    }
532
533    #[test]
534    fn test_template_error_dependency_render_failed() {
535        let source_error = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
536        let error = TemplateError::DependencyRenderFailed {
537            dependency: "helper-agent".to_string(),
538            source: Box::new(source_error),
539            location: Box::new(ErrorLocation {
540                resource_name: "main-agent".to_string(),
541                resource_type: ResourceType::Agent,
542                dependency_chain: vec![DependencyChainEntry {
543                    name: "helper-agent".to_string(),
544                    resource_type: ResourceType::Agent,
545                    path: Some("agents/helper-agent.md".to_string()),
546                }],
547                file_path: None,
548                line_number: None,
549                context_lines: None,
550            }),
551        };
552
553        let formatted = error.format_with_context();
554
555        // Check error-specific content (resource context shown in installer header)
556        assert!(formatted.contains("Dependency Render Failed"));
557        assert!(formatted.contains("helper-agent"));
558        assert!(formatted.contains("File not found"));
559        assert!(formatted.contains("Suggestion"));
560    }
561
562    #[test]
563    fn test_template_error_content_filter_error() {
564        let source_error =
565            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Access denied");
566        let error = TemplateError::ContentFilterError {
567            depth: 5,
568            source: Box::new(source_error),
569            location: Box::new(ErrorLocation {
570                resource_name: "test-script".to_string(),
571                resource_type: ResourceType::Script,
572                dependency_chain: vec![],
573                file_path: None,
574                line_number: Some(15),
575                context_lines: None,
576            }),
577        };
578
579        let formatted = error.format_with_context();
580
581        // Check error-specific content (resource context shown in installer header)
582        assert!(formatted.contains("Content Filter Error"));
583        assert!(formatted.contains("Depth: 5"));
584        assert!(formatted.contains("Access denied"));
585        assert!(formatted.contains("Suggestion"));
586    }
587
588    #[test]
589    fn test_error_location_with_line_number() {
590        let location = ErrorLocation {
591            resource_name: "test-resource".to_string(),
592            resource_type: ResourceType::McpServer,
593            dependency_chain: vec![DependencyChainEntry {
594                name: "dep1".to_string(),
595                resource_type: ResourceType::Agent,
596                path: Some("agents/dep1.md".to_string()),
597            }],
598            file_path: Some(std::path::PathBuf::from("agents/test.md")),
599            line_number: Some(42),
600            context_lines: None,
601        };
602
603        assert_eq!(location.resource_name, "test-resource");
604        assert_eq!(location.resource_type, ResourceType::McpServer);
605        assert_eq!(location.dependency_chain.len(), 1);
606        assert_eq!(location.file_path.as_ref().unwrap().to_str().unwrap(), "agents/test.md");
607        assert_eq!(location.line_number, Some(42));
608    }
609
610    #[test]
611    fn test_error_location_without_line_number() {
612        let location = ErrorLocation {
613            resource_name: "test-resource".to_string(),
614            resource_type: ResourceType::Command,
615            dependency_chain: vec![],
616            file_path: None,
617            line_number: None,
618            context_lines: None,
619        };
620
621        assert_eq!(location.resource_name, "test-resource");
622        assert_eq!(location.resource_type, ResourceType::Command);
623        assert!(location.dependency_chain.is_empty());
624        assert!(location.file_path.is_none());
625        assert!(location.line_number.is_none());
626    }
627
628    #[test]
629    fn test_format_resource_type() {
630        assert_eq!(format_resource_type(&ResourceType::Agent), "agent");
631        assert_eq!(format_resource_type(&ResourceType::Snippet), "snippet");
632        assert_eq!(format_resource_type(&ResourceType::Command), "command");
633        assert_eq!(format_resource_type(&ResourceType::McpServer), "mcp-server");
634        assert_eq!(format_resource_type(&ResourceType::Script), "script");
635        assert_eq!(format_resource_type(&ResourceType::Hook), "hook");
636    }
637
638    #[test]
639    fn test_template_error_source() {
640        let io_error = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Access denied");
641        let error = TemplateError::DependencyRenderFailed {
642            dependency: "test-dep".to_string(),
643            source: Box::new(io_error),
644            location: Box::new(ErrorLocation {
645                resource_name: "test-resource".to_string(),
646                resource_type: ResourceType::Agent,
647                dependency_chain: vec![],
648                file_path: None,
649                line_number: None,
650                context_lines: None,
651            }),
652        };
653
654        // Test that the error implements std::error::Error
655        let source = error.source();
656        assert!(source.is_some());
657    }
658}