#![forbid(unsafe_code)] //! Structured diagnostics shared by `FParkan` crates. /// Diagnostic severity. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Severity { /// Informational note. Info, /// Recoverable warning. Warning, /// Error for the current operation. Error, /// Fatal error for the current run. Fatal, } /// Evidence level for a contract or interpretation. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum EvidenceStatus { /// Described by project documentation. Documented, /// Verified by synthetic fixtures. SyntheticVerified, /// Verified against the licensed corpus. CorpusVerified, /// Verified by runtime capture. RuntimeCaptured, /// Working hypothesis; not a runtime contract. Hypothesis, } /// Operation phase where a diagnostic was produced. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Phase { /// Discovery. Discover, /// Read. Read, /// Parse. Parse, /// Validate. Validate, /// Resolve. Resolve, /// Prepare. Prepare, /// Construct. Construct, /// Register. Register, /// Simulate. Simulate, /// Render. Render, } /// Byte span in an input source. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct SourceSpan { /// Start offset. pub offset: u64, /// Length in bytes. pub length: u64, } /// Stable diagnostic code. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct DiagnosticCode(pub &'static str); /// Context attached to a diagnostic. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct DiagnosticContext { /// Phase. pub phase: Option, /// Redacted or logical path. pub path: Option, /// Archive entry name. pub archive_entry: Option, /// Object/prototype key. pub object_key: Option, /// Input span. pub span: Option, } /// Structured diagnostic with cause chain. #[derive(Clone, Debug, Eq, PartialEq)] pub struct Diagnostic { /// Stable code. pub code: DiagnosticCode, /// Severity. pub severity: Severity, /// Human message. pub message: String, /// Context. pub context: DiagnosticContext, /// Causes. pub causes: Vec, } /// Creates a diagnostic with default error severity. #[must_use] pub fn diagnostic(code: DiagnosticCode, message: impl Into) -> Diagnostic { Diagnostic { code, severity: Severity::Error, message: message.into(), context: DiagnosticContext::default(), causes: Vec::new(), } } impl Diagnostic { /// Returns a copy with severity changed. #[must_use] pub fn with_severity(mut self, severity: Severity) -> Self { self.severity = severity; self } /// Returns a copy with context changed. #[must_use] pub fn with_context(mut self, context: DiagnosticContext) -> Self { self.context = context; self } /// Adds a cause. pub fn push_cause(&mut self, cause: Diagnostic) { self.causes.push(cause); } } /// Renders a compact human-readable diagnostic. #[must_use] pub fn render_human(diagnostic: &Diagnostic) -> String { let mut out = format!( "{:?} {}: {}", diagnostic.severity, diagnostic.code.0, diagnostic.message ); if let Some(path) = &diagnostic.context.path { out.push_str(" ["); out.push_str(path); out.push(']'); } out } /// Renders deterministic JSON without requiring a serialization dependency. #[must_use] pub fn render_json(diagnostic: &Diagnostic) -> String { fn esc(value: &str) -> String { let mut out = String::with_capacity(value.len() + 2); for ch in value.chars() { match ch { '\\' => out.push_str("\\\\"), '"' => out.push_str("\\\""), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), _ => out.push(ch), } } out } let mut out = String::new(); out.push('{'); out.push_str("\"code\":\""); out.push_str(&esc(diagnostic.code.0)); out.push_str("\",\"severity\":\""); out.push_str(match diagnostic.severity { Severity::Info => "info", Severity::Warning => "warning", Severity::Error => "error", Severity::Fatal => "fatal", }); out.push_str("\",\"message\":\""); out.push_str(&esc(&diagnostic.message)); out.push_str("\",\"context\":{"); if let Some(phase) = diagnostic.context.phase { out.push_str("\"phase\":\""); out.push_str(match phase { Phase::Discover => "discover", Phase::Read => "read", Phase::Parse => "parse", Phase::Validate => "validate", Phase::Resolve => "resolve", Phase::Prepare => "prepare", Phase::Construct => "construct", Phase::Register => "register", Phase::Simulate => "simulate", Phase::Render => "render", }); out.push('"'); } if let Some(path) = &diagnostic.context.path { if diagnostic.context.phase.is_some() { out.push(','); } out.push_str("\"path\":\""); out.push_str(&esc(path)); out.push('"'); } if let Some(entry) = &diagnostic.context.archive_entry { if diagnostic.context.phase.is_some() || diagnostic.context.path.is_some() { out.push(','); } out.push_str("\"archive_entry\":\""); out.push_str(&esc(entry)); out.push('"'); } if let Some(key) = &diagnostic.context.object_key { if diagnostic.context.phase.is_some() || diagnostic.context.path.is_some() || diagnostic.context.archive_entry.is_some() { out.push(','); } out.push_str("\"object_key\":\""); out.push_str(&esc(key)); out.push('"'); } if let Some(span) = diagnostic.context.span { if diagnostic.context.phase.is_some() || diagnostic.context.path.is_some() || diagnostic.context.archive_entry.is_some() || diagnostic.context.object_key.is_some() { out.push(','); } out.push_str("\"span\":{\"offset\":"); out.push_str(&span.offset.to_string()); out.push_str(",\"length\":"); out.push_str(&span.length.to_string()); out.push('}'); } out.push_str("},\"causes\":["); for (idx, cause) in diagnostic.causes.iter().enumerate() { if idx > 0 { out.push(','); } out.push_str(&render_json(cause)); } out.push_str("]}"); out } #[cfg(test)] mod tests { use super::*; #[test] fn json_is_stable() { let d = diagnostic(DiagnosticCode("S0-DIAG-001"), "keeps context").with_context( DiagnosticContext { phase: Some(Phase::Parse), ..DiagnosticContext::default() }, ); assert_eq!( render_json(&d), "{\"code\":\"S0-DIAG-001\",\"severity\":\"error\",\"message\":\"keeps context\",\"context\":{\"phase\":\"parse\"},\"causes\":[]}" ); } #[test] fn diagnostic_chain_preserves_context() { let mut root = diagnostic(DiagnosticCode("ROOT"), "root").with_context(DiagnosticContext { phase: Some(Phase::Resolve), path: Some("archives/material.lib".to_string()), archive_entry: Some("MATERIAL.MAT0".to_string()), object_key: Some("unit/tank".to_string()), span: Some(SourceSpan { offset: 12, length: 4, }), }); root.push_cause(diagnostic(DiagnosticCode("CAUSE"), "cause").with_context( DiagnosticContext { phase: Some(Phase::Parse), path: Some("archives/material.lib".to_string()), span: Some(SourceSpan { offset: 16, length: 8, }), ..DiagnosticContext::default() }, )); let json = render_json(&root); assert!(json.contains("\"code\":\"ROOT\"")); assert!(json.contains("\"phase\":\"resolve\"")); assert!(json.contains("\"path\":\"archives/material.lib\"")); assert!(json.contains("\"archive_entry\":\"MATERIAL.MAT0\"")); assert!(json.contains("\"object_key\":\"unit/tank\"")); assert!(json.contains("\"span\":{\"offset\":12,\"length\":4}")); assert!(json.contains("\"code\":\"CAUSE\"")); assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}")); } }