1use chrono::{DateTime, Utc};
15use colored::Colorize;
16use serde::{Deserialize, Serialize};
17use std::path::PathBuf;
18
19use crate::error::{CleanroomError, Result};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum DiagnosticFormat {
28 Ansi,
30 Json,
32 GithubWorkflow,
34 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum ValidationStatus {
59 Pass,
61 Fail,
63 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#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct SpanValidation {
80 pub required_count: usize,
82 pub present_count: usize,
84 pub missing: Vec<String>,
86}
87
88impl SpanValidation {
89 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#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct AttributeValidation {
102 pub required_count: usize,
104 pub present_count: usize,
106 pub missing_count: usize,
108 pub missing: Vec<String>,
110}
111
112impl AttributeValidation {
113 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#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct Violation {
126 #[serde(rename = "type")]
128 pub type_: String,
129 pub severity: String,
131 pub name: String,
133 pub span: Option<String>,
135 pub schema_file: PathBuf,
137 pub schema_line: usize,
139 pub message: String,
141 pub documentation_url: Option<String>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct ConformanceReport {
148 pub clnrm_version: String,
150 pub test_name: String,
152 pub test_file: PathBuf,
154 pub timestamp: DateTime<Utc>,
156 pub duration_ms: u64,
158
159 pub validation_status: ValidationStatus,
162 pub spans: SpanValidation,
164 pub attributes: AttributeValidation,
166 pub violations: Vec<Violation>,
168
169 pub exit_code: i32,
172 pub recommendation: Option<String>,
174 pub environment: EnvironmentInfo,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct EnvironmentInfo {
181 pub os: String,
183 pub arch: String,
185 pub ci: bool,
187 pub github_actions: bool,
189}
190
191#[derive(Debug, Clone, Deserialize)]
197pub struct DiagnosticConfig {
198 #[serde(default = "default_format")]
200 pub format: String,
201
202 #[serde(default = "default_true")]
204 pub stdout: bool,
205
206 pub output_file: Option<String>,
208
209 #[serde(default = "default_true")]
211 pub github_artifact: bool,
212
213 #[serde(default = "default_true")]
215 pub fail_on_violation: bool,
216
217 #[serde(default)]
219 pub fail_on_missing_optional: bool,
220
221 #[serde(default = "default_true")]
223 pub fail_on_weaver_error: bool,
224
225 #[serde(default)]
227 pub ansi: AnsiConfig,
228
229 #[serde(default)]
231 pub json: JsonConfig,
232
233 #[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#[derive(Debug, Clone, Deserialize)]
264pub struct AnsiConfig {
265 #[serde(default = "default_true")]
267 pub colors: bool,
268
269 #[serde(default = "default_true")]
271 pub show_header: bool,
272
273 #[serde(default = "default_true")]
275 pub show_docs_links: bool,
276
277 #[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#[derive(Debug, Clone, Deserialize)]
299pub struct JsonConfig {
300 #[serde(default = "default_true")]
302 pub pretty: bool,
303
304 #[serde(default)]
306 pub include_raw_weaver: bool,
307
308 #[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#[derive(Debug, Clone, Deserialize)]
329pub struct GithubConfig {
330 #[serde(default = "default_error_level")]
332 pub critical_level: String,
333
334 #[serde(default = "default_warning_level")]
336 pub optional_level: String,
337
338 #[serde(default = "default_true")]
340 pub generate_summary: bool,
341
342 #[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
365pub fn detect_format() -> DiagnosticFormat {
371 if std::env::var("GITHUB_ACTIONS")
373 .map(|v| v == "true")
374 .unwrap_or(false)
375 {
376 return DiagnosticFormat::GithubWorkflow;
377 }
378
379 if std::env::var("CI").is_ok() || std::env::var("CONTINUOUS_INTEGRATION").is_ok() {
381 return DiagnosticFormat::Json;
382 }
383
384 #[cfg(unix)]
386 {
387 use std::os::unix::io::AsRawFd;
388 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 if std::env::var("TERM").is_ok() {
399 return DiagnosticFormat::Ansi;
400 }
401 }
402
403 DiagnosticFormat::Json
405}
406
407pub trait DiagnosticFormatter: Send + Sync {
413 fn format(&self, report: &ConformanceReport) -> Result<String>;
415
416 fn file_extension(&self) -> &str;
418
419 fn mime_type(&self) -> &str;
421}
422
423pub struct AnsiFormatter {
429 config: AnsiConfig,
430}
431
432impl AnsiFormatter {
433 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 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 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
653pub struct JsonFormatter {
659 config: JsonConfig,
660}
661
662impl JsonFormatter {
663 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
690pub struct GithubWorkflowFormatter {
696 config: GithubConfig,
697}
698
699impl GithubWorkflowFormatter {
700 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 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 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 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 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
782pub struct DiagnosticProcessor {
788 config: DiagnosticConfig,
789}
790
791impl DiagnosticProcessor {
792 pub fn new(config: DiagnosticConfig) -> Self {
794 Self { config }
795 }
796
797 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 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 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 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 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}