use colored::Colorize;
use std::fmt;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AgpmError {
#[error("Git operation failed: {operation}")]
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}")]
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("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("Network error: {operation}")]
NetworkError {
operation: String,
reason: String,
},
#[error("File system error: {operation}")]
FileSystemError {
operation: String,
path: String,
},
#[error("Permission denied: {operation}")]
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,
},
}
impl Clone for AgpmError {
fn clone(&self) -> Self {
match self {
Self::GitCommandError {
operation,
stderr,
} => Self::GitCommandError {
operation: operation.clone(),
stderr: stderr.clone(),
},
Self::GitNotFound => Self::GitNotFound,
Self::GitRepoInvalid {
path,
} => Self::GitRepoInvalid {
path: path.clone(),
},
Self::GitAuthenticationFailed {
url,
} => Self::GitAuthenticationFailed {
url: url.clone(),
},
Self::GitCloneFailed {
url,
reason,
} => Self::GitCloneFailed {
url: url.clone(),
reason: reason.clone(),
},
Self::GitCheckoutFailed {
reference,
reason,
} => Self::GitCheckoutFailed {
reference: reference.clone(),
reason: reason.clone(),
},
Self::ConfigError {
message,
} => Self::ConfigError {
message: message.clone(),
},
Self::ManifestNotFound => Self::ManifestNotFound,
Self::ManifestParseError {
file,
reason,
} => Self::ManifestParseError {
file: file.clone(),
reason: reason.clone(),
},
Self::ManifestValidationError {
reason,
} => Self::ManifestValidationError {
reason: reason.clone(),
},
Self::LockfileParseError {
file,
reason,
} => Self::LockfileParseError {
file: file.clone(),
reason: reason.clone(),
},
Self::ResourceNotFound {
name,
} => Self::ResourceNotFound {
name: name.clone(),
},
Self::ResourceFileNotFound {
path,
source_name,
} => Self::ResourceFileNotFound {
path: path.clone(),
source_name: source_name.clone(),
},
Self::SourceNotFound {
name,
} => Self::SourceNotFound {
name: name.clone(),
},
Self::SourceUnreachable {
name,
url,
} => Self::SourceUnreachable {
name: name.clone(),
url: url.clone(),
},
Self::InvalidVersionConstraint {
constraint,
} => Self::InvalidVersionConstraint {
constraint: constraint.clone(),
},
Self::VersionNotFound {
resource,
version,
} => Self::VersionNotFound {
resource: resource.clone(),
version: version.clone(),
},
Self::AlreadyInstalled {
name,
} => Self::AlreadyInstalled {
name: name.clone(),
},
Self::InvalidResourceType {
resource_type,
} => Self::InvalidResourceType {
resource_type: resource_type.clone(),
},
Self::InvalidResourceStructure {
file,
reason,
} => Self::InvalidResourceStructure {
file: file.clone(),
reason: reason.clone(),
},
Self::CircularDependency {
chain,
} => Self::CircularDependency {
chain: chain.clone(),
},
Self::DependencyResolutionFailed {
reason,
} => Self::DependencyResolutionFailed {
reason: reason.clone(),
},
Self::NetworkError {
operation,
reason,
} => Self::NetworkError {
operation: operation.clone(),
reason: reason.clone(),
},
Self::FileSystemError {
operation,
path,
} => Self::FileSystemError {
operation: operation.clone(),
path: path.clone(),
},
Self::PermissionDenied {
operation,
path,
} => Self::PermissionDenied {
operation: operation.clone(),
path: path.clone(),
},
Self::DirectoryNotEmpty {
path,
} => Self::DirectoryNotEmpty {
path: path.clone(),
},
Self::InvalidDependency {
name,
reason,
} => Self::InvalidDependency {
name: name.clone(),
reason: reason.clone(),
},
Self::InvalidResource {
name,
reason,
} => Self::InvalidResource {
name: name.clone(),
reason: reason.clone(),
},
Self::DependencyNotMet {
name,
required,
found,
} => Self::DependencyNotMet {
name: name.clone(),
required: required.clone(),
found: found.clone(),
},
Self::ConfigNotFound {
path,
} => Self::ConfigNotFound {
path: path.clone(),
},
Self::ChecksumMismatch {
name,
expected,
actual,
} => Self::ChecksumMismatch {
name: name.clone(),
expected: expected.clone(),
actual: actual.clone(),
},
Self::PlatformNotSupported {
operation,
} => Self::PlatformNotSupported {
operation: operation.clone(),
},
Self::IoError(e) => Self::Other {
message: format!("IO error: {e}"),
},
Self::TomlError(e) => Self::Other {
message: format!("TOML parsing error: {e}"),
},
Self::TomlSerError(e) => Self::Other {
message: format!("TOML serialization error: {e}"),
},
Self::SemverError(e) => Self::Other {
message: format!("Semver parsing error: {e}"),
},
Self::Other {
message,
} => Self::Other {
message: message.clone(),
},
}
}
}
#[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,
}
}
}
#[must_use]
pub fn user_friendly_error(error: anyhow::Error) -> ErrorContext {
if let Some(ccmp_error) = error.downcast_ref::<AgpmError>() {
return create_error_context(ccmp_error.clone());
}
if let Some(io_error) = error.downcast_ref::<std::io::Error>() {
let extracted_path = error
.chain()
.find_map(|e| {
let msg = e.to_string();
if let Some(idx) = msg.find(": /") {
let path_part = &msg[idx + 2..]; let end_idx = path_part.find(" (").unwrap_or(path_part.len());
let mut path = path_part[..end_idx].to_string();
path = path.replace("//", "/").replace("/./", "/");
Some(path)
} else if let Some(idx) = msg.find(": ./") {
let path_part = &msg[idx + 2..];
let end_idx = path_part.find(" (").unwrap_or(path_part.len());
let mut path = path_part[..end_idx].to_string();
path = path.replace("//", "/").replace("/./", "/");
Some(path)
} else if let Some(idx) = msg.find(": ../") {
let path_part = &msg[idx + 2..];
let end_idx = path_part.find(" (").unwrap_or(path_part.len());
let mut path = path_part[..end_idx].to_string();
path = path.replace("//", "/");
Some(path)
} else {
None
}
})
.unwrap_or_else(|| "unknown".to_string());
match io_error.kind() {
std::io::ErrorKind::PermissionDenied => {
return ErrorContext::new(AgpmError::PermissionDenied {
operation: "file access".to_string(),
path: extracted_path,
})
.with_suggestion("Try running with elevated permissions (sudo/Administrator) or check file ownership")
.with_details("This error occurs when AGPM doesn't have permission to read or write files");
}
std::io::ErrorKind::NotFound => {
return ErrorContext::new(AgpmError::FileSystemError {
operation: "file access".to_string(),
path: extracted_path.clone(),
})
.with_suggestion(format!(
"Check that the file '{}' exists and the path is correct",
extracted_path
))
.with_details(
"This error occurs when a required file or directory cannot be found",
);
}
std::io::ErrorKind::AlreadyExists => {
return ErrorContext::new(AgpmError::FileSystemError {
operation: "file creation".to_string(),
path: extracted_path,
})
.with_suggestion("Remove the existing file or use --force to overwrite")
.with_details("The target file or directory already exists");
}
std::io::ErrorKind::InvalidData => {
return ErrorContext::new(AgpmError::InvalidResource {
name: extracted_path,
reason: "invalid file format".to_string(),
})
.with_suggestion("Check the file format and ensure it's a valid resource file")
.with_details("The file contains invalid or corrupted data");
}
_ => {}
}
}
if let Some(toml_error) = error.downcast_ref::<toml::de::Error>() {
return ErrorContext::new(AgpmError::ManifestParseError {
file: "agpm.toml".to_string(),
reason: toml_error.to_string(),
})
.with_suggestion("Check the TOML syntax in your agpm.toml file. Verify quotes, brackets, and indentation")
.with_details("TOML parsing errors are usually caused by syntax issues like missing quotes or mismatched brackets");
}
let error_msg = error.to_string().to_lowercase();
let is_template_error = error_msg.contains("template")
|| error_msg.contains("variable")
|| error_msg.contains("filter")
|| error_msg.contains("tera");
if is_template_error {
let resource_name = error
.chain()
.find_map(|e| {
let msg = e.to_string();
if msg.contains("Failed to render template for") {
msg.split("'").nth(1).map(|s| s.to_string())
} else {
None
}
})
.unwrap_or_else(|| "unknown resource".to_string());
let mut message = error.to_string();
let chain: Vec<String> =
error.chain().skip(1).map(std::string::ToString::to_string).collect();
if !chain.is_empty() {
message.push_str("\n\nCaused by:");
for (i, cause) in chain.iter().enumerate() {
message.push_str(&format!("\n {}: {}", i + 1, cause));
}
}
return ErrorContext::new(AgpmError::InvalidResource {
name: resource_name,
reason: message,
})
.with_suggestion(
"Check template syntax: variables use {{ var }}, comments use {# #}, control flow uses {% %}. \
Ensure all variables referenced in the template exist in the context (agpm.resource, agpm.deps)",
)
.with_details(
"Template errors occur when Tera cannot render the template. Common issues:\n\
- Undefined variables (use {% if var is defined %} to check)\n\
- Syntax errors (unclosed {{ or {% delimiters)\n\
- Invalid filters or functions\n\
- Type mismatches in operations",
);
}
let mut message = error.to_string();
let chain: Vec<String> = error
.chain()
.skip(1) .map(std::string::ToString::to_string)
.collect();
if !chain.is_empty() {
message.push_str("\n\nCaused by:");
for (i, cause) in chain.iter().enumerate() {
message.push_str(&format!("\n {}: {}", i + 1, cause));
}
}
ErrorContext::new(AgpmError::Other {
message,
})
}
fn create_error_context(error: AgpmError) -> ErrorContext {
match &error {
AgpmError::GitNotFound => ErrorContext::new(AgpmError::GitNotFound)
.with_suggestion("Install git from https://git-scm.com/ or your package manager (e.g., 'brew install git', 'apt install git')")
.with_details("AGPM requires git to be installed and available in your PATH to manage repositories"),
AgpmError::GitCommandError { operation, stderr } => {
ErrorContext::new(AgpmError::GitCommandError {
operation: operation.clone(),
stderr: stderr.clone(),
})
.with_suggestion(match operation.as_str() {
op if op.contains("clone") => "Check the repository URL and your internet connection. Verify you have access to the repository",
op if op.contains("fetch") => "Check your internet connection and repository access. Try 'git fetch' manually in the repository directory",
op if op.contains("checkout") => "Verify the branch, tag, or commit exists. Use 'git tag -l' or 'git branch -r' to list available references",
op if op.contains("worktree") => {
if stderr.contains("invalid reference")
|| stderr.contains("not a valid object name")
|| stderr.contains("pathspec")
|| stderr.contains("did not match")
|| stderr.contains("unknown revision") {
"Invalid version: The specified version/tag/branch does not exist in the repository. Check available versions with 'git tag -l' or 'git branch -r'"
} else {
"Failed to create worktree. Check that the reference exists and the repository is valid"
}
},
_ => "Check your git configuration and repository access. Try running the git command manually for more details",
})
.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")) {
"Invalid version specification: Failed to checkout reference - the specified version/tag/branch does not exist"
} else {
"Git operations failed. This is often due to network issues, authentication problems, or invalid references"
})
}
AgpmError::GitAuthenticationFailed { url } => ErrorContext::new(AgpmError::GitAuthenticationFailed {
url: url.clone(),
})
.with_suggestion("Configure git authentication: use 'git config --global user.name' and 'git config --global user.email', or set up SSH keys")
.with_details("Authentication is required for private repositories. You may need to log in with 'git credential-manager-core' or similar"),
AgpmError::GitCloneFailed { url, reason } => ErrorContext::new(AgpmError::GitCloneFailed {
url: url.clone(),
reason: reason.clone(),
})
.with_suggestion(format!(
"Verify the repository URL is correct: {url}. Check your internet connection and repository access"
))
.with_details("Clone operations can fail due to invalid URLs, network issues, or access restrictions"),
AgpmError::ManifestNotFound => ErrorContext::new(AgpmError::ManifestNotFound)
.with_suggestion("Create a agpm.toml file in your project directory. See documentation for the manifest format")
.with_details("AGPM looks for agpm.toml in the current directory and parent directories up to the filesystem root"),
AgpmError::ManifestParseError { file, reason } => ErrorContext::new(AgpmError::ManifestParseError {
file: file.clone(),
reason: reason.clone(),
})
.with_suggestion(format!(
"Check the TOML syntax in {file}. Common issues: missing quotes, unmatched brackets, invalid characters"
))
.with_details("Use a TOML validator or check the agpm documentation for correct manifest format"),
AgpmError::SourceNotFound { name } => ErrorContext::new(AgpmError::SourceNotFound {
name: name.clone(),
})
.with_suggestion(format!(
"Add source '{name}' to the [sources] section in agpm.toml with the repository URL"
))
.with_details("All dependencies must reference a source defined in the [sources] section"),
AgpmError::ResourceFileNotFound { path, source_name } => ErrorContext::new(AgpmError::ResourceFileNotFound {
path: path.clone(),
source_name: source_name.clone(),
})
.with_suggestion(format!(
"Verify the file '{path}' exists in the '{source_name}' repository at the specified version/commit"
))
.with_details("The resource file may have been moved, renamed, or deleted in the repository"),
AgpmError::VersionNotFound { resource, version } => ErrorContext::new(AgpmError::VersionNotFound {
resource: resource.clone(),
version: version.clone(),
})
.with_suggestion(format!(
"Check available versions for '{resource}' using 'git tag -l' in the repository, or use 'main' or 'master' branch"
))
.with_details(format!(
"The version '{version}' doesn't exist as a git tag, branch, or commit in the repository"
)),
AgpmError::CircularDependency { chain } => ErrorContext::new(AgpmError::CircularDependency {
chain: chain.clone(),
})
.with_suggestion("Review your dependency graph and remove circular references")
.with_details(format!(
"Circular dependency chain detected: {chain}. Dependencies cannot depend on themselves directly or indirectly"
)),
AgpmError::PermissionDenied { operation, path } => ErrorContext::new(AgpmError::PermissionDenied {
operation: operation.clone(),
path: path.clone(),
})
.with_suggestion(match cfg!(windows) {
true => "Run as Administrator or check file permissions in File Explorer",
false => "Use 'sudo' or check file permissions with 'ls -la'",
})
.with_details(format!(
"Cannot {operation} due to insufficient permissions on {path}"
)),
AgpmError::ChecksumMismatch { name, expected, actual } => ErrorContext::new(AgpmError::ChecksumMismatch {
name: name.clone(),
expected: expected.clone(),
actual: actual.clone(),
})
.with_suggestion("The file may have been corrupted or modified. Try reinstalling with --force")
.with_details(format!(
"Resource '{name}' has checksum {actual} but expected {expected}. This indicates file corruption or tampering"
)),
_ => ErrorContext::new(error.clone()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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");
}
#[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"));
assert!(display.contains("Install git"));
}
#[test]
fn test_user_friendly_error_permission_denied() {
use std::io::{Error, ErrorKind};
let io_error = Error::new(ErrorKind::PermissionDenied, "access denied");
let anyhow_error = anyhow::Error::from(io_error);
let ctx = user_friendly_error(anyhow_error);
match ctx.error {
AgpmError::PermissionDenied {
..
} => {}
_ => panic!("Expected PermissionDenied error"),
}
assert!(ctx.suggestion.is_some());
assert!(ctx.details.is_some());
}
#[test]
fn test_user_friendly_error_not_found_with_path() {
use std::io::{Error, ErrorKind};
let io_error = Error::new(ErrorKind::NotFound, "file not found");
let anyhow_error =
anyhow::Error::from(io_error).context("Failed to read local file: /path/to/missing.md");
let ctx = user_friendly_error(anyhow_error);
match &ctx.error {
AgpmError::FileSystemError {
path,
..
} => {
assert_eq!(
path, "/path/to/missing.md",
"Path should be extracted from error context"
);
}
_ => panic!("Expected FileSystemError, got: {:?}", ctx.error),
}
assert!(ctx.suggestion.is_some());
assert!(ctx.suggestion.as_ref().unwrap().contains("/path/to/missing.md"));
}
#[test]
fn test_user_friendly_error_not_found_with_malformed_path() {
use std::io::{Error, ErrorKind};
let io_error = Error::new(ErrorKind::NotFound, "file not found");
let anyhow_error =
anyhow::Error::from(io_error).context("Failed to read: //Users/test/./foo/./bar.md");
let ctx = user_friendly_error(anyhow_error);
match &ctx.error {
AgpmError::FileSystemError {
path,
..
} => {
assert_eq!(
path, "/Users/test/foo/bar.md",
"Path should be normalized (double slashes and ./ removed)"
);
}
_ => panic!("Expected FileSystemError, got: {:?}", ctx.error),
}
assert!(ctx.suggestion.as_ref().unwrap().contains("/Users/test/foo/bar.md"));
}
#[test]
fn test_user_friendly_error_not_found() {
use std::io::{Error, ErrorKind};
let io_error = Error::new(ErrorKind::NotFound, "file not found");
let anyhow_error = anyhow::Error::from(io_error);
let ctx = user_friendly_error(anyhow_error);
match ctx.error {
AgpmError::FileSystemError {
..
} => {}
_ => panic!("Expected FileSystemError"),
}
assert!(ctx.suggestion.is_some());
assert!(ctx.details.is_some());
}
#[test]
fn test_from_io_error() {
use std::io::Error;
let io_error = Error::other("test error");
let agpm_error = AgpmError::from(io_error);
match agpm_error {
AgpmError::IoError(_) => {}
_ => panic!("Expected IoError"),
}
}
#[test]
fn test_from_toml_error() {
let toml_str = "invalid = toml {";
let result: Result<toml::Value, _> = toml::from_str(toml_str);
if let Err(e) = result {
let agpm_error = AgpmError::from(e);
match agpm_error {
AgpmError::TomlError(_) => {}
_ => panic!("Expected TomlError"),
}
}
}
#[test]
fn test_create_error_context_git_not_found() {
let ctx = create_error_context(AgpmError::GitNotFound);
assert!(ctx.suggestion.is_some());
assert!(ctx.suggestion.unwrap().contains("Install git"));
assert!(ctx.details.is_some());
}
#[test]
fn test_create_error_context_git_command_error() {
let ctx = create_error_context(AgpmError::GitCommandError {
operation: "clone".to_string(),
stderr: "error".to_string(),
});
assert!(ctx.suggestion.is_some());
assert!(ctx.suggestion.unwrap().contains("repository URL"));
assert!(ctx.details.is_some());
}
#[test]
fn test_create_error_context_git_auth_failed() {
let ctx = create_error_context(AgpmError::GitAuthenticationFailed {
url: "https://github.com/test/repo".to_string(),
});
assert!(ctx.suggestion.is_some());
assert!(ctx.suggestion.unwrap().contains("Configure git authentication"));
assert!(ctx.details.is_some());
}
#[test]
fn test_create_error_context_manifest_not_found() {
let ctx = create_error_context(AgpmError::ManifestNotFound);
assert!(ctx.suggestion.is_some());
assert!(ctx.suggestion.unwrap().contains("Create a agpm.toml"));
assert!(ctx.details.is_some());
}
#[test]
fn test_create_error_context_source_not_found() {
let ctx = create_error_context(AgpmError::SourceNotFound {
name: "test-source".to_string(),
});
assert!(ctx.suggestion.is_some());
assert!(ctx.suggestion.unwrap().contains("test-source"));
assert!(ctx.details.is_some());
}
#[test]
fn test_create_error_context_version_not_found() {
let ctx = create_error_context(AgpmError::VersionNotFound {
resource: "test-resource".to_string(),
version: "v1.0.0".to_string(),
});
assert!(ctx.suggestion.is_some());
assert!(ctx.suggestion.unwrap().contains("test-resource"));
assert!(ctx.details.is_some());
assert!(ctx.details.unwrap().contains("v1.0.0"));
}
#[test]
fn test_create_error_context_circular_dependency() {
let ctx = create_error_context(AgpmError::CircularDependency {
chain: "a -> b -> c -> a".to_string(),
});
assert!(ctx.suggestion.is_some());
assert!(ctx.suggestion.unwrap().contains("remove circular"));
assert!(ctx.details.is_some());
assert!(ctx.details.unwrap().contains("a -> b -> c -> a"));
}
#[test]
fn test_create_error_context_permission_denied() {
let ctx = create_error_context(AgpmError::PermissionDenied {
operation: "write".to_string(),
path: "/test/path".to_string(),
});
assert!(ctx.suggestion.is_some());
assert!(ctx.details.is_some());
assert!(ctx.details.unwrap().contains("/test/path"));
}
#[test]
fn test_create_error_context_checksum_mismatch() {
let ctx = create_error_context(AgpmError::ChecksumMismatch {
name: "test-resource".to_string(),
expected: "abc123".to_string(),
actual: "def456".to_string(),
});
assert!(ctx.suggestion.is_some());
assert!(ctx.suggestion.unwrap().contains("reinstalling"));
assert!(ctx.details.is_some());
assert!(ctx.details.unwrap().contains("abc123"));
}
#[test]
fn test_error_clone() {
let error1 = AgpmError::GitNotFound;
let error2 = error1.clone();
assert_eq!(error1.to_string(), error2.to_string());
let error1 = AgpmError::ResourceNotFound {
name: "test".to_string(),
};
let error2 = error1.clone();
assert_eq!(error1.to_string(), error2.to_string());
}
#[test]
fn test_error_context_suggestion() {
let ctx = ErrorContext::suggestion("Test suggestion");
assert_eq!(ctx.suggestion, Some("Test suggestion".to_string()));
assert!(ctx.details.is_none());
}
#[test]
fn test_into_anyhow_with_context() {
let error = AgpmError::GitNotFound;
let context = ErrorContext::new(AgpmError::Other {
message: "dummy".to_string(),
})
.with_suggestion("Test suggestion")
.with_details("Test details");
let anyhow_error = error.into_anyhow_with_context(context);
let display = format!("{anyhow_error}");
assert!(display.contains("Git is not installed"));
}
#[test]
fn test_user_friendly_error_already_exists() {
use std::io::{Error, ErrorKind};
let io_error = Error::new(ErrorKind::AlreadyExists, "file exists");
let anyhow_error = anyhow::Error::from(io_error);
let ctx = user_friendly_error(anyhow_error);
match ctx.error {
AgpmError::FileSystemError {
..
} => {}
_ => panic!("Expected FileSystemError"),
}
assert!(ctx.suggestion.is_some());
assert!(ctx.suggestion.unwrap().contains("overwrite"));
}
#[test]
fn test_user_friendly_error_invalid_data() {
use std::io::{Error, ErrorKind};
let io_error = Error::new(ErrorKind::InvalidData, "corrupt data");
let anyhow_error = anyhow::Error::from(io_error);
let ctx = user_friendly_error(anyhow_error);
match ctx.error {
AgpmError::InvalidResource {
..
} => {}
_ => panic!("Expected InvalidResource"),
}
assert!(ctx.suggestion.is_some());
assert!(ctx.details.is_some());
}
#[test]
fn test_user_friendly_error_template_with_resource_name() {
let template_error = anyhow::anyhow!("Variable `foo` not found in context");
let error_with_context = template_error
.context("Failed to render template for 'my-awesome-agent' (source: community, path: agents/awesome.md)");
let ctx = user_friendly_error(error_with_context);
match &ctx.error {
AgpmError::InvalidResource {
name,
reason,
} => {
assert_eq!(
name, "my-awesome-agent",
"Resource name should be extracted from error context"
);
assert!(reason.contains("Variable"), "Reason should contain the actual error");
}
_ => panic!("Expected InvalidResource, got {:?}", ctx.error),
}
assert!(ctx.suggestion.is_some());
assert!(ctx.details.is_some());
}
#[test]
fn test_user_friendly_error_template_without_resource_name() {
let template_error = anyhow::anyhow!("Variable `bar` not found in context");
let ctx = user_friendly_error(template_error);
match &ctx.error {
AgpmError::InvalidResource {
name,
reason,
} => {
assert_eq!(
name, "unknown resource",
"Should use fallback when resource name unavailable"
);
assert!(reason.contains("Variable"), "Reason should contain the actual error");
}
_ => panic!("Expected InvalidResource, got {:?}", ctx.error),
}
}
#[test]
fn test_user_friendly_error_agpm_error() {
let error = AgpmError::GitNotFound;
let anyhow_error = anyhow::Error::from(error);
let ctx = user_friendly_error(anyhow_error);
match ctx.error {
AgpmError::GitNotFound => {}
_ => panic!("Expected GitNotFound"),
}
assert!(ctx.suggestion.is_some());
}
#[test]
fn test_user_friendly_error_toml_parse() {
let toml_str = "invalid = toml {";
let result: Result<toml::Value, _> = toml::from_str(toml_str);
if let Err(e) = result {
let anyhow_error = anyhow::Error::from(e);
let ctx = user_friendly_error(anyhow_error);
match ctx.error {
AgpmError::ManifestParseError {
..
} => {}
_ => panic!("Expected ManifestParseError"),
}
assert!(ctx.suggestion.is_some());
assert!(ctx.suggestion.unwrap().contains("TOML syntax"));
}
}
#[test]
fn test_user_friendly_error_generic() {
let error = anyhow::anyhow!("Generic error");
let ctx = user_friendly_error(error);
match ctx.error {
AgpmError::Other {
message,
} => {
assert_eq!(message, "Generic error");
}
_ => panic!("Expected Other error"),
}
}
#[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());
}
}