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    if PERMISSION_ERROR_KEYWORDS.iter().any(|&keyword| error_msg.contains(keyword)) {
187        return ErrorContext::new(AgpmError::PermissionDenied {
188            operation: "unknown operation".to_string(),
189            path: "unknown path".to_string(),
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        // Default fallback for unhandled error types
291        _ => ErrorContext::new(AgpmError::Other {
292            message: error.to_string(),
293        })
294        .with_suggestion("Check the error message above for more details")
295        .with_details("An unexpected error occurred. Please report this issue if it persists."),
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use std::io;
303
304    #[test]
305    fn test_user_friendly_error_io_permission_denied() {
306        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "Access denied");
307        let error = anyhow::Error::from(io_err);
308        let ctx = user_friendly_error(error);
309
310        // IO permission errors are converted to PermissionDenied variant
311        assert!(matches!(ctx.error, AgpmError::PermissionDenied { .. }));
312        assert!(ctx.suggestion.is_some());
313    }
314
315    #[test]
316    fn test_user_friendly_error_io_not_found() {
317        let io_err = io::Error::new(io::ErrorKind::NotFound, "File not found");
318        let error = anyhow::Error::from(io_err);
319        let ctx = user_friendly_error(error);
320
321        assert!(matches!(ctx.error, AgpmError::FileSystemError { .. }));
322        assert!(ctx.suggestion.is_some());
323    }
324
325    #[test]
326    fn test_user_friendly_error_template_error() {
327        let error = anyhow::Error::msg("Failed to render template: variable 'foo' not found");
328        let ctx = user_friendly_error(error);
329
330        // Template errors become generic errors
331        assert!(ctx.suggestion.is_some());
332    }
333
334    #[test]
335    fn test_user_friendly_error_network_error() {
336        let error = anyhow::Error::msg("Network connection failed");
337        let ctx = user_friendly_error(error);
338
339        assert!(matches!(ctx.error, AgpmError::NetworkError { .. }));
340        assert!(ctx.suggestion.is_some());
341        assert!(ctx.suggestion.unwrap().contains("internet connection"));
342    }
343
344    #[test]
345    fn test_user_friendly_error_git_error() {
346        let error = anyhow::Error::msg("git command failed: repository not found");
347        let ctx = user_friendly_error(error);
348
349        assert!(matches!(ctx.error, AgpmError::GitCommandError { .. }));
350        assert!(ctx.suggestion.is_some());
351        assert!(ctx.suggestion.unwrap().contains("git is installed"));
352    }
353
354    #[test]
355    fn test_user_friendly_error_fallback() {
356        let error = anyhow::Error::msg("Some completely unknown error type");
357        let ctx = user_friendly_error(error);
358
359        assert!(matches!(ctx.error, AgpmError::Other { .. }));
360        assert!(ctx.suggestion.is_some());
361        // The suggestion might vary, so just check it exists
362    }
363}