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    /// Network error
474    #[error("Network error: {operation}")]
475    NetworkError {
476        /// The network operation that failed
477        operation: String,
478        /// Reason for the network failure
479        reason: String,
480    },
481
482    /// File system error
483    #[error("File system error: {operation}: {path}")]
484    FileSystemError {
485        /// The file system operation that failed
486        operation: String,
487        /// Path where the file system error occurred
488        path: String,
489    },
490
491    /// Permission denied
492    #[error("Permission denied: {operation}: {path}")]
493    PermissionDenied {
494        /// The operation that was denied due to insufficient permissions
495        operation: String,
496        /// Path where permission was denied
497        path: String,
498    },
499
500    /// Directory not empty
501    #[error("Directory is not empty: {path}")]
502    DirectoryNotEmpty {
503        /// Path to the directory that is not empty
504        path: String,
505    },
506
507    /// Invalid dependency specification
508    #[error("Invalid dependency specification for '{name}': {reason}")]
509    InvalidDependency {
510        /// Name of the invalid dependency
511        name: String,
512        /// Reason why the dependency specification is invalid
513        reason: String,
514    },
515
516    /// Invalid resource content
517    #[error("Invalid resource content in '{name}': {reason}")]
518    InvalidResource {
519        /// Name of the invalid resource
520        name: String,
521        /// Reason why the resource content is invalid
522        reason: String,
523    },
524
525    /// Dependency not met
526    #[error("Dependency '{name}' requires version {required}, but {found} was found")]
527    DependencyNotMet {
528        /// Name of the dependency that is not satisfied
529        name: String,
530        /// The required version constraint
531        required: String,
532        /// The version that was actually found
533        found: String,
534    },
535
536    /// Config file not found
537    #[error("Configuration file not found: {path}")]
538    ConfigNotFound {
539        /// Path to the configuration file that was not found
540        path: String,
541    },
542
543    /// Checksum mismatch
544    #[error("Checksum mismatch for resource '{name}': expected {expected}, got {actual}")]
545    ChecksumMismatch {
546        /// Name of the resource with checksum mismatch
547        name: String,
548        /// The expected checksum value
549        expected: String,
550        /// The actual checksum that was computed
551        actual: String,
552    },
553
554    /// Platform not supported
555    #[error("Operation not supported on this platform: {operation}")]
556    PlatformNotSupported {
557        /// The operation that is not supported on this platform
558        operation: String,
559    },
560
561    /// IO error
562    #[error("IO error: {0}")]
563    IoError(#[from] std::io::Error),
564
565    /// TOML parsing error
566    #[error("TOML parsing error: {0}")]
567    TomlError(#[from] toml::de::Error),
568
569    /// TOML serialization error
570    #[error("TOML serialization error: {0}")]
571    TomlSerError(#[from] toml::ser::Error),
572
573    /// Semver parsing error
574    #[error("Semver parsing error: {0}")]
575    SemverError(#[from] semver::Error),
576
577    /// Other error
578    #[error("{message}")]
579    Other {
580        /// Generic error message
581        message: String,
582    },
583}
584
585/// Error context wrapper that provides user-friendly error information
586///
587/// `ErrorContext` wraps a [`AgpmError`] and adds optional user-friendly messages,
588/// suggestions for resolution, and additional details. This is the primary way
589/// AGPM presents errors to CLI users.
590///
591/// # Design Philosophy
592///
593/// Error contexts are designed to be:
594/// - **Actionable**: Include specific suggestions for resolving the error
595/// - **Informative**: Provide context about why the error occurred
596/// - **Colorized**: Use terminal colors to highlight important information
597/// - **Consistent**: Follow a standard format across all error types
598///
599/// # Display Format
600///
601/// When displayed, errors show:
602/// 1. **Error**: The main error message in red
603/// 2. **Details**: Additional context about the error in yellow (optional)
604/// 3. **Suggestion**: Actionable steps to resolve the issue in green (optional)
605///
606/// # Examples
607///
608/// ## Creating Error Context
609///
610/// ```rust,no_run
611/// use agpm_cli::core::{AgpmError, ErrorContext};
612///
613/// let error = AgpmError::GitNotFound;
614/// let context = ErrorContext::new(error)
615///     .with_suggestion("Install git from https://git-scm.com/")
616///     .with_details("AGPM requires git for repository operations");
617///
618/// // Display to terminal with colors
619/// context.display();
620///
621/// // Or convert to string for logging
622/// let message = context.to_string();
623/// ```
624///
625/// ## Builder Pattern Usage
626///
627/// ```rust,no_run
628/// use agpm_cli::core::{AgpmError, ErrorContext};
629///
630/// let context = ErrorContext::new(AgpmError::ManifestNotFound)
631///     .with_suggestion("Create a agpm.toml file in your project directory")
632///     .with_details("AGPM searches current and parent directories for agpm.toml");
633///
634/// println!("{}", context);
635/// ```
636///
637/// ## Quick Suggestion Creation
638///
639/// ```rust,no_run
640/// use agpm_cli::core::ErrorContext;
641///
642/// // Create context with just a suggestion (useful for generic errors)
643/// let context = ErrorContext::suggestion("Try running the command with --verbose");
644/// ```
645#[derive(Debug)]
646pub struct ErrorContext {
647    /// The underlying AGPM error
648    pub error: AgpmError,
649    /// Optional suggestion for resolving the error
650    pub suggestion: Option<String>,
651    /// Optional additional details about the error
652    pub details: Option<String>,
653}
654
655impl ErrorContext {
656    /// Create a new error context from a [`AgpmError`]
657    ///
658    /// This creates a basic error context with no additional suggestions or details.
659    /// Use the builder methods [`with_suggestion`] and [`with_details`] to add
660    /// user-friendly information.
661    ///
662    /// # Examples
663    ///
664    /// ```rust,no_run
665    /// use agpm_cli::core::{AgpmError, ErrorContext};
666    ///
667    /// let context = ErrorContext::new(AgpmError::GitNotFound);
668    /// ```
669    ///
670    /// [`with_suggestion`]: ErrorContext::with_suggestion
671    /// [`with_details`]: ErrorContext::with_details
672    #[must_use]
673    pub const fn new(error: AgpmError) -> Self {
674        Self {
675            error,
676            suggestion: None,
677            details: None,
678        }
679    }
680
681    /// Add a suggestion for resolving the error
682    ///
683    /// Suggestions should be actionable steps that users can take to resolve
684    /// the error. They are displayed in green in the terminal to draw attention.
685    ///
686    /// # Examples
687    ///
688    /// ```rust,no_run
689    /// use agpm_cli::core::{AgpmError, ErrorContext};
690    ///
691    /// let context = ErrorContext::new(AgpmError::GitNotFound)
692    ///     .with_suggestion("Install git using 'brew install git' or visit https://git-scm.com/");
693    /// ```
694    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
695        self.suggestion = Some(suggestion.into());
696        self
697    }
698
699    /// Add additional details explaining the error
700    ///
701    /// Details provide context about why the error occurred or what it means.
702    /// They are displayed in yellow in the terminal to provide additional context
703    /// without being as prominent as the main error or suggestion.
704    ///
705    /// # Examples
706    ///
707    /// ```rust,no_run
708    /// use agpm_cli::core::{AgpmError, ErrorContext};
709    ///
710    /// let context = ErrorContext::new(AgpmError::ManifestNotFound)
711    ///     .with_details("AGPM looks for agpm.toml in current directory and parent directories");
712    /// ```
713    pub fn with_details(mut self, details: impl Into<String>) -> Self {
714        self.details = Some(details.into());
715        self
716    }
717
718    /// Display the error context to stderr with terminal colors
719    ///
720    /// This method prints the error, details, and suggestion to stderr using
721    /// color coding:
722    /// - Error message: Red and bold
723    /// - Details: Yellow
724    /// - Suggestion: Green
725    ///
726    /// This is the primary way AGPM presents errors to users in the CLI.
727    ///
728    /// # Examples
729    ///
730    /// ```rust,no_run
731    /// use agpm_cli::core::{AgpmError, ErrorContext};
732    ///
733    /// let context = ErrorContext::new(AgpmError::GitNotFound)
734    ///     .with_suggestion("Install git from https://git-scm.com/")
735    ///     .with_details("AGPM requires git for repository operations");
736    ///
737    /// context.display(); // Prints colored error to stderr
738    /// ```
739    pub fn display(&self) {
740        eprintln!("{}: {}", "error".red().bold(), self.error);
741
742        if let Some(details) = &self.details {
743            eprintln!("{}: {}", "details".yellow(), details);
744        }
745
746        if let Some(suggestion) = &self.suggestion {
747            eprintln!("{}: {}", "suggestion".green(), suggestion);
748        }
749    }
750}
751
752impl fmt::Display for ErrorContext {
753    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
754        write!(f, "{}", self.error)?;
755
756        if let Some(details) = &self.details {
757            write!(f, "\nDetails: {details}")?;
758        }
759
760        if let Some(suggestion) = &self.suggestion {
761            write!(f, "\nSuggestion: {suggestion}")?;
762        }
763
764        Ok(())
765    }
766}
767
768impl std::error::Error for ErrorContext {}
769
770/// Extension trait for converting [`AgpmError`] to [`anyhow::Error`] with context
771///
772/// This trait provides a method to convert AGPM-specific errors into generic
773/// [`anyhow::Error`] instances while preserving user-friendly context information.
774///
775/// # Examples
776///
777/// ```rust,no_run
778/// use agpm_cli::core::{AgpmError, ErrorContext, IntoAnyhowWithContext};
779///
780/// let error = AgpmError::GitNotFound;
781/// let context = ErrorContext::new(AgpmError::Other { message: "dummy".to_string() })
782///     .with_suggestion("Install git");
783///
784/// let anyhow_error = error.into_anyhow_with_context(context);
785/// ```
786pub trait IntoAnyhowWithContext {
787    /// Convert the error to an [`anyhow::Error`] with the provided context
788    fn into_anyhow_with_context(self, context: ErrorContext) -> anyhow::Error;
789}
790
791impl IntoAnyhowWithContext for AgpmError {
792    fn into_anyhow_with_context(self, context: ErrorContext) -> anyhow::Error {
793        anyhow::Error::new(ErrorContext {
794            error: self,
795            suggestion: context.suggestion,
796            details: context.details,
797        })
798    }
799}
800
801impl ErrorContext {
802    /// Create an [`ErrorContext`] with only a suggestion (no specific error)
803    ///
804    /// This is useful for generic errors where you want to provide a suggestion
805    /// but don't have a specific [`AgpmError`] variant.
806    ///
807    /// # Examples
808    ///
809    /// ```rust,no_run
810    /// use agpm_cli::core::ErrorContext;
811    ///
812    /// let context = ErrorContext::suggestion("Try running with --verbose for more information");
813    /// context.display();
814    /// ```
815    pub fn suggestion(suggestion: impl Into<String>) -> Self {
816        Self {
817            error: AgpmError::Other {
818                message: String::new(),
819            },
820            suggestion: Some(suggestion.into()),
821            details: None,
822        }
823    }
824}
825
826/// Convert any error to a user-friendly [`ErrorContext`] with actionable suggestions
827///
828/// This function is the main entry point for converting arbitrary errors into
829/// user-friendly error messages for CLI display. It recognizes common error types
830/// and provides appropriate context and suggestions.
831///
832/// # Error Recognition
833///
834/// The function recognizes and provides specific handling for:
835/// - [`AgpmError`] variants with tailored suggestions
836/// - [`std::io::Error`] with filesystem-specific guidance
837/// - [`toml::de::Error`] with TOML syntax help
838/// - Generic errors with basic context
839///
840/// # Examples
841///
842/// ## Converting AGPM Errors
843///
844/// ```rust,no_run
845/// use agpm_cli::core::{AgpmError, ErrorContext};
846///
847/// let error = AgpmError::GitNotFound;
848/// let anyhow_error = anyhow::Error::from(error);
849/// let context = anyhow_error.context("Operation failed");
850///
851/// context.display(); // Shows git installation suggestions
852/// ```
853///
854/// ## Converting IO Errors
855///
856/// ```rust,no_run
857/// use agpm_cli::core::ErrorContext;
858/// use std::io::{Error, ErrorKind};
859///
860/// let io_error = Error::new(ErrorKind::PermissionDenied, "access denied");
861/// let anyhow_error = anyhow::Error::from(io_error);
862/// let context = anyhow_error.context("Operation failed");
863///
864/// context.display(); // Shows permission-related suggestions
865/// ```
866///
867/// ## Converting Generic Errors
868///
869/// ```rust,no_run
870/// use agpm_cli::core::ErrorContext;
871///
872/// let error = anyhow::anyhow!("Something went wrong");
873/// let context = ErrorContext::new(error);
874///
875/// context.display(); // Shows the error with generic formatting
876/// ```
877///
878#[cfg(test)]
879mod tests {
880    use super::*;
881    use crate::core::error_formatting::create_error_context;
882
883    #[test]
884    fn test_error_display() {
885        let error = AgpmError::GitNotFound;
886        assert_eq!(error.to_string(), "Git is not installed or not found in PATH");
887
888        let error = AgpmError::ResourceNotFound {
889            name: "test".to_string(),
890        };
891        assert_eq!(error.to_string(), "Resource 'test' not found");
892
893        let error = AgpmError::InvalidVersionConstraint {
894            constraint: "bad-version".to_string(),
895        };
896        assert_eq!(error.to_string(), "Invalid version constraint: bad-version");
897
898        let error = AgpmError::GitCommandError {
899            operation: "clone".to_string(),
900            stderr: "repository not found".to_string(),
901        };
902        assert_eq!(error.to_string(), "Git operation failed: clone\nrepository not found");
903    }
904
905    #[test]
906    fn test_error_context() {
907        let ctx = ErrorContext::new(AgpmError::GitNotFound)
908            .with_suggestion("Install git using your package manager")
909            .with_details("Git is required for AGPM to function");
910
911        assert_eq!(ctx.suggestion, Some("Install git using your package manager".to_string()));
912        assert_eq!(ctx.details, Some("Git is required for AGPM to function".to_string()));
913    }
914
915    #[test]
916    fn test_error_context_display() {
917        let ctx = ErrorContext::new(AgpmError::GitNotFound).with_suggestion("Install git");
918
919        let display = format!("{ctx}");
920        assert!(display.contains("Git is not installed or not found in PATH"));
921    }
922
923    #[test]
924    fn test_from_semver_error() {
925        let result = semver::Version::parse("invalid-version");
926        if let Err(e) = result {
927            let agpm_error = AgpmError::from(e);
928            match agpm_error {
929                AgpmError::SemverError(_) => {}
930                _ => panic!("Expected SemverError"),
931            }
932        }
933    }
934
935    #[test]
936    fn test_error_display_all_variants() {
937        // Test display for various error variants
938        let errors = vec![
939            AgpmError::GitRepoInvalid {
940                path: "/test/path".to_string(),
941            },
942            AgpmError::GitCheckoutFailed {
943                reference: "main".to_string(),
944                reason: "not found".to_string(),
945            },
946            AgpmError::ConfigError {
947                message: "config issue".to_string(),
948            },
949            AgpmError::ManifestValidationError {
950                reason: "invalid format".to_string(),
951            },
952            AgpmError::LockfileParseError {
953                file: "agpm.lock".to_string(),
954                reason: "syntax error".to_string(),
955            },
956            AgpmError::ResourceFileNotFound {
957                path: "test.md".to_string(),
958                source_name: "source".to_string(),
959            },
960            AgpmError::DirectoryNotEmpty {
961                path: "/some/dir".to_string(),
962            },
963            AgpmError::InvalidDependency {
964                name: "dep".to_string(),
965                reason: "bad format".to_string(),
966            },
967            AgpmError::DependencyNotMet {
968                name: "dep".to_string(),
969                required: "v1.0".to_string(),
970                found: "v2.0".to_string(),
971            },
972            AgpmError::ConfigNotFound {
973                path: "/config/path".to_string(),
974            },
975            AgpmError::PlatformNotSupported {
976                operation: "test op".to_string(),
977            },
978        ];
979
980        for error in errors {
981            let display = format!("{error}");
982            assert!(!display.is_empty());
983        }
984    }
985
986    #[test]
987    fn test_create_error_context_git_operations() {
988        // Test different git operations
989        let operations = vec![
990            ("fetch", "internet connection"),
991            ("checkout", "branch, tag"),
992            ("pull", "git configuration"),
993        ];
994
995        for (op, expected_text) in operations {
996            let ctx = create_error_context(&AgpmError::GitCommandError {
997                operation: op.to_string(),
998                stderr: "error".to_string(),
999            });
1000            assert!(ctx.suggestion.is_some());
1001            assert!(ctx.suggestion.unwrap().to_lowercase().contains(expected_text));
1002        }
1003    }
1004
1005    #[test]
1006    fn test_create_error_context_resource_file_not_found() {
1007        let ctx = create_error_context(&AgpmError::ResourceFileNotFound {
1008            path: "agents/test.md".to_string(),
1009            source_name: "official".to_string(),
1010        });
1011        assert!(ctx.suggestion.is_some());
1012        let suggestion = ctx.suggestion.unwrap();
1013        assert!(suggestion.contains("agents/test.md"));
1014        assert!(suggestion.contains("official"));
1015        assert!(ctx.details.is_some());
1016    }
1017
1018    #[test]
1019    fn test_create_error_context_manifest_parse_error() {
1020        let ctx = create_error_context(&AgpmError::ManifestParseError {
1021            file: "custom.toml".to_string(),
1022            reason: "invalid syntax".to_string(),
1023        });
1024        assert!(ctx.suggestion.is_some());
1025        let suggestion = ctx.suggestion.unwrap();
1026        assert!(suggestion.contains("custom.toml"));
1027        assert!(ctx.details.is_some());
1028    }
1029
1030    #[test]
1031    fn test_create_error_context_git_clone_failed() {
1032        let ctx = create_error_context(&AgpmError::GitCloneFailed {
1033            url: "https://example.com/repo.git".to_string(),
1034            reason: "network error".to_string(),
1035        });
1036        assert!(ctx.suggestion.is_some());
1037        let suggestion = ctx.suggestion.unwrap();
1038        assert!(suggestion.contains("https://example.com/repo.git"));
1039        assert!(ctx.details.is_some());
1040    }
1041}