clnrm_core/telemetry/live_check/
diagnostics.rs

1//! Multi-format diagnostic report parsing and enhancement for Weaver conformance reports.
2//!
3//! This module provides diagnostic formatting capabilities for clnrm v1.3.0, supporting
4//! three output formats: ANSI (terminal), JSON (machine-readable), and GitHub Workflow
5//! Commands (CI/CD integration).
6//!
7//! # Features
8//! - Auto-detection of appropriate format based on environment
9//! - Beautiful ANSI output with box-drawing characters and colors
10//! - Machine-readable JSON output with full schema compliance
11//! - GitHub Actions integration with annotations and job summaries
12//! - Rich error context and actionable recommendations
13
14use chrono::{DateTime, Utc};
15use colored::Colorize;
16use serde::{Deserialize, Serialize};
17use std::path::PathBuf;
18
19use crate::error::{CleanroomError, Result};
20
21// ═══════════════════════════════════════════════════════════
22// Core Data Structures
23// ═══════════════════════════════════════════════════════════
24
25/// Diagnostic output format
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum DiagnosticFormat {
28    /// Human-readable ANSI terminal output with colors
29    Ansi,
30    /// Machine-readable JSON output
31    Json,
32    /// GitHub Actions workflow commands
33    GithubWorkflow,
34    /// Auto-detect based on environment
35    Auto,
36}
37
38impl std::str::FromStr for DiagnosticFormat {
39    type Err = CleanroomError;
40
41    fn from_str(s: &str) -> Result<Self> {
42        match s.to_lowercase().as_str() {
43            "ansi" => Ok(Self::Ansi),
44            "json" => Ok(Self::Json),
45            "gh_workflow" | "github" => Ok(Self::GithubWorkflow),
46            "auto" => Ok(Self::Auto),
47            _ => Err(CleanroomError::internal_error(format!(
48                "Invalid diagnostic format: '{}'. Must be 'ansi', 'json', 'gh_workflow', or 'auto'",
49                s
50            ))),
51        }
52    }
53}
54
55/// Validation status
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum ValidationStatus {
59    /// All validations passed
60    Pass,
61    /// One or more validations failed
62    Fail,
63    /// Validation completed with warnings
64    Warning,
65}
66
67impl std::fmt::Display for ValidationStatus {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            Self::Pass => write!(f, "PASSED"),
71            Self::Fail => write!(f, "FAILED"),
72            Self::Warning => write!(f, "WARNING"),
73        }
74    }
75}
76
77/// Span validation results
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct SpanValidation {
80    /// Number of required spans
81    pub required_count: usize,
82    /// Number of present spans
83    pub present_count: usize,
84    /// List of missing span names
85    pub missing: Vec<String>,
86}
87
88impl SpanValidation {
89    /// Calculate percentage of spans present
90    pub fn percentage(&self) -> f64 {
91        if self.required_count == 0 {
92            100.0
93        } else {
94            (self.present_count as f64 / self.required_count as f64) * 100.0
95        }
96    }
97}
98
99/// Attribute validation results
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct AttributeValidation {
102    /// Number of required attributes
103    pub required_count: usize,
104    /// Number of present attributes
105    pub present_count: usize,
106    /// Number of missing attributes
107    pub missing_count: usize,
108    /// List of missing attribute names
109    pub missing: Vec<String>,
110}
111
112impl AttributeValidation {
113    /// Calculate percentage of attributes present
114    pub fn percentage(&self) -> f64 {
115        if self.required_count == 0 {
116            100.0
117        } else {
118            (self.present_count as f64 / self.required_count as f64) * 100.0
119        }
120    }
121}
122
123/// A single violation detected during validation
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct Violation {
126    /// Type of violation
127    #[serde(rename = "type")]
128    pub type_: String,
129    /// Severity level
130    pub severity: String,
131    /// Name of the element with violation
132    pub name: String,
133    /// Optional parent span
134    pub span: Option<String>,
135    /// Schema file path
136    pub schema_file: PathBuf,
137    /// Line number in schema file
138    pub schema_line: usize,
139    /// Human-readable message
140    pub message: String,
141    /// Optional documentation URL
142    pub documentation_url: Option<String>,
143}
144
145/// Complete conformance report with clnrm context
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct ConformanceReport {
148    /// clnrm version that generated this report
149    pub clnrm_version: String,
150    /// Test name from configuration
151    pub test_name: String,
152    /// Test file path
153    pub test_file: PathBuf,
154    /// Report generation timestamp
155    pub timestamp: DateTime<Utc>,
156    /// Test execution duration in milliseconds
157    pub duration_ms: u64,
158
159    // Weaver validation results
160    /// Overall validation status
161    pub validation_status: ValidationStatus,
162    /// Span validation results
163    pub spans: SpanValidation,
164    /// Attribute validation results
165    pub attributes: AttributeValidation,
166    /// List of violations
167    pub violations: Vec<Violation>,
168
169    // clnrm analysis
170    /// Process exit code
171    pub exit_code: i32,
172    /// Optional recommendation text
173    pub recommendation: Option<String>,
174    /// Environment information
175    pub environment: EnvironmentInfo,
176}
177
178/// Environment information
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct EnvironmentInfo {
181    /// Operating system
182    pub os: String,
183    /// Architecture
184    pub arch: String,
185    /// Running in CI
186    pub ci: bool,
187    /// Running in GitHub Actions
188    pub github_actions: bool,
189}
190
191// ═══════════════════════════════════════════════════════════
192// Configuration
193// ═══════════════════════════════════════════════════════════
194
195/// Configuration for diagnostic output
196#[derive(Debug, Clone, Deserialize)]
197pub struct DiagnosticConfig {
198    /// Output format
199    #[serde(default = "default_format")]
200    pub format: String,
201
202    /// Write to stdout
203    #[serde(default = "default_true")]
204    pub stdout: bool,
205
206    /// Optional output file path
207    pub output_file: Option<String>,
208
209    /// Upload as GitHub artifact
210    #[serde(default = "default_true")]
211    pub github_artifact: bool,
212
213    /// Fail on any violations
214    #[serde(default = "default_true")]
215    pub fail_on_violation: bool,
216
217    /// Fail on missing optional attributes
218    #[serde(default)]
219    pub fail_on_missing_optional: bool,
220
221    /// Fail on Weaver errors
222    #[serde(default = "default_true")]
223    pub fail_on_weaver_error: bool,
224
225    /// ANSI-specific configuration
226    #[serde(default)]
227    pub ansi: AnsiConfig,
228
229    /// JSON-specific configuration
230    #[serde(default)]
231    pub json: JsonConfig,
232
233    /// GitHub-specific configuration
234    #[serde(default)]
235    pub github: GithubConfig,
236}
237
238fn default_format() -> String {
239    "auto".to_string()
240}
241fn default_true() -> bool {
242    true
243}
244
245impl Default for DiagnosticConfig {
246    fn default() -> Self {
247        Self {
248            format: default_format(),
249            stdout: true,
250            output_file: None,
251            github_artifact: true,
252            fail_on_violation: true,
253            fail_on_missing_optional: false,
254            fail_on_weaver_error: true,
255            ansi: AnsiConfig::default(),
256            json: JsonConfig::default(),
257            github: GithubConfig::default(),
258        }
259    }
260}
261
262/// ANSI formatter configuration
263#[derive(Debug, Clone, Deserialize)]
264pub struct AnsiConfig {
265    /// Enable colored output
266    #[serde(default = "default_true")]
267    pub colors: bool,
268
269    /// Show clnrm header
270    #[serde(default = "default_true")]
271    pub show_header: bool,
272
273    /// Show documentation links
274    #[serde(default = "default_true")]
275    pub show_docs_links: bool,
276
277    /// Verbosity level (0=minimal, 1=normal, 2=verbose)
278    #[serde(default = "default_verbosity")]
279    pub verbosity: u8,
280}
281
282fn default_verbosity() -> u8 {
283    1
284}
285
286impl Default for AnsiConfig {
287    fn default() -> Self {
288        Self {
289            colors: true,
290            show_header: true,
291            show_docs_links: true,
292            verbosity: 1,
293        }
294    }
295}
296
297/// JSON formatter configuration
298#[derive(Debug, Clone, Deserialize)]
299pub struct JsonConfig {
300    /// Pretty-print JSON
301    #[serde(default = "default_true")]
302    pub pretty: bool,
303
304    /// Include raw Weaver output
305    #[serde(default)]
306    pub include_raw_weaver: bool,
307
308    /// JSON schema version
309    #[serde(default = "default_schema_version")]
310    pub schema_version: String,
311}
312
313fn default_schema_version() -> String {
314    env!("CARGO_PKG_VERSION").to_string()
315}
316
317impl Default for JsonConfig {
318    fn default() -> Self {
319        Self {
320            pretty: true,
321            include_raw_weaver: false,
322            schema_version: default_schema_version(),
323        }
324    }
325}
326
327/// GitHub formatter configuration
328#[derive(Debug, Clone, Deserialize)]
329pub struct GithubConfig {
330    /// Annotation level for critical violations
331    #[serde(default = "default_error_level")]
332    pub critical_level: String,
333
334    /// Annotation level for optional violations
335    #[serde(default = "default_warning_level")]
336    pub optional_level: String,
337
338    /// Generate job summary
339    #[serde(default = "default_true")]
340    pub generate_summary: bool,
341
342    /// Include file paths in annotations
343    #[serde(default = "default_true")]
344    pub include_file_paths: bool,
345}
346
347fn default_error_level() -> String {
348    "error".to_string()
349}
350fn default_warning_level() -> String {
351    "warning".to_string()
352}
353
354impl Default for GithubConfig {
355    fn default() -> Self {
356        Self {
357            critical_level: "error".to_string(),
358            optional_level: "warning".to_string(),
359            generate_summary: true,
360            include_file_paths: true,
361        }
362    }
363}
364
365// ═══════════════════════════════════════════════════════════
366// Format Detection
367// ═══════════════════════════════════════════════════════════
368
369/// Auto-detect appropriate diagnostic format based on environment
370pub fn detect_format() -> DiagnosticFormat {
371    // Check for GitHub Actions
372    if std::env::var("GITHUB_ACTIONS")
373        .map(|v| v == "true")
374        .unwrap_or(false)
375    {
376        return DiagnosticFormat::GithubWorkflow;
377    }
378
379    // Check for generic CI
380    if std::env::var("CI").is_ok() || std::env::var("CONTINUOUS_INTEGRATION").is_ok() {
381        return DiagnosticFormat::Json;
382    }
383
384    // Check if stdout is a TTY
385    #[cfg(unix)]
386    {
387        use std::os::unix::io::AsRawFd;
388        // Use nix crate for isatty check (already a dependency)
389        use nix::unistd::isatty;
390        if isatty(std::io::stdout().as_raw_fd()).unwrap_or(false) {
391            return DiagnosticFormat::Ansi;
392        }
393    }
394
395    #[cfg(not(unix))]
396    {
397        // On Windows, default to ANSI if in terminal
398        if std::env::var("TERM").is_ok() {
399            return DiagnosticFormat::Ansi;
400        }
401    }
402
403    // Default to JSON for non-interactive
404    DiagnosticFormat::Json
405}
406
407// ═══════════════════════════════════════════════════════════
408// Formatter Trait
409// ═══════════════════════════════════════════════════════════
410
411/// Trait for formatting diagnostic reports
412pub trait DiagnosticFormatter: Send + Sync {
413    /// Format a conformance report to output string
414    fn format(&self, report: &ConformanceReport) -> Result<String>;
415
416    /// Get file extension for this format
417    fn file_extension(&self) -> &str;
418
419    /// Get MIME type for this format
420    fn mime_type(&self) -> &str;
421}
422
423// ═══════════════════════════════════════════════════════════
424// ANSI Formatter
425// ═══════════════════════════════════════════════════════════
426
427/// ANSI formatter for beautiful terminal output
428pub struct AnsiFormatter {
429    config: AnsiConfig,
430}
431
432impl AnsiFormatter {
433    /// Create a new ANSI formatter with configuration
434    pub fn new(config: AnsiConfig) -> Self {
435        Self { config }
436    }
437
438    fn format_header(&self, report: &ConformanceReport) -> String {
439        let mut s = String::new();
440
441        s.push_str("╔════════════════════════════════════════════════════════════╗\n");
442        s.push_str("║              clnrm Weaver Live Check Report               ║\n");
443        s.push_str(&format!(
444            "║                      v{:<20}               ║\n",
445            report.clnrm_version
446        ));
447        s.push_str("╚════════════════════════════════════════════════════════════╝\n\n");
448
449        s.push_str(&format!("Test: {}\n", report.test_name));
450        s.push_str(&format!("File: {}\n", report.test_file.display()));
451        s.push_str(&format!(
452            "Time: {}\n",
453            report.timestamp.format("%Y-%m-%d %H:%M:%S UTC")
454        ));
455        s.push_str(&format!("Duration: {}ms\n\n", report.duration_ms));
456
457        s
458    }
459
460    fn format_conformance_summary(&self, report: &ConformanceReport) -> String {
461        let mut s = String::new();
462
463        s.push_str("┌────────────────────────────────────────────────────────────┐\n");
464        s.push_str("│                  Conformance Summary                        │\n");
465        s.push_str("└────────────────────────────────────────────────────────────┘\n\n");
466
467        // Spans
468        let spans = &report.spans;
469        let spans_icon = if spans.missing.is_empty() {
470            "✅"
471        } else {
472            "❌"
473        };
474        let spans_text = format!(
475            "{} Spans: {}/{} ({:.1}%)",
476            spans_icon,
477            spans.present_count,
478            spans.required_count,
479            spans.percentage()
480        );
481        s.push_str(&if self.config.colors && spans.missing.is_empty() {
482            spans_text.green().to_string()
483        } else if self.config.colors {
484            spans_text.red().to_string()
485        } else {
486            spans_text
487        });
488        s.push('\n');
489
490        if !spans.missing.is_empty() {
491            s.push_str(&format!("   ❌ {} missing span(s)\n", spans.missing.len()));
492        }
493
494        // Attributes
495        let attrs = &report.attributes;
496        let attrs_icon = if attrs.missing_count == 0 {
497            "✅"
498        } else {
499            "⚠️"
500        };
501        let attrs_text = format!(
502            "{} Attributes: {}/{} ({:.1}%)",
503            attrs_icon,
504            attrs.present_count,
505            attrs.required_count,
506            attrs.percentage()
507        );
508        s.push_str(&if self.config.colors && attrs.missing_count == 0 {
509            attrs_text.green().to_string()
510        } else if self.config.colors {
511            attrs_text.yellow().to_string()
512        } else {
513            attrs_text
514        });
515        s.push_str("\n\n");
516
517        s
518    }
519
520    fn format_violations(&self, violations: &[Violation]) -> String {
521        if violations.is_empty() {
522            return String::new();
523        }
524
525        let mut s = String::new();
526
527        s.push_str("┌────────────────────────────────────────────────────────────┐\n");
528        s.push_str("│                   Critical Violations                       │\n");
529        s.push_str("└────────────────────────────────────────────────────────────┘\n\n");
530
531        for (i, violation) in violations.iter().enumerate() {
532            let name_str = format!("❌ {}", violation.name);
533            s.push_str(&if self.config.colors {
534                name_str.red().bold().to_string()
535            } else {
536                name_str
537            });
538            s.push('\n');
539
540            s.push_str(&format!(
541                "   Schema: {}:{}\n",
542                violation.schema_file.display(),
543                violation.schema_line
544            ));
545
546            let severity_str = format!("   Severity: {}", violation.severity.to_uppercase());
547            s.push_str(&if self.config.colors {
548                severity_str.red().to_string()
549            } else {
550                severity_str
551            });
552            s.push('\n');
553
554            s.push_str(&format!("\n   {}\n", violation.message));
555
556            if self.config.show_docs_links {
557                if let Some(url) = &violation.documentation_url {
558                    let url_str = format!("   📖 Documentation: {}", url);
559                    s.push_str(&if self.config.colors {
560                        url_str.blue().underline().to_string()
561                    } else {
562                        url_str
563                    });
564                    s.push('\n');
565                }
566            }
567
568            if i < violations.len() - 1 {
569                s.push('\n');
570            }
571        }
572
573        s.push('\n');
574        s
575    }
576
577    fn format_recommendation(&self, report: &ConformanceReport) -> String {
578        let mut s = String::new();
579
580        s.push_str("┌────────────────────────────────────────────────────────────┐\n");
581        s.push_str("│                      Recommendation                         │\n");
582        s.push_str("└────────────────────────────────────────────────────────────┘\n\n");
583
584        let status_text = match report.validation_status {
585            ValidationStatus::Pass => "✅ Test PASSED conformance validation".to_string(),
586            ValidationStatus::Fail => "❌ Test FAILED conformance validation".to_string(),
587            ValidationStatus::Warning => "⚠️  Test PASSED with warnings".to_string(),
588        };
589
590        s.push_str(&if self.config.colors {
591            match report.validation_status {
592                ValidationStatus::Pass => status_text.green().bold().to_string(),
593                ValidationStatus::Fail => status_text.red().bold().to_string(),
594                ValidationStatus::Warning => status_text.yellow().bold().to_string(),
595            }
596        } else {
597            status_text
598        });
599        s.push_str("\n\n");
600
601        if !report.violations.is_empty() {
602            s.push_str(&format!("Critical Issues: {}\n", report.violations.len()));
603        }
604
605        if let Some(recommendation) = &report.recommendation {
606            s.push_str(&format!("\n{}\n", recommendation));
607        }
608
609        s.push_str(&format!("\nExit Code: {}\n\n", report.exit_code));
610
611        s
612    }
613
614    fn format_footer(&self) -> String {
615        let mut s = String::new();
616        s.push_str("╔════════════════════════════════════════════════════════════╗\n");
617        s.push_str("║  Generated by clnrm + Weaver Registry Live Check          ║\n");
618        s.push_str("╚════════════════════════════════════════════════════════════╝\n");
619        s
620    }
621}
622
623impl DiagnosticFormatter for AnsiFormatter {
624    fn format(&self, report: &ConformanceReport) -> Result<String> {
625        let mut output = String::new();
626
627        if self.config.show_header {
628            output.push_str(&self.format_header(report));
629        }
630
631        output.push_str(&self.format_conformance_summary(report));
632
633        if !report.violations.is_empty() {
634            output.push_str(&self.format_violations(&report.violations));
635        }
636
637        output.push_str(&self.format_recommendation(report));
638
639        output.push_str(&self.format_footer());
640
641        Ok(output)
642    }
643
644    fn file_extension(&self) -> &str {
645        "txt"
646    }
647
648    fn mime_type(&self) -> &str {
649        "text/plain"
650    }
651}
652
653// ═══════════════════════════════════════════════════════════
654// JSON Formatter
655// ═══════════════════════════════════════════════════════════
656
657/// JSON formatter for machine-readable output
658pub struct JsonFormatter {
659    config: JsonConfig,
660}
661
662impl JsonFormatter {
663    /// Create a new JSON formatter with configuration
664    pub fn new(config: JsonConfig) -> Self {
665        Self { config }
666    }
667}
668
669impl DiagnosticFormatter for JsonFormatter {
670    fn format(&self, report: &ConformanceReport) -> Result<String> {
671        let json = if self.config.pretty {
672            serde_json::to_string_pretty(report)
673        } else {
674            serde_json::to_string(report)
675        }
676        .map_err(|e| CleanroomError::internal_error(format!("JSON formatting failed: {}", e)))?;
677
678        Ok(json)
679    }
680
681    fn file_extension(&self) -> &str {
682        "json"
683    }
684
685    fn mime_type(&self) -> &str {
686        "application/json"
687    }
688}
689
690// ═══════════════════════════════════════════════════════════
691// GitHub Workflow Formatter
692// ═══════════════════════════════════════════════════════════
693
694/// GitHub Workflow Command formatter for CI/CD integration
695pub struct GithubWorkflowFormatter {
696    config: GithubConfig,
697}
698
699impl GithubWorkflowFormatter {
700    /// Create a new GitHub formatter with configuration
701    pub fn new(config: GithubConfig) -> Self {
702        Self { config }
703    }
704}
705
706impl DiagnosticFormatter for GithubWorkflowFormatter {
707    fn format(&self, report: &ConformanceReport) -> Result<String> {
708        let mut output = String::new();
709
710        // Group: Header
711        output.push_str("::group::clnrm Weaver Live Check Report\n");
712        output.push_str(&format!("Test: {}\n", report.test_name));
713        output.push_str(&format!("File: {}\n", report.test_file.display()));
714        output.push_str(&format!(
715            "Time: {}\n",
716            report.timestamp.format("%Y-%m-%d %H:%M:%S UTC")
717        ));
718        output.push_str(&format!("Duration: {}ms\n", report.duration_ms));
719        output.push_str("::endgroup::\n\n");
720
721        // Group: Conformance summary
722        output.push_str("::group::Conformance Summary\n");
723        output.push_str(&format!(
724            "✅ Spans: {}/{} ({:.1}%)\n",
725            report.spans.present_count,
726            report.spans.required_count,
727            report.spans.percentage()
728        ));
729        output.push_str(&format!(
730            "✅ Attributes: {}/{} ({:.1}%)\n",
731            report.attributes.present_count,
732            report.attributes.required_count,
733            report.attributes.percentage()
734        ));
735        output.push_str("::endgroup::\n\n");
736
737        // Violations as error annotations
738        for violation in &report.violations {
739            if self.config.include_file_paths {
740                output.push_str(&format!(
741                    "::{}file={},line={},title={}::{}\n",
742                    self.config.critical_level,
743                    violation.schema_file.display(),
744                    violation.schema_line,
745                    violation.name,
746                    violation.message
747                ));
748            } else {
749                output.push_str(&format!(
750                    "::{}title={}::{}\n",
751                    self.config.critical_level, violation.name, violation.message
752                ));
753            }
754        }
755
756        // Set outputs
757        output.push_str(&format!(
758            "::set-output name=validation_status::{}\n",
759            report.validation_status
760        ));
761        output.push_str(&format!(
762            "::set-output name=violation_count::{}\n",
763            report.violations.len()
764        ));
765        output.push_str(&format!(
766            "::set-output name=exit_code::{}\n",
767            report.exit_code
768        ));
769
770        Ok(output)
771    }
772
773    fn file_extension(&self) -> &str {
774        "txt"
775    }
776
777    fn mime_type(&self) -> &str {
778        "text/plain"
779    }
780}
781
782// ═══════════════════════════════════════════════════════════
783// Diagnostic Processor
784// ═══════════════════════════════════════════════════════════
785
786/// Process and enhance diagnostic reports
787pub struct DiagnosticProcessor {
788    config: DiagnosticConfig,
789}
790
791impl DiagnosticProcessor {
792    /// Create a new diagnostic processor with configuration
793    pub fn new(config: DiagnosticConfig) -> Self {
794        Self { config }
795    }
796
797    /// Process a conformance report and format it
798    pub fn process(&self, report: &ConformanceReport) -> Result<String> {
799        let format = if self.config.format == "auto" {
800            detect_format()
801        } else {
802            self.config.format.parse()?
803        };
804
805        let formatter: Box<dyn DiagnosticFormatter> = match format {
806            DiagnosticFormat::Ansi => Box::new(AnsiFormatter::new(self.config.ansi.clone())),
807            DiagnosticFormat::Json => Box::new(JsonFormatter::new(self.config.json.clone())),
808            DiagnosticFormat::GithubWorkflow => {
809                Box::new(GithubWorkflowFormatter::new(self.config.github.clone()))
810            }
811            DiagnosticFormat::Auto => {
812                // Fallback to auto-detected format
813                let detected = detect_format();
814                return self.process_with_format(report, detected);
815            }
816        };
817
818        formatter.format(report)
819    }
820
821    fn process_with_format(
822        &self,
823        report: &ConformanceReport,
824        format: DiagnosticFormat,
825    ) -> Result<String> {
826        let formatter: Box<dyn DiagnosticFormatter> = match format {
827            DiagnosticFormat::Ansi => Box::new(AnsiFormatter::new(self.config.ansi.clone())),
828            DiagnosticFormat::Json => Box::new(JsonFormatter::new(self.config.json.clone())),
829            DiagnosticFormat::GithubWorkflow => {
830                Box::new(GithubWorkflowFormatter::new(self.config.github.clone()))
831            }
832            DiagnosticFormat::Auto => {
833                return Err(CleanroomError::internal_error(
834                    "Auto format should have been resolved",
835                ))
836            }
837        };
838
839        formatter.format(report)
840    }
841
842    /// Generate recommendation based on report
843    pub fn generate_recommendation(report: &ConformanceReport) -> Option<String> {
844        if report.violations.is_empty() {
845            None
846        } else {
847            Some(format!(
848                "Fix {} violation(s). See https://docs.clnrm.dev/telemetry for guidance.",
849                report.violations.len()
850            ))
851        }
852    }
853}
854
855#[cfg(test)]
856mod tests {
857    use super::*;
858
859    fn create_test_report() -> ConformanceReport {
860        ConformanceReport {
861            clnrm_version: "1.3.0".to_string(),
862            test_name: "test_example".to_string(),
863            test_file: PathBuf::from("tests/example.clnrm.toml"),
864            timestamp: Utc::now(),
865            duration_ms: 1234,
866            validation_status: ValidationStatus::Pass,
867            spans: SpanValidation {
868                required_count: 10,
869                present_count: 10,
870                missing: vec![],
871            },
872            attributes: AttributeValidation {
873                required_count: 20,
874                present_count: 20,
875                missing_count: 0,
876                missing: vec![],
877            },
878            violations: vec![],
879            exit_code: 0,
880            recommendation: None,
881            environment: EnvironmentInfo {
882                os: "linux".to_string(),
883                arch: "x86_64".to_string(),
884                ci: false,
885                github_actions: false,
886            },
887        }
888    }
889
890    fn create_test_report_with_violations() -> ConformanceReport {
891        let mut report = create_test_report();
892        report.validation_status = ValidationStatus::Fail;
893        report.spans.present_count = 8;
894        report.spans.missing = vec!["clnrm.test.cleanup".to_string()];
895        report.exit_code = 1;
896        report.violations = vec![Violation {
897            type_: "missing_span".to_string(),
898            severity: "error".to_string(),
899            name: "clnrm.test.cleanup".to_string(),
900            span: None,
901            schema_file: PathBuf::from("registry/test.yaml"),
902            schema_line: 12,
903            message: "Required span 'clnrm.test.cleanup' not found".to_string(),
904            documentation_url: Some("https://docs.clnrm.dev/telemetry/spans#cleanup".to_string()),
905        }];
906        report
907    }
908
909    #[test]
910    fn test_format_detection_default() {
911        let format = detect_format();
912        // Should default to JSON in test environment (non-TTY)
913        assert_eq!(format, DiagnosticFormat::Json);
914    }
915
916    #[test]
917    fn test_ansi_formatter_success() {
918        let report = create_test_report();
919        let formatter = AnsiFormatter::new(AnsiConfig::default());
920
921        let output = formatter.format(&report).unwrap();
922
923        assert!(output.contains("clnrm Weaver Live Check Report"));
924        assert!(output.contains("✅ Spans: 10/10 (100.0%)"));
925        assert!(output.contains("PASSED"));
926    }
927
928    #[test]
929    fn test_ansi_formatter_with_violations() {
930        let report = create_test_report_with_violations();
931        let formatter = AnsiFormatter::new(AnsiConfig::default());
932
933        let output = formatter.format(&report).unwrap();
934
935        assert!(output.contains("Critical Violations"));
936        assert!(output.contains("clnrm.test.cleanup"));
937        assert!(output.contains("FAILED"));
938    }
939
940    #[test]
941    fn test_json_formatter() {
942        let report = create_test_report();
943        let formatter = JsonFormatter::new(JsonConfig::default());
944
945        let output = formatter.format(&report).unwrap();
946
947        // Validate it's valid JSON
948        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
949        assert_eq!(parsed["test_name"], "test_example");
950        assert_eq!(parsed["validation_status"], "pass");
951    }
952
953    #[test]
954    fn test_github_formatter() {
955        let report = create_test_report_with_violations();
956        let formatter = GithubWorkflowFormatter::new(GithubConfig::default());
957
958        let output = formatter.format(&report).unwrap();
959
960        assert!(output.contains("::group::"));
961        assert!(output.contains("::error"));
962        assert!(output.contains("::set-output"));
963    }
964
965    #[test]
966    fn test_span_validation_percentage() {
967        let validation = SpanValidation {
968            required_count: 10,
969            present_count: 8,
970            missing: vec!["span1".to_string(), "span2".to_string()],
971        };
972
973        assert_eq!(validation.percentage(), 80.0);
974    }
975
976    #[test]
977    fn test_diagnostic_processor() {
978        let report = create_test_report();
979        let config = DiagnosticConfig::default();
980        let processor = DiagnosticProcessor::new(config);
981
982        let output = processor.process(&report).unwrap();
983        assert!(!output.is_empty());
984    }
985}