rs-teststand-bridge 0.0.8

Serve the National Instruments TestStand™ Engine to other processes, over any transport
//! What a user interface asks a host to do.
//!
//! The inbound half of the bridge. [`crate::MessageEvent`] travels out from the
//! engine; this travels back, and both transports carry both, so a front end
//! written against one works against the other unchanged.
//!
//! # Why an enum rather than a free-form call
//!
//! A remote caller cannot be handed the engine, and should not be handed a way
//! to invoke arbitrary members of it either: that is a remote-code-execution
//! surface on a machine wired to hardware. A closed set of commands is what a
//! host can actually reason about, and what it can refuse.
//!
//! Serialized externally tagged, so the wire form is
//! `{"command":"run","sequence_file":"...","sequence":"MainSequence"}` — one
//! obvious discriminant, readable by a reader with a fixed schema.

use serde::{Deserialize, Serialize};

/// A request from a front end, addressed to the host that owns the engine.
///
/// Every variant is something the engine can do on its own thread, because that
/// is where the host will run it. Nothing here returns a COM reference: a
/// command's answer is a [`Response`](crate::Response), which is data.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "command", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Command {
    /// Confirm the host is alive and report what it is.
    ///
    /// Cheap, and the first thing a front end should send: it distinguishes
    /// "the socket connected" from "the engine is actually up", which are not
    /// the same thing and fail differently.
    Hello,

    /// Log a user in, by name.
    ///
    /// The host looks the account up, checks the password, and makes it
    /// current. An account with no password takes an empty string, which is
    /// ordinary on a station where the operator is identified but not
    /// authenticated.
    Login {
        /// The account's login name.
        user_name: String,
        /// The password to check. Empty when the account has none.
        #[serde(default)]
        password: String,
    },

    /// Log the current user out, leaving nobody logged in.
    Logout,

    /// Open a sequence file and hold it, without running anything.
    ///
    /// Separate from [`Start`](Self::Start) because loading is where a bad path
    /// or a missing dependency shows up, and a panel wants that answer before
    /// it offers a run button.
    LoadFile {
        /// Path to the sequence file, as the host's filesystem sees it.
        path: String,
    },

    /// Start a sequence in the file already loaded.
    Start {
        /// Which sequence to run.
        #[serde(default = "main_sequence")]
        sequence: String,
    },

    /// Load a sequence file and start one of its sequences in one step.
    Run {
        /// Path to the sequence file, as the host's filesystem sees it.
        sequence_file: String,
        /// Which sequence to run. `MainSequence` unless the file says otherwise.
        #[serde(default = "main_sequence")]
        sequence: String,
    },

    /// Ask a running execution to stop.
    ///
    /// Termination, not abort: cleanup still runs, so hardware is left safe.
    Terminate {
        /// Which execution, as reported by
        /// [`MessageEvent::execution_id`](crate::MessageEvent::execution_id).
        execution_id: i32,
    },

    /// Read a value out of a running execution's context.
    ///
    /// `lookup` is an ordinary property path, `Locals.Result` or
    /// `FileGlobals.SerialNumber`. This is how a panel gets data it was not
    /// pushed: the host resolves the path and answers with the subtree as data,
    /// because the reference itself could never leave the process.
    ReadValue {
        /// Which execution to read from.
        execution_id: i32,
        /// The property path to resolve.
        lookup: String,
    },

    /// Stop the host after the current run finishes.
    Shutdown,
}

/// The default sequence name, so a caller may omit it.
fn main_sequence() -> String {
    "MainSequence".to_owned()
}

impl Command {
    /// A short name for logging, without rendering the whole command.
    #[must_use]
    pub const fn name(&self) -> &'static str {
        match self {
            Self::Hello => "hello",
            Self::Login { .. } => "login",
            Self::Logout => "logout",
            Self::LoadFile { .. } => "load_file",
            Self::Start { .. } => "start",
            Self::Run { .. } => "run",
            Self::Terminate { .. } => "terminate",
            Self::ReadValue { .. } => "read_value",
            Self::Shutdown => "shutdown",
        }
    }

    /// Whether this command changes what the station is doing.
    ///
    /// A host that serves more than one panel wants to treat these differently
    /// from reads: two observers are fine, two controllers are a decision
    /// somebody has to make.
    #[must_use]
    pub const fn is_control(&self) -> bool {
        matches!(
            self,
            Self::Run { .. }
                | Self::Start { .. }
                | Self::Terminate { .. }
                | Self::Login { .. }
                | Self::Logout
                | Self::Shutdown
        )
    }
}

#[cfg(test)]
mod tests {
    use super::Command;

    #[test]
    fn the_wire_form_is_tagged_by_a_command_field() {
        // A front end in another language reads this discriminant first, so its
        // spelling is part of the contract rather than an implementation detail.
        let text = serde_json::to_string(&Command::Hello).unwrap_or_default();
        assert_eq!(text, r#"{"command":"hello"}"#);
    }

    #[test]
    fn a_run_may_omit_the_sequence_name() {
        // Almost every file uses MainSequence, and requiring it of every caller
        // is friction with no benefit.
        let parsed: Command =
            serde_json::from_str(r#"{"command":"run","sequence_file":"C:\\x.seq"}"#)
                .unwrap_or(Command::Shutdown);
        assert_eq!(
            parsed,
            Command::Run {
                sequence_file: r"C:\x.seq".to_owned(),
                sequence: "MainSequence".to_owned(),
            }
        );
    }

    #[test]
    fn commands_round_trip() {
        for command in [
            Command::Hello,
            Command::Terminate { execution_id: 1 },
            Command::ReadValue {
                execution_id: 1,
                lookup: "Locals.Result".to_owned(),
            },
            Command::Shutdown,
        ] {
            let text = serde_json::to_string(&command).unwrap_or_default();
            let back: Command = serde_json::from_str(&text).unwrap_or(Command::Hello);
            assert_eq!(back, command, "round trip failed for {}", command.name());
        }
    }

    #[test]
    fn reads_are_not_control_but_runs_are() {
        assert!(!Command::Hello.is_control());
        assert!(
            !Command::ReadValue {
                execution_id: 1,
                lookup: "Locals".to_owned()
            }
            .is_control()
        );
        assert!(Command::Terminate { execution_id: 1 }.is_control());
        assert!(Command::Shutdown.is_control());
    }
}