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}