deps_core/error.rs
1use thiserror::Error;
2
3/// Reconstructs the "{status} {reason}" text `reqwest::StatusCode`'s `Display`
4/// produces, since `HttpStatus` stores a bare `u16` for structural matching
5/// and loses the canonical reason phrase otherwise.
6fn http_status_message(status: u16, url: &str) -> String {
7 let reason = reqwest::StatusCode::from_u16(status)
8 .ok()
9 .and_then(|s| s.canonical_reason());
10 reason.map_or_else(
11 || format!("HTTP {status} for {url}"),
12 |reason| format!("HTTP {status} {reason} for {url}"),
13 )
14}
15
16/// Core error types for deps-lsp.
17///
18/// Extended from Phase 1 to support multiple ecosystems (Cargo, npm, PyPI).
19/// All errors provide structured error handling with source error tracking.
20///
21/// # Examples
22///
23/// ```
24/// use deps_core::error::{DepsError, Result};
25///
26/// fn parse_file(content: &str, file_type: &str) -> Result<()> {
27/// // Parsing errors are automatically wrapped
28/// if content.is_empty() {
29/// return Err(DepsError::ParseError {
30/// file_type: file_type.into(),
31/// source: Box::new(std::io::Error::new(
32/// std::io::ErrorKind::InvalidData,
33/// "empty content"
34/// )),
35/// });
36/// }
37/// Ok(())
38/// }
39/// ```
40#[derive(Error, Debug)]
41pub enum DepsError {
42 #[error("failed to parse {file_type}: {source}")]
43 ParseError {
44 file_type: String,
45 #[source]
46 source: Box<dyn std::error::Error + Send + Sync>,
47 },
48
49 #[error("registry request failed for {package}: {source}")]
50 RegistryError {
51 package: String,
52 #[source]
53 source: reqwest::Error,
54 },
55
56 #[error("cache error: {0}")]
57 CacheError(String),
58
59 /// A registry request was rejected for exceeding a rate limit. Unlike other variants,
60 /// `message` is a pre-vetted, IP-free, actionable hint safe to surface verbatim in a
61 /// per-dependency diagnostic (see [`Self::fetch_failure`]) — never build one from a raw
62 /// registry error body, which can embed the caller's public IP (`github.rs:332-346`).
63 #[error("{message}")]
64 RateLimited { message: String },
65
66 #[error("{package} not found on {registry}")]
67 PackageNotFound {
68 package: String,
69 registry: &'static str,
70 },
71
72 #[error("{}", http_status_message(*status, url))]
73 HttpStatus { url: String, status: u16 },
74
75 #[error("failed to parse {registry} response for {package}: {source}")]
76 ApiResponse {
77 package: String,
78 registry: &'static str,
79 #[source]
80 source: serde_json::Error,
81 },
82
83 #[error("response body for {url} exceeds {limit} byte limit")]
84 ResponseTooLarge { url: String, limit: usize },
85
86 /// Deliberately shared between two distinct rejection kinds: malformed version-requirement
87 /// strings (all ecosystems) and malformed Go module paths (`deps-go`, which has no separate
88 /// variant for the latter — see its `validate_module_path`). Nothing in the workspace
89 /// discriminates on this variant beyond rendering its message, so a consumer-specific split
90 /// was deferred (#399).
91 #[error("invalid version requirement: {0}")]
92 InvalidVersionReq(String),
93
94 #[error("I/O error: {0}")]
95 Io(#[from] std::io::Error),
96
97 #[error("JSON error: {0}")]
98 Json(#[from] serde_json::Error),
99
100 #[error("unsupported ecosystem: {0}")]
101 UnsupportedEcosystem(String),
102
103 #[error("ambiguous ecosystem detection for file: {0}")]
104 AmbiguousEcosystem(String),
105
106 #[error("invalid URI: {0}")]
107 InvalidUri(String),
108
109 /// Returned by `deps_core::cache::HttpCache`'s 4 send sites (issue #483) when
110 /// `network.offline` is set, instead of attempting the request. `url` is the request
111 /// that was blocked, for diagnostic/logging purposes.
112 #[error("offline: request to {url} was blocked by network.offline")]
113 Offline { url: String },
114
115 /// A multi-hop alternate/private-index chain's resolution was halted because a hop
116 /// returned a genuine transport error (5xx, timeout, connection failure) rather than a
117 /// clean "not found" — the chain deliberately does not fall through to a further, less
118 /// trusted hop in this case (`deps_pypi`'s FR-005(c)/NFR-003(3), #513). Carries no
119 /// arbitrary error text — mirrors [`Self::RateLimited`]'s pre-vetted-message precedent
120 /// (see [`Self::fetch_failure`]'s security-load-bearing invariant) — so its
121 /// classification there can safely be [`FetchFailure::Actionable`] with a fixed, safe
122 /// message, surfacing this case in hover/diagnostics instead of only a `tracing::warn!`.
123 #[error(
124 "index chain resolution halted by a transport error on one hop — not falling back \
125 to a less-trusted index"
126 )]
127 ChainResolutionHalted,
128}
129
130impl DepsError {
131 /// Returns `true` when this error means the registry was successfully asked and
132 /// answered "this package doesn't exist", as opposed to the registry not having
133 /// been answerable at all (network failure, timeout, malformed response, 5xx).
134 ///
135 /// Distinguishing the two matters for diagnostics (#267): a genuine not-found is
136 /// evidence the package name is wrong, while any other error is evidence only that
137 /// this particular request failed — reporting the latter as "Unknown package" would
138 /// mislabel a transient registry outage as a nonexistent dependency. Covers
139 /// [`DepsError::PackageNotFound`] (the ecosystems that map a 404 to it explicitly:
140 /// npm, PyPI, Go, Swift) and a bare [`DepsError::HttpStatus`] with `status == 404`
141 /// (the ecosystems that propagate the raw HTTP status instead: Cargo, Maven, Gradle,
142 /// Bundler, Dart, Composer, NuGet).
143 ///
144 /// # Examples
145 ///
146 /// ```
147 /// use deps_core::DepsError;
148 ///
149 /// let not_found = DepsError::PackageNotFound {
150 /// package: "left-pad".into(),
151 /// registry: "npm",
152 /// };
153 /// assert!(not_found.is_not_found());
154 ///
155 /// let http_404 = DepsError::HttpStatus {
156 /// url: "https://crates.io/api/v1/crates/left-pad".into(),
157 /// status: 404,
158 /// };
159 /// assert!(http_404.is_not_found());
160 ///
161 /// let outage = DepsError::HttpStatus {
162 /// url: "https://crates.io/api/v1/crates/serde".into(),
163 /// status: 503,
164 /// };
165 /// assert!(!outage.is_not_found());
166 ///
167 /// let cache_err = DepsError::CacheError("connection reset".into());
168 /// assert!(!cache_err.is_not_found());
169 /// ```
170 #[must_use]
171 pub const fn is_not_found(&self) -> bool {
172 matches!(
173 self,
174 Self::PackageNotFound { .. } | Self::HttpStatus { status: 404, .. }
175 )
176 }
177
178 /// Classifies this error for the per-dependency "registry lookup failed" diagnostic
179 /// (#478), distinguishing a failure with a safe, actionable hint to show the user from
180 /// one whose raw text must never reach a diagnostic.
181 ///
182 /// **Security-load-bearing invariant**: [`FetchFailure::Actionable`] is produced *only*
183 /// from [`Self::RateLimited`]'s pre-vetted, IP-free canned message. Every other variant
184 /// must classify as [`FetchFailure::Transient`] — never call `.to_string()`/`Display` on
185 /// an arbitrary `DepsError` to build an `Actionable` value, since a raw `HttpStatus` or
186 /// `RegistryError` body can embed the caller's public IP (`github.rs:332-346`, exercised
187 /// by the `github` crate's `test_parse_tags_page_github_rate_limit_returns_error`).
188 ///
189 /// # Examples
190 ///
191 /// ```
192 /// use deps_core::error::{DepsError, FetchFailure};
193 ///
194 /// let rate_limited = DepsError::RateLimited { message: "set GITHUB_TOKEN".into() };
195 /// assert_eq!(
196 /// rate_limited.fetch_failure(),
197 /// FetchFailure::Actionable("set GITHUB_TOKEN".into())
198 /// );
199 ///
200 /// let other = DepsError::CacheError("connection reset".into());
201 /// assert_eq!(other.fetch_failure(), FetchFailure::Transient);
202 /// ```
203 #[must_use]
204 pub fn fetch_failure(&self) -> FetchFailure {
205 match self {
206 Self::RateLimited { message } => FetchFailure::Actionable(message.clone()),
207 // Fixed, pre-vetted message — see `Self::ChainResolutionHalted`'s own doc for why
208 // this is safe to build as `Actionable` the same way `RateLimited` is.
209 Self::ChainResolutionHalted => FetchFailure::Actionable(
210 "index unreachable — resolution halted, not falling back to a less-trusted \
211 index"
212 .to_string(),
213 ),
214 _ => FetchFailure::Transient,
215 }
216 }
217
218 /// Returns `true` when this error means a request was blocked by `network.offline`
219 /// (issue #483), as opposed to any other network or registry failure.
220 ///
221 /// Used by `deps_maven::registry` to skip poisoning its negative-search-failure
222 /// cache with an offline block, so toggling `network.offline` back to `false` takes
223 /// effect immediately instead of being masked by `RECENT_FAILURE_TTL`.
224 ///
225 /// # Examples
226 ///
227 /// ```
228 /// use deps_core::DepsError;
229 ///
230 /// let offline = DepsError::Offline { url: "https://crates.io/".into() };
231 /// assert!(offline.is_offline());
232 ///
233 /// let other = DepsError::CacheError("connection reset".into());
234 /// assert!(!other.is_offline());
235 /// ```
236 #[must_use]
237 pub const fn is_offline(&self) -> bool {
238 matches!(self, Self::Offline { .. })
239 }
240}
241
242/// Outcome of a registry fetch attempt for one dependency, as recorded in
243/// `DocumentState::outcomes` (`deps-lsp`) and rendered by
244/// [`crate::lsp_helpers::generate_diagnostics_from_cache`] (#478).
245///
246/// Replaces a bare `HashSet<PackageName>` membership check so the per-dependency diagnostic
247/// can distinguish a failure with a safe, user-actionable hint from an opaque one, without
248/// ever threading raw, potentially IP-bearing error text into the diagnostic (see
249/// [`DepsError::fetch_failure`]).
250#[derive(Clone, Debug, PartialEq, Eq)]
251pub enum FetchFailure {
252 /// The fetch failed with a pre-vetted, safe-to-display hint (currently only produced
253 /// from [`DepsError::RateLimited`]).
254 Actionable(String),
255 /// The fetch failed for a reason with no safe user-facing detail to show — the
256 /// diagnostic falls back to a generic "lookup failed" message.
257 Transient,
258 /// The dependency was never actually queried (e.g. a name/source collision detected
259 /// before the fetch, see `deps-lsp`'s `dedup_dependencies_by_source`) — renders the
260 /// same generic "lookup failed" message as [`Self::Transient`], since the absence of
261 /// an attempt is not evidence the package doesn't exist.
262 NotAttempted,
263}
264
265/// Convenience type alias for `Result<T, DepsError>`.
266///
267/// This is the standard `Result` type used throughout the deps-lsp codebase.
268/// It simplifies function signatures by defaulting the error type to `DepsError`.
269///
270/// # Examples
271///
272/// ```
273/// use deps_core::error::Result;
274///
275/// fn get_version(name: &str) -> Result<String> {
276/// if name.is_empty() {
277/// return Err(deps_core::error::DepsError::CacheError("empty name".into()));
278/// }
279/// Ok("1.0.0".into())
280/// }
281/// ```
282pub type Result<T> = std::result::Result<T, DepsError>;
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn test_error_display() {
290 let error = DepsError::CacheError("test error".into());
291 assert_eq!(error.to_string(), "cache error: test error");
292 }
293
294 #[test]
295 fn test_response_too_large() {
296 let error = DepsError::ResponseTooLarge {
297 url: "https://example.com/data".into(),
298 limit: 32 * 1024 * 1024,
299 };
300 assert_eq!(
301 error.to_string(),
302 "response body for https://example.com/data exceeds 33554432 byte limit"
303 );
304 }
305
306 #[test]
307 fn test_invalid_version_req() {
308 let error = DepsError::InvalidVersionReq("invalid".into());
309 assert_eq!(error.to_string(), "invalid version requirement: invalid");
310 }
311
312 #[test]
313 fn test_parse_error() {
314 let io_err = std::io::Error::new(std::io::ErrorKind::InvalidData, "bad data");
315 let error = DepsError::ParseError {
316 file_type: "Cargo.toml".into(),
317 source: Box::new(io_err),
318 };
319 assert!(error.to_string().contains("failed to parse Cargo.toml"));
320 }
321
322 #[test]
323 fn test_io_error_conversion() {
324 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
325 let error: DepsError = io_err.into();
326 assert!(error.to_string().contains("I/O error"));
327 }
328
329 #[test]
330 fn test_unsupported_ecosystem() {
331 let error = DepsError::UnsupportedEcosystem("unknown".into());
332 assert_eq!(error.to_string(), "unsupported ecosystem: unknown");
333 }
334
335 #[test]
336 fn test_ambiguous_ecosystem() {
337 let error = DepsError::AmbiguousEcosystem("file.txt".into());
338 assert_eq!(
339 error.to_string(),
340 "ambiguous ecosystem detection for file: file.txt"
341 );
342 }
343
344 #[test]
345 fn test_invalid_uri() {
346 let error = DepsError::InvalidUri("http://example.com".into());
347 assert_eq!(error.to_string(), "invalid URI: http://example.com");
348 }
349
350 #[test]
351 fn test_offline_error_display_and_predicate() {
352 let error = DepsError::Offline {
353 url: "https://crates.io/api/v1/crates/serde".into(),
354 };
355 assert!(error.to_string().contains("offline"));
356 assert!(error.is_offline());
357 assert!(!error.is_not_found());
358
359 let other = DepsError::CacheError("boom".into());
360 assert!(!other.is_offline());
361 }
362
363 #[test]
364 fn test_package_not_found() {
365 let error = DepsError::PackageNotFound {
366 package: "flask".into(),
367 registry: "PyPI",
368 };
369 assert_eq!(error.to_string(), "flask not found on PyPI");
370 }
371
372 #[test]
373 fn test_http_status_with_known_reason() {
374 let error = DepsError::HttpStatus {
375 url: "https://example.com/data".into(),
376 status: 404,
377 };
378 assert_eq!(
379 error.to_string(),
380 "HTTP 404 Not Found for https://example.com/data"
381 );
382 }
383
384 #[test]
385 fn test_http_status_with_unknown_code() {
386 let error = DepsError::HttpStatus {
387 url: "https://example.com/data".into(),
388 status: 599,
389 };
390 assert_eq!(error.to_string(), "HTTP 599 for https://example.com/data");
391 }
392
393 #[test]
394 fn test_api_response_error() {
395 let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
396 let error = DepsError::ApiResponse {
397 package: "flask".into(),
398 registry: "PyPI",
399 source: json_err,
400 };
401 assert!(
402 error
403 .to_string()
404 .starts_with("failed to parse PyPI response for flask:")
405 );
406 }
407
408 /// Exhaustive companion to the doc-test on [`DepsError::fetch_failure`]: every variant
409 /// other than [`DepsError::RateLimited`] and [`DepsError::ChainResolutionHalted`] must
410 /// classify as [`FetchFailure::Transient`]. This is the invariant the doc comment calls
411 /// security-load-bearing (a future variant wired to `Actionable` by mistake could leak
412 /// raw, potentially IP-bearing error text into a diagnostic), so it must be a real test
413 /// enumerating every variant, not just a handful of spot checks. `ChainResolutionHalted`
414 /// is exempted from the "everything else is Transient" list — like `RateLimited`, it
415 /// carries no arbitrary payload, only a fixed, pre-vetted message, so it is safe to be
416 /// the second `Actionable`-producing variant (see its own doc and #513's M2 fix).
417 #[test]
418 fn test_fetch_failure_classifies_every_non_rate_limited_variant_as_transient() {
419 // A `reqwest::Error` built from an invalid URL — `RequestBuilder::build`
420 // is synchronous and fails on URL parsing alone, so this needs no network
421 // access or async runtime.
422 let reqwest_err = reqwest::Client::new()
423 .get("not a valid url")
424 .build()
425 .unwrap_err();
426 let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
427 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
428
429 let non_rate_limited = [
430 DepsError::ParseError {
431 file_type: "Cargo.toml".into(),
432 source: Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, "bad")),
433 },
434 DepsError::RegistryError {
435 package: "flask".into(),
436 source: reqwest_err,
437 },
438 DepsError::CacheError("connection reset".into()),
439 DepsError::PackageNotFound {
440 package: "flask".into(),
441 registry: "PyPI",
442 },
443 DepsError::HttpStatus {
444 url: "https://example.com".into(),
445 status: 500,
446 },
447 DepsError::ApiResponse {
448 package: "flask".into(),
449 registry: "PyPI",
450 source: json_err,
451 },
452 DepsError::ResponseTooLarge {
453 url: "https://example.com".into(),
454 limit: 1024,
455 },
456 DepsError::InvalidVersionReq("bad range".into()),
457 DepsError::Io(io_err),
458 DepsError::Json(serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err()),
459 DepsError::UnsupportedEcosystem("unknown".into()),
460 DepsError::AmbiguousEcosystem("file.txt".into()),
461 DepsError::InvalidUri("not a uri".into()),
462 DepsError::Offline {
463 url: "https://example.com".into(),
464 },
465 ];
466
467 for error in non_rate_limited {
468 assert_eq!(
469 error.fetch_failure(),
470 FetchFailure::Transient,
471 "expected Transient for {error:?}"
472 );
473 }
474
475 let rate_limited = DepsError::RateLimited {
476 message: "set GITHUB_TOKEN to increase the rate limit".into(),
477 };
478 assert_eq!(
479 rate_limited.fetch_failure(),
480 FetchFailure::Actionable("set GITHUB_TOKEN to increase the rate limit".into())
481 );
482
483 assert_eq!(
484 DepsError::ChainResolutionHalted.fetch_failure(),
485 FetchFailure::Actionable(
486 "index unreachable — resolution halted, not falling back to a less-trusted \
487 index"
488 .to_string()
489 )
490 );
491 }
492}