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