agpm_cli/core/
error_builders.rs

1//! Error context builders for consistent error reporting
2//!
3//! This module provides utilities for creating consistent error contexts
4//! throughout the application, reducing boilerplate and ensuring uniform
5//! error messages for users.
6
7use crate::core::error::ErrorContext;
8use anyhow::{Context, Result};
9use std::path::Path;
10
11/// Create an error context for file operations
12///
13/// # Arguments
14///
15/// * `operation` - The operation being performed (e.g., "read", "write", "delete")
16/// * `path` - The path being operated on
17///
18/// # Example
19///
20/// ```no_run
21/// # use anyhow::{Context, Result};
22/// # fn example() -> Result<()> {
23/// use agpm_cli::core::error_builders::file_error_context;
24/// use std::fs;
25/// use std::path::Path;
26///
27/// let path = Path::new("config.toml");
28/// let contents = fs::read_to_string(&path)
29///     .with_context(|| file_error_context("read", path))?;
30/// # Ok(())
31/// # }
32/// ```
33pub fn file_error_context(operation: &str, path: &Path) -> ErrorContext {
34    use crate::core::AgpmError;
35
36    ErrorContext {
37        error: AgpmError::FileSystemError {
38            operation: operation.to_string(),
39            path: path.display().to_string(),
40        },
41        suggestion: match operation {
42            "read" => Some("Check that the file exists and you have read permissions".to_string()),
43            "write" => Some("Check that you have write permissions for this location".to_string()),
44            "create" => Some(
45                "Check that the parent directory exists and you have write permissions".to_string(),
46            ),
47            "delete" => {
48                Some("Check that the file exists and you have delete permissions".to_string())
49            }
50            _ => None,
51        },
52        details: Some(format!("File path: {}", path.display())),
53    }
54}
55
56/// Create an error context for git operations
57///
58/// # Arguments
59///
60/// * `command` - The git command that failed (e.g., "clone", "fetch", "pull")
61/// * `repo` - Optional repository URL or path
62///
63/// # Example
64///
65/// ```no_run
66/// use agpm_cli::core::error_builders::git_error_context;
67///
68/// let context = git_error_context("clone", Some("https://github.com/user/repo.git"));
69/// ```
70pub fn git_error_context(command: &str, repo: Option<&str>) -> ErrorContext {
71    use crate::core::AgpmError;
72
73    ErrorContext {
74        error: AgpmError::GitCommandError {
75            operation: command.to_string(),
76            stderr: format!("Git {command} operation failed"),
77        },
78        suggestion: match command {
79            "clone" => {
80                Some("Check your network connection and that the repository exists".to_string())
81            }
82            "fetch" | "pull" => {
83                Some("Check your network connection and repository access".to_string())
84            }
85            "checkout" => Some("Ensure the branch or tag exists in the repository".to_string()),
86            "status" => Some("Ensure you're in a valid git repository".to_string()),
87            _ => Some("Check that git is installed and accessible".to_string()),
88        },
89        details: repo.map(|r| format!("Repository: {r}")),
90    }
91}
92
93/// Create an error context for manifest operations
94///
95/// # Arguments
96///
97/// * `operation` - The operation being performed (e.g., "load", "parse", "validate")
98/// * `details` - Optional additional details
99///
100/// # Example
101///
102/// ```no_run
103/// use agpm_cli::core::error_builders::manifest_error_context;
104///
105/// let context = manifest_error_context("parse", Some("Invalid TOML syntax at line 5"));
106/// ```
107pub fn manifest_error_context(operation: &str, details: Option<&str>) -> ErrorContext {
108    use crate::core::AgpmError;
109
110    let error = match operation {
111        "load" => AgpmError::ManifestNotFound,
112        "parse" => AgpmError::ManifestParseError {
113            file: "agpm.toml".to_string(),
114            reason: details.unwrap_or("Invalid TOML syntax").to_string(),
115        },
116        "validate" => AgpmError::ManifestValidationError {
117            reason: details.unwrap_or("Validation failed").to_string(),
118        },
119        _ => AgpmError::Other {
120            message: format!("Manifest operation '{operation}' failed"),
121        },
122    };
123
124    ErrorContext {
125        error,
126        suggestion: match operation {
127            "load" => Some("Check that agpm.toml exists in the project directory".to_string()),
128            "parse" => Some("Check that agpm.toml contains valid TOML syntax".to_string()),
129            "validate" => {
130                Some("Ensure all required fields are present in the manifest".to_string())
131            }
132            _ => None,
133        },
134        details: details.map(std::string::ToString::to_string),
135    }
136}
137
138/// Create an error context for dependency resolution
139///
140/// # Arguments
141///
142/// * `dependency` - The dependency that caused the error
143/// * `reason` - The reason for the failure
144///
145/// # Example
146///
147/// ```no_run
148/// use agpm_cli::core::error_builders::dependency_error_context;
149///
150/// let context = dependency_error_context("my-agent", "Version conflict with existing dependency");
151/// ```
152pub fn dependency_error_context(dependency: &str, reason: &str) -> ErrorContext {
153    use crate::core::AgpmError;
154
155    ErrorContext {
156        error: AgpmError::InvalidDependency {
157            name: dependency.to_string(),
158            reason: reason.to_string(),
159        },
160        suggestion: Some("Try running 'agpm update' to update dependencies".to_string()),
161        details: Some(reason.to_string()),
162    }
163}
164
165/// Create an error context for network operations
166///
167/// # Arguments
168///
169/// * `operation` - The network operation (e.g., "download", "fetch", "connect")
170/// * `url` - Optional URL being accessed
171///
172/// # Example
173///
174/// ```no_run
175/// use agpm_cli::core::error_builders::network_error_context;
176///
177/// let context = network_error_context("fetch", Some("https://api.example.com"));
178/// ```
179pub fn network_error_context(operation: &str, url: Option<&str>) -> ErrorContext {
180    use crate::core::AgpmError;
181
182    ErrorContext {
183        error: AgpmError::NetworkError {
184            operation: operation.to_string(),
185            reason: format!("Network {operation} failed"),
186        },
187        suggestion: Some("Check your internet connection and try again".to_string()),
188        details: url.map(|u| format!("URL: {u}")),
189    }
190}
191
192/// Create an error context for configuration issues
193///
194/// # Arguments
195///
196/// * `config_type` - The type of configuration (e.g., "global", "project", "mcp")
197/// * `issue` - Description of the issue
198///
199/// # Example
200///
201/// ```no_run
202/// use agpm_cli::core::error_builders::config_error_context;
203///
204/// let context = config_error_context("global", "Missing authentication token");
205/// ```
206pub fn config_error_context(config_type: &str, issue: &str) -> ErrorContext {
207    use crate::core::AgpmError;
208
209    ErrorContext {
210        error: AgpmError::ConfigError {
211            message: format!("Configuration error in {config_type} config: {issue}"),
212        },
213        suggestion: match config_type {
214            "global" => Some("Check ~/.agpm/config.toml for correct settings".to_string()),
215            "project" => Some("Check agpm.toml in your project directory".to_string()),
216            "mcp" => Some("Check .mcp.json for valid MCP server configurations".to_string()),
217            _ => None,
218        },
219        details: Some(issue.to_string()),
220    }
221}
222
223/// Create an error context for permission issues
224///
225/// # Arguments
226///
227/// * `resource` - The resource that requires permissions
228/// * `operation` - The operation that failed
229///
230/// # Example
231///
232/// ```no_run
233/// use agpm_cli::core::error_builders::permission_error_context;
234///
235/// let context = permission_error_context("/usr/local/bin", "write");
236/// ```
237pub fn permission_error_context(resource: &str, operation: &str) -> ErrorContext {
238    use crate::core::AgpmError;
239
240    ErrorContext {
241        error: AgpmError::PermissionDenied {
242            operation: operation.to_string(),
243            path: resource.to_string(),
244        },
245        suggestion: Some(format!("Check that you have {operation} permissions for: {resource}")),
246        details: if cfg!(windows) {
247            Some("On Windows, you may need to run as Administrator".to_string())
248        } else {
249            Some("On Unix systems, you may need to use sudo or change file permissions".to_string())
250        },
251    }
252}
253
254/// Helper trait to easily attach error contexts
255pub trait ErrorContextExt<T> {
256    /// Attach a file error context
257    fn file_context(self, operation: &str, path: &Path) -> Result<T>;
258
259    /// Attach a git error context
260    fn git_context(self, command: &str, repo: Option<&str>) -> Result<T>;
261
262    /// Attach a manifest error context
263    fn manifest_context(self, operation: &str, details: Option<&str>) -> Result<T>;
264
265    /// Attach a dependency error context
266    fn dependency_context(self, dependency: &str, reason: &str) -> Result<T>;
267
268    /// Attach a network error context
269    fn network_context(self, operation: &str, url: Option<&str>) -> Result<T>;
270}
271
272impl<T, E> ErrorContextExt<T> for std::result::Result<T, E>
273where
274    E: std::error::Error + Send + Sync + 'static,
275{
276    fn file_context(self, operation: &str, path: &Path) -> Result<T> {
277        self.with_context(|| file_error_context(operation, path))
278    }
279
280    fn git_context(self, command: &str, repo: Option<&str>) -> Result<T> {
281        self.with_context(|| git_error_context(command, repo))
282    }
283
284    fn manifest_context(self, operation: &str, details: Option<&str>) -> Result<T> {
285        self.with_context(|| manifest_error_context(operation, details))
286    }
287
288    fn dependency_context(self, dependency: &str, reason: &str) -> Result<T> {
289        self.with_context(|| dependency_error_context(dependency, reason))
290    }
291
292    fn network_context(self, operation: &str, url: Option<&str>) -> Result<T> {
293        self.with_context(|| network_error_context(operation, url))
294    }
295}
296
297/// Macro for creating custom error contexts quickly
298///
299/// # Example
300///
301/// ```
302/// use agpm_cli::{error_context, core::AgpmError};
303///
304/// let context = error_context! {
305///     error: AgpmError::Other { message: "Operation failed".to_string() },
306///     suggestion: "Try again later",
307///     details: "Additional information"
308/// };
309/// ```
310#[macro_export]
311macro_rules! error_context {
312    (error: $err:expr) => {
313        $crate::core::error::ErrorContext {
314            error: $err,
315            suggestion: None,
316            details: None,
317        }
318    };
319    (error: $err:expr, suggestion: $sug:expr) => {
320        $crate::core::error::ErrorContext {
321            error: $err,
322            suggestion: Some($sug.to_string()),
323            details: None,
324        }
325    };
326    (error: $err:expr, suggestion: $sug:expr, details: $det:expr) => {
327        $crate::core::error::ErrorContext {
328            error: $err,
329            suggestion: Some($sug.to_string()),
330            details: Some($det.to_string()),
331        }
332    };
333    (error: $err:expr, details: $det:expr) => {
334        $crate::core::error::ErrorContext {
335            error: $err,
336            suggestion: None,
337            details: Some($det.to_string()),
338        }
339    };
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn test_file_error_context() {
348        let context = file_error_context("read", Path::new("/tmp/test.txt"));
349        assert!(matches!(context.error, crate::core::AgpmError::FileSystemError { .. }));
350        assert!(context.suggestion.is_some());
351        assert!(context.details.unwrap().contains("/tmp/test.txt"));
352    }
353
354    #[test]
355    fn test_git_error_context() {
356        let context = git_error_context("clone", Some("https://github.com/test/repo"));
357        assert!(matches!(context.error, crate::core::AgpmError::GitCommandError { .. }));
358        assert!(context.suggestion.unwrap().contains("network"));
359        assert!(context.details.unwrap().contains("github.com"));
360    }
361
362    #[test]
363    fn test_error_context_macro() {
364        use crate::core::AgpmError;
365
366        let context = error_context! {
367            error: AgpmError::Other { message: "Test error".to_string() },
368            suggestion: "Test suggestion",
369            details: "Test details"
370        };
371        assert!(matches!(context.error, AgpmError::Other { .. }));
372        assert_eq!(context.suggestion.unwrap(), "Test suggestion");
373        assert_eq!(context.details.unwrap(), "Test details");
374    }
375
376    #[test]
377    fn test_permission_error_context() {
378        let context = permission_error_context("/usr/local", "write");
379        assert!(matches!(context.error, crate::core::AgpmError::PermissionDenied { .. }));
380        assert!(context.suggestion.unwrap().contains("write permissions"));
381        assert!(context.details.is_some());
382    }
383
384    #[test]
385    fn test_manifest_error_context_all_operations() {
386        // Test load operation
387        let context = manifest_error_context("load", None);
388        assert!(matches!(context.error, crate::core::AgpmError::ManifestNotFound));
389        assert!(context.suggestion.unwrap().contains("agpm.toml exists"));
390
391        // Test parse operation with details
392        let context = manifest_error_context("parse", Some("Syntax error at line 10"));
393        assert!(matches!(context.error, crate::core::AgpmError::ManifestParseError { .. }));
394        assert!(context.suggestion.unwrap().contains("valid TOML syntax"));
395        assert_eq!(context.details.unwrap(), "Syntax error at line 10");
396
397        // Test validate operation
398        let context = manifest_error_context("validate", Some("Missing required field"));
399        assert!(matches!(context.error, crate::core::AgpmError::ManifestValidationError { .. }));
400        assert!(context.suggestion.unwrap().contains("required fields"));
401        assert_eq!(context.details.unwrap(), "Missing required field");
402
403        // Test unknown operation
404        let context = manifest_error_context("unknown", None);
405        assert!(matches!(context.error, crate::core::AgpmError::Other { .. }));
406        assert!(context.suggestion.is_none());
407    }
408
409    #[test]
410    fn test_dependency_error_context() {
411        let context = dependency_error_context("test-agent", "Version not found");
412        assert!(matches!(context.error, crate::core::AgpmError::InvalidDependency { .. }));
413        assert!(context.suggestion.unwrap().contains("agpm update"));
414        assert_eq!(context.details.unwrap(), "Version not found");
415    }
416
417    #[test]
418    fn test_network_error_context() {
419        let context = network_error_context("download", Some("https://example.com/file"));
420        assert!(matches!(context.error, crate::core::AgpmError::NetworkError { .. }));
421        assert!(context.suggestion.unwrap().contains("internet connection"));
422        assert!(context.details.unwrap().contains("example.com"));
423    }
424
425    #[test]
426    fn test_config_error_context_types() {
427        // Test global config
428        let context = config_error_context("global", "Invalid format");
429        assert!(matches!(context.error, crate::core::AgpmError::ConfigError { .. }));
430        assert!(context.suggestion.unwrap().contains("~/.agpm/config.toml"));
431
432        // Test project config
433        let context = config_error_context("project", "Missing dependency");
434        assert!(context.suggestion.unwrap().contains("agpm.toml"));
435
436        // Test MCP config
437        let context = config_error_context("mcp", "Invalid server");
438        assert!(context.suggestion.unwrap().contains(".mcp.json"));
439
440        // Test unknown config type
441        let context = config_error_context("unknown", "Some issue");
442        assert!(context.suggestion.is_none());
443    }
444
445    #[test]
446    fn test_file_error_context_operations() {
447        // Test read operation
448        let context = file_error_context("read", Path::new("/test/file.txt"));
449        assert!(context.suggestion.unwrap().contains("read permissions"));
450
451        // Test write operation
452        let context = file_error_context("write", Path::new("/test/file.txt"));
453        assert!(context.suggestion.unwrap().contains("write permissions"));
454
455        // Test create operation
456        let context = file_error_context("create", Path::new("/test/file.txt"));
457        assert!(context.suggestion.unwrap().contains("parent directory"));
458
459        // Test delete operation
460        let context = file_error_context("delete", Path::new("/test/file.txt"));
461        assert!(context.suggestion.unwrap().contains("delete permissions"));
462
463        // Test unknown operation
464        let context = file_error_context("unknown", Path::new("/test/file.txt"));
465        assert!(context.suggestion.is_none());
466    }
467
468    #[test]
469    fn test_git_error_context_commands() {
470        // Test clone command
471        let context = git_error_context("clone", Some("repo.git"));
472        assert!(context.suggestion.unwrap().contains("repository exists"));
473
474        // Test fetch command
475        let context = git_error_context("fetch", None);
476        assert!(context.suggestion.unwrap().contains("repository access"));
477
478        // Test pull command
479        let context = git_error_context("pull", Some("origin"));
480        assert!(context.suggestion.unwrap().contains("repository access"));
481
482        // Test checkout command
483        let context = git_error_context("checkout", Some("branch"));
484        assert!(context.suggestion.unwrap().contains("branch or tag exists"));
485
486        // Test status command
487        let context = git_error_context("status", None);
488        assert!(context.suggestion.unwrap().contains("valid git repository"));
489
490        // Test unknown command
491        let context = git_error_context("unknown", None);
492        assert!(context.suggestion.unwrap().contains("git is installed"));
493    }
494
495    #[test]
496    fn test_error_context_ext_trait() {
497        use std::io;
498
499        // Test file_context
500        let result: Result<(), io::Error> = Err(io::Error::new(io::ErrorKind::NotFound, "test"));
501        let result = result.file_context("read", Path::new("/test.txt"));
502        assert!(result.is_err());
503
504        // Test git_context
505        let result: Result<(), io::Error> = Err(io::Error::other("test"));
506        let result = result.git_context("clone", Some("repo"));
507        assert!(result.is_err());
508
509        // Test manifest_context
510        let result: Result<(), io::Error> = Err(io::Error::new(io::ErrorKind::InvalidData, "test"));
511        let result = result.manifest_context("parse", Some("details"));
512        assert!(result.is_err());
513
514        // Test dependency_context
515        let result: Result<(), io::Error> = Err(io::Error::other("test"));
516        let result = result.dependency_context("dep", "reason");
517        assert!(result.is_err());
518
519        // Test network_context
520        let result: Result<(), io::Error> = Err(io::Error::new(io::ErrorKind::TimedOut, "test"));
521        let result = result.network_context("fetch", Some("url"));
522        assert!(result.is_err());
523    }
524
525    #[test]
526    fn test_permission_error_context_platforms() {
527        let context = permission_error_context("/path", "execute");
528        assert!(context.details.is_some());
529
530        #[cfg(windows)]
531        assert!(context.details.unwrap().contains("Administrator"));
532
533        #[cfg(not(windows))]
534        assert!(context.details.unwrap().contains("sudo"));
535    }
536
537    #[test]
538    fn test_error_context_macro_variants() {
539        use crate::core::AgpmError;
540
541        // Test with error only
542        let context = error_context! {
543            error: AgpmError::Other { message: "Error only".to_string() }
544        };
545        assert!(context.suggestion.is_none());
546        assert!(context.details.is_none());
547
548        // Test with error and suggestion
549        let context = error_context! {
550            error: AgpmError::Other { message: "Error".to_string() },
551            suggestion: "Do this"
552        };
553        assert_eq!(context.suggestion.unwrap(), "Do this");
554        assert!(context.details.is_none());
555
556        // Test with error and details
557        let context = error_context! {
558            error: AgpmError::Other { message: "Error".to_string() },
559            details: "More info"
560        };
561        assert!(context.suggestion.is_none());
562        assert_eq!(context.details.unwrap(), "More info");
563    }
564}