agpm_cli/core/
error_formatting.rs

1//! Error formatting utilities for AGPM
2//!
3//! This module provides user-friendly error formatting functions that convert
4//! internal errors into clear, actionable messages for users.
5
6use super::*;
7use crate::core::file_error::FileOperationError;
8
9/// Keywords that indicate template-related errors
10const TEMPLATE_ERROR_KEYWORDS: &[&str] = &["template", "variable", "filter"];
11
12/// Keywords that indicate network-related errors
13const NETWORK_ERROR_KEYWORDS: &[&str] = &["network", "connection", "timeout"];
14
15/// Keywords that indicate git-related errors
16const GIT_ERROR_KEYWORDS: &[&str] = &["git command", "git operation", "git clone", "git fetch"];
17
18/// Keywords that indicate permission-related errors
19const PERMISSION_ERROR_KEYWORDS: &[&str] = &["permission", "denied", "access"];
20
21/// Convert any error into a user-friendly format with contextual suggestions
22///
23/// This function analyzes the error type and provides:
24/// - Clear, actionable error messages
25/// - Specific suggestions based on the error type
26/// - Additional details to help users understand and resolve the issue
27///
28/// # Arguments
29///
30/// * `error` - The error to convert to a user-friendly format
31///
32/// # Returns
33///
34/// An [`ErrorContext`] with user-friendly messages and suggestions
35#[must_use]
36pub fn user_friendly_error(error: anyhow::Error) -> ErrorContext {
37    // Check for specific error types and provide helpful suggestions
38    if let Some(ccmp_error) = error.downcast_ref::<AgpmError>() {
39        return create_error_context(ccmp_error);
40    }
41
42    // Walk the error chain to find specific errors
43    let mut current_error: &dyn std::error::Error = error.as_ref();
44    loop {
45        // Check for AgpmError in the chain (for errors wrapped by anyhow context)
46        if let Some(agpm_error) = current_error.downcast_ref::<AgpmError>() {
47            // Any AgpmError in the chain should be handled by create_error_context
48            // This ensures ManifestNotFound and other specific errors are properly formatted
49            return create_error_context(agpm_error);
50        }
51
52        // Check for TemplateError
53        if let Some(template_error) =
54            current_error.downcast_ref::<crate::templating::TemplateError>()
55        {
56            // Found a TemplateError - use its detailed formatting
57            let formatted = template_error.format_with_context();
58            return ErrorContext::new(AgpmError::Other {
59                message: formatted.clone(),
60            })
61            .with_suggestion("Check your template syntax and variable declarations")
62            .with_details(formatted);
63        }
64
65        // Move to the next error in the chain
66        match current_error.source() {
67            Some(source) => current_error = source,
68            None => break,
69        }
70    }
71
72    if let Some(file_error) = error.downcast_ref::<FileOperationError>() {
73        // Check if the underlying IO error is a permission error
74        if file_error.source.kind() == std::io::ErrorKind::PermissionDenied {
75            return ErrorContext::new(AgpmError::PermissionDenied {
76                operation: file_error.operation.to_string(),
77                path: file_error.file_path.to_string_lossy().to_string(),
78            })
79            .with_suggestion("Check file permissions and try running with appropriate privileges")
80            .with_details(format!(
81                "Permission denied for '{}' on path: {}",
82                file_error.operation,
83                file_error.file_path.display()
84            ));
85        }
86
87        return ErrorContext::new(AgpmError::FileSystemError {
88            operation: file_error.operation.to_string(),
89            path: file_error.file_path.to_string_lossy().to_string(),
90        })
91        .with_suggestion("Check that the path exists and you have the necessary permissions")
92        .with_details(format!(
93            "Failed to {} at path: {}",
94            file_error.operation,
95            file_error.file_path.display()
96        ));
97    }
98
99    if let Some(io_error) = error.downcast_ref::<std::io::Error>() {
100        match io_error.kind() {
101            std::io::ErrorKind::PermissionDenied => {
102                return create_error_context(&AgpmError::PermissionDenied {
103                    operation: "file access".to_string(),
104                    path: "file path not specified in error context".to_string(),
105                });
106            }
107            std::io::ErrorKind::NotFound => {
108                return create_error_context(&AgpmError::FileSystemError {
109                    operation: "file not found".to_string(),
110                    path: "file path not specified in error context".to_string(),
111                });
112            }
113            std::io::ErrorKind::AlreadyExists => {
114                return create_error_context(&AgpmError::FileSystemError {
115                    operation: "file creation".to_string(),
116                    path: "file path not specified in error context".to_string(),
117                });
118            }
119            _ => {
120                return ErrorContext::new(AgpmError::FileSystemError {
121                    operation: "file operation".to_string(),
122                    path: "unknown path".to_string(),
123                })
124                .with_suggestion("Check file permissions and disk space")
125                .with_details(format!("IO error: {}", io_error));
126            }
127        }
128    }
129
130    // Walk the error chain again to check for specific error messages
131    let mut current_error: &dyn std::error::Error = error.as_ref();
132    loop {
133        let error_msg = current_error.to_string();
134
135        // Check for version resolution errors with no matching tags
136        if error_msg.contains("No tags found") || error_msg.contains("No tag found") {
137            return ErrorContext::new(AgpmError::Other {
138                message: error_msg.clone(),
139            })
140            .with_suggestion("Check available tags with 'git tag -l' in the source repository, or adjust your version constraint")
141            .with_details("No tags match the requested version constraint");
142        }
143
144        // Move to the next error in the chain
145        match current_error.source() {
146            Some(source) => current_error = source,
147            None => break,
148        }
149    }
150
151    // Try to extract context from the top-level error message
152    let error_msg = error.to_string();
153
154    // Check for template-related errors
155    if TEMPLATE_ERROR_KEYWORDS.iter().any(|&keyword| error_msg.contains(keyword)) {
156        return ErrorContext::new(AgpmError::Other {
157            message: format!("Template error: {}", error_msg),
158        })
159        .with_suggestion("Check your template syntax and variable names")
160        .with_details("Template rendering failed. Make sure all variables are defined and the syntax is correct.");
161    }
162
163    // Check for network-related errors
164    if NETWORK_ERROR_KEYWORDS.iter().any(|&keyword| error_msg.contains(keyword)) {
165        return ErrorContext::new(AgpmError::NetworkError {
166            operation: "network request".to_string(),
167            reason: error_msg.clone(),
168        })
169        .with_suggestion("Check your internet connection and try again")
170        .with_details("A network operation failed. Please verify your connection and retry.");
171    }
172
173    // Check for git-related errors
174    if GIT_ERROR_KEYWORDS.iter().any(|&keyword| error_msg.contains(keyword)) {
175        return ErrorContext::new(AgpmError::GitCommandError {
176            operation: "git operation".to_string(),
177            stderr: error_msg.clone(),
178        })
179        .with_suggestion("Ensure git is installed and configured correctly")
180        .with_details(
181            "A git operation failed. Check that git is in your PATH and properly configured.",
182        );
183    }
184
185    // Check for permission-related errors
186    // Preserve the original error message to maintain context about what operation failed
187    if PERMISSION_ERROR_KEYWORDS.iter().any(|&keyword| error_msg.contains(keyword)) {
188        return ErrorContext::new(AgpmError::Other {
189            message: error_msg.clone(),
190        })
191        .with_suggestion("Check file permissions and try running with appropriate privileges")
192        .with_details("Permission was denied for the requested operation.");
193    }
194
195    // Default fallback for unknown errors
196    ErrorContext::new(AgpmError::Other {
197        message: error_msg,
198    })
199    .with_suggestion("Check the error message above for more details")
200    .with_details("An unexpected error occurred. Please report this issue if it persists.")
201}
202
203/// Create a user-friendly error context from an [`AgpmError`]
204///
205/// This function analyzes the error type and provides:
206/// - Clear, actionable error messages
207/// - Specific suggestions based on the error type
208/// - Additional details to help users understand and resolve the issue
209pub fn create_error_context(error: &AgpmError) -> ErrorContext {
210    match &error {
211        AgpmError::GitNotFound => ErrorContext::new(AgpmError::GitNotFound)
212            .with_suggestion("Install git from https://git-scm.com/ or your package manager")
213            .with_details("AGPM requires git to be installed and available in your PATH"),
214        AgpmError::ManifestNotFound => ErrorContext::new(AgpmError::ManifestNotFound)
215            .with_suggestion("Run 'agpm init' to create a new manifest, or navigate to a directory with an existing agpm.toml")
216            .with_details("AGPM searches for agpm.toml in the current directory and parent directories"),
217        AgpmError::GitCommandError {
218            operation,
219            stderr,
220        } => {
221            let suggestion = match operation.as_str() {
222                "fetch" => "Check your internet connection and try again",
223                "checkout" => "Verify the branch, tag, or commit reference exists",
224                "pull" => "Check your git configuration and remote settings",
225                "clone" => "Verify the repository URL and your network connection",
226                _ => "Ensure git is properly configured and try again",
227            };
228            ErrorContext::new(AgpmError::GitCommandError {
229                operation: operation.clone(),
230                stderr: stderr.clone(),
231            })
232            .with_suggestion(suggestion)
233            .with_details(format!("Git {} operation failed: {}", operation, stderr))
234        }
235        AgpmError::GitCloneFailed {
236            url,
237            reason,
238        } => ErrorContext::new(AgpmError::GitCloneFailed {
239            url: url.clone(),
240            reason: reason.clone(),
241        })
242        .with_suggestion(format!("Verify the repository URL '{}' is correct and accessible", url))
243        .with_details(format!("Failed to clone repository: {}", reason)),
244        AgpmError::ResourceNotFound {
245            name,
246        } => ErrorContext::new(AgpmError::ResourceNotFound {
247            name: name.clone(),
248        })
249        .with_suggestion("Check that the resource is installed and available")
250        .with_details(format!("Resource '{}' not found", name)),
251        AgpmError::ResourceFileNotFound {
252            path,
253            source_name,
254        } => ErrorContext::new(AgpmError::ResourceFileNotFound {
255            path: path.clone(),
256            source_name: source_name.clone(),
257        })
258        .with_suggestion(format!(
259            "Check that '{}' exists in source '{}' and the version/tag is correct",
260            path, source_name
261        ))
262        .with_details(format!("Resource file '{}' not found in source '{}'", path, source_name)),
263        AgpmError::ManifestParseError {
264            file,
265            reason,
266        } => ErrorContext::new(AgpmError::ManifestParseError {
267            file: file.clone(),
268            reason: reason.clone(),
269        })
270        .with_suggestion(format!("Check the syntax in '{}' - TOML format must be valid", file))
271        .with_details(format!("Failed to parse manifest file: {}", reason)),
272        AgpmError::FileSystemError {
273            operation,
274            path,
275        } => ErrorContext::new(AgpmError::FileSystemError {
276            operation: operation.clone(),
277            path: path.clone(),
278        })
279        .with_suggestion("Check that the path exists and you have the necessary permissions")
280        .with_details(format!("Failed to {} at path: {}", operation, path)),
281        AgpmError::PermissionDenied {
282            operation,
283            path,
284        } => ErrorContext::new(AgpmError::PermissionDenied {
285            operation: operation.clone(),
286            path: path.clone(),
287        })
288        .with_suggestion("Check file permissions and try running with appropriate privileges")
289        .with_details(format!("Permission denied for '{}' on path: {}", operation, path)),
290        AgpmError::DependencyResolutionMismatch {
291            resource,
292            declared_count,
293            resolved_count,
294            declared_deps,
295        } => {
296            let mut details = format!(
297                "Declared {} dependencies in frontmatter:\n",
298                declared_count
299            );
300            for (resource_type, path) in declared_deps {
301                details.push_str(&format!("  - {}: {}\n", resource_type, path));
302            }
303            details.push_str(&format!("\nResolved: {} dependencies", resolved_count));
304
305            ErrorContext::new(AgpmError::DependencyResolutionMismatch {
306                resource: resource.clone(),
307                declared_count: *declared_count,
308                resolved_count: *resolved_count,
309                declared_deps: declared_deps.clone(),
310            })
311            .with_suggestion(
312                "This indicates a bug in dependency resolution. Run with RUST_LOG=debug for more details and report at https://github.com/aig787/agpm/issues",
313            )
314            .with_details(details)
315        }
316        // Default fallback for unhandled error types
317        _ => ErrorContext::new(AgpmError::Other {
318            message: error.to_string(),
319        })
320        .with_suggestion("Check the error message above for more details")
321        .with_details("An unexpected error occurred. Please report this issue if it persists."),
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use std::io;
329
330    #[test]
331    fn test_user_friendly_error_io_permission_denied() {
332        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "Access denied");
333        let error = anyhow::Error::from(io_err);
334        let ctx = user_friendly_error(error);
335
336        // IO permission errors are converted to PermissionDenied variant
337        assert!(matches!(ctx.error, AgpmError::PermissionDenied { .. }));
338        assert!(ctx.suggestion.is_some());
339    }
340
341    #[test]
342    fn test_user_friendly_error_io_not_found() {
343        let io_err = io::Error::new(io::ErrorKind::NotFound, "File not found");
344        let error = anyhow::Error::from(io_err);
345        let ctx = user_friendly_error(error);
346
347        assert!(matches!(ctx.error, AgpmError::FileSystemError { .. }));
348        assert!(ctx.suggestion.is_some());
349    }
350
351    #[test]
352    fn test_user_friendly_error_template_error() {
353        let error = anyhow::Error::msg("Failed to render template: variable 'foo' not found");
354        let ctx = user_friendly_error(error);
355
356        // Template errors become generic errors
357        assert!(ctx.suggestion.is_some());
358    }
359
360    #[test]
361    fn test_user_friendly_error_network_error() {
362        let error = anyhow::Error::msg("Network connection failed");
363        let ctx = user_friendly_error(error);
364
365        assert!(matches!(ctx.error, AgpmError::NetworkError { .. }));
366        assert!(ctx.suggestion.is_some());
367        assert!(ctx.suggestion.unwrap().contains("internet connection"));
368    }
369
370    #[test]
371    fn test_user_friendly_error_git_error() {
372        let error = anyhow::Error::msg("git command failed: repository not found");
373        let ctx = user_friendly_error(error);
374
375        assert!(matches!(ctx.error, AgpmError::GitCommandError { .. }));
376        assert!(ctx.suggestion.is_some());
377        assert!(ctx.suggestion.unwrap().contains("git is installed"));
378    }
379
380    #[test]
381    fn test_user_friendly_error_fallback() {
382        let error = anyhow::Error::msg("Some completely unknown error type");
383        let ctx = user_friendly_error(error);
384
385        assert!(matches!(ctx.error, AgpmError::Other { .. }));
386        assert!(ctx.suggestion.is_some());
387        // The suggestion might vary, so just check it exists
388    }
389
390    #[test]
391    fn test_dependency_resolution_mismatch_error_formatting() {
392        let error = AgpmError::DependencyResolutionMismatch {
393            resource: "agents/my-agent".to_string(),
394            declared_count: 3,
395            resolved_count: 0,
396            declared_deps: vec![
397                ("snippets".to_string(), "../../snippets/styleguide.md".to_string()),
398                ("snippets".to_string(), "../../snippets/tooling.md".to_string()),
399                ("agents".to_string(), "../helper.md".to_string()),
400            ],
401        };
402
403        let ctx = create_error_context(&error);
404
405        // Verify the error is correctly typed
406        assert!(matches!(ctx.error, AgpmError::DependencyResolutionMismatch { .. }));
407
408        // Verify suggestion contains bug report info
409        let suggestion = ctx.suggestion.expect("Should have suggestion");
410        assert!(suggestion.contains("bug"), "Suggestion should mention this is a bug");
411        assert!(suggestion.contains("github"), "Suggestion should point to GitHub issues");
412
413        // Verify details contain the declared dependencies
414        let details = ctx.details.expect("Should have details");
415        assert!(details.contains("Declared 3 dependencies"), "Details should show declared count");
416        assert!(
417            details.contains("snippets: ../../snippets/styleguide.md"),
418            "Details should list declared deps"
419        );
420        assert!(details.contains("Resolved: 0 dependencies"), "Details should show resolved count");
421    }
422}