relux-runtime 0.8.0

Internal: runtime for Relux. No semver guarantees.
//! Diagnostic and failure-translation emitters.
//!
//! Two-part module:
//!
//! - Per-event diagnostic pushers (`emit_annotate` / `emit_log` /
//!   `emit_warning` / `emit_error` / `emit_cancelled` /
//!   `emit_failure_progress`) that record a single diagnostic into the
//!   structured stream and post a corresponding progress sigil.
//! - The two translators (`failure_record` / `cancellation_record`)
//!   that flatten runtime `Failure` / `Cancellation` types into the
//!   on-disk `FailureRecord` / `CancellationRecord` shapes used by the
//!   viewer.

use relux_core::diagnostics::IrSpan;

use super::StructuredLogBuilder;
use crate::observe::progress::ProgressEvent;
use crate::observe::structured::event::CancelReasonRecord;
use crate::observe::structured::event::EventKind;
use crate::observe::structured::failure::CancellationRecord;
use crate::observe::structured::failure::FailureRecord;
use crate::observe::structured::span::SpanId;

impl StructuredLogBuilder {
    pub fn emit_annotate(
        &self,
        span: SpanId,
        shell: &str,
        marker: &str,
        text: &str,
        location: Option<&IrSpan>,
    ) {
        self.push_event(
            span,
            Some(shell),
            Some(marker),
            location,
            EventKind::Annotate {
                text: text.to_string(),
            },
        );
        self.push_progress(ProgressEvent::Annotation(text.to_string()));
    }

    pub fn emit_log(
        &self,
        span: SpanId,
        shell: &str,
        marker: &str,
        message: &str,
        location: Option<&IrSpan>,
    ) {
        self.push_event(
            span,
            Some(shell),
            Some(marker),
            location,
            EventKind::Log {
                message: message.to_string(),
            },
        );
    }

    pub fn emit_warning(
        &self,
        span: SpanId,
        shell: &str,
        marker: &str,
        message: &str,
        location: Option<&IrSpan>,
    ) {
        self.push_event(
            span,
            Some(shell),
            Some(marker),
            location,
            EventKind::Warning {
                message: message.to_string(),
            },
        );
        self.push_progress(ProgressEvent::Warning(message.to_string()));
    }

    pub fn emit_error(
        &self,
        span: SpanId,
        shell: &str,
        marker: &str,
        message: &str,
        location: Option<&IrSpan>,
    ) {
        self.push_event(
            span,
            Some(shell),
            Some(marker),
            location,
            EventKind::Error {
                message: message.to_string(),
            },
        );
        self.push_progress(ProgressEvent::Error(message.to_string()));
    }

    /// Emit a `cancelled` event on the span the VM was in when it observed
    /// the cancel token flipping. Carries the reason recorded by whoever
    /// called `cancel_with(...)`. Pushes a `C` sigil into the per-test
    /// progress sliding window so live TUI viewers see the cancel land
    /// in the same place errors and timeouts do.
    pub fn emit_cancelled(
        &self,
        span: SpanId,
        shell: Option<&str>,
        shell_marker: Option<&str>,
        reason: &crate::cancel::CancelReason,
    ) {
        self.push_event(
            span,
            shell,
            shell_marker,
            None,
            EventKind::Cancelled {
                reason: CancelReasonRecord::from(reason),
            },
        );
        self.push_progress(ProgressEvent::Cancellation);
    }

    /// Push a `Failure` progress notification only. The structured failure
    /// information is carried in the `FailureRecord` passed to `build()`.
    pub fn emit_failure_progress(&self) {
        self.push_progress(ProgressEvent::Failure);
    }

