use colored::Colorize;
use std::fmt;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AgpmError {
#[error("Git operation failed: {operation}\n{stderr}")]
GitCommandError {
operation: String,
stderr: String,
},
#[error("Git is not installed or not found in PATH")]
GitNotFound,
#[error("Not a valid git repository: {path}")]
GitRepoInvalid {
path: String,
},
#[error("Git authentication failed for repository: {url}")]
GitAuthenticationFailed {
url: String,
},
#[error("Failed to clone repository: {url}\n{reason}")]
GitCloneFailed {
url: String,
reason: String,
},
#[error("Failed to checkout reference '{reference}' in repository")]
GitCheckoutFailed {
reference: String,
reason: String,
},
#[error("Configuration error: {message}")]
ConfigError {
message: String,
},
#[error("Manifest file agpm.toml not found in current directory or any parent directory")]
ManifestNotFound,
#[error("Invalid manifest file syntax in {file}")]
ManifestParseError {
file: String,
reason: String,
},
#[error("Manifest validation failed: {reason}")]
ManifestValidationError {
reason: String,
},
#[error("Invalid lockfile syntax in {file}")]
LockfileParseError {
file: String,
reason: String,
},
#[error(
"Invalid or corrupted lockfile detected: {file}\n\n{reason}\n\nNote: The lockfile format is not yet stable as this is beta software."
)]
InvalidLockfileError {
file: String,
reason: String,
can_regenerate: bool,
},
#[error("Resource '{name}' not found")]
ResourceNotFound {
name: String,
},
#[error("Resource file '{path}' not found in source '{source_name}'")]
ResourceFileNotFound {
path: String,
source_name: String,
},
#[error("Source repository '{name}' not defined in manifest")]
SourceNotFound {
name: String,
},
#[error("Cannot reach source repository '{name}' at {url}")]
SourceUnreachable {
name: String,
url: String,
},
#[error("Invalid version constraint: {constraint}")]
InvalidVersionConstraint {
constraint: String,
},
#[error("Version '{version}' not found for resource '{resource}'")]
VersionNotFound {
resource: String,
version: String,
},
#[error("Resource '{name}' is already installed")]
AlreadyInstalled {
name: String,
},
#[error("Invalid resource type: {resource_type}")]
InvalidResourceType {
resource_type: String,
},
#[error("Invalid resource structure in '{file}': {reason}")]
InvalidResourceStructure {
file: String,
reason: String,
},
#[error("Circular dependency detected: {chain}")]
CircularDependency {
chain: String,
},
#[error("Cannot resolve dependencies: {reason}")]
DependencyResolutionFailed {
reason: String,
},
#[error("Dependency resolution mismatch for resource '{resource}'")]
DependencyResolutionMismatch {
resource: String,
declared_count: usize,
resolved_count: usize,
declared_deps: Vec<(String, String)>,
},
#[error("Network error: {operation}")]
NetworkError {
operation: String,
reason: String,
},
#[error("File system error: {operation}: {path}")]
FileSystemError {
operation: String,
path: String,
},
#[error("Permission denied: {operation}: {path}")]
PermissionDenied {
operation: String,
path: String,
},
#[error("Directory is not empty: {path}")]
DirectoryNotEmpty {
path: String,
},
#[error("Invalid dependency specification for '{name}': {reason}")]
InvalidDependency {
name: String,
reason: String,
},
#[error("Invalid resource content in '{name}': {reason}")]
InvalidResource {
name: String,
reason: String,
},
#[error("Dependency '{name}' requires version {required}, but {found} was found")]
DependencyNotMet {
name: String,
required: String,
found: String,
},
#[error("Configuration file not found: {path}")]
ConfigNotFound {
path: String,
},
#[error("Checksum mismatch for resource '{name}': expected {expected}, got {actual}")]
ChecksumMismatch {
name: String,
expected: String,
actual: String,
},
#[error("Operation not supported on this platform: {operation}")]
PlatformNotSupported {
operation: String,
},
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("TOML parsing error: {0}")]
TomlError(#[from] toml::de::Error),
#[error("TOML serialization error: {0}")]
TomlSerError(#[from] toml::ser::Error),
#[error("Semver parsing error: {0}")]
SemverError(#[from] semver::Error),
#[error("{message}")]
Other {
message: String,
},
}
#[derive(Debug)]
pub struct ErrorContext {
pub error: AgpmError,
pub suggestion: Option<String>,
pub details: Option<String>,
}
impl ErrorContext {
#[must_use]
pub const fn new(error: AgpmError) -> Self {
Self {
error,
suggestion: None,
details: None,
}
}
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
self.suggestion = Some(suggestion.into());
self
}
pub fn with_details(mut self, details: impl Into<String>) -> Self {
self.details = Some(details.into());
self
}
pub fn display(&self) {
eprintln!("{}: {}", "error".red().bold(), self.error);
if let Some(details) = &self.details {
eprintln!("{}: {}", "details".yellow(), details);
}
if let Some(suggestion) = &self.suggestion {
eprintln!("{}: {}", "suggestion".green(), suggestion);
}
}
}
impl fmt::Display for ErrorContext {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.error)?;
if let Some(details) = &self.details {
write!(f, "\nDetails: {details}")?;
}
if let Some(suggestion) = &self.suggestion {
write!(f, "\nSuggestion: {suggestion}")?;
}
Ok(())
}
}
impl std::error::Error for ErrorContext {}
pub trait IntoAnyhowWithContext {
fn into_anyhow_with_context(self, context: ErrorContext) -> anyhow::Error;
}
impl IntoAnyhowWithContext for AgpmError {
fn into_anyhow_with_context(self, context: ErrorContext) -> anyhow::Error {
anyhow::Error::new(ErrorContext {
error: self,
suggestion: context.suggestion,
details: context.details,
})
}
}
impl ErrorContext {
pub fn suggestion(suggestion: impl Into<String>) -> Self {
Self {
error: AgpmError::Other {
message: String::new(),
},
suggestion: Some(suggestion.into()),
details: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::error_formatting::create_error_context;
#[test]
fn test_error_display() {
let error = AgpmError::GitNotFound;
assert_eq!(error.to_string(), "Git is not installed or not found in PATH");
let error = AgpmError::ResourceNotFound {
name: "test".to_string(),
};
assert_eq!(error.to_string(), "Resource 'test' not found");
let error = AgpmError::InvalidVersionConstraint {
constraint: "bad-version".to_string(),
};
assert_eq!(error.to_string(), "Invalid version constraint: bad-version");
let error = AgpmError::GitCommandError {
operation: "clone".to_string(),
stderr: "repository not found".to_string(),
};
assert_eq!(error.to_string(), "Git operation failed: clone\nrepository not found");
}
#[test]
fn test_error_context() {
let ctx = ErrorContext::new(AgpmError::GitNotFound)
.with_suggestion("Install git using your package manager")
.with_details("Git is required for AGPM to function");
assert_eq!(ctx.suggestion, Some("Install git using your package manager".to_string()));
assert_eq!(ctx.details, Some("Git is required for AGPM to function".to_string()));
}
#[test]
fn test_error_context_display() {
let ctx = ErrorContext::new(AgpmError::GitNotFound).with_suggestion("Install git");
let display = format!("{ctx}");
assert!(display.contains("Git is not installed or not found in PATH"));
}
#[test]
fn test_from_semver_error() {
let result = semver::Version::parse("invalid-version");
if let Err(e) = result {
let agpm_error = AgpmError::from(e);
match agpm_error {
AgpmError::SemverError(_) => {}
_ => panic!("Expected SemverError"),
}
}
}
#[test]
fn test_error_display_all_variants() {
let errors = vec![
AgpmError::GitRepoInvalid {
path: "/test/path".to_string(),
},
AgpmError::GitCheckoutFailed {
reference: "main".to_string(),
reason: "not found".to_string(),
},
AgpmError::ConfigError {
message: "config issue".to_string(),
},
AgpmError::ManifestValidationError {
reason: "invalid format".to_string(),
},
AgpmError::LockfileParseError {
file: "agpm.lock".to_string(),
reason: "syntax error".to_string(),
},
AgpmError::ResourceFileNotFound {
path: "test.md".to_string(),
source_name: "source".to_string(),
},
AgpmError::DirectoryNotEmpty {
path: "/some/dir".to_string(),
},
AgpmError::InvalidDependency {
name: "dep".to_string(),
reason: "bad format".to_string(),
},
AgpmError::DependencyNotMet {
name: "dep".to_string(),
required: "v1.0".to_string(),
found: "v2.0".to_string(),
},
AgpmError::ConfigNotFound {
path: "/config/path".to_string(),
},
AgpmError::PlatformNotSupported {
operation: "test op".to_string(),
},
];
for error in errors {
let display = format!("{error}");
assert!(!display.is_empty());
}
}
#[test]
fn test_create_error_context_git_operations() {
let operations = vec![
("fetch", "internet connection"),
("checkout", "branch, tag"),
("pull", "git configuration"),
];
for (op, expected_text) in operations {
let ctx = create_error_context(&AgpmError::GitCommandError {
operation: op.to_string(),
stderr: "error".to_string(),
});
assert!(ctx.suggestion.is_some());
assert!(ctx.suggestion.unwrap().to_lowercase().contains(expected_text));
}
}
#[test]
fn test_create_error_context_resource_file_not_found() {
let ctx = create_error_context(&AgpmError::ResourceFileNotFound {
path: "agents/test.md".to_string(),
source_name: "official".to_string(),
});
assert!(ctx.suggestion.is_some());
let suggestion = ctx.suggestion.unwrap();
assert!(suggestion.contains("agents/test.md"));
assert!(suggestion.contains("official"));
assert!(ctx.details.is_some());
}
#[test]
fn test_create_error_context_manifest_parse_error() {
let ctx = create_error_context(&AgpmError::ManifestParseError {
file: "custom.toml".to_string(),
reason: "invalid syntax".to_string(),
});
assert!(ctx.suggestion.is_some());
let suggestion = ctx.suggestion.unwrap();
assert!(suggestion.contains("custom.toml"));
assert!(ctx.details.is_some());
}
#[test]
fn test_create_error_context_git_clone_failed() {
let ctx = create_error_context(&AgpmError::GitCloneFailed {
url: "https://example.com/repo.git".to_string(),
reason: "network error".to_string(),
});
assert!(ctx.suggestion.is_some());
let suggestion = ctx.suggestion.unwrap();
assert!(suggestion.contains("https://example.com/repo.git"));
assert!(ctx.details.is_some());
}
}