Skip to main content

fml_engine/
trace.rs

1//! Debug trace of a transform, in the form the Mapping Language IG proposes for `$transform`
2//! with `debug=true` (debug.md): `enter` / `step` / `exit` events with their depth, the position
3//! in the StructureMap, and a snapshot of the variables in scope.
4
5use fml_model::Span;
6use serde_json::{Map, Value, json};
7
8use crate::engine::{Error, ErrorKind};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Phase {
12    Enter,
13    Step,
14    Exit,
15}
16
17impl Phase {
18    pub fn code(self) -> &'static str {
19        match self {
20            Phase::Enter => "enter",
21            Phase::Step => "step",
22            Phase::Exit => "exit",
23        }
24    }
25}
26
27/// One variable binding at an event.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct TraceVariable {
30    pub name: String,
31    /// `input` or `output`.
32    pub context_type: String,
33    /// The value as FHIR JSON.
34    pub value: Value,
35    /// The FHIR type, where known: `Patient`, `string`, `QuestionnaireResponse.item`.
36    pub data_type: Option<String>,
37    /// Where the value is in the input, for values that come from it: a FHIRPath of names and
38    /// indexes (`Patient.name[0].given[1]`).
39    pub path: Option<String>,
40}
41
42/// One evaluation unit.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct TraceEvent {
45    pub phase: Phase,
46    pub depth: usize,
47    /// The map the event is in: its canonical URL.
48    pub source: Option<String>,
49    /// Where in the map, as a FHIRPath location: `StructureMap.group[0].rule[2].target[1]`.
50    pub position: String,
51    pub variables: Vec<TraceVariable>,
52    /// `log (…)` messages produced by this event.
53    pub messages: Vec<String>,
54}
55
56impl TraceEvent {
57    /// Roughly the size of the event as JSON, in bytes: enough to keep a trace within a budget.
58    pub fn approximate_size(&self) -> usize {
59        let variables: usize = self
60            .variables
61            .iter()
62            .map(|v| v.name.len() + v.context_type.len() + json_size(&v.value) + 96)
63            .sum();
64        let messages: usize = self.messages.iter().map(|m| m.len() + 48).sum();
65        self.position.len()
66            + self.source.as_ref().map_or(0, String::len)
67            + 128
68            + variables
69            + messages
70    }
71}
72
73fn json_size(value: &Value) -> usize {
74    match value {
75        Value::Null | Value::Bool(_) => 5,
76        Value::Number(_) => 12,
77        Value::String(text) => text.len() + 2,
78        Value::Array(items) => 2 + items.iter().map(|v| json_size(v) + 1).sum::<usize>(),
79        Value::Object(map) => {
80            2 + map
81                .iter()
82                .map(|(k, v)| k.len() + 4 + json_size(v))
83                .sum::<usize>()
84        }
85    }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum TraceStatus {
90    /// Evaluation ran to the end and every event was kept.
91    Complete,
92    /// Evaluation went on past the kept events: the trace reached its limits.
93    Truncated,
94    /// Evaluation stopped on an error; open scopes have no `exit`.
95    Stopped,
96}
97
98impl TraceStatus {
99    pub fn code(self) -> &'static str {
100        match self {
101            TraceStatus::Complete => "complete",
102            TraceStatus::Truncated => "truncated",
103            TraceStatus::Stopped => "stopped",
104        }
105    }
106}
107
108/// The recorded events of a transform.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Trace {
111    pub status: TraceStatus,
112    /// Why events were left out, for a `truncated` trace.
113    pub note: Option<String>,
114    pub events: Vec<TraceEvent>,
115}
116
117/// What a `$transform` with `debug=true` reports.
118#[derive(Debug, Clone, Copy)]
119pub struct DebugReport<'a> {
120    /// The result, when the transform produced one.
121    pub result: Option<&'a Value>,
122    pub trace: &'a Trace,
123    /// The failure, when it failed.
124    pub error: Option<&'a Error>,
125    /// Values left out of the result ([`Transformed::warnings`](crate::Transformed::warnings)).
126    pub warnings: &'a [String],
127}
128
129/// Where a part of a map is in its FML text: the map's canonical URL (the trace's `source`) and
130/// the part's location (`StructureMap.group[0].rule[1]`). For maps compiled from FML, this comes
131/// from [`fml_model::Locations`].
132pub type SpanLookup<'a> = &'a dyn Fn(Option<&str>, &str) -> Option<Span>;
133
134/// No text positions: maps that were not compiled from FML here.
135pub fn no_spans(_source: Option<&str>, _position: &str) -> Option<Span> {
136    None
137}
138
139/// The `Parameters` response of `$transform` with `debug=true` (debug.md): `return` when there
140/// is a result, `traceStatus`, the `trace` events with their text positions where `spans` knows
141/// them, and an `outcome` with the failure, the reason events were left out, and values left out
142/// of the result.
143pub fn debug_parameters(report: &DebugReport<'_>, spans: SpanLookup<'_>) -> Value {
144    let trace = report.trace;
145    let mut parameters = Vec::new();
146    if let Some(result) = report.result {
147        parameters.push(json!({ "name": "return", "resource": result }));
148    }
149    parameters.push(json!({ "name": "traceStatus", "valueCode": trace.status.code() }));
150    for event in &trace.events {
151        let mut parts = Vec::new();
152        if let Some(source) = &event.source {
153            parts.push(json!({ "name": "source", "valueString": source }));
154        }
155        if let Some(span) = spans(event.source.as_deref(), &event.position) {
156            parts.push(json!({ "name": "startRow", "valueInteger": span.start_row }));
157            parts.push(json!({ "name": "startColumn", "valueInteger": span.start_column }));
158            parts.push(json!({ "name": "endRow", "valueInteger": span.end_row }));
159            parts.push(json!({ "name": "endColumn", "valueInteger": span.end_column }));
160        }
161        parts.push(json!({ "name": "expressionPosition", "valueString": event.position }));
162        parts.push(json!({ "name": "phase", "valueCode": event.phase.code() }));
163        parts.push(json!({ "name": "depth", "valueInteger": event.depth }));
164        for variable in &event.variables {
165            parts.push(variable_parameter(variable));
166        }
167        for message in &event.messages {
168            parts.push(json!({
169                "name": "message",
170                "part": [{ "name": "text", "valueString": message }]
171            }));
172        }
173        parameters.push(json!({ "name": "trace", "part": parts }));
174    }
175    let mut issues = Vec::new();
176    if let Some(error) = report.error {
177        let code = match error.kind {
178            ErrorKind::Failed => "processing",
179            ErrorKind::Limit => "too-costly",
180            ErrorKind::Cancelled => "incomplete",
181        };
182        let mut issue = json!({ "severity": "error", "code": code, "diagnostics": error.message });
183        if let Some(position) = &error.position {
184            issue["expression"] = json!([position]);
185            let mut extensions = Vec::new();
186            if let Some(map) = &error.map {
187                extensions.push(json!({
188                    "url": "http://hl7.org/fhir/StructureDefinition/operationoutcome-file",
189                    "valueString": map
190                }));
191            }
192            if let Some(span) = spans(error.map.as_deref(), position) {
193                // The location extensions count from 1.
194                extensions.push(json!({
195                    "url": "http://hl7.org/fhir/StructureDefinition/operationoutcome-issue-line",
196                    "valueInteger": span.start_row + 1
197                }));
198                extensions.push(json!({
199                    "url": "http://hl7.org/fhir/StructureDefinition/operationoutcome-issue-col",
200                    "valueInteger": span.start_column + 1
201                }));
202            }
203            if !extensions.is_empty() {
204                issue["extension"] = Value::Array(extensions);
205            }
206        }
207        issues.push(issue);
208    }
209    if let Some(note) = &trace.note {
210        issues.push(json!({ "severity": "warning", "code": "too-costly", "diagnostics": note }));
211    }
212    for warning in report.warnings {
213        issues.push(
214            json!({ "severity": "warning", "code": "informational", "diagnostics": warning }),
215        );
216    }
217    if !issues.is_empty() {
218        parameters.push(json!({
219            "name": "outcome",
220            "resource": { "resourceType": "OperationOutcome", "issue": issues }
221        }));
222    }
223    json!({ "resourceType": "Parameters", "parameter": parameters })
224}
225
226/// A variable as a `trace.variable` part: a resource in `resource`, a primitive or data type in
227/// `value[x]`, anything else (a backbone element) as `jsonValue`.
228fn variable_parameter(variable: &TraceVariable) -> Value {
229    let mut parts = vec![
230        json!({ "name": "name", "valueString": variable.name }),
231        json!({ "name": "contextType", "valueString": variable.context_type }),
232    ];
233    let data_type = variable.data_type.as_deref();
234    let value = &variable.value;
235    let is_resource = value.get("resourceType").is_some();
236    let is_data_type = data_type.is_some_and(|t| !t.contains('.') && t != "BackboneElement");
237    if is_resource {
238        parts.push(json!({ "name": "resource", "resource": value }));
239    } else if value.is_null() {
240        // An unset value: an empty collection.
241    } else if let (Some(type_name), true) = (data_type, is_data_type) {
242        let mut part = Map::new();
243        part.insert("name".into(), "value".into());
244        part.insert(format!("value{}", capitalize(type_name)), value.clone());
245        parts.push(Value::Object(part));
246    } else if !value.is_object() && !value.is_array() {
247        parts.push(json!({ "name": "value", "valueString": scalar_text(value) }));
248    } else {
249        parts.push(json!({ "name": "jsonValue", "valueString": value.to_string() }));
250    }
251    if let Some(data_type) = data_type {
252        parts.push(json!({ "name": "dataType", "valueString": data_type }));
253    }
254    if let Some(path) = &variable.path {
255        parts.push(json!({ "name": "path", "valueString": path }));
256    }
257    json!({ "name": "variable", "part": parts })
258}
259
260fn scalar_text(value: &Value) -> String {
261    match value {
262        Value::String(text) => text.clone(),
263        other => other.to_string(),
264    }
265}
266
267fn capitalize(text: &str) -> String {
268    let mut chars = text.chars();
269    match chars.next() {
270        Some(first) => first.to_uppercase().chain(chars).collect(),
271        None => String::new(),
272    }
273}