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}