pub mod sites;
use std::cell::RefCell;
use std::fmt::Write;
use std::rc::Rc;
use std::sync::{Arc, Mutex, OnceLock};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Phase {
Bind,
Occur,
Presence,
Measure,
Paginate,
Suppress,
Resolve,
Emit,
Fallback,
}
impl Phase {
pub fn tag(self) -> &'static str {
match self {
Phase::Bind => "bind",
Phase::Occur => "occur",
Phase::Presence => "presence",
Phase::Measure => "measure",
Phase::Paginate => "paginate",
Phase::Suppress => "suppress",
Phase::Resolve => "resolve",
Phase::Emit => "emit",
Phase::Fallback => "fallback",
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Reason {
DataCountMatchesInitial,
DataCountClampedByOccurMax,
DataCountLiftedByOccurMin,
SubformMaterialisedFromData,
SubformMaterialisedFromInitial,
SubformSuppressedNoData,
PresenceHidden,
PresenceInvisible,
PresenceInactive,
PresenceVisible,
PaginateFitsCurrentPage,
PaginateDeferToNextPageMinH,
PaginateDeferToNextPageKeep,
PaginateSplit,
SuppressEmptyDataPageDropped,
SuppressCappedByFormDom,
SuppressStaticBindNonePreserved,
SuppressGatedByDataBoundSignal,
SuppressSkippedNoDataBoundSignal,
BindNoneFieldExcludedFromDataCheck,
StaticXfafTrimAllowed,
StaticXfafTrimBlocked,
InvisibleFieldBindingIgnored,
NonDataWidgetExcludedFromDataCheck,
BindNoneSubformExpansionSkipped,
ResolveLookupMiss,
MeasureSingleLine,
MeasureWrapped,
EmitOk,
StaticFallbackTaken,
Unspecified,
}
impl Reason {
pub fn tag(self) -> &'static str {
match self {
Reason::DataCountMatchesInitial => "data_count_matches_initial",
Reason::DataCountClampedByOccurMax => "data_count_clamped_by_occur_max",
Reason::DataCountLiftedByOccurMin => "data_count_lifted_by_occur_min",
Reason::SubformMaterialisedFromData => "subform_materialised_from_data",
Reason::SubformMaterialisedFromInitial => "subform_materialised_from_initial",
Reason::SubformSuppressedNoData => "subform_suppressed_no_data",
Reason::PresenceHidden => "presence_hidden",
Reason::PresenceInvisible => "presence_invisible",
Reason::PresenceInactive => "presence_inactive",
Reason::PresenceVisible => "presence_visible",
Reason::PaginateFitsCurrentPage => "paginate_fits_current_page",
Reason::PaginateDeferToNextPageMinH => "paginate_defer_to_next_page_min_h",
Reason::PaginateDeferToNextPageKeep => "paginate_defer_to_next_page_keep",
Reason::PaginateSplit => "paginate_split",
Reason::SuppressEmptyDataPageDropped => "suppress_empty_data_page_dropped",
Reason::SuppressCappedByFormDom => "suppress_capped_by_form_dom",
Reason::SuppressStaticBindNonePreserved => "suppress_static_bind_none_preserved",
Reason::SuppressGatedByDataBoundSignal => "suppress_gated_by_data_bound_signal",
Reason::SuppressSkippedNoDataBoundSignal => "suppress_skipped_no_data_bound_signal",
Reason::BindNoneFieldExcludedFromDataCheck => {
"bind_none_field_excluded_from_data_check"
}
Reason::StaticXfafTrimAllowed => "static_xfaf_trim_allowed",
Reason::StaticXfafTrimBlocked => "static_xfaf_trim_blocked",
Reason::InvisibleFieldBindingIgnored => "invisible_field_binding_ignored",
Reason::NonDataWidgetExcludedFromDataCheck => {
"non_data_widget_excluded_from_data_check"
}
Reason::BindNoneSubformExpansionSkipped => "bind_none_subform_expansion_skipped",
Reason::ResolveLookupMiss => "resolve_lookup_miss",
Reason::MeasureSingleLine => "measure_single_line",
Reason::MeasureWrapped => "measure_wrapped",
Reason::EmitOk => "emit_ok",
Reason::StaticFallbackTaken => "static_fallback_taken",
Reason::Unspecified => "unspecified",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TraceEvent {
pub phase: Phase,
pub reason: Reason,
pub som: Option<String>,
pub input: Option<String>,
pub decision: Option<String>,
pub source: Option<String>,
}
impl TraceEvent {
pub fn new(phase: Phase, reason: Reason) -> Self {
Self {
phase,
reason,
som: None,
input: None,
decision: None,
source: None,
}
}
pub fn with_som(mut self, som: impl Into<String>) -> Self {
self.som = Some(som.into());
self
}
pub fn with_input(mut self, input: impl Into<String>) -> Self {
self.input = Some(input.into());
self
}
pub fn with_decision(mut self, decision: impl Into<String>) -> Self {
self.decision = Some(decision.into());
self
}
pub fn with_source(mut self, source: impl Into<String>) -> Self {
self.source = Some(source.into());
self
}
}
pub trait Sink {
fn record(&self, event: TraceEvent);
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoopSink;
impl Sink for NoopSink {
fn record(&self, _event: TraceEvent) {}
}
#[derive(Debug, Default)]
pub struct RecordingSink {
events: Mutex<Vec<TraceEvent>>,
}
impl RecordingSink {
pub fn new() -> Self {
Self::default()
}
pub fn events(&self) -> Vec<TraceEvent> {
self.events
.lock()
.expect("trace sink mutex poisoned")
.clone()
}
pub fn len(&self) -> usize {
self.events.lock().expect("trace sink mutex poisoned").len()
}
pub fn is_empty(&self) -> bool {
self.events
.lock()
.expect("trace sink mutex poisoned")
.is_empty()
}
pub fn to_canonical_json(&self) -> String {
events_to_canonical_json(&self.events.lock().expect("trace sink mutex poisoned"))
}
}
impl Sink for RecordingSink {
fn record(&self, event: TraceEvent) {
self.events
.lock()
.expect("trace sink mutex poisoned")
.push(event);
}
}
thread_local! {
static CURRENT_SINK: RefCell<Option<Rc<dyn Sink>>> = const { RefCell::new(None) };
}
pub fn with_sink<R>(sink: Rc<dyn Sink>, f: impl FnOnce() -> R) -> R {
let prev = CURRENT_SINK.with(|cell| cell.borrow_mut().replace(sink));
struct Guard {
prev: Option<Rc<dyn Sink>>,
}
impl Drop for Guard {
fn drop(&mut self) {
CURRENT_SINK.with(|cell| {
*cell.borrow_mut() = self.prev.take();
});
}
}
let _guard = Guard { prev };
f()
}
type GlobalSink = Arc<dyn Sink + Send + Sync>;
fn global_slot() -> &'static Mutex<Option<GlobalSink>> {
static SLOT: OnceLock<Mutex<Option<GlobalSink>>> = OnceLock::new();
SLOT.get_or_init(|| Mutex::new(None))
}
pub fn set_global_sink(sink: GlobalSink) {
*global_slot().lock().expect("trace global sink poisoned") = Some(sink);
}
pub fn clear_global_sink() {
*global_slot().lock().expect("trace global sink poisoned") = None;
}
pub fn with_global_sink<R>(sink: GlobalSink, f: impl FnOnce() -> R) -> R {
let prev = {
let mut guard = global_slot().lock().expect("trace global sink poisoned");
guard.replace(sink)
};
struct Guard {
prev: Option<GlobalSink>,
}
impl Drop for Guard {
fn drop(&mut self) {
*global_slot().lock().expect("trace global sink poisoned") = self.prev.take();
}
}
let _guard = Guard { prev };
f()
}
pub fn emit(event: TraceEvent) {
let mut consumed = false;
CURRENT_SINK.with(|cell| {
if let Some(sink) = cell.borrow().as_ref() {
sink.record(event.clone());
consumed = true;
}
});
if let Ok(guard) = global_slot().lock() {
if let Some(sink) = guard.as_ref() {
let _ = consumed;
sink.record(event);
}
}
}
pub fn emit_simple(phase: Phase, reason: Reason) {
emit(TraceEvent::new(phase, reason));
}
pub fn events_to_canonical_json(events: &[TraceEvent]) -> String {
let mut out = String::new();
out.push_str("[\n");
let last = events.len().saturating_sub(1);
for (i, e) in events.iter().enumerate() {
out.push_str(" {\n");
write_kv_optional(&mut out, "decision", e.decision.as_deref());
write_kv_optional(&mut out, "input", e.input.as_deref());
write_kv(&mut out, "phase", e.phase.tag());
write_kv(&mut out, "reason", e.reason.tag());
write_kv_optional(&mut out, "som", e.som.as_deref());
write_kv_optional_last(&mut out, "source", e.source.as_deref());
out.push_str(" }");
if i != last {
out.push(',');
}
out.push('\n');
}
out.push_str("]\n");
out
}
fn write_json_string(out: &mut String, s: &str) {
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{08}' => out.push_str("\\b"),
'\u{0C}' => out.push_str("\\f"),
c if (c as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
}
fn write_kv(out: &mut String, key: &str, value: &str) {
out.push_str(" \"");
out.push_str(key);
out.push_str("\": ");
write_json_string(out, value);
out.push_str(",\n");
}
fn write_kv_optional(out: &mut String, key: &str, value: Option<&str>) {
out.push_str(" \"");
out.push_str(key);
out.push_str("\": ");
match value {
Some(s) => write_json_string(out, s),
None => out.push_str("null"),
}
out.push_str(",\n");
}
fn write_kv_optional_last(out: &mut String, key: &str, value: Option<&str>) {
out.push_str(" \"");
out.push_str(key);
out.push_str("\": ");
match value {
Some(s) => write_json_string(out, s),
None => out.push_str("null"),
}
out.push('\n');
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_sink_means_no_record() {
emit_simple(Phase::Bind, Reason::Unspecified);
}
#[test]
fn recording_sink_collects_events() {
let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
with_sink(sink.clone(), || {
emit_simple(Phase::Bind, Reason::DataCountMatchesInitial);
emit(TraceEvent::new(Phase::Paginate, Reason::PaginateSplit).with_som("form1.subform"));
});
assert_eq!(sink.len(), 2);
let evs = sink.events();
assert_eq!(evs[0].phase, Phase::Bind);
assert_eq!(evs[1].som.as_deref(), Some("form1.subform"));
}
#[test]
fn nested_with_sink_restores() {
let outer: Rc<RecordingSink> = Rc::new(RecordingSink::new());
let inner: Rc<RecordingSink> = Rc::new(RecordingSink::new());
with_sink(outer.clone(), || {
emit_simple(Phase::Bind, Reason::Unspecified);
with_sink(inner.clone(), || {
emit_simple(Phase::Paginate, Reason::PaginateSplit);
});
emit_simple(Phase::Suppress, Reason::SuppressEmptyDataPageDropped);
});
assert_eq!(outer.len(), 2);
assert_eq!(inner.len(), 1);
assert_eq!(inner.events()[0].phase, Phase::Paginate);
}
#[test]
fn canonical_json_is_byte_stable() {
let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
with_sink(sink.clone(), || {
emit(
TraceEvent::new(Phase::Bind, Reason::DataCountMatchesInitial)
.with_som("a")
.with_input("n=3")
.with_decision("ok"),
);
emit_simple(Phase::Paginate, Reason::PaginateSplit);
});
let a = sink.to_canonical_json();
let b = sink.to_canonical_json();
assert_eq!(a, b);
}
#[test]
fn phase_tags_are_stable() {
assert_eq!(Phase::Bind.tag(), "bind");
assert_eq!(Phase::Occur.tag(), "occur");
assert_eq!(Phase::Presence.tag(), "presence");
assert_eq!(Phase::Measure.tag(), "measure");
assert_eq!(Phase::Paginate.tag(), "paginate");
assert_eq!(Phase::Suppress.tag(), "suppress");
assert_eq!(Phase::Resolve.tag(), "resolve");
assert_eq!(Phase::Emit.tag(), "emit");
}
#[test]
fn reason_tags_are_stable_subset() {
assert_eq!(
Reason::DataCountMatchesInitial.tag(),
"data_count_matches_initial"
);
assert_eq!(
Reason::SubformMaterialisedFromData.tag(),
"subform_materialised_from_data"
);
assert_eq!(Reason::PresenceVisible.tag(), "presence_visible");
assert_eq!(Reason::PaginateSplit.tag(), "paginate_split");
assert_eq!(
Reason::SuppressEmptyDataPageDropped.tag(),
"suppress_empty_data_page_dropped"
);
}
}