1use std::fmt;
4use std::path::PathBuf;
5
6use rust_i18n::t;
7use serde::Serialize;
8use thiserror::Error;
9
10pub type Result<T> = std::result::Result<T, ScoopError>;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
15#[repr(u8)]
16pub enum MigrationExitCode {
17 Success = 0,
19 PartialSuccess = 1,
21 CompleteFailure = 2,
23 SourceError = 3,
25}
26
27#[derive(Error, Debug)]
29pub enum ScoopError {
30 VirtualenvNotFound { name: String },
32
33 VirtualenvExists { name: String },
35
36 InvalidEnvName { name: String, reason: String },
38
39 InvalidPythonVersion { version: String },
41
42 UvNotFound,
44
45 UvCommandFailed { command: String, message: String },
47
48 PathError(String),
50
51 HomeNotFound,
53
54 Io(#[from] std::io::Error),
56
57 Json(#[from] serde_json::Error),
59
60 VersionFileNotFound { path: PathBuf },
62
63 UnsupportedShell { shell: String },
65
66 PythonNotInstalled { version: String },
68
69 PythonInstallFailed { version: String, message: String },
71
72 PythonUninstallFailed { version: String, message: String },
74
75 NoPythonVersions { pattern: String },
77
78 InvalidArgument { message: String },
80
81 PyenvNotFound,
83
84 PyenvEnvNotFound { name: String },
86
87 VenvWrapperEnvNotFound { name: String },
89
90 CondaEnvNotFound { name: String },
92
93 CorruptedEnvironment { name: String, reason: String },
95
96 PackageExtractionFailed { reason: String },
98
99 MigrationFailed { reason: String },
101
102 MigrationNameConflict { name: String, existing: PathBuf },
104}
105
106impl fmt::Display for ScoopError {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 match self {
113 Self::VirtualenvNotFound { name } => {
114 write!(f, "{}", t!("error.virtualenv_not_found", name = name))
115 }
116 Self::VirtualenvExists { name } => {
117 write!(f, "{}", t!("error.virtualenv_exists", name = name))
118 }
119 Self::InvalidEnvName { name, reason } => {
120 write!(
121 f,
122 "{}",
123 t!("error.invalid_env_name", name = name, reason = reason)
124 )
125 }
126 Self::InvalidPythonVersion { version } => {
127 write!(
128 f,
129 "{}",
130 t!("error.invalid_python_version", version = version)
131 )
132 }
133 Self::UvNotFound => write!(f, "{}", t!("error.uv_not_found")),
134 Self::UvCommandFailed { command, message } => {
135 write!(
136 f,
137 "{}",
138 t!(
139 "error.uv_command_failed",
140 command = command,
141 message = message
142 )
143 )
144 }
145 Self::PathError(msg) => {
146 write!(f, "{}", t!("error.path_error", message = msg))
147 }
148 Self::HomeNotFound => write!(f, "{}", t!("error.home_not_found")),
149 Self::Io(err) => {
150 write!(f, "{}", t!("error.io", message = err.to_string()))
151 }
152 Self::Json(err) => {
153 write!(f, "{}", t!("error.json", message = err.to_string()))
154 }
155 Self::VersionFileNotFound { path } => {
156 write!(
157 f,
158 "{}",
159 t!("error.version_file_not_found", path = path.display())
160 )
161 }
162 Self::UnsupportedShell { shell } => {
163 write!(f, "{}", t!("error.unsupported_shell", shell = shell))
164 }
165 Self::PythonNotInstalled { version } => {
166 write!(f, "{}", t!("error.python_not_installed", version = version))
167 }
168 Self::PythonInstallFailed { version, message } => {
169 write!(
170 f,
171 "{}",
172 t!(
173 "error.python_install_failed",
174 version = version,
175 message = message
176 )
177 )
178 }
179 Self::PythonUninstallFailed { version, message } => {
180 write!(
181 f,
182 "{}",
183 t!(
184 "error.python_uninstall_failed",
185 version = version,
186 message = message
187 )
188 )
189 }
190 Self::NoPythonVersions { pattern } => {
191 write!(f, "{}", t!("error.no_python_versions", pattern = pattern))
192 }
193 Self::InvalidArgument { message } => {
194 write!(f, "{}", t!("error.invalid_argument", message = message))
195 }
196 Self::PyenvNotFound => write!(f, "{}", t!("error.pyenv_not_found")),
197 Self::PyenvEnvNotFound { name } => {
198 write!(f, "{}", t!("error.pyenv_env_not_found", name = name))
199 }
200 Self::VenvWrapperEnvNotFound { name } => {
201 write!(f, "{}", t!("error.venvwrapper_env_not_found", name = name))
202 }
203 Self::CondaEnvNotFound { name } => {
204 write!(f, "{}", t!("error.conda_env_not_found", name = name))
205 }
206 Self::CorruptedEnvironment { name, reason } => {
207 write!(
208 f,
209 "{}",
210 t!("error.corrupted_environment", name = name, reason = reason)
211 )
212 }
213 Self::PackageExtractionFailed { reason } => {
214 write!(
215 f,
216 "{}",
217 t!("error.package_extraction_failed", reason = reason)
218 )
219 }
220 Self::MigrationFailed { reason } => {
221 write!(f, "{}", t!("error.migration_failed", reason = reason))
222 }
223 Self::MigrationNameConflict { name, existing } => {
224 write!(
225 f,
226 "{}",
227 t!(
228 "error.migration_name_conflict",
229 name = name,
230 path = existing.display()
231 )
232 )
233 }
234 }
235 }
236}
237
238impl ScoopError {
243 pub fn code(&self) -> &'static str {
245 match self {
246 Self::VirtualenvNotFound { .. } => "ENV_NOT_FOUND",
247 Self::VirtualenvExists { .. } => "ENV_ALREADY_EXISTS",
248 Self::InvalidEnvName { .. } => "ENV_INVALID_NAME",
249 Self::InvalidPythonVersion { .. } => "PYTHON_INVALID_VERSION",
250 Self::UvNotFound => "UV_NOT_INSTALLED",
251 Self::UvCommandFailed { .. } => "UV_COMMAND_FAILED",
252 Self::PathError(_) => "IO_PATH_ERROR",
253 Self::HomeNotFound => "IO_HOME_NOT_FOUND",
254 Self::Io(_) => "IO_ERROR",
255 Self::Json(_) => "INTERNAL_JSON_ERROR",
256 Self::VersionFileNotFound { .. } => "CONFIG_VERSION_FILE_NOT_FOUND",
257 Self::UnsupportedShell { .. } => "SHELL_NOT_SUPPORTED",
258 Self::PythonNotInstalled { .. } => "PYTHON_NOT_INSTALLED",
259 Self::PythonInstallFailed { .. } => "PYTHON_INSTALL_FAILED",
260 Self::PythonUninstallFailed { .. } => "PYTHON_UNINSTALL_FAILED",
261 Self::NoPythonVersions { .. } => "PYTHON_NO_MATCHING_VERSION",
262 Self::InvalidArgument { .. } => "ARG_INVALID",
263 Self::PyenvNotFound => "SOURCE_PYENV_NOT_FOUND",
264 Self::PyenvEnvNotFound { .. } => "SOURCE_PYENV_ENV_NOT_FOUND",
265 Self::VenvWrapperEnvNotFound { .. } => "SOURCE_VENVWRAPPER_ENV_NOT_FOUND",
266 Self::CondaEnvNotFound { .. } => "SOURCE_CONDA_ENV_NOT_FOUND",
267 Self::CorruptedEnvironment { .. } => "MIGRATE_CORRUPTED",
268 Self::PackageExtractionFailed { .. } => "MIGRATE_EXTRACTION_FAILED",
269 Self::MigrationFailed { .. } => "MIGRATE_FAILED",
270 Self::MigrationNameConflict { .. } => "MIGRATE_NAME_CONFLICT",
271 }
272 }
273
274 pub fn suggestion(&self) -> Option<String> {
276 match self {
277 Self::VirtualenvNotFound { name } => {
278 Some(t!("suggestion.virtualenv_not_found", name = name).to_string())
279 }
280 Self::VirtualenvExists { .. } => Some(t!("suggestion.virtualenv_exists").to_string()),
281 Self::InvalidEnvName { .. } => Some(t!("suggestion.invalid_env_name").to_string()),
282 Self::UvNotFound => Some(t!("suggestion.uv_not_found").to_string()),
283 Self::PythonNotInstalled { version } => {
284 Some(t!("suggestion.python_not_installed", version = version).to_string())
285 }
286 Self::NoPythonVersions { .. } => Some(t!("suggestion.no_python_versions").to_string()),
287 Self::PyenvNotFound => Some(t!("suggestion.pyenv_not_found").to_string()),
288 Self::PyenvEnvNotFound { .. }
289 | Self::VenvWrapperEnvNotFound { .. }
290 | Self::CondaEnvNotFound { .. } => {
291 Some(t!("suggestion.source_env_not_found").to_string())
292 }
293 Self::MigrationNameConflict { .. } => {
294 Some(t!("suggestion.migration_name_conflict").to_string())
295 }
296 _ => None,
297 }
298 }
299
300 pub fn migration_exit_code(&self) -> MigrationExitCode {
304 match self {
305 Self::PyenvNotFound
306 | Self::PyenvEnvNotFound { .. }
307 | Self::VenvWrapperEnvNotFound { .. }
308 | Self::CondaEnvNotFound { .. }
309 | Self::CorruptedEnvironment { .. } => MigrationExitCode::SourceError,
310 Self::MigrationFailed { .. } | Self::MigrationNameConflict { .. } => {
311 MigrationExitCode::CompleteFailure
312 }
313 _ => MigrationExitCode::CompleteFailure,
314 }
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use std::io;
322
323 #[test]
324 fn test_virtualenv_not_found_message() {
325 let err = ScoopError::VirtualenvNotFound {
326 name: "myenv".to_string(),
327 };
328 assert_eq!(err.to_string(), "Can't find 'myenv' environment");
329 }
330
331 #[test]
332 fn test_virtualenv_exists_message() {
333 let err = ScoopError::VirtualenvExists {
334 name: "existing".to_string(),
335 };
336 assert_eq!(err.to_string(), "'existing' already exists");
337 }
338
339 #[test]
340 fn test_invalid_env_name_message() {
341 let err = ScoopError::InvalidEnvName {
342 name: "123bad".to_string(),
343 reason: "must start with a letter".to_string(),
344 };
345 assert!(err.to_string().contains("123bad"));
346 assert!(err.to_string().contains("must start with a letter"));
347 }
348
349 #[test]
350 fn test_invalid_python_version_message() {
351 let err = ScoopError::InvalidPythonVersion {
352 version: "abc".to_string(),
353 };
354 assert_eq!(err.to_string(), "Invalid Python version: abc");
355 }
356
357 #[test]
358 fn test_uv_not_found_message() {
359 let err = ScoopError::UvNotFound;
360 let msg = err.to_string();
361 assert!(msg.contains("uv not found"));
362 assert!(msg.contains("core engine"));
363 }
364
365 #[test]
366 fn test_uv_command_failed_message() {
367 let err = ScoopError::UvCommandFailed {
368 command: "venv".to_string(),
369 message: "Python not found".to_string(),
370 };
371 assert!(err.to_string().contains("uv venv failed"));
372 assert!(err.to_string().contains("Python not found"));
373 }
374
375 #[test]
376 fn test_path_error_message() {
377 let err = ScoopError::PathError("invalid UTF-8".to_string());
378 assert_eq!(err.to_string(), "Path error: invalid UTF-8");
379 }
380
381 #[test]
382 fn test_home_not_found_message() {
383 let err = ScoopError::HomeNotFound;
384 assert!(err.to_string().contains("Can't find home directory"));
385 }
386
387 #[test]
388 fn test_io_error_conversion() {
389 let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
390 let err: ScoopError = io_err.into();
391 assert!(matches!(err, ScoopError::Io(_)));
392 assert!(err.to_string().contains("file missing"));
393 }
394
395 #[test]
396 fn test_json_error_conversion() {
397 let json_str = "{ invalid json }";
398 let json_err: serde_json::Error =
399 serde_json::from_str::<serde_json::Value>(json_str).expect_err("should fail");
400 let err: ScoopError = json_err.into();
401 assert!(matches!(err, ScoopError::Json(_)));
402 }
403
404 #[test]
405 fn test_version_file_not_found_message() {
406 let err = ScoopError::VersionFileNotFound {
407 path: PathBuf::from("/some/path"),
408 };
409 assert!(err.to_string().contains("/some/path"));
410 assert!(err.to_string().contains("parent directories"));
411 }
412
413 #[test]
414 fn test_unsupported_shell_message() {
415 let err = ScoopError::UnsupportedShell {
416 shell: "fish".to_string(),
417 };
418 assert_eq!(err.to_string(), "Shell 'fish' not supported");
419 }
420
421 #[test]
422 fn test_python_not_installed_message() {
423 let err = ScoopError::PythonNotInstalled {
424 version: "3.13".to_string(),
425 };
426 let msg = err.to_string();
427 assert!(msg.contains("3.13"));
428 assert!(msg.contains("not installed"));
429 }
430
431 #[test]
432 fn test_python_install_failed_message() {
433 let err = ScoopError::PythonInstallFailed {
434 version: "3.12".to_string(),
435 message: "network error".to_string(),
436 };
437 assert!(err.to_string().contains("Couldn't install"));
438 assert!(err.to_string().contains("3.12"));
439 assert!(err.to_string().contains("network error"));
440 }
441
442 #[test]
443 fn test_python_uninstall_failed_message() {
444 let err = ScoopError::PythonUninstallFailed {
445 version: "3.11".to_string(),
446 message: "in use".to_string(),
447 };
448 assert!(err.to_string().contains("Couldn't uninstall"));
449 assert!(err.to_string().contains("3.11"));
450 assert!(err.to_string().contains("in use"));
451 }
452
453 #[test]
454 fn test_no_python_versions_message() {
455 let err = ScoopError::NoPythonVersions {
456 pattern: "2.7".to_string(),
457 };
458 assert!(err.to_string().contains("2.7"));
459 }
460
461 #[test]
462 fn test_invalid_argument_message() {
463 let err = ScoopError::InvalidArgument {
464 message: "Cannot use --stable and --latest together".to_string(),
465 };
466 assert_eq!(err.to_string(), "Cannot use --stable and --latest together");
467 }
468
469 #[test]
474 fn test_io_error_not_found() {
475 let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
476 let err: ScoopError = io_err.into();
477 assert!(matches!(err, ScoopError::Io(_)));
478 assert!(err.to_string().contains("file not found"));
479 }
480
481 #[test]
482 fn test_io_error_permission_denied() {
483 let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "access denied");
484 let err: ScoopError = io_err.into();
485 assert!(matches!(err, ScoopError::Io(_)));
486 assert!(err.to_string().contains("access denied"));
487 }
488
489 #[test]
490 fn test_io_error_already_exists() {
491 let io_err = io::Error::new(io::ErrorKind::AlreadyExists, "file exists");
492 let err: ScoopError = io_err.into();
493 assert!(matches!(err, ScoopError::Io(_)));
494 }
495
496 #[test]
497 fn test_io_error_preserves_kind() {
498 let original = io::Error::new(io::ErrorKind::TimedOut, "operation timed out");
499 let err: ScoopError = original.into();
500
501 if let ScoopError::Io(inner) = err {
502 assert_eq!(inner.kind(), io::ErrorKind::TimedOut);
503 } else {
504 panic!("Expected ScoopError::Io");
505 }
506 }
507
508 #[test]
509 fn test_json_error_details() {
510 let json_err: serde_json::Error =
512 serde_json::from_str::<serde_json::Value>("{ invalid }").expect_err("should fail");
513 let err: ScoopError = json_err.into();
514 assert!(matches!(err, ScoopError::Json(_)));
515
516 let msg = err.to_string();
518 assert!(msg.contains("JSON"));
519 }
520
521 #[test]
522 fn test_result_type_alias() {
523 fn returns_result() -> Result<i32> {
525 Ok(42)
526 }
527
528 fn returns_error() -> Result<i32> {
529 Err(ScoopError::HomeNotFound)
530 }
531
532 assert_eq!(returns_result().unwrap(), 42);
533 assert!(returns_error().is_err());
534 }
535
536 #[test]
537 fn test_error_source_chain() {
538 use std::error::Error;
539
540 let io_err = io::Error::new(io::ErrorKind::NotFound, "original error");
542 let err: ScoopError = io_err.into();
543
544 if let ScoopError::Io(inner) = &err {
546 assert!(inner.source().is_none()); }
548
549 let json_err: serde_json::Error =
551 serde_json::from_str::<serde_json::Value>("invalid").expect_err("should fail");
552 let err: ScoopError = json_err.into();
553 assert!(err.source().is_some()); }
555
556 #[test]
561 fn test_error_messages_are_user_friendly() {
562 let errors = vec![
564 ScoopError::VirtualenvNotFound {
565 name: "test".to_string(),
566 },
567 ScoopError::VirtualenvExists {
568 name: "test".to_string(),
569 },
570 ScoopError::HomeNotFound,
571 ScoopError::UvNotFound,
572 ];
573
574 for err in errors {
575 let msg = err.to_string();
576 assert!(!msg.is_empty(), "Error message should not be empty");
578 let first_char = msg.chars().next().unwrap();
580 assert!(
581 first_char.is_uppercase() || first_char == '\'' || first_char == 'u',
582 "Error message should start with uppercase, quote, or 'u': {}",
583 msg
584 );
585 }
586 }
587
588 #[test]
589 fn test_error_messages_include_context() {
590 let err = ScoopError::VirtualenvNotFound {
592 name: "myenv".to_string(),
593 };
594 assert!(
595 err.to_string().contains("myenv"),
596 "Error should include the env name"
597 );
598
599 let err = ScoopError::InvalidPythonVersion {
600 version: "abc".to_string(),
601 };
602 assert!(
603 err.to_string().contains("abc"),
604 "Error should include the invalid version"
605 );
606
607 let err = ScoopError::UnsupportedShell {
608 shell: "fish".to_string(),
609 };
610 assert!(
611 err.to_string().contains("fish"),
612 "Error should include the shell name"
613 );
614 }
615
616 #[test]
617 fn test_error_suggestions_provide_hints() {
618 let err = ScoopError::UvNotFound;
620 let suggestion = err.suggestion().expect("should have suggestion");
621 assert!(
622 suggestion.contains("curl") && suggestion.contains("astral.sh"),
623 "UvNotFound suggestion should include install command"
624 );
625
626 let err = ScoopError::PythonNotInstalled {
628 version: "3.13".to_string(),
629 };
630 let suggestion = err.suggestion().expect("should have suggestion");
631 assert!(
632 suggestion.contains("scoop install") && suggestion.contains("3.13"),
633 "PythonNotInstalled suggestion should include scoop install"
634 );
635 }
636
637 #[test]
638 fn test_error_messages_no_sensitive_info() {
639 let err = ScoopError::PathError("test path error".to_string());
641 let msg = err.to_string();
642 assert!(
644 !msg.contains("/Users/") && !msg.contains("/home/"),
645 "Error should not leak full paths"
646 );
647 }
648
649 #[test]
650 fn test_invalid_env_name_provides_reason() {
651 let err = ScoopError::InvalidEnvName {
652 name: "123".to_string(),
653 reason: "must start with a letter".to_string(),
654 };
655 let msg = err.to_string();
656 assert!(msg.contains("123"), "Should include the invalid name");
657 assert!(
658 msg.contains("must start with a letter"),
659 "Should include the reason"
660 );
661 }
662
663 #[test]
664 fn test_uv_command_failed_includes_details() {
665 let err = ScoopError::UvCommandFailed {
666 command: "venv".to_string(),
667 message: "Python 3.15 not found".to_string(),
668 };
669 let msg = err.to_string();
670 assert!(
671 msg.contains("uv venv failed"),
672 "Should indicate uv command failure"
673 );
674 assert!(
675 msg.contains("Python 3.15 not found"),
676 "Should include the error message"
677 );
678 }
679
680 #[test]
681 fn test_version_file_not_found_shows_path() {
682 let err = ScoopError::VersionFileNotFound {
683 path: PathBuf::from("/project/dir"),
684 };
685 let msg = err.to_string();
686 assert!(msg.contains("/project/dir"), "Should include the path");
687 assert!(
688 msg.contains("parent directories"),
689 "Should mention parent directory search"
690 );
691 }
692
693 #[test]
698 fn test_error_code_env_not_found() {
699 let err = ScoopError::VirtualenvNotFound { name: "x".into() };
700 assert_eq!(err.code(), "ENV_NOT_FOUND");
701 }
702
703 #[test]
704 fn test_error_code_env_already_exists() {
705 let err = ScoopError::VirtualenvExists { name: "x".into() };
706 assert_eq!(err.code(), "ENV_ALREADY_EXISTS");
707 }
708
709 #[test]
710 fn test_error_code_env_invalid_name() {
711 let err = ScoopError::InvalidEnvName {
712 name: "x".into(),
713 reason: "r".into(),
714 };
715 assert_eq!(err.code(), "ENV_INVALID_NAME");
716 }
717
718 #[test]
719 fn test_error_code_python_invalid_version() {
720 let err = ScoopError::InvalidPythonVersion {
721 version: "x".into(),
722 };
723 assert_eq!(err.code(), "PYTHON_INVALID_VERSION");
724 }
725
726 #[test]
727 fn test_error_code_uv_not_installed() {
728 let err = ScoopError::UvNotFound;
729 assert_eq!(err.code(), "UV_NOT_INSTALLED");
730 }
731
732 #[test]
733 fn test_error_code_uv_command_failed() {
734 let err = ScoopError::UvCommandFailed {
735 command: "x".into(),
736 message: "m".into(),
737 };
738 assert_eq!(err.code(), "UV_COMMAND_FAILED");
739 }
740
741 #[test]
742 fn test_error_code_io_path_error() {
743 let err = ScoopError::PathError("x".into());
744 assert_eq!(err.code(), "IO_PATH_ERROR");
745 }
746
747 #[test]
748 fn test_error_code_io_home_not_found() {
749 let err = ScoopError::HomeNotFound;
750 assert_eq!(err.code(), "IO_HOME_NOT_FOUND");
751 }
752
753 #[test]
754 fn test_error_code_io_error() {
755 let err = ScoopError::Io(io::Error::other("test"));
756 assert_eq!(err.code(), "IO_ERROR");
757 }
758
759 #[test]
760 fn test_error_code_internal_json_error() {
761 let json_err: serde_json::Error =
762 serde_json::from_str::<serde_json::Value>("invalid").expect_err("should fail");
763 let err: ScoopError = json_err.into();
764 assert_eq!(err.code(), "INTERNAL_JSON_ERROR");
765 }
766
767 #[test]
768 fn test_error_code_config_version_file_not_found() {
769 let err = ScoopError::VersionFileNotFound {
770 path: PathBuf::new(),
771 };
772 assert_eq!(err.code(), "CONFIG_VERSION_FILE_NOT_FOUND");
773 }
774
775 #[test]
776 fn test_error_code_shell_not_supported() {
777 let err = ScoopError::UnsupportedShell { shell: "x".into() };
778 assert_eq!(err.code(), "SHELL_NOT_SUPPORTED");
779 }
780
781 #[test]
782 fn test_error_code_python_not_installed() {
783 let err = ScoopError::PythonNotInstalled {
784 version: "x".into(),
785 };
786 assert_eq!(err.code(), "PYTHON_NOT_INSTALLED");
787 }
788
789 #[test]
790 fn test_error_code_python_install_failed() {
791 let err = ScoopError::PythonInstallFailed {
792 version: "x".into(),
793 message: "m".into(),
794 };
795 assert_eq!(err.code(), "PYTHON_INSTALL_FAILED");
796 }
797
798 #[test]
799 fn test_error_code_python_uninstall_failed() {
800 let err = ScoopError::PythonUninstallFailed {
801 version: "x".into(),
802 message: "m".into(),
803 };
804 assert_eq!(err.code(), "PYTHON_UNINSTALL_FAILED");
805 }
806
807 #[test]
808 fn test_error_code_python_no_matching_version() {
809 let err = ScoopError::NoPythonVersions {
810 pattern: "x".into(),
811 };
812 assert_eq!(err.code(), "PYTHON_NO_MATCHING_VERSION");
813 }
814
815 #[test]
816 fn test_error_code_arg_invalid() {
817 let err = ScoopError::InvalidArgument {
818 message: "x".into(),
819 };
820 assert_eq!(err.code(), "ARG_INVALID");
821 }
822
823 #[test]
824 fn test_error_code_source_pyenv_not_found() {
825 let err = ScoopError::PyenvNotFound;
826 assert_eq!(err.code(), "SOURCE_PYENV_NOT_FOUND");
827 }
828
829 #[test]
830 fn test_error_code_source_pyenv_env_not_found() {
831 let err = ScoopError::PyenvEnvNotFound { name: "x".into() };
832 assert_eq!(err.code(), "SOURCE_PYENV_ENV_NOT_FOUND");
833 }
834
835 #[test]
836 fn test_error_code_source_venvwrapper_env_not_found() {
837 let err = ScoopError::VenvWrapperEnvNotFound { name: "x".into() };
838 assert_eq!(err.code(), "SOURCE_VENVWRAPPER_ENV_NOT_FOUND");
839 }
840
841 #[test]
842 fn test_error_code_source_conda_env_not_found() {
843 let err = ScoopError::CondaEnvNotFound { name: "x".into() };
844 assert_eq!(err.code(), "SOURCE_CONDA_ENV_NOT_FOUND");
845 }
846
847 #[test]
848 fn test_error_code_migrate_corrupted() {
849 let err = ScoopError::CorruptedEnvironment {
850 name: "x".into(),
851 reason: "r".into(),
852 };
853 assert_eq!(err.code(), "MIGRATE_CORRUPTED");
854 }
855
856 #[test]
857 fn test_error_code_migrate_extraction_failed() {
858 let err = ScoopError::PackageExtractionFailed { reason: "x".into() };
859 assert_eq!(err.code(), "MIGRATE_EXTRACTION_FAILED");
860 }
861
862 #[test]
863 fn test_error_code_migrate_failed() {
864 let err = ScoopError::MigrationFailed { reason: "x".into() };
865 assert_eq!(err.code(), "MIGRATE_FAILED");
866 }
867
868 #[test]
869 fn test_error_code_migrate_name_conflict() {
870 let err = ScoopError::MigrationNameConflict {
871 name: "x".into(),
872 existing: PathBuf::from("/path"),
873 };
874 assert_eq!(err.code(), "MIGRATE_NAME_CONFLICT");
875 }
876
877 #[test]
878 fn test_all_error_codes_are_unique() {
879 use std::collections::HashSet;
880
881 let codes: Vec<&str> = vec![
882 ScoopError::VirtualenvNotFound { name: "".into() }.code(),
883 ScoopError::VirtualenvExists { name: "".into() }.code(),
884 ScoopError::InvalidEnvName {
885 name: "".into(),
886 reason: "".into(),
887 }
888 .code(),
889 ScoopError::InvalidPythonVersion { version: "".into() }.code(),
890 ScoopError::UvNotFound.code(),
891 ScoopError::UvCommandFailed {
892 command: "".into(),
893 message: "".into(),
894 }
895 .code(),
896 ScoopError::PathError("".into()).code(),
897 ScoopError::HomeNotFound.code(),
898 ScoopError::Io(io::Error::other("")).code(),
899 ScoopError::VersionFileNotFound {
900 path: PathBuf::new(),
901 }
902 .code(),
903 ScoopError::UnsupportedShell { shell: "".into() }.code(),
904 ScoopError::PythonNotInstalled { version: "".into() }.code(),
905 ScoopError::PythonInstallFailed {
906 version: "".into(),
907 message: "".into(),
908 }
909 .code(),
910 ScoopError::PythonUninstallFailed {
911 version: "".into(),
912 message: "".into(),
913 }
914 .code(),
915 ScoopError::NoPythonVersions { pattern: "".into() }.code(),
916 ScoopError::InvalidArgument { message: "".into() }.code(),
917 ScoopError::PyenvNotFound.code(),
919 ScoopError::PyenvEnvNotFound { name: "".into() }.code(),
920 ScoopError::VenvWrapperEnvNotFound { name: "".into() }.code(),
921 ScoopError::CondaEnvNotFound { name: "".into() }.code(),
922 ScoopError::CorruptedEnvironment {
923 name: "".into(),
924 reason: "".into(),
925 }
926 .code(),
927 ScoopError::PackageExtractionFailed { reason: "".into() }.code(),
928 ScoopError::MigrationFailed { reason: "".into() }.code(),
929 ScoopError::MigrationNameConflict {
930 name: "".into(),
931 existing: PathBuf::new(),
932 }
933 .code(),
934 ];
935
936 let unique: HashSet<_> = codes.iter().collect();
937 assert_eq!(
938 codes.len(),
939 unique.len(),
940 "All error codes must be unique. Found duplicates."
941 );
942 }
943
944 #[test]
945 fn test_error_codes_follow_naming_convention() {
946 let codes = vec![
948 ScoopError::VirtualenvNotFound { name: "".into() }.code(),
949 ScoopError::UvNotFound.code(),
950 ScoopError::HomeNotFound.code(),
951 ScoopError::InvalidArgument { message: "".into() }.code(),
952 ScoopError::PyenvNotFound.code(),
954 ScoopError::PyenvEnvNotFound { name: "".into() }.code(),
955 ScoopError::VenvWrapperEnvNotFound { name: "".into() }.code(),
956 ScoopError::CondaEnvNotFound { name: "".into() }.code(),
957 ScoopError::CorruptedEnvironment {
958 name: "".into(),
959 reason: "".into(),
960 }
961 .code(),
962 ScoopError::PackageExtractionFailed { reason: "".into() }.code(),
963 ScoopError::MigrationFailed { reason: "".into() }.code(),
964 ScoopError::MigrationNameConflict {
965 name: "".into(),
966 existing: PathBuf::new(),
967 }
968 .code(),
969 ];
970
971 for code in codes {
972 assert!(
973 code.chars().all(|c| c.is_uppercase() || c == '_'),
974 "Error code '{}' should be SCREAMING_SNAKE_CASE",
975 code
976 );
977 }
978 }
979
980 #[test]
985 fn test_suggestion_virtualenv_not_found_includes_name() {
986 let err = ScoopError::VirtualenvNotFound {
987 name: "myenv".into(),
988 };
989 let suggestion = err.suggestion().unwrap();
990 assert!(suggestion.starts_with("→"));
991 assert!(suggestion.contains("myenv"));
992 assert!(suggestion.contains("scoop create"));
993 }
994
995 #[test]
996 fn test_suggestion_virtualenv_exists() {
997 let err = ScoopError::VirtualenvExists {
998 name: "existing".into(),
999 };
1000 let suggestion = err.suggestion().unwrap();
1001 assert!(suggestion.starts_with("→"));
1002 assert!(suggestion.contains("--force"));
1003 }
1004
1005 #[test]
1006 fn test_suggestion_invalid_env_name() {
1007 let err = ScoopError::InvalidEnvName {
1008 name: "123".into(),
1009 reason: "must start with letter".into(),
1010 };
1011 let suggestion = err.suggestion().unwrap();
1012 assert!(suggestion.starts_with("→"));
1013 assert!(suggestion.contains("letter"));
1014 }
1015
1016 #[test]
1017 fn test_suggestion_uv_not_found() {
1018 let err = ScoopError::UvNotFound;
1019 let suggestion = err.suggestion().unwrap();
1020 assert!(suggestion.starts_with("→"));
1021 assert!(suggestion.contains("curl"));
1022 assert!(suggestion.contains("astral.sh"));
1023 }
1024
1025 #[test]
1026 fn test_suggestion_python_not_installed_includes_version() {
1027 let err = ScoopError::PythonNotInstalled {
1028 version: "3.13".into(),
1029 };
1030 let suggestion = err.suggestion().unwrap();
1031 assert!(suggestion.starts_with("→"));
1032 assert!(suggestion.contains("3.13"));
1033 assert!(suggestion.contains("scoop install"));
1034 }
1035
1036 #[test]
1037 fn test_suggestion_no_python_versions() {
1038 let err = ScoopError::NoPythonVersions {
1039 pattern: "2.7".into(),
1040 };
1041 let suggestion = err.suggestion().unwrap();
1042 assert!(suggestion.starts_with("→"));
1043 assert!(suggestion.contains("scoop list --pythons"));
1044 }
1045
1046 #[test]
1047 fn test_no_suggestion_for_io_error() {
1048 let err = ScoopError::Io(io::Error::other("test"));
1049 assert!(err.suggestion().is_none());
1050 }
1051
1052 #[test]
1053 fn test_no_suggestion_for_json_error() {
1054 let json_err: serde_json::Error =
1055 serde_json::from_str::<serde_json::Value>("invalid").expect_err("should fail");
1056 let err: ScoopError = json_err.into();
1057 assert!(err.suggestion().is_none());
1058 }
1059
1060 #[test]
1061 fn test_no_suggestion_for_uv_command_failed() {
1062 let err = ScoopError::UvCommandFailed {
1063 command: "venv".into(),
1064 message: "failed".into(),
1065 };
1066 assert!(err.suggestion().is_none());
1067 }
1068
1069 #[test]
1070 fn test_no_suggestion_for_path_error() {
1071 let err = ScoopError::PathError("invalid path".into());
1072 assert!(err.suggestion().is_none());
1073 }
1074
1075 #[test]
1076 fn test_no_suggestion_for_home_not_found() {
1077 let err = ScoopError::HomeNotFound;
1078 assert!(err.suggestion().is_none());
1079 }
1080
1081 #[test]
1082 fn test_no_suggestion_for_version_file_not_found() {
1083 let err = ScoopError::VersionFileNotFound {
1084 path: PathBuf::from("/project"),
1085 };
1086 assert!(err.suggestion().is_none());
1087 }
1088
1089 #[test]
1090 fn test_no_suggestion_for_unsupported_shell() {
1091 let err = ScoopError::UnsupportedShell {
1092 shell: "fish".into(),
1093 };
1094 assert!(err.suggestion().is_none());
1095 }
1096
1097 #[test]
1098 fn test_no_suggestion_for_python_install_failed() {
1099 let err = ScoopError::PythonInstallFailed {
1100 version: "3.12".into(),
1101 message: "network error".into(),
1102 };
1103 assert!(err.suggestion().is_none());
1104 }
1105
1106 #[test]
1107 fn test_no_suggestion_for_python_uninstall_failed() {
1108 let err = ScoopError::PythonUninstallFailed {
1109 version: "3.11".into(),
1110 message: "in use".into(),
1111 };
1112 assert!(err.suggestion().is_none());
1113 }
1114
1115 #[test]
1116 fn test_no_suggestion_for_invalid_python_version() {
1117 let err = ScoopError::InvalidPythonVersion {
1118 version: "abc".into(),
1119 };
1120 assert!(err.suggestion().is_none());
1121 }
1122
1123 #[test]
1124 fn test_no_suggestion_for_invalid_argument() {
1125 let err = ScoopError::InvalidArgument {
1126 message: "conflicting flags".into(),
1127 };
1128 assert!(err.suggestion().is_none());
1129 }
1130}