Files
fparkan/crates/fparkan-diagnostics/src/lib.rs
T

250 lines
7.0 KiB
Rust
Raw Normal View History

#![forbid(unsafe_code)]
2026-06-23 22:32:50 +04:00
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Structured diagnostics shared by `FParkan` crates.
2026-06-23 22:05:16 +04:00
use serde::Serialize;
/// Diagnostic severity.
2026-06-23 22:05:16 +04:00
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
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.
2026-06-23 22:05:16 +04:00
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
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.
2026-06-23 22:05:16 +04:00
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
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.
2026-06-23 22:05:16 +04:00
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub struct SourceSpan {
/// Start offset.
pub offset: u64,
/// Length in bytes.
pub length: u64,
}
/// Stable diagnostic code.
2026-06-23 22:05:16 +04:00
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
pub struct DiagnosticCode(pub &'static str);
/// Context attached to a diagnostic.
2026-06-23 22:05:16 +04:00
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub struct DiagnosticContext {
/// Phase.
2026-06-23 22:32:50 +04:00
#[serde(skip_serializing_if = "Option::is_none")]
pub phase: Option<Phase>,
/// Redacted or logical path.
2026-06-23 22:32:50 +04:00
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
/// Archive entry name.
2026-06-23 22:32:50 +04:00
#[serde(skip_serializing_if = "Option::is_none")]
pub archive_entry: Option<String>,
/// Object/prototype key.
2026-06-23 22:32:50 +04:00
#[serde(skip_serializing_if = "Option::is_none")]
pub object_key: Option<String>,
/// Input span.
2026-06-23 22:32:50 +04:00
#[serde(skip_serializing_if = "Option::is_none")]
pub span: Option<SourceSpan>,
}
/// Structured diagnostic with cause chain.
2026-06-23 22:05:16 +04:00
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
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
}
2026-06-23 22:05:16 +04:00
/// Renders deterministic JSON using the typed diagnostic schema.
#[must_use]
pub fn render_json(diagnostic: &Diagnostic) -> String {
2026-06-23 22:05:16 +04:00
match serde_json::to_string(diagnostic) {
Ok(json) => json,
Err(err) => format!("{{\"error\":\"diagnostic serialization failed: {err}\"}}"),
}
}
#[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}"));
}
2026-06-23 22:05:16 +04:00
#[test]
fn json_escapes_all_control_characters() {
let value = diagnostic(DiagnosticCode("S1-H01"), "quote\"\u{0000}tab\tline\r\n");
let json = render_json(&value);
assert!(json.contains("\\u0000"));
2026-06-23 22:32:50 +04:00
assert!(json.contains("\\t"));
2026-06-23 22:05:16 +04:00
assert!(!json.contains('\t'));
assert!(!json.contains('\r'));
}
}