diff options
Diffstat (limited to 'crates/fparkan-diagnostics/src/lib.rs')
| -rw-r--r-- | crates/fparkan-diagnostics/src/lib.rs | 301 |
1 files changed, 301 insertions, 0 deletions
diff --git a/crates/fparkan-diagnostics/src/lib.rs b/crates/fparkan-diagnostics/src/lib.rs new file mode 100644 index 0000000..8b3e160 --- /dev/null +++ b/crates/fparkan-diagnostics/src/lib.rs @@ -0,0 +1,301 @@ +#![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<Phase>, + /// Redacted or logical path. + pub path: Option<String>, + /// Archive entry name. + pub archive_entry: Option<String>, + /// Object/prototype key. + pub object_key: Option<String>, + /// Input span. + pub span: Option<SourceSpan>, +} + +/// 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<Diagnostic>, +} + +/// Creates a diagnostic with default error severity. +#[must_use] +pub fn diagnostic(code: DiagnosticCode, message: impl Into<String>) -> 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}")); + } +} |
