Skip to main content

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}