Skip to main content

relux_runtime/observe/structured/builder/
diagnostics.rs

1//! Diagnostic and failure-translation emitters.
2//!
3//! Two-part module:
4//!
5//! - Per-event diagnostic pushers (`emit_annotate` / `emit_log` /
6//!   `emit_warning` / `emit_error` / `emit_cancelled` /
7//!   `emit_failure_progress`) that record a single diagnostic into the
8//!   structured stream and post a corresponding progress sigil.
9//! - The two translators (`failure_record` / `cancellation_record`)
10//!   that flatten runtime `Failure` / `Cancellation` types into the
11//!   on-disk `FailureRecord` / `CancellationRecord` shapes used by the
12//!   viewer.
13
14use relux_core::diagnostics::IrSpan;
15
16use super::StructuredLogBuilder;
17use crate::observe::progress::ProgressEvent;
18use crate::observe::structured::event::CancelReasonRecord;
19use crate::observe::structured::event::EventKind;
20use crate::observe::structured::failure::CancellationRecord;
21use crate::observe::structured::failure::FailureRecord;
22use crate::observe::structured::span::SpanId;
23
24impl StructuredLogBuilder {
25    pub fn emit_annotate(
26        &self,
27        span: SpanId,
28        shell: &str,
29        marker: &str,
30        text: &str,
31        location: Option<&IrSpan>,
32    ) {
33        self.push_event(
34            span,
35            Some(shell),
36            Some(marker),
37            location,
38            EventKind::Annotate {
39                text: text.to_string(),
40            },
41        );
42        self.push_progress(ProgressEvent::Annotation(text.to_string()));
43    }
44
45    pub fn emit_log(
46        &self,
47        span: SpanId,
48        shell: &str,
49        marker: &str,
50        message: &str,
51        location: Option<&IrSpan>,
52    ) {
53        self.push_event(
54            span,
55            Some(shell),
56            Some(marker),
57            location,
58            EventKind::Log {
59                message: message.to_string(),
60            },
61        );
62    }
63
64    pub fn emit_warning(
65        &self,
66        span: SpanId,
67        shell: &str,
68        marker: &str,
69        message: &str,
70        location: Option<&IrSpan>,
71    ) {
72        self.push_event(
73            span,
74            Some(shell),
75            Some(marker),
76            location,
77            EventKind::Warning {
78                message: message.to_string(),
79            },
80        );
81        self.push_progress(ProgressEvent::Warning(message.to_string()));
82    }
83
84    pub fn emit_error(
85        &self,
86        span: SpanId,
87        shell: &str,
88        marker: &str,
89        message: &str,
90        location: Option<&IrSpan>,
91    ) {
92        self.push_event(
93            span,
94            Some(shell),
95            Some(marker),
96            location,
97            EventKind::Error {
98                message: message.to_string(),
99            },
100        );
101        self.push_progress(ProgressEvent::Error(message.to_string()));
102    }
103
104    /// Emit a `cancelled` event on the span the VM was in when it observed
105    /// the cancel token flipping. Carries the reason recorded by whoever
106    /// called `cancel_with(...)`. Pushes a `C` sigil into the per-test
107    /// progress sliding window so live TUI viewers see the cancel land
108    /// in the same place errors and timeouts do.
109    pub fn emit_cancelled(
110        &self,
111        span: SpanId,
112        shell: Option<&str>,
113        shell_marker: Option<&str>,
114        reason: &crate::cancel::CancelReason,
115    ) {
116        self.push_event(
117            span,
118            shell,
119            shell_marker,
120            None,
121            EventKind::Cancelled {
122                reason: CancelReasonRecord::from(reason),
123            },
124        );
125        self.push_progress(ProgressEvent::Cancellation);
126    }
127
128    /// Push a `Failure` progress notification only. The structured failure
129    /// information is carried in the `FailureRecord` passed to `build()`.
130    pub fn emit_failure_progress(&self) {
131        self.push_progress(ProgressEvent::Failure);
132    }
133
134    /// Translate a runtime `Failure` into a `FailureRecord`, flattening the
135    /// `FailureContext` enum into the on-disk shape via its accessor
136    /// methods. `Vm` failures produce full diagnostic context; `PreVm`
137    /// failures (effect-resolution errors, pre-VM init, cleanup-shell
138    /// spawn) land with the surrounding span and empty stack / tail /
139    /// vars - the artifact stays well-formed.
140    pub fn failure_record(&self, failure: &crate::report::result::Failure) -> FailureRecord {
141        use crate::report::result::Failure;
142        match failure {
143            Failure::MatchTimeout {
144                pattern,
145                shell,
146                effective,
147                context,
148                ..
149            } => FailureRecord::MatchTimeout {
150                span: context.span().unwrap_or(0),
151                event_seq: context.event_seq().unwrap_or(0),
152                shell: shell.clone(),
153                pattern: pattern.clone(),
154                effective: self.timeout_value(effective),
155                call_stack: context.call_stack().to_vec(),
156                buffer_tail: context.buffer_tail().to_string(),
157                vars_in_scope: context.vars_in_scope().to_vec(),
158            },
159            Failure::FailPatternMatched {
160                pattern,
161                matched_line,
162                shell,
163                context,
164                ..
165            } => FailureRecord::FailPatternMatched {
166                span: context.span().unwrap_or(0),
167                event_seq: context.event_seq().unwrap_or(0),
168                shell: shell.clone(),
169                pattern: pattern.clone(),
170                matched_line: matched_line.clone(),
171                call_stack: context.call_stack().to_vec(),
172                buffer_tail: context.buffer_tail().to_string(),
173                vars_in_scope: context.vars_in_scope().to_vec(),
174            },
175            Failure::ShellExited {
176                shell,
177                exit_code,
178                context,
179                ..
180            } => FailureRecord::ShellExited {
181                span: context.span().unwrap_or(0),
182                event_seq: context.event_seq().unwrap_or(0),
183                shell: shell.clone(),
184                exit_code: *exit_code,
185                call_stack: context.call_stack().to_vec(),
186                buffer_tail: context.buffer_tail().to_string(),
187                vars_in_scope: context.vars_in_scope().to_vec(),
188            },
189            Failure::Runtime {
190                message,
191                shell,
192                context,
193                ..
194            } => FailureRecord::Runtime {
195                span: context.span(),
196                event_seq: context.event_seq(),
197                shell: shell.clone(),
198                message: message.clone(),
199                call_stack: context.call_stack().to_vec(),
200                vars_in_scope: context.vars_in_scope().to_vec(),
201            },
202            Failure::PureMatch {
203                value,
204                pattern,
205                is_regex,
206                match_context,
207                context,
208                ..
209            } => FailureRecord::PureMatch {
210                span: context
211                    .span()
212                    .expect("pure-match failure always carries a span"),
213                event_seq: context
214                    .event_seq()
215                    .expect("pure-match failure always carries an event seq"),
216                match_context: match_context.clone(),
217                value: value.clone(),
218                pattern: pattern.clone(),
219                is_regex: *is_regex,
220                call_stack: context.call_stack().to_vec(),
221                vars_in_scope: context.vars_in_scope().to_vec(),
222            },
223            Failure::MultiMatch {
224                shell,
225                patterns,
226                matched,
227                effective,
228                context,
229                ..
230            } => FailureRecord::MultiMatch {
231                span: context.span().unwrap_or(0),
232                event_seq: context.event_seq().unwrap_or(0),
233                shell: shell.clone(),
234                patterns: patterns.clone(),
235                matched: matched.clone(),
236                effective: self.timeout_value(effective),
237                call_stack: context.call_stack().to_vec(),
238                buffer_tail: context.buffer_tail().to_string(),
239                vars_in_scope: context.vars_in_scope().to_vec(),
240            },
241        }
242    }
243
244    /// Translate a runtime `Cancellation` into a `CancellationRecord`.
245    pub fn cancellation_record(
246        &self,
247        c: &crate::report::result::Cancellation,
248    ) -> CancellationRecord {
249        let ctx = &c.context;
250        CancellationRecord {
251            reason: CancelReasonRecord::from(&c.reason),
252            span: ctx.span(),
253            event_seq: ctx.event_seq(),
254            shell: None,
255            call_stack: ctx.call_stack().to_vec(),
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use std::path::PathBuf;
263    use std::sync::Arc;
264    use std::time::Instant;
265
266    use super::StructuredLogBuilder;
267    use crate::observe::progress;
268    use crate::observe::structured::MatchContext;
269    use crate::observe::structured::failure::FailureRecord;
270    use crate::report::result::Failure;
271    use crate::report::result::FailureContext;
272    use relux_core::diagnostics::IrSpan;
273
274    fn make_builder() -> StructuredLogBuilder {
275        let (tx, _rx) = progress::channel();
276        let sources = relux_core::table::SharedTable::new();
277        StructuredLogBuilder::new(
278            tx,
279            Instant::now(),
280            sources,
281            Arc::from(PathBuf::from("/project").as_path()),
282        )
283    }
284
285    #[test]
286    fn pure_match_record_carries_real_seq_and_vars() {
287        // A pure-match failure travels via `FailureContext::Pure` with a real
288        // seq (3, not 0) and a scope-var snapshot; the on-disk record must
289        // preserve both, plus the typed match context.
290        let builder = make_builder();
291        let f = Failure::PureMatch {
292            value: "abc".into(),
293            pattern: "xyz".into(),
294            is_regex: false,
295            span: IrSpan::synthetic(),
296            match_context: MatchContext::TestPreamble {
297                name: "login".into(),
298            },
299            context: FailureContext::pure(7, 3, vec![], vec![("v".into(), "abc".into())]),
300        };
301        match builder.failure_record(&f) {
302            FailureRecord::PureMatch {
303                span,
304                event_seq,
305                match_context,
306                vars_in_scope,
307                value,
308                pattern,
309                ..
310            } => {
311                assert_eq!(span, 7);
312                assert_eq!(event_seq, 3, "real seq is threaded through, not 0");
313                assert_eq!(
314                    match_context,
315                    MatchContext::TestPreamble {
316                        name: "login".into()
317                    }
318                );
319                assert_eq!(vars_in_scope, vec![("v".to_string(), "abc".to_string())]);
320                assert_eq!(value, "abc");
321                assert_eq!(pattern, "xyz");
322            }
323            other => panic!("expected PureMatch, got {other:?}"),
324        }
325    }
326}