1use std::{error::Error, fmt};
4
5#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7#[non_exhaustive]
8pub enum NativeFindingLabelKind {
9 Primary,
11 Secondary,
13}
14
15#[derive(Clone, Debug, Eq, PartialEq)]
20#[non_exhaustive]
21pub struct NativeFindingLabel {
22 kind: NativeFindingLabelKind,
23 source_id: u32,
24 start: usize,
25 end: usize,
26 message: String,
27}
28
29impl NativeFindingLabel {
30 #[must_use]
32 pub fn new(
33 kind: NativeFindingLabelKind,
34 source_id: u32,
35 start: usize,
36 end: usize,
37 message: impl Into<String>,
38 ) -> Self {
39 Self {
40 kind,
41 source_id,
42 start,
43 end,
44 message: message.into(),
45 }
46 }
47
48 #[must_use]
50 pub const fn kind(&self) -> NativeFindingLabelKind {
51 self.kind
52 }
53
54 #[must_use]
56 pub const fn source_id(&self) -> u32 {
57 self.source_id
58 }
59
60 #[must_use]
62 pub const fn start(&self) -> usize {
63 self.start
64 }
65
66 #[must_use]
68 pub const fn end(&self) -> usize {
69 self.end
70 }
71
72 #[must_use]
74 pub fn message(&self) -> &str {
75 &self.message
76 }
77}
78
79#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct InvalidDiagnosticCode;
82
83impl fmt::Display for InvalidDiagnosticCode {
84 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
85 formatter.write_str("diagnostic code must contain only uppercase ASCII letters and digits")
86 }
87}
88
89impl Error for InvalidDiagnosticCode {}
90
91#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
93pub struct DiagnosticCode(String);
94
95impl DiagnosticCode {
96 pub fn new(value: impl Into<String>) -> Result<Self, InvalidDiagnosticCode> {
102 let value = value.into();
103 if value.is_empty()
104 || !value
105 .bytes()
106 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
107 {
108 return Err(InvalidDiagnosticCode);
109 }
110 Ok(Self(value))
111 }
112
113 #[must_use]
115 pub fn as_str(&self) -> &str {
116 &self.0
117 }
118}
119
120#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122#[non_exhaustive]
123pub enum Severity {
124 Error,
126 Warning,
128 Note,
130}
131
132#[derive(Clone, Eq, PartialEq)]
134#[non_exhaustive]
135pub enum DiagnosticValue {
136 Plain(String),
138 Sensitive(String),
140}
141
142impl DiagnosticValue {
143 #[must_use]
145 pub fn plain(value: impl Into<String>) -> Self {
146 Self::Plain(value.into())
147 }
148
149 #[must_use]
151 pub fn sensitive(value: impl Into<String>) -> Self {
152 Self::Sensitive(value.into())
153 }
154
155 #[must_use]
157 pub const fn is_sensitive(&self) -> bool {
158 matches!(self, Self::Sensitive(_))
159 }
160
161 #[must_use]
163 pub fn expose(&self) -> &str {
164 match self {
165 Self::Plain(value) | Self::Sensitive(value) => value,
166 }
167 }
168
169 #[must_use]
171 pub fn redacted(&self) -> &str {
172 match self {
173 Self::Plain(value) => value,
174 Self::Sensitive(_) => "[REDACTED]",
175 }
176 }
177}
178
179impl fmt::Debug for DiagnosticValue {
180 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
181 formatter
182 .debug_tuple("DiagnosticValue")
183 .field(&self.redacted())
184 .finish()
185 }
186}
187
188#[derive(Clone, Debug, Eq, PartialEq)]
190pub struct DiagnosticField {
191 name: String,
192 value: DiagnosticValue,
193}
194
195#[derive(Clone, Debug, Eq, PartialEq)]
201#[non_exhaustive]
202pub struct NativeFinding {
203 source_format: String,
204 producer: String,
205 producer_version: Option<String>,
206 code: String,
207 stage: String,
208 severity: Severity,
209 summary: String,
210 fields: Vec<DiagnosticField>,
211 labels: Vec<NativeFindingLabel>,
212 notes: Vec<String>,
213 help: Option<String>,
214}
215
216impl NativeFinding {
217 #[must_use]
219 pub fn new(
220 source_format: impl Into<String>,
221 producer: impl Into<String>,
222 code: impl Into<String>,
223 stage: impl Into<String>,
224 severity: Severity,
225 summary: impl Into<String>,
226 ) -> Self {
227 Self {
228 source_format: source_format.into(),
229 producer: producer.into(),
230 producer_version: None,
231 code: code.into(),
232 stage: stage.into(),
233 severity,
234 summary: summary.into(),
235 fields: Vec::new(),
236 labels: Vec::new(),
237 notes: Vec::new(),
238 help: None,
239 }
240 }
241
242 #[must_use]
244 pub fn with_producer_version(mut self, version: impl Into<String>) -> Self {
245 self.producer_version = Some(version.into());
246 self
247 }
248
249 #[must_use]
251 pub fn with_field(mut self, field: DiagnosticField) -> Self {
252 self.fields.push(field);
253 self
254 }
255
256 #[must_use]
258 pub fn with_label(mut self, label: NativeFindingLabel) -> Self {
259 self.labels.push(label);
260 self
261 }
262
263 #[must_use]
265 pub fn with_note(mut self, note: impl Into<String>) -> Self {
266 self.notes.push(note.into());
267 self
268 }
269
270 #[must_use]
272 pub fn with_help(mut self, help: impl Into<String>) -> Self {
273 self.help = Some(help.into());
274 self
275 }
276
277 #[must_use]
279 pub fn source_format(&self) -> &str {
280 &self.source_format
281 }
282
283 #[must_use]
285 pub fn producer(&self) -> &str {
286 &self.producer
287 }
288
289 #[must_use]
291 pub fn producer_version(&self) -> Option<&str> {
292 self.producer_version.as_deref()
293 }
294
295 #[must_use]
297 pub fn code(&self) -> &str {
298 &self.code
299 }
300
301 #[must_use]
303 pub fn stage(&self) -> &str {
304 &self.stage
305 }
306
307 #[must_use]
309 pub const fn severity(&self) -> Severity {
310 self.severity
311 }
312
313 #[must_use]
315 pub fn summary(&self) -> &str {
316 &self.summary
317 }
318
319 #[must_use]
321 pub fn fields(&self) -> &[DiagnosticField] {
322 &self.fields
323 }
324
325 #[must_use]
327 pub fn labels(&self) -> &[NativeFindingLabel] {
328 &self.labels
329 }
330
331 #[must_use]
333 pub fn notes(&self) -> &[String] {
334 &self.notes
335 }
336
337 #[must_use]
339 pub fn help(&self) -> Option<&str> {
340 self.help.as_deref()
341 }
342}
343
344impl DiagnosticField {
345 #[must_use]
347 pub fn new(name: impl Into<String>, value: DiagnosticValue) -> Self {
348 Self {
349 name: name.into(),
350 value,
351 }
352 }
353
354 #[must_use]
356 pub fn name(&self) -> &str {
357 &self.name
358 }
359
360 #[must_use]
362 pub const fn value(&self) -> &DiagnosticValue {
363 &self.value
364 }
365}
366
367#[derive(Clone, Debug, Eq, PartialEq)]
369pub struct Diagnostic {
370 code: DiagnosticCode,
371 severity: Severity,
372 summary: String,
373 fields: Vec<DiagnosticField>,
374 native_finding: Option<NativeFinding>,
375}
376
377impl Diagnostic {
378 #[must_use]
380 pub fn new(code: DiagnosticCode, severity: Severity, summary: impl Into<String>) -> Self {
381 Self {
382 code,
383 severity,
384 summary: summary.into(),
385 fields: Vec::new(),
386 native_finding: None,
387 }
388 }
389
390 #[must_use]
392 pub fn with_field(mut self, field: DiagnosticField) -> Self {
393 self.fields.push(field);
394 self
395 }
396
397 #[must_use]
399 pub fn with_native_finding(mut self, finding: NativeFinding) -> Self {
400 self.native_finding = Some(finding);
401 self
402 }
403
404 #[must_use]
406 pub const fn code(&self) -> &DiagnosticCode {
407 &self.code
408 }
409
410 #[must_use]
412 pub const fn severity(&self) -> Severity {
413 self.severity
414 }
415
416 #[must_use]
418 pub fn summary(&self) -> &str {
419 &self.summary
420 }
421
422 #[must_use]
424 pub fn fields(&self) -> &[DiagnosticField] {
425 &self.fields
426 }
427
428 #[must_use]
430 pub const fn native_finding(&self) -> Option<&NativeFinding> {
431 self.native_finding.as_ref()
432 }
433}
434
435impl fmt::Display for Diagnostic {
436 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
437 write!(formatter, "{}: {}", self.code.as_str(), self.summary)?;
438 for field in &self.fields {
439 write!(formatter, " {}={}", field.name(), field.value().redacted())?;
440 }
441 Ok(())
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::{
448 Diagnostic, DiagnosticCode, DiagnosticField, DiagnosticValue, NativeFinding, NativeFindingLabel,
449 NativeFindingLabelKind, Severity,
450 };
451
452 #[test]
453 fn sensitive_fields_are_redacted_from_debug_and_display() -> Result<(), String> {
454 let finding = NativeFinding::new(
455 "compose",
456 "compose-lens",
457 "compose.example",
458 "model",
459 Severity::Warning,
460 "native value needs review",
461 )
462 .with_field(DiagnosticField::new(
463 "native_value",
464 DiagnosticValue::sensitive("never-print-native-this"),
465 ))
466 .with_label(NativeFindingLabel::new(
467 NativeFindingLabelKind::Primary,
468 1,
469 4,
470 8,
471 "value is here",
472 ));
473 let diagnostic = Diagnostic::new(code("BFE0001")?, Severity::Warning, "value was adjusted")
474 .with_field(DiagnosticField::new(
475 "value",
476 DiagnosticValue::sensitive("never-print-this"),
477 ))
478 .with_native_finding(finding);
479 for rendered in [format!("{diagnostic:?}"), diagnostic.to_string()] {
480 assert!(!rendered.contains("never-print-this"));
481 assert!(!rendered.contains("never-print-native-this"));
482 assert!(rendered.contains("[REDACTED]"));
483 }
484 let native = diagnostic.native_finding().ok_or("missing native finding")?;
485 assert_eq!(native.producer(), "compose-lens");
486 assert_eq!(native.labels()[0].source_id(), 1);
487 Ok(())
488 }
489
490 fn code(value: &str) -> Result<DiagnosticCode, String> {
491 DiagnosticCode::new(value).map_err(|error| error.to_string())
492 }
493}