relux-runtime 0.8.1

Internal: runtime for Relux. No semver guarantees.
Documentation
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;

use super::MatchContext;
use super::SourceLocation;
use super::event::EventSeq;
use super::event::TimeoutValue;
use super::span::SpanId;

#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[cfg_attr(
    feature = "ts-export",
    ts(export, export_to = "../../../viewer/src/types/")
)]
pub struct StackFrame {
    pub span: SpanId,
    /// Span-kind discriminator name (e.g. `"fn-call"`, `"shell-block"`).
    pub kind: String,
    /// Function name or effect name, when applicable.
    pub name: Option<String>,
    pub args: Vec<(String, String)>,
    /// User-supplied alias bound at start time (`start FX as Alias`).
    /// Only effect-setup frames carry one today.
    pub alias: Option<String>,
    pub location: Option<SourceLocation>,
}

impl StackFrame {
    /// Whether this frame is a function call - an impure `fn` (`"fn-call"`)
    /// or a compile-time `pure fn` (`"pure-fn-call"`). Both render the
    /// failing match's context as `MatchContext::Fn`.
    pub fn is_fn_call(&self) -> bool {
        self.kind == "fn-call" || self.kind == "pure-fn-call"
    }
}

/// Self-contained record of a test failure. Variant-specific fields plus
/// pre-computed convenience fields (`call_stack`, `buffer_tail`,
/// `vars_in_scope`) lifted from the `FailureContext` captured at the
/// failure site. `FailureContext::Vm` populates every field; the
/// `PreVm` variant (effect resolution, pre-VM init) carries only the
/// surrounding span and empty stack / empty buffer tail - the artifact
/// stays well-formed. Pure-match failures now travel via
/// `FailureContext::Pure` and land with a real `event_seq` and the
/// scope vars snapshotted at the failure site.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[cfg_attr(
    feature = "ts-export",
    ts(export, export_to = "../../../viewer/src/types/")
)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum FailureRecord {
    MatchTimeout {
        span: SpanId,
        event_seq: EventSeq,
        shell: String,
        pattern: String,
        /// The timeout that fired.
        effective: TimeoutValue,
        call_stack: Vec<StackFrame>,
        buffer_tail: String,
        vars_in_scope: Vec<(String, String)>,
    },
    FailPatternMatched {
        span: SpanId,
        event_seq: EventSeq,
        shell: String,
        pattern: String,
        matched_line: String,
        call_stack: Vec<StackFrame>,
        buffer_tail: String,
        vars_in_scope: Vec<(String, String)>,
    },
    ShellExited {
        span: SpanId,
        event_seq: EventSeq,
        shell: String,
        exit_code: Option<i32>,
        call_stack: Vec<StackFrame>,
        buffer_tail: String,
        vars_in_scope: Vec<(String, String)>,
    },
    Runtime {
        span: Option<SpanId>,
        event_seq: Option<EventSeq>,
        shell: Option<String>,
        message: String,
        call_stack: Vec<StackFrame>,
        vars_in_scope: Vec<(String, String)>,
    },
    PureMatch {
        span: SpanId,
        event_seq: EventSeq,
        match_context: MatchContext,
        value: String,
        pattern: String,
        is_regex: bool,
        call_stack: Vec<StackFrame>,
        vars_in_scope: Vec<(String, String)>,
    },
    MultiMatch {
        span: SpanId,
        event_seq: EventSeq,
        shell: String,
        /// All patterns in source order (mirrors `MultiMatchStart.patterns`).
        patterns: Vec<super::event::MultiMatchPattern>,
        /// Indices into `patterns` that matched before the block timed out.
        /// The unmatched indices are the complement.
        matched: Vec<usize>,
        /// The block-level timeout that fired.
        effective: TimeoutValue,
        call_stack: Vec<StackFrame>,
        buffer_tail: String,
        vars_in_scope: Vec<(String, String)>,
    },
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::observe::structured::event::MultiMatchPattern;

    #[test]
    fn failure_record_multimatch_round_trips_serde() {
        let original = FailureRecord::MultiMatch {
            span: 3,
            event_seq: 11,
            shell: "default".into(),
            patterns: vec![
                MultiMatchPattern {
                    pattern: "^a$".into(),
                    is_regex: true,
                },
                MultiMatchPattern {
                    pattern: "b".into(),
                    is_regex: false,
                },
            ],
            matched: vec![1],
            effective: TimeoutValue::Assertion {
                duration: "5s".into(),
                source: None,
            },
            call_stack: vec![],
            buffer_tail: "tail".into(),
            vars_in_scope: vec![("k".into(), "v".into())],
        };
        let json = serde_json::to_string(&original).unwrap();
        assert!(json.contains("\"type\":\"multi-match\""), "json: {json}");
        let parsed: FailureRecord = serde_json::from_str(&json).unwrap();
        match parsed {
            FailureRecord::MultiMatch {
                matched, patterns, ..
            } => {
                assert_eq!(matched, vec![1usize]);
                assert_eq!(patterns.len(), 2);
            }
            other => panic!("expected MultiMatch, got {other:?}"),
        }
    }

    fn stack_frame(kind: &str) -> StackFrame {
        StackFrame {
            span: 1,
            kind: kind.to_string(),
            name: None,
            args: vec![],
            alias: None,
            location: None,
        }
    }

    #[test]
    fn is_fn_call_true_for_fn_call() {
        assert!(stack_frame("fn-call").is_fn_call());
    }

    #[test]
    fn is_fn_call_true_for_pure_fn_call() {
        assert!(stack_frame("pure-fn-call").is_fn_call());
    }

    #[test]
    fn is_fn_call_false_for_other_kinds() {
        assert!(!stack_frame("shell-block").is_fn_call());
    }
}

/// Self-contained record of a test cancellation. Distinct from
/// `FailureRecord` because cancellation is not a failure: the test was
/// running fine when an external event (the per-test watchdog, the
/// suite-wide watchdog, fail-fast, SIGINT) cut it short.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[cfg_attr(
    feature = "ts-export",
    ts(export, export_to = "../../../viewer/src/types/")
)]
pub struct CancellationRecord {
    pub reason: super::event::CancelReasonRecord,
    pub span: Option<SpanId>,
    pub event_seq: Option<EventSeq>,
    pub shell: Option<String>,
    pub call_stack: Vec<StackFrame>,
}