    /// Translate a runtime `Failure` into a `FailureRecord`, flattening the
    /// `FailureContext` enum into the on-disk shape via its accessor
    /// methods. `Vm` failures produce full diagnostic context; `PreVm`
    /// failures (effect-resolution errors, pre-VM init, cleanup-shell
    /// spawn) land with the surrounding span and empty stack / tail /
    /// vars - the artifact stays well-formed.
    pub fn failure_record(&self, failure: &crate::report::result::Failure) -> FailureRecord {
        use crate::report::result::Failure;
        match failure {
            Failure::MatchTimeout {
                pattern,
                shell,
                effective,
                context,
                ..
            } => FailureRecord::MatchTimeout {
                span: context.span().unwrap_or(0),
                event_seq: context.event_seq().unwrap_or(0),
                shell: shell.clone(),
                pattern: pattern.clone(),
                effective: self.timeout_value(effective),
                call_stack: context.call_stack().to_vec(),
                buffer_tail: context.buffer_tail().to_string(),
                vars_in_scope: context.vars_in_scope().to_vec(),
            },
            Failure::FailPatternMatched {
                pattern,
                matched_line,
                shell,
                context,
                ..
            } => FailureRecord::FailPatternMatched {
                span: context.span().unwrap_or(0),
                event_seq: context.event_seq().unwrap_or(0),
                shell: shell.clone(),
                pattern: pattern.clone(),
                matched_line: matched_line.clone(),
                call_stack: context.call_stack().to_vec(),
                buffer_tail: context.buffer_tail().to_string(),
                vars_in_scope: context.vars_in_scope().to_vec(),
            },
            Failure::ShellExited {
                shell,
                exit_code,
                context,
                ..
            } => FailureRecord::ShellExited {
                span: context.span().unwrap_or(0),
                event_seq: context.event_seq().unwrap_or(0),
                shell: shell.clone(),
                exit_code: *exit_code,
                call_stack: context.call_stack().to_vec(),
                buffer_tail: context.buffer_tail().to_string(),
                vars_in_scope: context.vars_in_scope().to_vec(),
            },
            Failure::Runtime {
                message,
                shell,
                context,
                ..
            } => FailureRecord::Runtime {
                span: context.span(),
                event_seq: context.event_seq(),
                shell: shell.clone(),
                message: message.clone(),
                call_stack: context.call_stack().to_vec(),
                vars_in_scope: context.vars_in_scope().to_vec(),
            },
            Failure::PureMatch {
                value,
                pattern,
                is_regex,
                match_context,
                context,
                ..
            } => FailureRecord::PureMatch {
                span: context
                    .span()
                    .expect("pure-match failure always carries a span"),
                event_seq: context
                    .event_seq()
                    .expect("pure-match failure always carries an event seq"),
                match_context: match_context.clone(),
                value: value.clone(),
                pattern: pattern.clone(),
                is_regex: *is_regex,
                call_stack: context.call_stack().to_vec(),
                vars_in_scope: context.vars_in_scope().to_vec(),
            },
            Failure::MultiMatch {
                shell,
                patterns,
                matched,
                effective,
                context,
                ..
            } => FailureRecord::MultiMatch {
                span: context.span().unwrap_or(0),
                event_seq: context.event_seq().unwrap_or(0),
                shell: shell.clone(),
                patterns: patterns.clone(),
                matched: matched.clone(),
                effective: self.timeout_value(effective),
                call_stack: context.call_stack().to_vec(),
                buffer_tail: context.buffer_tail().to_string(),
                vars_in_scope: context.vars_in_scope().to_vec(),
            },
        }
    }

    /// Translate a runtime `Cancellation` into a `CancellationRecord`.
    pub fn cancellation_record(
        &self,
        c: &crate::report::result::Cancellation,
    ) -> CancellationRecord {
        let ctx = &c.context;
        CancellationRecord {
            reason: CancelReasonRecord::from(&c.reason),
            span: ctx.span(),
            event_seq: ctx.event_seq(),
            shell: None,
            call_stack: ctx.call_stack().to_vec(),
        }
    }
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;
    use std::sync::Arc;
    use std::time::Instant;

    use super::StructuredLogBuilder;
    use crate::observe::progress;
    use crate::observe::structured::MatchContext;
    use crate::observe::structured::failure::FailureRecord;
    use crate::report::result::Failure;
    use crate::report::result::FailureContext;
    use relux_core::diagnostics::IrSpan;

    fn make_builder() -> StructuredLogBuilder {
        let (tx, _rx) = progress::channel();
        let sources = relux_core::table::SharedTable::new();
        StructuredLogBuilder::new(
            tx,
            Instant::now(),
            sources,
            Arc::from(PathBuf::from("/project").as_path()),
        )
    }

    #[test]
    fn pure_match_record_carries_real_seq_and_vars() {
        // A pure-match failure travels via `FailureContext::Pure` with a real
        // seq (3, not 0) and a scope-var snapshot; the on-disk record must
        // preserve both, plus the typed match context.
        let builder = make_builder();
        let f = Failure::PureMatch {
            value: "abc".into(),
            pattern: "xyz".into(),
            is_regex: false,
            span: IrSpan::synthetic(),
            match_context: MatchContext::TestPreamble {
                name: "login".into(),
            },
            context: FailureContext::pure(7, 3, vec![], vec![("v".into(), "abc".into())]),
        };
        match builder.failure_record(&f) {
            FailureRecord::PureMatch {
                span,
                event_seq,
                match_context,
                vars_in_scope,
                value,
                pattern,
                ..
            } => {
                assert_eq!(span, 7);
                assert_eq!(event_seq, 3, "real seq is threaded through, not 0");
                assert_eq!(
                    match_context,
                    MatchContext::TestPreamble {
                        name: "login".into()
                    }
                );
                assert_eq!(vars_in_scope, vec![("v".to_string(), "abc".to_string())]);
                assert_eq!(value, "abc");
                assert_eq!(pattern, "xyz");
            }
            other => panic!("expected PureMatch, got {other:?}"),
        }
    }
}