1use colored::Colorize;
96use std::fmt;
97use thiserror::Error;
98
99#[derive(Error, Debug)]
247pub enum AgpmError {
248 #[error("Git operation failed: {operation}")]
258 GitCommandError {
259 operation: String,
261 stderr: String,
263 },
264
265 #[error("Git is not installed or not found in PATH")]
275 GitNotFound,
276
277 #[error("Not a valid git repository: {path}")]
285 GitRepoInvalid {
286 path: String,
288 },
289
290 #[error("Git authentication failed for repository: {url}")]
298 GitAuthenticationFailed {
299 url: String,
301 },
302
303 #[error("Failed to clone repository: {url}")]
305 GitCloneFailed {
306 url: String,
308 reason: String,
310 },
311
312 #[error("Failed to checkout reference '{reference}' in repository")]
314 GitCheckoutFailed {
315 reference: String,
317 reason: String,
319 },
320
321 #[error("Configuration error: {message}")]
323 ConfigError {
324 message: String,
326 },
327
328 #[error("Manifest file agpm.toml not found in current directory or any parent directory")]
336 ManifestNotFound,
337
338 #[error("Invalid manifest file syntax in {file}")]
340 ManifestParseError {
341 file: String,
343 reason: String,
345 },
346
347 #[error("Manifest validation failed: {reason}")]
349 ManifestValidationError {
350 reason: String,
352 },
353
354 #[error("Invalid lockfile syntax in {file}")]
356 LockfileParseError {
357 file: String,
359 reason: String,
361 },
362
363 #[error(
365 "Invalid or corrupted lockfile detected: {file}\n\n{reason}\n\nNote: The lockfile format is not yet stable as this is beta software."
366 )]
367 InvalidLockfileError {
368 file: String,
370 reason: String,
372 can_regenerate: bool,
374 },
375
376 #[error("Resource '{name}' not found")]
378 ResourceNotFound {
379 name: String,
381 },
382
383 #[error("Resource file '{path}' not found in source '{source_name}'")]
385 ResourceFileNotFound {
386 path: String,
388 source_name: String,
390 },
391
392 #[error("Source repository '{name}' not defined in manifest")]
394 SourceNotFound {
395 name: String,
397 },
398
399 #[error("Cannot reach source repository '{name}' at {url}")]
401 SourceUnreachable {
402 name: String,
404 url: String,
406 },
407
408 #[error("Invalid version constraint: {constraint}")]
410 InvalidVersionConstraint {
411 constraint: String,
413 },
414
415 #[error("Version '{version}' not found for resource '{resource}'")]
417 VersionNotFound {
418 resource: String,
420 version: String,
422 },
423
424 #[error("Resource '{name}' is already installed")]
426 AlreadyInstalled {
427 name: String,
429 },
430
431 #[error("Invalid resource type: {resource_type}")]
433 InvalidResourceType {
434 resource_type: String,
436 },
437
438 #[error("Invalid resource structure in '{file}': {reason}")]
440 InvalidResourceStructure {
441 file: String,
443 reason: String,
445 },
446
447 #[error("Circular dependency detected: {chain}")]
457 CircularDependency {
458 chain: String,
460 },
461
462 #[error("Cannot resolve dependencies: {reason}")]
464 DependencyResolutionFailed {
465 reason: String,
467 },
468
469 #[error("Network error: {operation}")]
471 NetworkError {
472 operation: String,
474 reason: String,
476 },
477
478 #[error("File system error: {operation}: {path}")]
480 FileSystemError {
481 operation: String,
483 path: String,
485 },
486
487 #[error("Permission denied: {operation}: {path}")]
489 PermissionDenied {
490 operation: String,
492 path: String,
494 },
495
496 #[error("Directory is not empty: {path}")]
498 DirectoryNotEmpty {
499 path: String,
501 },
502
503 #[error("Invalid dependency specification for '{name}': {reason}")]
505 InvalidDependency {
506 name: String,
508 reason: String,
510 },
511
512 #[error("Invalid resource content in '{name}': {reason}")]
514 InvalidResource {
515 name: String,
517 reason: String,
519 },
520
521 #[error("Dependency '{name}' requires version {required}, but {found} was found")]
523 DependencyNotMet {
524 name: String,
526 required: String,
528 found: String,
530 },
531
532 #[error("Configuration file not found: {path}")]
534 ConfigNotFound {
535 path: String,
537 },
538
539 #[error("Checksum mismatch for resource '{name}': expected {expected}, got {actual}")]
541 ChecksumMismatch {
542 name: String,
544 expected: String,
546 actual: String,
548 },
549
550 #[error("Operation not supported on this platform: {operation}")]
552 PlatformNotSupported {
553 operation: String,
555 },
556
557 #[error("IO error: {0}")]
559 IoError(#[from] std::io::Error),
560
561 #[error("TOML parsing error: {0}")]
563 TomlError(#[from] toml::de::Error),
564
565 #[error("TOML serialization error: {0}")]
567 TomlSerError(#[from] toml::ser::Error),
568
569 #[error("Semver parsing error: {0}")]
571 SemverError(#[from] semver::Error),
572
573 #[error("{message}")]
575 Other {
576 message: String,
578 },
579}
580
581impl Clone for AgpmError {
582 fn clone(&self) -> Self {
583 match self {
584 Self::GitCommandError {
585 operation,
586 stderr,
587 } => Self::GitCommandError {
588 operation: operation.clone(),
589 stderr: stderr.clone(),
590 },
591 Self::GitNotFound => Self::GitNotFound,
592 Self::GitRepoInvalid {
593 path,
594 } => Self::GitRepoInvalid {
595 path: path.clone(),
596 },
597 Self::GitAuthenticationFailed {
598 url,
599 } => Self::GitAuthenticationFailed {
600 url: url.clone(),
601 },
602 Self::GitCloneFailed {
603 url,
604 reason,
605 } => Self::GitCloneFailed {
606 url: url.clone(),
607 reason: reason.clone(),
608 },
609 Self::GitCheckoutFailed {
610 reference,
611 reason,
612 } => Self::GitCheckoutFailed {
613 reference: reference.clone(),
614 reason: reason.clone(),
615 },
616 Self::ConfigError {
617 message,
618 } => Self::ConfigError {
619 message: message.clone(),
620 },
621 Self::ManifestNotFound => Self::ManifestNotFound,
622 Self::ManifestParseError {
623 file,
624 reason,
625 } => Self::ManifestParseError {
626 file: file.clone(),
627 reason: reason.clone(),
628 },
629 Self::ManifestValidationError {
630 reason,
631 } => Self::ManifestValidationError {
632 reason: reason.clone(),
633 },
634 Self::LockfileParseError {
635 file,
636 reason,
637 } => Self::LockfileParseError {
638 file: file.clone(),
639 reason: reason.clone(),
640 },
641 Self::InvalidLockfileError {
642 file,
643 reason,
644 can_regenerate,
645 } => Self::InvalidLockfileError {
646 file: file.clone(),
647 reason: reason.clone(),
648 can_regenerate: *can_regenerate,
649 },
650 Self::ResourceNotFound {
651 name,
652 } => Self::ResourceNotFound {
653 name: name.clone(),
654 },
655 Self::ResourceFileNotFound {
656 path,
657 source_name,
658 } => Self::ResourceFileNotFound {
659 path: path.clone(),
660 source_name: source_name.clone(),
661 },
662 Self::SourceNotFound {
663 name,
664 } => Self::SourceNotFound {
665 name: name.clone(),
666 },
667 Self::SourceUnreachable {
668 name,
669 url,
670 } => Self::SourceUnreachable {
671 name: name.clone(),
672 url: url.clone(),
673 },
674 Self::InvalidVersionConstraint {
675 constraint,
676 } => Self::InvalidVersionConstraint {
677 constraint: constraint.clone(),
678 },
679 Self::VersionNotFound {
680 resource,
681 version,
682 } => Self::VersionNotFound {
683 resource: resource.clone(),
684 version: version.clone(),
685 },
686 Self::AlreadyInstalled {
687 name,
688 } => Self::AlreadyInstalled {
689 name: name.clone(),
690 },
691 Self::InvalidResourceType {
692 resource_type,
693 } => Self::InvalidResourceType {
694 resource_type: resource_type.clone(),
695 },
696 Self::InvalidResourceStructure {
697 file,
698 reason,
699 } => Self::InvalidResourceStructure {
700 file: file.clone(),
701 reason: reason.clone(),
702 },
703 Self::CircularDependency {
704 chain,
705 } => Self::CircularDependency {
706 chain: chain.clone(),
707 },
708 Self::DependencyResolutionFailed {
709 reason,
710 } => Self::DependencyResolutionFailed {
711 reason: reason.clone(),
712 },
713 Self::NetworkError {
714 operation,
715 reason,
716 } => Self::NetworkError {
717 operation: operation.clone(),
718 reason: reason.clone(),
719 },
720 Self::FileSystemError {
721 operation,
722 path,
723 } => Self::FileSystemError {
724 operation: operation.clone(),
725 path: path.clone(),
726 },
727 Self::PermissionDenied {
728 operation,
729 path,
730 } => Self::PermissionDenied {
731 operation: operation.clone(),
732 path: path.clone(),
733 },
734 Self::DirectoryNotEmpty {
735 path,
736 } => Self::DirectoryNotEmpty {
737 path: path.clone(),
738 },
739 Self::InvalidDependency {
740 name,
741 reason,
742 } => Self::InvalidDependency {
743 name: name.clone(),
744 reason: reason.clone(),
745 },
746 Self::InvalidResource {
747 name,
748 reason,
749 } => Self::InvalidResource {
750 name: name.clone(),
751 reason: reason.clone(),
752 },
753 Self::DependencyNotMet {
754 name,
755 required,
756 found,
757 } => Self::DependencyNotMet {
758 name: name.clone(),
759 required: required.clone(),
760 found: found.clone(),
761 },
762 Self::ConfigNotFound {
763 path,
764 } => Self::ConfigNotFound {
765 path: path.clone(),
766 },
767 Self::ChecksumMismatch {
768 name,
769 expected,
770 actual,
771 } => Self::ChecksumMismatch {
772 name: name.clone(),
773 expected: expected.clone(),
774 actual: actual.clone(),
775 },
776 Self::PlatformNotSupported {
777 operation,
778 } => Self::PlatformNotSupported {
779 operation: operation.clone(),
780 },
781 Self::IoError(e) => Self::Other {
783 message: format!("IO error: {e}"),
784 },
785 Self::TomlError(e) => Self::Other {
786 message: format!("TOML parsing error: {e}"),
787 },
788 Self::TomlSerError(e) => Self::Other {
789 message: format!("TOML serialization error: {e}"),
790 },
791 Self::SemverError(e) => Self::Other {
792 message: format!("Semver parsing error: {e}"),
793 },
794 Self::Other {
795 message,
796 } => Self::Other {
797 message: message.clone(),
798 },
799 }
800 }
801}
802
803#[derive(Debug)]
864pub struct ErrorContext {
865 pub error: AgpmError,
867 pub suggestion: Option<String>,
869 pub details: Option<String>,
871}
872
873impl ErrorContext {
874 #[must_use]
891 pub const fn new(error: AgpmError) -> Self {
892 Self {
893 error,
894 suggestion: None,
895 details: None,
896 }
897 }
898
899 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
913 self.suggestion = Some(suggestion.into());
914 self
915 }
916
917 pub fn with_details(mut self, details: impl Into<String>) -> Self {
932 self.details = Some(details.into());
933 self
934 }
935
936 pub fn display(&self) {
958 eprintln!("{}: {}", "error".red().bold(), self.error);
959
960 if let Some(details) = &self.details {
961 eprintln!("{}: {}", "details".yellow(), details);
962 }
963
964 if let Some(suggestion) = &self.suggestion {
965 eprintln!("{}: {}", "suggestion".green(), suggestion);
966 }
967 }
968}
969
970impl fmt::Display for ErrorContext {
971 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
972 write!(f, "{}", self.error)?;
973
974 if let Some(details) = &self.details {
975 write!(f, "\nDetails: {details}")?;
976 }
977
978 if let Some(suggestion) = &self.suggestion {
979 write!(f, "\nSuggestion: {suggestion}")?;
980 }
981
982 Ok(())
983 }
984}
985
986impl std::error::Error for ErrorContext {}
987
988pub trait IntoAnyhowWithContext {
1005 fn into_anyhow_with_context(self, context: ErrorContext) -> anyhow::Error;
1007}
1008
1009impl IntoAnyhowWithContext for AgpmError {
1010 fn into_anyhow_with_context(self, context: ErrorContext) -> anyhow::Error {
1011 anyhow::Error::new(ErrorContext {
1012 error: self,
1013 suggestion: context.suggestion,
1014 details: context.details,
1015 })
1016 }
1017}
1018
1019impl ErrorContext {
1020 pub fn suggestion(suggestion: impl Into<String>) -> Self {
1034 Self {
1035 error: AgpmError::Other {
1036 message: String::new(),
1037 },
1038 suggestion: Some(suggestion.into()),
1039 details: None,
1040 }
1041 }
1042}
1043
1044#[derive(Debug, Clone)]
1117struct ParsedEnhancedContext {
1118 canonical_name: String,
1119 manifest_alias: Option<String>,
1120 source: Option<String>,
1121 tool: Option<String>,
1122 resolved_commit: Option<String>,
1123 required_by: Option<String>,
1124}
1125
1126fn parse_enhanced_context(msg: &str) -> ParsedEnhancedContext {
1127 let extract_field = |field_name: &str| -> Option<String> {
1128 let pattern = format!("{}=\"", field_name);
1129 if let Some(start_idx) = msg.find(&pattern) {
1130 let value_start = start_idx + pattern.len();
1131 if let Some(end_idx) = msg[value_start..].find('"') {
1132 return Some(msg[value_start..value_start + end_idx].to_string());
1133 }
1134 }
1135 None
1136 };
1137
1138 let canonical_name = extract_field("canonical_name").unwrap_or_else(|| "unknown".to_string());
1139 let manifest_alias = extract_field("manifest_alias");
1140 let source = extract_field("source");
1141 let tool = extract_field("tool");
1142 let resolved_commit = extract_field("resolved_commit");
1143 let required_by = extract_field("required_by");
1144
1145 ParsedEnhancedContext {
1146 canonical_name,
1147 manifest_alias,
1148 source,
1149 tool,
1150 resolved_commit,
1151 required_by,
1152 }
1153}
1154
1155#[must_use]
1156pub fn user_friendly_error(error: anyhow::Error) -> ErrorContext {
1157 if let Some(ccmp_error) = error.downcast_ref::<AgpmError>() {
1159 return create_error_context(ccmp_error.clone());
1160 }
1161
1162 if let Some(io_error) = error.downcast_ref::<std::io::Error>() {
1163 let extracted_path = error
1165 .chain()
1166 .find_map(|e| {
1167 let msg = e.to_string();
1168 if let Some(idx) = msg.find(": /") {
1174 let path_part = &msg[idx + 2..]; let end_idx = path_part.find(" (").unwrap_or(path_part.len());
1178 let mut path = path_part[..end_idx].to_string();
1179 path = path.replace("//", "/").replace("/./", "/");
1181 Some(path)
1182 } else if let Some(idx) = msg.find(": ./") {
1183 let path_part = &msg[idx + 2..];
1185 let end_idx = path_part.find(" (").unwrap_or(path_part.len());
1186 let mut path = path_part[..end_idx].to_string();
1187 path = path.replace("//", "/").replace("/./", "/");
1189 Some(path)
1190 } else if let Some(idx) = msg.find(": ../") {
1191 let path_part = &msg[idx + 2..];
1193 let end_idx = path_part.find(" (").unwrap_or(path_part.len());
1194 let mut path = path_part[..end_idx].to_string();
1195 path = path.replace("//", "/");
1197 Some(path)
1198 } else {
1199 None
1200 }
1201 })
1202 .unwrap_or_else(|| "unknown".to_string());
1203
1204 match io_error.kind() {
1205 std::io::ErrorKind::PermissionDenied => {
1206 return create_error_context(AgpmError::PermissionDenied {
1207 operation: "file access".to_string(),
1208 path: extracted_path,
1209 });
1210 }
1211 std::io::ErrorKind::NotFound => {
1212 return create_error_context(AgpmError::FileSystemError {
1213 operation: "file access".to_string(),
1214 path: extracted_path,
1215 });
1216 }
1217 std::io::ErrorKind::AlreadyExists => {
1218 return create_error_context(AgpmError::FileSystemError {
1219 operation: "file creation".to_string(),
1220 path: extracted_path,
1221 });
1222 }
1223 std::io::ErrorKind::InvalidData => {
1224 return ErrorContext::new(AgpmError::InvalidResource {
1225 name: extracted_path,
1226 reason: "invalid file format".to_string(),
1227 })
1228 .with_suggestion("Check the file format and ensure it's a valid resource file")
1229 .with_details("The file contains invalid or corrupted data");
1230 }
1231 _ => {}
1232 }
1233 }
1234
1235 if let Some(toml_error) = error.downcast_ref::<toml::de::Error>() {
1236 return ErrorContext::new(AgpmError::ManifestParseError {
1237 file: "agpm.toml".to_string(),
1238 reason: toml_error.to_string(),
1239 })
1240 .with_suggestion("Check the TOML syntax in your agpm.toml file. Verify quotes, brackets, and indentation")
1241 .with_details("TOML parsing errors are usually caused by syntax issues like missing quotes or mismatched brackets");
1242 }
1243
1244 let error_msg = error.to_string().to_lowercase();
1246 let is_template_error = error_msg.contains("template")
1247 || error_msg.contains("variable")
1248 || error_msg.contains("filter")
1249 || error_msg.contains("tera")
1250 || error_msg.contains("render"); if is_template_error {
1253 let context = error
1256 .chain()
1257 .find_map(|e| {
1258 let msg = e.to_string();
1259 if msg.contains("Failed to render") && msg.contains("canonical_name=") {
1260 Some(parse_enhanced_context(&msg))
1262 } else {
1263 None
1264 }
1265 })
1266 .unwrap_or_else(|| {
1267 let name = error
1269 .chain()
1270 .find_map(|e| {
1271 let msg = e.to_string();
1272 if msg.contains("Failed to render template for")
1273 || msg.contains("Failed to render frontmatter template in")
1274 {
1275 msg.split("'").nth(1).map(|s| s.to_string())
1276 } else {
1277 None
1278 }
1279 })
1280 .unwrap_or_else(|| "unknown resource".to_string());
1281 ParsedEnhancedContext {
1282 canonical_name: name,
1283 manifest_alias: None,
1284 source: None,
1285 tool: None,
1286 resolved_commit: None,
1287 required_by: None,
1288 }
1289 });
1290
1291 let tera_error_msg = error
1293 .chain()
1294 .last() .map(|e| e.to_string())
1296 .unwrap_or_else(|| "Unknown template error".to_string());
1297
1298 let mut message = String::new();
1300
1301 message.push_str(&tera_error_msg);
1303
1304 message.push_str(&format!("\n\n Resource: {}", context.canonical_name));
1306
1307 if let Some(alias) = context.manifest_alias.as_ref() {
1309 message.push_str(&format!("\n Manifest alias: {}", alias));
1310 }
1311
1312 if let Some(parents) = context.required_by.as_ref() {
1314 message.push_str(&format!("\n Required by: {}", parents));
1315 }
1316
1317 let mut context_parts = Vec::new();
1319 if let Some(s) = context.source.as_ref() {
1320 context_parts.push(format!("source: {}", s));
1321 }
1322 if let Some(t) = context.tool.as_ref() {
1323 context_parts.push(format!("tool: {}", t));
1324 }
1325 if let Some(c) = context.resolved_commit.as_ref() {
1326 context_parts.push(format!("commit: {}", c));
1327 }
1328 if !context_parts.is_empty() {
1329 message.push_str(&format!("\n Context: {}", context_parts.join(", ")));
1330 }
1331
1332 return ErrorContext::new(AgpmError::InvalidResource {
1333 name: context.canonical_name,
1334 reason: message,
1335 })
1336 .with_suggestion(
1337 "Check template syntax: variables use {{ var }}, comments use {# #}, control flow uses {% %}. \
1338 Ensure all variables referenced in the template exist in the context (agpm.resource, agpm.deps)",
1339 )
1340 .with_details(
1341 "Template errors occur when Tera cannot render the template. Common issues:\n\
1342 - Undefined variables (use {% if var is defined %} to check)\n\
1343 - Syntax errors (unclosed {{ or {% delimiters)\n\
1344 - Invalid filters or functions\n\
1345 - Type mismatches in operations",
1346 );
1347 }
1348
1349 let mut message = error.to_string();
1351
1352 let chain: Vec<String> = error
1354 .chain()
1355 .skip(1) .map(std::string::ToString::to_string)
1357 .collect();
1358
1359 if !chain.is_empty() {
1360 message.push_str("\n\nCaused by:");
1361 for (i, cause) in chain.iter().enumerate() {
1362 message.push_str(&format!("\n {}: {}", i + 1, cause));
1363 }
1364 }
1365
1366 ErrorContext::new(AgpmError::Other {
1367 message,
1368 })
1369}
1370
1371fn create_error_context(error: AgpmError) -> ErrorContext {
1384 match &error {
1385 AgpmError::GitNotFound => ErrorContext::new(AgpmError::GitNotFound)
1386 .with_suggestion("Install git from https://git-scm.com/ or your package manager (e.g., 'brew install git', 'apt install git')")
1387 .with_details("AGPM requires git to be installed and available in your PATH to manage repositories"),
1388
1389 AgpmError::GitCommandError { operation, stderr } => {
1390 ErrorContext::new(AgpmError::GitCommandError {
1391 operation: operation.clone(),
1392 stderr: stderr.clone(),
1393 })
1394 .with_suggestion(match operation.as_str() {
1395 op if op.contains("clone") => "Check the repository URL and your internet connection. Verify you have access to the repository",
1396 op if op.contains("fetch") => "Check your internet connection and repository access. Try 'git fetch' manually in the repository directory",
1397 op if op.contains("checkout") => "Verify the branch, tag, or commit exists. Use 'git tag -l' or 'git branch -r' to list available references",
1398 op if op.contains("worktree") => {
1399 if stderr.contains("invalid reference")
1400 || stderr.contains("not a valid object name")
1401 || stderr.contains("pathspec")
1402 || stderr.contains("did not match")
1403 || stderr.contains("unknown revision") {
1404 "Invalid version: The specified version/tag/branch does not exist in the repository. Check available versions with 'git tag -l' or 'git branch -r'"
1405 } else {
1406 "Failed to create worktree. Check that the reference exists and the repository is valid"
1407 }
1408 },
1409 _ => "Check your git configuration and repository access. Try running the git command manually for more details",
1410 })
1411 .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")) {
1412 "Invalid version specification: Failed to checkout reference - the specified version/tag/branch does not exist"
1413 } else {
1414 "Git operations failed. This is often due to network issues, authentication problems, or invalid references"
1415 })
1416 }
1417
1418 AgpmError::GitAuthenticationFailed { url } => ErrorContext::new(AgpmError::GitAuthenticationFailed {
1419 url: url.clone(),
1420 })
1421 .with_suggestion("Configure git authentication: use 'git config --global user.name' and 'git config --global user.email', or set up SSH keys")
1422 .with_details("Authentication is required for private repositories. You may need to log in with 'git credential-manager-core' or similar"),
1423
1424 AgpmError::GitCloneFailed { url, reason } => ErrorContext::new(AgpmError::GitCloneFailed {
1425 url: url.clone(),
1426 reason: reason.clone(),
1427 })
1428 .with_suggestion(format!(
1429 "Verify the repository URL is correct: {url}. Check your internet connection and repository access"
1430 ))
1431 .with_details("Clone operations can fail due to invalid URLs, network issues, or access restrictions"),
1432
1433 AgpmError::ManifestNotFound => ErrorContext::new(AgpmError::ManifestNotFound)
1434 .with_suggestion("Create a agpm.toml file in your project directory. See documentation for the manifest format")
1435 .with_details("AGPM looks for agpm.toml in the current directory and parent directories up to the filesystem root"),
1436
1437 AgpmError::ManifestParseError { file, reason } => ErrorContext::new(AgpmError::ManifestParseError {
1438 file: file.clone(),
1439 reason: reason.clone(),
1440 })
1441 .with_suggestion(format!(
1442 "Check the TOML syntax in {file}. Common issues: missing quotes, unmatched brackets, invalid characters"
1443 ))
1444 .with_details("Use a TOML validator or check the agpm documentation for correct manifest format"),
1445
1446 AgpmError::SourceNotFound { name } => ErrorContext::new(AgpmError::SourceNotFound {
1447 name: name.clone(),
1448 })
1449 .with_suggestion(format!(
1450 "Add source '{name}' to the [sources] section in agpm.toml with the repository URL"
1451 ))
1452 .with_details("All dependencies must reference a source defined in the [sources] section"),
1453
1454 AgpmError::ResourceFileNotFound { path, source_name } => ErrorContext::new(AgpmError::ResourceFileNotFound {
1455 path: path.clone(),
1456 source_name: source_name.clone(),
1457 })
1458 .with_suggestion(format!(
1459 "Verify the file '{path}' exists in the '{source_name}' repository at the specified version/commit"
1460 ))
1461 .with_details("The resource file may have been moved, renamed, or deleted in the repository"),
1462
1463 AgpmError::VersionNotFound { resource, version } => ErrorContext::new(AgpmError::VersionNotFound {
1464 resource: resource.clone(),
1465 version: version.clone(),
1466 })
1467 .with_suggestion(format!(
1468 "Check available versions for '{resource}' using 'git tag -l' in the repository, or use 'main' or 'master' branch"
1469 ))
1470 .with_details(format!(
1471 "The version '{version}' doesn't exist as a git tag, branch, or commit in the repository"
1472 )),
1473
1474 AgpmError::CircularDependency { chain } => ErrorContext::new(AgpmError::CircularDependency {
1475 chain: chain.clone(),
1476 })
1477 .with_suggestion("Review your dependency graph and remove circular references")
1478 .with_details(format!(
1479 "Circular dependency chain detected: {chain}. Dependencies cannot depend on themselves directly or indirectly"
1480 )),
1481
1482 AgpmError::PermissionDenied { operation, path } => ErrorContext::new(AgpmError::PermissionDenied {
1483 operation: operation.clone(),
1484 path: path.clone(),
1485 })
1486 .with_suggestion(match cfg!(windows) {
1487 true => "Run as Administrator or check file permissions in File Explorer",
1488 false => "Use 'sudo' or check file permissions with 'ls -la'",
1489 })
1490 .with_details(format!(
1491 "Cannot {operation} due to insufficient permissions on {path}"
1492 )),
1493
1494 AgpmError::ChecksumMismatch { name, expected, actual } => ErrorContext::new(AgpmError::ChecksumMismatch {
1495 name: name.clone(),
1496 expected: expected.clone(),
1497 actual: actual.clone(),
1498 })
1499 .with_suggestion("The file may have been corrupted or modified. Try reinstalling with --force")
1500 .with_details(format!(
1501 "Resource '{name}' has checksum {actual} but expected {expected}. This indicates file corruption or tampering"
1502 )),
1503
1504 AgpmError::FileSystemError { operation, path } => {
1505 let suggestion = if operation.contains("creation") {
1506 format!("The file already exists at {path}. Use --force to overwrite it")
1507 } else {
1508 format!("Check that the path exists and is accessible: {path}")
1509 };
1510 ErrorContext::new(AgpmError::FileSystemError {
1511 operation: operation.clone(),
1512 path: path.clone(),
1513 })
1514 .with_suggestion(suggestion)
1515 .with_details(format!(
1516 "Failed to {operation} at path: {path}"
1517 ))
1518 }
1519
1520 _ => ErrorContext::new(error.clone()),
1521 }
1522}
1523
1524#[cfg(test)]
1525mod tests {
1526 use super::*;
1527
1528 #[test]
1529 fn test_error_display() {
1530 let error = AgpmError::GitNotFound;
1531 assert_eq!(error.to_string(), "Git is not installed or not found in PATH");
1532
1533 let error = AgpmError::ResourceNotFound {
1534 name: "test".to_string(),
1535 };
1536 assert_eq!(error.to_string(), "Resource 'test' not found");
1537
1538 let error = AgpmError::InvalidVersionConstraint {
1539 constraint: "bad-version".to_string(),
1540 };
1541 assert_eq!(error.to_string(), "Invalid version constraint: bad-version");
1542
1543 let error = AgpmError::GitCommandError {
1544 operation: "clone".to_string(),
1545 stderr: "repository not found".to_string(),
1546 };
1547 assert_eq!(error.to_string(), "Git operation failed: clone");
1548 }
1549
1550 #[test]
1551 fn test_error_context() {
1552 let ctx = ErrorContext::new(AgpmError::GitNotFound)
1553 .with_suggestion("Install git using your package manager")
1554 .with_details("Git is required for AGPM to function");
1555
1556 assert_eq!(ctx.suggestion, Some("Install git using your package manager".to_string()));
1557 assert_eq!(ctx.details, Some("Git is required for AGPM to function".to_string()));
1558 }
1559
1560 #[test]
1561 fn test_error_context_display() {
1562 let ctx = ErrorContext::new(AgpmError::GitNotFound).with_suggestion("Install git");
1563
1564 let display = format!("{ctx}");
1565 assert!(display.contains("Git is not installed or not found in PATH"));
1566 assert!(display.contains("Install git"));
1567 }
1568
1569 #[test]
1570 fn test_user_friendly_error_permission_denied() {
1571 use std::io::{Error, ErrorKind};
1572
1573 let io_error = Error::new(ErrorKind::PermissionDenied, "access denied");
1574 let anyhow_error = anyhow::Error::from(io_error);
1575
1576 let ctx = user_friendly_error(anyhow_error);
1577 match ctx.error {
1578 AgpmError::PermissionDenied {
1579 ..
1580 } => {}
1581 _ => panic!("Expected PermissionDenied error"),
1582 }
1583 assert!(ctx.suggestion.is_some());
1584 assert!(ctx.details.is_some());
1585 }
1586
1587 #[test]
1588 fn test_user_friendly_error_not_found_with_path() {
1589 use std::io::{Error, ErrorKind};
1590
1591 let io_error = Error::new(ErrorKind::NotFound, "file not found");
1592 let anyhow_error =
1593 anyhow::Error::from(io_error).context("Failed to read local file: /path/to/missing.md");
1594
1595 let ctx = user_friendly_error(anyhow_error);
1596 match &ctx.error {
1597 AgpmError::FileSystemError {
1598 path,
1599 ..
1600 } => {
1601 assert_eq!(
1602 path, "/path/to/missing.md",
1603 "Path should be extracted from error context"
1604 );
1605 }
1606 _ => panic!("Expected FileSystemError, got: {:?}", ctx.error),
1607 }
1608 assert!(ctx.suggestion.is_some());
1609 assert!(ctx.suggestion.as_ref().unwrap().contains("/path/to/missing.md"));
1610 }
1611
1612 #[test]
1613 fn test_user_friendly_error_not_found_with_malformed_path() {
1614 use std::io::{Error, ErrorKind};
1615
1616 let io_error = Error::new(ErrorKind::NotFound, "file not found");
1618 let anyhow_error =
1619 anyhow::Error::from(io_error).context("Failed to read: //Users/test/./foo/./bar.md");
1620
1621 let ctx = user_friendly_error(anyhow_error);
1622 match &ctx.error {
1623 AgpmError::FileSystemError {
1624 path,
1625 ..
1626 } => {
1627 assert_eq!(
1628 path, "/Users/test/foo/bar.md",
1629 "Path should be normalized (double slashes and ./ removed)"
1630 );
1631 }
1632 _ => panic!("Expected FileSystemError, got: {:?}", ctx.error),
1633 }
1634 assert!(ctx.suggestion.as_ref().unwrap().contains("/Users/test/foo/bar.md"));
1635 }
1636
1637 #[test]
1638 fn test_user_friendly_error_not_found() {
1639 use std::io::{Error, ErrorKind};
1640
1641 let io_error = Error::new(ErrorKind::NotFound, "file not found");
1642 let anyhow_error = anyhow::Error::from(io_error);
1643
1644 let ctx = user_friendly_error(anyhow_error);
1645 match ctx.error {
1646 AgpmError::FileSystemError {
1647 ..
1648 } => {}
1649 _ => panic!("Expected FileSystemError"),
1650 }
1651 assert!(ctx.suggestion.is_some());
1652 assert!(ctx.details.is_some());
1653 }
1654
1655 #[test]
1656 fn test_from_io_error() {
1657 use std::io::Error;
1658
1659 let io_error = Error::other("test error");
1660 let agpm_error = AgpmError::from(io_error);
1661
1662 match agpm_error {
1663 AgpmError::IoError(_) => {}
1664 _ => panic!("Expected IoError"),
1665 }
1666 }
1667
1668 #[test]
1669 fn test_from_toml_error() {
1670 let toml_str = "invalid = toml {";
1671 let result: Result<toml::Value, _> = toml::from_str(toml_str);
1672
1673 if let Err(e) = result {
1674 let agpm_error = AgpmError::from(e);
1675 match agpm_error {
1676 AgpmError::TomlError(_) => {}
1677 _ => panic!("Expected TomlError"),
1678 }
1679 }
1680 }
1681
1682 #[test]
1683 fn test_create_error_context_git_not_found() {
1684 let ctx = create_error_context(AgpmError::GitNotFound);
1685 assert!(ctx.suggestion.is_some());
1686 assert!(ctx.suggestion.unwrap().contains("Install git"));
1687 assert!(ctx.details.is_some());
1688 }
1689
1690 #[test]
1691 fn test_create_error_context_git_command_error() {
1692 let ctx = create_error_context(AgpmError::GitCommandError {
1693 operation: "clone".to_string(),
1694 stderr: "error".to_string(),
1695 });
1696 assert!(ctx.suggestion.is_some());
1697 assert!(ctx.suggestion.unwrap().contains("repository URL"));
1698 assert!(ctx.details.is_some());
1699 }
1700
1701 #[test]
1702 fn test_create_error_context_git_auth_failed() {
1703 let ctx = create_error_context(AgpmError::GitAuthenticationFailed {
1704 url: "https://github.com/test/repo".to_string(),
1705 });
1706 assert!(ctx.suggestion.is_some());
1707 assert!(ctx.suggestion.unwrap().contains("Configure git authentication"));
1708 assert!(ctx.details.is_some());
1709 }
1710
1711 #[test]
1712 fn test_create_error_context_manifest_not_found() {
1713 let ctx = create_error_context(AgpmError::ManifestNotFound);
1714 assert!(ctx.suggestion.is_some());
1715 assert!(ctx.suggestion.unwrap().contains("Create a agpm.toml"));
1716 assert!(ctx.details.is_some());
1717 }
1718
1719 #[test]
1720 fn test_create_error_context_source_not_found() {
1721 let ctx = create_error_context(AgpmError::SourceNotFound {
1722 name: "test-source".to_string(),
1723 });
1724 assert!(ctx.suggestion.is_some());
1725 assert!(ctx.suggestion.unwrap().contains("test-source"));
1726 assert!(ctx.details.is_some());
1727 }
1728
1729 #[test]
1730 fn test_create_error_context_version_not_found() {
1731 let ctx = create_error_context(AgpmError::VersionNotFound {
1732 resource: "test-resource".to_string(),
1733 version: "v1.0.0".to_string(),
1734 });
1735 assert!(ctx.suggestion.is_some());
1736 assert!(ctx.suggestion.unwrap().contains("test-resource"));
1737 assert!(ctx.details.is_some());
1738 assert!(ctx.details.unwrap().contains("v1.0.0"));
1739 }
1740
1741 #[test]
1742 fn test_create_error_context_circular_dependency() {
1743 let ctx = create_error_context(AgpmError::CircularDependency {
1744 chain: "a -> b -> c -> a".to_string(),
1745 });
1746 assert!(ctx.suggestion.is_some());
1747 assert!(ctx.suggestion.unwrap().contains("remove circular"));
1748 assert!(ctx.details.is_some());
1749 assert!(ctx.details.unwrap().contains("a -> b -> c -> a"));
1750 }
1751
1752 #[test]
1753 fn test_create_error_context_permission_denied() {
1754 let ctx = create_error_context(AgpmError::PermissionDenied {
1755 operation: "write".to_string(),
1756 path: "/test/path".to_string(),
1757 });
1758 assert!(ctx.suggestion.is_some());
1759 assert!(ctx.details.is_some());
1760 assert!(ctx.details.unwrap().contains("/test/path"));
1761 }
1762
1763 #[test]
1764 fn test_create_error_context_checksum_mismatch() {
1765 let ctx = create_error_context(AgpmError::ChecksumMismatch {
1766 name: "test-resource".to_string(),
1767 expected: "abc123".to_string(),
1768 actual: "def456".to_string(),
1769 });
1770 assert!(ctx.suggestion.is_some());
1771 assert!(ctx.suggestion.unwrap().contains("reinstalling"));
1772 assert!(ctx.details.is_some());
1773 assert!(ctx.details.unwrap().contains("abc123"));
1774 }
1775
1776 #[test]
1777 fn test_error_clone() {
1778 let error1 = AgpmError::GitNotFound;
1779 let error2 = error1.clone();
1780 assert_eq!(error1.to_string(), error2.to_string());
1781
1782 let error1 = AgpmError::ResourceNotFound {
1783 name: "test".to_string(),
1784 };
1785 let error2 = error1.clone();
1786 assert_eq!(error1.to_string(), error2.to_string());
1787 }
1788
1789 #[test]
1790 fn test_error_context_suggestion() {
1791 let ctx = ErrorContext::suggestion("Test suggestion");
1792 assert_eq!(ctx.suggestion, Some("Test suggestion".to_string()));
1793 assert!(ctx.details.is_none());
1794 }
1795
1796 #[test]
1797 fn test_into_anyhow_with_context() {
1798 let error = AgpmError::GitNotFound;
1799 let context = ErrorContext::new(AgpmError::Other {
1800 message: "dummy".to_string(),
1801 })
1802 .with_suggestion("Test suggestion")
1803 .with_details("Test details");
1804
1805 let anyhow_error = error.into_anyhow_with_context(context);
1806 let display = format!("{anyhow_error}");
1807 assert!(display.contains("Git is not installed"));
1808 }
1809
1810 #[test]
1811 fn test_user_friendly_error_already_exists() {
1812 use std::io::{Error, ErrorKind};
1813
1814 let io_error = Error::new(ErrorKind::AlreadyExists, "file exists");
1815 let anyhow_error = anyhow::Error::from(io_error);
1816
1817 let ctx = user_friendly_error(anyhow_error);
1818 match ctx.error {
1819 AgpmError::FileSystemError {
1820 ..
1821 } => {}
1822 _ => panic!("Expected FileSystemError"),
1823 }
1824 assert!(ctx.suggestion.is_some());
1825 assert!(ctx.suggestion.unwrap().contains("overwrite"));
1826 }
1827
1828 #[test]
1829 fn test_user_friendly_error_invalid_data() {
1830 use std::io::{Error, ErrorKind};
1831
1832 let io_error = Error::new(ErrorKind::InvalidData, "corrupt data");
1833 let anyhow_error = anyhow::Error::from(io_error);
1834
1835 let ctx = user_friendly_error(anyhow_error);
1836 match ctx.error {
1837 AgpmError::InvalidResource {
1838 ..
1839 } => {}
1840 _ => panic!("Expected InvalidResource"),
1841 }
1842 assert!(ctx.suggestion.is_some());
1843 assert!(ctx.details.is_some());
1844 }
1845
1846 #[test]
1847 fn test_user_friendly_error_template_with_resource_name() {
1848 let template_error = anyhow::anyhow!("Variable `foo` not found in context");
1850 let error_with_context = template_error.context(
1851 "Failed to render body for canonical_name=\"my-awesome-agent\", source=\"community\"",
1852 );
1853
1854 let ctx = user_friendly_error(error_with_context);
1855
1856 match &ctx.error {
1857 AgpmError::InvalidResource {
1858 name,
1859 reason,
1860 } => {
1861 assert_eq!(
1863 name, "my-awesome-agent",
1864 "Resource name should be extracted from canonical_name field"
1865 );
1866 assert!(
1868 reason.contains("Variable `foo` not found"),
1869 "Reason should contain the actual Tera error. Got: {}",
1870 reason
1871 );
1872 assert!(
1874 reason.contains("Resource: my-awesome-agent"),
1875 "Reason should include resource context. Got: {}",
1876 reason
1877 );
1878 }
1879 _ => panic!("Expected InvalidResource, got {:?}", ctx.error),
1880 }
1881 assert!(ctx.suggestion.is_some());
1882 assert!(ctx.details.is_some());
1883 }
1884
1885 #[test]
1886 fn test_user_friendly_error_template_without_resource_name() {
1887 let template_error = anyhow::anyhow!("Variable `bar` not found in context");
1889
1890 let ctx = user_friendly_error(template_error);
1891
1892 match &ctx.error {
1893 AgpmError::InvalidResource {
1894 name,
1895 reason,
1896 } => {
1897 assert_eq!(
1899 name, "unknown resource",
1900 "Should use fallback when resource name unavailable"
1901 );
1902 assert!(reason.contains("Variable"), "Reason should contain the actual error");
1903 }
1904 _ => panic!("Expected InvalidResource, got {:?}", ctx.error),
1905 }
1906 }
1907
1908 #[test]
1909 fn test_user_friendly_error_agpm_error() {
1910 let error = AgpmError::GitNotFound;
1911 let anyhow_error = anyhow::Error::from(error);
1912
1913 let ctx = user_friendly_error(anyhow_error);
1914 match ctx.error {
1915 AgpmError::GitNotFound => {}
1916 _ => panic!("Expected GitNotFound"),
1917 }
1918 assert!(ctx.suggestion.is_some());
1919 }
1920
1921 #[test]
1922 fn test_user_friendly_error_toml_parse() {
1923 let toml_str = "invalid = toml {";
1924 let result: Result<toml::Value, _> = toml::from_str(toml_str);
1925
1926 if let Err(e) = result {
1927 let anyhow_error = anyhow::Error::from(e);
1928 let ctx = user_friendly_error(anyhow_error);
1929
1930 match ctx.error {
1931 AgpmError::ManifestParseError {
1932 ..
1933 } => {}
1934 _ => panic!("Expected ManifestParseError"),
1935 }
1936 assert!(ctx.suggestion.is_some());
1937 assert!(ctx.suggestion.unwrap().contains("TOML syntax"));
1938 }
1939 }
1940
1941 #[test]
1942 fn test_user_friendly_error_generic() {
1943 let error = anyhow::anyhow!("Generic error");
1944 let ctx = user_friendly_error(error);
1945
1946 match ctx.error {
1947 AgpmError::Other {
1948 message,
1949 } => {
1950 assert_eq!(message, "Generic error");
1951 }
1952 _ => panic!("Expected Other error"),
1953 }
1954 }
1955
1956 #[test]
1957 fn test_from_semver_error() {
1958 let result = semver::Version::parse("invalid-version");
1959 if let Err(e) = result {
1960 let agpm_error = AgpmError::from(e);
1961 match agpm_error {
1962 AgpmError::SemverError(_) => {}
1963 _ => panic!("Expected SemverError"),
1964 }
1965 }
1966 }
1967
1968 #[test]
1969 fn test_error_display_all_variants() {
1970 let errors = vec![
1972 AgpmError::GitRepoInvalid {
1973 path: "/test/path".to_string(),
1974 },
1975 AgpmError::GitCheckoutFailed {
1976 reference: "main".to_string(),
1977 reason: "not found".to_string(),
1978 },
1979 AgpmError::ConfigError {
1980 message: "config issue".to_string(),
1981 },
1982 AgpmError::ManifestValidationError {
1983 reason: "invalid format".to_string(),
1984 },
1985 AgpmError::LockfileParseError {
1986 file: "agpm.lock".to_string(),
1987 reason: "syntax error".to_string(),
1988 },
1989 AgpmError::ResourceFileNotFound {
1990 path: "test.md".to_string(),
1991 source_name: "source".to_string(),
1992 },
1993 AgpmError::DirectoryNotEmpty {
1994 path: "/some/dir".to_string(),
1995 },
1996 AgpmError::InvalidDependency {
1997 name: "dep".to_string(),
1998 reason: "bad format".to_string(),
1999 },
2000 AgpmError::DependencyNotMet {
2001 name: "dep".to_string(),
2002 required: "v1.0".to_string(),
2003 found: "v2.0".to_string(),
2004 },
2005 AgpmError::ConfigNotFound {
2006 path: "/config/path".to_string(),
2007 },
2008 AgpmError::PlatformNotSupported {
2009 operation: "test op".to_string(),
2010 },
2011 ];
2012
2013 for error in errors {
2014 let display = format!("{error}");
2015 assert!(!display.is_empty());
2016 }
2017 }
2018
2019 #[test]
2020 fn test_create_error_context_git_operations() {
2021 let operations = vec![
2023 ("fetch", "internet connection"),
2024 ("checkout", "branch, tag"),
2025 ("pull", "git configuration"),
2026 ];
2027
2028 for (op, expected_text) in operations {
2029 let ctx = create_error_context(AgpmError::GitCommandError {
2030 operation: op.to_string(),
2031 stderr: "error".to_string(),
2032 });
2033 assert!(ctx.suggestion.is_some());
2034 assert!(ctx.suggestion.unwrap().to_lowercase().contains(expected_text));
2035 }
2036 }
2037
2038 #[test]
2039 fn test_create_error_context_resource_file_not_found() {
2040 let ctx = create_error_context(AgpmError::ResourceFileNotFound {
2041 path: "agents/test.md".to_string(),
2042 source_name: "official".to_string(),
2043 });
2044 assert!(ctx.suggestion.is_some());
2045 let suggestion = ctx.suggestion.unwrap();
2046 assert!(suggestion.contains("agents/test.md"));
2047 assert!(suggestion.contains("official"));
2048 assert!(ctx.details.is_some());
2049 }
2050
2051 #[test]
2052 fn test_create_error_context_manifest_parse_error() {
2053 let ctx = create_error_context(AgpmError::ManifestParseError {
2054 file: "custom.toml".to_string(),
2055 reason: "invalid syntax".to_string(),
2056 });
2057 assert!(ctx.suggestion.is_some());
2058 let suggestion = ctx.suggestion.unwrap();
2059 assert!(suggestion.contains("custom.toml"));
2060 assert!(ctx.details.is_some());
2061 }
2062
2063 #[test]
2064 fn test_create_error_context_git_clone_failed() {
2065 let ctx = create_error_context(AgpmError::GitCloneFailed {
2066 url: "https://example.com/repo.git".to_string(),
2067 reason: "network error".to_string(),
2068 });
2069 assert!(ctx.suggestion.is_some());
2070 let suggestion = ctx.suggestion.unwrap();
2071 assert!(suggestion.contains("https://example.com/repo.git"));
2072 assert!(ctx.details.is_some());
2073 }
2074}