use thiserror::Error;
fn http_status_message(status: u16, url: &str) -> String {
let reason = reqwest::StatusCode::from_u16(status)
.ok()
.and_then(|s| s.canonical_reason());
reason.map_or_else(
|| format!("HTTP {status} for {url}"),
|reason| format!("HTTP {status} {reason} for {url}"),
)
}
#[derive(Error, Debug)]
pub enum DepsError {
#[error("failed to parse {file_type}: {source}")]
ParseError {
file_type: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("registry request failed for {package}: {source}")]
RegistryError {
package: String,
#[source]
source: reqwest::Error,
},
#[error("cache error: {0}")]
CacheError(String),
#[error("{message}")]
RateLimited { message: String },
#[error("{package} not found on {registry}")]
PackageNotFound {
package: String,
registry: &'static str,
},
#[error("{}", http_status_message(*status, url))]
HttpStatus { url: String, status: u16 },
#[error("failed to parse {registry} response for {package}: {source}")]
ApiResponse {
package: String,
registry: &'static str,
#[source]
source: serde_json::Error,
},
#[error("response body for {url} exceeds {limit} byte limit")]
ResponseTooLarge { url: String, limit: usize },
#[error("invalid version requirement: {0}")]
InvalidVersionReq(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("unsupported ecosystem: {0}")]
UnsupportedEcosystem(String),
#[error("ambiguous ecosystem detection for file: {0}")]
AmbiguousEcosystem(String),
#[error("invalid URI: {0}")]
InvalidUri(String),
#[error("offline: request to {url} was blocked by network.offline")]
Offline { url: String },
#[error(
"index chain resolution halted by a transport error on one hop — not falling back \
to a less-trusted index"
)]
ChainResolutionHalted,
}
impl DepsError {
#[must_use]
pub const fn is_not_found(&self) -> bool {
matches!(
self,
Self::PackageNotFound { .. } | Self::HttpStatus { status: 404, .. }
)
}
#[must_use]
pub fn fetch_failure(&self) -> FetchFailure {
match self {
Self::RateLimited { message } => FetchFailure::Actionable(message.clone()),
Self::ChainResolutionHalted => FetchFailure::Actionable(
"index unreachable — resolution halted, not falling back to a less-trusted \
index"
.to_string(),
),
_ => FetchFailure::Transient,
}
}
#[must_use]
pub const fn is_offline(&self) -> bool {
matches!(self, Self::Offline { .. })
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FetchFailure {
Actionable(String),
Transient,
NotAttempted,
}
pub type Result<T> = std::result::Result<T, DepsError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_display() {
let error = DepsError::CacheError("test error".into());
assert_eq!(error.to_string(), "cache error: test error");
}
#[test]
fn test_response_too_large() {
let error = DepsError::ResponseTooLarge {
url: "https://example.com/data".into(),
limit: 32 * 1024 * 1024,
};
assert_eq!(
error.to_string(),
"response body for https://example.com/data exceeds 33554432 byte limit"
);
}
#[test]
fn test_invalid_version_req() {
let error = DepsError::InvalidVersionReq("invalid".into());
assert_eq!(error.to_string(), "invalid version requirement: invalid");
}
#[test]
fn test_parse_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::InvalidData, "bad data");
let error = DepsError::ParseError {
file_type: "Cargo.toml".into(),
source: Box::new(io_err),
};
assert!(error.to_string().contains("failed to parse Cargo.toml"));
}
#[test]
fn test_io_error_conversion() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let error: DepsError = io_err.into();
assert!(error.to_string().contains("I/O error"));
}
#[test]
fn test_unsupported_ecosystem() {
let error = DepsError::UnsupportedEcosystem("unknown".into());
assert_eq!(error.to_string(), "unsupported ecosystem: unknown");
}
#[test]
fn test_ambiguous_ecosystem() {
let error = DepsError::AmbiguousEcosystem("file.txt".into());
assert_eq!(
error.to_string(),
"ambiguous ecosystem detection for file: file.txt"
);
}
#[test]
fn test_invalid_uri() {
let error = DepsError::InvalidUri("http://example.com".into());
assert_eq!(error.to_string(), "invalid URI: http://example.com");
}
#[test]
fn test_offline_error_display_and_predicate() {
let error = DepsError::Offline {
url: "https://crates.io/api/v1/crates/serde".into(),
};
assert!(error.to_string().contains("offline"));
assert!(error.is_offline());
assert!(!error.is_not_found());
let other = DepsError::CacheError("boom".into());
assert!(!other.is_offline());
}
#[test]
fn test_package_not_found() {
let error = DepsError::PackageNotFound {
package: "flask".into(),
registry: "PyPI",
};
assert_eq!(error.to_string(), "flask not found on PyPI");
}
#[test]
fn test_http_status_with_known_reason() {
let error = DepsError::HttpStatus {
url: "https://example.com/data".into(),
status: 404,
};
assert_eq!(
error.to_string(),
"HTTP 404 Not Found for https://example.com/data"
);
}
#[test]
fn test_http_status_with_unknown_code() {
let error = DepsError::HttpStatus {
url: "https://example.com/data".into(),
status: 599,
};
assert_eq!(error.to_string(), "HTTP 599 for https://example.com/data");
}
#[test]
fn test_api_response_error() {
let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
let error = DepsError::ApiResponse {
package: "flask".into(),
registry: "PyPI",
source: json_err,
};
assert!(
error
.to_string()
.starts_with("failed to parse PyPI response for flask:")
);
}
#[test]
fn test_fetch_failure_classifies_every_non_rate_limited_variant_as_transient() {
let reqwest_err = reqwest::Client::new()
.get("not a valid url")
.build()
.unwrap_err();
let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
let non_rate_limited = [
DepsError::ParseError {
file_type: "Cargo.toml".into(),
source: Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, "bad")),
},
DepsError::RegistryError {
package: "flask".into(),
source: reqwest_err,
},
DepsError::CacheError("connection reset".into()),
DepsError::PackageNotFound {
package: "flask".into(),
registry: "PyPI",
},
DepsError::HttpStatus {
url: "https://example.com".into(),
status: 500,
},
DepsError::ApiResponse {
package: "flask".into(),
registry: "PyPI",
source: json_err,
},
DepsError::ResponseTooLarge {
url: "https://example.com".into(),
limit: 1024,
},
DepsError::InvalidVersionReq("bad range".into()),
DepsError::Io(io_err),
DepsError::Json(serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err()),
DepsError::UnsupportedEcosystem("unknown".into()),
DepsError::AmbiguousEcosystem("file.txt".into()),
DepsError::InvalidUri("not a uri".into()),
DepsError::Offline {
url: "https://example.com".into(),
},
];
for error in non_rate_limited {
assert_eq!(
error.fetch_failure(),
FetchFailure::Transient,
"expected Transient for {error:?}"
);
}
let rate_limited = DepsError::RateLimited {
message: "set GITHUB_TOKEN to increase the rate limit".into(),
};
assert_eq!(
rate_limited.fetch_failure(),
FetchFailure::Actionable("set GITHUB_TOKEN to increase the rate limit".into())
);
assert_eq!(
DepsError::ChainResolutionHalted.fetch_failure(),
FetchFailure::Actionable(
"index unreachable — resolution halted, not falling back to a less-trusted \
index"
.to_string()
)
);
}
}