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/// - [`ConfigError`] - Configuration file issues
133/// - [`TomlError`] - TOML parsing errors from [`toml::de::Error`]
134/// - [`TomlSerError`] - TOML serialization errors from [`toml::ser::Error`]
135///
136/// ## Resource Management
137/// - [`ResourceNotFound`] - Named resource doesn't exist
138/// - [`ResourceFileNotFound`] - Resource file missing from repository
139/// - [`InvalidResourceType`] - Unknown resource type specified
140/// - [`InvalidResourceStructure`] - Resource content is malformed
141/// - [`InvalidResource`] - Resource validation failed
142/// - [`AlreadyInstalled`] - Resource already exists
143///
144/// ## Dependency Resolution
145/// - [`CircularDependency`] - Dependency cycle detected
146/// - [`DependencyResolutionFailed`] - Cannot resolve dependencies
147/// - [`DependencyNotMet`] - Version constraint not satisfied
148/// - [`InvalidDependency`] - Malformed dependency specification
149/// - [`InvalidVersionConstraint`] - Invalid version format
150/// - [`VersionNotFound`] - Requested version doesn't exist
151/// - [`SemverError`] - Semantic version parsing from [`semver::Error`]
152///
153/// ## Source Management
154/// - [`SourceNotFound`] - Named source not defined
155/// - [`SourceUnreachable`] - Cannot connect to source repository
156///
157/// ## Platform and Network
158/// - [`NetworkError`] - Network connectivity issues
159/// - [`PlatformNotSupported`] - Operation not supported on current platform
160/// - [`ChecksumMismatch`] - File integrity verification failed
161///
162/// # Examples
163///
164/// ## Pattern Matching on Errors
165///
166/// ```rust,no_run
167/// use agpm_cli::core::AgpmError;
168///
169/// fn handle_error(error: AgpmError) {
170///     match error {
171///         AgpmError::GitNotFound => {
172///             eprintln!("Please install git to use AGPM");
173///             std::process::exit(1);
174///         }
175///         AgpmError::ManifestNotFound => {
176///             eprintln!("Run 'agpm init' to create a manifest file");
177///         }
178///         AgpmError::NetworkError { operation, .. } => {
179///             eprintln!("Network error during {}: check your connection", operation);
180///         }
181///         _ => {
182///             eprintln!("Unexpected error: {}", error);
183///         }
184///     }
185/// }
186/// ```
187///
188/// ## Creating Specific Errors
189///
190/// ```rust,no_run
191/// use agpm_cli::core::AgpmError;
192///
193/// // Create a git command error with context
194/// let error = AgpmError::GitCommandError {
195///     operation: "clone".to_string(),
196///     stderr: "repository not found".to_string(),
197/// };
198///
199/// // Create a resource not found error
200/// let error = AgpmError::ResourceNotFound {
201///     name: "my-agent".to_string(),
202/// };
203///
204/// // Create a version constraint error
205/// let error = AgpmError::InvalidVersionConstraint {
206///     constraint: "~1.x.y".to_string(),
207/// };
208/// ```
209///
210/// [`GitNotFound`]: AgpmError::GitNotFound
211/// [`GitCommandError`]: AgpmError::GitCommandError
212/// [`GitAuthenticationFailed`]: AgpmError::GitAuthenticationFailed
213/// [`GitCloneFailed`]: AgpmError::GitCloneFailed
214/// [`GitCheckoutFailed`]: AgpmError::GitCheckoutFailed
215/// [`FileSystemError`]: AgpmError::FileSystemError
216/// [`PermissionDenied`]: AgpmError::PermissionDenied
217/// [`DirectoryNotEmpty`]: AgpmError::DirectoryNotEmpty
218/// [`IoError`]: AgpmError::IoError
219/// [`ManifestNotFound`]: AgpmError::ManifestNotFound
220/// [`ManifestParseError`]: AgpmError::ManifestParseError
221/// [`ManifestValidationError`]: AgpmError::ManifestValidationError
222/// [`LockfileParseError`]: AgpmError::LockfileParseError
223/// [`ConfigError`]: AgpmError::ConfigError
224/// [`TomlError`]: AgpmError::TomlError
225/// [`TomlSerError`]: AgpmError::TomlSerError
226/// [`ResourceNotFound`]: AgpmError::ResourceNotFound
227/// [`ResourceFileNotFound`]: AgpmError::ResourceFileNotFound
228/// [`InvalidResourceType`]: AgpmError::InvalidResourceType
229/// [`InvalidResourceStructure`]: AgpmError::InvalidResourceStructure
230/// [`InvalidResource`]: AgpmError::InvalidResource
231/// [`AlreadyInstalled`]: AgpmError::AlreadyInstalled
232/// [`CircularDependency`]: AgpmError::CircularDependency
233/// [`DependencyResolutionFailed`]: AgpmError::DependencyResolutionFailed
234/// [`DependencyNotMet`]: AgpmError::DependencyNotMet
235/// [`InvalidDependency`]: AgpmError::InvalidDependency
236/// [`InvalidVersionConstraint`]: AgpmError::InvalidVersionConstraint
237/// [`VersionNotFound`]: AgpmError::VersionNotFound
238/// [`SemverError`]: AgpmError::SemverError
239/// [`SourceNotFound`]: AgpmError::SourceNotFound
240/// [`SourceUnreachable`]: AgpmError::SourceUnreachable
241/// [`NetworkError`]: AgpmError::NetworkError
242/// [`PlatformNotSupported`]: AgpmError::PlatformNotSupported
243/// [`ChecksumMismatch`]: AgpmError::ChecksumMismatch
244#[derive(Error, Debug)]
245pub enum AgpmError {
246    /// Git operation failed during execution
247    ///
248    /// This error occurs when a git command returns a non-zero exit code.
249    /// Common causes include network issues, authentication problems, or
250    /// invalid git repository states.
251    ///
252    /// # Fields
253    /// - `operation`: The git operation that failed (e.g., "clone", "fetch", "checkout")
254    /// - `stderr`: The error output from the git command
255    #[error("Git operation failed: {operation}")]
256    GitCommandError {
257        /// The git operation that failed (e.g., "clone", "fetch", "checkout")
258        operation: String,
259        /// The error output from the git command
260        stderr: String,
261    },
262
263    /// Git executable not found in PATH
264    ///
265    /// This error occurs when AGPM cannot locate the `git` command in the system PATH.
266    /// AGPM requires git to be installed and available for repository operations.
267    ///
268    /// Common solutions:
269    /// - Install git from <https://git-scm.com/>
270    /// - Use a package manager: `brew install git`, `apt install git`, etc.
271    /// - Ensure git is in your PATH environment variable
272    #[error("Git is not installed or not found in PATH")]
273    GitNotFound,
274
275    /// Git repository is invalid or corrupted
276    ///
277    /// This error occurs when a directory exists but doesn't contain a valid
278    /// git repository structure (missing .git directory or corrupted).
279    ///
280    /// # Fields
281    /// - `path`: The path that was expected to contain a git repository
282    #[error("Not a valid git repository: {path}")]
283    GitRepoInvalid {
284        /// The path that was expected to contain a git repository
285        path: String,
286    },
287
288    /// Git authentication failed for repository access
289    ///
290    /// This error occurs when git cannot authenticate with a remote repository.
291    /// Common for private repositories or when credentials are missing/expired.
292    ///
293    /// # Fields
294    /// - `url`: The repository URL that failed authentication
295    #[error("Git authentication failed for repository: {url}")]
296    GitAuthenticationFailed {
297        /// The repository URL that failed authentication
298        url: String,
299    },
300
301    /// Git repository clone failed
302    #[error("Failed to clone repository: {url}")]
303    GitCloneFailed {
304        /// The repository URL that failed to clone
305        url: String,
306        /// The reason for the clone failure
307        reason: String,
308    },
309
310    /// Git checkout failed
311    #[error("Failed to checkout reference '{reference}' in repository")]
312    GitCheckoutFailed {
313        /// The git reference (branch, tag, or commit) that failed to checkout
314        reference: String,
315        /// The reason for the checkout failure
316        reason: String,
317    },
318
319    /// Configuration error
320    #[error("Configuration error: {message}")]
321    ConfigError {
322        /// Description of the configuration error
323        message: String,
324    },
325
326    /// Manifest file (agpm.toml) not found
327    ///
328    /// This error occurs when AGPM cannot locate a agpm.toml file in the current
329    /// directory or any parent directory up to the filesystem root.
330    ///
331    /// AGPM searches for agpm.toml starting from the current working directory
332    /// and walking up the directory tree, similar to how git searches for .git.
333    #[error("Manifest file agpm.toml not found in current directory or any parent directory")]
334    ManifestNotFound,
335
336    /// Manifest parsing error
337    #[error("Invalid manifest file syntax in {file}")]
338    ManifestParseError {
339        /// Path to the manifest file that failed to parse
340        file: String,
341        /// Specific reason for the parsing failure
342        reason: String,
343    },
344
345    /// Manifest validation error
346    #[error("Manifest validation failed: {reason}")]
347    ManifestValidationError {
348        /// Reason why manifest validation failed
349        reason: String,
350    },
351
352    /// Lockfile parsing error
353    #[error("Invalid lockfile syntax in {file}")]
354    LockfileParseError {
355        /// Path to the lockfile that failed to parse
356        file: String,
357        /// Specific reason for the parsing failure
358        reason: String,
359    },
360
361    /// Resource not found
362    #[error("Resource '{name}' not found")]
363    ResourceNotFound {
364        /// Name of the resource that could not be found
365        name: String,
366    },
367
368    /// Resource file not found in repository
369    #[error("Resource file '{path}' not found in source '{source_name}'")]
370    ResourceFileNotFound {
371        /// Path to the resource file within the source repository
372        path: String,
373        /// Name of the source repository where the file was expected
374        source_name: String,
375    },
376
377    /// Source repository not found
378    #[error("Source repository '{name}' not defined in manifest")]
379    SourceNotFound {
380        /// Name of the source repository that is not defined
381        name: String,
382    },
383
384    /// Source repository unreachable
385    #[error("Cannot reach source repository '{name}' at {url}")]
386    SourceUnreachable {
387        /// Name of the source repository
388        name: String,
389        /// URL of the unreachable repository
390        url: String,
391    },
392
393    /// Invalid version constraint
394    #[error("Invalid version constraint: {constraint}")]
395    InvalidVersionConstraint {
396        /// The invalid version constraint string
397        constraint: String,
398    },
399
400    /// Version not found
401    #[error("Version '{version}' not found for resource '{resource}'")]
402    VersionNotFound {
403        /// Name of the resource for which the version was not found
404        resource: String,
405        /// The version string that could not be found
406        version: String,
407    },
408
409    /// Resource already installed
410    #[error("Resource '{name}' is already installed")]
411    AlreadyInstalled {
412        /// Name of the resource that is already installed
413        name: String,
414    },
415
416    /// Invalid resource type
417    #[error("Invalid resource type: {resource_type}")]
418    InvalidResourceType {
419        /// The invalid resource type that was specified
420        resource_type: String,
421    },
422
423    /// Invalid resource structure
424    #[error("Invalid resource structure in '{file}': {reason}")]
425    InvalidResourceStructure {
426        /// Path to the file with invalid resource structure
427        file: String,
428        /// Reason why the resource structure is invalid
429        reason: String,
430    },
431
432    /// Circular dependency detected in dependency graph
433    ///
434    /// This error occurs when resources depend on each other in a cycle,
435    /// making it impossible to determine installation order.
436    ///
437    /// Example: A depends on B, B depends on C, C depends on A
438    ///
439    /// # Fields
440    /// - `chain`: The dependency chain showing the circular reference
441    #[error("Circular dependency detected: {chain}")]
442    CircularDependency {
443        /// String representation of the circular dependency chain
444        chain: String,
445    },
446
447    /// Dependency resolution failed
448    #[error("Cannot resolve dependencies: {reason}")]
449    DependencyResolutionFailed {
450        /// Reason why dependency resolution failed
451        reason: String,
452    },
453
454    /// Network error
455    #[error("Network error: {operation}")]
456    NetworkError {
457        /// The network operation that failed
458        operation: String,
459        /// Reason for the network failure
460        reason: String,
461    },
462
463    /// File system error
464    #[error("File system error: {operation}")]
465    FileSystemError {
466        /// The file system operation that failed
467        operation: String,
468        /// Path where the file system error occurred
469        path: String,
470    },
471
472    /// Permission denied
473    #[error("Permission denied: {operation}")]
474    PermissionDenied {
475        /// The operation that was denied due to insufficient permissions
476        operation: String,
477        /// Path where permission was denied
478        path: String,
479    },
480
481    /// Directory not empty
482    #[error("Directory is not empty: {path}")]
483    DirectoryNotEmpty {
484        /// Path to the directory that is not empty
485        path: String,
486    },
487
488    /// Invalid dependency specification
489    #[error("Invalid dependency specification for '{name}': {reason}")]
490    InvalidDependency {
491        /// Name of the invalid dependency
492        name: String,
493        /// Reason why the dependency specification is invalid
494        reason: String,
495    },
496
497    /// Invalid resource content
498    #[error("Invalid resource content in '{name}': {reason}")]
499    InvalidResource {
500        /// Name of the invalid resource
501        name: String,
502        /// Reason why the resource content is invalid
503        reason: String,
504    },
505
506    /// Dependency not met
507    #[error("Dependency '{name}' requires version {required}, but {found} was found")]
508    DependencyNotMet {
509        /// Name of the dependency that is not satisfied
510        name: String,
511        /// The required version constraint
512        required: String,
513        /// The version that was actually found
514        found: String,
515    },
516
517    /// Config file not found
518    #[error("Configuration file not found: {path}")]
519    ConfigNotFound {
520        /// Path to the configuration file that was not found
521        path: String,
522    },
523
524    /// Checksum mismatch
525    #[error("Checksum mismatch for resource '{name}': expected {expected}, got {actual}")]
526    ChecksumMismatch {
527        /// Name of the resource with checksum mismatch
528        name: String,
529        /// The expected checksum value
530        expected: String,
531        /// The actual checksum that was computed
532        actual: String,
533    },
534
535    /// Platform not supported
536    #[error("Operation not supported on this platform: {operation}")]
537    PlatformNotSupported {
538        /// The operation that is not supported on this platform
539        operation: String,
540    },
541
542    /// IO error
543    #[error("IO error: {0}")]
544    IoError(#[from] std::io::Error),
545
546    /// TOML parsing error
547    #[error("TOML parsing error: {0}")]
548    TomlError(#[from] toml::de::Error),
549
550    /// TOML serialization error
551    #[error("TOML serialization error: {0}")]
552    TomlSerError(#[from] toml::ser::Error),
553
554    /// Semver parsing error
555    #[error("Semver parsing error: {0}")]
556    SemverError(#[from] semver::Error),
557
558    /// Other error
559    #[error("{message}")]
560    Other {
561        /// Generic error message
562        message: String,
563    },
564}
565
566impl Clone for AgpmError {
567    fn clone(&self) -> Self {
568        match self {
569            Self::GitCommandError {
570                operation,
571                stderr,
572            } => Self::GitCommandError {
573                operation: operation.clone(),
574                stderr: stderr.clone(),
575            },
576            Self::GitNotFound => Self::GitNotFound,
577            Self::GitRepoInvalid {
578                path,
579            } => Self::GitRepoInvalid {
580                path: path.clone(),
581            },
582            Self::GitAuthenticationFailed {
583                url,
584            } => Self::GitAuthenticationFailed {
585                url: url.clone(),
586            },
587            Self::GitCloneFailed {
588                url,
589                reason,
590            } => Self::GitCloneFailed {
591                url: url.clone(),
592                reason: reason.clone(),
593            },
594            Self::GitCheckoutFailed {
595                reference,
596                reason,
597            } => Self::GitCheckoutFailed {
598                reference: reference.clone(),
599                reason: reason.clone(),
600            },
601            Self::ConfigError {
602                message,
603            } => Self::ConfigError {
604                message: message.clone(),
605            },
606            Self::ManifestNotFound => Self::ManifestNotFound,
607            Self::ManifestParseError {
608                file,
609                reason,
610            } => Self::ManifestParseError {
611                file: file.clone(),
612                reason: reason.clone(),
613            },
614            Self::ManifestValidationError {
615                reason,
616            } => Self::ManifestValidationError {
617                reason: reason.clone(),
618            },
619            Self::LockfileParseError {
620                file,
621                reason,
622            } => Self::LockfileParseError {
623                file: file.clone(),
624                reason: reason.clone(),
625            },
626            Self::ResourceNotFound {
627                name,
628            } => Self::ResourceNotFound {
629                name: name.clone(),
630            },
631            Self::ResourceFileNotFound {
632                path,
633                source_name,
634            } => Self::ResourceFileNotFound {
635                path: path.clone(),
636                source_name: source_name.clone(),
637            },
638            Self::SourceNotFound {
639                name,
640            } => Self::SourceNotFound {
641                name: name.clone(),
642            },
643            Self::SourceUnreachable {
644                name,
645                url,
646            } => Self::SourceUnreachable {
647                name: name.clone(),
648                url: url.clone(),
649            },
650            Self::InvalidVersionConstraint {
651                constraint,
652            } => Self::InvalidVersionConstraint {
653                constraint: constraint.clone(),
654            },
655            Self::VersionNotFound {
656                resource,
657                version,
658            } => Self::VersionNotFound {
659                resource: resource.clone(),
660                version: version.clone(),
661            },
662            Self::AlreadyInstalled {
663                name,
664            } => Self::AlreadyInstalled {
665                name: name.clone(),
666            },
667            Self::InvalidResourceType {
668                resource_type,
669            } => Self::InvalidResourceType {
670                resource_type: resource_type.clone(),
671            },
672            Self::InvalidResourceStructure {
673                file,
674                reason,
675            } => Self::InvalidResourceStructure {
676                file: file.clone(),
677                reason: reason.clone(),
678            },
679            Self::CircularDependency {
680                chain,
681            } => Self::CircularDependency {
682                chain: chain.clone(),
683            },
684            Self::DependencyResolutionFailed {
685                reason,
686            } => Self::DependencyResolutionFailed {
687                reason: reason.clone(),
688            },
689            Self::NetworkError {
690                operation,
691                reason,
692            } => Self::NetworkError {
693                operation: operation.clone(),
694                reason: reason.clone(),
695            },
696            Self::FileSystemError {
697                operation,
698                path,
699            } => Self::FileSystemError {
700                operation: operation.clone(),
701                path: path.clone(),
702            },
703            Self::PermissionDenied {
704                operation,
705                path,
706            } => Self::PermissionDenied {
707                operation: operation.clone(),
708                path: path.clone(),
709            },
710            Self::DirectoryNotEmpty {
711                path,
712            } => Self::DirectoryNotEmpty {
713                path: path.clone(),
714            },
715            Self::InvalidDependency {
716                name,
717                reason,
718            } => Self::InvalidDependency {
719                name: name.clone(),
720                reason: reason.clone(),
721            },
722            Self::InvalidResource {
723                name,
724                reason,
725            } => Self::InvalidResource {
726                name: name.clone(),
727                reason: reason.clone(),
728            },
729            Self::DependencyNotMet {
730                name,
731                required,
732                found,
733            } => Self::DependencyNotMet {
734                name: name.clone(),
735                required: required.clone(),
736                found: found.clone(),
737            },
738            Self::ConfigNotFound {
739                path,
740            } => Self::ConfigNotFound {
741                path: path.clone(),
742            },
743            Self::ChecksumMismatch {
744                name,
745                expected,
746                actual,
747            } => Self::ChecksumMismatch {
748                name: name.clone(),
749                expected: expected.clone(),
750                actual: actual.clone(),
751            },
752            Self::PlatformNotSupported {
753                operation,
754            } => Self::PlatformNotSupported {
755                operation: operation.clone(),
756            },
757            // For errors that don't implement Clone, convert to Other
758            Self::IoError(e) => Self::Other {
759                message: format!("IO error: {e}"),
760            },
761            Self::TomlError(e) => Self::Other {
762                message: format!("TOML parsing error: {e}"),
763            },
764            Self::TomlSerError(e) => Self::Other {
765                message: format!("TOML serialization error: {e}"),
766            },
767            Self::SemverError(e) => Self::Other {
768                message: format!("Semver parsing error: {e}"),
769            },
770            Self::Other {
771                message,
772            } => Self::Other {
773                message: message.clone(),
774            },
775        }
776    }
777}
778
779/// Error context wrapper that provides user-friendly error information
780///
781/// `ErrorContext` wraps a [`AgpmError`] and adds optional user-friendly messages,
782/// suggestions for resolution, and additional details. This is the primary way
783/// AGPM presents errors to CLI users.
784///
785/// # Design Philosophy
786///
787/// Error contexts are designed to be:
788/// - **Actionable**: Include specific suggestions for resolving the error
789/// - **Informative**: Provide context about why the error occurred
790/// - **Colorized**: Use terminal colors to highlight important information
791/// - **Consistent**: Follow a standard format across all error types
792///
793/// # Display Format
794///
795/// When displayed, errors show:
796/// 1. **Error**: The main error message in red
797/// 2. **Details**: Additional context about the error in yellow (optional)
798/// 3. **Suggestion**: Actionable steps to resolve the issue in green (optional)
799///
800/// # Examples
801///
802/// ## Creating Error Context
803///
804/// ```rust,no_run
805/// use agpm_cli::core::{AgpmError, ErrorContext};
806///
807/// let error = AgpmError::GitNotFound;
808/// let context = ErrorContext::new(error)
809///     .with_suggestion("Install git from https://git-scm.com/")
810///     .with_details("AGPM requires git for repository operations");
811///
812/// // Display to terminal with colors
813/// context.display();
814///
815/// // Or convert to string for logging
816/// let message = context.to_string();
817/// ```
818///
819/// ## Builder Pattern Usage
820///
821/// ```rust,no_run
822/// use agpm_cli::core::{AgpmError, ErrorContext};
823///
824/// let context = ErrorContext::new(AgpmError::ManifestNotFound)
825///     .with_suggestion("Create a agpm.toml file in your project directory")
826///     .with_details("AGPM searches current and parent directories for agpm.toml");
827///
828/// println!("{}", context);
829/// ```
830///
831/// ## Quick Suggestion Creation
832///
833/// ```rust,no_run
834/// use agpm_cli::core::ErrorContext;
835///
836/// // Create context with just a suggestion (useful for generic errors)
837/// let context = ErrorContext::suggestion("Try running the command with --verbose");
838/// ```
839#[derive(Debug)]
840pub struct ErrorContext {
841    /// The underlying AGPM error
842    pub error: AgpmError,
843    /// Optional suggestion for resolving the error
844    pub suggestion: Option<String>,
845    /// Optional additional details about the error
846    pub details: Option<String>,
847}
848
849impl ErrorContext {
850    /// Create a new error context from a [`AgpmError`]
851    ///
852    /// This creates a basic error context with no additional suggestions or details.
853    /// Use the builder methods [`with_suggestion`] and [`with_details`] to add
854    /// user-friendly information.
855    ///
856    /// # Examples
857    ///
858    /// ```rust,no_run
859    /// use agpm_cli::core::{AgpmError, ErrorContext};
860    ///
861    /// let context = ErrorContext::new(AgpmError::GitNotFound);
862    /// ```
863    ///
864    /// [`with_suggestion`]: ErrorContext::with_suggestion
865    /// [`with_details`]: ErrorContext::with_details
866    #[must_use]
867    pub const fn new(error: AgpmError) -> Self {
868        Self {
869            error,
870            suggestion: None,
871            details: None,
872        }
873    }
874
875    /// Add a suggestion for resolving the error
876    ///
877    /// Suggestions should be actionable steps that users can take to resolve
878    /// the error. They are displayed in green in the terminal to draw attention.
879    ///
880    /// # Examples
881    ///
882    /// ```rust,no_run
883    /// use agpm_cli::core::{AgpmError, ErrorContext};
884    ///
885    /// let context = ErrorContext::new(AgpmError::GitNotFound)
886    ///     .with_suggestion("Install git using 'brew install git' or visit https://git-scm.com/");
887    /// ```
888    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
889        self.suggestion = Some(suggestion.into());
890        self
891    }
892
893    /// Add additional details explaining the error
894    ///
895    /// Details provide context about why the error occurred or what it means.
896    /// They are displayed in yellow in the terminal to provide additional context
897    /// without being as prominent as the main error or suggestion.
898    ///
899    /// # Examples
900    ///
901    /// ```rust,no_run
902    /// use agpm_cli::core::{AgpmError, ErrorContext};
903    ///
904    /// let context = ErrorContext::new(AgpmError::ManifestNotFound)
905    ///     .with_details("AGPM looks for agpm.toml in current directory and parent directories");
906    /// ```
907    pub fn with_details(mut self, details: impl Into<String>) -> Self {
908        self.details = Some(details.into());
909        self
910    }
911
912    /// Display the error context to stderr with terminal colors
913    ///
914    /// This method prints the error, details, and suggestion to stderr using
915    /// color coding:
916    /// - Error message: Red and bold
917    /// - Details: Yellow
918    /// - Suggestion: Green
919    ///
920    /// This is the primary way AGPM presents errors to users in the CLI.
921    ///
922    /// # Examples
923    ///
924    /// ```rust,no_run
925    /// use agpm_cli::core::{AgpmError, ErrorContext};
926    ///
927    /// let context = ErrorContext::new(AgpmError::GitNotFound)
928    ///     .with_suggestion("Install git from https://git-scm.com/")
929    ///     .with_details("AGPM requires git for repository operations");
930    ///
931    /// context.display(); // Prints colored error to stderr
932    /// ```
933    pub fn display(&self) {
934        eprintln!("{}: {}", "error".red().bold(), self.error);
935
936        if let Some(details) = &self.details {
937            eprintln!("{}: {}", "details".yellow(), details);
938        }
939
940        if let Some(suggestion) = &self.suggestion {
941            eprintln!("{}: {}", "suggestion".green(), suggestion);
942        }
943    }
944}
945
946impl fmt::Display for ErrorContext {
947    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
948        write!(f, "{}", self.error)?;
949
950        if let Some(details) = &self.details {
951            write!(f, "\nDetails: {details}")?;
952        }
953
954        if let Some(suggestion) = &self.suggestion {
955            write!(f, "\nSuggestion: {suggestion}")?;
956        }
957
958        Ok(())
959    }
960}
961
962impl std::error::Error for ErrorContext {}
963
964/// Extension trait for converting [`AgpmError`] to [`anyhow::Error`] with context
965///
966/// This trait provides a method to convert AGPM-specific errors into generic
967/// [`anyhow::Error`] instances while preserving user-friendly context information.
968///
969/// # Examples
970///
971/// ```rust,no_run
972/// use agpm_cli::core::{AgpmError, ErrorContext, IntoAnyhowWithContext};
973///
974/// let error = AgpmError::GitNotFound;
975/// let context = ErrorContext::new(AgpmError::Other { message: "dummy".to_string() })
976///     .with_suggestion("Install git");
977///
978/// let anyhow_error = error.into_anyhow_with_context(context);
979/// ```
980pub trait IntoAnyhowWithContext {
981    /// Convert the error to an [`anyhow::Error`] with the provided context
982    fn into_anyhow_with_context(self, context: ErrorContext) -> anyhow::Error;
983}
984
985impl IntoAnyhowWithContext for AgpmError {
986    fn into_anyhow_with_context(self, context: ErrorContext) -> anyhow::Error {
987        anyhow::Error::new(ErrorContext {
988            error: self,
989            suggestion: context.suggestion,
990            details: context.details,
991        })
992    }
993}
994
995impl ErrorContext {
996    /// Create an [`ErrorContext`] with only a suggestion (no specific error)
997    ///
998    /// This is useful for generic errors where you want to provide a suggestion
999    /// but don't have a specific [`AgpmError`] variant.
1000    ///
1001    /// # Examples
1002    ///
1003    /// ```rust,no_run
1004    /// use agpm_cli::core::ErrorContext;
1005    ///
1006    /// let context = ErrorContext::suggestion("Try running with --verbose for more information");
1007    /// context.display();
1008    /// ```
1009    pub fn suggestion(suggestion: impl Into<String>) -> Self {
1010        Self {
1011            error: AgpmError::Other {
1012                message: String::new(),
1013            },
1014            suggestion: Some(suggestion.into()),
1015            details: None,
1016        }
1017    }
1018}
1019
1020/// Convert any error to a user-friendly [`ErrorContext`] with actionable suggestions
1021///
1022/// This function is the main entry point for converting arbitrary errors into
1023/// user-friendly error messages for CLI display. It recognizes common error types
1024/// and provides appropriate context and suggestions.
1025///
1026/// # Error Recognition
1027///
1028/// The function recognizes and provides specific handling for:
1029/// - [`AgpmError`] variants with tailored suggestions
1030/// - [`std::io::Error`] with filesystem-specific guidance
1031/// - [`toml::de::Error`] with TOML syntax help
1032/// - Generic errors with basic context
1033///
1034/// # Examples
1035///
1036/// ## Converting AGPM Errors
1037///
1038/// ```rust,no_run
1039/// use agpm_cli::core::{AgpmError, user_friendly_error};
1040///
1041/// let error = AgpmError::GitNotFound;
1042/// let anyhow_error = anyhow::Error::from(error);
1043/// let context = user_friendly_error(anyhow_error);
1044///
1045/// context.display(); // Shows git installation suggestions
1046/// ```
1047///
1048/// ## Converting IO Errors
1049///
1050/// ```rust,no_run
1051/// use agpm_cli::core::user_friendly_error;
1052/// use std::io::{Error, ErrorKind};
1053///
1054/// let io_error = Error::new(ErrorKind::PermissionDenied, "access denied");
1055/// let anyhow_error = anyhow::Error::from(io_error);
1056/// let context = user_friendly_error(anyhow_error);
1057///
1058/// context.display(); // Shows permission-related suggestions
1059/// ```
1060///
1061/// ## Converting Generic Errors
1062///
1063/// ```rust,no_run
1064/// use agpm_cli::core::user_friendly_error;
1065///
1066/// let error = anyhow::anyhow!("Something went wrong");
1067/// let context = user_friendly_error(error);
1068///
1069/// context.display(); // Shows the error with generic formatting
1070/// ```
1071#[must_use]
1072pub fn user_friendly_error(error: anyhow::Error) -> ErrorContext {
1073    // Check for specific error types and provide helpful suggestions
1074    if let Some(ccmp_error) = error.downcast_ref::<AgpmError>() {
1075        return create_error_context(ccmp_error.clone());
1076    }
1077
1078    if let Some(io_error) = error.downcast_ref::<std::io::Error>() {
1079        match io_error.kind() {
1080            std::io::ErrorKind::PermissionDenied => {
1081                return ErrorContext::new(AgpmError::PermissionDenied {
1082                    operation: "file access".to_string(),
1083                    path: "unknown".to_string(),
1084                })
1085                .with_suggestion("Try running with elevated permissions (sudo/Administrator) or check file ownership")
1086                .with_details("This error occurs when AGPM doesn't have permission to read or write files");
1087            }
1088            std::io::ErrorKind::NotFound => {
1089                return ErrorContext::new(AgpmError::FileSystemError {
1090                    operation: "file access".to_string(),
1091                    path: "unknown".to_string(),
1092                })
1093                .with_suggestion("Check that the file or directory exists and the path is correct")
1094                .with_details(
1095                    "This error occurs when a required file or directory cannot be found",
1096                );
1097            }
1098            std::io::ErrorKind::AlreadyExists => {
1099                return ErrorContext::new(AgpmError::FileSystemError {
1100                    operation: "file creation".to_string(),
1101                    path: "unknown".to_string(),
1102                })
1103                .with_suggestion("Remove the existing file or use --force to overwrite")
1104                .with_details("The target file or directory already exists");
1105            }
1106            std::io::ErrorKind::InvalidData => {
1107                return ErrorContext::new(AgpmError::InvalidResource {
1108                    name: "unknown".to_string(),
1109                    reason: "invalid file format".to_string(),
1110                })
1111                .with_suggestion("Check the file format and ensure it's a valid resource file")
1112                .with_details("The file contains invalid or corrupted data");
1113            }
1114            _ => {}
1115        }
1116    }
1117
1118    if let Some(toml_error) = error.downcast_ref::<toml::de::Error>() {
1119        return ErrorContext::new(AgpmError::ManifestParseError {
1120            file: "agpm.toml".to_string(),
1121            reason: toml_error.to_string(),
1122        })
1123        .with_suggestion("Check the TOML syntax in your agpm.toml file. Verify quotes, brackets, and indentation")
1124        .with_details("TOML parsing errors are usually caused by syntax issues like missing quotes or mismatched brackets");
1125    }
1126
1127    // Generic error - include the full error chain for better diagnostics
1128    let mut message = error.to_string();
1129
1130    // Append error chain if available
1131    let chain: Vec<String> = error
1132        .chain()
1133        .skip(1) // Skip the root cause which is already in to_string()
1134        .map(std::string::ToString::to_string)
1135        .collect();
1136
1137    if !chain.is_empty() {
1138        message.push_str("\n\nCaused by:");
1139        for (i, cause) in chain.iter().enumerate() {
1140            message.push_str(&format!("\n  {}: {}", i + 1, cause));
1141        }
1142    }
1143
1144    ErrorContext::new(AgpmError::Other {
1145        message,
1146    })
1147}
1148
1149/// Create appropriate [`ErrorContext`] with suggestions for specific AGPM errors
1150///
1151/// This internal function maps each [`AgpmError`] variant to an appropriate
1152/// [`ErrorContext`] with tailored suggestions and details. It's used by
1153/// [`user_friendly_error`] to provide consistent, helpful error messages.
1154///
1155/// # Implementation Notes
1156///
1157/// - Each error type has specific suggestions based on common resolution steps
1158/// - Platform-specific suggestions are provided where applicable
1159/// - Error messages focus on actionable steps rather than technical details
1160/// - Cross-references to related commands or documentation are included
1161fn create_error_context(error: AgpmError) -> ErrorContext {
1162    match &error {
1163        AgpmError::GitNotFound => ErrorContext::new(AgpmError::GitNotFound)
1164            .with_suggestion("Install git from https://git-scm.com/ or your package manager (e.g., 'brew install git', 'apt install git')")
1165            .with_details("AGPM requires git to be installed and available in your PATH to manage repositories"),
1166
1167        AgpmError::GitCommandError { operation, stderr } => {
1168            ErrorContext::new(AgpmError::GitCommandError {
1169                operation: operation.clone(),
1170                stderr: stderr.clone(),
1171            })
1172            .with_suggestion(match operation.as_str() {
1173                op if op.contains("clone") => "Check the repository URL and your internet connection. Verify you have access to the repository",
1174                op if op.contains("fetch") => "Check your internet connection and repository access. Try 'git fetch' manually in the repository directory",
1175                op if op.contains("checkout") => "Verify the branch, tag, or commit exists. Use 'git tag -l' or 'git branch -r' to list available references",
1176                op if op.contains("worktree") => {
1177                    if stderr.contains("invalid reference")
1178                        || stderr.contains("not a valid object name")
1179                        || stderr.contains("pathspec")
1180                        || stderr.contains("did not match")
1181                        || stderr.contains("unknown revision") {
1182                        "Invalid version: The specified version/tag/branch does not exist in the repository. Check available versions with 'git tag -l' or 'git branch -r'"
1183                    } else {
1184                        "Failed to create worktree. Check that the reference exists and the repository is valid"
1185                    }
1186                },
1187                _ => "Check your git configuration and repository access. Try running the git command manually for more details",
1188            })
1189            .with_details(if operation.contains("worktree") && (stderr.contains("invalid reference") || stderr.contains("not a valid object name") || stderr.contains("pathspec") || stderr.contains("did not match") || stderr.contains("unknown revision")) {
1190                "Invalid version specification: Failed to checkout reference - the specified version/tag/branch does not exist"
1191            } else {
1192                "Git operations failed. This is often due to network issues, authentication problems, or invalid references"
1193            })
1194        }
1195
1196        AgpmError::GitAuthenticationFailed { url } => ErrorContext::new(AgpmError::GitAuthenticationFailed {
1197            url: url.clone(),
1198        })
1199            .with_suggestion("Configure git authentication: use 'git config --global user.name' and 'git config --global user.email', or set up SSH keys")
1200            .with_details("Authentication is required for private repositories. You may need to log in with 'git credential-manager-core' or similar"),
1201
1202        AgpmError::GitCloneFailed { url, reason } => ErrorContext::new(AgpmError::GitCloneFailed {
1203            url: url.clone(),
1204            reason: reason.clone(),
1205        })
1206            .with_suggestion(format!(
1207                "Verify the repository URL is correct: {url}. Check your internet connection and repository access"
1208            ))
1209            .with_details("Clone operations can fail due to invalid URLs, network issues, or access restrictions"),
1210
1211        AgpmError::ManifestNotFound => ErrorContext::new(AgpmError::ManifestNotFound)
1212            .with_suggestion("Create a agpm.toml file in your project directory. See documentation for the manifest format")
1213            .with_details("AGPM looks for agpm.toml in the current directory and parent directories up to the filesystem root"),
1214
1215        AgpmError::ManifestParseError { file, reason } => ErrorContext::new(AgpmError::ManifestParseError {
1216            file: file.clone(),
1217            reason: reason.clone(),
1218        })
1219            .with_suggestion(format!(
1220                "Check the TOML syntax in {file}. Common issues: missing quotes, unmatched brackets, invalid characters"
1221            ))
1222            .with_details("Use a TOML validator or check the agpm documentation for correct manifest format"),
1223
1224        AgpmError::SourceNotFound { name } => ErrorContext::new(AgpmError::SourceNotFound {
1225            name: name.clone(),
1226        })
1227            .with_suggestion(format!(
1228                "Add source '{name}' to the [sources] section in agpm.toml with the repository URL"
1229            ))
1230            .with_details("All dependencies must reference a source defined in the [sources] section"),
1231
1232        AgpmError::ResourceFileNotFound { path, source_name } => ErrorContext::new(AgpmError::ResourceFileNotFound {
1233            path: path.clone(),
1234            source_name: source_name.clone(),
1235        })
1236            .with_suggestion(format!(
1237                "Verify the file '{path}' exists in the '{source_name}' repository at the specified version/commit"
1238            ))
1239            .with_details("The resource file may have been moved, renamed, or deleted in the repository"),
1240
1241        AgpmError::VersionNotFound { resource, version } => ErrorContext::new(AgpmError::VersionNotFound {
1242            resource: resource.clone(),
1243            version: version.clone(),
1244        })
1245            .with_suggestion(format!(
1246                "Check available versions for '{resource}' using 'git tag -l' in the repository, or use 'main' or 'master' branch"
1247            ))
1248            .with_details(format!(
1249                "The version '{version}' doesn't exist as a git tag, branch, or commit in the repository"
1250            )),
1251
1252        AgpmError::CircularDependency { chain } => ErrorContext::new(AgpmError::CircularDependency {
1253            chain: chain.clone(),
1254        })
1255            .with_suggestion("Review your dependency graph and remove circular references")
1256            .with_details(format!(
1257                "Circular dependency chain detected: {chain}. Dependencies cannot depend on themselves directly or indirectly"
1258            )),
1259
1260        AgpmError::PermissionDenied { operation, path } => ErrorContext::new(AgpmError::PermissionDenied {
1261            operation: operation.clone(),
1262            path: path.clone(),
1263        })
1264            .with_suggestion(match cfg!(windows) {
1265                true => "Run as Administrator or check file permissions in File Explorer",
1266                false => "Use 'sudo' or check file permissions with 'ls -la'",
1267            })
1268            .with_details(format!(
1269                "Cannot {operation} due to insufficient permissions on {path}"
1270            )),
1271
1272        AgpmError::ChecksumMismatch { name, expected, actual } => ErrorContext::new(AgpmError::ChecksumMismatch {
1273            name: name.clone(),
1274            expected: expected.clone(),
1275            actual: actual.clone(),
1276        })
1277            .with_suggestion("The file may have been corrupted or modified. Try reinstalling with --force")
1278            .with_details(format!(
1279                "Resource '{name}' has checksum {actual} but expected {expected}. This indicates file corruption or tampering"
1280            )),
1281
1282        _ => ErrorContext::new(error.clone()),
1283    }
1284}
1285
1286#[cfg(test)]
1287mod tests {
1288    use super::*;
1289
1290    #[test]
1291    fn test_error_display() {
1292        let error = AgpmError::GitNotFound;
1293        assert_eq!(error.to_string(), "Git is not installed or not found in PATH");
1294
1295        let error = AgpmError::ResourceNotFound {
1296            name: "test".to_string(),
1297        };
1298        assert_eq!(error.to_string(), "Resource 'test' not found");
1299
1300        let error = AgpmError::InvalidVersionConstraint {
1301            constraint: "bad-version".to_string(),
1302        };
1303        assert_eq!(error.to_string(), "Invalid version constraint: bad-version");
1304
1305        let error = AgpmError::GitCommandError {
1306            operation: "clone".to_string(),
1307            stderr: "repository not found".to_string(),
1308        };
1309        assert_eq!(error.to_string(), "Git operation failed: clone");
1310    }
1311
1312    #[test]
1313    fn test_error_context() {
1314        let ctx = ErrorContext::new(AgpmError::GitNotFound)
1315            .with_suggestion("Install git using your package manager")
1316            .with_details("Git is required for AGPM to function");
1317
1318        assert_eq!(ctx.suggestion, Some("Install git using your package manager".to_string()));
1319        assert_eq!(ctx.details, Some("Git is required for AGPM to function".to_string()));
1320    }
1321
1322    #[test]
1323    fn test_error_context_display() {
1324        let ctx = ErrorContext::new(AgpmError::GitNotFound).with_suggestion("Install git");
1325
1326        let display = format!("{ctx}");
1327        assert!(display.contains("Git is not installed or not found in PATH"));
1328        assert!(display.contains("Install git"));
1329    }
1330
1331    #[test]
1332    fn test_user_friendly_error_permission_denied() {
1333        use std::io::{Error, ErrorKind};
1334
1335        let io_error = Error::new(ErrorKind::PermissionDenied, "access denied");
1336        let anyhow_error = anyhow::Error::from(io_error);
1337
1338        let ctx = user_friendly_error(anyhow_error);
1339        match ctx.error {
1340            AgpmError::PermissionDenied {
1341                ..
1342            } => {}
1343            _ => panic!("Expected PermissionDenied error"),
1344        }
1345        assert!(ctx.suggestion.is_some());
1346        assert!(ctx.details.is_some());
1347    }
1348
1349    #[test]
1350    fn test_user_friendly_error_not_found() {
1351        use std::io::{Error, ErrorKind};
1352
1353        let io_error = Error::new(ErrorKind::NotFound, "file not found");
1354        let anyhow_error = anyhow::Error::from(io_error);
1355
1356        let ctx = user_friendly_error(anyhow_error);
1357        match ctx.error {
1358            AgpmError::FileSystemError {
1359                ..
1360            } => {}
1361            _ => panic!("Expected FileSystemError"),
1362        }
1363        assert!(ctx.suggestion.is_some());
1364        assert!(ctx.details.is_some());
1365    }
1366
1367    #[test]
1368    fn test_from_io_error() {
1369        use std::io::Error;
1370
1371        let io_error = Error::other("test error");
1372        let agpm_error = AgpmError::from(io_error);
1373
1374        match agpm_error {
1375            AgpmError::IoError(_) => {}
1376            _ => panic!("Expected IoError"),
1377        }
1378    }
1379
1380    #[test]
1381    fn test_from_toml_error() {
1382        let toml_str = "invalid = toml {";
1383        let result: Result<toml::Value, _> = toml::from_str(toml_str);
1384
1385        if let Err(e) = result {
1386            let agpm_error = AgpmError::from(e);
1387            match agpm_error {
1388                AgpmError::TomlError(_) => {}
1389                _ => panic!("Expected TomlError"),
1390            }
1391        }
1392    }
1393
1394    #[test]
1395    fn test_create_error_context_git_not_found() {
1396        let ctx = create_error_context(AgpmError::GitNotFound);
1397        assert!(ctx.suggestion.is_some());
1398        assert!(ctx.suggestion.unwrap().contains("Install git"));
1399        assert!(ctx.details.is_some());
1400    }
1401
1402    #[test]
1403    fn test_create_error_context_git_command_error() {
1404        let ctx = create_error_context(AgpmError::GitCommandError {
1405            operation: "clone".to_string(),
1406            stderr: "error".to_string(),
1407        });
1408        assert!(ctx.suggestion.is_some());
1409        assert!(ctx.suggestion.unwrap().contains("repository URL"));
1410        assert!(ctx.details.is_some());
1411    }
1412
1413    #[test]
1414    fn test_create_error_context_git_auth_failed() {
1415        let ctx = create_error_context(AgpmError::GitAuthenticationFailed {
1416            url: "https://github.com/test/repo".to_string(),
1417        });
1418        assert!(ctx.suggestion.is_some());
1419        assert!(ctx.suggestion.unwrap().contains("Configure git authentication"));
1420        assert!(ctx.details.is_some());
1421    }
1422
1423    #[test]
1424    fn test_create_error_context_manifest_not_found() {
1425        let ctx = create_error_context(AgpmError::ManifestNotFound);
1426        assert!(ctx.suggestion.is_some());
1427        assert!(ctx.suggestion.unwrap().contains("Create a agpm.toml"));
1428        assert!(ctx.details.is_some());
1429    }
1430
1431    #[test]
1432    fn test_create_error_context_source_not_found() {
1433        let ctx = create_error_context(AgpmError::SourceNotFound {
1434            name: "test-source".to_string(),
1435        });
1436        assert!(ctx.suggestion.is_some());
1437        assert!(ctx.suggestion.unwrap().contains("test-source"));
1438        assert!(ctx.details.is_some());
1439    }
1440
1441    #[test]
1442    fn test_create_error_context_version_not_found() {
1443        let ctx = create_error_context(AgpmError::VersionNotFound {
1444            resource: "test-resource".to_string(),
1445            version: "v1.0.0".to_string(),
1446        });
1447        assert!(ctx.suggestion.is_some());
1448        assert!(ctx.suggestion.unwrap().contains("test-resource"));
1449        assert!(ctx.details.is_some());
1450        assert!(ctx.details.unwrap().contains("v1.0.0"));
1451    }
1452
1453    #[test]
1454    fn test_create_error_context_circular_dependency() {
1455        let ctx = create_error_context(AgpmError::CircularDependency {
1456            chain: "a -> b -> c -> a".to_string(),
1457        });
1458        assert!(ctx.suggestion.is_some());
1459        assert!(ctx.suggestion.unwrap().contains("remove circular"));
1460        assert!(ctx.details.is_some());
1461        assert!(ctx.details.unwrap().contains("a -> b -> c -> a"));
1462    }
1463
1464    #[test]
1465    fn test_create_error_context_permission_denied() {
1466        let ctx = create_error_context(AgpmError::PermissionDenied {
1467            operation: "write".to_string(),
1468            path: "/test/path".to_string(),
1469        });
1470        assert!(ctx.suggestion.is_some());
1471        assert!(ctx.details.is_some());
1472        assert!(ctx.details.unwrap().contains("/test/path"));
1473    }
1474
1475    #[test]
1476    fn test_create_error_context_checksum_mismatch() {
1477        let ctx = create_error_context(AgpmError::ChecksumMismatch {
1478            name: "test-resource".to_string(),
1479            expected: "abc123".to_string(),
1480            actual: "def456".to_string(),
1481        });
1482        assert!(ctx.suggestion.is_some());
1483        assert!(ctx.suggestion.unwrap().contains("reinstalling"));
1484        assert!(ctx.details.is_some());
1485        assert!(ctx.details.unwrap().contains("abc123"));
1486    }
1487
1488    #[test]
1489    fn test_error_clone() {
1490        let error1 = AgpmError::GitNotFound;
1491        let error2 = error1.clone();
1492        assert_eq!(error1.to_string(), error2.to_string());
1493
1494        let error1 = AgpmError::ResourceNotFound {
1495            name: "test".to_string(),
1496        };
1497        let error2 = error1.clone();
1498        assert_eq!(error1.to_string(), error2.to_string());
1499    }
1500
1501    #[test]
1502    fn test_error_context_suggestion() {
1503        let ctx = ErrorContext::suggestion("Test suggestion");
1504        assert_eq!(ctx.suggestion, Some("Test suggestion".to_string()));
1505        assert!(ctx.details.is_none());
1506    }
1507
1508    #[test]
1509    fn test_into_anyhow_with_context() {
1510        let error = AgpmError::GitNotFound;
1511        let context = ErrorContext::new(AgpmError::Other {
1512            message: "dummy".to_string(),
1513        })
1514        .with_suggestion("Test suggestion")
1515        .with_details("Test details");
1516
1517        let anyhow_error = error.into_anyhow_with_context(context);
1518        let display = format!("{anyhow_error}");
1519        assert!(display.contains("Git is not installed"));
1520    }
1521
1522    #[test]
1523    fn test_user_friendly_error_already_exists() {
1524        use std::io::{Error, ErrorKind};
1525
1526        let io_error = Error::new(ErrorKind::AlreadyExists, "file exists");
1527        let anyhow_error = anyhow::Error::from(io_error);
1528
1529        let ctx = user_friendly_error(anyhow_error);
1530        match ctx.error {
1531            AgpmError::FileSystemError {
1532                ..
1533            } => {}
1534            _ => panic!("Expected FileSystemError"),
1535        }
1536        assert!(ctx.suggestion.is_some());
1537        assert!(ctx.suggestion.unwrap().contains("overwrite"));
1538    }
1539
1540    #[test]
1541    fn test_user_friendly_error_invalid_data() {
1542        use std::io::{Error, ErrorKind};
1543
1544        let io_error = Error::new(ErrorKind::InvalidData, "corrupt data");
1545        let anyhow_error = anyhow::Error::from(io_error);
1546
1547        let ctx = user_friendly_error(anyhow_error);
1548        match ctx.error {
1549            AgpmError::InvalidResource {
1550                ..
1551            } => {}
1552            _ => panic!("Expected InvalidResource"),
1553        }
1554        assert!(ctx.suggestion.is_some());
1555        assert!(ctx.details.is_some());
1556    }
1557
1558    #[test]
1559    fn test_user_friendly_error_agpm_error() {
1560        let error = AgpmError::GitNotFound;
1561        let anyhow_error = anyhow::Error::from(error);
1562
1563        let ctx = user_friendly_error(anyhow_error);
1564        match ctx.error {
1565            AgpmError::GitNotFound => {}
1566            _ => panic!("Expected GitNotFound"),
1567        }
1568        assert!(ctx.suggestion.is_some());
1569    }
1570
1571    #[test]
1572    fn test_user_friendly_error_toml_parse() {
1573        let toml_str = "invalid = toml {";
1574        let result: Result<toml::Value, _> = toml::from_str(toml_str);
1575
1576        if let Err(e) = result {
1577            let anyhow_error = anyhow::Error::from(e);
1578            let ctx = user_friendly_error(anyhow_error);
1579
1580            match ctx.error {
1581                AgpmError::ManifestParseError {
1582                    ..
1583                } => {}
1584                _ => panic!("Expected ManifestParseError"),
1585            }
1586            assert!(ctx.suggestion.is_some());
1587            assert!(ctx.suggestion.unwrap().contains("TOML syntax"));
1588        }
1589    }
1590
1591    #[test]
1592    fn test_user_friendly_error_generic() {
1593        let error = anyhow::anyhow!("Generic error");
1594        let ctx = user_friendly_error(error);
1595
1596        match ctx.error {
1597            AgpmError::Other {
1598                message,
1599            } => {
1600                assert_eq!(message, "Generic error");
1601            }
1602            _ => panic!("Expected Other error"),
1603        }
1604    }
1605
1606    #[test]
1607    fn test_from_semver_error() {
1608        let result = semver::Version::parse("invalid-version");
1609        if let Err(e) = result {
1610            let agpm_error = AgpmError::from(e);
1611            match agpm_error {
1612                AgpmError::SemverError(_) => {}
1613                _ => panic!("Expected SemverError"),
1614            }
1615        }
1616    }
1617
1618    #[test]
1619    fn test_error_display_all_variants() {
1620        // Test display for various error variants
1621        let errors = vec![
1622            AgpmError::GitRepoInvalid {
1623                path: "/test/path".to_string(),
1624            },
1625            AgpmError::GitCheckoutFailed {
1626                reference: "main".to_string(),
1627                reason: "not found".to_string(),
1628            },
1629            AgpmError::ConfigError {
1630                message: "config issue".to_string(),
1631            },
1632            AgpmError::ManifestValidationError {
1633                reason: "invalid format".to_string(),
1634            },
1635            AgpmError::LockfileParseError {
1636                file: "agpm.lock".to_string(),
1637                reason: "syntax error".to_string(),
1638            },
1639            AgpmError::ResourceFileNotFound {
1640                path: "test.md".to_string(),
1641                source_name: "source".to_string(),
1642            },
1643            AgpmError::DirectoryNotEmpty {
1644                path: "/some/dir".to_string(),
1645            },
1646            AgpmError::InvalidDependency {
1647                name: "dep".to_string(),
1648                reason: "bad format".to_string(),
1649            },
1650            AgpmError::DependencyNotMet {
1651                name: "dep".to_string(),
1652                required: "v1.0".to_string(),
1653                found: "v2.0".to_string(),
1654            },
1655            AgpmError::ConfigNotFound {
1656                path: "/config/path".to_string(),
1657            },
1658            AgpmError::PlatformNotSupported {
1659                operation: "test op".to_string(),
1660            },
1661        ];
1662
1663        for error in errors {
1664            let display = format!("{error}");
1665            assert!(!display.is_empty());
1666        }
1667    }
1668
1669    #[test]
1670    fn test_create_error_context_git_operations() {
1671        // Test different git operations
1672        let operations = vec![
1673            ("fetch", "internet connection"),
1674            ("checkout", "branch, tag"),
1675            ("pull", "git configuration"),
1676        ];
1677
1678        for (op, expected_text) in operations {
1679            let ctx = create_error_context(AgpmError::GitCommandError {
1680                operation: op.to_string(),
1681                stderr: "error".to_string(),
1682            });
1683            assert!(ctx.suggestion.is_some());
1684            assert!(ctx.suggestion.unwrap().to_lowercase().contains(expected_text));
1685        }
1686    }
1687
1688    #[test]
1689    fn test_create_error_context_resource_file_not_found() {
1690        let ctx = create_error_context(AgpmError::ResourceFileNotFound {
1691            path: "agents/test.md".to_string(),
1692            source_name: "official".to_string(),
1693        });
1694        assert!(ctx.suggestion.is_some());
1695        let suggestion = ctx.suggestion.unwrap();
1696        assert!(suggestion.contains("agents/test.md"));
1697        assert!(suggestion.contains("official"));
1698        assert!(ctx.details.is_some());
1699    }
1700
1701    #[test]
1702    fn test_create_error_context_manifest_parse_error() {
1703        let ctx = create_error_context(AgpmError::ManifestParseError {
1704            file: "custom.toml".to_string(),
1705            reason: "invalid syntax".to_string(),
1706        });
1707        assert!(ctx.suggestion.is_some());
1708        let suggestion = ctx.suggestion.unwrap();
1709        assert!(suggestion.contains("custom.toml"));
1710        assert!(ctx.details.is_some());
1711    }
1712
1713    #[test]
1714    fn test_create_error_context_git_clone_failed() {
1715        let ctx = create_error_context(AgpmError::GitCloneFailed {
1716            url: "https://example.com/repo.git".to_string(),
1717            reason: "network error".to_string(),
1718        });
1719        assert!(ctx.suggestion.is_some());
1720        let suggestion = ctx.suggestion.unwrap();
1721        assert!(suggestion.contains("https://example.com/repo.git"));
1722        assert!(ctx.details.is_some());
1723    }
1724}