agpm_cli/core/error.rs
1//! Error handling for AGPM
2//!
3//! This module provides comprehensive error types and user-friendly error reporting for the
4//! AGPM package manager. The error system is designed around two core principles:
5//! 1. **Strongly-typed errors** for precise error handling in code
6//! 2. **User-friendly messages** with actionable suggestions for CLI users
7//!
8//! # Architecture
9//!
10//! The error system consists of two main types:
11//! - [`AgpmError`] - Enumerated error types for all failure cases in AGPM
12//! - [`ErrorContext`] - Wrapper that adds user-friendly messages and suggestions
13//!
14//! # Error Categories
15//!
16//! AGPM errors are organized into several categories:
17//! - **Git Operations**: [`AgpmError::GitNotFound`], [`AgpmError::GitCommandError`], etc.
18//! - **File System**: [`AgpmError::FileSystemError`], [`AgpmError::PermissionDenied`], etc.
19//! - **Configuration**: [`AgpmError::ManifestNotFound`], [`AgpmError::ManifestParseError`], etc.
20//! - **Dependencies**: [`AgpmError::CircularDependency`], [`AgpmError::DependencyNotMet`], etc.
21//! - **Resources**: [`AgpmError::ResourceNotFound`], [`AgpmError::InvalidResource`], etc.
22//!
23//! # Error Conversion and Context
24//!
25//! Common standard library errors are automatically converted to AGPM errors:
26//! - [`std::io::Error`] → [`AgpmError::IoError`]
27//! - [`toml::de::Error`] → [`AgpmError::TomlError`]
28//! - [`semver::Error`] → [`AgpmError::SemverError`]
29//!
30//! Convert errors into user-friendly format with
31//! contextual suggestions.
32//!
33//! # Examples
34//!
35//! ## Basic Error Handling
36//!
37//! ```rust,no_run
38//! use agpm_cli::core::{AgpmError, ErrorContext};
39//!
40//! fn handle_git_operation() -> Result<(), AgpmError> {
41//! // Simulate a git operation failure
42//! Err(AgpmError::GitNotFound)
43//! }
44//!
45//! match handle_git_operation() {
46//! Ok(_) => println!("Success!"),
47//! Err(e) => {
48//! let ctx = ErrorContext::new(e)
49//! .with_suggestion("Install git from https://git-scm.com/")
50//! .with_details("AGPM requires git for repository operations");
51//! ctx.display(); // Shows colored error with suggestions
52//! }
53//! }
54//! ```
55//!
56//! ## Creating Error Context Manually
57//!
58//! ```rust,no_run
59//! use agpm_cli::core::{AgpmError, ErrorContext};
60//!
61//! let error = AgpmError::ManifestNotFound;
62//! let context = ErrorContext::new(error)
63//! .with_suggestion("Create a agpm.toml file in your project directory")
64//! .with_details("AGPM searches for agpm.toml in current and parent directories");
65//!
66//! // Display with colors in terminal
67//! context.display();
68//!
69//! // Or get as string for logging
70//! let message = format!("{}", context);
71//! ```
72//!
73//! ## Error Recovery Patterns
74//!
75//! ```rust,no_run
76//! use agpm_cli::core::{AgpmError, ErrorContext};
77//!
78//! fn install_dependency(name: &str) -> Result<(), AgpmError> {
79//! // Try installation
80//! match perform_installation(name) {
81//! Ok(()) => Ok(()),
82//! Err(e) => {
83//! // Convert to user-friendly error for CLI display
84//! let friendly = ErrorContext::new(e)
85//! .with_suggestion(format!("Check the dependency name '{}' and try again", name))
86//! .with_details("AGPM will attempt to install the dependency and its requirements");
87//! friendly.display(); // Shows colored error with suggestions
88//! Err(AgpmError::Other { message: "Installation failed".to_string() })
89//! }
90//! }
91//! }
92//!
93//! fn perform_installation(_name: &str) -> Result<(), AgpmError> {
94//! // Implementation would go here
95//! Ok(())
96//! }
97//! ```
98
99use colored::Colorize;
100use std::fmt;
101use thiserror::Error;
102
103/// The main error type for AGPM operations
104///
105/// This enum represents all possible errors that can occur during AGPM operations.
106/// Each variant is designed to provide specific context about the failure and enable
107/// appropriate error handling strategies.
108///
109/// # Design Philosophy
110///
111/// - **Specific Error Types**: Each error variant represents a specific failure mode
112/// - **Rich Context**: Errors include relevant details like file paths, URLs, and reasons
113/// - **User-Friendly**: Error messages are written for end users, not just developers
114/// - **Actionable**: Most errors provide clear guidance on how to resolve the issue
115///
116/// # Error Categories
117///
118/// ## Git Operations
119/// - [`GitNotFound`] - Git executable not available
120/// - [`GitCommandError`] - Git command execution failed
121/// - [`GitAuthenticationFailed`] - Git authentication problems
122/// - [`GitCloneFailed`] - Repository cloning failed
123/// - [`GitCheckoutFailed`] - Git checkout operation failed
124///
125/// ## File System Operations
126/// - [`FileSystemError`] - General file system operations
127/// - [`PermissionDenied`] - Insufficient permissions
128/// - [`DirectoryNotEmpty`] - Directory contains files when empty expected
129/// - [`IoError`] - Standard I/O errors from [`std::io::Error`]
130///
131/// ## Configuration and Parsing
132/// - [`ManifestNotFound`] - agpm.toml file missing
133/// - [`ManifestParseError`] - Invalid TOML syntax in manifest
134/// - [`ManifestValidationError`] - Manifest content validation failed
135/// - [`LockfileParseError`] - Invalid lockfile format
136/// - [`InvalidLockfileError`] - Invalid lockfile that can be automatically regenerated
137/// - [`ConfigError`] - Configuration file issues
138/// - [`TomlError`] - TOML parsing errors from [`toml::de::Error`]
139/// - [`TomlSerError`] - TOML serialization errors from [`toml::ser::Error`]
140///
141/// ## Resource Management
142/// - [`ResourceNotFound`] - Named resource doesn't exist
143/// - [`ResourceFileNotFound`] - Resource file missing from repository
144/// - [`InvalidResourceType`] - Unknown resource type specified
145/// - [`InvalidResourceStructure`] - Resource content is malformed
146/// - [`InvalidResource`] - Resource validation failed
147/// - [`AlreadyInstalled`] - Resource already exists
148///
149/// ## Dependency Resolution
150/// - [`CircularDependency`] - Dependency cycle detected
151/// - [`DependencyResolutionFailed`] - Cannot resolve dependencies
152/// - [`DependencyNotMet`] - Version constraint not satisfied
153/// - [`InvalidDependency`] - Malformed dependency specification
154/// - [`InvalidVersionConstraint`] - Invalid version format
155/// - [`VersionNotFound`] - Requested version doesn't exist
156/// - [`SemverError`] - Semantic version parsing from [`semver::Error`]
157///
158/// ## Source Management
159/// - [`SourceNotFound`] - Named source not defined
160/// - [`SourceUnreachable`] - Cannot connect to source repository
161///
162/// ## Platform and Network
163/// - [`NetworkError`] - Network connectivity issues
164/// - [`PlatformNotSupported`] - Operation not supported on current platform
165/// - [`ChecksumMismatch`] - File integrity verification failed
166///
167/// # Examples
168///
169/// ## Pattern Matching on Errors
170///
171/// ```rust,no_run
172/// use agpm_cli::core::AgpmError;
173///
174/// fn handle_error(error: AgpmError) {
175/// match error {
176/// AgpmError::GitNotFound => {
177/// eprintln!("Please install git to use AGPM");
178/// std::process::exit(1);
179/// }
180/// AgpmError::ManifestNotFound => {
181/// eprintln!("Run 'agpm init' to create a manifest file");
182/// }
183/// AgpmError::NetworkError { operation, .. } => {
184/// eprintln!("Network error during {}: check your connection", operation);
185/// }
186/// _ => {
187/// eprintln!("Unexpected error: {}", error);
188/// }
189/// }
190/// }
191/// ```
192///
193/// ## Creating Specific Errors
194///
195/// ```rust,no_run
196/// use agpm_cli::core::AgpmError;
197///
198/// // Create a git command error with context
199/// let error = AgpmError::GitCommandError {
200/// operation: "clone".to_string(),
201/// stderr: "repository not found".to_string(),
202/// };
203///
204/// // Create a resource not found error
205/// let error = AgpmError::ResourceNotFound {
206/// name: "my-agent".to_string(),
207/// };
208///
209/// // Create a version constraint error
210/// let error = AgpmError::InvalidVersionConstraint {
211/// constraint: "~1.x.y".to_string(),
212/// };
213/// ```
214///
215/// [`GitNotFound`]: AgpmError::GitNotFound
216/// [`GitCommandError`]: AgpmError::GitCommandError
217/// [`GitAuthenticationFailed`]: AgpmError::GitAuthenticationFailed
218/// [`GitCloneFailed`]: AgpmError::GitCloneFailed
219/// [`GitCheckoutFailed`]: AgpmError::GitCheckoutFailed
220/// [`FileSystemError`]: AgpmError::FileSystemError
221/// [`PermissionDenied`]: AgpmError::PermissionDenied
222/// [`DirectoryNotEmpty`]: AgpmError::DirectoryNotEmpty
223/// [`IoError`]: AgpmError::IoError
224/// [`ManifestNotFound`]: AgpmError::ManifestNotFound
225/// [`ManifestParseError`]: AgpmError::ManifestParseError
226/// [`ManifestValidationError`]: AgpmError::ManifestValidationError
227/// [`LockfileParseError`]: AgpmError::LockfileParseError
228/// [`InvalidLockfileError`]: AgpmError::InvalidLockfileError
229/// [`ConfigError`]: AgpmError::ConfigError
230/// [`TomlError`]: AgpmError::TomlError
231/// [`TomlSerError`]: AgpmError::TomlSerError
232/// [`ResourceNotFound`]: AgpmError::ResourceNotFound
233/// [`ResourceFileNotFound`]: AgpmError::ResourceFileNotFound
234/// [`InvalidResourceType`]: AgpmError::InvalidResourceType
235/// [`InvalidResourceStructure`]: AgpmError::InvalidResourceStructure
236/// [`InvalidResource`]: AgpmError::InvalidResource
237/// [`AlreadyInstalled`]: AgpmError::AlreadyInstalled
238/// [`CircularDependency`]: AgpmError::CircularDependency
239/// [`DependencyResolutionFailed`]: AgpmError::DependencyResolutionFailed
240/// [`DependencyNotMet`]: AgpmError::DependencyNotMet
241/// [`InvalidDependency`]: AgpmError::InvalidDependency
242/// [`InvalidVersionConstraint`]: AgpmError::InvalidVersionConstraint
243/// [`VersionNotFound`]: AgpmError::VersionNotFound
244/// [`SemverError`]: AgpmError::SemverError
245/// [`SourceNotFound`]: AgpmError::SourceNotFound
246/// [`SourceUnreachable`]: AgpmError::SourceUnreachable
247/// [`NetworkError`]: AgpmError::NetworkError
248/// [`PlatformNotSupported`]: AgpmError::PlatformNotSupported
249/// [`ChecksumMismatch`]: AgpmError::ChecksumMismatch
250#[derive(Error, Debug)]
251pub enum AgpmError {
252 /// Git operation failed during execution
253 ///
254 /// This error occurs when a git command returns a non-zero exit code.
255 /// Common causes include network issues, authentication problems, or
256 /// invalid git repository states.
257 ///
258 /// # Fields
259 /// - `operation`: The git operation that failed (e.g., "clone", "fetch", "checkout")
260 /// - `stderr`: The error output from the git command
261 #[error("Git operation failed: {operation}\n{stderr}")]
262 GitCommandError {
263 /// The git operation that failed (e.g., "clone", "fetch", "checkout")
264 operation: String,
265 /// The error output from the git command
266 stderr: String,
267 },
268
269 /// Git executable not found in PATH
270 ///
271 /// This error occurs when AGPM cannot locate the `git` command in the system PATH.
272 /// AGPM requires git to be installed and available for repository operations.
273 ///
274 /// Common solutions:
275 /// - Install git from <https://git-scm.com/>
276 /// - Use a package manager: `brew install git`, `apt install git`, etc.
277 /// - Ensure git is in your PATH environment variable
278 #[error("Git is not installed or not found in PATH")]
279 GitNotFound,
280
281 /// Git repository is invalid or corrupted
282 ///
283 /// This error occurs when a directory exists but doesn't contain a valid
284 /// git repository structure (missing .git directory or corrupted).
285 ///
286 /// # Fields
287 /// - `path`: The path that was expected to contain a git repository
288 #[error("Not a valid git repository: {path}")]
289 GitRepoInvalid {
290 /// The path that was expected to contain a git repository
291 path: String,
292 },
293
294 /// Git authentication failed for repository access
295 ///
296 /// This error occurs when git cannot authenticate with a remote repository.
297 /// Common for private repositories or when credentials are missing/expired.
298 ///
299 /// # Fields
300 /// - `url`: The repository URL that failed authentication
301 #[error("Git authentication failed for repository: {url}")]
302 GitAuthenticationFailed {
303 /// The repository URL that failed authentication
304 url: String,
305 },
306
307 /// Git repository clone failed
308 #[error("Failed to clone repository: {url}\n{reason}")]
309 GitCloneFailed {
310 /// The repository URL that failed to clone
311 url: String,
312 /// The reason for the clone failure
313 reason: String,
314 },
315
316 /// Git checkout failed
317 #[error("Failed to checkout reference '{reference}' in repository")]
318 GitCheckoutFailed {
319 /// The git reference (branch, tag, or commit) that failed to checkout
320 reference: String,
321 /// The reason for the checkout failure
322 reason: String,
323 },
324
325 /// Configuration error
326 #[error("Configuration error: {message}")]
327 ConfigError {
328 /// Description of the configuration error
329 message: String,
330 },
331
332 /// Manifest file (agpm.toml) not found
333 ///
334 /// This error occurs when AGPM cannot locate a agpm.toml file in the current
335 /// directory or any parent directory up to the filesystem root.
336 ///
337 /// AGPM searches for agpm.toml starting from the current working directory
338 /// and walking up the directory tree, similar to how git searches for .git.
339 #[error("Manifest file agpm.toml not found in current directory or any parent directory")]
340 ManifestNotFound,
341
342 /// Manifest parsing error
343 #[error("Invalid manifest file syntax in {file}")]
344 ManifestParseError {
345 /// Path to the manifest file that failed to parse
346 file: String,
347 /// Specific reason for the parsing failure
348 reason: String,
349 },
350
351 /// Manifest validation error
352 #[error("Manifest validation failed: {reason}")]
353 ManifestValidationError {
354 /// Reason why manifest validation failed
355 reason: String,
356 },
357
358 /// Lockfile parsing error
359 #[error("Invalid lockfile syntax in {file}")]
360 LockfileParseError {
361 /// Path to the lockfile that failed to parse
362 file: String,
363 /// Specific reason for the parsing failure
364 reason: String,
365 },
366
367 /// Invalid lockfile that can be automatically regenerated
368 #[error(
369 "Invalid or corrupted lockfile detected: {file}\n\n{reason}\n\nNote: The lockfile format is not yet stable as this is beta software."
370 )]
371 InvalidLockfileError {
372 /// Path to the invalid lockfile
373 file: String,
374 /// Specific reason why the lockfile is invalid
375 reason: String,
376 /// Whether automatic regeneration is offered
377 can_regenerate: bool,
378 },
379
380 /// Resource not found
381 #[error("Resource '{name}' not found")]
382 ResourceNotFound {
383 /// Name of the resource that could not be found
384 name: String,
385 },
386
387 /// Resource file not found in repository
388 #[error("Resource file '{path}' not found in source '{source_name}'")]
389 ResourceFileNotFound {
390 /// Path to the resource file within the source repository
391 path: String,
392 /// Name of the source repository where the file was expected
393 source_name: String,
394 },
395
396 /// Source repository not found
397 #[error("Source repository '{name}' not defined in manifest")]
398 SourceNotFound {
399 /// Name of the source repository that is not defined
400 name: String,
401 },
402
403 /// Source repository unreachable
404 #[error("Cannot reach source repository '{name}' at {url}")]
405 SourceUnreachable {
406 /// Name of the source repository
407 name: String,
408 /// URL of the unreachable repository
409 url: String,
410 },
411
412 /// Invalid version constraint
413 #[error("Invalid version constraint: {constraint}")]
414 InvalidVersionConstraint {
415 /// The invalid version constraint string
416 constraint: String,
417 },
418
419 /// Version not found
420 #[error("Version '{version}' not found for resource '{resource}'")]
421 VersionNotFound {
422 /// Name of the resource for which the version was not found
423 resource: String,
424 /// The version string that could not be found
425 version: String,
426 },
427
428 /// Resource already installed
429 #[error("Resource '{name}' is already installed")]
430 AlreadyInstalled {
431 /// Name of the resource that is already installed
432 name: String,
433 },
434
435 /// Invalid resource type
436 #[error("Invalid resource type: {resource_type}")]
437 InvalidResourceType {
438 /// The invalid resource type that was specified
439 resource_type: String,
440 },
441
442 /// Invalid resource structure
443 #[error("Invalid resource structure in '{file}': {reason}")]
444 InvalidResourceStructure {
445 /// Path to the file with invalid resource structure
446 file: String,
447 /// Reason why the resource structure is invalid
448 reason: String,
449 },
450
451 /// Circular dependency detected in dependency graph
452 ///
453 /// This error occurs when resources depend on each other in a cycle,
454 /// making it impossible to determine installation order.
455 ///
456 /// Example: A depends on B, B depends on C, C depends on A
457 ///
458 /// # Fields
459 /// - `chain`: The dependency chain showing the circular reference
460 #[error("Circular dependency detected: {chain}")]
461 CircularDependency {
462 /// String representation of the circular dependency chain
463 chain: String,
464 },
465
466 /// Dependency resolution failed
467 #[error("Cannot resolve dependencies: {reason}")]
468 DependencyResolutionFailed {
469 /// Reason why dependency resolution failed
470 reason: String,
471 },
472
473 /// Dependency resolution mismatch between declared and resolved dependencies
474 ///
475 /// This error occurs when a resource declares N dependencies in its frontmatter
476 /// but only M dependencies (where M < N) were successfully resolved. This indicates
477 /// a bug in the dependency resolution process, likely due to path normalization issues.
478 ///
479 /// # Fields
480 /// - `resource`: Name of the resource with the mismatch
481 /// - `declared_count`: Number of dependencies declared in frontmatter
482 /// - `resolved_count`: Number of dependencies actually resolved
483 /// - `declared_deps`: List of (resource_type, path) for declared dependencies
484 #[error("Dependency resolution mismatch for resource '{resource}'")]
485 DependencyResolutionMismatch {
486 /// Name of the resource with the dependency mismatch
487 resource: String,
488 /// Number of dependencies declared in frontmatter
489 declared_count: usize,
490 /// Number of dependencies actually resolved
491 resolved_count: usize,
492 /// List of declared dependencies as (resource_type, path) tuples
493 declared_deps: Vec<(String, String)>,
494 },
495
496 /// Network error
497 #[error("Network error: {operation}")]
498 NetworkError {
499 /// The network operation that failed
500 operation: String,
501 /// Reason for the network failure
502 reason: String,
503 },
504
505 /// File system error
506 #[error("File system error: {operation}: {path}")]
507 FileSystemError {
508 /// The file system operation that failed
509 operation: String,
510 /// Path where the file system error occurred
511 path: String,
512 },
513
514 /// Permission denied
515 #[error("Permission denied: {operation}: {path}")]
516 PermissionDenied {
517 /// The operation that was denied due to insufficient permissions
518 operation: String,
519 /// Path where permission was denied
520 path: String,
521 },
522
523 /// Directory not empty
524 #[error("Directory is not empty: {path}")]
525 DirectoryNotEmpty {
526 /// Path to the directory that is not empty
527 path: String,
528 },
529
530 /// Invalid dependency specification
531 #[error("Invalid dependency specification for '{name}': {reason}")]
532 InvalidDependency {
533 /// Name of the invalid dependency
534 name: String,
535 /// Reason why the dependency specification is invalid
536 reason: String,
537 },
538
539 /// Invalid resource content
540 #[error("Invalid resource content in '{name}': {reason}")]
541 InvalidResource {
542 /// Name of the invalid resource
543 name: String,
544 /// Reason why the resource content is invalid
545 reason: String,
546 },
547
548 /// Dependency not met
549 #[error("Dependency '{name}' requires version {required}, but {found} was found")]
550 DependencyNotMet {
551 /// Name of the dependency that is not satisfied
552 name: String,
553 /// The required version constraint
554 required: String,
555 /// The version that was actually found
556 found: String,
557 },
558
559 /// Config file not found
560 #[error("Configuration file not found: {path}")]
561 ConfigNotFound {
562 /// Path to the configuration file that was not found
563 path: String,
564 },
565
566 /// Checksum mismatch
567 #[error("Checksum mismatch for resource '{name}': expected {expected}, got {actual}")]
568 ChecksumMismatch {
569 /// Name of the resource with checksum mismatch
570 name: String,
571 /// The expected checksum value
572 expected: String,
573 /// The actual checksum that was computed
574 actual: String,
575 },
576
577 /// Platform not supported
578 #[error("Operation not supported on this platform: {operation}")]
579 PlatformNotSupported {
580 /// The operation that is not supported on this platform
581 operation: String,
582 },
583
584 /// IO error
585 #[error("IO error: {0}")]
586 IoError(#[from] std::io::Error),
587
588 /// TOML parsing error
589 #[error("TOML parsing error: {0}")]
590 TomlError(#[from] toml::de::Error),
591
592 /// TOML serialization error
593 #[error("TOML serialization error: {0}")]
594 TomlSerError(#[from] toml::ser::Error),
595
596 /// Semver parsing error
597 #[error("Semver parsing error: {0}")]
598 SemverError(#[from] semver::Error),
599
600 /// Other error
601 #[error("{message}")]
602 Other {
603 /// Generic error message
604 message: String,
605 },
606}
607
608/// Error context wrapper that provides user-friendly error information
609///
610/// `ErrorContext` wraps a [`AgpmError`] and adds optional user-friendly messages,
611/// suggestions for resolution, and additional details. This is the primary way
612/// AGPM presents errors to CLI users.
613///
614/// # Design Philosophy
615///
616/// Error contexts are designed to be:
617/// - **Actionable**: Include specific suggestions for resolving the error
618/// - **Informative**: Provide context about why the error occurred
619/// - **Colorized**: Use terminal colors to highlight important information
620/// - **Consistent**: Follow a standard format across all error types
621///
622/// # Display Format
623///
624/// When displayed, errors show:
625/// 1. **Error**: The main error message in red
626/// 2. **Details**: Additional context about the error in yellow (optional)
627/// 3. **Suggestion**: Actionable steps to resolve the issue in green (optional)
628///
629/// # Examples
630///
631/// ## Creating Error Context
632///
633/// ```rust,no_run
634/// use agpm_cli::core::{AgpmError, ErrorContext};
635///
636/// let error = AgpmError::GitNotFound;
637/// let context = ErrorContext::new(error)
638/// .with_suggestion("Install git from https://git-scm.com/")
639/// .with_details("AGPM requires git for repository operations");
640///
641/// // Display to terminal with colors
642/// context.display();
643///
644/// // Or convert to string for logging
645/// let message = context.to_string();
646/// ```
647///
648/// ## Builder Pattern Usage
649///
650/// ```rust,no_run
651/// use agpm_cli::core::{AgpmError, ErrorContext};
652///
653/// let context = ErrorContext::new(AgpmError::ManifestNotFound)
654/// .with_suggestion("Create a agpm.toml file in your project directory")
655/// .with_details("AGPM searches current and parent directories for agpm.toml");
656///
657/// println!("{}", context);
658/// ```
659///
660/// ## Quick Suggestion Creation
661///
662/// ```rust,no_run
663/// use agpm_cli::core::ErrorContext;
664///
665/// // Create context with just a suggestion (useful for generic errors)
666/// let context = ErrorContext::suggestion("Try running the command with --verbose");
667/// ```
668#[derive(Debug)]
669pub struct ErrorContext {
670 /// The underlying AGPM error
671 pub error: AgpmError,
672 /// Optional suggestion for resolving the error
673 pub suggestion: Option<String>,
674 /// Optional additional details about the error
675 pub details: Option<String>,
676}
677
678impl ErrorContext {
679 /// Create a new error context from a [`AgpmError`]
680 ///
681 /// This creates a basic error context with no additional suggestions or details.
682 /// Use the builder methods [`with_suggestion`] and [`with_details`] to add
683 /// user-friendly information.
684 ///
685 /// # Examples
686 ///
687 /// ```rust,no_run
688 /// use agpm_cli::core::{AgpmError, ErrorContext};
689 ///
690 /// let context = ErrorContext::new(AgpmError::GitNotFound);
691 /// ```
692 ///
693 /// [`with_suggestion`]: ErrorContext::with_suggestion
694 /// [`with_details`]: ErrorContext::with_details
695 #[must_use]
696 pub const fn new(error: AgpmError) -> Self {
697 Self {
698 error,
699 suggestion: None,
700 details: None,
701 }
702 }
703
704 /// Add a suggestion for resolving the error
705 ///
706 /// Suggestions should be actionable steps that users can take to resolve
707 /// the error. They are displayed in green in the terminal to draw attention.
708 ///
709 /// # Examples
710 ///
711 /// ```rust,no_run
712 /// use agpm_cli::core::{AgpmError, ErrorContext};
713 ///
714 /// let context = ErrorContext::new(AgpmError::GitNotFound)
715 /// .with_suggestion("Install git using 'brew install git' or visit https://git-scm.com/");
716 /// ```
717 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
718 self.suggestion = Some(suggestion.into());
719 self
720 }
721
722 /// Add additional details explaining the error
723 ///
724 /// Details provide context about why the error occurred or what it means.
725 /// They are displayed in yellow in the terminal to provide additional context
726 /// without being as prominent as the main error or suggestion.
727 ///
728 /// # Examples
729 ///
730 /// ```rust,no_run
731 /// use agpm_cli::core::{AgpmError, ErrorContext};
732 ///
733 /// let context = ErrorContext::new(AgpmError::ManifestNotFound)
734 /// .with_details("AGPM looks for agpm.toml in current directory and parent directories");
735 /// ```
736 pub fn with_details(mut self, details: impl Into<String>) -> Self {
737 self.details = Some(details.into());
738 self
739 }
740
741 /// Display the error context to stderr with terminal colors
742 ///
743 /// This method prints the error, details, and suggestion to stderr using
744 /// color coding:
745 /// - Error message: Red and bold
746 /// - Details: Yellow
747 /// - Suggestion: Green
748 ///
749 /// This is the primary way AGPM presents errors to users in the CLI.
750 ///
751 /// # Examples
752 ///
753 /// ```rust,no_run
754 /// use agpm_cli::core::{AgpmError, ErrorContext};
755 ///
756 /// let context = ErrorContext::new(AgpmError::GitNotFound)
757 /// .with_suggestion("Install git from https://git-scm.com/")
758 /// .with_details("AGPM requires git for repository operations");
759 ///
760 /// context.display(); // Prints colored error to stderr
761 /// ```
762 pub fn display(&self) {
763 eprintln!("{}: {}", "error".red().bold(), self.error);
764
765 if let Some(details) = &self.details {
766 eprintln!("{}: {}", "details".yellow(), details);
767 }
768
769 if let Some(suggestion) = &self.suggestion {
770 eprintln!("{}: {}", "suggestion".green(), suggestion);
771 }
772 }
773}
774
775impl fmt::Display for ErrorContext {
776 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
777 write!(f, "{}", self.error)?;
778
779 if let Some(details) = &self.details {
780 write!(f, "\nDetails: {details}")?;
781 }
782
783 if let Some(suggestion) = &self.suggestion {
784 write!(f, "\nSuggestion: {suggestion}")?;
785 }
786
787 Ok(())
788 }
789}
790
791impl std::error::Error for ErrorContext {}
792
793/// Extension trait for converting [`AgpmError`] to [`anyhow::Error`] with context
794///
795/// This trait provides a method to convert AGPM-specific errors into generic
796/// [`anyhow::Error`] instances while preserving user-friendly context information.
797///
798/// # Examples
799///
800/// ```rust,no_run
801/// use agpm_cli::core::{AgpmError, ErrorContext, IntoAnyhowWithContext};
802///
803/// let error = AgpmError::GitNotFound;
804/// let context = ErrorContext::new(AgpmError::Other { message: "dummy".to_string() })
805/// .with_suggestion("Install git");
806///
807/// let anyhow_error = error.into_anyhow_with_context(context);
808/// ```
809pub trait IntoAnyhowWithContext {
810 /// Convert the error to an [`anyhow::Error`] with the provided context
811 fn into_anyhow_with_context(self, context: ErrorContext) -> anyhow::Error;
812}
813
814impl IntoAnyhowWithContext for AgpmError {
815 fn into_anyhow_with_context(self, context: ErrorContext) -> anyhow::Error {
816 anyhow::Error::new(ErrorContext {
817 error: self,
818 suggestion: context.suggestion,
819 details: context.details,
820 })
821 }
822}
823
824impl ErrorContext {
825 /// Create an [`ErrorContext`] with only a suggestion (no specific error)
826 ///
827 /// This is useful for generic errors where you want to provide a suggestion
828 /// but don't have a specific [`AgpmError`] variant.
829 ///
830 /// # Examples
831 ///
832 /// ```rust,no_run
833 /// use agpm_cli::core::ErrorContext;
834 ///
835 /// let context = ErrorContext::suggestion("Try running with --verbose for more information");
836 /// context.display();
837 /// ```
838 pub fn suggestion(suggestion: impl Into<String>) -> Self {
839 Self {
840 error: AgpmError::Other {
841 message: String::new(),
842 },
843 suggestion: Some(suggestion.into()),
844 details: None,
845 }
846 }
847}
848
849/// Convert any error to a user-friendly [`ErrorContext`] with actionable suggestions
850///
851/// This function is the main entry point for converting arbitrary errors into
852/// user-friendly error messages for CLI display. It recognizes common error types
853/// and provides appropriate context and suggestions.
854///
855/// # Error Recognition
856///
857/// The function recognizes and provides specific handling for:
858/// - [`AgpmError`] variants with tailored suggestions
859/// - [`std::io::Error`] with filesystem-specific guidance
860/// - [`toml::de::Error`] with TOML syntax help
861/// - Generic errors with basic context
862///
863/// # Examples
864///
865/// ## Converting AGPM Errors
866///
867/// ```rust,no_run
868/// use agpm_cli::core::{AgpmError, ErrorContext};
869///
870/// let error = AgpmError::GitNotFound;
871/// let anyhow_error = anyhow::Error::from(error);
872/// let context = anyhow_error.context("Operation failed");
873///
874/// context.display(); // Shows git installation suggestions
875/// ```
876///
877/// ## Converting IO Errors
878///
879/// ```rust,no_run
880/// use agpm_cli::core::ErrorContext;
881/// use std::io::{Error, ErrorKind};
882///
883/// let io_error = Error::new(ErrorKind::PermissionDenied, "access denied");
884/// let anyhow_error = anyhow::Error::from(io_error);
885/// let context = anyhow_error.context("Operation failed");
886///
887/// context.display(); // Shows permission-related suggestions
888/// ```
889///
890/// ## Converting Generic Errors
891///
892/// ```rust,no_run
893/// use agpm_cli::core::ErrorContext;
894///
895/// let error = anyhow::anyhow!("Something went wrong");
896/// let context = ErrorContext::new(error);
897///
898/// context.display(); // Shows the error with generic formatting
899/// ```
900///
901#[cfg(test)]
902mod tests {
903 use super::*;
904 use crate::core::error_formatting::create_error_context;
905
906 #[test]
907 fn test_error_display() {
908 let error = AgpmError::GitNotFound;
909 assert_eq!(error.to_string(), "Git is not installed or not found in PATH");
910
911 let error = AgpmError::ResourceNotFound {
912 name: "test".to_string(),
913 };
914 assert_eq!(error.to_string(), "Resource 'test' not found");
915
916 let error = AgpmError::InvalidVersionConstraint {
917 constraint: "bad-version".to_string(),
918 };
919 assert_eq!(error.to_string(), "Invalid version constraint: bad-version");
920
921 let error = AgpmError::GitCommandError {
922 operation: "clone".to_string(),
923 stderr: "repository not found".to_string(),
924 };
925 assert_eq!(error.to_string(), "Git operation failed: clone\nrepository not found");
926 }
927
928 #[test]
929 fn test_error_context() {
930 let ctx = ErrorContext::new(AgpmError::GitNotFound)
931 .with_suggestion("Install git using your package manager")
932 .with_details("Git is required for AGPM to function");
933
934 assert_eq!(ctx.suggestion, Some("Install git using your package manager".to_string()));
935 assert_eq!(ctx.details, Some("Git is required for AGPM to function".to_string()));
936 }
937
938 #[test]
939 fn test_error_context_display() {
940 let ctx = ErrorContext::new(AgpmError::GitNotFound).with_suggestion("Install git");
941
942 let display = format!("{ctx}");
943 assert!(display.contains("Git is not installed or not found in PATH"));
944 }
945
946 #[test]
947 fn test_from_semver_error() {
948 let result = semver::Version::parse("invalid-version");
949 if let Err(e) = result {
950 let agpm_error = AgpmError::from(e);
951 match agpm_error {
952 AgpmError::SemverError(_) => {}
953 _ => panic!("Expected SemverError"),
954 }
955 }
956 }
957
958 #[test]
959 fn test_error_display_all_variants() {
960 // Test display for various error variants
961 let errors = vec![
962 AgpmError::GitRepoInvalid {
963 path: "/test/path".to_string(),
964 },
965 AgpmError::GitCheckoutFailed {
966 reference: "main".to_string(),
967 reason: "not found".to_string(),
968 },
969 AgpmError::ConfigError {
970 message: "config issue".to_string(),
971 },
972 AgpmError::ManifestValidationError {
973 reason: "invalid format".to_string(),
974 },
975 AgpmError::LockfileParseError {
976 file: "agpm.lock".to_string(),
977 reason: "syntax error".to_string(),
978 },
979 AgpmError::ResourceFileNotFound {
980 path: "test.md".to_string(),
981 source_name: "source".to_string(),
982 },
983 AgpmError::DirectoryNotEmpty {
984 path: "/some/dir".to_string(),
985 },
986 AgpmError::InvalidDependency {
987 name: "dep".to_string(),
988 reason: "bad format".to_string(),
989 },
990 AgpmError::DependencyNotMet {
991 name: "dep".to_string(),
992 required: "v1.0".to_string(),
993 found: "v2.0".to_string(),
994 },
995 AgpmError::ConfigNotFound {
996 path: "/config/path".to_string(),
997 },
998 AgpmError::PlatformNotSupported {
999 operation: "test op".to_string(),
1000 },
1001 ];
1002
1003 for error in errors {
1004 let display = format!("{error}");
1005 assert!(!display.is_empty());
1006 }
1007 }
1008
1009 #[test]
1010 fn test_create_error_context_git_operations() {
1011 // Test different git operations
1012 let operations = vec![
1013 ("fetch", "internet connection"),
1014 ("checkout", "branch, tag"),
1015 ("pull", "git configuration"),
1016 ];
1017
1018 for (op, expected_text) in operations {
1019 let ctx = create_error_context(&AgpmError::GitCommandError {
1020 operation: op.to_string(),
1021 stderr: "error".to_string(),
1022 });
1023 assert!(ctx.suggestion.is_some());
1024 assert!(ctx.suggestion.unwrap().to_lowercase().contains(expected_text));
1025 }
1026 }
1027
1028 #[test]
1029 fn test_create_error_context_resource_file_not_found() {
1030 let ctx = create_error_context(&AgpmError::ResourceFileNotFound {
1031 path: "agents/test.md".to_string(),
1032 source_name: "official".to_string(),
1033 });
1034 assert!(ctx.suggestion.is_some());
1035 let suggestion = ctx.suggestion.unwrap();
1036 assert!(suggestion.contains("agents/test.md"));
1037 assert!(suggestion.contains("official"));
1038 assert!(ctx.details.is_some());
1039 }
1040
1041 #[test]
1042 fn test_create_error_context_manifest_parse_error() {
1043 let ctx = create_error_context(&AgpmError::ManifestParseError {
1044 file: "custom.toml".to_string(),
1045 reason: "invalid syntax".to_string(),
1046 });
1047 assert!(ctx.suggestion.is_some());
1048 let suggestion = ctx.suggestion.unwrap();
1049 assert!(suggestion.contains("custom.toml"));
1050 assert!(ctx.details.is_some());
1051 }
1052
1053 #[test]
1054 fn test_create_error_context_git_clone_failed() {
1055 let ctx = create_error_context(&AgpmError::GitCloneFailed {
1056 url: "https://example.com/repo.git".to_string(),
1057 reason: "network error".to_string(),
1058 });
1059 assert!(ctx.suggestion.is_some());
1060 let suggestion = ctx.suggestion.unwrap();
1061 assert!(suggestion.contains("https://example.com/repo.git"));
1062 assert!(ctx.details.is_some());
1063 }
1064}