nord-usb 0.6.0

Talk to a Nord keyboard over USB from Rust, on the desktop and in the browser
Documentation
//! A recording tap over a live transport: every frame in either direction is appended
//! to a script file in the format [`crate::transport::replay`] reads back.
//!
//! Frames are written byte-exactly in wire order. Bulk reads contribute one line per
//! chunk.
//!
//! Writes are unbuffered: a session that wedges or is killed still leaves everything
//! that reached the wire on disk, which is the case the recording usually exists for.

use std::fs::File;
use std::io::Write;
use std::path::Path;

use crate::error::Result;

/// Appends directed frames to a replay script.
///
/// An I/O failure part-way through is held rather than raised: aborting a live session
/// mid-transaction leaves the instrument with an open session, which is worse than a
/// short script. [`Recorder::check`], reached through
/// [`UsbTransport::finish_recording`](super::UsbTransport::finish_recording), surfaces it
/// once the operation is done.
pub struct Recorder {
    file: File,
    failed: Option<std::io::Error>,
}

impl Recorder {
    /// Create `path`, truncating it, and write the script header.
    ///
    /// The header says where the frames came from, which is what a reader needs to know
    /// whether they are an oracle: `source: nord` is this project's own traffic, a
    /// regression baseline rather than a match against the vendor application.
    ///
    /// Fails immediately if the path is not writable — the point at which a caller can
    /// still do something about it.
    pub fn create(path: &Path, device: Option<&str>) -> Result<Self> {
        let mut file = File::create(path)?;
        writeln!(
            file,
            "# nord-usb replay script, recorded from hardware.\n\
             # Format: '<O|I> <hex>' -- O = host->device, I = device->host.\n\
             # source: nord"
        )?;
        if let Some(device) = device {
            writeln!(file, "# device: {device}")?;
        }
        Ok(Self { file, failed: None })
    }

    /// Declare what the frames that follow are doing: `<class> <verb> <args…>`, in the
    /// CLI's own spellings.
    ///
    /// One command opens several transactions — a move names both slots before moving
    /// anything — so this is written per transaction, not per file, and each one opens a
    /// section the replay sweep drives on its own.
    pub fn intent(&mut self, intent: &str) {
        if self.failed.is_some() {
            return;
        }
        if let Err(e) = writeln!(self.file, "\n# intent: {intent}") {
            self.failed = Some(e);
        }
    }

    /// Record a frame the host sent.
    pub fn out(&mut self, bytes: &[u8]) {
        self.line('O', bytes);
    }

    /// Record a frame the device sent.
    pub fn r#in(&mut self, bytes: &[u8]) {
        self.line('I', bytes);
    }

    /// Declare that the transaction just recorded failed, and how.
    ///
    /// Written after its frames rather than with its intent: the outcome is only known
    /// once the operation is over, and a script that says nothing claims it succeeded.
    pub fn expect(&mut self, e: &crate::error::Error) {
        if self.failed.is_some() {
            return;
        }
        if let Err(io) = writeln!(self.file, "# expect: err {}", e.expect_kind()) {
            self.failed = Some(io);
        }
    }

    /// The first I/O error the recorder hit, if any. Recording stops at that point.
    pub fn check(&mut self) -> Result<()> {
        match self.failed.take() {
            Some(e) => Err(e.into()),
            None => Ok(()),
        }
    }

    fn line(&mut self, tag: char, bytes: &[u8]) {
        if self.failed.is_some() {
            return;
        }
        let mut hex = String::with_capacity(bytes.len() * 2);
        for b in bytes {
            hex.push_str(&format!("{b:02x}"));
        }
        if let Err(e) = writeln!(self.file, "{tag} {hex}") {
            self.failed = Some(e);
        }
    }
}

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

    /// A frame the recorder could not write must reach the caller: a script short of
    /// the frames it claims replays as a different exchange.
    #[test]
    fn a_frame_that_could_not_be_written_is_reported_by_the_check() {
        let path = std::env::temp_dir().join(format!("nord-record-{}.script", std::process::id()));
        File::create(&path).expect("the script path is writable");
        let unwritable = File::open(&path).expect("reopening it read-only");
        let mut recorder = Recorder {
            file: unwritable,
            failed: None,
        };

        recorder.out(&[0x00, 0x11]);
        let err = recorder
            .check()
            .expect_err("the frame never reached the script");

        assert!(matches!(err, crate::error::Error::Io(_)), "{err}");
        assert!(
            recorder.check().is_ok(),
            "a reported failure is not reported twice"
        );
        std::fs::remove_file(&path).ok();
    }
}