Skip to main content

atproto_devtool/commands/test/labeler/
report.rs

1//! Report aggregation and rendering for the labeler conformance suite.
2
3use std::fmt;
4use std::io;
5use std::time::Instant;
6
7use miette::{Diagnostic, GraphicalReportHandler, GraphicalTheme};
8
9/// The five rendering severities for a check result.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum CheckStatus {
12    /// All checks passed — renders as `[OK]`.
13    Pass,
14    /// Specification violation — renders as `[FAIL]`.
15    SpecViolation,
16    /// Network error — renders as `[NET]`.
17    NetworkError,
18    /// Advisory warning — renders as `[WARN]`.
19    Advisory,
20    /// Check skipped (not yet implemented or blocked by earlier failure) — renders as `[SKIP]`.
21    Skipped,
22}
23
24impl CheckStatus {
25    /// Plain-text glyph for this status.
26    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    /// Glyph wrapped in an ANSI SGR sequence for this status, or the
37    /// plain glyph when `no_color` is true. Colors are chosen to give
38    /// each severity a distinct visual weight in a terminal:
39    ///
40    /// * `Pass` — bold green.
41    /// * `SpecViolation` — bold red.
42    /// * `NetworkError` — bold magenta (distinct from `Advisory` so
43    ///   reachability failures don't blur into spec warnings).
44    /// * `Advisory` — bold yellow.
45    /// * `Skipped` — dim.
46    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/// Result of a single check within a labeler validation stage.
67#[derive(Debug)]
68pub struct CheckResult {
69    /// Stable identifier for this check (e.g., "identity::target_resolved").
70    pub id: &'static str,
71    /// Which stage this check belongs to.
72    pub stage: Stage,
73    /// The outcome severity of this check.
74    pub status: CheckStatus,
75    /// Human-readable summary of what was checked.
76    pub summary: std::borrow::Cow<'static, str>,
77    /// Optional diagnostic with source code context (for failures).
78    pub diagnostic: Option<Box<dyn Diagnostic + Send + Sync>>,
79    /// Optional reason why a check was skipped.
80    pub skipped_reason: Option<std::borrow::Cow<'static, str>>,
81}
82
83/// The stages of labeler validation.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
85pub enum Stage {
86    /// DID document and labeler record validation.
87    Identity,
88    /// HTTP endpoint healthchecks.
89    Http,
90    /// WebSocket subscription validation.
91    Subscription,
92    /// Cryptographic signing verification.
93    Crypto,
94    /// `com.atproto.moderation.createReport` authenticated-write stage.
95    Report,
96}
97
98impl Stage {
99    /// Human-readable heading for this stage.
100    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/// Summary counts of check results by severity.
112#[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    /// Count results by severity.
123    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/// Header information for a labeler report.
147#[derive(Debug, Clone)]
148pub struct ReportHeader {
149    /// The input target (handle, DID, or URL).
150    pub target: String,
151    /// The resolved DID if applicable.
152    pub resolved_did: Option<String>,
153    /// The PDS endpoint if resolved.
154    pub pds_endpoint: Option<String>,
155    /// The labeler service endpoint if resolved.
156    pub labeler_endpoint: Option<String>,
157}
158
159/// Configuration for rendering the report.
160#[derive(Debug, Clone)]
161pub struct RenderConfig {
162    /// Whether to suppress colored output.
163    pub no_color: bool,
164}
165
166/// The complete labeler validation report.
167#[derive(Debug)]
168pub struct LabelerReport {
169    /// Header with target and resolved endpoints.
170    pub header: ReportHeader,
171    /// All validation results collected during the run.
172    pub results: Vec<CheckResult>,
173    /// When the run started.
174    pub started_at: Instant,
175    /// When the run finished.
176    pub finished_at: Option<Instant>,
177}
178
179impl LabelerReport {
180    /// Create a new empty report.
181    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    /// Record a check result.
191    pub fn record(&mut self, result: CheckResult) {
192        self.results.push(result);
193    }
194
195    /// Mark the report as finished.
196    pub fn finish(&mut self) {
197        self.finished_at = Some(Instant::now());
198    }
199
200    /// Compute the exit code:
201    ///
202    /// * `1` if any check is a `SpecViolation` — the labeler is
203    ///   reachable but does not conform to the spec.
204    /// * `2` if there is no `SpecViolation` but at least one
205    ///   `NetworkError` — the run could not fully exercise the labeler
206    ///   (DNS, HTTP, or WebSocket failure), so the result is
207    ///   inconclusive and the operator should investigate.
208    /// * `0` otherwise.
209    ///
210    /// Spec violations take precedence over network errors so that a
211    /// run that uncovered a real conformance bug still surfaces as
212    /// such even if some other stage was unreachable.
213    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    /// Get summary counts of all results.
233    pub fn summary_counts(&self) -> SummaryCounts {
234        SummaryCounts::from_results(&self.results)
235    }
236
237    /// Render the report to the given writer.
238    pub fn render<W: io::Write>(&self, out: &mut W, config: &RenderConfig) -> io::Result<()> {
239        // Header line with target and resolved endpoints.
240        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        // Group results by stage and render.
258        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 the check result line.
266            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            // Render diagnostic if present (and not skipped).
278            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                    // Render the diagnostic to a string buffer.
288                    if let Err(_e) = handler.render_report(&mut buf, diag.as_ref()) {
289                        // If rendering fails, write a fallback message.
290                        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        // Summary footer.
303        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        // Check for the expected glyphs.
520        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        // With `no_color: true` there must be no ANSI SGR escapes.
533        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        // Color on: each status wraps its glyph in an ANSI SGR sequence
542        // and closes with the reset escape.
543        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        // End-to-end: render() with `no_color: false` must emit colored
573        // glyphs — this is the load-bearing path for the terminal UX.
574        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}