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