use super::Transport;
use crate::error::{Error, Result};
pub use crate::error::ErrKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Nsm,
Nord,
Synthetic,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Header {
pub source: Option<Source>,
pub device: Option<String>,
pub trimmed: Option<String>,
pub note: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Expect {
#[default]
Ok,
Err(ErrKind),
}
#[derive(Debug, Clone, Default)]
pub struct Section {
pub intent: Option<String>,
expect: Option<Expect>,
pub steps: Vec<Step>,
}
impl Section {
pub fn expect(&self) -> Expect {
self.expect.unwrap_or_default()
}
}
#[derive(Debug, Clone)]
pub struct Script {
pub header: Header,
pub sections: Vec<Section>,
}
pub const KEYS: &[&str] = &["intent", "expect", "source", "device", "trimmed", "note"];
impl Source {
fn parse(value: &str) -> std::result::Result<Self, String> {
match value {
"nsm" => Ok(Source::Nsm),
"nord" => Ok(Source::Nord),
"synthetic" => Ok(Source::Synthetic),
other => Err(format!(
"unknown source {other:?}; the vocabulary is nsm, nord, synthetic"
)),
}
}
}
impl std::fmt::Display for Source {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Source::Nsm => "nsm",
Source::Nord => "nord",
Source::Synthetic => "synthetic",
})
}
}
impl Expect {
fn parse(value: &str) -> std::result::Result<Self, String> {
match value.strip_prefix("err") {
None if value == "ok" => Ok(Expect::Ok),
None => Err(format!("expected 'ok' or 'err <kind>', got {value:?}")),
Some(rest) => ErrKind::parse(rest.trim()).map(Expect::Err),
}
}
pub fn check<T>(&self, outcome: &Result<T>) -> std::result::Result<(), String> {
match (self, outcome) {
(Expect::Ok, Ok(_)) => Ok(()),
(Expect::Ok, Err(e)) => Err(format!("expected ok, got {e}")),
(Expect::Err(kind), Ok(_)) => Err(format!("expected {kind}, but it succeeded")),
(Expect::Err(kind), Err(e)) if kind.matches(e) => Ok(()),
(Expect::Err(kind), Err(e)) => Err(format!("expected {kind}, got {e}")),
}
}
}
impl std::fmt::Display for Expect {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Expect::Ok => f.write_str("ok"),
Expect::Err(kind) => write!(f, "err {kind}"),
}
}
}
fn field(comment: &str) -> Option<(&str, &str)> {
let (key, value) = comment.trim().split_once(':')?;
let named = !key.is_empty() && key.bytes().all(|b| b.is_ascii_lowercase() || b == b'_');
named.then(|| (key, value.trim()))
}
impl Script {
pub fn parse(text: &str) -> Result<Self> {
let fail =
|n: usize, what: std::fmt::Arguments| Error::Replay(format!("line {}: {what}", n + 1));
let mut header = Header::default();
let mut sections = vec![Section::default()];
let mut seen_frame = false;
for (n, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.is_empty() {
continue;
}
if let Some(comment) = line.strip_prefix('#') {
let Some((key, value)) = field(comment) else {
continue;
};
let section = sections.last_mut().expect("one section always exists");
match key {
"intent" => {
if section.intent.is_none() && section.steps.is_empty() {
section.intent = Some(value.to_string());
} else {
sections.push(Section {
intent: Some(value.to_string()),
..Section::default()
});
}
}
"expect" => {
if section.expect.is_some() {
return Err(fail(
n,
format_args!("this section already says what to expect"),
));
}
section.expect =
Some(Expect::parse(value).map_err(|e| fail(n, format_args!("{e}")))?);
}
"source" | "device" | "trimmed" | "note" if seen_frame => {
return Err(fail(
n,
format_args!(
"{key} describes the file and must come before its first frame"
),
))
}
"source" => {
let source =
Source::parse(value).map_err(|e| fail(n, format_args!("{e}")))?;
if header.source.replace(source).is_some() {
return Err(fail(n, format_args!("source is given twice")));
}
}
"device" if header.device.replace(value.into()).is_some() => {
return Err(fail(n, format_args!("device is given twice")))
}
"trimmed" if header.trimmed.replace(value.into()).is_some() => {
return Err(fail(n, format_args!("trimmed is given twice")))
}
"note" if header.note.replace(value.into()).is_some() => {
return Err(fail(n, format_args!("note is given twice")))
}
"device" | "trimmed" | "note" => {}
other => {
return Err(fail(
n,
format_args!(
"unknown header key {other:?}; the vocabulary is {}",
KEYS.join(", ")
),
))
}
}
continue;
}
let frame = match line.split_once('#') {
Some((frame, _)) => frame.trim(),
None => line,
};
let (tag, hex) = frame
.split_once(char::is_whitespace)
.ok_or_else(|| fail(n, format_args!("expected '<O|I> <hex>'")))?;
let direction = match tag {
"O" | "o" => Direction::Out,
"I" | "i" => Direction::In,
other => {
return Err(fail(
n,
format_args!("unknown direction {other:?}, want O or I"),
))
}
};
let hex = hex.trim();
if !hex.is_ascii() {
return Err(fail(n, format_args!("non-hex byte")));
}
if hex.len() % 2 != 0 {
return Err(fail(n, format_args!("odd-length hex")));
}
let bytes = (0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
.collect::<std::result::Result<Vec<u8>, _>>()
.map_err(|e| fail(n, format_args!("{e}")))?;
seen_frame = true;
sections
.last_mut()
.expect("one section always exists")
.steps
.push(Step { direction, bytes });
}
for section in §ions {
if section.steps.is_empty() {
let what = match §ion.intent {
Some(intent) => format!("intent {intent:?} accounts for no frames"),
None => "the script holds no frames".into(),
};
return Err(Error::Replay(what));
}
if section.intent.is_none() && section.expect.is_some() {
return Err(Error::Replay(
"expect without an intent: nothing would be driven, so nothing could \
produce it"
.into(),
));
}
}
Ok(Self { header, sections })
}
pub fn steps(&self) -> Vec<Step> {
self.sections
.iter()
.flat_map(|s| s.steps.iter().cloned())
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Out,
In,
}
#[derive(Debug, Clone)]
pub struct Step {
pub direction: Direction,
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Strictness {
Exact,
Lenient,
}
pub struct ReplayTransport {
script: Vec<Step>,
pos: usize,
sent: Vec<Vec<u8>>,
strictness: Strictness,
}
impl ReplayTransport {
pub fn new(script: Vec<Step>) -> Self {
Self {
script,
pos: 0,
sent: Vec::new(),
strictness: Strictness::Exact,
}
}
pub fn lenient(mut self) -> Self {
self.strictness = Strictness::Lenient;
self
}
pub fn sent(&self) -> &[Vec<u8>] {
&self.sent
}
pub fn is_exhausted(&self) -> bool {
self.pos >= self.script.len()
}
pub fn position(&self) -> usize {
self.pos
}
pub fn from_script(text: &str) -> Result<Self> {
Ok(Self::new(Script::parse(text)?.steps()))
}
}
impl Transport for ReplayTransport {
async fn write(&mut self, buf: &[u8]) -> Result<()> {
self.sent.push(buf.to_vec());
let step = self.script.get(self.pos).ok_or_else(|| {
Error::Replay(format!(
"script exhausted; host sent an extra {} bytes",
buf.len()
))
})?;
if step.direction != Direction::Out {
return Err(Error::Replay(
"host wrote, but the script expects the device to speak next".into(),
));
}
if self.strictness == Strictness::Exact && step.bytes != buf {
return Err(Error::Replay(format!(
"sent bytes differ from the script at step {}\n expected {}\n got {}",
self.pos,
hex(&step.bytes),
hex(buf),
)));
}
self.pos += 1;
Ok(())
}
async fn read_timeout(
&mut self,
max: usize,
_limit: std::time::Duration,
) -> Result<Option<Vec<u8>>> {
match self.script.get(self.pos) {
Some(step) if step.direction == Direction::In => self.read(max).await.map(Some),
_ => Ok(None),
}
}
async fn read(&mut self, max: usize) -> Result<Vec<u8>> {
let step = self
.script
.get(self.pos)
.ok_or_else(|| Error::Replay("script exhausted; host expected a response".into()))?;
if step.direction != Direction::In {
return Err(Error::Replay(
"host read, but the script expects the host to speak next".into(),
));
}
if step.bytes.len() > max {
return Err(Error::Replay(format!(
"device sent {} bytes, but the read buffer holds at most {max}",
step.bytes.len()
)));
}
self.pos += 1;
Ok(step.bytes.clone())
}
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_recording_parses_as_one_section_with_no_intent() {
let script = Script::parse(
"# nord-usb replay script, recorded from hardware.\n\
# Format: '<O|I> <hex>' -- O = host->device, I = device->host.\n\
O 00\n\
I 0102\n",
)
.unwrap();
assert_eq!(script.header, Header::default());
assert_eq!(script.sections.len(), 1);
assert!(script.sections[0].intent.is_none());
assert_eq!(script.sections[0].expect(), Expect::Ok);
assert_eq!(script.steps().len(), 2);
}
#[test]
fn the_file_level_fields_are_read() {
let script = Script::parse(
"# source: nsm\n\
# device: Nord Electro 5, firmware v2.04\n\
# trimmed: ui-refresh\n\
# note: the dependency read from the duplicate capture\n\
# intent: program deps 7:3\n\
O 00\n",
)
.unwrap();
assert_eq!(script.header.source, Some(Source::Nsm));
assert_eq!(script.header.trimmed.as_deref(), Some("ui-refresh"));
assert_eq!(
script.sections[0].intent.as_deref(),
Some("program deps 7:3")
);
}
#[test]
fn each_intent_opens_a_section_over_the_frames_that_follow() {
let script = Script::parse(
"# source: nord\n\
# intent: program info 7:11\n\
O 00\n\
I 01\n\
# intent: program info 7:12\n\
# expect: err device-status 0x1\n\
O 02\n\
# intent: program move 7:11 7:12\n\
O 03\n\
I 04\n",
)
.unwrap();
let intents: Vec<&str> = script
.sections
.iter()
.map(|s| s.intent.as_deref().unwrap())
.collect();
assert_eq!(
intents,
[
"program info 7:11",
"program info 7:12",
"program move 7:11 7:12"
]
);
assert_eq!(
script
.sections
.iter()
.map(|s| s.steps.len())
.collect::<Vec<_>>(),
[2, 1, 2]
);
assert_eq!(
script.sections[1].expect(),
Expect::Err(ErrKind::DeviceStatus(1))
);
assert_eq!(script.sections[2].expect(), Expect::Ok);
}
#[test]
fn a_device_status_expectation_round_trips_its_code() {
for (text, code) in [("err device-status 0x15", 0x15), ("err device-status 1", 1)] {
let expect = Expect::parse(text).unwrap();
assert_eq!(expect, Expect::Err(ErrKind::DeviceStatus(code)));
assert!(expect.check::<()>(&Err(Error::DeviceStatus(code))).is_ok());
assert!(expect
.check::<()>(&Err(Error::DeviceStatus(code + 1)))
.is_err());
}
assert_eq!(
Expect::Err(ErrKind::DeviceStatus(0x15)).to_string(),
"err device-status 0x15"
);
}
#[test]
fn an_expectation_is_judged_against_the_outcome() {
let unexpected = Expect::parse("err unexpected-response").unwrap();
assert!(unexpected
.check::<()>(&Err(Error::UnexpectedResponse {
expected: 0x30,
got: 0x1f
}))
.is_ok());
assert!(unexpected.check(&Ok(())).is_err());
assert!(Expect::Ok.check(&Ok(())).is_ok());
assert!(Expect::Ok
.check::<()>(&Err(Error::DeviceStatus(5)))
.is_err());
}
#[test]
fn a_frame_may_carry_a_trailing_label() {
let script = Script::parse("O 0011 # SESSION_OPEN\nI 22\n").unwrap();
assert_eq!(script.steps()[0].bytes, vec![0x00, 0x11]);
}
#[test]
fn a_read_rejects_a_frame_larger_than_its_buffer() {
let mut transport = ReplayTransport::new(vec![Step {
direction: Direction::In,
bytes: vec![0; 2],
}]);
let err = pollster::block_on(transport.read(1)).expect_err("the frame is too large");
assert!(matches!(err, Error::Replay(_)));
assert_eq!(
transport.position(),
0,
"an oversized frame was not consumed"
);
}
#[test]
fn an_unknown_key_is_refused_rather_than_skipped() {
let err = Script::parse("# intention: program status\nO 00\n").unwrap_err();
assert!(err.to_string().contains("unknown header key"), "{err}");
}
#[test]
fn a_file_level_field_after_the_first_frame_is_refused() {
let err = Script::parse("O 00\n# source: nsm\n").unwrap_err();
assert!(err.to_string().contains("before its first frame"), "{err}");
}
#[test]
fn an_expect_below_its_frames_judges_the_section_it_closes() {
let script = Script::parse(
"# intent: program info 7:10\n\
O 00\n\
# expect: err device-status 0x1\n\
# intent: program focus\n\
O 01\n",
)
.unwrap();
assert_eq!(
script.sections[0].expect(),
Expect::Err(ErrKind::DeviceStatus(1))
);
assert_eq!(script.sections[1].expect(), Expect::Ok);
}
#[test]
fn a_section_may_only_say_what_to_expect_once() {
let err = Script::parse("# intent: program status\n# expect: ok\nO 00\n# expect: ok\n")
.unwrap_err();
assert!(err.to_string().contains("already says"), "{err}");
}
#[test]
fn a_recorded_failure_reads_back_as_the_error_it_names() {
let at = crate::wire::Location { bank: 1, slot: 2 };
let every = [
Error::Truncated { got: 2, need: 8 },
Error::LengthMismatch {
declared: 34,
actual: 30,
},
Error::BadCrc {
expected: 0x4a55,
actual: 0x7197,
},
Error::DeviceStatus(0x15),
Error::ClassRefused {
class: crate::wire::ObjectClass::Piano,
status: 5,
},
Error::UnexpectedResponse {
expected: 0x30,
got: 0x1f,
},
Error::UnexpectedLocation {
requested: at,
reported: crate::wire::Location { bank: 1, slot: 3 },
},
Error::UnexpectedPartition {
requested: 4,
reported: 5,
},
Error::Enumeration {
bank: 1,
answered: at,
slots: 50,
},
Error::ScanLimit {
bank: 1,
limit: 4096,
},
Error::Transport("stalled".into()),
Error::Envelope("bad magic".into()),
Error::Replay("mismatch".into()),
Error::InvalidArgument("no such bank".into()),
Error::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe)),
];
for e in every {
let line = format!("err {}", e.expect_kind());
let expect = Expect::parse(&line).unwrap_or_else(|m| panic!("{line}: {m}"));
assert!(
expect.check::<()>(&Err(e)).is_ok(),
"{line} does not read back as itself"
);
}
}
#[test]
fn an_intent_that_accounts_for_no_frames_is_refused() {
let err =
Script::parse("# intent: program status\nO 00\n# intent: program focus\n").unwrap_err();
assert!(err.to_string().contains("no frames"), "{err}");
}
#[test]
fn a_frame_carrying_a_non_ascii_character_is_refused() {
let err = Script::parse("O aéa\n").unwrap_err();
assert!(err.to_string().contains("line 1: non-hex byte"), "{err}");
}
#[test]
fn an_unknown_source_is_refused() {
let err = Script::parse("# source: pcap\nO 00\n").unwrap_err();
assert!(err.to_string().contains("unknown source"), "{err}");
}
}