1use std::path::PathBuf;
7
8use super::renderer::DependencyChainEntry;
9use crate::core::ResourceType;
10
11const MAX_VARIABLE_GROUPS_TO_DISPLAY: usize = 5;
14
15#[derive(Debug)]
17pub enum TemplateError {
18 VariableNotFound {
20 variable: String,
22 available_variables: Box<Vec<String>>,
24 suggestions: Box<Vec<String>>,
26 location: Box<ErrorLocation>,
28 },
29
30 CircularDependency {
32 chain: Box<Vec<DependencyChainEntry>>,
34 },
35
36 SyntaxError {
38 message: String,
40 location: Box<ErrorLocation>,
42 },
43
44 DependencyRenderFailed {
46 dependency: String,
48 source: Box<dyn std::error::Error + Send + Sync>,
50 location: Box<ErrorLocation>,
52 },
53
54 ContentFilterError {
56 depth: usize,
58 source: Box<dyn std::error::Error + Send + Sync>,
60 location: Box<ErrorLocation>,
62 },
63}
64
65#[derive(Debug, Clone)]
67pub struct ErrorLocation {
68 pub resource_name: String,
70 pub resource_type: ResourceType,
71 pub dependency_chain: Vec<DependencyChainEntry>,
73 pub file_path: Option<PathBuf>,
75 pub line_number: Option<usize>,
77 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 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
174fn 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 msg.push_str("ERROR: Template Variable Not Found\n\n");
185
186 msg.push_str(&format!("Variable: {}\n\n", variable));
188
189 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 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 if !available_variables.is_empty() {
230 msg.push_str("Available variables in this context:\n");
231
232 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
258fn format_missing_dependency_suggestion(variable: &str, location: &ErrorLocation) -> String {
260 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]; let dep_name = parts[3]; 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
294fn 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
318fn 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 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
364fn 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
381fn 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
398pub 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 assert!(formatted.contains("Template Variable Not Found"));
477 assert!(formatted.contains("missing_var"));
478 assert!(formatted.contains("agent1")); assert!(formatted.contains("similar_var")); }
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 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 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 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 let source = error.source();
656 assert!(source.is_some());
657 }
658}