1use super::*;
7use crate::core::file_error::FileOperationError;
8
9const TEMPLATE_ERROR_KEYWORDS: &[&str] = &["template", "variable", "filter"];
11
12const NETWORK_ERROR_KEYWORDS: &[&str] = &["network", "connection", "timeout"];
14
15const GIT_ERROR_KEYWORDS: &[&str] = &["git command", "git operation", "git clone", "git fetch"];
17
18const PERMISSION_ERROR_KEYWORDS: &[&str] = &["permission", "denied", "access"];
20
21#[must_use]
36pub fn user_friendly_error(error: anyhow::Error) -> ErrorContext {
37 if let Some(ccmp_error) = error.downcast_ref::<AgpmError>() {
39 return create_error_context(ccmp_error);
40 }
41
42 let mut current_error: &dyn std::error::Error = error.as_ref();
44 loop {
45 if let Some(agpm_error) = current_error.downcast_ref::<AgpmError>() {
47 return create_error_context(agpm_error);
50 }
51
52 if let Some(template_error) =
54 current_error.downcast_ref::<crate::templating::TemplateError>()
55 {
56 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 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 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 let mut current_error: &dyn std::error::Error = error.as_ref();
132 loop {
133 let error_msg = current_error.to_string();
134
135 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 match current_error.source() {
146 Some(source) => current_error = source,
147 None => break,
148 }
149 }
150
151 let error_msg = error.to_string();
153
154 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 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 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 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 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
203pub 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 _ => 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 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 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 }
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 assert!(matches!(ctx.error, AgpmError::DependencyResolutionMismatch { .. }));
407
408 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 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}