Skip to main content

deps_core/
diagnostic.rs

1//! Protocol-agnostic diagnostic types (issue #1083, spec 064).
2//!
3//! [`crate::diagnostic::Diagnostic`], [`crate::diagnostic::Severity`],
4//! [`crate::diagnostic::RelatedInformation`], and [`crate::diagnostic::CodeDescription`] are
5//! the domain-level replacement for `tower_lsp_server::ls_types::{Diagnostic,
6//! DiagnosticSeverity, DiagnosticRelatedInformation, CodeDescription}` in
7//! [`crate::lsp_helpers::generate_diagnostics_from_cache`]'s and
8//! [`crate::ecosystem::Ecosystem::generate_diagnostics`]'s return value: field-for-field
9//! equivalent, but this module carries no dependency on `tower-lsp-server`, so a consumer that
10//! only needs parsed diagnostic data (e.g. `deps-cli`) never has to name an LSP-protocol type
11//! or link `tower-lsp-server` to run `check`.
12//!
13//! `deps-lsp` converts a [`crate::diagnostic::Diagnostic`] (and its nested types) into the
14//! matching `ls_types` type at its own boundary (`crates/deps-lsp/src/lsp_types_interop.rs`)
15//! — the same shape [`crate::position`] already established for `Position`/`Range` (see that
16//! module's docs for why the conversion lives in `deps-lsp` rather than here for
17//! `url::Url`-carrying types).
18
19use crate::position::Range;
20
21/// A diagnostic severity level, field-for-field identical to
22/// `tower_lsp_server::ls_types::DiagnosticSeverity`'s four defined values, but carrying no
23/// dependency on `tower-lsp-server`.
24///
25/// `Deserialize` is deliberately as permissive as `ls_types::DiagnosticSeverity` itself
26/// (`#[serde(transparent)]` over a bare `i32`, which accepts *any* integer with no
27/// validation at all) — see this type's `Deserialize` impl doc for why an out-of-range
28/// integer clamps to the nearest defined variant instead of failing deserialization.
29///
30/// Declaration order doubles as the canonical severity ordering (`Error` most severe,
31/// `Hint` least) — the single source of truth every ranking/sorting call site in the
32/// workspace should derive from via `Ord`/`PartialOrd` rather than hand-rolling its own
33/// numeric rank (issue #1532 code-review finding 1: two independently hand-rolled, oppositely
34/// signed rank functions had drifted apart before this derive was added).
35///
36/// # Examples
37///
38/// ```
39/// use deps_core::diagnostic::Severity;
40///
41/// let severity = Severity::Warning;
42/// assert_eq!(severity, Severity::Warning);
43/// assert_ne!(severity, Severity::Error);
44///
45/// // `Error` is the most severe, `Hint` the least — matches the LSP wire ordering
46/// // (`Severity`'s `Serialize` impl uses the same `1..=4` encoding).
47/// assert!(Severity::Error < Severity::Warning);
48/// assert!(Severity::Warning < Severity::Information);
49/// assert!(Severity::Information < Severity::Hint);
50/// ```
51#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
52pub enum Severity {
53    /// Reports an error.
54    Error,
55    /// Reports a warning.
56    Warning,
57    /// Reports information.
58    Information,
59    /// Reports a hint.
60    Hint,
61}
62
63// Manual `Serialize`/`Deserialize` (rather than a derive) so the wire representation stays
64// the LSP protocol's own `1..=4` integer encoding (`tower_lsp_server::ls_types::DiagnosticSeverity`
65// is `#[serde(transparent)]` over `i32`) — `crate::policy_config::DiagnosticsConfig`'s existing
66// JSON config schema and tests already depend on this exact numbering, and this type replaces
67// `ls_types::DiagnosticSeverity` as that struct's field type.
68impl serde::Serialize for Severity {
69    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
70        let value: i32 = match self {
71            Self::Error => 1,
72            Self::Warning => 2,
73            Self::Information => 3,
74            Self::Hint => 4,
75        };
76        serializer.serialize_i32(value)
77    }
78}
79
80/// Deliberately infallible for any `i32` input, mirroring
81/// `tower_lsp_server::ls_types::DiagnosticSeverity`'s own `#[serde(transparent)]` newtype
82/// over `i32` — the LSP protocol places no validation on this field either, so a value
83/// outside `1..=4` was never a deserialization error before this domain type replaced
84/// `ls_types::DiagnosticSeverity` as `DiagnosticsConfig`'s field type (issue #1083).
85///
86/// Rejecting out-of-range values here instead would be a real regression: `DepsConfig`
87/// (`deps-lsp/src/server.rs`'s `parse_config`) and `deps-cli`'s config loader both
88/// deserialize a much larger struct in one shot, so a single out-of-range severity would
89/// fail the *entire* config update — exactly the failure mode `#[serde(deny_unknown_fields)]`
90/// exists to prevent for a different class of mistake, reintroduced here through a
91/// stricter-than-the-protocol enum. Out-of-range values instead clamp to the nearest
92/// defined variant: `<= 1` and negative values clamp to [`Self::Error`] (fail safe — never
93/// silently downgrade a possibly-important severity), `>= 4` clamps to [`Self::Hint`].
94impl<'de> serde::Deserialize<'de> for Severity {
95    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
96        Ok(match i32::deserialize(deserializer)? {
97            ..=1 => Self::Error,
98            2 => Self::Warning,
99            3 => Self::Information,
100            4.. => Self::Hint,
101        })
102    }
103}
104
105/// An advisory/rule URL attached to a `Diagnostic`'s `code`, field-for-field identical to
106/// `tower_lsp_server::ls_types::CodeDescription` but carrying no dependency on
107/// `tower-lsp-server`.
108///
109/// # Examples
110///
111/// ```
112/// use deps_core::diagnostic::CodeDescription;
113/// use url::Url;
114///
115/// let href = Url::parse("https://osv.dev/GHSA-xxxx").unwrap();
116/// let code_description = CodeDescription::new(href.clone());
117/// assert_eq!(code_description.href, href);
118/// ```
119#[non_exhaustive]
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct CodeDescription {
122    /// URI describing the diagnostic's code, e.g. an OSV advisory page.
123    pub href: url::Url,
124}
125
126impl CodeDescription {
127    /// Builds a [`CodeDescription`] from its `href` field.
128    ///
129    /// Needed because [`Self`] is `#[non_exhaustive]`: a struct literal only works inside
130    /// this crate, so every other crate must call this constructor instead.
131    ///
132    /// # Examples
133    ///
134    /// ```
135    /// use deps_core::diagnostic::CodeDescription;
136    /// use url::Url;
137    ///
138    /// let code_description = CodeDescription::new(Url::parse("https://example.com").unwrap());
139    /// assert_eq!(code_description.href.as_str(), "https://example.com/");
140    /// ```
141    #[must_use]
142    pub const fn new(href: url::Url) -> Self {
143        Self { href }
144    }
145}
146
147/// A related location and message attached to a [`Diagnostic`].
148///
149/// Field-for-field identical to `tower_lsp_server::ls_types::DiagnosticRelatedInformation` but
150/// with its nested `Location` flattened in and `Location::uri` typed as `url::Url` instead of
151/// `ls_types::Uri` (matching #1071's file-identity approach for
152/// [`crate::ecosystem::ParseResult::uri`]).
153///
154/// # Examples
155///
156/// ```
157/// use deps_core::diagnostic::RelatedInformation;
158/// use deps_core::position::{Position, Range};
159/// use url::Url;
160///
161/// let related = RelatedInformation::new(
162///     Url::parse("file:///Cargo.toml").unwrap(),
163///     Range::new(Position::new(0, 0), Position::new(0, 4)),
164///     "also declared here",
165/// );
166/// assert_eq!(related.message(), "also declared here");
167/// ```
168///
169/// # The sanitization backstop this design relies on (issue #1280)
170///
171/// `message` is private — the only way to build a `RelatedInformation` from outside this
172/// crate is through [`Self::new`], which sanitizes it. Setting `message` directly fails to
173/// compile (the field does not exist from outside this crate):
174///
175/// ```compile_fail
176/// use deps_core::diagnostic::RelatedInformation;
177/// use deps_core::position::{Position, Range};
178/// use url::Url;
179///
180/// let mut related = RelatedInformation::new(
181///     Url::parse("file:///Cargo.toml").unwrap(),
182///     Range::new(Position::new(0, 0), Position::new(0, 1)),
183///     "safe",
184/// );
185/// related.message = "raw".to_string();
186/// ```
187#[non_exhaustive]
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct RelatedInformation {
190    /// File the related location is in.
191    pub uri: url::Url,
192    /// Span within [`Self::uri`].
193    pub range: Range,
194    message: String,
195}
196
197impl RelatedInformation {
198    /// Builds a [`RelatedInformation`] from its `uri`/`range`/`message` fields.
199    ///
200    /// Needed because [`Self`] is `#[non_exhaustive]`: a struct literal only works inside
201    /// this crate, so every other crate must call this constructor to build a new value.
202    ///
203    /// `message` is sanitized through an internal markdown-unsafe-character filter (#1276)
204    /// on this constructor path, on top of (not instead of) producer-side sanitization. This
205    /// is a real guarantee, not just a best-effort backstop (#1280): [`Self::message`] is
206    /// private, so [`Self::new`] is the only way to set it, and there is no setter that
207    /// bypasses sanitization. The filter is idempotent, so callers that already sanitized
208    /// their input are unaffected.
209    ///
210    /// # Examples
211    ///
212    /// ```
213    /// use deps_core::diagnostic::RelatedInformation;
214    /// use deps_core::position::{Position, Range};
215    /// use url::Url;
216    ///
217    /// let related = RelatedInformation::new(
218    ///     Url::parse("file:///Cargo.toml").unwrap(),
219    ///     Range::new(Position::new(1, 0), Position::new(1, 4)),
220    ///     "duplicate entry",
221    /// );
222    /// assert_eq!(related.range.start.line, 1);
223    /// ```
224    #[must_use]
225    pub fn new(uri: url::Url, range: Range, message: impl Into<String>) -> Self {
226        Self {
227            uri,
228            range,
229            message: crate::lsp_helpers::replace_markdown_unsafe_chars(&message.into()),
230        }
231    }
232
233    /// Returns the sanitized message describing the relation.
234    ///
235    /// # Examples
236    ///
237    /// ```
238    /// use deps_core::diagnostic::RelatedInformation;
239    /// use deps_core::position::{Position, Range};
240    /// use url::Url;
241    ///
242    /// let related = RelatedInformation::new(
243    ///     Url::parse("file:///Cargo.toml").unwrap(),
244    ///     Range::new(Position::new(0, 0), Position::new(0, 1)),
245    ///     "also declared here",
246    /// );
247    /// assert_eq!(related.message(), "also declared here");
248    /// ```
249    #[must_use]
250    pub fn message(&self) -> &str {
251        &self.message
252    }
253}
254
255/// A single diagnostic finding.
256///
257/// Field-for-field identical to `tower_lsp_server::ls_types::Diagnostic`'s commonly used
258/// subset but carrying no dependency on `tower-lsp-server`. Returned by
259/// [`crate::lsp_helpers::generate_diagnostics_from_cache`] and
260/// [`crate::ecosystem::Ecosystem::generate_diagnostics`].
261///
262/// `deps-lsp` converts this into `tower_lsp_server::ls_types::Diagnostic` at its own boundary
263/// (`crates/deps-lsp/src/lsp_types_interop.rs`); `deps-cli` consumes it directly to build its
264/// `check` output without ever naming an `ls_types` type.
265///
266/// # Examples
267///
268/// ```
269/// use deps_core::diagnostic::{Diagnostic, Severity};
270/// use deps_core::position::{Position, Range};
271///
272/// let diagnostic = Diagnostic::new(
273///     Range::new(Position::new(0, 0), Position::new(0, 10)),
274///     "newer version available",
275/// )
276/// .with_severity(Severity::Hint)
277/// .with_code("outdated");
278///
279/// assert_eq!(diagnostic.message(), "newer version available");
280/// assert_eq!(diagnostic.severity, Some(Severity::Hint));
281/// assert_eq!(diagnostic.code(), Some("outdated"));
282/// ```
283///
284/// # The sanitization backstop this design relies on (issue #1280)
285///
286/// `message` and `code` are private, and `Self` does not implement `Default` — the only way
287/// to build or mutate a `Diagnostic` from outside this crate is through [`Self::new`] and the
288/// `with_*` builders, every one of which sanitizes the value it sets. Each of the following
289/// fails to compile:
290///
291/// Setting `message` directly (the field does not exist from outside this crate):
292///
293/// ```compile_fail
294/// use deps_core::diagnostic::Diagnostic;
295/// use deps_core::position::{Position, Range};
296///
297/// let mut d = Diagnostic::new(Range::new(Position::new(0, 0), Position::new(0, 1)), "safe");
298/// d.message = "raw".to_string();
299/// ```
300///
301/// Setting `code` directly:
302///
303/// ```compile_fail
304/// use deps_core::diagnostic::Diagnostic;
305/// use deps_core::position::{Position, Range};
306///
307/// let mut d = Diagnostic::new(Range::new(Position::new(0, 0), Position::new(0, 1)), "safe");
308/// d.code = Some("raw".to_string());
309/// ```
310///
311/// Constructing via `Default` (not implemented):
312///
313/// ```compile_fail
314/// use deps_core::diagnostic::Diagnostic;
315///
316/// let _d = Diagnostic::default();
317/// ```
318#[non_exhaustive]
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct Diagnostic {
321    /// Span the diagnostic applies to.
322    pub range: Range,
323    /// Severity, if classified.
324    pub severity: Option<Severity>,
325    code: Option<String>,
326    /// Advisory/rule URL for `code`, e.g. an OSV advisory page.
327    pub code_description: Option<CodeDescription>,
328    message: String,
329    /// Other locations related to this diagnostic, e.g. sibling occurrences of a blocked
330    /// registry.
331    pub related_information: Option<Vec<RelatedInformation>>,
332}
333
334impl Diagnostic {
335    /// Builds a [`Diagnostic`] from its `range`/`message` fields, with every other field left
336    /// unset. Chain the `with_*` builders to set them.
337    ///
338    /// Needed because [`Self`] is `#[non_exhaustive]`: a struct literal only works inside
339    /// this crate, so every other crate must call this constructor to build a new value.
340    ///
341    /// `message` is sanitized through an internal markdown-unsafe-character filter (#1276)
342    /// on this constructor path, on top of (not instead of) producer-side sanitization. This
343    /// is a real guarantee, not just a best-effort backstop (#1280): [`Self`] does not
344    /// implement `Default`, [`Self::message`] is private, and there is no setter that
345    /// bypasses sanitization — [`Self::new`] is the only way to set it. The filter is
346    /// idempotent, so callers that already sanitized their input are unaffected;
347    /// length-capping (e.g. `MAX_DIAGNOSTIC_NAME_CHARS`) stays producer-side and is not
348    /// duplicated here.
349    ///
350    /// # Examples
351    ///
352    /// ```
353    /// use deps_core::diagnostic::Diagnostic;
354    /// use deps_core::position::{Position, Range};
355    ///
356    /// let diagnostic = Diagnostic::new(
357    ///     Range::new(Position::new(2, 0), Position::new(2, 6)),
358    ///     "package not found",
359    /// );
360    /// assert_eq!(diagnostic.severity, None);
361    /// ```
362    #[must_use]
363    pub fn new(range: Range, message: impl Into<String>) -> Self {
364        Self {
365            range,
366            severity: None,
367            code: None,
368            code_description: None,
369            message: crate::lsp_helpers::replace_markdown_unsafe_chars(&message.into()),
370            related_information: None,
371        }
372    }
373
374    /// Returns the sanitized, human-readable diagnostic message.
375    ///
376    /// # Examples
377    ///
378    /// ```
379    /// use deps_core::diagnostic::Diagnostic;
380    /// use deps_core::position::{Position, Range};
381    ///
382    /// let diagnostic = Diagnostic::new(
383    ///     Range::new(Position::new(0, 0), Position::new(0, 1)),
384    ///     "package not found",
385    /// );
386    /// assert_eq!(diagnostic.message(), "package not found");
387    /// ```
388    #[must_use]
389    pub fn message(&self) -> &str {
390        &self.message
391    }
392
393    /// Returns the stable machine-readable code identifying the diagnostic kind (e.g.
394    /// `"unsatisfiable-requirement"`), if set.
395    ///
396    /// # Examples
397    ///
398    /// ```
399    /// use deps_core::diagnostic::Diagnostic;
400    /// use deps_core::position::{Position, Range};
401    ///
402    /// let diagnostic = Diagnostic::new(
403    ///     Range::new(Position::new(0, 0), Position::new(0, 1)),
404    ///     "package not found",
405    /// )
406    /// .with_code("unknown-package");
407    /// assert_eq!(diagnostic.code(), Some("unknown-package"));
408    /// ```
409    #[must_use]
410    pub fn code(&self) -> Option<&str> {
411        self.code.as_deref()
412    }
413
414    /// Overrides [`Self::severity`]. See [`Self::new`].
415    #[must_use]
416    pub const fn with_severity(mut self, severity: Severity) -> Self {
417        self.severity = Some(severity);
418        self
419    }
420
421    /// Overrides the diagnostic's `code` (see [`Self::code`]). See [`Self::new`].
422    ///
423    /// `code` is sanitized through the same markdown-unsafe-character filter as `message`
424    /// (#1280) — it reaches the same client-visible sinks (the editor's Problems panel,
425    /// SARIF `ruleId`), so it gets the same defense-in-depth guarantee, on top of (not
426    /// instead of) producer-side validation.
427    #[must_use]
428    pub fn with_code(mut self, code: impl Into<String>) -> Self {
429        self.code = Some(crate::lsp_helpers::replace_markdown_unsafe_chars(
430            &code.into(),
431        ));
432        self
433    }
434
435    /// Overrides [`Self::code_description`]. See [`Self::new`].
436    #[must_use]
437    pub fn with_code_description(mut self, code_description: CodeDescription) -> Self {
438        self.code_description = Some(code_description);
439        self
440    }
441
442    /// Overrides [`Self::related_information`]. See [`Self::new`].
443    #[must_use]
444    pub fn with_related_information(
445        mut self,
446        related_information: Vec<RelatedInformation>,
447    ) -> Self {
448        self.related_information = Some(related_information);
449        self
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn test_diagnostic_builder_sets_all_fields() {
459        use crate::position::Position;
460
461        let href = url::Url::parse("https://osv.dev/GHSA-xxxx").unwrap();
462        let related_uri = url::Url::parse("file:///Cargo.toml").unwrap();
463        let diagnostic = Diagnostic::new(
464            Range::new(Position::new(0, 0), Position::new(0, 4)),
465            "vulnerable",
466        )
467        .with_severity(Severity::Error)
468        .with_code("GHSA-xxxx")
469        .with_code_description(CodeDescription::new(href.clone()))
470        .with_related_information(vec![RelatedInformation::new(
471            related_uri.clone(),
472            Range::new(Position::new(1, 0), Position::new(1, 4)),
473            "also here",
474        )]);
475
476        assert_eq!(diagnostic.severity, Some(Severity::Error));
477        assert_eq!(diagnostic.code(), Some("GHSA-xxxx"));
478        assert_eq!(diagnostic.code_description.as_ref().unwrap().href, href);
479        let related = diagnostic.related_information.as_ref().unwrap();
480        assert_eq!(related.len(), 1);
481        assert_eq!(related[0].uri, related_uri);
482        assert_eq!(related[0].message(), "also here");
483    }
484
485    #[test]
486    fn test_diagnostic_new_leaves_optional_fields_unset() {
487        use crate::position::Position;
488
489        let diagnostic = Diagnostic::new(
490            Range::new(Position::new(0, 0), Position::new(0, 1)),
491            "message",
492        );
493        assert_eq!(diagnostic.severity, None);
494        assert_eq!(diagnostic.code(), None);
495        assert_eq!(diagnostic.code_description, None);
496        assert_eq!(diagnostic.related_information, None);
497    }
498
499    #[test]
500    fn test_severity_serialize_matches_lsp_wire_encoding() {
501        assert_eq!(serde_json::to_string(&Severity::Error).unwrap(), "1");
502        assert_eq!(serde_json::to_string(&Severity::Warning).unwrap(), "2");
503        assert_eq!(serde_json::to_string(&Severity::Information).unwrap(), "3");
504        assert_eq!(serde_json::to_string(&Severity::Hint).unwrap(), "4");
505    }
506
507    #[test]
508    fn test_severity_deserialize_accepts_every_valid_value() {
509        assert_eq!(
510            serde_json::from_str::<Severity>("1").unwrap(),
511            Severity::Error
512        );
513        assert_eq!(
514            serde_json::from_str::<Severity>("2").unwrap(),
515            Severity::Warning
516        );
517        assert_eq!(
518            serde_json::from_str::<Severity>("3").unwrap(),
519            Severity::Information
520        );
521        assert_eq!(
522            serde_json::from_str::<Severity>("4").unwrap(),
523            Severity::Hint
524        );
525    }
526
527    #[test]
528    fn test_severity_roundtrips_through_serialize_deserialize() {
529        for severity in [
530            Severity::Error,
531            Severity::Warning,
532            Severity::Information,
533            Severity::Hint,
534        ] {
535            let json = serde_json::to_string(&severity).unwrap();
536            assert_eq!(serde_json::from_str::<Severity>(&json).unwrap(), severity);
537        }
538    }
539
540    /// #1276: `Diagnostic::new` is a defense-in-depth backstop on its own constructor
541    /// path — a bidi-override character in `message` must not survive construction through
542    /// `new`, even without an explicit producer-side call to `replace_markdown_unsafe_chars`.
543    #[test]
544    fn test_diagnostic_new_sanitizes_bidi_override_in_message() {
545        use crate::position::Position;
546
547        let diagnostic = Diagnostic::new(
548            Range::new(Position::new(0, 0), Position::new(0, 1)),
549            "vulnerable\u{202E}gnp.sj",
550        );
551        assert_eq!(diagnostic.message(), "vulnerable gnp.sj");
552    }
553
554    /// #1280: `with_code` gets the same sanitization backstop as `message` — a `code` value
555    /// reaches the same client-visible sinks (Problems panel, SARIF `ruleId`).
556    #[test]
557    fn test_diagnostic_with_code_sanitizes_bidi_override() {
558        use crate::position::Position;
559
560        let diagnostic = Diagnostic::new(
561            Range::new(Position::new(0, 0), Position::new(0, 1)),
562            "vulnerable",
563        )
564        .with_code("GHSA-xxxx\u{202E}gnp.sj");
565        assert_eq!(diagnostic.code(), Some("GHSA-xxxx gnp.sj"));
566    }
567
568    /// #1276: same backstop applies to `RelatedInformation::new`.
569    #[test]
570    fn test_related_information_new_sanitizes_bidi_override_in_message() {
571        use crate::position::Position;
572
573        let related = RelatedInformation::new(
574            url::Url::parse("file:///Cargo.toml").unwrap(),
575            Range::new(Position::new(0, 0), Position::new(0, 1)),
576            "also here\u{202E}gnp.sj",
577        );
578        assert_eq!(related.message(), "also here gnp.sj");
579    }
580
581    /// #1083 critic S1: an out-of-range integer must never fail deserialization — it must
582    /// clamp to the nearest defined variant instead, exactly like
583    /// `tower_lsp_server::ls_types::DiagnosticSeverity`'s own permissive `#[serde(transparent)]`
584    /// `i32` newtype. Rejecting it here would fail an entire `DepsConfig`/`deps-cli` config
585    /// deserialization over one out-of-range field.
586    #[test]
587    fn test_severity_deserialize_clamps_out_of_range_values_instead_of_failing() {
588        assert_eq!(
589            serde_json::from_str::<Severity>("0").unwrap(),
590            Severity::Error
591        );
592        assert_eq!(
593            serde_json::from_str::<Severity>("-5").unwrap(),
594            Severity::Error
595        );
596        assert_eq!(
597            serde_json::from_str::<Severity>("5").unwrap(),
598            Severity::Hint
599        );
600        assert_eq!(
601            serde_json::from_str::<Severity>("999").unwrap(),
602            Severity::Hint
603        );
604    }
605}