1use colored::Colorize;
96use std::fmt;
97use thiserror::Error;
98
99#[derive(Error, Debug)]
245pub enum AgpmError {
246 #[error("Git operation failed: {operation}")]
256 GitCommandError {
257 operation: String,
259 stderr: String,
261 },
262
263 #[error("Git is not installed or not found in PATH")]
273 GitNotFound,
274
275 #[error("Not a valid git repository: {path}")]
283 GitRepoInvalid {
284 path: String,
286 },
287
288 #[error("Git authentication failed for repository: {url}")]
296 GitAuthenticationFailed {
297 url: String,
299 },
300
301 #[error("Failed to clone repository: {url}")]
303 GitCloneFailed {
304 url: String,
306 reason: String,
308 },
309
310 #[error("Failed to checkout reference '{reference}' in repository")]
312 GitCheckoutFailed {
313 reference: String,
315 reason: String,
317 },
318
319 #[error("Configuration error: {message}")]
321 ConfigError {
322 message: String,
324 },
325
326 #[error("Manifest file agpm.toml not found in current directory or any parent directory")]
334 ManifestNotFound,
335
336 #[error("Invalid manifest file syntax in {file}")]
338 ManifestParseError {
339 file: String,
341 reason: String,
343 },
344
345 #[error("Manifest validation failed: {reason}")]
347 ManifestValidationError {
348 reason: String,
350 },
351
352 #[error("Invalid lockfile syntax in {file}")]
354 LockfileParseError {
355 file: String,
357 reason: String,
359 },
360
361 #[error("Resource '{name}' not found")]
363 ResourceNotFound {
364 name: String,
366 },
367
368 #[error("Resource file '{path}' not found in source '{source_name}'")]
370 ResourceFileNotFound {
371 path: String,
373 source_name: String,
375 },
376
377 #[error("Source repository '{name}' not defined in manifest")]
379 SourceNotFound {
380 name: String,
382 },
383
384 #[error("Cannot reach source repository '{name}' at {url}")]
386 SourceUnreachable {
387 name: String,
389 url: String,
391 },
392
393 #[error("Invalid version constraint: {constraint}")]
395 InvalidVersionConstraint {
396 constraint: String,
398 },
399
400 #[error("Version '{version}' not found for resource '{resource}'")]
402 VersionNotFound {
403 resource: String,
405 version: String,
407 },
408
409 #[error("Resource '{name}' is already installed")]
411 AlreadyInstalled {
412 name: String,
414 },
415
416 #[error("Invalid resource type: {resource_type}")]
418 InvalidResourceType {
419 resource_type: String,
421 },
422
423 #[error("Invalid resource structure in '{file}': {reason}")]
425 InvalidResourceStructure {
426 file: String,
428 reason: String,
430 },
431
432 #[error("Circular dependency detected: {chain}")]
442 CircularDependency {
443 chain: String,
445 },
446
447 #[error("Cannot resolve dependencies: {reason}")]
449 DependencyResolutionFailed {
450 reason: String,
452 },
453
454 #[error("Network error: {operation}")]
456 NetworkError {
457 operation: String,
459 reason: String,
461 },
462
463 #[error("File system error: {operation}")]
465 FileSystemError {
466 operation: String,
468 path: String,
470 },
471
472 #[error("Permission denied: {operation}")]
474 PermissionDenied {
475 operation: String,
477 path: String,
479 },
480
481 #[error("Directory is not empty: {path}")]
483 DirectoryNotEmpty {
484 path: String,
486 },
487
488 #[error("Invalid dependency specification for '{name}': {reason}")]
490 InvalidDependency {
491 name: String,
493 reason: String,
495 },
496
497 #[error("Invalid resource content in '{name}': {reason}")]
499 InvalidResource {
500 name: String,
502 reason: String,
504 },
505
506 #[error("Dependency '{name}' requires version {required}, but {found} was found")]
508 DependencyNotMet {
509 name: String,
511 required: String,
513 found: String,
515 },
516
517 #[error("Configuration file not found: {path}")]
519 ConfigNotFound {
520 path: String,
522 },
523
524 #[error("Checksum mismatch for resource '{name}': expected {expected}, got {actual}")]
526 ChecksumMismatch {
527 name: String,
529 expected: String,
531 actual: String,
533 },
534
535 #[error("Operation not supported on this platform: {operation}")]
537 PlatformNotSupported {
538 operation: String,
540 },
541
542 #[error("IO error: {0}")]
544 IoError(#[from] std::io::Error),
545
546 #[error("TOML parsing error: {0}")]
548 TomlError(#[from] toml::de::Error),
549
550 #[error("TOML serialization error: {0}")]
552 TomlSerError(#[from] toml::ser::Error),
553
554 #[error("Semver parsing error: {0}")]
556 SemverError(#[from] semver::Error),
557
558 #[error("{message}")]
560 Other {
561 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 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#[derive(Debug)]
840pub struct ErrorContext {
841 pub error: AgpmError,
843 pub suggestion: Option<String>,
845 pub details: Option<String>,
847}
848
849impl ErrorContext {
850 #[must_use]
867 pub const fn new(error: AgpmError) -> Self {
868 Self {
869 error,
870 suggestion: None,
871 details: None,
872 }
873 }
874
875 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
889 self.suggestion = Some(suggestion.into());
890 self
891 }
892
893 pub fn with_details(mut self, details: impl Into<String>) -> Self {
908 self.details = Some(details.into());
909 self
910 }
911
912 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
964pub trait IntoAnyhowWithContext {
981 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 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#[must_use]
1072pub fn user_friendly_error(error: anyhow::Error) -> ErrorContext {
1073 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 let extracted_path = error
1081 .chain()
1082 .find_map(|e| {
1083 let msg = e.to_string();
1084 if let Some(idx) = msg.find(": /") {
1090 let path_part = &msg[idx + 2..]; let end_idx = path_part.find(" (").unwrap_or(path_part.len());
1094 let mut path = path_part[..end_idx].to_string();
1095 path = path.replace("//", "/").replace("/./", "/");
1097 Some(path)
1098 } else if let Some(idx) = msg.find(": ./") {
1099 let path_part = &msg[idx + 2..];
1101 let end_idx = path_part.find(" (").unwrap_or(path_part.len());
1102 let mut path = path_part[..end_idx].to_string();
1103 path = path.replace("//", "/").replace("/./", "/");
1105 Some(path)
1106 } else if let Some(idx) = msg.find(": ../") {
1107 let path_part = &msg[idx + 2..];
1109 let end_idx = path_part.find(" (").unwrap_or(path_part.len());
1110 let mut path = path_part[..end_idx].to_string();
1111 path = path.replace("//", "/");
1113 Some(path)
1114 } else {
1115 None
1116 }
1117 })
1118 .unwrap_or_else(|| "unknown".to_string());
1119
1120 match io_error.kind() {
1121 std::io::ErrorKind::PermissionDenied => {
1122 return ErrorContext::new(AgpmError::PermissionDenied {
1123 operation: "file access".to_string(),
1124 path: extracted_path,
1125 })
1126 .with_suggestion("Try running with elevated permissions (sudo/Administrator) or check file ownership")
1127 .with_details("This error occurs when AGPM doesn't have permission to read or write files");
1128 }
1129 std::io::ErrorKind::NotFound => {
1130 return ErrorContext::new(AgpmError::FileSystemError {
1131 operation: "file access".to_string(),
1132 path: extracted_path.clone(),
1133 })
1134 .with_suggestion(format!(
1135 "Check that the file '{}' exists and the path is correct",
1136 extracted_path
1137 ))
1138 .with_details(
1139 "This error occurs when a required file or directory cannot be found",
1140 );
1141 }
1142 std::io::ErrorKind::AlreadyExists => {
1143 return ErrorContext::new(AgpmError::FileSystemError {
1144 operation: "file creation".to_string(),
1145 path: extracted_path,
1146 })
1147 .with_suggestion("Remove the existing file or use --force to overwrite")
1148 .with_details("The target file or directory already exists");
1149 }
1150 std::io::ErrorKind::InvalidData => {
1151 return ErrorContext::new(AgpmError::InvalidResource {
1152 name: extracted_path,
1153 reason: "invalid file format".to_string(),
1154 })
1155 .with_suggestion("Check the file format and ensure it's a valid resource file")
1156 .with_details("The file contains invalid or corrupted data");
1157 }
1158 _ => {}
1159 }
1160 }
1161
1162 if let Some(toml_error) = error.downcast_ref::<toml::de::Error>() {
1163 return ErrorContext::new(AgpmError::ManifestParseError {
1164 file: "agpm.toml".to_string(),
1165 reason: toml_error.to_string(),
1166 })
1167 .with_suggestion("Check the TOML syntax in your agpm.toml file. Verify quotes, brackets, and indentation")
1168 .with_details("TOML parsing errors are usually caused by syntax issues like missing quotes or mismatched brackets");
1169 }
1170
1171 let error_msg = error.to_string().to_lowercase();
1173 let is_template_error = error_msg.contains("template")
1174 || error_msg.contains("variable")
1175 || error_msg.contains("filter")
1176 || error_msg.contains("tera");
1177
1178 if is_template_error {
1179 let resource_name = error
1182 .chain()
1183 .find_map(|e| {
1184 let msg = e.to_string();
1185 if msg.contains("Failed to render template for") {
1186 msg.split("'").nth(1).map(|s| s.to_string())
1188 } else {
1189 None
1190 }
1191 })
1192 .unwrap_or_else(|| "unknown resource".to_string());
1193
1194 let mut message = error.to_string();
1196 let chain: Vec<String> =
1197 error.chain().skip(1).map(std::string::ToString::to_string).collect();
1198
1199 if !chain.is_empty() {
1200 message.push_str("\n\nCaused by:");
1201 for (i, cause) in chain.iter().enumerate() {
1202 message.push_str(&format!("\n {}: {}", i + 1, cause));
1203 }
1204 }
1205
1206 return ErrorContext::new(AgpmError::InvalidResource {
1207 name: resource_name,
1208 reason: message,
1209 })
1210 .with_suggestion(
1211 "Check template syntax: variables use {{ var }}, comments use {# #}, control flow uses {% %}. \
1212 Ensure all variables referenced in the template exist in the context (agpm.resource, agpm.deps)",
1213 )
1214 .with_details(
1215 "Template errors occur when Tera cannot render the template. Common issues:\n\
1216 - Undefined variables (use {% if var is defined %} to check)\n\
1217 - Syntax errors (unclosed {{ or {% delimiters)\n\
1218 - Invalid filters or functions\n\
1219 - Type mismatches in operations",
1220 );
1221 }
1222
1223 let mut message = error.to_string();
1225
1226 let chain: Vec<String> = error
1228 .chain()
1229 .skip(1) .map(std::string::ToString::to_string)
1231 .collect();
1232
1233 if !chain.is_empty() {
1234 message.push_str("\n\nCaused by:");
1235 for (i, cause) in chain.iter().enumerate() {
1236 message.push_str(&format!("\n {}: {}", i + 1, cause));
1237 }
1238 }
1239
1240 ErrorContext::new(AgpmError::Other {
1241 message,
1242 })
1243}
1244
1245fn create_error_context(error: AgpmError) -> ErrorContext {
1258 match &error {
1259 AgpmError::GitNotFound => ErrorContext::new(AgpmError::GitNotFound)
1260 .with_suggestion("Install git from https://git-scm.com/ or your package manager (e.g., 'brew install git', 'apt install git')")
1261 .with_details("AGPM requires git to be installed and available in your PATH to manage repositories"),
1262
1263 AgpmError::GitCommandError { operation, stderr } => {
1264 ErrorContext::new(AgpmError::GitCommandError {
1265 operation: operation.clone(),
1266 stderr: stderr.clone(),
1267 })
1268 .with_suggestion(match operation.as_str() {
1269 op if op.contains("clone") => "Check the repository URL and your internet connection. Verify you have access to the repository",
1270 op if op.contains("fetch") => "Check your internet connection and repository access. Try 'git fetch' manually in the repository directory",
1271 op if op.contains("checkout") => "Verify the branch, tag, or commit exists. Use 'git tag -l' or 'git branch -r' to list available references",
1272 op if op.contains("worktree") => {
1273 if stderr.contains("invalid reference")
1274 || stderr.contains("not a valid object name")
1275 || stderr.contains("pathspec")
1276 || stderr.contains("did not match")
1277 || stderr.contains("unknown revision") {
1278 "Invalid version: The specified version/tag/branch does not exist in the repository. Check available versions with 'git tag -l' or 'git branch -r'"
1279 } else {
1280 "Failed to create worktree. Check that the reference exists and the repository is valid"
1281 }
1282 },
1283 _ => "Check your git configuration and repository access. Try running the git command manually for more details",
1284 })
1285 .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")) {
1286 "Invalid version specification: Failed to checkout reference - the specified version/tag/branch does not exist"
1287 } else {
1288 "Git operations failed. This is often due to network issues, authentication problems, or invalid references"
1289 })
1290 }
1291
1292 AgpmError::GitAuthenticationFailed { url } => ErrorContext::new(AgpmError::GitAuthenticationFailed {
1293 url: url.clone(),
1294 })
1295 .with_suggestion("Configure git authentication: use 'git config --global user.name' and 'git config --global user.email', or set up SSH keys")
1296 .with_details("Authentication is required for private repositories. You may need to log in with 'git credential-manager-core' or similar"),
1297
1298 AgpmError::GitCloneFailed { url, reason } => ErrorContext::new(AgpmError::GitCloneFailed {
1299 url: url.clone(),
1300 reason: reason.clone(),
1301 })
1302 .with_suggestion(format!(
1303 "Verify the repository URL is correct: {url}. Check your internet connection and repository access"
1304 ))
1305 .with_details("Clone operations can fail due to invalid URLs, network issues, or access restrictions"),
1306
1307 AgpmError::ManifestNotFound => ErrorContext::new(AgpmError::ManifestNotFound)
1308 .with_suggestion("Create a agpm.toml file in your project directory. See documentation for the manifest format")
1309 .with_details("AGPM looks for agpm.toml in the current directory and parent directories up to the filesystem root"),
1310
1311 AgpmError::ManifestParseError { file, reason } => ErrorContext::new(AgpmError::ManifestParseError {
1312 file: file.clone(),
1313 reason: reason.clone(),
1314 })
1315 .with_suggestion(format!(
1316 "Check the TOML syntax in {file}. Common issues: missing quotes, unmatched brackets, invalid characters"
1317 ))
1318 .with_details("Use a TOML validator or check the agpm documentation for correct manifest format"),
1319
1320 AgpmError::SourceNotFound { name } => ErrorContext::new(AgpmError::SourceNotFound {
1321 name: name.clone(),
1322 })
1323 .with_suggestion(format!(
1324 "Add source '{name}' to the [sources] section in agpm.toml with the repository URL"
1325 ))
1326 .with_details("All dependencies must reference a source defined in the [sources] section"),
1327
1328 AgpmError::ResourceFileNotFound { path, source_name } => ErrorContext::new(AgpmError::ResourceFileNotFound {
1329 path: path.clone(),
1330 source_name: source_name.clone(),
1331 })
1332 .with_suggestion(format!(
1333 "Verify the file '{path}' exists in the '{source_name}' repository at the specified version/commit"
1334 ))
1335 .with_details("The resource file may have been moved, renamed, or deleted in the repository"),
1336
1337 AgpmError::VersionNotFound { resource, version } => ErrorContext::new(AgpmError::VersionNotFound {
1338 resource: resource.clone(),
1339 version: version.clone(),
1340 })
1341 .with_suggestion(format!(
1342 "Check available versions for '{resource}' using 'git tag -l' in the repository, or use 'main' or 'master' branch"
1343 ))
1344 .with_details(format!(
1345 "The version '{version}' doesn't exist as a git tag, branch, or commit in the repository"
1346 )),
1347
1348 AgpmError::CircularDependency { chain } => ErrorContext::new(AgpmError::CircularDependency {
1349 chain: chain.clone(),
1350 })
1351 .with_suggestion("Review your dependency graph and remove circular references")
1352 .with_details(format!(
1353 "Circular dependency chain detected: {chain}. Dependencies cannot depend on themselves directly or indirectly"
1354 )),
1355
1356 AgpmError::PermissionDenied { operation, path } => ErrorContext::new(AgpmError::PermissionDenied {
1357 operation: operation.clone(),
1358 path: path.clone(),
1359 })
1360 .with_suggestion(match cfg!(windows) {
1361 true => "Run as Administrator or check file permissions in File Explorer",
1362 false => "Use 'sudo' or check file permissions with 'ls -la'",
1363 })
1364 .with_details(format!(
1365 "Cannot {operation} due to insufficient permissions on {path}"
1366 )),
1367
1368 AgpmError::ChecksumMismatch { name, expected, actual } => ErrorContext::new(AgpmError::ChecksumMismatch {
1369 name: name.clone(),
1370 expected: expected.clone(),
1371 actual: actual.clone(),
1372 })
1373 .with_suggestion("The file may have been corrupted or modified. Try reinstalling with --force")
1374 .with_details(format!(
1375 "Resource '{name}' has checksum {actual} but expected {expected}. This indicates file corruption or tampering"
1376 )),
1377
1378 _ => ErrorContext::new(error.clone()),
1379 }
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384 use super::*;
1385
1386 #[test]
1387 fn test_error_display() {
1388 let error = AgpmError::GitNotFound;
1389 assert_eq!(error.to_string(), "Git is not installed or not found in PATH");
1390
1391 let error = AgpmError::ResourceNotFound {
1392 name: "test".to_string(),
1393 };
1394 assert_eq!(error.to_string(), "Resource 'test' not found");
1395
1396 let error = AgpmError::InvalidVersionConstraint {
1397 constraint: "bad-version".to_string(),
1398 };
1399 assert_eq!(error.to_string(), "Invalid version constraint: bad-version");
1400
1401 let error = AgpmError::GitCommandError {
1402 operation: "clone".to_string(),
1403 stderr: "repository not found".to_string(),
1404 };
1405 assert_eq!(error.to_string(), "Git operation failed: clone");
1406 }
1407
1408 #[test]
1409 fn test_error_context() {
1410 let ctx = ErrorContext::new(AgpmError::GitNotFound)
1411 .with_suggestion("Install git using your package manager")
1412 .with_details("Git is required for AGPM to function");
1413
1414 assert_eq!(ctx.suggestion, Some("Install git using your package manager".to_string()));
1415 assert_eq!(ctx.details, Some("Git is required for AGPM to function".to_string()));
1416 }
1417
1418 #[test]
1419 fn test_error_context_display() {
1420 let ctx = ErrorContext::new(AgpmError::GitNotFound).with_suggestion("Install git");
1421
1422 let display = format!("{ctx}");
1423 assert!(display.contains("Git is not installed or not found in PATH"));
1424 assert!(display.contains("Install git"));
1425 }
1426
1427 #[test]
1428 fn test_user_friendly_error_permission_denied() {
1429 use std::io::{Error, ErrorKind};
1430
1431 let io_error = Error::new(ErrorKind::PermissionDenied, "access denied");
1432 let anyhow_error = anyhow::Error::from(io_error);
1433
1434 let ctx = user_friendly_error(anyhow_error);
1435 match ctx.error {
1436 AgpmError::PermissionDenied {
1437 ..
1438 } => {}
1439 _ => panic!("Expected PermissionDenied error"),
1440 }
1441 assert!(ctx.suggestion.is_some());
1442 assert!(ctx.details.is_some());
1443 }
1444
1445 #[test]
1446 fn test_user_friendly_error_not_found_with_path() {
1447 use std::io::{Error, ErrorKind};
1448
1449 let io_error = Error::new(ErrorKind::NotFound, "file not found");
1450 let anyhow_error =
1451 anyhow::Error::from(io_error).context("Failed to read local file: /path/to/missing.md");
1452
1453 let ctx = user_friendly_error(anyhow_error);
1454 match &ctx.error {
1455 AgpmError::FileSystemError {
1456 path,
1457 ..
1458 } => {
1459 assert_eq!(
1460 path, "/path/to/missing.md",
1461 "Path should be extracted from error context"
1462 );
1463 }
1464 _ => panic!("Expected FileSystemError, got: {:?}", ctx.error),
1465 }
1466 assert!(ctx.suggestion.is_some());
1467 assert!(ctx.suggestion.as_ref().unwrap().contains("/path/to/missing.md"));
1468 }
1469
1470 #[test]
1471 fn test_user_friendly_error_not_found_with_malformed_path() {
1472 use std::io::{Error, ErrorKind};
1473
1474 let io_error = Error::new(ErrorKind::NotFound, "file not found");
1476 let anyhow_error =
1477 anyhow::Error::from(io_error).context("Failed to read: //Users/test/./foo/./bar.md");
1478
1479 let ctx = user_friendly_error(anyhow_error);
1480 match &ctx.error {
1481 AgpmError::FileSystemError {
1482 path,
1483 ..
1484 } => {
1485 assert_eq!(
1486 path, "/Users/test/foo/bar.md",
1487 "Path should be normalized (double slashes and ./ removed)"
1488 );
1489 }
1490 _ => panic!("Expected FileSystemError, got: {:?}", ctx.error),
1491 }
1492 assert!(ctx.suggestion.as_ref().unwrap().contains("/Users/test/foo/bar.md"));
1493 }
1494
1495 #[test]
1496 fn test_user_friendly_error_not_found() {
1497 use std::io::{Error, ErrorKind};
1498
1499 let io_error = Error::new(ErrorKind::NotFound, "file not found");
1500 let anyhow_error = anyhow::Error::from(io_error);
1501
1502 let ctx = user_friendly_error(anyhow_error);
1503 match ctx.error {
1504 AgpmError::FileSystemError {
1505 ..
1506 } => {}
1507 _ => panic!("Expected FileSystemError"),
1508 }
1509 assert!(ctx.suggestion.is_some());
1510 assert!(ctx.details.is_some());
1511 }
1512
1513 #[test]
1514 fn test_from_io_error() {
1515 use std::io::Error;
1516
1517 let io_error = Error::other("test error");
1518 let agpm_error = AgpmError::from(io_error);
1519
1520 match agpm_error {
1521 AgpmError::IoError(_) => {}
1522 _ => panic!("Expected IoError"),
1523 }
1524 }
1525
1526 #[test]
1527 fn test_from_toml_error() {
1528 let toml_str = "invalid = toml {";
1529 let result: Result<toml::Value, _> = toml::from_str(toml_str);
1530
1531 if let Err(e) = result {
1532 let agpm_error = AgpmError::from(e);
1533 match agpm_error {
1534 AgpmError::TomlError(_) => {}
1535 _ => panic!("Expected TomlError"),
1536 }
1537 }
1538 }
1539
1540 #[test]
1541 fn test_create_error_context_git_not_found() {
1542 let ctx = create_error_context(AgpmError::GitNotFound);
1543 assert!(ctx.suggestion.is_some());
1544 assert!(ctx.suggestion.unwrap().contains("Install git"));
1545 assert!(ctx.details.is_some());
1546 }
1547
1548 #[test]
1549 fn test_create_error_context_git_command_error() {
1550 let ctx = create_error_context(AgpmError::GitCommandError {
1551 operation: "clone".to_string(),
1552 stderr: "error".to_string(),
1553 });
1554 assert!(ctx.suggestion.is_some());
1555 assert!(ctx.suggestion.unwrap().contains("repository URL"));
1556 assert!(ctx.details.is_some());
1557 }
1558
1559 #[test]
1560 fn test_create_error_context_git_auth_failed() {
1561 let ctx = create_error_context(AgpmError::GitAuthenticationFailed {
1562 url: "https://github.com/test/repo".to_string(),
1563 });
1564 assert!(ctx.suggestion.is_some());
1565 assert!(ctx.suggestion.unwrap().contains("Configure git authentication"));
1566 assert!(ctx.details.is_some());
1567 }
1568
1569 #[test]
1570 fn test_create_error_context_manifest_not_found() {
1571 let ctx = create_error_context(AgpmError::ManifestNotFound);
1572 assert!(ctx.suggestion.is_some());
1573 assert!(ctx.suggestion.unwrap().contains("Create a agpm.toml"));
1574 assert!(ctx.details.is_some());
1575 }
1576
1577 #[test]
1578 fn test_create_error_context_source_not_found() {
1579 let ctx = create_error_context(AgpmError::SourceNotFound {
1580 name: "test-source".to_string(),
1581 });
1582 assert!(ctx.suggestion.is_some());
1583 assert!(ctx.suggestion.unwrap().contains("test-source"));
1584 assert!(ctx.details.is_some());
1585 }
1586
1587 #[test]
1588 fn test_create_error_context_version_not_found() {
1589 let ctx = create_error_context(AgpmError::VersionNotFound {
1590 resource: "test-resource".to_string(),
1591 version: "v1.0.0".to_string(),
1592 });
1593 assert!(ctx.suggestion.is_some());
1594 assert!(ctx.suggestion.unwrap().contains("test-resource"));
1595 assert!(ctx.details.is_some());
1596 assert!(ctx.details.unwrap().contains("v1.0.0"));
1597 }
1598
1599 #[test]
1600 fn test_create_error_context_circular_dependency() {
1601 let ctx = create_error_context(AgpmError::CircularDependency {
1602 chain: "a -> b -> c -> a".to_string(),
1603 });
1604 assert!(ctx.suggestion.is_some());
1605 assert!(ctx.suggestion.unwrap().contains("remove circular"));
1606 assert!(ctx.details.is_some());
1607 assert!(ctx.details.unwrap().contains("a -> b -> c -> a"));
1608 }
1609
1610 #[test]
1611 fn test_create_error_context_permission_denied() {
1612 let ctx = create_error_context(AgpmError::PermissionDenied {
1613 operation: "write".to_string(),
1614 path: "/test/path".to_string(),
1615 });
1616 assert!(ctx.suggestion.is_some());
1617 assert!(ctx.details.is_some());
1618 assert!(ctx.details.unwrap().contains("/test/path"));
1619 }
1620
1621 #[test]
1622 fn test_create_error_context_checksum_mismatch() {
1623 let ctx = create_error_context(AgpmError::ChecksumMismatch {
1624 name: "test-resource".to_string(),
1625 expected: "abc123".to_string(),
1626 actual: "def456".to_string(),
1627 });
1628 assert!(ctx.suggestion.is_some());
1629 assert!(ctx.suggestion.unwrap().contains("reinstalling"));
1630 assert!(ctx.details.is_some());
1631 assert!(ctx.details.unwrap().contains("abc123"));
1632 }
1633
1634 #[test]
1635 fn test_error_clone() {
1636 let error1 = AgpmError::GitNotFound;
1637 let error2 = error1.clone();
1638 assert_eq!(error1.to_string(), error2.to_string());
1639
1640 let error1 = AgpmError::ResourceNotFound {
1641 name: "test".to_string(),
1642 };
1643 let error2 = error1.clone();
1644 assert_eq!(error1.to_string(), error2.to_string());
1645 }
1646
1647 #[test]
1648 fn test_error_context_suggestion() {
1649 let ctx = ErrorContext::suggestion("Test suggestion");
1650 assert_eq!(ctx.suggestion, Some("Test suggestion".to_string()));
1651 assert!(ctx.details.is_none());
1652 }
1653
1654 #[test]
1655 fn test_into_anyhow_with_context() {
1656 let error = AgpmError::GitNotFound;
1657 let context = ErrorContext::new(AgpmError::Other {
1658 message: "dummy".to_string(),
1659 })
1660 .with_suggestion("Test suggestion")
1661 .with_details("Test details");
1662
1663 let anyhow_error = error.into_anyhow_with_context(context);
1664 let display = format!("{anyhow_error}");
1665 assert!(display.contains("Git is not installed"));
1666 }
1667
1668 #[test]
1669 fn test_user_friendly_error_already_exists() {
1670 use std::io::{Error, ErrorKind};
1671
1672 let io_error = Error::new(ErrorKind::AlreadyExists, "file exists");
1673 let anyhow_error = anyhow::Error::from(io_error);
1674
1675 let ctx = user_friendly_error(anyhow_error);
1676 match ctx.error {
1677 AgpmError::FileSystemError {
1678 ..
1679 } => {}
1680 _ => panic!("Expected FileSystemError"),
1681 }
1682 assert!(ctx.suggestion.is_some());
1683 assert!(ctx.suggestion.unwrap().contains("overwrite"));
1684 }
1685
1686 #[test]
1687 fn test_user_friendly_error_invalid_data() {
1688 use std::io::{Error, ErrorKind};
1689
1690 let io_error = Error::new(ErrorKind::InvalidData, "corrupt data");
1691 let anyhow_error = anyhow::Error::from(io_error);
1692
1693 let ctx = user_friendly_error(anyhow_error);
1694 match ctx.error {
1695 AgpmError::InvalidResource {
1696 ..
1697 } => {}
1698 _ => panic!("Expected InvalidResource"),
1699 }
1700 assert!(ctx.suggestion.is_some());
1701 assert!(ctx.details.is_some());
1702 }
1703
1704 #[test]
1705 fn test_user_friendly_error_template_with_resource_name() {
1706 let template_error = anyhow::anyhow!("Variable `foo` not found in context");
1708 let error_with_context = template_error
1709 .context("Failed to render template for 'my-awesome-agent' (source: community, path: agents/awesome.md)");
1710
1711 let ctx = user_friendly_error(error_with_context);
1712
1713 match &ctx.error {
1714 AgpmError::InvalidResource {
1715 name,
1716 reason,
1717 } => {
1718 assert_eq!(
1720 name, "my-awesome-agent",
1721 "Resource name should be extracted from error context"
1722 );
1723 assert!(reason.contains("Variable"), "Reason should contain the actual error");
1724 }
1725 _ => panic!("Expected InvalidResource, got {:?}", ctx.error),
1726 }
1727 assert!(ctx.suggestion.is_some());
1728 assert!(ctx.details.is_some());
1729 }
1730
1731 #[test]
1732 fn test_user_friendly_error_template_without_resource_name() {
1733 let template_error = anyhow::anyhow!("Variable `bar` not found in context");
1735
1736 let ctx = user_friendly_error(template_error);
1737
1738 match &ctx.error {
1739 AgpmError::InvalidResource {
1740 name,
1741 reason,
1742 } => {
1743 assert_eq!(
1745 name, "unknown resource",
1746 "Should use fallback when resource name unavailable"
1747 );
1748 assert!(reason.contains("Variable"), "Reason should contain the actual error");
1749 }
1750 _ => panic!("Expected InvalidResource, got {:?}", ctx.error),
1751 }
1752 }
1753
1754 #[test]
1755 fn test_user_friendly_error_agpm_error() {
1756 let error = AgpmError::GitNotFound;
1757 let anyhow_error = anyhow::Error::from(error);
1758
1759 let ctx = user_friendly_error(anyhow_error);
1760 match ctx.error {
1761 AgpmError::GitNotFound => {}
1762 _ => panic!("Expected GitNotFound"),
1763 }
1764 assert!(ctx.suggestion.is_some());
1765 }
1766
1767 #[test]
1768 fn test_user_friendly_error_toml_parse() {
1769 let toml_str = "invalid = toml {";
1770 let result: Result<toml::Value, _> = toml::from_str(toml_str);
1771
1772 if let Err(e) = result {
1773 let anyhow_error = anyhow::Error::from(e);
1774 let ctx = user_friendly_error(anyhow_error);
1775
1776 match ctx.error {
1777 AgpmError::ManifestParseError {
1778 ..
1779 } => {}
1780 _ => panic!("Expected ManifestParseError"),
1781 }
1782 assert!(ctx.suggestion.is_some());
1783 assert!(ctx.suggestion.unwrap().contains("TOML syntax"));
1784 }
1785 }
1786
1787 #[test]
1788 fn test_user_friendly_error_generic() {
1789 let error = anyhow::anyhow!("Generic error");
1790 let ctx = user_friendly_error(error);
1791
1792 match ctx.error {
1793 AgpmError::Other {
1794 message,
1795 } => {
1796 assert_eq!(message, "Generic error");
1797 }
1798 _ => panic!("Expected Other error"),
1799 }
1800 }
1801
1802 #[test]
1803 fn test_from_semver_error() {
1804 let result = semver::Version::parse("invalid-version");
1805 if let Err(e) = result {
1806 let agpm_error = AgpmError::from(e);
1807 match agpm_error {
1808 AgpmError::SemverError(_) => {}
1809 _ => panic!("Expected SemverError"),
1810 }
1811 }
1812 }
1813
1814 #[test]
1815 fn test_error_display_all_variants() {
1816 let errors = vec![
1818 AgpmError::GitRepoInvalid {
1819 path: "/test/path".to_string(),
1820 },
1821 AgpmError::GitCheckoutFailed {
1822 reference: "main".to_string(),
1823 reason: "not found".to_string(),
1824 },
1825 AgpmError::ConfigError {
1826 message: "config issue".to_string(),
1827 },
1828 AgpmError::ManifestValidationError {
1829 reason: "invalid format".to_string(),
1830 },
1831 AgpmError::LockfileParseError {
1832 file: "agpm.lock".to_string(),
1833 reason: "syntax error".to_string(),
1834 },
1835 AgpmError::ResourceFileNotFound {
1836 path: "test.md".to_string(),
1837 source_name: "source".to_string(),
1838 },
1839 AgpmError::DirectoryNotEmpty {
1840 path: "/some/dir".to_string(),
1841 },
1842 AgpmError::InvalidDependency {
1843 name: "dep".to_string(),
1844 reason: "bad format".to_string(),
1845 },
1846 AgpmError::DependencyNotMet {
1847 name: "dep".to_string(),
1848 required: "v1.0".to_string(),
1849 found: "v2.0".to_string(),
1850 },
1851 AgpmError::ConfigNotFound {
1852 path: "/config/path".to_string(),
1853 },
1854 AgpmError::PlatformNotSupported {
1855 operation: "test op".to_string(),
1856 },
1857 ];
1858
1859 for error in errors {
1860 let display = format!("{error}");
1861 assert!(!display.is_empty());
1862 }
1863 }
1864
1865 #[test]
1866 fn test_create_error_context_git_operations() {
1867 let operations = vec![
1869 ("fetch", "internet connection"),
1870 ("checkout", "branch, tag"),
1871 ("pull", "git configuration"),
1872 ];
1873
1874 for (op, expected_text) in operations {
1875 let ctx = create_error_context(AgpmError::GitCommandError {
1876 operation: op.to_string(),
1877 stderr: "error".to_string(),
1878 });
1879 assert!(ctx.suggestion.is_some());
1880 assert!(ctx.suggestion.unwrap().to_lowercase().contains(expected_text));
1881 }
1882 }
1883
1884 #[test]
1885 fn test_create_error_context_resource_file_not_found() {
1886 let ctx = create_error_context(AgpmError::ResourceFileNotFound {
1887 path: "agents/test.md".to_string(),
1888 source_name: "official".to_string(),
1889 });
1890 assert!(ctx.suggestion.is_some());
1891 let suggestion = ctx.suggestion.unwrap();
1892 assert!(suggestion.contains("agents/test.md"));
1893 assert!(suggestion.contains("official"));
1894 assert!(ctx.details.is_some());
1895 }
1896
1897 #[test]
1898 fn test_create_error_context_manifest_parse_error() {
1899 let ctx = create_error_context(AgpmError::ManifestParseError {
1900 file: "custom.toml".to_string(),
1901 reason: "invalid syntax".to_string(),
1902 });
1903 assert!(ctx.suggestion.is_some());
1904 let suggestion = ctx.suggestion.unwrap();
1905 assert!(suggestion.contains("custom.toml"));
1906 assert!(ctx.details.is_some());
1907 }
1908
1909 #[test]
1910 fn test_create_error_context_git_clone_failed() {
1911 let ctx = create_error_context(AgpmError::GitCloneFailed {
1912 url: "https://example.com/repo.git".to_string(),
1913 reason: "network error".to_string(),
1914 });
1915 assert!(ctx.suggestion.is_some());
1916 let suggestion = ctx.suggestion.unwrap();
1917 assert!(suggestion.contains("https://example.com/repo.git"));
1918 assert!(ctx.details.is_some());
1919 }
1920}