agpm_cli/core/
error.rs

1//! Error handling for AGPM
2//!
3//! This module provides comprehensive error types and user-friendly error reporting for the
4//! AGPM package manager. The error system is designed around two core principles:
5//! 1. **Strongly-typed errors** for precise error handling in code
6//! 2. **User-friendly messages** with actionable suggestions for CLI users
7//!
8//! # Architecture
9//!
10//! The error system consists of two main types:
11//! - [`AgpmError`] - Enumerated error types for all failure cases in AGPM
12//! - [`ErrorContext`] - Wrapper that adds user-friendly messages and suggestions
13//!
14//! # Error Categories
15//!
16//! AGPM errors are organized into several categories:
17//! - **Git Operations**: [`AgpmError::GitNotFound`], [`AgpmError::GitCommandError`], etc.
18//! - **File System**: [`AgpmError::FileSystemError`], [`AgpmError::PermissionDenied`], etc.
19//! - **Configuration**: [`AgpmError::ManifestNotFound`], [`AgpmError::ManifestParseError`], etc.
20//! - **Dependencies**: [`AgpmError::CircularDependency`], [`AgpmError::DependencyNotMet`], etc.
21//! - **Resources**: [`AgpmError::ResourceNotFound`], [`AgpmError::InvalidResource`], etc.
22//!
23//! # Error Conversion and Context
24//!
25//! Common standard library errors are automatically converted to AGPM errors:
26//! - [`std::io::Error`] → [`AgpmError::IoError`]
27//! - [`toml::de::Error`] → [`AgpmError::TomlError`]
28//! - [`semver::Error`] → [`AgpmError::SemverError`]
29//!
30//! Use [`user_friendly_error`] to convert any error into a user-friendly format with
31//! contextual suggestions.
32//!
33//! # Examples
34//!
35//! ## Basic Error Handling
36//!
37//! ```rust,no_run
38//! use agpm_cli::core::{AgpmError, ErrorContext, user_friendly_error};
39//!
40//! fn handle_git_operation() -> Result<(), AgpmError> {
41//!     // Simulate a git operation failure
42//!     Err(AgpmError::GitNotFound)
43//! }
44//!
45//! match handle_git_operation() {
46//!     Ok(_) => println!("Success!"),
47//!     Err(e) => {
48//!         let ctx = user_friendly_error(anyhow::Error::from(e));
49//!         ctx.display(); // Shows colored error with suggestions
50//!     }
51//! }
52//! ```
53//!
54//! ## Creating Error Context Manually
55//!
56//! ```rust,no_run
57//! use agpm_cli::core::{AgpmError, ErrorContext};
58//!
59//! let error = AgpmError::ManifestNotFound;
60//! let context = ErrorContext::new(error)
61//!     .with_suggestion("Create a agpm.toml file in your project directory")
62//!     .with_details("AGPM searches for agpm.toml in current and parent directories");
63//!
64//! // Display with colors in terminal
65//! context.display();
66//!
67//! // Or get as string for logging
68//! let message = format!("{}", context);
69//! ```
70//!
71//! ## Error Recovery Patterns
72//!
73//! ```rust,no_run
74//! use agpm_cli::core::{AgpmError, user_friendly_error};
75//! use anyhow::Context;
76//!
77//! fn install_dependency(name: &str) -> anyhow::Result<()> {
78//!     // Try installation
79//!     perform_installation(name)
80//!         .with_context(|| format!("Failed to install dependency '{}'", name))
81//!         .map_err(|e| {
82//!             // Convert to user-friendly error for CLI display
83//!             let friendly = user_friendly_error(e);
84//!             friendly.display();
85//!             anyhow::anyhow!("Installation failed")
86//!         })
87//! }
88//!
89//! fn perform_installation(_name: &str) -> anyhow::Result<()> {
90//!     // Implementation would go here
91//!     Ok(())
92//! }
93//! ```
94
95use colored::Colorize;
96use std::fmt;
97use thiserror::Error;
98
99/// The main error type for AGPM operations
100///
101/// This enum represents all possible errors that can occur during AGPM operations.
102/// Each variant is designed to provide specific context about the failure and enable
103/// appropriate error handling strategies.
104///
105/// # Design Philosophy
106///
107/// - **Specific Error Types**: Each error variant represents a specific failure mode
108/// - **Rich Context**: Errors include relevant details like file paths, URLs, and reasons
109/// - **User-Friendly**: Error messages are written for end users, not just developers
110/// - **Actionable**: Most errors provide clear guidance on how to resolve the issue
111///
112/// # Error Categories
113///
114/// ## Git Operations
115/// - [`GitNotFound`] - Git executable not available
116/// - [`GitCommandError`] - Git command execution failed
117/// - [`GitAuthenticationFailed`] - Git authentication problems
118/// - [`GitCloneFailed`] - Repository cloning failed
119/// - [`GitCheckoutFailed`] - Git checkout operation failed
120///
121/// ## File System Operations  
122/// - [`FileSystemError`] - General file system operations
123/// - [`PermissionDenied`] - Insufficient permissions
124/// - [`DirectoryNotEmpty`] - Directory contains files when empty expected
125/// - [`IoError`] - Standard I/O errors from [`std::io::Error`]
126///
127/// ## Configuration and Parsing
128/// - [`ManifestNotFound`] - agpm.toml file missing
129/// - [`ManifestParseError`] - Invalid TOML syntax in manifest
130/// - [`ManifestValidationError`] - Manifest content validation failed
131/// - [`LockfileParseError`] - Invalid lockfile format
132/// - [`InvalidLockfileError`] - Invalid lockfile that can be automatically regenerated
133/// - [`ConfigError`] - Configuration file issues
134/// - [`TomlError`] - TOML parsing errors from [`toml::de::Error`]
135/// - [`TomlSerError`] - TOML serialization errors from [`toml::ser::Error`]
136///
137/// ## Resource Management
138/// - [`ResourceNotFound`] - Named resource doesn't exist
139/// - [`ResourceFileNotFound`] - Resource file missing from repository
140/// - [`InvalidResourceType`] - Unknown resource type specified
141/// - [`InvalidResourceStructure`] - Resource content is malformed
142/// - [`InvalidResource`] - Resource validation failed
143/// - [`AlreadyInstalled`] - Resource already exists
144///
145/// ## Dependency Resolution
146/// - [`CircularDependency`] - Dependency cycle detected
147/// - [`DependencyResolutionFailed`] - Cannot resolve dependencies
148/// - [`DependencyNotMet`] - Version constraint not satisfied
149/// - [`InvalidDependency`] - Malformed dependency specification
150/// - [`InvalidVersionConstraint`] - Invalid version format
151/// - [`VersionNotFound`] - Requested version doesn't exist
152/// - [`SemverError`] - Semantic version parsing from [`semver::Error`]
153///
154/// ## Source Management
155/// - [`SourceNotFound`] - Named source not defined
156/// - [`SourceUnreachable`] - Cannot connect to source repository
157///
158/// ## Platform and Network
159/// - [`NetworkError`] - Network connectivity issues
160/// - [`PlatformNotSupported`] - Operation not supported on current platform
161/// - [`ChecksumMismatch`] - File integrity verification failed
162///
163/// # Examples
164///
165/// ## Pattern Matching on Errors
166///
167/// ```rust,no_run
168/// use agpm_cli::core::AgpmError;
169///
170/// fn handle_error(error: AgpmError) {
171///     match error {
172///         AgpmError::GitNotFound => {
173///             eprintln!("Please install git to use AGPM");
174///             std::process::exit(1);
175///         }
176///         AgpmError::ManifestNotFound => {
177///             eprintln!("Run 'agpm init' to create a manifest file");
178///         }
179///         AgpmError::NetworkError { operation, .. } => {
180///             eprintln!("Network error during {}: check your connection", operation);
181///         }
182///         _ => {
183///             eprintln!("Unexpected error: {}", error);
184///         }
185///     }
186/// }
187/// ```
188///
189/// ## Creating Specific Errors
190///
191/// ```rust,no_run
192/// use agpm_cli::core::AgpmError;
193///
194/// // Create a git command error with context
195/// let error = AgpmError::GitCommandError {
196///     operation: "clone".to_string(),
197///     stderr: "repository not found".to_string(),
198/// };
199///
200/// // Create a resource not found error
201/// let error = AgpmError::ResourceNotFound {
202///     name: "my-agent".to_string(),
203/// };
204///
205/// // Create a version constraint error
206/// let error = AgpmError::InvalidVersionConstraint {
207///     constraint: "~1.x.y".to_string(),
208/// };
209/// ```
210///
211/// [`GitNotFound`]: AgpmError::GitNotFound
212/// [`GitCommandError`]: AgpmError::GitCommandError
213/// [`GitAuthenticationFailed`]: AgpmError::GitAuthenticationFailed
214/// [`GitCloneFailed`]: AgpmError::GitCloneFailed
215/// [`GitCheckoutFailed`]: AgpmError::GitCheckoutFailed
216/// [`FileSystemError`]: AgpmError::FileSystemError
217/// [`PermissionDenied`]: AgpmError::PermissionDenied
218/// [`DirectoryNotEmpty`]: AgpmError::DirectoryNotEmpty
219/// [`IoError`]: AgpmError::IoError
220/// [`ManifestNotFound`]: AgpmError::ManifestNotFound
221/// [`ManifestParseError`]: AgpmError::ManifestParseError
222/// [`ManifestValidationError`]: AgpmError::ManifestValidationError
223/// [`LockfileParseError`]: AgpmError::LockfileParseError
224/// [`InvalidLockfileError`]: AgpmError::InvalidLockfileError
225/// [`ConfigError`]: AgpmError::ConfigError
226/// [`TomlError`]: AgpmError::TomlError
227/// [`TomlSerError`]: AgpmError::TomlSerError
228/// [`ResourceNotFound`]: AgpmError::ResourceNotFound
229/// [`ResourceFileNotFound`]: AgpmError::ResourceFileNotFound
230/// [`InvalidResourceType`]: AgpmError::InvalidResourceType
231/// [`InvalidResourceStructure`]: AgpmError::InvalidResourceStructure
232/// [`InvalidResource`]: AgpmError::InvalidResource
233/// [`AlreadyInstalled`]: AgpmError::AlreadyInstalled
234/// [`CircularDependency`]: AgpmError::CircularDependency
235/// [`DependencyResolutionFailed`]: AgpmError::DependencyResolutionFailed
236/// [`DependencyNotMet`]: AgpmError::DependencyNotMet
237/// [`InvalidDependency`]: AgpmError::InvalidDependency
238/// [`InvalidVersionConstraint`]: AgpmError::InvalidVersionConstraint
239/// [`VersionNotFound`]: AgpmError::VersionNotFound
240/// [`SemverError`]: AgpmError::SemverError
241/// [`SourceNotFound`]: AgpmError::SourceNotFound
242/// [`SourceUnreachable`]: AgpmError::SourceUnreachable
243/// [`NetworkError`]: AgpmError::NetworkError
244/// [`PlatformNotSupported`]: AgpmError::PlatformNotSupported
245/// [`ChecksumMismatch`]: AgpmError::ChecksumMismatch
246#[derive(Error, Debug)]
247pub enum AgpmError {
248    /// Git operation failed during execution
249    ///
250    /// This error occurs when a git command returns a non-zero exit code.
251    /// Common causes include network issues, authentication problems, or
252    /// invalid git repository states.
253    ///
254    /// # Fields
255    /// - `operation`: The git operation that failed (e.g., "clone", "fetch", "checkout")
256    /// - `stderr`: The error output from the git command
257    #[error("Git operation failed: {operation}")]
258    GitCommandError {
259        /// The git operation that failed (e.g., "clone", "fetch", "checkout")
260        operation: String,
261        /// The error output from the git command
262        stderr: String,
263    },
264
265    /// Git executable not found in PATH
266    ///
267    /// This error occurs when AGPM cannot locate the `git` command in the system PATH.
268    /// AGPM requires git to be installed and available for repository operations.
269    ///
270    /// Common solutions:
271    /// - Install git from <https://git-scm.com/>
272    /// - Use a package manager: `brew install git`, `apt install git`, etc.
273    /// - Ensure git is in your PATH environment variable
274    #[error("Git is not installed or not found in PATH")]
275    GitNotFound,
276
277    /// Git repository is invalid or corrupted
278    ///
279    /// This error occurs when a directory exists but doesn't contain a valid
280    /// git repository structure (missing .git directory or corrupted).
281    ///
282    /// # Fields
283    /// - `path`: The path that was expected to contain a git repository
284    #[error("Not a valid git repository: {path}")]
285    GitRepoInvalid {
286        /// The path that was expected to contain a git repository
287        path: String,
288    },
289
290    /// Git authentication failed for repository access
291    ///
292    /// This error occurs when git cannot authenticate with a remote repository.
293    /// Common for private repositories or when credentials are missing/expired.
294    ///
295    /// # Fields
296    /// - `url`: The repository URL that failed authentication
297    #[error("Git authentication failed for repository: {url}")]
298    GitAuthenticationFailed {
299        /// The repository URL that failed authentication
300        url: String,
301    },
302
303    /// Git repository clone failed
304    #[error("Failed to clone repository: {url}")]
305    GitCloneFailed {
306        /// The repository URL that failed to clone
307        url: String,
308        /// The reason for the clone failure
309        reason: String,
310    },
311
312    /// Git checkout failed
313    #[error("Failed to checkout reference '{reference}' in repository")]
314    GitCheckoutFailed {
315        /// The git reference (branch, tag, or commit) that failed to checkout
316        reference: String,
317        /// The reason for the checkout failure
318        reason: String,
319    },
320
321    /// Configuration error
322    #[error("Configuration error: {message}")]
323    ConfigError {
324        /// Description of the configuration error
325        message: String,
326    },
327
328    /// Manifest file (agpm.toml) not found
329    ///
330    /// This error occurs when AGPM cannot locate a agpm.toml file in the current
331    /// directory or any parent directory up to the filesystem root.
332    ///
333    /// AGPM searches for agpm.toml starting from the current working directory
334    /// and walking up the directory tree, similar to how git searches for .git.
335    #[error("Manifest file agpm.toml not found in current directory or any parent directory")]
336    ManifestNotFound,
337
338    /// Manifest parsing error
339    #[error("Invalid manifest file syntax in {file}")]
340    ManifestParseError {
341        /// Path to the manifest file that failed to parse
342        file: String,
343        /// Specific reason for the parsing failure
344        reason: String,
345    },
346
347    /// Manifest validation error
348    #[error("Manifest validation failed: {reason}")]
349    ManifestValidationError {
350        /// Reason why manifest validation failed
351        reason: String,
352    },
353
354    /// Lockfile parsing error
355    #[error("Invalid lockfile syntax in {file}")]
356    LockfileParseError {
357        /// Path to the lockfile that failed to parse
358        file: String,
359        /// Specific reason for the parsing failure
360        reason: String,
361    },
362
363    /// Invalid lockfile that can be automatically regenerated
364    #[error(
365        "Invalid or corrupted lockfile detected: {file}\n\n{reason}\n\nNote: The lockfile format is not yet stable as this is beta software."
366    )]
367    InvalidLockfileError {
368        /// Path to the invalid lockfile
369        file: String,
370        /// Specific reason why the lockfile is invalid
371        reason: String,
372        /// Whether automatic regeneration is offered
373        can_regenerate: bool,
374    },
375
376    /// Resource not found
377    #[error("Resource '{name}' not found")]
378    ResourceNotFound {
379        /// Name of the resource that could not be found
380        name: String,
381    },
382
383    /// Resource file not found in repository
384    #[error("Resource file '{path}' not found in source '{source_name}'")]
385    ResourceFileNotFound {
386        /// Path to the resource file within the source repository
387        path: String,
388        /// Name of the source repository where the file was expected
389        source_name: String,
390    },
391
392    /// Source repository not found
393    #[error("Source repository '{name}' not defined in manifest")]
394    SourceNotFound {
395        /// Name of the source repository that is not defined
396        name: String,
397    },
398
399    /// Source repository unreachable
400    #[error("Cannot reach source repository '{name}' at {url}")]
401    SourceUnreachable {
402        /// Name of the source repository
403        name: String,
404        /// URL of the unreachable repository
405        url: String,
406    },
407
408    /// Invalid version constraint
409    #[error("Invalid version constraint: {constraint}")]
410    InvalidVersionConstraint {
411        /// The invalid version constraint string
412        constraint: String,
413    },
414
415    /// Version not found
416    #[error("Version '{version}' not found for resource '{resource}'")]
417    VersionNotFound {
418        /// Name of the resource for which the version was not found
419        resource: String,
420        /// The version string that could not be found
421        version: String,
422    },
423
424    /// Resource already installed
425    #[error("Resource '{name}' is already installed")]
426    AlreadyInstalled {
427        /// Name of the resource that is already installed
428        name: String,
429    },
430
431    /// Invalid resource type
432    #[error("Invalid resource type: {resource_type}")]
433    InvalidResourceType {
434        /// The invalid resource type that was specified
435        resource_type: String,
436    },
437
438    /// Invalid resource structure
439    #[error("Invalid resource structure in '{file}': {reason}")]
440    InvalidResourceStructure {
441        /// Path to the file with invalid resource structure
442        file: String,
443        /// Reason why the resource structure is invalid
444        reason: String,
445    },
446
447    /// Circular dependency detected in dependency graph
448    ///
449    /// This error occurs when resources depend on each other in a cycle,
450    /// making it impossible to determine installation order.
451    ///
452    /// Example: A depends on B, B depends on C, C depends on A
453    ///
454    /// # Fields
455    /// - `chain`: The dependency chain showing the circular reference
456    #[error("Circular dependency detected: {chain}")]
457    CircularDependency {
458        /// String representation of the circular dependency chain
459        chain: String,
460    },
461
462    /// Dependency resolution failed
463    #[error("Cannot resolve dependencies: {reason}")]
464    DependencyResolutionFailed {
465        /// Reason why dependency resolution failed
466        reason: String,
467    },
468
469    /// Network error
470    #[error("Network error: {operation}")]
471    NetworkError {
472        /// The network operation that failed
473        operation: String,
474        /// Reason for the network failure
475        reason: String,
476    },
477
478    /// File system error
479    #[error("File system error: {operation}: {path}")]
480    FileSystemError {
481        /// The file system operation that failed
482        operation: String,
483        /// Path where the file system error occurred
484        path: String,
485    },
486
487    /// Permission denied
488    #[error("Permission denied: {operation}: {path}")]
489    PermissionDenied {
490        /// The operation that was denied due to insufficient permissions
491        operation: String,
492        /// Path where permission was denied
493        path: String,
494    },
495
496    /// Directory not empty
497    #[error("Directory is not empty: {path}")]
498    DirectoryNotEmpty {
499        /// Path to the directory that is not empty
500        path: String,
501    },
502
503    /// Invalid dependency specification
504    #[error("Invalid dependency specification for '{name}': {reason}")]
505    InvalidDependency {
506        /// Name of the invalid dependency
507        name: String,
508        /// Reason why the dependency specification is invalid
509        reason: String,
510    },
511
512    /// Invalid resource content
513    #[error("Invalid resource content in '{name}': {reason}")]
514    InvalidResource {
515        /// Name of the invalid resource
516        name: String,
517        /// Reason why the resource content is invalid
518        reason: String,
519    },
520
521    /// Dependency not met
522    #[error("Dependency '{name}' requires version {required}, but {found} was found")]
523    DependencyNotMet {
524        /// Name of the dependency that is not satisfied
525        name: String,
526        /// The required version constraint
527        required: String,
528        /// The version that was actually found
529        found: String,
530    },
531
532    /// Config file not found
533    #[error("Configuration file not found: {path}")]
534    ConfigNotFound {
535        /// Path to the configuration file that was not found
536        path: String,
537    },
538
539    /// Checksum mismatch
540    #[error("Checksum mismatch for resource '{name}': expected {expected}, got {actual}")]
541    ChecksumMismatch {
542        /// Name of the resource with checksum mismatch
543        name: String,
544        /// The expected checksum value
545        expected: String,
546        /// The actual checksum that was computed
547        actual: String,
548    },
549
550    /// Platform not supported
551    #[error("Operation not supported on this platform: {operation}")]
552    PlatformNotSupported {
553        /// The operation that is not supported on this platform
554        operation: String,
555    },
556
557    /// IO error
558    #[error("IO error: {0}")]
559    IoError(#[from] std::io::Error),
560
561    /// TOML parsing error
562    #[error("TOML parsing error: {0}")]
563    TomlError(#[from] toml::de::Error),
564
565    /// TOML serialization error
566    #[error("TOML serialization error: {0}")]
567    TomlSerError(#[from] toml::ser::Error),
568
569    /// Semver parsing error
570    #[error("Semver parsing error: {0}")]
571    SemverError(#[from] semver::Error),
572
573    /// Other error
574    #[error("{message}")]
575    Other {
576        /// Generic error message
577        message: String,
578    },
579}
580
581impl Clone for AgpmError {
582    fn clone(&self) -> Self {
583        match self {
584            Self::GitCommandError {
585                operation,
586                stderr,
587            } => Self::GitCommandError {
588                operation: operation.clone(),
589                stderr: stderr.clone(),
590            },
591            Self::GitNotFound => Self::GitNotFound,
592            Self::GitRepoInvalid {
593                path,
594            } => Self::GitRepoInvalid {
595                path: path.clone(),
596            },
597            Self::GitAuthenticationFailed {
598                url,
599            } => Self::GitAuthenticationFailed {
600                url: url.clone(),
601            },
602            Self::GitCloneFailed {
603                url,
604                reason,
605            } => Self::GitCloneFailed {
606                url: url.clone(),
607                reason: reason.clone(),
608            },
609            Self::GitCheckoutFailed {
610                reference,
611                reason,
612            } => Self::GitCheckoutFailed {
613                reference: reference.clone(),
614                reason: reason.clone(),
615            },
616            Self::ConfigError {
617                message,
618            } => Self::ConfigError {
619                message: message.clone(),
620            },
621            Self::ManifestNotFound => Self::ManifestNotFound,
622            Self::ManifestParseError {
623                file,
624                reason,
625            } => Self::ManifestParseError {
626                file: file.clone(),
627                reason: reason.clone(),
628            },
629            Self::ManifestValidationError {
630                reason,
631            } => Self::ManifestValidationError {
632                reason: reason.clone(),
633            },
634            Self::LockfileParseError {
635                file,
636                reason,
637            } => Self::LockfileParseError {
638                file: file.clone(),
639                reason: reason.clone(),
640            },
641            Self::InvalidLockfileError {
642                file,
643                reason,
644                can_regenerate,
645            } => Self::InvalidLockfileError {
646                file: file.clone(),
647                reason: reason.clone(),
648                can_regenerate: *can_regenerate,
649            },
650            Self::ResourceNotFound {
651                name,
652            } => Self::ResourceNotFound {
653                name: name.clone(),
654            },
655            Self::ResourceFileNotFound {
656                path,
657                source_name,
658            } => Self::ResourceFileNotFound {
659                path: path.clone(),
660                source_name: source_name.clone(),
661            },
662            Self::SourceNotFound {
663                name,
664            } => Self::SourceNotFound {
665                name: name.clone(),
666            },
667            Self::SourceUnreachable {
668                name,
669                url,
670            } => Self::SourceUnreachable {
671                name: name.clone(),
672                url: url.clone(),
673            },
674            Self::InvalidVersionConstraint {
675                constraint,
676            } => Self::InvalidVersionConstraint {
677                constraint: constraint.clone(),
678            },
679            Self::VersionNotFound {
680                resource,
681                version,
682            } => Self::VersionNotFound {
683                resource: resource.clone(),
684                version: version.clone(),
685            },
686            Self::AlreadyInstalled {
687                name,
688            } => Self::AlreadyInstalled {
689                name: name.clone(),
690            },
691            Self::InvalidResourceType {
692                resource_type,
693            } => Self::InvalidResourceType {
694                resource_type: resource_type.clone(),
695            },
696            Self::InvalidResourceStructure {
697                file,
698                reason,
699            } => Self::InvalidResourceStructure {
700                file: file.clone(),
701                reason: reason.clone(),
702            },
703            Self::CircularDependency {
704                chain,
705            } => Self::CircularDependency {
706                chain: chain.clone(),
707            },
708            Self::DependencyResolutionFailed {
709                reason,
710            } => Self::DependencyResolutionFailed {
711                reason: reason.clone(),
712            },
713            Self::NetworkError {
714                operation,
715                reason,
716            } => Self::NetworkError {
717                operation: operation.clone(),
718                reason: reason.clone(),
719            },
720            Self::FileSystemError {
721                operation,
722                path,
723            } => Self::FileSystemError {
724                operation: operation.clone(),
725                path: path.clone(),
726            },
727            Self::PermissionDenied {
728                operation,
729                path,
730            } => Self::PermissionDenied {
731                operation: operation.clone(),
732                path: path.clone(),
733            },
734            Self::DirectoryNotEmpty {
735                path,
736            } => Self::DirectoryNotEmpty {
737                path: path.clone(),
738            },
739            Self::InvalidDependency {
740                name,
741                reason,
742            } => Self::InvalidDependency {
743                name: name.clone(),
744                reason: reason.clone(),
745            },
746            Self::InvalidResource {
747                name,
748                reason,
749            } => Self::InvalidResource {
750                name: name.clone(),
751                reason: reason.clone(),
752            },
753            Self::DependencyNotMet {
754                name,
755                required,
756                found,
757            } => Self::DependencyNotMet {
758                name: name.clone(),
759                required: required.clone(),
760                found: found.clone(),
761            },
762            Self::ConfigNotFound {
763                path,
764            } => Self::ConfigNotFound {
765                path: path.clone(),
766            },
767            Self::ChecksumMismatch {
768                name,
769                expected,
770                actual,
771            } => Self::ChecksumMismatch {
772                name: name.clone(),
773                expected: expected.clone(),
774                actual: actual.clone(),
775            },
776            Self::PlatformNotSupported {
777                operation,
778            } => Self::PlatformNotSupported {
779                operation: operation.clone(),
780            },
781            // For errors that don't implement Clone, convert to Other
782            Self::IoError(e) => Self::Other {
783                message: format!("IO error: {e}"),
784            },
785            Self::TomlError(e) => Self::Other {
786                message: format!("TOML parsing error: {e}"),
787            },
788            Self::TomlSerError(e) => Self::Other {
789                message: format!("TOML serialization error: {e}"),
790            },
791            Self::SemverError(e) => Self::Other {
792                message: format!("Semver parsing error: {e}"),
793            },
794            Self::Other {
795                message,
796            } => Self::Other {
797                message: message.clone(),
798            },
799        }
800    }
801}
802
803/// Error context wrapper that provides user-friendly error information
804///
805/// `ErrorContext` wraps a [`AgpmError`] and adds optional user-friendly messages,
806/// suggestions for resolution, and additional details. This is the primary way
807/// AGPM presents errors to CLI users.
808///
809/// # Design Philosophy
810///
811/// Error contexts are designed to be:
812/// - **Actionable**: Include specific suggestions for resolving the error
813/// - **Informative**: Provide context about why the error occurred
814/// - **Colorized**: Use terminal colors to highlight important information
815/// - **Consistent**: Follow a standard format across all error types
816///
817/// # Display Format
818///
819/// When displayed, errors show:
820/// 1. **Error**: The main error message in red
821/// 2. **Details**: Additional context about the error in yellow (optional)
822/// 3. **Suggestion**: Actionable steps to resolve the issue in green (optional)
823///
824/// # Examples
825///
826/// ## Creating Error Context
827///
828/// ```rust,no_run
829/// use agpm_cli::core::{AgpmError, ErrorContext};
830///
831/// let error = AgpmError::GitNotFound;
832/// let context = ErrorContext::new(error)
833///     .with_suggestion("Install git from https://git-scm.com/")
834///     .with_details("AGPM requires git for repository operations");
835///
836/// // Display to terminal with colors
837/// context.display();
838///
839/// // Or convert to string for logging
840/// let message = context.to_string();
841/// ```
842///
843/// ## Builder Pattern Usage
844///
845/// ```rust,no_run
846/// use agpm_cli::core::{AgpmError, ErrorContext};
847///
848/// let context = ErrorContext::new(AgpmError::ManifestNotFound)
849///     .with_suggestion("Create a agpm.toml file in your project directory")
850///     .with_details("AGPM searches current and parent directories for agpm.toml");
851///
852/// println!("{}", context);
853/// ```
854///
855/// ## Quick Suggestion Creation
856///
857/// ```rust,no_run
858/// use agpm_cli::core::ErrorContext;
859///
860/// // Create context with just a suggestion (useful for generic errors)
861/// let context = ErrorContext::suggestion("Try running the command with --verbose");
862/// ```
863#[derive(Debug)]
864pub struct ErrorContext {
865    /// The underlying AGPM error
866    pub error: AgpmError,
867    /// Optional suggestion for resolving the error
868    pub suggestion: Option<String>,
869    /// Optional additional details about the error
870    pub details: Option<String>,
871}
872
873impl ErrorContext {
874    /// Create a new error context from a [`AgpmError`]
875    ///
876    /// This creates a basic error context with no additional suggestions or details.
877    /// Use the builder methods [`with_suggestion`] and [`with_details`] to add
878    /// user-friendly information.
879    ///
880    /// # Examples
881    ///
882    /// ```rust,no_run
883    /// use agpm_cli::core::{AgpmError, ErrorContext};
884    ///
885    /// let context = ErrorContext::new(AgpmError::GitNotFound);
886    /// ```
887    ///
888    /// [`with_suggestion`]: ErrorContext::with_suggestion
889    /// [`with_details`]: ErrorContext::with_details
890    #[must_use]
891    pub const fn new(error: AgpmError) -> Self {
892        Self {
893            error,
894            suggestion: None,
895            details: None,
896        }
897    }
898
899    /// Add a suggestion for resolving the error
900    ///
901    /// Suggestions should be actionable steps that users can take to resolve
902    /// the error. They are displayed in green in the terminal to draw attention.
903    ///
904    /// # Examples
905    ///
906    /// ```rust,no_run
907    /// use agpm_cli::core::{AgpmError, ErrorContext};
908    ///
909    /// let context = ErrorContext::new(AgpmError::GitNotFound)
910    ///     .with_suggestion("Install git using 'brew install git' or visit https://git-scm.com/");
911    /// ```
912    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
913        self.suggestion = Some(suggestion.into());
914        self
915    }
916
917    /// Add additional details explaining the error
918    ///
919    /// Details provide context about why the error occurred or what it means.
920    /// They are displayed in yellow in the terminal to provide additional context
921    /// without being as prominent as the main error or suggestion.
922    ///
923    /// # Examples
924    ///
925    /// ```rust,no_run
926    /// use agpm_cli::core::{AgpmError, ErrorContext};
927    ///
928    /// let context = ErrorContext::new(AgpmError::ManifestNotFound)
929    ///     .with_details("AGPM looks for agpm.toml in current directory and parent directories");
930    /// ```
931    pub fn with_details(mut self, details: impl Into<String>) -> Self {
932        self.details = Some(details.into());
933        self
934    }
935
936    /// Display the error context to stderr with terminal colors
937    ///
938    /// This method prints the error, details, and suggestion to stderr using
939    /// color coding:
940    /// - Error message: Red and bold
941    /// - Details: Yellow
942    /// - Suggestion: Green
943    ///
944    /// This is the primary way AGPM presents errors to users in the CLI.
945    ///
946    /// # Examples
947    ///
948    /// ```rust,no_run
949    /// use agpm_cli::core::{AgpmError, ErrorContext};
950    ///
951    /// let context = ErrorContext::new(AgpmError::GitNotFound)
952    ///     .with_suggestion("Install git from https://git-scm.com/")
953    ///     .with_details("AGPM requires git for repository operations");
954    ///
955    /// context.display(); // Prints colored error to stderr
956    /// ```
957    pub fn display(&self) {
958        eprintln!("{}: {}", "error".red().bold(), self.error);
959
960        if let Some(details) = &self.details {
961            eprintln!("{}: {}", "details".yellow(), details);
962        }
963
964        if let Some(suggestion) = &self.suggestion {
965            eprintln!("{}: {}", "suggestion".green(), suggestion);
966        }
967    }
968}
969
970impl fmt::Display for ErrorContext {
971    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
972        write!(f, "{}", self.error)?;
973
974        if let Some(details) = &self.details {
975            write!(f, "\nDetails: {details}")?;
976        }
977
978        if let Some(suggestion) = &self.suggestion {
979            write!(f, "\nSuggestion: {suggestion}")?;
980        }
981
982        Ok(())
983    }
984}
985
986impl std::error::Error for ErrorContext {}
987
988/// Extension trait for converting [`AgpmError`] to [`anyhow::Error`] with context
989///
990/// This trait provides a method to convert AGPM-specific errors into generic
991/// [`anyhow::Error`] instances while preserving user-friendly context information.
992///
993/// # Examples
994///
995/// ```rust,no_run
996/// use agpm_cli::core::{AgpmError, ErrorContext, IntoAnyhowWithContext};
997///
998/// let error = AgpmError::GitNotFound;
999/// let context = ErrorContext::new(AgpmError::Other { message: "dummy".to_string() })
1000///     .with_suggestion("Install git");
1001///
1002/// let anyhow_error = error.into_anyhow_with_context(context);
1003/// ```
1004pub trait IntoAnyhowWithContext {
1005    /// Convert the error to an [`anyhow::Error`] with the provided context
1006    fn into_anyhow_with_context(self, context: ErrorContext) -> anyhow::Error;
1007}
1008
1009impl IntoAnyhowWithContext for AgpmError {
1010    fn into_anyhow_with_context(self, context: ErrorContext) -> anyhow::Error {
1011        anyhow::Error::new(ErrorContext {
1012            error: self,
1013            suggestion: context.suggestion,
1014            details: context.details,
1015        })
1016    }
1017}
1018
1019impl ErrorContext {
1020    /// Create an [`ErrorContext`] with only a suggestion (no specific error)
1021    ///
1022    /// This is useful for generic errors where you want to provide a suggestion
1023    /// but don't have a specific [`AgpmError`] variant.
1024    ///
1025    /// # Examples
1026    ///
1027    /// ```rust,no_run
1028    /// use agpm_cli::core::ErrorContext;
1029    ///
1030    /// let context = ErrorContext::suggestion("Try running with --verbose for more information");
1031    /// context.display();
1032    /// ```
1033    pub fn suggestion(suggestion: impl Into<String>) -> Self {
1034        Self {
1035            error: AgpmError::Other {
1036                message: String::new(),
1037            },
1038            suggestion: Some(suggestion.into()),
1039            details: None,
1040        }
1041    }
1042}
1043
1044/// Convert any error to a user-friendly [`ErrorContext`] with actionable suggestions
1045///
1046/// This function is the main entry point for converting arbitrary errors into
1047/// user-friendly error messages for CLI display. It recognizes common error types
1048/// and provides appropriate context and suggestions.
1049///
1050/// # Error Recognition
1051///
1052/// The function recognizes and provides specific handling for:
1053/// - [`AgpmError`] variants with tailored suggestions
1054/// - [`std::io::Error`] with filesystem-specific guidance
1055/// - [`toml::de::Error`] with TOML syntax help
1056/// - Generic errors with basic context
1057///
1058/// # Examples
1059///
1060/// ## Converting AGPM Errors
1061///
1062/// ```rust,no_run
1063/// use agpm_cli::core::{AgpmError, user_friendly_error};
1064///
1065/// let error = AgpmError::GitNotFound;
1066/// let anyhow_error = anyhow::Error::from(error);
1067/// let context = user_friendly_error(anyhow_error);
1068///
1069/// context.display(); // Shows git installation suggestions
1070/// ```
1071///
1072/// ## Converting IO Errors
1073///
1074/// ```rust,no_run
1075/// use agpm_cli::core::user_friendly_error;
1076/// use std::io::{Error, ErrorKind};
1077///
1078/// let io_error = Error::new(ErrorKind::PermissionDenied, "access denied");
1079/// let anyhow_error = anyhow::Error::from(io_error);
1080/// let context = user_friendly_error(anyhow_error);
1081///
1082/// context.display(); // Shows permission-related suggestions
1083/// ```
1084///
1085/// ## Converting Generic Errors
1086///
1087/// ```rust,no_run
1088/// use agpm_cli::core::user_friendly_error;
1089///
1090/// let error = anyhow::anyhow!("Something went wrong");
1091/// let context = user_friendly_error(error);
1092///
1093/// context.display(); // Shows the error with generic formatting
1094/// ```
1095///
1096/// Parse enhanced error context from a formatted error message.
1097///
1098/// Extracts structured context information from error messages in the format:
1099/// `Failed to render X for canonical_name="...", manifest_alias="...", source="...", ...`
1100///
1101/// # Arguments
1102///
1103/// * `msg` - The error message to parse
1104///
1105/// # Returns
1106///
1107/// A tuple containing:
1108/// - `canonical_name`: The canonical resource name
1109/// - `manifest_alias`: Optional manifest alias (user's key from agpm.toml)
1110/// - `source`: Optional source repository name
1111/// - `tool`: Optional target tool (claude-code, opencode, etc.)
1112/// - `resolved_commit`: Optional Git commit SHA (truncated to 8 chars)
1113/// - `required_by`: Optional comma-separated list of parent resources
1114///
1115/// Struct to hold parsed enhanced context from error messages
1116#[derive(Debug, Clone)]
1117struct ParsedEnhancedContext {
1118    canonical_name: String,
1119    manifest_alias: Option<String>,
1120    source: Option<String>,
1121    tool: Option<String>,
1122    resolved_commit: Option<String>,
1123    required_by: Option<String>,
1124}
1125
1126fn parse_enhanced_context(msg: &str) -> ParsedEnhancedContext {
1127    let extract_field = |field_name: &str| -> Option<String> {
1128        let pattern = format!("{}=\"", field_name);
1129        if let Some(start_idx) = msg.find(&pattern) {
1130            let value_start = start_idx + pattern.len();
1131            if let Some(end_idx) = msg[value_start..].find('"') {
1132                return Some(msg[value_start..value_start + end_idx].to_string());
1133            }
1134        }
1135        None
1136    };
1137
1138    let canonical_name = extract_field("canonical_name").unwrap_or_else(|| "unknown".to_string());
1139    let manifest_alias = extract_field("manifest_alias");
1140    let source = extract_field("source");
1141    let tool = extract_field("tool");
1142    let resolved_commit = extract_field("resolved_commit");
1143    let required_by = extract_field("required_by");
1144
1145    ParsedEnhancedContext {
1146        canonical_name,
1147        manifest_alias,
1148        source,
1149        tool,
1150        resolved_commit,
1151        required_by,
1152    }
1153}
1154
1155#[must_use]
1156pub fn user_friendly_error(error: anyhow::Error) -> ErrorContext {
1157    // Check for specific error types and provide helpful suggestions
1158    if let Some(ccmp_error) = error.downcast_ref::<AgpmError>() {
1159        return create_error_context(ccmp_error.clone());
1160    }
1161
1162    if let Some(io_error) = error.downcast_ref::<std::io::Error>() {
1163        // Try to extract path from the error chain context
1164        let extracted_path = error
1165            .chain()
1166            .find_map(|e| {
1167                let msg = e.to_string();
1168                // Look for common patterns in our context messages:
1169                // "Failed to read file: /path/to/file"
1170                // "Failed to read local file: /path/to/file"
1171                // "Failed to read resource file: /path/to/file"
1172                // "Transitive dependency does not exist: /path (resolved from ...)"
1173                if let Some(idx) = msg.find(": /") {
1174                    // Extract path starting after ": "
1175                    let path_part = &msg[idx + 2..]; // Skip ": " to keep the leading /
1176                    // Take until end or until we hit " (" for additional context
1177                    let end_idx = path_part.find(" (").unwrap_or(path_part.len());
1178                    let mut path = path_part[..end_idx].to_string();
1179                    // Clean up double slashes and normalize ./ segments
1180                    path = path.replace("//", "/").replace("/./", "/");
1181                    Some(path)
1182                } else if let Some(idx) = msg.find(": ./") {
1183                    // Relative path starting with "./"
1184                    let path_part = &msg[idx + 2..];
1185                    let end_idx = path_part.find(" (").unwrap_or(path_part.len());
1186                    let mut path = path_part[..end_idx].to_string();
1187                    // Clean up double slashes and normalize ./ segments
1188                    path = path.replace("//", "/").replace("/./", "/");
1189                    Some(path)
1190                } else if let Some(idx) = msg.find(": ../") {
1191                    // Relative path starting with "../"
1192                    let path_part = &msg[idx + 2..];
1193                    let end_idx = path_part.find(" (").unwrap_or(path_part.len());
1194                    let mut path = path_part[..end_idx].to_string();
1195                    // Clean up double slashes
1196                    path = path.replace("//", "/");
1197                    Some(path)
1198                } else {
1199                    None
1200                }
1201            })
1202            .unwrap_or_else(|| "unknown".to_string());
1203
1204        match io_error.kind() {
1205            std::io::ErrorKind::PermissionDenied => {
1206                return create_error_context(AgpmError::PermissionDenied {
1207                    operation: "file access".to_string(),
1208                    path: extracted_path,
1209                });
1210            }
1211            std::io::ErrorKind::NotFound => {
1212                return create_error_context(AgpmError::FileSystemError {
1213                    operation: "file access".to_string(),
1214                    path: extracted_path,
1215                });
1216            }
1217            std::io::ErrorKind::AlreadyExists => {
1218                return create_error_context(AgpmError::FileSystemError {
1219                    operation: "file creation".to_string(),
1220                    path: extracted_path,
1221                });
1222            }
1223            std::io::ErrorKind::InvalidData => {
1224                return ErrorContext::new(AgpmError::InvalidResource {
1225                    name: extracted_path,
1226                    reason: "invalid file format".to_string(),
1227                })
1228                .with_suggestion("Check the file format and ensure it's a valid resource file")
1229                .with_details("The file contains invalid or corrupted data");
1230            }
1231            _ => {}
1232        }
1233    }
1234
1235    if let Some(toml_error) = error.downcast_ref::<toml::de::Error>() {
1236        return ErrorContext::new(AgpmError::ManifestParseError {
1237            file: "agpm.toml".to_string(),
1238            reason: toml_error.to_string(),
1239        })
1240        .with_suggestion("Check the TOML syntax in your agpm.toml file. Verify quotes, brackets, and indentation")
1241        .with_details("TOML parsing errors are usually caused by syntax issues like missing quotes or mismatched brackets");
1242    }
1243
1244    // Check for template rendering errors by examining the error message
1245    let error_msg = error.to_string().to_lowercase();
1246    let is_template_error = error_msg.contains("template")
1247        || error_msg.contains("variable")
1248        || error_msg.contains("filter")
1249        || error_msg.contains("tera")
1250        || error_msg.contains("render"); // Also check for "render" as in "Failed to render body/frontmatter"
1251
1252    if is_template_error {
1253        // Extract resource context from error chain
1254        // Look for enhanced context format with canonical_name, manifest_alias, etc.
1255        let context = error
1256            .chain()
1257            .find_map(|e| {
1258                let msg = e.to_string();
1259                if msg.contains("Failed to render") && msg.contains("canonical_name=") {
1260                    // Parse enhanced context format
1261                    Some(parse_enhanced_context(&msg))
1262                } else {
1263                    None
1264                }
1265            })
1266            .unwrap_or_else(|| {
1267                // Fallback: try old format for backwards compatibility
1268                let name = error
1269                    .chain()
1270                    .find_map(|e| {
1271                        let msg = e.to_string();
1272                        if msg.contains("Failed to render template for")
1273                            || msg.contains("Failed to render frontmatter template in")
1274                        {
1275                            msg.split("'").nth(1).map(|s| s.to_string())
1276                        } else {
1277                            None
1278                        }
1279                    })
1280                    .unwrap_or_else(|| "unknown resource".to_string());
1281                ParsedEnhancedContext {
1282                    canonical_name: name,
1283                    manifest_alias: None,
1284                    source: None,
1285                    tool: None,
1286                    resolved_commit: None,
1287                    required_by: None,
1288                }
1289            });
1290
1291        // Extract the actual Tera error message (the root cause, not the context)
1292        let tera_error_msg = error
1293            .chain()
1294            .last() // Get the root cause, not the outermost context
1295            .map(|e| e.to_string())
1296            .unwrap_or_else(|| "Unknown template error".to_string());
1297
1298        // Build user-facing error message with context
1299        let mut message = String::new();
1300
1301        // Show the actual error first (what went wrong)
1302        message.push_str(&tera_error_msg);
1303
1304        // Then show resource context (where it went wrong)
1305        message.push_str(&format!("\n\n       Resource: {}", context.canonical_name));
1306
1307        // Add manifest alias if available
1308        if let Some(alias) = context.manifest_alias.as_ref() {
1309            message.push_str(&format!("\n       Manifest alias: {}", alias));
1310        }
1311
1312        // Add parent chain if available
1313        if let Some(parents) = context.required_by.as_ref() {
1314            message.push_str(&format!("\n       Required by: {}", parents));
1315        }
1316
1317        // Add source and tool context (for detailed diagnostics)
1318        let mut context_parts = Vec::new();
1319        if let Some(s) = context.source.as_ref() {
1320            context_parts.push(format!("source: {}", s));
1321        }
1322        if let Some(t) = context.tool.as_ref() {
1323            context_parts.push(format!("tool: {}", t));
1324        }
1325        if let Some(c) = context.resolved_commit.as_ref() {
1326            context_parts.push(format!("commit: {}", c));
1327        }
1328        if !context_parts.is_empty() {
1329            message.push_str(&format!("\n       Context: {}", context_parts.join(", ")));
1330        }
1331
1332        return ErrorContext::new(AgpmError::InvalidResource {
1333            name: context.canonical_name,
1334            reason: message,
1335        })
1336        .with_suggestion(
1337            "Check template syntax: variables use {{ var }}, comments use {# #}, control flow uses {% %}. \
1338             Ensure all variables referenced in the template exist in the context (agpm.resource, agpm.deps)",
1339        )
1340        .with_details(
1341            "Template errors occur when Tera cannot render the template. Common issues:\n\
1342             - Undefined variables (use {% if var is defined %} to check)\n\
1343             - Syntax errors (unclosed {{ or {% delimiters)\n\
1344             - Invalid filters or functions\n\
1345             - Type mismatches in operations",
1346        );
1347    }
1348
1349    // Generic error - include the full error chain for better diagnostics
1350    let mut message = error.to_string();
1351
1352    // Append error chain if available
1353    let chain: Vec<String> = error
1354        .chain()
1355        .skip(1) // Skip the root cause which is already in to_string()
1356        .map(std::string::ToString::to_string)
1357        .collect();
1358
1359    if !chain.is_empty() {
1360        message.push_str("\n\nCaused by:");
1361        for (i, cause) in chain.iter().enumerate() {
1362            message.push_str(&format!("\n  {}: {}", i + 1, cause));
1363        }
1364    }
1365
1366    ErrorContext::new(AgpmError::Other {
1367        message,
1368    })
1369}
1370
1371/// Create appropriate [`ErrorContext`] with suggestions for specific AGPM errors
1372///
1373/// This internal function maps each [`AgpmError`] variant to an appropriate
1374/// [`ErrorContext`] with tailored suggestions and details. It's used by
1375/// [`user_friendly_error`] to provide consistent, helpful error messages.
1376///
1377/// # Implementation Notes
1378///
1379/// - Each error type has specific suggestions based on common resolution steps
1380/// - Platform-specific suggestions are provided where applicable
1381/// - Error messages focus on actionable steps rather than technical details
1382/// - Cross-references to related commands or documentation are included
1383fn create_error_context(error: AgpmError) -> ErrorContext {
1384    match &error {
1385        AgpmError::GitNotFound => ErrorContext::new(AgpmError::GitNotFound)
1386            .with_suggestion("Install git from https://git-scm.com/ or your package manager (e.g., 'brew install git', 'apt install git')")
1387            .with_details("AGPM requires git to be installed and available in your PATH to manage repositories"),
1388
1389        AgpmError::GitCommandError { operation, stderr } => {
1390            ErrorContext::new(AgpmError::GitCommandError {
1391                operation: operation.clone(),
1392                stderr: stderr.clone(),
1393            })
1394            .with_suggestion(match operation.as_str() {
1395                op if op.contains("clone") => "Check the repository URL and your internet connection. Verify you have access to the repository",
1396                op if op.contains("fetch") => "Check your internet connection and repository access. Try 'git fetch' manually in the repository directory",
1397                op if op.contains("checkout") => "Verify the branch, tag, or commit exists. Use 'git tag -l' or 'git branch -r' to list available references",
1398                op if op.contains("worktree") => {
1399                    if stderr.contains("invalid reference")
1400                        || stderr.contains("not a valid object name")
1401                        || stderr.contains("pathspec")
1402                        || stderr.contains("did not match")
1403                        || stderr.contains("unknown revision") {
1404                        "Invalid version: The specified version/tag/branch does not exist in the repository. Check available versions with 'git tag -l' or 'git branch -r'"
1405                    } else {
1406                        "Failed to create worktree. Check that the reference exists and the repository is valid"
1407                    }
1408                },
1409                _ => "Check your git configuration and repository access. Try running the git command manually for more details",
1410            })
1411            .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")) {
1412                "Invalid version specification: Failed to checkout reference - the specified version/tag/branch does not exist"
1413            } else {
1414                "Git operations failed. This is often due to network issues, authentication problems, or invalid references"
1415            })
1416        }
1417
1418        AgpmError::GitAuthenticationFailed { url } => ErrorContext::new(AgpmError::GitAuthenticationFailed {
1419            url: url.clone(),
1420        })
1421            .with_suggestion("Configure git authentication: use 'git config --global user.name' and 'git config --global user.email', or set up SSH keys")
1422            .with_details("Authentication is required for private repositories. You may need to log in with 'git credential-manager-core' or similar"),
1423
1424        AgpmError::GitCloneFailed { url, reason } => ErrorContext::new(AgpmError::GitCloneFailed {
1425            url: url.clone(),
1426            reason: reason.clone(),
1427        })
1428            .with_suggestion(format!(
1429                "Verify the repository URL is correct: {url}. Check your internet connection and repository access"
1430            ))
1431            .with_details("Clone operations can fail due to invalid URLs, network issues, or access restrictions"),
1432
1433        AgpmError::ManifestNotFound => ErrorContext::new(AgpmError::ManifestNotFound)
1434            .with_suggestion("Create a agpm.toml file in your project directory. See documentation for the manifest format")
1435            .with_details("AGPM looks for agpm.toml in the current directory and parent directories up to the filesystem root"),
1436
1437        AgpmError::ManifestParseError { file, reason } => ErrorContext::new(AgpmError::ManifestParseError {
1438            file: file.clone(),
1439            reason: reason.clone(),
1440        })
1441            .with_suggestion(format!(
1442                "Check the TOML syntax in {file}. Common issues: missing quotes, unmatched brackets, invalid characters"
1443            ))
1444            .with_details("Use a TOML validator or check the agpm documentation for correct manifest format"),
1445
1446        AgpmError::SourceNotFound { name } => ErrorContext::new(AgpmError::SourceNotFound {
1447            name: name.clone(),
1448        })
1449            .with_suggestion(format!(
1450                "Add source '{name}' to the [sources] section in agpm.toml with the repository URL"
1451            ))
1452            .with_details("All dependencies must reference a source defined in the [sources] section"),
1453
1454        AgpmError::ResourceFileNotFound { path, source_name } => ErrorContext::new(AgpmError::ResourceFileNotFound {
1455            path: path.clone(),
1456            source_name: source_name.clone(),
1457        })
1458            .with_suggestion(format!(
1459                "Verify the file '{path}' exists in the '{source_name}' repository at the specified version/commit"
1460            ))
1461            .with_details("The resource file may have been moved, renamed, or deleted in the repository"),
1462
1463        AgpmError::VersionNotFound { resource, version } => ErrorContext::new(AgpmError::VersionNotFound {
1464            resource: resource.clone(),
1465            version: version.clone(),
1466        })
1467            .with_suggestion(format!(
1468                "Check available versions for '{resource}' using 'git tag -l' in the repository, or use 'main' or 'master' branch"
1469            ))
1470            .with_details(format!(
1471                "The version '{version}' doesn't exist as a git tag, branch, or commit in the repository"
1472            )),
1473
1474        AgpmError::CircularDependency { chain } => ErrorContext::new(AgpmError::CircularDependency {
1475            chain: chain.clone(),
1476        })
1477            .with_suggestion("Review your dependency graph and remove circular references")
1478            .with_details(format!(
1479                "Circular dependency chain detected: {chain}. Dependencies cannot depend on themselves directly or indirectly"
1480            )),
1481
1482        AgpmError::PermissionDenied { operation, path } => ErrorContext::new(AgpmError::PermissionDenied {
1483            operation: operation.clone(),
1484            path: path.clone(),
1485        })
1486            .with_suggestion(match cfg!(windows) {
1487                true => "Run as Administrator or check file permissions in File Explorer",
1488                false => "Use 'sudo' or check file permissions with 'ls -la'",
1489            })
1490            .with_details(format!(
1491                "Cannot {operation} due to insufficient permissions on {path}"
1492            )),
1493
1494        AgpmError::ChecksumMismatch { name, expected, actual } => ErrorContext::new(AgpmError::ChecksumMismatch {
1495            name: name.clone(),
1496            expected: expected.clone(),
1497            actual: actual.clone(),
1498        })
1499            .with_suggestion("The file may have been corrupted or modified. Try reinstalling with --force")
1500            .with_details(format!(
1501                "Resource '{name}' has checksum {actual} but expected {expected}. This indicates file corruption or tampering"
1502            )),
1503
1504        AgpmError::FileSystemError { operation, path } => {
1505            let suggestion = if operation.contains("creation") {
1506                format!("The file already exists at {path}. Use --force to overwrite it")
1507            } else {
1508                format!("Check that the path exists and is accessible: {path}")
1509            };
1510            ErrorContext::new(AgpmError::FileSystemError {
1511                operation: operation.clone(),
1512                path: path.clone(),
1513            })
1514            .with_suggestion(suggestion)
1515            .with_details(format!(
1516                "Failed to {operation} at path: {path}"
1517            ))
1518        }
1519
1520        _ => ErrorContext::new(error.clone()),
1521    }
1522}
1523
1524#[cfg(test)]
1525mod tests {
1526    use super::*;
1527
1528    #[test]
1529    fn test_error_display() {
1530        let error = AgpmError::GitNotFound;
1531        assert_eq!(error.to_string(), "Git is not installed or not found in PATH");
1532
1533        let error = AgpmError::ResourceNotFound {
1534            name: "test".to_string(),
1535        };
1536        assert_eq!(error.to_string(), "Resource 'test' not found");
1537
1538        let error = AgpmError::InvalidVersionConstraint {
1539            constraint: "bad-version".to_string(),
1540        };
1541        assert_eq!(error.to_string(), "Invalid version constraint: bad-version");
1542
1543        let error = AgpmError::GitCommandError {
1544            operation: "clone".to_string(),
1545            stderr: "repository not found".to_string(),
1546        };
1547        assert_eq!(error.to_string(), "Git operation failed: clone");
1548    }
1549
1550    #[test]
1551    fn test_error_context() {
1552        let ctx = ErrorContext::new(AgpmError::GitNotFound)
1553            .with_suggestion("Install git using your package manager")
1554            .with_details("Git is required for AGPM to function");
1555
1556        assert_eq!(ctx.suggestion, Some("Install git using your package manager".to_string()));
1557        assert_eq!(ctx.details, Some("Git is required for AGPM to function".to_string()));
1558    }
1559
1560    #[test]
1561    fn test_error_context_display() {
1562        let ctx = ErrorContext::new(AgpmError::GitNotFound).with_suggestion("Install git");
1563
1564        let display = format!("{ctx}");
1565        assert!(display.contains("Git is not installed or not found in PATH"));
1566        assert!(display.contains("Install git"));
1567    }
1568
1569    #[test]
1570    fn test_user_friendly_error_permission_denied() {
1571        use std::io::{Error, ErrorKind};
1572
1573        let io_error = Error::new(ErrorKind::PermissionDenied, "access denied");
1574        let anyhow_error = anyhow::Error::from(io_error);
1575
1576        let ctx = user_friendly_error(anyhow_error);
1577        match ctx.error {
1578            AgpmError::PermissionDenied {
1579                ..
1580            } => {}
1581            _ => panic!("Expected PermissionDenied error"),
1582        }
1583        assert!(ctx.suggestion.is_some());
1584        assert!(ctx.details.is_some());
1585    }
1586
1587    #[test]
1588    fn test_user_friendly_error_not_found_with_path() {
1589        use std::io::{Error, ErrorKind};
1590
1591        let io_error = Error::new(ErrorKind::NotFound, "file not found");
1592        let anyhow_error =
1593            anyhow::Error::from(io_error).context("Failed to read local file: /path/to/missing.md");
1594
1595        let ctx = user_friendly_error(anyhow_error);
1596        match &ctx.error {
1597            AgpmError::FileSystemError {
1598                path,
1599                ..
1600            } => {
1601                assert_eq!(
1602                    path, "/path/to/missing.md",
1603                    "Path should be extracted from error context"
1604                );
1605            }
1606            _ => panic!("Expected FileSystemError, got: {:?}", ctx.error),
1607        }
1608        assert!(ctx.suggestion.is_some());
1609        assert!(ctx.suggestion.as_ref().unwrap().contains("/path/to/missing.md"));
1610    }
1611
1612    #[test]
1613    fn test_user_friendly_error_not_found_with_malformed_path() {
1614        use std::io::{Error, ErrorKind};
1615
1616        // Test that we clean up double slashes and ./ segments
1617        let io_error = Error::new(ErrorKind::NotFound, "file not found");
1618        let anyhow_error =
1619            anyhow::Error::from(io_error).context("Failed to read: //Users/test/./foo/./bar.md");
1620
1621        let ctx = user_friendly_error(anyhow_error);
1622        match &ctx.error {
1623            AgpmError::FileSystemError {
1624                path,
1625                ..
1626            } => {
1627                assert_eq!(
1628                    path, "/Users/test/foo/bar.md",
1629                    "Path should be normalized (double slashes and ./ removed)"
1630                );
1631            }
1632            _ => panic!("Expected FileSystemError, got: {:?}", ctx.error),
1633        }
1634        assert!(ctx.suggestion.as_ref().unwrap().contains("/Users/test/foo/bar.md"));
1635    }
1636
1637    #[test]
1638    fn test_user_friendly_error_not_found() {
1639        use std::io::{Error, ErrorKind};
1640
1641        let io_error = Error::new(ErrorKind::NotFound, "file not found");
1642        let anyhow_error = anyhow::Error::from(io_error);
1643
1644        let ctx = user_friendly_error(anyhow_error);
1645        match ctx.error {
1646            AgpmError::FileSystemError {
1647                ..
1648            } => {}
1649            _ => panic!("Expected FileSystemError"),
1650        }
1651        assert!(ctx.suggestion.is_some());
1652        assert!(ctx.details.is_some());
1653    }
1654
1655    #[test]
1656    fn test_from_io_error() {
1657        use std::io::Error;
1658
1659        let io_error = Error::other("test error");
1660        let agpm_error = AgpmError::from(io_error);
1661
1662        match agpm_error {
1663            AgpmError::IoError(_) => {}
1664            _ => panic!("Expected IoError"),
1665        }
1666    }
1667
1668    #[test]
1669    fn test_from_toml_error() {
1670        let toml_str = "invalid = toml {";
1671        let result: Result<toml::Value, _> = toml::from_str(toml_str);
1672
1673        if let Err(e) = result {
1674            let agpm_error = AgpmError::from(e);
1675            match agpm_error {
1676                AgpmError::TomlError(_) => {}
1677                _ => panic!("Expected TomlError"),
1678            }
1679        }
1680    }
1681
1682    #[test]
1683    fn test_create_error_context_git_not_found() {
1684        let ctx = create_error_context(AgpmError::GitNotFound);
1685        assert!(ctx.suggestion.is_some());
1686        assert!(ctx.suggestion.unwrap().contains("Install git"));
1687        assert!(ctx.details.is_some());
1688    }
1689
1690    #[test]
1691    fn test_create_error_context_git_command_error() {
1692        let ctx = create_error_context(AgpmError::GitCommandError {
1693            operation: "clone".to_string(),
1694            stderr: "error".to_string(),
1695        });
1696        assert!(ctx.suggestion.is_some());
1697        assert!(ctx.suggestion.unwrap().contains("repository URL"));
1698        assert!(ctx.details.is_some());
1699    }
1700
1701    #[test]
1702    fn test_create_error_context_git_auth_failed() {
1703        let ctx = create_error_context(AgpmError::GitAuthenticationFailed {
1704            url: "https://github.com/test/repo".to_string(),
1705        });
1706        assert!(ctx.suggestion.is_some());
1707        assert!(ctx.suggestion.unwrap().contains("Configure git authentication"));
1708        assert!(ctx.details.is_some());
1709    }
1710
1711    #[test]
1712    fn test_create_error_context_manifest_not_found() {
1713        let ctx = create_error_context(AgpmError::ManifestNotFound);
1714        assert!(ctx.suggestion.is_some());
1715        assert!(ctx.suggestion.unwrap().contains("Create a agpm.toml"));
1716        assert!(ctx.details.is_some());
1717    }
1718
1719    #[test]
1720    fn test_create_error_context_source_not_found() {
1721        let ctx = create_error_context(AgpmError::SourceNotFound {
1722            name: "test-source".to_string(),
1723        });
1724        assert!(ctx.suggestion.is_some());
1725        assert!(ctx.suggestion.unwrap().contains("test-source"));
1726        assert!(ctx.details.is_some());
1727    }
1728
1729    #[test]
1730    fn test_create_error_context_version_not_found() {
1731        let ctx = create_error_context(AgpmError::VersionNotFound {
1732            resource: "test-resource".to_string(),
1733            version: "v1.0.0".to_string(),
1734        });
1735        assert!(ctx.suggestion.is_some());
1736        assert!(ctx.suggestion.unwrap().contains("test-resource"));
1737        assert!(ctx.details.is_some());
1738        assert!(ctx.details.unwrap().contains("v1.0.0"));
1739    }
1740
1741    #[test]
1742    fn test_create_error_context_circular_dependency() {
1743        let ctx = create_error_context(AgpmError::CircularDependency {
1744            chain: "a -> b -> c -> a".to_string(),
1745        });
1746        assert!(ctx.suggestion.is_some());
1747        assert!(ctx.suggestion.unwrap().contains("remove circular"));
1748        assert!(ctx.details.is_some());
1749        assert!(ctx.details.unwrap().contains("a -> b -> c -> a"));
1750    }
1751
1752    #[test]
1753    fn test_create_error_context_permission_denied() {
1754        let ctx = create_error_context(AgpmError::PermissionDenied {
1755            operation: "write".to_string(),
1756            path: "/test/path".to_string(),
1757        });
1758        assert!(ctx.suggestion.is_some());
1759        assert!(ctx.details.is_some());
1760        assert!(ctx.details.unwrap().contains("/test/path"));
1761    }
1762
1763    #[test]
1764    fn test_create_error_context_checksum_mismatch() {
1765        let ctx = create_error_context(AgpmError::ChecksumMismatch {
1766            name: "test-resource".to_string(),
1767            expected: "abc123".to_string(),
1768            actual: "def456".to_string(),
1769        });
1770        assert!(ctx.suggestion.is_some());
1771        assert!(ctx.suggestion.unwrap().contains("reinstalling"));
1772        assert!(ctx.details.is_some());
1773        assert!(ctx.details.unwrap().contains("abc123"));
1774    }
1775
1776    #[test]
1777    fn test_error_clone() {
1778        let error1 = AgpmError::GitNotFound;
1779        let error2 = error1.clone();
1780        assert_eq!(error1.to_string(), error2.to_string());
1781
1782        let error1 = AgpmError::ResourceNotFound {
1783            name: "test".to_string(),
1784        };
1785        let error2 = error1.clone();
1786        assert_eq!(error1.to_string(), error2.to_string());
1787    }
1788
1789    #[test]
1790    fn test_error_context_suggestion() {
1791        let ctx = ErrorContext::suggestion("Test suggestion");
1792        assert_eq!(ctx.suggestion, Some("Test suggestion".to_string()));
1793        assert!(ctx.details.is_none());
1794    }
1795
1796    #[test]
1797    fn test_into_anyhow_with_context() {
1798        let error = AgpmError::GitNotFound;
1799        let context = ErrorContext::new(AgpmError::Other {
1800            message: "dummy".to_string(),
1801        })
1802        .with_suggestion("Test suggestion")
1803        .with_details("Test details");
1804
1805        let anyhow_error = error.into_anyhow_with_context(context);
1806        let display = format!("{anyhow_error}");
1807        assert!(display.contains("Git is not installed"));
1808    }
1809
1810    #[test]
1811    fn test_user_friendly_error_already_exists() {
1812        use std::io::{Error, ErrorKind};
1813
1814        let io_error = Error::new(ErrorKind::AlreadyExists, "file exists");
1815        let anyhow_error = anyhow::Error::from(io_error);
1816
1817        let ctx = user_friendly_error(anyhow_error);
1818        match ctx.error {
1819            AgpmError::FileSystemError {
1820                ..
1821            } => {}
1822            _ => panic!("Expected FileSystemError"),
1823        }
1824        assert!(ctx.suggestion.is_some());
1825        assert!(ctx.suggestion.unwrap().contains("overwrite"));
1826    }
1827
1828    #[test]
1829    fn test_user_friendly_error_invalid_data() {
1830        use std::io::{Error, ErrorKind};
1831
1832        let io_error = Error::new(ErrorKind::InvalidData, "corrupt data");
1833        let anyhow_error = anyhow::Error::from(io_error);
1834
1835        let ctx = user_friendly_error(anyhow_error);
1836        match ctx.error {
1837            AgpmError::InvalidResource {
1838                ..
1839            } => {}
1840            _ => panic!("Expected InvalidResource"),
1841        }
1842        assert!(ctx.suggestion.is_some());
1843        assert!(ctx.details.is_some());
1844    }
1845
1846    #[test]
1847    fn test_user_friendly_error_template_with_resource_name() {
1848        // Simulate a template error with enhanced resource context
1849        let template_error = anyhow::anyhow!("Variable `foo` not found in context");
1850        let error_with_context = template_error.context(
1851            "Failed to render body for canonical_name=\"my-awesome-agent\", source=\"community\"",
1852        );
1853
1854        let ctx = user_friendly_error(error_with_context);
1855
1856        match &ctx.error {
1857            AgpmError::InvalidResource {
1858                name,
1859                reason,
1860            } => {
1861                // Verify resource name was extracted from enhanced context
1862                assert_eq!(
1863                    name, "my-awesome-agent",
1864                    "Resource name should be extracted from canonical_name field"
1865                );
1866                // Verify the reason contains the actual Tera error
1867                assert!(
1868                    reason.contains("Variable `foo` not found"),
1869                    "Reason should contain the actual Tera error. Got: {}",
1870                    reason
1871                );
1872                // Verify the reason includes the resource context
1873                assert!(
1874                    reason.contains("Resource: my-awesome-agent"),
1875                    "Reason should include resource context. Got: {}",
1876                    reason
1877                );
1878            }
1879            _ => panic!("Expected InvalidResource, got {:?}", ctx.error),
1880        }
1881        assert!(ctx.suggestion.is_some());
1882        assert!(ctx.details.is_some());
1883    }
1884
1885    #[test]
1886    fn test_user_friendly_error_template_without_resource_name() {
1887        // Simulate a template error without resource context
1888        let template_error = anyhow::anyhow!("Variable `bar` not found in context");
1889
1890        let ctx = user_friendly_error(template_error);
1891
1892        match &ctx.error {
1893            AgpmError::InvalidResource {
1894                name,
1895                reason,
1896            } => {
1897                // Should fallback to "unknown resource" when name can't be extracted
1898                assert_eq!(
1899                    name, "unknown resource",
1900                    "Should use fallback when resource name unavailable"
1901                );
1902                assert!(reason.contains("Variable"), "Reason should contain the actual error");
1903            }
1904            _ => panic!("Expected InvalidResource, got {:?}", ctx.error),
1905        }
1906    }
1907
1908    #[test]
1909    fn test_user_friendly_error_agpm_error() {
1910        let error = AgpmError::GitNotFound;
1911        let anyhow_error = anyhow::Error::from(error);
1912
1913        let ctx = user_friendly_error(anyhow_error);
1914        match ctx.error {
1915            AgpmError::GitNotFound => {}
1916            _ => panic!("Expected GitNotFound"),
1917        }
1918        assert!(ctx.suggestion.is_some());
1919    }
1920
1921    #[test]
1922    fn test_user_friendly_error_toml_parse() {
1923        let toml_str = "invalid = toml {";
1924        let result: Result<toml::Value, _> = toml::from_str(toml_str);
1925
1926        if let Err(e) = result {
1927            let anyhow_error = anyhow::Error::from(e);
1928            let ctx = user_friendly_error(anyhow_error);
1929
1930            match ctx.error {
1931                AgpmError::ManifestParseError {
1932                    ..
1933                } => {}
1934                _ => panic!("Expected ManifestParseError"),
1935            }
1936            assert!(ctx.suggestion.is_some());
1937            assert!(ctx.suggestion.unwrap().contains("TOML syntax"));
1938        }
1939    }
1940
1941    #[test]
1942    fn test_user_friendly_error_generic() {
1943        let error = anyhow::anyhow!("Generic error");
1944        let ctx = user_friendly_error(error);
1945
1946        match ctx.error {
1947            AgpmError::Other {
1948                message,
1949            } => {
1950                assert_eq!(message, "Generic error");
1951            }
1952            _ => panic!("Expected Other error"),
1953        }
1954    }
1955
1956    #[test]
1957    fn test_from_semver_error() {
1958        let result = semver::Version::parse("invalid-version");
1959        if let Err(e) = result {
1960            let agpm_error = AgpmError::from(e);
1961            match agpm_error {
1962                AgpmError::SemverError(_) => {}
1963                _ => panic!("Expected SemverError"),
1964            }
1965        }
1966    }
1967
1968    #[test]
1969    fn test_error_display_all_variants() {
1970        // Test display for various error variants
1971        let errors = vec![
1972            AgpmError::GitRepoInvalid {
1973                path: "/test/path".to_string(),
1974            },
1975            AgpmError::GitCheckoutFailed {
1976                reference: "main".to_string(),
1977                reason: "not found".to_string(),
1978            },
1979            AgpmError::ConfigError {
1980                message: "config issue".to_string(),
1981            },
1982            AgpmError::ManifestValidationError {
1983                reason: "invalid format".to_string(),
1984            },
1985            AgpmError::LockfileParseError {
1986                file: "agpm.lock".to_string(),
1987                reason: "syntax error".to_string(),
1988            },
1989            AgpmError::ResourceFileNotFound {
1990                path: "test.md".to_string(),
1991                source_name: "source".to_string(),
1992            },
1993            AgpmError::DirectoryNotEmpty {
1994                path: "/some/dir".to_string(),
1995            },
1996            AgpmError::InvalidDependency {
1997                name: "dep".to_string(),
1998                reason: "bad format".to_string(),
1999            },
2000            AgpmError::DependencyNotMet {
2001                name: "dep".to_string(),
2002                required: "v1.0".to_string(),
2003                found: "v2.0".to_string(),
2004            },
2005            AgpmError::ConfigNotFound {
2006                path: "/config/path".to_string(),
2007            },
2008            AgpmError::PlatformNotSupported {
2009                operation: "test op".to_string(),
2010            },
2011        ];
2012
2013        for error in errors {
2014            let display = format!("{error}");
2015            assert!(!display.is_empty());
2016        }
2017    }
2018
2019    #[test]
2020    fn test_create_error_context_git_operations() {
2021        // Test different git operations
2022        let operations = vec![
2023            ("fetch", "internet connection"),
2024            ("checkout", "branch, tag"),
2025            ("pull", "git configuration"),
2026        ];
2027
2028        for (op, expected_text) in operations {
2029            let ctx = create_error_context(AgpmError::GitCommandError {
2030                operation: op.to_string(),
2031                stderr: "error".to_string(),
2032            });
2033            assert!(ctx.suggestion.is_some());
2034            assert!(ctx.suggestion.unwrap().to_lowercase().contains(expected_text));
2035        }
2036    }
2037
2038    #[test]
2039    fn test_create_error_context_resource_file_not_found() {
2040        let ctx = create_error_context(AgpmError::ResourceFileNotFound {
2041            path: "agents/test.md".to_string(),
2042            source_name: "official".to_string(),
2043        });
2044        assert!(ctx.suggestion.is_some());
2045        let suggestion = ctx.suggestion.unwrap();
2046        assert!(suggestion.contains("agents/test.md"));
2047        assert!(suggestion.contains("official"));
2048        assert!(ctx.details.is_some());
2049    }
2050
2051    #[test]
2052    fn test_create_error_context_manifest_parse_error() {
2053        let ctx = create_error_context(AgpmError::ManifestParseError {
2054            file: "custom.toml".to_string(),
2055            reason: "invalid syntax".to_string(),
2056        });
2057        assert!(ctx.suggestion.is_some());
2058        let suggestion = ctx.suggestion.unwrap();
2059        assert!(suggestion.contains("custom.toml"));
2060        assert!(ctx.details.is_some());
2061    }
2062
2063    #[test]
2064    fn test_create_error_context_git_clone_failed() {
2065        let ctx = create_error_context(AgpmError::GitCloneFailed {
2066            url: "https://example.com/repo.git".to_string(),
2067            reason: "network error".to_string(),
2068        });
2069        assert!(ctx.suggestion.is_some());
2070        let suggestion = ctx.suggestion.unwrap();
2071        assert!(suggestion.contains("https://example.com/repo.git"));
2072        assert!(ctx.details.is_some());
2073    }
2074}