CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add LabelerReport types and renderer

Implement the report aggregation and rendering for the labeler conformance
suite with CheckStatus enum, CheckResult and LabelerReport types. The report
renders check results grouped by stage with ASCII glyphs ([OK], [FAIL], [NET],
[WARN], [SKIP]). Exit code is 1 if any SpecViolation, else 0. Diagnostics are
rendered using a local GraphicalReportHandler (not the process-global handler)
to avoid race conditions in parallel snapshot tests.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

+449
+2
src/commands/test/labeler.rs
··· 1 1 //! `atproto-devtool test labeler <target>` command. 2 2 3 + pub mod report; 4 + 3 5 use std::time::Duration; 4 6 5 7 use clap::Args;
+447
src/commands/test/labeler/report.rs
··· 1 + //! Report aggregation and rendering for the labeler conformance suite. 2 + 3 + use std::fmt; 4 + use std::io; 5 + use std::time::Instant; 6 + 7 + use miette::{Diagnostic, GraphicalReportHandler, GraphicalTheme}; 8 + 9 + /// The five rendering severities for a check result. 10 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 11 + pub 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 + 24 + impl fmt::Display for CheckStatus { 25 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 26 + match self { 27 + CheckStatus::Pass => write!(f, "[OK]"), 28 + CheckStatus::SpecViolation => write!(f, "[FAIL]"), 29 + CheckStatus::NetworkError => write!(f, "[NET]"), 30 + CheckStatus::Advisory => write!(f, "[WARN]"), 31 + CheckStatus::Skipped => write!(f, "[SKIP]"), 32 + } 33 + } 34 + } 35 + 36 + /// Result of a single check within a labeler validation stage. 37 + #[derive(Debug)] 38 + pub struct CheckResult { 39 + /// Stable identifier for this check (e.g., "identity::target_resolved"). 40 + pub id: &'static str, 41 + /// Which stage this check belongs to. 42 + pub stage: Stage, 43 + /// The outcome severity of this check. 44 + pub status: CheckStatus, 45 + /// Human-readable summary of what was checked. 46 + pub summary: std::borrow::Cow<'static, str>, 47 + /// Optional diagnostic with source code context (for failures). 48 + pub diagnostic: Option<Box<dyn Diagnostic + Send + Sync>>, 49 + /// Optional reason why a check was skipped. 50 + pub skipped_reason: Option<std::borrow::Cow<'static, str>>, 51 + } 52 + 53 + /// The stages of labeler validation. 54 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 55 + pub enum Stage { 56 + /// DID document and labeler record validation. 57 + Identity, 58 + /// HTTP endpoint healthchecks. 59 + Http, 60 + /// WebSocket subscription validation. 61 + Subscription, 62 + /// Cryptographic signing verification. 63 + Crypto, 64 + } 65 + 66 + impl Stage { 67 + /// Human-readable heading for this stage. 68 + pub fn label(self) -> &'static str { 69 + match self { 70 + Stage::Identity => "Identity", 71 + Stage::Http => "HTTP", 72 + Stage::Subscription => "Subscription", 73 + Stage::Crypto => "Crypto", 74 + } 75 + } 76 + } 77 + 78 + /// Summary counts of check results by severity. 79 + #[derive(Debug, Clone, PartialEq, Eq)] 80 + pub struct SummaryCounts { 81 + pub pass: usize, 82 + pub spec_violation: usize, 83 + pub network_error: usize, 84 + pub advisory: usize, 85 + pub skipped: usize, 86 + } 87 + 88 + impl SummaryCounts { 89 + /// Count results by severity. 90 + pub fn from_results(results: &[CheckResult]) -> Self { 91 + let mut counts = SummaryCounts { 92 + pass: 0, 93 + spec_violation: 0, 94 + network_error: 0, 95 + advisory: 0, 96 + skipped: 0, 97 + }; 98 + 99 + for result in results { 100 + match result.status { 101 + CheckStatus::Pass => counts.pass += 1, 102 + CheckStatus::SpecViolation => counts.spec_violation += 1, 103 + CheckStatus::NetworkError => counts.network_error += 1, 104 + CheckStatus::Advisory => counts.advisory += 1, 105 + CheckStatus::Skipped => counts.skipped += 1, 106 + } 107 + } 108 + 109 + counts 110 + } 111 + } 112 + 113 + /// Header information for a labeler report. 114 + #[derive(Debug, Clone)] 115 + pub struct ReportHeader { 116 + /// The input target (handle, DID, or URL). 117 + pub target: String, 118 + /// The resolved DID if applicable. 119 + pub resolved_did: Option<String>, 120 + /// The PDS endpoint if resolved. 121 + pub pds_endpoint: Option<String>, 122 + /// The labeler service endpoint if resolved. 123 + pub labeler_endpoint: Option<String>, 124 + } 125 + 126 + /// Configuration for rendering the report. 127 + #[derive(Debug, Clone)] 128 + pub struct RenderConfig { 129 + /// Whether to suppress colored output. 130 + pub no_color: bool, 131 + } 132 + 133 + /// The complete labeler validation report. 134 + #[derive(Debug)] 135 + pub struct LabelerReport { 136 + /// Header with target and resolved endpoints. 137 + pub header: ReportHeader, 138 + /// All validation results collected during the run. 139 + pub results: Vec<CheckResult>, 140 + /// When the run started. 141 + pub started_at: Instant, 142 + /// When the run finished. 143 + pub finished_at: Option<Instant>, 144 + } 145 + 146 + impl LabelerReport { 147 + /// Create a new empty report. 148 + pub fn new(header: ReportHeader) -> Self { 149 + LabelerReport { 150 + header, 151 + results: Vec::new(), 152 + started_at: Instant::now(), 153 + finished_at: None, 154 + } 155 + } 156 + 157 + /// Record a check result. 158 + pub fn record(&mut self, result: CheckResult) { 159 + self.results.push(result); 160 + } 161 + 162 + /// Mark the report as finished. 163 + pub fn finish(&mut self) { 164 + self.finished_at = Some(Instant::now()); 165 + } 166 + 167 + /// Compute the exit code: 1 if any SpecViolation, else 0. 168 + pub fn exit_code(&self) -> i32 { 169 + if self 170 + .results 171 + .iter() 172 + .any(|r| r.status == CheckStatus::SpecViolation) 173 + { 174 + 1 175 + } else { 176 + 0 177 + } 178 + } 179 + 180 + /// Get summary counts of all results. 181 + pub fn summary_counts(&self) -> SummaryCounts { 182 + SummaryCounts::from_results(&self.results) 183 + } 184 + 185 + /// Render the report to the given writer. 186 + pub fn render<W: io::Write>(&self, out: &mut W, config: &RenderConfig) -> io::Result<()> { 187 + // Header line with target and resolved endpoints. 188 + let elapsed = self 189 + .finished_at 190 + .map(|f| f.duration_since(self.started_at).as_millis()) 191 + .unwrap_or(0); 192 + writeln!(out, "Target: {}", self.header.target)?; 193 + if let Some(did) = &self.header.resolved_did { 194 + writeln!(out, " Resolved DID: {}", did)?; 195 + } 196 + if let Some(pds) = &self.header.pds_endpoint { 197 + writeln!(out, " PDS endpoint: {}", pds)?; 198 + } 199 + if let Some(labeler) = &self.header.labeler_endpoint { 200 + writeln!(out, " Labeler endpoint: {}", labeler)?; 201 + } 202 + writeln!(out, " elapsed: {}ms", elapsed)?; 203 + writeln!(out)?; 204 + 205 + // Group results by stage and render. 206 + let mut current_stage: Option<Stage> = None; 207 + for result in &self.results { 208 + if Some(result.stage) != current_stage { 209 + current_stage = Some(result.stage); 210 + writeln!(out, "== {} ==", result.stage.label())?; 211 + } 212 + 213 + // Write the check result line. 214 + write!(out, "{} {} ", result.status, result.summary)?; 215 + if let Some(reason) = &result.skipped_reason { 216 + write!(out, "— {}", reason)?; 217 + } 218 + writeln!(out)?; 219 + 220 + // Render diagnostic if present (and not skipped). 221 + if let Some(diag) = &result.diagnostic { 222 + if result.status != CheckStatus::Skipped { 223 + let theme = if config.no_color { 224 + GraphicalTheme::unicode_nocolor() 225 + } else { 226 + GraphicalTheme::default() 227 + }; 228 + let handler = GraphicalReportHandler::new().with_theme(theme); 229 + let mut buf = String::new(); 230 + // Cast the boxed diagnostic to a trait object. 231 + if let Err(_e) = handler.render_report(&mut buf, diag.as_ref()) { 232 + // If rendering fails, write a fallback message. 233 + writeln!(out, " (diagnostic rendering failed)")?; 234 + } else { 235 + for line in buf.lines() { 236 + writeln!(out, " {}", line)?; 237 + } 238 + } 239 + } 240 + } 241 + } 242 + 243 + writeln!(out)?; 244 + 245 + // Summary footer. 246 + let counts = self.summary_counts(); 247 + write!( 248 + out, 249 + "Summary: {} passed, {} failed (spec), {} network errors, {} advisories, {} skipped. ", 250 + counts.pass, counts.spec_violation, counts.network_error, counts.advisory, counts.skipped 251 + )?; 252 + writeln!(out, "Exit code: {}", self.exit_code())?; 253 + 254 + Ok(()) 255 + } 256 + } 257 + 258 + #[cfg(test)] 259 + mod tests { 260 + use super::*; 261 + 262 + #[test] 263 + fn exit_code_only_advisory_is_zero() { 264 + let header = ReportHeader { 265 + target: "test".to_string(), 266 + resolved_did: None, 267 + pds_endpoint: None, 268 + labeler_endpoint: None, 269 + }; 270 + let mut report = LabelerReport::new(header); 271 + report.record(CheckResult { 272 + id: "test", 273 + stage: Stage::Identity, 274 + status: CheckStatus::Advisory, 275 + summary: "advisory check".into(), 276 + diagnostic: None, 277 + skipped_reason: None, 278 + }); 279 + assert_eq!(report.exit_code(), 0); 280 + } 281 + 282 + #[test] 283 + fn exit_code_only_network_errors_is_zero() { 284 + let header = ReportHeader { 285 + target: "test".to_string(), 286 + resolved_did: None, 287 + pds_endpoint: None, 288 + labeler_endpoint: None, 289 + }; 290 + let mut report = LabelerReport::new(header); 291 + report.record(CheckResult { 292 + id: "test", 293 + stage: Stage::Identity, 294 + status: CheckStatus::NetworkError, 295 + summary: "network check".into(), 296 + diagnostic: None, 297 + skipped_reason: None, 298 + }); 299 + assert_eq!(report.exit_code(), 0); 300 + } 301 + 302 + #[test] 303 + fn exit_code_with_spec_violation_is_one() { 304 + let header = ReportHeader { 305 + target: "test".to_string(), 306 + resolved_did: None, 307 + pds_endpoint: None, 308 + labeler_endpoint: None, 309 + }; 310 + let mut report = LabelerReport::new(header); 311 + report.record(CheckResult { 312 + id: "test", 313 + stage: Stage::Identity, 314 + status: CheckStatus::SpecViolation, 315 + summary: "spec check".into(), 316 + diagnostic: None, 317 + skipped_reason: None, 318 + }); 319 + assert_eq!(report.exit_code(), 1); 320 + } 321 + 322 + #[test] 323 + fn summary_counts_partition_correct() { 324 + let header = ReportHeader { 325 + target: "test".to_string(), 326 + resolved_did: None, 327 + pds_endpoint: None, 328 + labeler_endpoint: None, 329 + }; 330 + let mut report = LabelerReport::new(header); 331 + 332 + report.record(CheckResult { 333 + id: "test1", 334 + stage: Stage::Identity, 335 + status: CheckStatus::Pass, 336 + summary: "pass check".into(), 337 + diagnostic: None, 338 + skipped_reason: None, 339 + }); 340 + 341 + report.record(CheckResult { 342 + id: "test2", 343 + stage: Stage::Identity, 344 + status: CheckStatus::SpecViolation, 345 + summary: "fail check".into(), 346 + diagnostic: None, 347 + skipped_reason: None, 348 + }); 349 + 350 + report.record(CheckResult { 351 + id: "test3", 352 + stage: Stage::Http, 353 + status: CheckStatus::NetworkError, 354 + summary: "net check".into(), 355 + diagnostic: None, 356 + skipped_reason: None, 357 + }); 358 + 359 + report.record(CheckResult { 360 + id: "test4", 361 + stage: Stage::Http, 362 + status: CheckStatus::Advisory, 363 + summary: "warn check".into(), 364 + diagnostic: None, 365 + skipped_reason: None, 366 + }); 367 + 368 + report.record(CheckResult { 369 + id: "test5", 370 + stage: Stage::Subscription, 371 + status: CheckStatus::Skipped, 372 + summary: "skip check".into(), 373 + diagnostic: None, 374 + skipped_reason: Some("not implemented".into()), 375 + }); 376 + 377 + let counts = report.summary_counts(); 378 + assert_eq!(counts.pass, 1); 379 + assert_eq!(counts.spec_violation, 1); 380 + assert_eq!(counts.network_error, 1); 381 + assert_eq!(counts.advisory, 1); 382 + assert_eq!(counts.skipped, 1); 383 + } 384 + 385 + #[test] 386 + fn render_basic_glyphs() { 387 + let header = ReportHeader { 388 + target: "test.example".to_string(), 389 + resolved_did: None, 390 + pds_endpoint: None, 391 + labeler_endpoint: None, 392 + }; 393 + let mut report = LabelerReport::new(header); 394 + 395 + report.record(CheckResult { 396 + id: "test1", 397 + stage: Stage::Identity, 398 + status: CheckStatus::Pass, 399 + summary: "pass check".into(), 400 + diagnostic: None, 401 + skipped_reason: None, 402 + }); 403 + 404 + report.record(CheckResult { 405 + id: "test2", 406 + stage: Stage::Identity, 407 + status: CheckStatus::SpecViolation, 408 + summary: "fail check".into(), 409 + diagnostic: None, 410 + skipped_reason: None, 411 + }); 412 + 413 + report.record(CheckResult { 414 + id: "test3", 415 + stage: Stage::Http, 416 + status: CheckStatus::Skipped, 417 + summary: "skip check".into(), 418 + diagnostic: None, 419 + skipped_reason: Some("not yet implemented".into()), 420 + }); 421 + 422 + report.finish(); 423 + 424 + let mut buf = Vec::new(); 425 + let config = RenderConfig { no_color: true }; 426 + report.render(&mut buf, &config).expect("render failed"); 427 + 428 + let output = String::from_utf8(buf).expect("invalid utf-8"); 429 + 430 + // Check for the expected glyphs. 431 + assert!( 432 + output.contains("[OK]"), 433 + "output should contain [OK] glyph:\n{}", 434 + output 435 + ); 436 + assert!( 437 + output.contains("[FAIL]"), 438 + "output should contain [FAIL] glyph:\n{}", 439 + output 440 + ); 441 + assert!( 442 + output.contains("[SKIP]"), 443 + "output should contain [SKIP] glyph:\n{}", 444 + output 445 + ); 446 + } 447 + }