1use std::fmt;
4use std::io;
5use std::time::Instant;
6
7use miette::{Diagnostic, GraphicalReportHandler, GraphicalTheme};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum CheckStatus {
12 Pass,
14 SpecViolation,
16 NetworkError,
18 Advisory,
20 Skipped,
22}
23
24impl CheckStatus {
25 pub fn glyph(self) -> &'static str {
27 match self {
28 CheckStatus::Pass => "[OK]",
29 CheckStatus::SpecViolation => "[FAIL]",
30 CheckStatus::NetworkError => "[NET]",
31 CheckStatus::Advisory => "[WARN]",
32 CheckStatus::Skipped => "[SKIP]",
33 }
34 }
35
36 pub fn styled_glyph(self, no_color: bool) -> &'static str {
47 if no_color {
48 return self.glyph();
49 }
50 match self {
51 CheckStatus::Pass => "\x1b[1;32m[OK]\x1b[0m",
52 CheckStatus::SpecViolation => "\x1b[1;31m[FAIL]\x1b[0m",
53 CheckStatus::NetworkError => "\x1b[1;35m[NET]\x1b[0m",
54 CheckStatus::Advisory => "\x1b[1;33m[WARN]\x1b[0m",
55 CheckStatus::Skipped => "\x1b[2m[SKIP]\x1b[0m",
56 }
57 }
58}
59
60impl fmt::Display for CheckStatus {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 f.write_str(self.glyph())
63 }
64}
65
66#[derive(Debug)]
68pub struct CheckResult {
69 pub id: &'static str,
71 pub stage: Stage,
73 pub status: CheckStatus,
75 pub summary: std::borrow::Cow<'static, str>,
77 pub diagnostic: Option<Box<dyn Diagnostic + Send + Sync>>,
79 pub skipped_reason: Option<std::borrow::Cow<'static, str>>,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
85pub enum Stage {
86 Identity,
88 Http,
90 Subscription,
92 Crypto,
94 Report,
96}
97
98impl Stage {
99 pub fn label(self) -> &'static str {
101 match self {
102 Stage::Identity => "Identity",
103 Stage::Http => "HTTP",
104 Stage::Subscription => "Subscription",
105 Stage::Crypto => "Crypto",
106 Stage::Report => "Report",
107 }
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct SummaryCounts {
114 pub pass: usize,
115 pub spec_violation: usize,
116 pub network_error: usize,
117 pub advisory: usize,
118 pub skipped: usize,
119}
120
121impl SummaryCounts {
122 pub fn from_results(results: &[CheckResult]) -> Self {
124 let mut counts = SummaryCounts {
125 pass: 0,
126 spec_violation: 0,
127 network_error: 0,
128 advisory: 0,
129 skipped: 0,
130 };
131
132 for result in results {
133 match result.status {
134 CheckStatus::Pass => counts.pass += 1,
135 CheckStatus::SpecViolation => counts.spec_violation += 1,
136 CheckStatus::NetworkError => counts.network_error += 1,
137 CheckStatus::Advisory => counts.advisory += 1,
138 CheckStatus::Skipped => counts.skipped += 1,
139 }
140 }
141
142 counts
143 }
144}
145
146#[derive(Debug, Clone)]
148pub struct ReportHeader {
149 pub target: String,
151 pub resolved_did: Option<String>,
153 pub pds_endpoint: Option<String>,
155 pub labeler_endpoint: Option<String>,
157}
158
159#[derive(Debug, Clone)]
161pub struct RenderConfig {
162 pub no_color: bool,
164}
165
166#[derive(Debug)]
168pub struct LabelerReport {
169 pub header: ReportHeader,
171 pub results: Vec<CheckResult>,
173 pub started_at: Instant,
175 pub finished_at: Option<Instant>,
177}
178
179impl LabelerReport {
180 pub fn new(header: ReportHeader) -> Self {
182 LabelerReport {
183 header,
184 results: Vec::new(),
185 started_at: Instant::now(),
186 finished_at: None,
187 }
188 }
189
190 pub fn record(&mut self, result: CheckResult) {
192 self.results.push(result);
193 }
194
195 pub fn finish(&mut self) {
197 self.finished_at = Some(Instant::now());
198 }
199
200 pub fn exit_code(&self) -> i32 {
214 let mut has_spec_violation = false;
215 let mut has_network_error = false;
216 for r in &self.results {
217 match r.status {
218 CheckStatus::SpecViolation => has_spec_violation = true,
219 CheckStatus::NetworkError => has_network_error = true,
220 _ => {}
221 }
222 }
223 if has_spec_violation {
224 1
225 } else if has_network_error {
226 2
227 } else {
228 0
229 }
230 }
231
232 pub fn summary_counts(&self) -> SummaryCounts {
234 SummaryCounts::from_results(&self.results)
235 }
236
237 pub fn render<W: io::Write>(&self, out: &mut W, config: &RenderConfig) -> io::Result<()> {
239 let elapsed = self
241 .finished_at
242 .map(|f| f.duration_since(self.started_at).as_millis())
243 .unwrap_or(0);
244 writeln!(out, "Target: {}", self.header.target)?;
245 if let Some(did) = &self.header.resolved_did {
246 writeln!(out, " Resolved DID: {did}")?;
247 }
248 if let Some(pds) = &self.header.pds_endpoint {
249 writeln!(out, " PDS endpoint: {pds}")?;
250 }
251 if let Some(labeler) = &self.header.labeler_endpoint {
252 writeln!(out, " Labeler endpoint: {labeler}")?;
253 }
254 writeln!(out, " elapsed: {elapsed}ms")?;
255 writeln!(out)?;
256
257 let mut current_stage: Option<Stage> = None;
259 for result in &self.results {
260 if Some(result.stage) != current_stage {
261 current_stage = Some(result.stage);
262 writeln!(out, "== {} ==", result.stage.label())?;
263 }
264
265 write!(
267 out,
268 "{} {} ",
269 result.status.styled_glyph(config.no_color),
270 result.summary
271 )?;
272 if let Some(reason) = &result.skipped_reason {
273 write!(out, "— {reason}")?;
274 }
275 writeln!(out)?;
276
277 if let Some(diag) = &result.diagnostic {
279 if result.status != CheckStatus::Skipped {
280 let theme = if config.no_color {
281 GraphicalTheme::unicode_nocolor()
282 } else {
283 GraphicalTheme::default()
284 };
285 let handler = GraphicalReportHandler::new().with_theme(theme);
286 let mut buf = String::new();
287 if let Err(_e) = handler.render_report(&mut buf, diag.as_ref()) {
289 writeln!(out, " (diagnostic rendering failed)")?;
291 } else {
292 for line in buf.lines() {
293 writeln!(out, " {line}")?;
294 }
295 }
296 }
297 }
298 }
299
300 writeln!(out)?;
301
302 let counts = self.summary_counts();
304 write!(
305 out,
306 "Summary: {} passed, {} failed (spec), {} network errors, {} advisories, {} skipped. ",
307 counts.pass,
308 counts.spec_violation,
309 counts.network_error,
310 counts.advisory,
311 counts.skipped
312 )?;
313 writeln!(out, "Exit code: {}", self.exit_code())?;
314
315 Ok(())
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn exit_code_only_advisory_is_zero() {
325 let header = ReportHeader {
326 target: "test".to_string(),
327 resolved_did: None,
328 pds_endpoint: None,
329 labeler_endpoint: None,
330 };
331 let mut report = LabelerReport::new(header);
332 report.record(CheckResult {
333 id: "test",
334 stage: Stage::Identity,
335 status: CheckStatus::Advisory,
336 summary: "advisory check".into(),
337 diagnostic: None,
338 skipped_reason: None,
339 });
340 assert_eq!(report.exit_code(), 0);
341 }
342
343 #[test]
344 fn exit_code_only_network_errors_is_two() {
345 let header = ReportHeader {
346 target: "test".to_string(),
347 resolved_did: None,
348 pds_endpoint: None,
349 labeler_endpoint: None,
350 };
351 let mut report = LabelerReport::new(header);
352 report.record(CheckResult {
353 id: "test",
354 stage: Stage::Identity,
355 status: CheckStatus::NetworkError,
356 summary: "network check".into(),
357 diagnostic: None,
358 skipped_reason: None,
359 });
360 assert_eq!(report.exit_code(), 2);
361 }
362
363 #[test]
364 fn exit_code_spec_violation_takes_precedence_over_network_error() {
365 let header = ReportHeader {
366 target: "test".to_string(),
367 resolved_did: None,
368 pds_endpoint: None,
369 labeler_endpoint: None,
370 };
371 let mut report = LabelerReport::new(header);
372 report.record(CheckResult {
373 id: "net",
374 stage: Stage::Identity,
375 status: CheckStatus::NetworkError,
376 summary: "network check".into(),
377 diagnostic: None,
378 skipped_reason: None,
379 });
380 report.record(CheckResult {
381 id: "spec",
382 stage: Stage::Identity,
383 status: CheckStatus::SpecViolation,
384 summary: "spec check".into(),
385 diagnostic: None,
386 skipped_reason: None,
387 });
388 assert_eq!(report.exit_code(), 1);
389 }
390
391 #[test]
392 fn exit_code_with_spec_violation_is_one() {
393 let header = ReportHeader {
394 target: "test".to_string(),
395 resolved_did: None,
396 pds_endpoint: None,
397 labeler_endpoint: None,
398 };
399 let mut report = LabelerReport::new(header);
400 report.record(CheckResult {
401 id: "test",
402 stage: Stage::Identity,
403 status: CheckStatus::SpecViolation,
404 summary: "spec check".into(),
405 diagnostic: None,
406 skipped_reason: None,
407 });
408 assert_eq!(report.exit_code(), 1);
409 }
410
411 #[test]
412 fn summary_counts_partition_correct() {
413 let header = ReportHeader {
414 target: "test".to_string(),
415 resolved_did: None,
416 pds_endpoint: None,
417 labeler_endpoint: None,
418 };
419 let mut report = LabelerReport::new(header);
420
421 report.record(CheckResult {
422 id: "test1",
423 stage: Stage::Identity,
424 status: CheckStatus::Pass,
425 summary: "pass check".into(),
426 diagnostic: None,
427 skipped_reason: None,
428 });
429
430 report.record(CheckResult {
431 id: "test2",
432 stage: Stage::Identity,
433 status: CheckStatus::SpecViolation,
434 summary: "fail check".into(),
435 diagnostic: None,
436 skipped_reason: None,
437 });
438
439 report.record(CheckResult {
440 id: "test3",
441 stage: Stage::Http,
442 status: CheckStatus::NetworkError,
443 summary: "net check".into(),
444 diagnostic: None,
445 skipped_reason: None,
446 });
447
448 report.record(CheckResult {
449 id: "test4",
450 stage: Stage::Http,
451 status: CheckStatus::Advisory,
452 summary: "warn check".into(),
453 diagnostic: None,
454 skipped_reason: None,
455 });
456
457 report.record(CheckResult {
458 id: "test5",
459 stage: Stage::Subscription,
460 status: CheckStatus::Skipped,
461 summary: "skip check".into(),
462 diagnostic: None,
463 skipped_reason: Some("not implemented".into()),
464 });
465
466 let counts = report.summary_counts();
467 assert_eq!(counts.pass, 1);
468 assert_eq!(counts.spec_violation, 1);
469 assert_eq!(counts.network_error, 1);
470 assert_eq!(counts.advisory, 1);
471 assert_eq!(counts.skipped, 1);
472 }
473
474 #[test]
475 fn render_basic_glyphs() {
476 let header = ReportHeader {
477 target: "test.example".to_string(),
478 resolved_did: None,
479 pds_endpoint: None,
480 labeler_endpoint: None,
481 };
482 let mut report = LabelerReport::new(header);
483
484 report.record(CheckResult {
485 id: "test1",
486 stage: Stage::Identity,
487 status: CheckStatus::Pass,
488 summary: "pass check".into(),
489 diagnostic: None,
490 skipped_reason: None,
491 });
492
493 report.record(CheckResult {
494 id: "test2",
495 stage: Stage::Identity,
496 status: CheckStatus::SpecViolation,
497 summary: "fail check".into(),
498 diagnostic: None,
499 skipped_reason: None,
500 });
501
502 report.record(CheckResult {
503 id: "test3",
504 stage: Stage::Http,
505 status: CheckStatus::Skipped,
506 summary: "skip check".into(),
507 diagnostic: None,
508 skipped_reason: Some("not yet implemented".into()),
509 });
510
511 report.finish();
512
513 let mut buf = Vec::new();
514 let config = RenderConfig { no_color: true };
515 report.render(&mut buf, &config).expect("render failed");
516
517 let output = String::from_utf8(buf).expect("invalid utf-8");
518
519 assert!(
521 output.contains("[OK]"),
522 "output should contain [OK] glyph:\n{output}"
523 );
524 assert!(
525 output.contains("[FAIL]"),
526 "output should contain [FAIL] glyph:\n{output}"
527 );
528 assert!(
529 output.contains("[SKIP]"),
530 "output should contain [SKIP] glyph:\n{output}"
531 );
532 assert!(
534 !output.contains('\x1b'),
535 "no_color output should not contain ANSI escapes:\n{output}"
536 );
537 }
538
539 #[test]
540 fn styled_glyph_emits_ansi_when_color_enabled() {
541 for status in [
544 CheckStatus::Pass,
545 CheckStatus::SpecViolation,
546 CheckStatus::NetworkError,
547 CheckStatus::Advisory,
548 CheckStatus::Skipped,
549 ] {
550 let colored = status.styled_glyph(false);
551 assert!(
552 colored.starts_with("\x1b[") && colored.ends_with("\x1b[0m"),
553 "{status:?} colored form must be wrapped in SGR escapes: {colored:?}"
554 );
555 assert!(
556 colored.contains(status.glyph()),
557 "{status:?} colored form must contain the plain glyph"
558 );
559 }
560 }
561
562 #[test]
563 fn report_stage_ordering_places_report_last() {
564 assert!(Stage::Identity < Stage::Http);
565 assert!(Stage::Http < Stage::Subscription);
566 assert!(Stage::Subscription < Stage::Crypto);
567 assert!(Stage::Crypto < Stage::Report);
568 }
569
570 #[test]
571 fn render_with_color_wraps_glyphs_in_ansi() {
572 let header = ReportHeader {
575 target: "test.example".to_string(),
576 resolved_did: None,
577 pds_endpoint: None,
578 labeler_endpoint: None,
579 };
580 let mut report = LabelerReport::new(header);
581 report.record(CheckResult {
582 id: "test1",
583 stage: Stage::Identity,
584 status: CheckStatus::Pass,
585 summary: "pass check".into(),
586 diagnostic: None,
587 skipped_reason: None,
588 });
589 report.finish();
590
591 let mut buf = Vec::new();
592 report
593 .render(&mut buf, &RenderConfig { no_color: false })
594 .expect("render failed");
595 let output = String::from_utf8(buf).expect("invalid utf-8");
596 assert!(
597 output.contains(CheckStatus::Pass.styled_glyph(false)),
598 "colored output should contain the colored [OK] glyph:\n{output}"
599 );
600 }
601}