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