Skip to main content

scoop_uv/
error.rs

1//! Error types for scoop
2
3use std::fmt;
4use std::path::PathBuf;
5
6use rust_i18n::t;
7use serde::Serialize;
8use thiserror::Error;
9
10/// Result type alias using ScoopError
11pub type Result<T> = std::result::Result<T, ScoopError>;
12
13/// Exit status for migration operations
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
15#[repr(u8)]
16pub enum MigrationExitCode {
17    /// Complete success - all packages migrated
18    Success = 0,
19    /// Partial success - some packages failed to install
20    PartialSuccess = 1,
21    /// Complete failure - rollback occurred
22    CompleteFailure = 2,
23    /// Source error - source not found or corrupted
24    SourceError = 3,
25}
26
27/// Main error type for scoop
28#[derive(Error, Debug)]
29pub enum ScoopError {
30    /// Virtual environment not found
31    VirtualenvNotFound { name: String },
32
33    /// Virtual environment already exists
34    VirtualenvExists { name: String },
35
36    /// Invalid environment name
37    InvalidEnvName { name: String, reason: String },
38
39    /// Invalid Python version
40    InvalidPythonVersion { version: String },
41
42    /// uv not found
43    UvNotFound,
44
45    /// uv command failed
46    UvCommandFailed { command: String, message: String },
47
48    /// Path error
49    PathError(String),
50
51    /// Home directory not found
52    HomeNotFound,
53
54    /// IO error
55    Io(#[from] std::io::Error),
56
57    /// JSON error
58    Json(#[from] serde_json::Error),
59
60    /// Version file not found
61    VersionFileNotFound { path: PathBuf },
62
63    /// Shell not supported
64    UnsupportedShell { shell: String },
65
66    /// Python version not installed
67    PythonNotInstalled { version: String },
68
69    /// Python installation failed
70    PythonInstallFailed { version: String, message: String },
71
72    /// Python uninstallation failed
73    PythonUninstallFailed { version: String, message: String },
74
75    /// No Python versions available
76    NoPythonVersions { pattern: String },
77
78    /// Invalid argument combination
79    InvalidArgument { message: String },
80
81    /// pyenv not found
82    PyenvNotFound,
83
84    /// pyenv environment not found
85    PyenvEnvNotFound { name: String },
86
87    /// virtualenvwrapper environment not found
88    VenvWrapperEnvNotFound { name: String },
89
90    /// conda environment not found
91    CondaEnvNotFound { name: String },
92
93    /// Corrupted environment
94    CorruptedEnvironment { name: String, reason: String },
95
96    /// Package extraction failed
97    PackageExtractionFailed { reason: String },
98
99    /// Migration failed
100    MigrationFailed { reason: String },
101
102    /// Name conflict with existing scoop environment
103    MigrationNameConflict { name: String, existing: PathBuf },
104}
105
106// ============================================================================
107// Display Implementation (i18n-aware)
108// ============================================================================
109
110impl 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
238// ============================================================================
239// JSON Error Support
240// ============================================================================
241
242impl ScoopError {
243    /// Returns the error code for JSON output
244    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    /// Returns a suggested fix for the error (if available)
275    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    /// Returns the migration exit code for this error.
301    ///
302    /// Maps error types to appropriate exit codes for migration operations.
303    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    // ==========================================================================
470    // IO Error Propagation Tests
471    // ==========================================================================
472
473    #[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        // Invalid JSON syntax
511        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        // The error message should contain useful info
517        let msg = err.to_string();
518        assert!(msg.contains("JSON"));
519    }
520
521    #[test]
522    fn test_result_type_alias() {
523        // Verify that Result<T> is an alias for std::result::Result<T, ScoopError>
524        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        // IO error should have source
541        let io_err = io::Error::new(io::ErrorKind::NotFound, "original error");
542        let err: ScoopError = io_err.into();
543
544        // ScoopError::Io wraps the original error
545        if let ScoopError::Io(inner) = &err {
546            assert!(inner.source().is_none()); // Simple io::Error has no source
547        }
548
549        // JSON error should also work
550        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()); // JSON error has source
554    }
555
556    // ==========================================================================
557    // Error Message Quality Tests
558    // ==========================================================================
559
560    #[test]
561    fn test_error_messages_are_user_friendly() {
562        // All error messages should be complete sentences or clear phrases
563        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            // Messages should not be empty
577            assert!(!msg.is_empty(), "Error message should not be empty");
578            // Messages should start with uppercase, quote, or 'u' (for 'uv')
579            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        // Errors with context should include that context in the message
591        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        // UvNotFound suggestion should include installation instructions
619        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        // PythonNotInstalled suggestion should suggest install command
627        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        // Ensure error messages don't leak sensitive paths or info
640        let err = ScoopError::PathError("test path error".to_string());
641        let msg = err.to_string();
642        // Should not contain home directory patterns
643        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    // ==========================================================================
694    // Error Code Tests (JSON API)
695    // ==========================================================================
696
697    #[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            // Migration error codes
918            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        // All codes should be SCREAMING_SNAKE_CASE
947        let codes = vec![
948            ScoopError::VirtualenvNotFound { name: "".into() }.code(),
949            ScoopError::UvNotFound.code(),
950            ScoopError::HomeNotFound.code(),
951            ScoopError::InvalidArgument { message: "".into() }.code(),
952            // Migration error codes
953            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    // ==========================================================================
981    // Suggestion Tests (JSON API)
982    // ==========================================================================
983
984    #[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}