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