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        // Try to extract path from the error chain context
1080        let extracted_path = error
1081            .chain()
1082            .find_map(|e| {
1083                let msg = e.to_string();
1084                // Look for common patterns in our context messages:
1085                // "Failed to read file: /path/to/file"
1086                // "Failed to read local file: /path/to/file"
1087                // "Failed to read resource file: /path/to/file"
1088                // "Transitive dependency does not exist: /path (resolved from ...)"
1089                if let Some(idx) = msg.find(": /") {
1090                    // Extract path starting after ": "
1091                    let path_part = &msg[idx + 2..]; // Skip ": " to keep the leading /
1092                    // Take until end or until we hit " (" for additional context
1093                    let end_idx = path_part.find(" (").unwrap_or(path_part.len());
1094                    let mut path = path_part[..end_idx].to_string();
1095                    // Clean up double slashes and normalize ./ segments
1096                    path = path.replace("//", "/").replace("/./", "/");
1097                    Some(path)
1098                } else if let Some(idx) = msg.find(": ./") {
1099                    // Relative path starting with "./"
1100                    let path_part = &msg[idx + 2..];
1101                    let end_idx = path_part.find(" (").unwrap_or(path_part.len());
1102                    let mut path = path_part[..end_idx].to_string();
1103                    // Clean up double slashes and normalize ./ segments
1104                    path = path.replace("//", "/").replace("/./", "/");
1105                    Some(path)
1106                } else if let Some(idx) = msg.find(": ../") {
1107                    // Relative path starting with "../"
1108                    let path_part = &msg[idx + 2..];
1109                    let end_idx = path_part.find(" (").unwrap_or(path_part.len());
1110                    let mut path = path_part[..end_idx].to_string();
1111                    // Clean up double slashes
1112                    path = path.replace("//", "/");
1113                    Some(path)
1114                } else {
1115                    None
1116                }
1117            })
1118            .unwrap_or_else(|| "unknown".to_string());
1119
1120        match io_error.kind() {
1121            std::io::ErrorKind::PermissionDenied => {
1122                return ErrorContext::new(AgpmError::PermissionDenied {
1123                    operation: "file access".to_string(),
1124                    path: extracted_path,
1125                })
1126                .with_suggestion("Try running with elevated permissions (sudo/Administrator) or check file ownership")
1127                .with_details("This error occurs when AGPM doesn't have permission to read or write files");
1128            }
1129            std::io::ErrorKind::NotFound => {
1130                return ErrorContext::new(AgpmError::FileSystemError {
1131                    operation: "file access".to_string(),
1132                    path: extracted_path.clone(),
1133                })
1134                .with_suggestion(format!(
1135                    "Check that the file '{}' exists and the path is correct",
1136                    extracted_path
1137                ))
1138                .with_details(
1139                    "This error occurs when a required file or directory cannot be found",
1140                );
1141            }
1142            std::io::ErrorKind::AlreadyExists => {
1143                return ErrorContext::new(AgpmError::FileSystemError {
1144                    operation: "file creation".to_string(),
1145                    path: extracted_path,
1146                })
1147                .with_suggestion("Remove the existing file or use --force to overwrite")
1148                .with_details("The target file or directory already exists");
1149            }
1150            std::io::ErrorKind::InvalidData => {
1151                return ErrorContext::new(AgpmError::InvalidResource {
1152                    name: extracted_path,
1153                    reason: "invalid file format".to_string(),
1154                })
1155                .with_suggestion("Check the file format and ensure it's a valid resource file")
1156                .with_details("The file contains invalid or corrupted data");
1157            }
1158            _ => {}
1159        }
1160    }
1161
1162    if let Some(toml_error) = error.downcast_ref::<toml::de::Error>() {
1163        return ErrorContext::new(AgpmError::ManifestParseError {
1164            file: "agpm.toml".to_string(),
1165            reason: toml_error.to_string(),
1166        })
1167        .with_suggestion("Check the TOML syntax in your agpm.toml file. Verify quotes, brackets, and indentation")
1168        .with_details("TOML parsing errors are usually caused by syntax issues like missing quotes or mismatched brackets");
1169    }
1170
1171    // Check for template rendering errors by examining the error message
1172    let error_msg = error.to_string().to_lowercase();
1173    let is_template_error = error_msg.contains("template")
1174        || error_msg.contains("variable")
1175        || error_msg.contains("filter")
1176        || error_msg.contains("tera");
1177
1178    if is_template_error {
1179        // Extract resource name from error chain
1180        // Look for "Failed to render template for '{name}'" pattern
1181        let resource_name = error
1182            .chain()
1183            .find_map(|e| {
1184                let msg = e.to_string();
1185                if msg.contains("Failed to render template for") {
1186                    // Extract name between single quotes
1187                    msg.split("'").nth(1).map(|s| s.to_string())
1188                } else {
1189                    None
1190                }
1191            })
1192            .unwrap_or_else(|| "unknown resource".to_string());
1193
1194        // Build full error chain for template errors
1195        let mut message = error.to_string();
1196        let chain: Vec<String> =
1197            error.chain().skip(1).map(std::string::ToString::to_string).collect();
1198
1199        if !chain.is_empty() {
1200            message.push_str("\n\nCaused by:");
1201            for (i, cause) in chain.iter().enumerate() {
1202                message.push_str(&format!("\n  {}: {}", i + 1, cause));
1203            }
1204        }
1205
1206        return ErrorContext::new(AgpmError::InvalidResource {
1207            name: resource_name,
1208            reason: message,
1209        })
1210        .with_suggestion(
1211            "Check template syntax: variables use {{ var }}, comments use {# #}, control flow uses {% %}. \
1212             Ensure all variables referenced in the template exist in the context (agpm.resource, agpm.deps)",
1213        )
1214        .with_details(
1215            "Template errors occur when Tera cannot render the template. Common issues:\n\
1216             - Undefined variables (use {% if var is defined %} to check)\n\
1217             - Syntax errors (unclosed {{ or {% delimiters)\n\
1218             - Invalid filters or functions\n\
1219             - Type mismatches in operations",
1220        );
1221    }
1222
1223    // Generic error - include the full error chain for better diagnostics
1224    let mut message = error.to_string();
1225
1226    // Append error chain if available
1227    let chain: Vec<String> = error
1228        .chain()
1229        .skip(1) // Skip the root cause which is already in to_string()
1230        .map(std::string::ToString::to_string)
1231        .collect();
1232
1233    if !chain.is_empty() {
1234        message.push_str("\n\nCaused by:");
1235        for (i, cause) in chain.iter().enumerate() {
1236            message.push_str(&format!("\n  {}: {}", i + 1, cause));
1237        }
1238    }
1239
1240    ErrorContext::new(AgpmError::Other {
1241        message,
1242    })
1243}
1244
1245/// Create appropriate [`ErrorContext`] with suggestions for specific AGPM errors
1246///
1247/// This internal function maps each [`AgpmError`] variant to an appropriate
1248/// [`ErrorContext`] with tailored suggestions and details. It's used by
1249/// [`user_friendly_error`] to provide consistent, helpful error messages.
1250///
1251/// # Implementation Notes
1252///
1253/// - Each error type has specific suggestions based on common resolution steps
1254/// - Platform-specific suggestions are provided where applicable
1255/// - Error messages focus on actionable steps rather than technical details
1256/// - Cross-references to related commands or documentation are included
1257fn create_error_context(error: AgpmError) -> ErrorContext {
1258    match &error {
1259        AgpmError::GitNotFound => ErrorContext::new(AgpmError::GitNotFound)
1260            .with_suggestion("Install git from https://git-scm.com/ or your package manager (e.g., 'brew install git', 'apt install git')")
1261            .with_details("AGPM requires git to be installed and available in your PATH to manage repositories"),
1262
1263        AgpmError::GitCommandError { operation, stderr } => {
1264            ErrorContext::new(AgpmError::GitCommandError {
1265                operation: operation.clone(),
1266                stderr: stderr.clone(),
1267            })
1268            .with_suggestion(match operation.as_str() {
1269                op if op.contains("clone") => "Check the repository URL and your internet connection. Verify you have access to the repository",
1270                op if op.contains("fetch") => "Check your internet connection and repository access. Try 'git fetch' manually in the repository directory",
1271                op if op.contains("checkout") => "Verify the branch, tag, or commit exists. Use 'git tag -l' or 'git branch -r' to list available references",
1272                op if op.contains("worktree") => {
1273                    if stderr.contains("invalid reference")
1274                        || stderr.contains("not a valid object name")
1275                        || stderr.contains("pathspec")
1276                        || stderr.contains("did not match")
1277                        || stderr.contains("unknown revision") {
1278                        "Invalid version: The specified version/tag/branch does not exist in the repository. Check available versions with 'git tag -l' or 'git branch -r'"
1279                    } else {
1280                        "Failed to create worktree. Check that the reference exists and the repository is valid"
1281                    }
1282                },
1283                _ => "Check your git configuration and repository access. Try running the git command manually for more details",
1284            })
1285            .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")) {
1286                "Invalid version specification: Failed to checkout reference - the specified version/tag/branch does not exist"
1287            } else {
1288                "Git operations failed. This is often due to network issues, authentication problems, or invalid references"
1289            })
1290        }
1291
1292        AgpmError::GitAuthenticationFailed { url } => ErrorContext::new(AgpmError::GitAuthenticationFailed {
1293            url: url.clone(),
1294        })
1295            .with_suggestion("Configure git authentication: use 'git config --global user.name' and 'git config --global user.email', or set up SSH keys")
1296            .with_details("Authentication is required for private repositories. You may need to log in with 'git credential-manager-core' or similar"),
1297
1298        AgpmError::GitCloneFailed { url, reason } => ErrorContext::new(AgpmError::GitCloneFailed {
1299            url: url.clone(),
1300            reason: reason.clone(),
1301        })
1302            .with_suggestion(format!(
1303                "Verify the repository URL is correct: {url}. Check your internet connection and repository access"
1304            ))
1305            .with_details("Clone operations can fail due to invalid URLs, network issues, or access restrictions"),
1306
1307        AgpmError::ManifestNotFound => ErrorContext::new(AgpmError::ManifestNotFound)
1308            .with_suggestion("Create a agpm.toml file in your project directory. See documentation for the manifest format")
1309            .with_details("AGPM looks for agpm.toml in the current directory and parent directories up to the filesystem root"),
1310
1311        AgpmError::ManifestParseError { file, reason } => ErrorContext::new(AgpmError::ManifestParseError {
1312            file: file.clone(),
1313            reason: reason.clone(),
1314        })
1315            .with_suggestion(format!(
1316                "Check the TOML syntax in {file}. Common issues: missing quotes, unmatched brackets, invalid characters"
1317            ))
1318            .with_details("Use a TOML validator or check the agpm documentation for correct manifest format"),
1319
1320        AgpmError::SourceNotFound { name } => ErrorContext::new(AgpmError::SourceNotFound {
1321            name: name.clone(),
1322        })
1323            .with_suggestion(format!(
1324                "Add source '{name}' to the [sources] section in agpm.toml with the repository URL"
1325            ))
1326            .with_details("All dependencies must reference a source defined in the [sources] section"),
1327
1328        AgpmError::ResourceFileNotFound { path, source_name } => ErrorContext::new(AgpmError::ResourceFileNotFound {
1329            path: path.clone(),
1330            source_name: source_name.clone(),
1331        })
1332            .with_suggestion(format!(
1333                "Verify the file '{path}' exists in the '{source_name}' repository at the specified version/commit"
1334            ))
1335            .with_details("The resource file may have been moved, renamed, or deleted in the repository"),
1336
1337        AgpmError::VersionNotFound { resource, version } => ErrorContext::new(AgpmError::VersionNotFound {
1338            resource: resource.clone(),
1339            version: version.clone(),
1340        })
1341            .with_suggestion(format!(
1342                "Check available versions for '{resource}' using 'git tag -l' in the repository, or use 'main' or 'master' branch"
1343            ))
1344            .with_details(format!(
1345                "The version '{version}' doesn't exist as a git tag, branch, or commit in the repository"
1346            )),
1347
1348        AgpmError::CircularDependency { chain } => ErrorContext::new(AgpmError::CircularDependency {
1349            chain: chain.clone(),
1350        })
1351            .with_suggestion("Review your dependency graph and remove circular references")
1352            .with_details(format!(
1353                "Circular dependency chain detected: {chain}. Dependencies cannot depend on themselves directly or indirectly"
1354            )),
1355
1356        AgpmError::PermissionDenied { operation, path } => ErrorContext::new(AgpmError::PermissionDenied {
1357            operation: operation.clone(),
1358            path: path.clone(),
1359        })
1360            .with_suggestion(match cfg!(windows) {
1361                true => "Run as Administrator or check file permissions in File Explorer",
1362                false => "Use 'sudo' or check file permissions with 'ls -la'",
1363            })
1364            .with_details(format!(
1365                "Cannot {operation} due to insufficient permissions on {path}"
1366            )),
1367
1368        AgpmError::ChecksumMismatch { name, expected, actual } => ErrorContext::new(AgpmError::ChecksumMismatch {
1369            name: name.clone(),
1370            expected: expected.clone(),
1371            actual: actual.clone(),
1372        })
1373            .with_suggestion("The file may have been corrupted or modified. Try reinstalling with --force")
1374            .with_details(format!(
1375                "Resource '{name}' has checksum {actual} but expected {expected}. This indicates file corruption or tampering"
1376            )),
1377
1378        _ => ErrorContext::new(error.clone()),
1379    }
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384    use super::*;
1385
1386    #[test]
1387    fn test_error_display() {
1388        let error = AgpmError::GitNotFound;
1389        assert_eq!(error.to_string(), "Git is not installed or not found in PATH");
1390
1391        let error = AgpmError::ResourceNotFound {
1392            name: "test".to_string(),
1393        };
1394        assert_eq!(error.to_string(), "Resource 'test' not found");
1395
1396        let error = AgpmError::InvalidVersionConstraint {
1397            constraint: "bad-version".to_string(),
1398        };
1399        assert_eq!(error.to_string(), "Invalid version constraint: bad-version");
1400
1401        let error = AgpmError::GitCommandError {
1402            operation: "clone".to_string(),
1403            stderr: "repository not found".to_string(),
1404        };
1405        assert_eq!(error.to_string(), "Git operation failed: clone");
1406    }
1407
1408    #[test]
1409    fn test_error_context() {
1410        let ctx = ErrorContext::new(AgpmError::GitNotFound)
1411            .with_suggestion("Install git using your package manager")
1412            .with_details("Git is required for AGPM to function");
1413
1414        assert_eq!(ctx.suggestion, Some("Install git using your package manager".to_string()));
1415        assert_eq!(ctx.details, Some("Git is required for AGPM to function".to_string()));
1416    }
1417
1418    #[test]
1419    fn test_error_context_display() {
1420        let ctx = ErrorContext::new(AgpmError::GitNotFound).with_suggestion("Install git");
1421
1422        let display = format!("{ctx}");
1423        assert!(display.contains("Git is not installed or not found in PATH"));
1424        assert!(display.contains("Install git"));
1425    }
1426
1427    #[test]
1428    fn test_user_friendly_error_permission_denied() {
1429        use std::io::{Error, ErrorKind};
1430
1431        let io_error = Error::new(ErrorKind::PermissionDenied, "access denied");
1432        let anyhow_error = anyhow::Error::from(io_error);
1433
1434        let ctx = user_friendly_error(anyhow_error);
1435        match ctx.error {
1436            AgpmError::PermissionDenied {
1437                ..
1438            } => {}
1439            _ => panic!("Expected PermissionDenied error"),
1440        }
1441        assert!(ctx.suggestion.is_some());
1442        assert!(ctx.details.is_some());
1443    }
1444
1445    #[test]
1446    fn test_user_friendly_error_not_found_with_path() {
1447        use std::io::{Error, ErrorKind};
1448
1449        let io_error = Error::new(ErrorKind::NotFound, "file not found");
1450        let anyhow_error =
1451            anyhow::Error::from(io_error).context("Failed to read local file: /path/to/missing.md");
1452
1453        let ctx = user_friendly_error(anyhow_error);
1454        match &ctx.error {
1455            AgpmError::FileSystemError {
1456                path,
1457                ..
1458            } => {
1459                assert_eq!(
1460                    path, "/path/to/missing.md",
1461                    "Path should be extracted from error context"
1462                );
1463            }
1464            _ => panic!("Expected FileSystemError, got: {:?}", ctx.error),
1465        }
1466        assert!(ctx.suggestion.is_some());
1467        assert!(ctx.suggestion.as_ref().unwrap().contains("/path/to/missing.md"));
1468    }
1469
1470    #[test]
1471    fn test_user_friendly_error_not_found_with_malformed_path() {
1472        use std::io::{Error, ErrorKind};
1473
1474        // Test that we clean up double slashes and ./ segments
1475        let io_error = Error::new(ErrorKind::NotFound, "file not found");
1476        let anyhow_error =
1477            anyhow::Error::from(io_error).context("Failed to read: //Users/test/./foo/./bar.md");
1478
1479        let ctx = user_friendly_error(anyhow_error);
1480        match &ctx.error {
1481            AgpmError::FileSystemError {
1482                path,
1483                ..
1484            } => {
1485                assert_eq!(
1486                    path, "/Users/test/foo/bar.md",
1487                    "Path should be normalized (double slashes and ./ removed)"
1488                );
1489            }
1490            _ => panic!("Expected FileSystemError, got: {:?}", ctx.error),
1491        }
1492        assert!(ctx.suggestion.as_ref().unwrap().contains("/Users/test/foo/bar.md"));
1493    }
1494
1495    #[test]
1496    fn test_user_friendly_error_not_found() {
1497        use std::io::{Error, ErrorKind};
1498
1499        let io_error = Error::new(ErrorKind::NotFound, "file not found");
1500        let anyhow_error = anyhow::Error::from(io_error);
1501
1502        let ctx = user_friendly_error(anyhow_error);
1503        match ctx.error {
1504            AgpmError::FileSystemError {
1505                ..
1506            } => {}
1507            _ => panic!("Expected FileSystemError"),
1508        }
1509        assert!(ctx.suggestion.is_some());
1510        assert!(ctx.details.is_some());
1511    }
1512
1513    #[test]
1514    fn test_from_io_error() {
1515        use std::io::Error;
1516
1517        let io_error = Error::other("test error");
1518        let agpm_error = AgpmError::from(io_error);
1519
1520        match agpm_error {
1521            AgpmError::IoError(_) => {}
1522            _ => panic!("Expected IoError"),
1523        }
1524    }
1525
1526    #[test]
1527    fn test_from_toml_error() {
1528        let toml_str = "invalid = toml {";
1529        let result: Result<toml::Value, _> = toml::from_str(toml_str);
1530
1531        if let Err(e) = result {
1532            let agpm_error = AgpmError::from(e);
1533            match agpm_error {
1534                AgpmError::TomlError(_) => {}
1535                _ => panic!("Expected TomlError"),
1536            }
1537        }
1538    }
1539
1540    #[test]
1541    fn test_create_error_context_git_not_found() {
1542        let ctx = create_error_context(AgpmError::GitNotFound);
1543        assert!(ctx.suggestion.is_some());
1544        assert!(ctx.suggestion.unwrap().contains("Install git"));
1545        assert!(ctx.details.is_some());
1546    }
1547
1548    #[test]
1549    fn test_create_error_context_git_command_error() {
1550        let ctx = create_error_context(AgpmError::GitCommandError {
1551            operation: "clone".to_string(),
1552            stderr: "error".to_string(),
1553        });
1554        assert!(ctx.suggestion.is_some());
1555        assert!(ctx.suggestion.unwrap().contains("repository URL"));
1556        assert!(ctx.details.is_some());
1557    }
1558
1559    #[test]
1560    fn test_create_error_context_git_auth_failed() {
1561        let ctx = create_error_context(AgpmError::GitAuthenticationFailed {
1562            url: "https://github.com/test/repo".to_string(),
1563        });
1564        assert!(ctx.suggestion.is_some());
1565        assert!(ctx.suggestion.unwrap().contains("Configure git authentication"));
1566        assert!(ctx.details.is_some());
1567    }
1568
1569    #[test]
1570    fn test_create_error_context_manifest_not_found() {
1571        let ctx = create_error_context(AgpmError::ManifestNotFound);
1572        assert!(ctx.suggestion.is_some());
1573        assert!(ctx.suggestion.unwrap().contains("Create a agpm.toml"));
1574        assert!(ctx.details.is_some());
1575    }
1576
1577    #[test]
1578    fn test_create_error_context_source_not_found() {
1579        let ctx = create_error_context(AgpmError::SourceNotFound {
1580            name: "test-source".to_string(),
1581        });
1582        assert!(ctx.suggestion.is_some());
1583        assert!(ctx.suggestion.unwrap().contains("test-source"));
1584        assert!(ctx.details.is_some());
1585    }
1586
1587    #[test]
1588    fn test_create_error_context_version_not_found() {
1589        let ctx = create_error_context(AgpmError::VersionNotFound {
1590            resource: "test-resource".to_string(),
1591            version: "v1.0.0".to_string(),
1592        });
1593        assert!(ctx.suggestion.is_some());
1594        assert!(ctx.suggestion.unwrap().contains("test-resource"));
1595        assert!(ctx.details.is_some());
1596        assert!(ctx.details.unwrap().contains("v1.0.0"));
1597    }
1598
1599    #[test]
1600    fn test_create_error_context_circular_dependency() {
1601        let ctx = create_error_context(AgpmError::CircularDependency {
1602            chain: "a -> b -> c -> a".to_string(),
1603        });
1604        assert!(ctx.suggestion.is_some());
1605        assert!(ctx.suggestion.unwrap().contains("remove circular"));
1606        assert!(ctx.details.is_some());
1607        assert!(ctx.details.unwrap().contains("a -> b -> c -> a"));
1608    }
1609
1610    #[test]
1611    fn test_create_error_context_permission_denied() {
1612        let ctx = create_error_context(AgpmError::PermissionDenied {
1613            operation: "write".to_string(),
1614            path: "/test/path".to_string(),
1615        });
1616        assert!(ctx.suggestion.is_some());
1617        assert!(ctx.details.is_some());
1618        assert!(ctx.details.unwrap().contains("/test/path"));
1619    }
1620
1621    #[test]
1622    fn test_create_error_context_checksum_mismatch() {
1623        let ctx = create_error_context(AgpmError::ChecksumMismatch {
1624            name: "test-resource".to_string(),
1625            expected: "abc123".to_string(),
1626            actual: "def456".to_string(),
1627        });
1628        assert!(ctx.suggestion.is_some());
1629        assert!(ctx.suggestion.unwrap().contains("reinstalling"));
1630        assert!(ctx.details.is_some());
1631        assert!(ctx.details.unwrap().contains("abc123"));
1632    }
1633
1634    #[test]
1635    fn test_error_clone() {
1636        let error1 = AgpmError::GitNotFound;
1637        let error2 = error1.clone();
1638        assert_eq!(error1.to_string(), error2.to_string());
1639
1640        let error1 = AgpmError::ResourceNotFound {
1641            name: "test".to_string(),
1642        };
1643        let error2 = error1.clone();
1644        assert_eq!(error1.to_string(), error2.to_string());
1645    }
1646
1647    #[test]
1648    fn test_error_context_suggestion() {
1649        let ctx = ErrorContext::suggestion("Test suggestion");
1650        assert_eq!(ctx.suggestion, Some("Test suggestion".to_string()));
1651        assert!(ctx.details.is_none());
1652    }
1653
1654    #[test]
1655    fn test_into_anyhow_with_context() {
1656        let error = AgpmError::GitNotFound;
1657        let context = ErrorContext::new(AgpmError::Other {
1658            message: "dummy".to_string(),
1659        })
1660        .with_suggestion("Test suggestion")
1661        .with_details("Test details");
1662
1663        let anyhow_error = error.into_anyhow_with_context(context);
1664        let display = format!("{anyhow_error}");
1665        assert!(display.contains("Git is not installed"));
1666    }
1667
1668    #[test]
1669    fn test_user_friendly_error_already_exists() {
1670        use std::io::{Error, ErrorKind};
1671
1672        let io_error = Error::new(ErrorKind::AlreadyExists, "file exists");
1673        let anyhow_error = anyhow::Error::from(io_error);
1674
1675        let ctx = user_friendly_error(anyhow_error);
1676        match ctx.error {
1677            AgpmError::FileSystemError {
1678                ..
1679            } => {}
1680            _ => panic!("Expected FileSystemError"),
1681        }
1682        assert!(ctx.suggestion.is_some());
1683        assert!(ctx.suggestion.unwrap().contains("overwrite"));
1684    }
1685
1686    #[test]
1687    fn test_user_friendly_error_invalid_data() {
1688        use std::io::{Error, ErrorKind};
1689
1690        let io_error = Error::new(ErrorKind::InvalidData, "corrupt data");
1691        let anyhow_error = anyhow::Error::from(io_error);
1692
1693        let ctx = user_friendly_error(anyhow_error);
1694        match ctx.error {
1695            AgpmError::InvalidResource {
1696                ..
1697            } => {}
1698            _ => panic!("Expected InvalidResource"),
1699        }
1700        assert!(ctx.suggestion.is_some());
1701        assert!(ctx.details.is_some());
1702    }
1703
1704    #[test]
1705    fn test_user_friendly_error_template_with_resource_name() {
1706        // Simulate a template error with resource name in context
1707        let template_error = anyhow::anyhow!("Variable `foo` not found in context");
1708        let error_with_context = template_error
1709            .context("Failed to render template for 'my-awesome-agent' (source: community, path: agents/awesome.md)");
1710
1711        let ctx = user_friendly_error(error_with_context);
1712
1713        match &ctx.error {
1714            AgpmError::InvalidResource {
1715                name,
1716                reason,
1717            } => {
1718                // Verify resource name was extracted instead of using "template"
1719                assert_eq!(
1720                    name, "my-awesome-agent",
1721                    "Resource name should be extracted from error context"
1722                );
1723                assert!(reason.contains("Variable"), "Reason should contain the actual error");
1724            }
1725            _ => panic!("Expected InvalidResource, got {:?}", ctx.error),
1726        }
1727        assert!(ctx.suggestion.is_some());
1728        assert!(ctx.details.is_some());
1729    }
1730
1731    #[test]
1732    fn test_user_friendly_error_template_without_resource_name() {
1733        // Simulate a template error without resource context
1734        let template_error = anyhow::anyhow!("Variable `bar` not found in context");
1735
1736        let ctx = user_friendly_error(template_error);
1737
1738        match &ctx.error {
1739            AgpmError::InvalidResource {
1740                name,
1741                reason,
1742            } => {
1743                // Should fallback to "unknown resource" when name can't be extracted
1744                assert_eq!(
1745                    name, "unknown resource",
1746                    "Should use fallback when resource name unavailable"
1747                );
1748                assert!(reason.contains("Variable"), "Reason should contain the actual error");
1749            }
1750            _ => panic!("Expected InvalidResource, got {:?}", ctx.error),
1751        }
1752    }
1753
1754    #[test]
1755    fn test_user_friendly_error_agpm_error() {
1756        let error = AgpmError::GitNotFound;
1757        let anyhow_error = anyhow::Error::from(error);
1758
1759        let ctx = user_friendly_error(anyhow_error);
1760        match ctx.error {
1761            AgpmError::GitNotFound => {}
1762            _ => panic!("Expected GitNotFound"),
1763        }
1764        assert!(ctx.suggestion.is_some());
1765    }
1766
1767    #[test]
1768    fn test_user_friendly_error_toml_parse() {
1769        let toml_str = "invalid = toml {";
1770        let result: Result<toml::Value, _> = toml::from_str(toml_str);
1771
1772        if let Err(e) = result {
1773            let anyhow_error = anyhow::Error::from(e);
1774            let ctx = user_friendly_error(anyhow_error);
1775
1776            match ctx.error {
1777                AgpmError::ManifestParseError {
1778                    ..
1779                } => {}
1780                _ => panic!("Expected ManifestParseError"),
1781            }
1782            assert!(ctx.suggestion.is_some());
1783            assert!(ctx.suggestion.unwrap().contains("TOML syntax"));
1784        }
1785    }
1786
1787    #[test]
1788    fn test_user_friendly_error_generic() {
1789        let error = anyhow::anyhow!("Generic error");
1790        let ctx = user_friendly_error(error);
1791
1792        match ctx.error {
1793            AgpmError::Other {
1794                message,
1795            } => {
1796                assert_eq!(message, "Generic error");
1797            }
1798            _ => panic!("Expected Other error"),
1799        }
1800    }
1801
1802    #[test]
1803    fn test_from_semver_error() {
1804        let result = semver::Version::parse("invalid-version");
1805        if let Err(e) = result {
1806            let agpm_error = AgpmError::from(e);
1807            match agpm_error {
1808                AgpmError::SemverError(_) => {}
1809                _ => panic!("Expected SemverError"),
1810            }
1811        }
1812    }
1813
1814    #[test]
1815    fn test_error_display_all_variants() {
1816        // Test display for various error variants
1817        let errors = vec![
1818            AgpmError::GitRepoInvalid {
1819                path: "/test/path".to_string(),
1820            },
1821            AgpmError::GitCheckoutFailed {
1822                reference: "main".to_string(),
1823                reason: "not found".to_string(),
1824            },
1825            AgpmError::ConfigError {
1826                message: "config issue".to_string(),
1827            },
1828            AgpmError::ManifestValidationError {
1829                reason: "invalid format".to_string(),
1830            },
1831            AgpmError::LockfileParseError {
1832                file: "agpm.lock".to_string(),
1833                reason: "syntax error".to_string(),
1834            },
1835            AgpmError::ResourceFileNotFound {
1836                path: "test.md".to_string(),
1837                source_name: "source".to_string(),
1838            },
1839            AgpmError::DirectoryNotEmpty {
1840                path: "/some/dir".to_string(),
1841            },
1842            AgpmError::InvalidDependency {
1843                name: "dep".to_string(),
1844                reason: "bad format".to_string(),
1845            },
1846            AgpmError::DependencyNotMet {
1847                name: "dep".to_string(),
1848                required: "v1.0".to_string(),
1849                found: "v2.0".to_string(),
1850            },
1851            AgpmError::ConfigNotFound {
1852                path: "/config/path".to_string(),
1853            },
1854            AgpmError::PlatformNotSupported {
1855                operation: "test op".to_string(),
1856            },
1857        ];
1858
1859        for error in errors {
1860            let display = format!("{error}");
1861            assert!(!display.is_empty());
1862        }
1863    }
1864
1865    #[test]
1866    fn test_create_error_context_git_operations() {
1867        // Test different git operations
1868        let operations = vec![
1869            ("fetch", "internet connection"),
1870            ("checkout", "branch, tag"),
1871            ("pull", "git configuration"),
1872        ];
1873
1874        for (op, expected_text) in operations {
1875            let ctx = create_error_context(AgpmError::GitCommandError {
1876                operation: op.to_string(),
1877                stderr: "error".to_string(),
1878            });
1879            assert!(ctx.suggestion.is_some());
1880            assert!(ctx.suggestion.unwrap().to_lowercase().contains(expected_text));
1881        }
1882    }
1883
1884    #[test]
1885    fn test_create_error_context_resource_file_not_found() {
1886        let ctx = create_error_context(AgpmError::ResourceFileNotFound {
1887            path: "agents/test.md".to_string(),
1888            source_name: "official".to_string(),
1889        });
1890        assert!(ctx.suggestion.is_some());
1891        let suggestion = ctx.suggestion.unwrap();
1892        assert!(suggestion.contains("agents/test.md"));
1893        assert!(suggestion.contains("official"));
1894        assert!(ctx.details.is_some());
1895    }
1896
1897    #[test]
1898    fn test_create_error_context_manifest_parse_error() {
1899        let ctx = create_error_context(AgpmError::ManifestParseError {
1900            file: "custom.toml".to_string(),
1901            reason: "invalid syntax".to_string(),
1902        });
1903        assert!(ctx.suggestion.is_some());
1904        let suggestion = ctx.suggestion.unwrap();
1905        assert!(suggestion.contains("custom.toml"));
1906        assert!(ctx.details.is_some());
1907    }
1908
1909    #[test]
1910    fn test_create_error_context_git_clone_failed() {
1911        let ctx = create_error_context(AgpmError::GitCloneFailed {
1912            url: "https://example.com/repo.git".to_string(),
1913            reason: "network error".to_string(),
1914        });
1915        assert!(ctx.suggestion.is_some());
1916        let suggestion = ctx.suggestion.unwrap();
1917        assert!(suggestion.contains("https://example.com/repo.git"));
1918        assert!(ctx.details.is_some());
1919    }
1920}