use std::fmt;
use std::path::PathBuf;
use rust_i18n::t;
use serde::Serialize;
use thiserror::Error;
pub type Result<T> = std::result::Result<T, ScoopError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[repr(u8)]
pub enum MigrationExitCode {
Success = 0,
PartialSuccess = 1,
CompleteFailure = 2,
SourceError = 3,
}
#[derive(Error, Debug)]
pub enum ScoopError {
VirtualenvNotFound { name: String },
VirtualenvExists { name: String },
InvalidEnvName { name: String, reason: String },
InvalidPythonVersion { version: String },
UvNotFound,
UvCommandFailed { command: String, message: String },
PathError(String),
HomeNotFound,
Io(#[from] std::io::Error),
Json(#[from] serde_json::Error),
VersionFileNotFound { path: PathBuf },
UnsupportedShell { shell: String },
PythonNotInstalled { version: String },
PythonInstallFailed { version: String, message: String },
PythonUninstallFailed { version: String, message: String },
NoPythonVersions { pattern: String },
InvalidArgument { message: String },
PyenvNotFound,
PyenvEnvNotFound { name: String },
VenvWrapperEnvNotFound { name: String },
CondaEnvNotFound { name: String },
CorruptedEnvironment { name: String, reason: String },
PackageExtractionFailed { reason: String },
MigrationFailed { reason: String },
MigrationNameConflict { name: String, existing: PathBuf },
InvalidPythonPath { path: PathBuf, reason: String },
CascadeAborted,
SelfUpdateFailed { message: String },
}
impl fmt::Display for ScoopError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::VirtualenvNotFound { name } => {
write!(f, "{}", t!("error.virtualenv_not_found", name = name))
}
Self::VirtualenvExists { name } => {
write!(f, "{}", t!("error.virtualenv_exists", name = name))
}
Self::InvalidEnvName { name, reason } => {
write!(
f,
"{}",
t!("error.invalid_env_name", name = name, reason = reason)
)
}
Self::InvalidPythonVersion { version } => {
write!(
f,
"{}",
t!("error.invalid_python_version", version = version)
)
}
Self::UvNotFound => write!(f, "{}", t!("error.uv_not_found")),
Self::UvCommandFailed { command, message } => {
write!(
f,
"{}",
t!(
"error.uv_command_failed",
command = command,
message = message
)
)
}
Self::PathError(msg) => {
write!(f, "{}", t!("error.path_error", message = msg))
}
Self::HomeNotFound => write!(f, "{}", t!("error.home_not_found")),
Self::Io(err) => {
write!(f, "{}", t!("error.io", message = err.to_string()))
}
Self::Json(err) => {
write!(f, "{}", t!("error.json", message = err.to_string()))
}
Self::VersionFileNotFound { path } => {
write!(
f,
"{}",
t!("error.version_file_not_found", path = path.display())
)
}
Self::UnsupportedShell { shell } => {
write!(f, "{}", t!("error.unsupported_shell", shell = shell))
}
Self::PythonNotInstalled { version } => {
write!(f, "{}", t!("error.python_not_installed", version = version))
}
Self::PythonInstallFailed { version, message } => {
write!(
f,
"{}",
t!(
"error.python_install_failed",
version = version,
message = message
)
)
}
Self::PythonUninstallFailed { version, message } => {
write!(
f,
"{}",
t!(
"error.python_uninstall_failed",
version = version,
message = message
)
)
}
Self::NoPythonVersions { pattern } => {
write!(f, "{}", t!("error.no_python_versions", pattern = pattern))
}
Self::InvalidArgument { message } => {
write!(f, "{}", t!("error.invalid_argument", message = message))
}
Self::PyenvNotFound => write!(f, "{}", t!("error.pyenv_not_found")),
Self::PyenvEnvNotFound { name } => {
write!(f, "{}", t!("error.pyenv_env_not_found", name = name))
}
Self::VenvWrapperEnvNotFound { name } => {
write!(f, "{}", t!("error.venvwrapper_env_not_found", name = name))
}
Self::CondaEnvNotFound { name } => {
write!(f, "{}", t!("error.conda_env_not_found", name = name))
}
Self::CorruptedEnvironment { name, reason } => {
write!(
f,
"{}",
t!("error.corrupted_environment", name = name, reason = reason)
)
}
Self::PackageExtractionFailed { reason } => {
write!(
f,
"{}",
t!("error.package_extraction_failed", reason = reason)
)
}
Self::MigrationFailed { reason } => {
write!(f, "{}", t!("error.migration_failed", reason = reason))
}
Self::MigrationNameConflict { name, existing } => {
write!(
f,
"{}",
t!(
"error.migration_name_conflict",
name = name,
path = existing.display()
)
)
}
Self::InvalidPythonPath { path, reason } => {
write!(
f,
"{}",
t!(
"error.invalid_python_path",
path = path.display(),
reason = reason
)
)
}
Self::CascadeAborted => write!(f, "{}", t!("error.cascade_aborted")),
Self::SelfUpdateFailed { message } => {
write!(f, "{}", t!("error.self_update_failed", message = message))
}
}
}
}
impl ScoopError {
pub fn code(&self) -> &'static str {
match self {
Self::VirtualenvNotFound { .. } => "ENV_NOT_FOUND",
Self::VirtualenvExists { .. } => "ENV_ALREADY_EXISTS",
Self::InvalidEnvName { .. } => "ENV_INVALID_NAME",
Self::InvalidPythonVersion { .. } => "PYTHON_INVALID_VERSION",
Self::UvNotFound => "UV_NOT_INSTALLED",
Self::UvCommandFailed { .. } => "UV_COMMAND_FAILED",
Self::PathError(_) => "IO_PATH_ERROR",
Self::HomeNotFound => "IO_HOME_NOT_FOUND",
Self::Io(_) => "IO_ERROR",
Self::Json(_) => "INTERNAL_JSON_ERROR",
Self::VersionFileNotFound { .. } => "CONFIG_VERSION_FILE_NOT_FOUND",
Self::UnsupportedShell { .. } => "SHELL_NOT_SUPPORTED",
Self::PythonNotInstalled { .. } => "PYTHON_NOT_INSTALLED",
Self::PythonInstallFailed { .. } => "PYTHON_INSTALL_FAILED",
Self::PythonUninstallFailed { .. } => "PYTHON_UNINSTALL_FAILED",
Self::NoPythonVersions { .. } => "PYTHON_NO_MATCHING_VERSION",
Self::InvalidArgument { .. } => "ARG_INVALID",
Self::PyenvNotFound => "SOURCE_PYENV_NOT_FOUND",
Self::PyenvEnvNotFound { .. } => "SOURCE_PYENV_ENV_NOT_FOUND",
Self::VenvWrapperEnvNotFound { .. } => "SOURCE_VENVWRAPPER_ENV_NOT_FOUND",
Self::CondaEnvNotFound { .. } => "SOURCE_CONDA_ENV_NOT_FOUND",
Self::CorruptedEnvironment { .. } => "MIGRATE_CORRUPTED",
Self::PackageExtractionFailed { .. } => "MIGRATE_EXTRACTION_FAILED",
Self::MigrationFailed { .. } => "MIGRATE_FAILED",
Self::MigrationNameConflict { .. } => "MIGRATE_NAME_CONFLICT",
Self::InvalidPythonPath { .. } => "PYTHON_INVALID_PATH",
Self::CascadeAborted => "UNINSTALL_CASCADE_ABORTED",
Self::SelfUpdateFailed { .. } => "SELF_UPDATE_FAILED",
}
}
pub fn suggestion(&self) -> Option<String> {
match self {
Self::VirtualenvNotFound { name } => {
Some(t!("suggestion.virtualenv_not_found", name = name).to_string())
}
Self::VirtualenvExists { .. } => Some(t!("suggestion.virtualenv_exists").to_string()),
Self::InvalidEnvName { .. } => Some(t!("suggestion.invalid_env_name").to_string()),
Self::UvNotFound => Some(t!("suggestion.uv_not_found").to_string()),
Self::PythonNotInstalled { version } => {
Some(t!("suggestion.python_not_installed", version = version).to_string())
}
Self::NoPythonVersions { .. } => Some(t!("suggestion.no_python_versions").to_string()),
Self::PyenvNotFound => Some(t!("suggestion.pyenv_not_found").to_string()),
Self::PyenvEnvNotFound { .. }
| Self::VenvWrapperEnvNotFound { .. }
| Self::CondaEnvNotFound { .. } => {
Some(t!("suggestion.source_env_not_found").to_string())
}
Self::MigrationNameConflict { .. } => {
Some(t!("suggestion.migration_name_conflict").to_string())
}
Self::InvalidPythonPath { .. } => {
Some(t!("suggestion.invalid_python_path").to_string())
}
_ => None,
}
}
pub fn migration_exit_code(&self) -> MigrationExitCode {
match self {
Self::PyenvNotFound
| Self::PyenvEnvNotFound { .. }
| Self::VenvWrapperEnvNotFound { .. }
| Self::CondaEnvNotFound { .. }
| Self::CorruptedEnvironment { .. } => MigrationExitCode::SourceError,
Self::MigrationFailed { .. } | Self::MigrationNameConflict { .. } => {
MigrationExitCode::CompleteFailure
}
_ => MigrationExitCode::CompleteFailure,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::io;
#[test]
#[serial]
fn test_virtualenv_not_found_message() {
let err = ScoopError::VirtualenvNotFound {
name: "myenv".to_string(),
};
assert_eq!(err.to_string(), "Can't find 'myenv' environment");
}
#[test]
#[serial]
fn test_virtualenv_exists_message() {
let err = ScoopError::VirtualenvExists {
name: "existing".to_string(),
};
assert_eq!(err.to_string(), "'existing' already exists");
}
#[test]
#[serial]
fn test_invalid_env_name_message() {
let err = ScoopError::InvalidEnvName {
name: "123bad".to_string(),
reason: "must start with a letter".to_string(),
};
assert!(err.to_string().contains("123bad"));
assert!(err.to_string().contains("must start with a letter"));
}
#[test]
#[serial]
fn test_invalid_python_version_message() {
let err = ScoopError::InvalidPythonVersion {
version: "abc".to_string(),
};
assert_eq!(err.to_string(), "Invalid Python version: abc");
}
#[test]
#[serial]
fn test_uv_not_found_message() {
let err = ScoopError::UvNotFound;
let msg = err.to_string();
assert!(msg.contains("uv not found"));
assert!(msg.contains("core engine"));
}
#[test]
#[serial]
fn test_uv_command_failed_message() {
let err = ScoopError::UvCommandFailed {
command: "venv".to_string(),
message: "Python not found".to_string(),
};
assert!(err.to_string().contains("uv venv failed"));
assert!(err.to_string().contains("Python not found"));
}
#[test]
#[serial]
fn test_path_error_message() {
let err = ScoopError::PathError("invalid UTF-8".to_string());
assert_eq!(err.to_string(), "Path error: invalid UTF-8");
}
#[test]
#[serial]
fn test_home_not_found_message() {
let err = ScoopError::HomeNotFound;
assert!(err.to_string().contains("Can't find home directory"));
}
#[test]
#[serial]
fn test_io_error_conversion() {
let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
let err: ScoopError = io_err.into();
assert!(matches!(err, ScoopError::Io(_)));
assert!(err.to_string().contains("file missing"));
}
#[test]
#[serial]
fn test_json_error_conversion() {
let json_str = "{ invalid json }";
let json_err: serde_json::Error =
serde_json::from_str::<serde_json::Value>(json_str).expect_err("should fail");
let err: ScoopError = json_err.into();
assert!(matches!(err, ScoopError::Json(_)));
}
#[test]
#[serial]
fn test_version_file_not_found_message() {
let err = ScoopError::VersionFileNotFound {
path: PathBuf::from("/some/path"),
};
assert!(err.to_string().contains("/some/path"));
assert!(err.to_string().contains("parent directories"));
}
#[test]
#[serial]
fn test_unsupported_shell_message() {
let err = ScoopError::UnsupportedShell {
shell: "fish".to_string(),
};
assert_eq!(err.to_string(), "Shell 'fish' not supported");
}
#[test]
#[serial]
fn test_python_not_installed_message() {
let err = ScoopError::PythonNotInstalled {
version: "3.13".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("3.13"));
assert!(msg.contains("not installed"));
}
#[test]
#[serial]
fn test_python_install_failed_message() {
let err = ScoopError::PythonInstallFailed {
version: "3.12".to_string(),
message: "network error".to_string(),
};
assert!(err.to_string().contains("Couldn't install"));
assert!(err.to_string().contains("3.12"));
assert!(err.to_string().contains("network error"));
}
#[test]
#[serial]
fn test_python_uninstall_failed_message() {
let err = ScoopError::PythonUninstallFailed {
version: "3.11".to_string(),
message: "in use".to_string(),
};
assert!(err.to_string().contains("Couldn't uninstall"));
assert!(err.to_string().contains("3.11"));
assert!(err.to_string().contains("in use"));
}
#[test]
#[serial]
fn test_no_python_versions_message() {
let err = ScoopError::NoPythonVersions {
pattern: "2.7".to_string(),
};
assert!(err.to_string().contains("2.7"));
}
#[test]
#[serial]
fn test_invalid_argument_message() {
let err = ScoopError::InvalidArgument {
message: "Cannot use --stable and --latest together".to_string(),
};
assert_eq!(err.to_string(), "Cannot use --stable and --latest together");
}
#[test]
#[serial]
fn test_io_error_not_found() {
let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
let err: ScoopError = io_err.into();
assert!(matches!(err, ScoopError::Io(_)));
assert!(err.to_string().contains("file not found"));
}
#[test]
#[serial]
fn test_io_error_permission_denied() {
let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "access denied");
let err: ScoopError = io_err.into();
assert!(matches!(err, ScoopError::Io(_)));
assert!(err.to_string().contains("access denied"));
}
#[test]
#[serial]
fn test_io_error_already_exists() {
let io_err = io::Error::new(io::ErrorKind::AlreadyExists, "file exists");
let err: ScoopError = io_err.into();
assert!(matches!(err, ScoopError::Io(_)));
}
#[test]
#[serial]
fn test_io_error_preserves_kind() {
let original = io::Error::new(io::ErrorKind::TimedOut, "operation timed out");
let err: ScoopError = original.into();
if let ScoopError::Io(inner) = err {
assert_eq!(inner.kind(), io::ErrorKind::TimedOut);
} else {
panic!("Expected ScoopError::Io");
}
}
#[test]
#[serial]
fn test_json_error_details() {
let json_err: serde_json::Error =
serde_json::from_str::<serde_json::Value>("{ invalid }").expect_err("should fail");
let err: ScoopError = json_err.into();
assert!(matches!(err, ScoopError::Json(_)));
let msg = err.to_string();
assert!(msg.contains("JSON"));
}
#[test]
#[serial]
fn test_result_type_alias() {
fn returns_result() -> Result<i32> {
Ok(42)
}
fn returns_error() -> Result<i32> {
Err(ScoopError::HomeNotFound)
}
assert_eq!(returns_result().unwrap(), 42);
assert!(returns_error().is_err());
}
#[test]
#[serial]
fn test_error_source_chain() {
use std::error::Error;
let io_err = io::Error::new(io::ErrorKind::NotFound, "original error");
let err: ScoopError = io_err.into();
if let ScoopError::Io(inner) = &err {
assert!(inner.source().is_none()); }
let json_err: serde_json::Error =
serde_json::from_str::<serde_json::Value>("invalid").expect_err("should fail");
let err: ScoopError = json_err.into();
assert!(err.source().is_some()); }
#[test]
#[serial]
fn test_error_messages_are_user_friendly() {
let errors = vec![
ScoopError::VirtualenvNotFound {
name: "test".to_string(),
},
ScoopError::VirtualenvExists {
name: "test".to_string(),
},
ScoopError::HomeNotFound,
ScoopError::UvNotFound,
];
for err in errors {
let msg = err.to_string();
assert!(!msg.is_empty(), "Error message should not be empty");
let first_char = msg.chars().next().unwrap();
assert!(
first_char.is_uppercase() || first_char == '\'' || first_char == 'u',
"Error message should start with uppercase, quote, or 'u': {}",
msg
);
}
}
#[test]
#[serial]
fn test_error_messages_include_context() {
let err = ScoopError::VirtualenvNotFound {
name: "myenv".to_string(),
};
assert!(
err.to_string().contains("myenv"),
"Error should include the env name"
);
let err = ScoopError::InvalidPythonVersion {
version: "abc".to_string(),
};
assert!(
err.to_string().contains("abc"),
"Error should include the invalid version"
);
let err = ScoopError::UnsupportedShell {
shell: "fish".to_string(),
};
assert!(
err.to_string().contains("fish"),
"Error should include the shell name"
);
}
#[test]
#[serial]
fn test_error_suggestions_provide_hints() {
let err = ScoopError::UvNotFound;
let suggestion = err.suggestion().expect("should have suggestion");
assert!(
suggestion.contains("curl") && suggestion.contains("astral.sh"),
"UvNotFound suggestion should include install command"
);
let err = ScoopError::PythonNotInstalled {
version: "3.13".to_string(),
};
let suggestion = err.suggestion().expect("should have suggestion");
assert!(
suggestion.contains("scoop install") && suggestion.contains("3.13"),
"PythonNotInstalled suggestion should include scoop install"
);
}
#[test]
#[serial]
fn test_error_messages_no_sensitive_info() {
let err = ScoopError::PathError("test path error".to_string());
let msg = err.to_string();
assert!(
!msg.contains("/Users/") && !msg.contains("/home/"),
"Error should not leak full paths"
);
}
#[test]
#[serial]
fn test_invalid_env_name_provides_reason() {
let err = ScoopError::InvalidEnvName {
name: "123".to_string(),
reason: "must start with a letter".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("123"), "Should include the invalid name");
assert!(
msg.contains("must start with a letter"),
"Should include the reason"
);
}
#[test]
#[serial]
fn test_uv_command_failed_includes_details() {
let err = ScoopError::UvCommandFailed {
command: "venv".to_string(),
message: "Python 3.15 not found".to_string(),
};
let msg = err.to_string();
assert!(
msg.contains("uv venv failed"),
"Should indicate uv command failure"
);
assert!(
msg.contains("Python 3.15 not found"),
"Should include the error message"
);
}
#[test]
#[serial]
fn test_version_file_not_found_shows_path() {
let err = ScoopError::VersionFileNotFound {
path: PathBuf::from("/project/dir"),
};
let msg = err.to_string();
assert!(msg.contains("/project/dir"), "Should include the path");
assert!(
msg.contains("parent directories"),
"Should mention parent directory search"
);
}
#[test]
#[serial]
fn test_error_code_env_not_found() {
let err = ScoopError::VirtualenvNotFound { name: "x".into() };
assert_eq!(err.code(), "ENV_NOT_FOUND");
}
#[test]
#[serial]
fn test_error_code_env_already_exists() {
let err = ScoopError::VirtualenvExists { name: "x".into() };
assert_eq!(err.code(), "ENV_ALREADY_EXISTS");
}
#[test]
#[serial]
fn test_error_code_env_invalid_name() {
let err = ScoopError::InvalidEnvName {
name: "x".into(),
reason: "r".into(),
};
assert_eq!(err.code(), "ENV_INVALID_NAME");
}
#[test]
#[serial]
fn test_error_code_python_invalid_version() {
let err = ScoopError::InvalidPythonVersion {
version: "x".into(),
};
assert_eq!(err.code(), "PYTHON_INVALID_VERSION");
}
#[test]
#[serial]
fn test_error_code_uv_not_installed() {
let err = ScoopError::UvNotFound;
assert_eq!(err.code(), "UV_NOT_INSTALLED");
}
#[test]
#[serial]
fn test_error_code_uv_command_failed() {
let err = ScoopError::UvCommandFailed {
command: "x".into(),
message: "m".into(),
};
assert_eq!(err.code(), "UV_COMMAND_FAILED");
}
#[test]
#[serial]
fn test_error_code_io_path_error() {
let err = ScoopError::PathError("x".into());
assert_eq!(err.code(), "IO_PATH_ERROR");
}
#[test]
#[serial]
fn test_error_code_io_home_not_found() {
let err = ScoopError::HomeNotFound;
assert_eq!(err.code(), "IO_HOME_NOT_FOUND");
}
#[test]
#[serial]
fn test_error_code_io_error() {
let err = ScoopError::Io(io::Error::other("test"));
assert_eq!(err.code(), "IO_ERROR");
}
#[test]
#[serial]
fn test_error_code_internal_json_error() {
let json_err: serde_json::Error =
serde_json::from_str::<serde_json::Value>("invalid").expect_err("should fail");
let err: ScoopError = json_err.into();
assert_eq!(err.code(), "INTERNAL_JSON_ERROR");
}
#[test]
#[serial]
fn test_error_code_config_version_file_not_found() {
let err = ScoopError::VersionFileNotFound {
path: PathBuf::new(),
};
assert_eq!(err.code(), "CONFIG_VERSION_FILE_NOT_FOUND");
}
#[test]
#[serial]
fn test_error_code_shell_not_supported() {
let err = ScoopError::UnsupportedShell { shell: "x".into() };
assert_eq!(err.code(), "SHELL_NOT_SUPPORTED");
}
#[test]
#[serial]
fn test_error_code_python_not_installed() {
let err = ScoopError::PythonNotInstalled {
version: "x".into(),
};
assert_eq!(err.code(), "PYTHON_NOT_INSTALLED");
}
#[test]
#[serial]
fn test_error_code_python_install_failed() {
let err = ScoopError::PythonInstallFailed {
version: "x".into(),
message: "m".into(),
};
assert_eq!(err.code(), "PYTHON_INSTALL_FAILED");
}
#[test]
#[serial]
fn test_error_code_python_uninstall_failed() {
let err = ScoopError::PythonUninstallFailed {
version: "x".into(),
message: "m".into(),
};
assert_eq!(err.code(), "PYTHON_UNINSTALL_FAILED");
}
#[test]
#[serial]
fn test_error_code_python_no_matching_version() {
let err = ScoopError::NoPythonVersions {
pattern: "x".into(),
};
assert_eq!(err.code(), "PYTHON_NO_MATCHING_VERSION");
}
#[test]
#[serial]
fn test_error_code_arg_invalid() {
let err = ScoopError::InvalidArgument {
message: "x".into(),
};
assert_eq!(err.code(), "ARG_INVALID");
}
#[test]
#[serial]
fn test_error_code_source_pyenv_not_found() {
let err = ScoopError::PyenvNotFound;
assert_eq!(err.code(), "SOURCE_PYENV_NOT_FOUND");
}
#[test]
#[serial]
fn test_error_code_source_pyenv_env_not_found() {
let err = ScoopError::PyenvEnvNotFound { name: "x".into() };
assert_eq!(err.code(), "SOURCE_PYENV_ENV_NOT_FOUND");
}
#[test]
#[serial]
fn test_error_code_source_venvwrapper_env_not_found() {
let err = ScoopError::VenvWrapperEnvNotFound { name: "x".into() };
assert_eq!(err.code(), "SOURCE_VENVWRAPPER_ENV_NOT_FOUND");
}
#[test]
#[serial]
fn test_error_code_source_conda_env_not_found() {
let err = ScoopError::CondaEnvNotFound { name: "x".into() };
assert_eq!(err.code(), "SOURCE_CONDA_ENV_NOT_FOUND");
}
#[test]
#[serial]
fn test_error_code_migrate_corrupted() {
let err = ScoopError::CorruptedEnvironment {
name: "x".into(),
reason: "r".into(),
};
assert_eq!(err.code(), "MIGRATE_CORRUPTED");
}
#[test]
#[serial]
fn test_error_code_migrate_extraction_failed() {
let err = ScoopError::PackageExtractionFailed { reason: "x".into() };
assert_eq!(err.code(), "MIGRATE_EXTRACTION_FAILED");
}
#[test]
#[serial]
fn test_error_code_migrate_failed() {
let err = ScoopError::MigrationFailed { reason: "x".into() };
assert_eq!(err.code(), "MIGRATE_FAILED");
}
#[test]
#[serial]
fn test_error_code_migrate_name_conflict() {
let err = ScoopError::MigrationNameConflict {
name: "x".into(),
existing: PathBuf::from("/path"),
};
assert_eq!(err.code(), "MIGRATE_NAME_CONFLICT");
}
#[test]
#[serial]
fn test_error_code_invalid_python_path() {
let err = ScoopError::InvalidPythonPath {
path: PathBuf::from("/bad/python"),
reason: "not found".into(),
};
assert_eq!(err.code(), "PYTHON_INVALID_PATH");
}
#[test]
#[serial]
fn test_invalid_python_path_message() {
let err = ScoopError::InvalidPythonPath {
path: PathBuf::from("/usr/bin/fake-python"),
reason: "file not found".into(),
};
let msg = err.to_string();
assert!(msg.contains("/usr/bin/fake-python"));
assert!(msg.contains("file not found"));
}
#[test]
#[serial]
fn test_invalid_python_path_suggestion() {
let err = ScoopError::InvalidPythonPath {
path: PathBuf::from("/bad/path"),
reason: "not executable".into(),
};
let suggestion = err.suggestion().expect("should have suggestion");
assert!(suggestion.starts_with("→"));
assert!(suggestion.contains("Python executable"));
}
#[test]
#[serial]
fn test_all_error_codes_are_unique() {
use std::collections::HashSet;
let codes: Vec<&str> = vec![
ScoopError::VirtualenvNotFound { name: "".into() }.code(),
ScoopError::VirtualenvExists { name: "".into() }.code(),
ScoopError::InvalidEnvName {
name: "".into(),
reason: "".into(),
}
.code(),
ScoopError::InvalidPythonVersion { version: "".into() }.code(),
ScoopError::UvNotFound.code(),
ScoopError::UvCommandFailed {
command: "".into(),
message: "".into(),
}
.code(),
ScoopError::PathError("".into()).code(),
ScoopError::HomeNotFound.code(),
ScoopError::Io(io::Error::other("")).code(),
ScoopError::VersionFileNotFound {
path: PathBuf::new(),
}
.code(),
ScoopError::UnsupportedShell { shell: "".into() }.code(),
ScoopError::PythonNotInstalled { version: "".into() }.code(),
ScoopError::PythonInstallFailed {
version: "".into(),
message: "".into(),
}
.code(),
ScoopError::PythonUninstallFailed {
version: "".into(),
message: "".into(),
}
.code(),
ScoopError::NoPythonVersions { pattern: "".into() }.code(),
ScoopError::InvalidArgument { message: "".into() }.code(),
ScoopError::PyenvNotFound.code(),
ScoopError::PyenvEnvNotFound { name: "".into() }.code(),
ScoopError::VenvWrapperEnvNotFound { name: "".into() }.code(),
ScoopError::CondaEnvNotFound { name: "".into() }.code(),
ScoopError::CorruptedEnvironment {
name: "".into(),
reason: "".into(),
}
.code(),
ScoopError::PackageExtractionFailed { reason: "".into() }.code(),
ScoopError::MigrationFailed { reason: "".into() }.code(),
ScoopError::MigrationNameConflict {
name: "".into(),
existing: PathBuf::new(),
}
.code(),
ScoopError::InvalidPythonPath {
path: PathBuf::new(),
reason: "".into(),
}
.code(),
ScoopError::CascadeAborted.code(),
];
let unique: HashSet<_> = codes.iter().collect();
assert_eq!(
codes.len(),
unique.len(),
"All error codes must be unique. Found duplicates."
);
}
#[test]
#[serial]
fn test_error_codes_follow_naming_convention() {
let codes = vec![
ScoopError::VirtualenvNotFound { name: "".into() }.code(),
ScoopError::UvNotFound.code(),
ScoopError::HomeNotFound.code(),
ScoopError::InvalidArgument { message: "".into() }.code(),
ScoopError::PyenvNotFound.code(),
ScoopError::PyenvEnvNotFound { name: "".into() }.code(),
ScoopError::VenvWrapperEnvNotFound { name: "".into() }.code(),
ScoopError::CondaEnvNotFound { name: "".into() }.code(),
ScoopError::CorruptedEnvironment {
name: "".into(),
reason: "".into(),
}
.code(),
ScoopError::PackageExtractionFailed { reason: "".into() }.code(),
ScoopError::MigrationFailed { reason: "".into() }.code(),
ScoopError::MigrationNameConflict {
name: "".into(),
existing: PathBuf::new(),
}
.code(),
ScoopError::InvalidPythonPath {
path: PathBuf::new(),
reason: "".into(),
}
.code(),
];
for code in codes {
assert!(
code.chars().all(|c| c.is_uppercase() || c == '_'),
"Error code '{}' should be SCREAMING_SNAKE_CASE",
code
);
}
}
#[test]
#[serial]
fn test_suggestion_virtualenv_not_found_includes_name() {
let err = ScoopError::VirtualenvNotFound {
name: "myenv".into(),
};
let suggestion = err.suggestion().unwrap();
assert!(suggestion.starts_with("→"));
assert!(suggestion.contains("myenv"));
assert!(suggestion.contains("scoop create"));
}
#[test]
#[serial]
fn test_suggestion_virtualenv_exists() {
let err = ScoopError::VirtualenvExists {
name: "existing".into(),
};
let suggestion = err.suggestion().unwrap();
assert!(suggestion.starts_with("→"));
assert!(suggestion.contains("--force"));
}
#[test]
#[serial]
fn test_suggestion_invalid_env_name() {
let err = ScoopError::InvalidEnvName {
name: "123".into(),
reason: "must start with letter".into(),
};
let suggestion = err.suggestion().unwrap();
assert!(suggestion.starts_with("→"));
assert!(suggestion.contains("letter"));
}
#[test]
#[serial]
fn test_suggestion_uv_not_found() {
let err = ScoopError::UvNotFound;
let suggestion = err.suggestion().unwrap();
assert!(suggestion.starts_with("→"));
assert!(suggestion.contains("curl"));
assert!(suggestion.contains("astral.sh"));
}
#[test]
#[serial]
fn test_suggestion_python_not_installed_includes_version() {
let err = ScoopError::PythonNotInstalled {
version: "3.13".into(),
};
let suggestion = err.suggestion().unwrap();
assert!(suggestion.starts_with("→"));
assert!(suggestion.contains("3.13"));
assert!(suggestion.contains("scoop install"));
}
#[test]
#[serial]
fn test_suggestion_no_python_versions() {
let err = ScoopError::NoPythonVersions {
pattern: "2.7".into(),
};
let suggestion = err.suggestion().unwrap();
assert!(suggestion.starts_with("→"));
assert!(suggestion.contains("scoop list --pythons"));
}
#[test]
#[serial]
fn test_no_suggestion_for_io_error() {
let err = ScoopError::Io(io::Error::other("test"));
assert!(err.suggestion().is_none());
}
#[test]
#[serial]
fn test_no_suggestion_for_json_error() {
let json_err: serde_json::Error =
serde_json::from_str::<serde_json::Value>("invalid").expect_err("should fail");
let err: ScoopError = json_err.into();
assert!(err.suggestion().is_none());
}
#[test]
#[serial]
fn test_no_suggestion_for_uv_command_failed() {
let err = ScoopError::UvCommandFailed {
command: "venv".into(),
message: "failed".into(),
};
assert!(err.suggestion().is_none());
}
#[test]
#[serial]
fn test_no_suggestion_for_path_error() {
let err = ScoopError::PathError("invalid path".into());
assert!(err.suggestion().is_none());
}
#[test]
#[serial]
fn test_no_suggestion_for_home_not_found() {
let err = ScoopError::HomeNotFound;
assert!(err.suggestion().is_none());
}
#[test]
#[serial]
fn test_no_suggestion_for_version_file_not_found() {
let err = ScoopError::VersionFileNotFound {
path: PathBuf::from("/project"),
};
assert!(err.suggestion().is_none());
}
#[test]
#[serial]
fn test_no_suggestion_for_unsupported_shell() {
let err = ScoopError::UnsupportedShell {
shell: "fish".into(),
};
assert!(err.suggestion().is_none());
}
#[test]
#[serial]
fn test_no_suggestion_for_python_install_failed() {
let err = ScoopError::PythonInstallFailed {
version: "3.12".into(),
message: "network error".into(),
};
assert!(err.suggestion().is_none());
}
#[test]
#[serial]
fn test_no_suggestion_for_python_uninstall_failed() {
let err = ScoopError::PythonUninstallFailed {
version: "3.11".into(),
message: "in use".into(),
};
assert!(err.suggestion().is_none());
}
#[test]
#[serial]
fn test_no_suggestion_for_invalid_python_version() {
let err = ScoopError::InvalidPythonVersion {
version: "abc".into(),
};
assert!(err.suggestion().is_none());
}
#[test]
#[serial]
fn test_no_suggestion_for_invalid_argument() {
let err = ScoopError::InvalidArgument {
message: "conflicting flags".into(),
};
assert!(err.suggestion().is_none());
}
}