use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use nord_format::accept::{Acceptance, Family};
use nord_usb::op;
use nord_usb::transport::{Transport, UsbTransport};
use nord_usb::wire::{Bank, Location, ProgramInfo, Status};
use nord_usb::{op as usb_op, Device, Geometry, ObjectClass, Session};
use crate::slot::{addr, noun, shown};
use crate::ui::Ui;
pub enum Source {
Usb,
Replay(PathBuf),
}
pub fn status(ui: &Ui, source: Source, json: bool) -> Result<(), String> {
let report = match source {
Source::Usb => {
let mut device = open_usb()?;
transact(&mut device, "device status", |d| {
nord_usb::block_on(op::inventory(d.transport()))
})
.map_err(|e| e.to_string())?
}
Source::Replay(path) => {
let text =
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut transport = nord_usb::ReplayTransport::from_script(&text)
.map_err(|e| e.to_string())?
.lenient();
collect(&mut transport)?
}
};
if report.is_empty() {
return Err(
"every object class refused — the instrument is not in a usable session \
state, and a power cycle clears it. `nord device info` shows what is on \
the bus."
.into(),
);
}
if json {
print_json(ui, &report);
} else {
print_table(ui, &report);
}
Ok(())
}
pub fn info(ui: &Ui) -> Result<(), String> {
let devices = nord_usb::transport::usb::list().map_err(|e| e.to_string())?;
if devices.is_empty() {
return Err("no Clavia device found".into());
}
let mut unreachable = None;
for (i, d) in devices.iter().enumerate() {
if i > 0 {
ui.out("");
}
ui.out(format!(
" product: {}",
d.product_string().unwrap_or("(none reported)")
));
ui.out(format!(
" vendor: {} ({:#06x})",
d.manufacturer_string().unwrap_or("(none reported)"),
d.vendor_id(),
));
ui.out(format!(" product id: {:#06x}", d.product_id()));
ui.out(format!(
" serial: {}",
d.serial_number().unwrap_or("(none reported)")
));
let vendor_iface = d.interfaces().any(|i| i.class() == 0xff);
ui.out(format!(
" protocol: {}",
if vendor_iface {
"vendor interface present"
} else {
"no vendor interface — this tool cannot drive it"
}
));
if vendor_iface {
match nord_usb::transport::UsbTransport::open(d).and_then(|t| t.identity()) {
Ok(id) => {
ui.out(format!(
" firmware: {}",
crate::summary::version_label(u32::from(id.firmware))
));
ui.out(format!(" build: {}", id.build));
ui.out(format!(" max xfer: {} bytes", id.max_transfer));
}
Err(e) => {
ui.out(format!(" firmware: {}", ui.dim(e.to_string())));
unreachable.get_or_insert_with(|| e.to_string());
}
}
}
}
match unreachable {
Some(e) => Err(format!("could not identify the instrument: {e}")),
None => Ok(()),
}
}
fn collect<T: Transport>(transport: &mut T) -> Result<Vec<Status>, String> {
nord_usb::block_on(op::inventory(transport)).map_err(|e| e.to_string())
}
fn print_table(ui: &Ui, report: &[Status]) {
ui.out(ui.dim(format!(
"{:<10} {:>20} {:>7} {:>14} {}",
"class", "used", "full", "free", "of"
)));
let mut any_dirty = false;
for s in report {
let (used, free, of) = match s.slots() {
Some(slots) => (
format!("{} / {} slots", s.count, slots),
u64::from(slots)
.saturating_sub(u64::from(s.count))
.to_string(),
format!("{} bytes each", s.bytes_per_item().unwrap_or(0)),
),
None => {
let unit = if s.class.is_library() {
"blocks"
} else {
"bytes"
};
(
format!("{} / {} {unit}", s.used, s.total()),
match s.dirty {
0 => s.available().to_string(),
dirty => {
any_dirty = true;
format!("{} ({dirty} dirty)", s.available())
}
},
format!("{} items", s.count),
)
}
};
ui.out(format!(
"{:<10} {:>20} {:>6.1}% {:>14} {}",
s.class.label(),
used,
s.used_percent(),
free,
ui.dim(of),
));
}
let mut footnotes: Vec<&str> = Vec::new();
if report.iter().any(|s| s.class.is_library()) {
footnotes.push("a block is the library partition's own allocation unit, and");
footnotes.push("`nord device geometry` reports its size; the slot classes count bytes");
}
if any_dirty {
footnotes.push("dirty blocks hold deleted content and are not free yet — a write that");
footnotes.push("needs them reclaims exactly the shortfall first");
}
if !footnotes.is_empty() {
ui.note("");
let last = footnotes.len() - 1;
for (i, line) in footnotes.iter().enumerate() {
let open = if i == 0 { "(" } else { "" };
let close = if i == last { ")" } else { "" };
ui.note(format!("{open}{line}{close}"));
}
}
}
fn print_json(ui: &Ui, report: &[Status]) {
ui.out("[");
for (i, s) in report.iter().enumerate() {
let comma = if i + 1 == report.len() { "" } else { "," };
ui.out(format!(
" {{\"class\": \"{}\", \"code\": {}, \"items\": {}, \"used\": {}, \"free\": {}, \"dirty\": {}, \"available\": {}, \"capacity\": {}}}{comma}",
s.class.label(),
s.class.to_raw(),
s.count,
s.used,
s.free,
s.dirty,
s.available(),
s.total(),
));
}
ui.out("]");
}
fn explain(e: nord_usb::Error, at: Location) -> String {
match e {
nord_usb::Error::DeviceStatus(1) => {
format!("{} is empty", shown(at))
}
nord_usb::Error::DeviceStatus(3) => {
format!("{} is out of range for this instrument", shown(at))
}
nord_usb::Error::DeviceStatus(4) => {
format!(
"{} is occupied, and the instrument does not overwrite in place",
shown(at)
)
}
other => other.to_string(),
}
}
fn explain_pair(e: nord_usb::Error, from: Location, to: Location) -> String {
match e {
nord_usb::Error::DeviceStatus(1) => explain(e, from),
nord_usb::Error::DeviceStatus(4) => explain(e, to),
nord_usb::Error::DeviceStatus(3) => format!(
"{} or {} is out of range for this instrument",
shown(from),
shown(to)
),
other => other.to_string(),
}
}
fn explain_walk(e: nord_usb::Error) -> String {
match e {
nord_usb::Error::DeviceStatus(usb_op::ENUMERATION_DISABLED) => {
"the instrument refused the enumeration request as malformed (status 0x11) \
— it refuses a cursor request without the direction word after any write \
since power-up. nord sends the full form, so this should not happen; \
per-slot `info` still works in the meantime"
.into()
}
other => other.to_string(),
}
}
static RECORDING: OnceLock<Option<PathBuf>> = OnceLock::new();
pub fn set_recording(path: Option<PathBuf>) {
let _ = RECORDING.set(path);
}
trait Recorded {
fn mark_intent(&mut self, intent: &str);
fn mark_expect(&mut self, e: &nord_usb::Error);
fn finish_recording(&mut self) -> nord_usb::Result<()>;
fn product(&self) -> Option<&str>;
}
#[cfg(test)]
impl Recorded for nord_usb::ReplayTransport {
fn mark_intent(&mut self, _intent: &str) {}
fn mark_expect(&mut self, _e: &nord_usb::Error) {}
fn finish_recording(&mut self) -> nord_usb::Result<()> {
Ok(())
}
fn product(&self) -> Option<&str> {
None
}
}
impl Recorded for UsbTransport {
fn mark_intent(&mut self, intent: &str) {
UsbTransport::mark_intent(self, intent);
}
fn mark_expect(&mut self, e: &nord_usb::Error) {
UsbTransport::mark_expect(self, e);
}
fn finish_recording(&mut self) -> nord_usb::Result<()> {
UsbTransport::finish_recording(self)
}
fn product(&self) -> Option<&str> {
UsbTransport::product(self)
}
}
fn transact<T: Transport + Recorded, R>(
device: &mut Device<T>,
intent: impl std::fmt::Display,
run: impl FnOnce(&mut Device<T>) -> nord_usb::Result<R>,
) -> nord_usb::Result<R> {
device.transport().mark_intent(&intent.to_string());
let outcome = run(device);
if let Err(e) = &outcome {
device.transport().mark_expect(e);
}
let recorded = device.transport().finish_recording();
outcome.and_then(|value| recorded.map(|()| value))
}
fn open_usb() -> Result<Device<UsbTransport>, String> {
let transport = UsbTransport::open_first().map_err(|e| e.to_string())?;
let transport = match RECORDING.get().and_then(Option::as_deref) {
Some(path) => transport.recording_to(path).map_err(|e| e.to_string())?,
None => transport,
};
Ok(Device::new(transport))
}
fn read_geometry<T: Transport + Recorded>(device: &mut Device<T>) -> Result<&Geometry, String> {
transact(device, "device geometry", |d| {
nord_usb::block_on(d.geometry()).map(|_| ())
})
.map_err(|e| e.to_string())?;
nord_usb::block_on(device.geometry()).map_err(|e| e.to_string())
}
fn declared_banks(
device: &mut Device<UsbTransport>,
class: ObjectClass,
) -> Result<Vec<Bank>, String> {
read_geometry(device)?
.banks(class)
.map(<[Bank]>::to_vec)
.map_err(|e| e.to_string())
}
fn read_object(
device: &mut Device<UsbTransport>,
at: Location,
class: ObjectClass,
body: bool,
) -> Result<(ProgramInfo, Vec<u8>), String> {
let verb = if body { "get-body" } else { "get" };
transact(
device,
format!("{} {verb} {}", noun(class), addr(at)),
|d| {
nord_usb::block_on(d.read(class, async |s| {
let info = usb_op::info(s, at).await?;
let file = if body {
usb_op::read_body(s, at).await?
} else {
usb_op::read_program(s, at).await?
};
Ok((info, file))
}))
},
)
.map_err(|e| explain(e, at))
}
pub fn get(
ui: &Ui,
at: Location,
out: Option<PathBuf>,
class: ObjectClass,
body: bool,
) -> Result<(), String> {
if body && out.is_none() {
return Err("--body writes a file; give -o a path".into());
}
let mut device = open_usb()?;
let (info, file) = read_object(&mut device, at, class, body)?;
if let Some(path) = out {
crate::edit::replace_file(&path, &file)?;
ui.note(format!(
"read {:?} ({} bytes) from {} -> {}",
info.name,
file.len(),
shown(at),
path.display(),
));
return Ok(());
}
let entity = nord_format::from_stream(&mut std::io::Cursor::new(&file)).map_err(|e| {
format!(
"{} decoded off the device but did not parse: {e}",
shown(at)
)
})?;
ui.out(format!(
"{} {} {:?} ({}, version {})",
shown(at),
ui.dash(),
info.name,
info.format,
info.version
));
crate::summary::print(ui, &entity);
Ok(())
}
pub fn sweep(
ui: &Ui,
at: Location,
dir: PathBuf,
class: ObjectClass,
body: bool,
) -> Result<(), String> {
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let mut device = open_usb()?;
ui.note(format!(
"sweeping {} ({}) into {}",
shown(at),
class.label(),
dir.display()
));
ui.note("change one thing on the instrument, then say what it was");
ui.note("each prompt reopens with your last answer, editable; clear it to finish");
let mut captured = 0usize;
let mut previous = String::new();
while let Some(label) = ui.ask("what changed", &previous)? {
previous = label.clone();
let stem = match stem(&label) {
Ok(s) => s,
Err(e) => {
ui.warn(e);
continue;
}
};
if taken(&dir, &stem)? {
ui.warn(format!(
"{stem:?} is already captured; give this one another name"
));
continue;
}
let (info, file) = match read_object(&mut device, at, class, body) {
Ok(read) => read,
Err(e) => {
ui.warn(e);
continue;
}
};
let path = dir.join(match body {
true => format!("{stem}.bin"),
false => format!("{stem}.{}", info.format),
});
let mut output = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|e| format!("{}: {e}", path.display()))?;
output
.write_all(&file)
.map_err(|e| format!("{}: {e}", path.display()))?;
captured += 1;
ui.note(format!(" {} ({} bytes)", path.display(), file.len()));
}
ui.note(format!("captured {captured} file(s) in {}", dir.display()));
Ok(())
}
fn stem(label: &str) -> Result<String, String> {
let mut owed = false;
let mut out = String::with_capacity(label.len());
for c in label.chars() {
match c {
'-' => owed = !out.is_empty(),
_ if c.is_whitespace() => owed = !out.is_empty(),
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => owed = !out.is_empty(),
_ if c.is_control() => {}
_ => {
if std::mem::take(&mut owed) {
out.push('-');
}
out.push(c);
}
}
}
let out = out.trim_matches(['.', '-']);
if out.is_empty() {
return Err(format!("{label:?} leaves nothing usable as a filename"));
}
let device = out.split('.').next().unwrap_or(out).to_ascii_uppercase();
let reserved = matches!(device.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| device
.strip_prefix("COM")
.or_else(|| device.strip_prefix("LPT"))
.is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
if reserved {
return Err(format!("{label:?} is a reserved filename on Windows"));
}
Ok(out.to_string())
}
fn taken(dir: &Path, stem: &str) -> Result<bool, String> {
let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
let same = Path::new(&entry.file_name())
.file_stem()
.is_some_and(|held| held.to_string_lossy().eq_ignore_ascii_case(stem));
if same {
return Ok(true);
}
}
Ok(false)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Admit {
Takes,
Warn(String),
Refuse(String),
}
pub fn admit(product: Option<&str>, class: ObjectClass, tag: &str) -> Admit {
let Some(product) = product else {
return Admit::Warn(format!(
"this transport reports no product string, so nothing here says whether \
the instrument takes a {tag} file"
));
};
let (Some(slot), Some(family)) = (class.storage(), Family::from_product(product)) else {
return Admit::Warn(format!(
"{product} is not in the acceptance table, so nothing here says whether it \
takes a {tag} file"
));
};
match family.accepts(slot, tag) {
Acceptance::Confirmed => Admit::Takes,
Acceptance::Inferred => Admit::Warn(format!(
"this is a {} file and the instrument is a {product}, but no file of this \
kind has ever been written to one: this write is untried",
family.label()
)),
Acceptance::Unknown => Admit::Warn(format!(
"nothing here says whether a {product} takes a {tag} file"
)),
Acceptance::Refused => Admit::Refuse(match Family::of_tag(tag) {
Some(owner) => format!(
"this is a {} file and the instrument is a {product}",
owner.label()
),
None => format!("a {tag} file is not one a {product} takes"),
}),
}
}
pub fn put(
ui: &Ui,
path: PathBuf,
at: Location,
class: ObjectClass,
confirmed: bool,
) -> Result<(), String> {
let file = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
nord_usb::envelope::unwrap(&file).map_err(|e| e.to_string())?;
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.filter(|s| !s.is_empty() && !s.starts_with('.'))
.ok_or_else(|| {
format!(
"{}: the slot takes its name from the file's stem, and this file has none",
path.display()
)
})?;
let stamp = std::fs::metadata(&path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| u32::try_from(d.as_secs()))
.transpose()
.map_err(|_| {
format!(
"{}: modification time does not fit the protocol",
path.display()
)
})?;
send(
ui,
&file,
at,
class,
confirmed,
&path.display().to_string(),
Some(&stem),
stamp,
)
}
#[allow(clippy::too_many_arguments)]
pub fn send(
ui: &Ui,
file: &[u8],
at: Location,
class: ObjectClass,
confirmed: bool,
what: &str,
name: Option<&str>,
stamp: Option<u32>,
) -> Result<(), String> {
send_with(
ui,
&mut open_usb()?,
&std::env::current_dir().unwrap_or_default(),
file,
at,
class,
confirmed,
what,
name,
stamp,
)
}
#[allow(clippy::too_many_arguments)]
fn send_with<T: Transport + Recorded>(
ui: &Ui,
device: &mut Device<T>,
spill_into: &Path,
file: &[u8],
at: Location,
class: ObjectClass,
confirmed: bool,
what: &str,
name: Option<&str>,
stamp: Option<u32>,
) -> Result<(), String> {
let bad = transact(
device,
format!("{} check-address {}", noun(class), addr(at)),
|d| nord_usb::block_on(d.read(class, async |s| usb_op::check_address(s, at).await)),
)
.map_err(|e| explain(e, at))?;
if let Some(reason) = bad {
return Err(format!("{}: {reason}", shown(at)));
}
let timestamp = match stamp {
Some(stamp) => stamp,
None => crate::edit::unix_seconds_now()?,
};
let existing = transact(device, format!("{} info {}", noun(class), addr(at)), |d| {
nord_usb::block_on(d.read(class, async |s| usb_op::info(s, at).await))
});
let existing = match existing {
Ok(info) => Some(info),
Err(nord_usb::Error::DeviceStatus(1)) => None,
Err(e) => return Err(explain(e, at)),
};
let in_place = class.overwrites_in_place();
match &existing {
Some(info) => {
ui.note(format!(
"about to {} {} (currently {:?}) with {what}",
ui.danger("overwrite"),
shown(at),
info.name,
));
let room = match in_place {
true => format!(
"the instrument overwrites {} in place, so nothing is deleted.",
shown(at)
),
false => format!(
"{} the instrument will not overwrite in place, so {} is deleted first.",
ui.danger("note:"),
shown(at),
),
};
ui.note(format!(
" {room} Its {} bytes are read back beforehand and put back if the \
write fails.",
info.body_len,
));
}
None => ui.note(format!("{} is empty; writing {what}", shown(at))),
}
match name.filter(|n| !n.is_empty()) {
Some(name) if class.names_its_slots() => {
ui.note(format!("the slot will be named {name:?}"))
}
Some(_) => ui.note(format!(
"{} keeps its fixed name: this class stores none, so the device discards \
the write's name argument",
shown(at),
)),
None => {}
}
if class == ObjectClass::Settings {
ui.warn(
"a settings write reloads the selected program: panel state that has not \
been stored is lost, so re-select and re-apply afterwards",
);
}
if let Some(tag) = tag(file) {
match admit(device.transport().product(), class, &tag) {
Admit::Takes => {}
Admit::Warn(why) => ui.warn(why),
Admit::Refuse(why) => return Err(format!("{what}: {why}")),
}
}
ui.confirm(confirmed)?;
let backup = match &existing {
Some(_) => Some(
transact(device, format!("{} read {}", noun(class), addr(at)), |d| {
nord_usb::block_on(d.read(class, async |s| usb_op::read_program(s, at).await))
})
.map_err(|e| {
format!(
"could not read {} back before replacing it, so it was left alone: {}",
shown(at),
explain(e, at)
)
})?,
),
None => None,
};
read_geometry(device)?;
if let (Some(backup), false) = (&backup, in_place) {
ui.note(format!("deleting {} to make room", shown(at)));
if let Err(e) = transact(
device,
format!("{} delete {}", noun(class), addr(at)),
|d| nord_usb::block_on(delete_for_replacement(d, class, at)),
) {
if let nord_usb::Error::DeviceStatus(_) = e {
return Err(format!("deleting {}: {}", shown(at), explain(e, at)));
}
return Err(spill(
ui,
spill_into,
at,
backup,
format!("{} may have been deleted: {}", shown(at), explain(e, at)),
));
}
}
let write_name = name
.map(str::to_string)
.or_else(|| existing.as_ref().map(|i| i.name.clone()))
.unwrap_or_default();
let written = if fail_after_delete() {
Err(nord_usb::Error::Transport(
"NORD_FAIL_AFTER_DELETE was set, so the write was not attempted".into(),
))
} else {
transact(
device,
put_intent(class, what, at, &write_name, timestamp),
|d| nord_usb::block_on(d.write(class, at, file, &write_name, timestamp)),
)
};
match (written, backup) {
(Ok(()), _) => {
ui.note(format!("wrote {what} -> {}", shown(at)));
Ok(())
}
(Err(e), None) => Err(explain(e, at)),
(Err(e), Some(backup)) => {
ui.warn(format!(
"the write failed and {}; putting the original back",
aftermath(class, at)
));
let restore_name = existing
.as_ref()
.map(|i| i.name.clone())
.unwrap_or_else(|| write_name.clone());
let restore = transact(
device,
put_intent(
class,
&rescue_name(at, &backup),
at,
&restore_name,
timestamp,
),
|d| nord_usb::block_on(d.write(class, at, &backup, &restore_name, timestamp)),
);
match restore {
Ok(()) => {
ui.note(format!("restored {}", shown(at)));
Err(format!(
"{} ({} was restored, and is unchanged)",
explain(e, at),
shown(at)
))
}
Err(restore) => {
ui.warn("restoring failed too");
Err(spill(
ui,
spill_into,
at,
&backup,
format!(
"{} (restoring failed as well: {}) {}",
explain(e, at),
explain(restore, at),
aftermath(class, at),
),
))
}
}
}
}
}
#[cfg(feature = "fault-injection")]
fn fail_after_delete() -> bool {
std::env::var_os("NORD_FAIL_AFTER_DELETE").is_some()
}
#[cfg(not(feature = "fault-injection"))]
fn fail_after_delete() -> bool {
false
}
fn spill(ui: &Ui, dir: &Path, at: Location, backup: &[u8], lost: String) -> String {
let path = dir.join(rescue_name(at, backup));
match crate::edit::replace_file(&path, backup) {
Ok(()) => {
ui.warn(format!("wrote the original to {}", path.display()));
format!(
"{lost}; its former contents were saved to {} — put it back with `nord put`",
path.display(),
)
}
Err(io) => format!(
"{lost}, and its former contents could not be saved either ({io}); {} bytes \
are lost",
backup.len(),
),
}
}
async fn delete_for_replacement<T: Transport>(
device: &mut Device<T>,
class: ObjectClass,
at: Location,
) -> nord_usb::Result<()> {
device.geometry().await?.allocation_unit(class)?;
device
.destructive(class, async |s| usb_op::delete(s, at).await)
.await
}
fn aftermath(class: ObjectClass, at: Location) -> String {
match class.overwrites_in_place() {
true => format!("{} may hold a partly written body", shown(at)),
false => format!("{} is empty", shown(at)),
}
}
fn peek_info<T: Transport + Recorded>(
device: &mut Device<T>,
class: ObjectClass,
at: Location,
) -> nord_usb::Result<ProgramInfo> {
transact(device, format!("{} info {}", noun(class), addr(at)), |d| {
nord_usb::block_on(d.read(class, async |s| usb_op::info(s, at).await))
})
}
fn peek<T: Transport + Recorded>(
device: &mut Device<T>,
class: ObjectClass,
at: Location,
) -> Result<String, String> {
peek_info(device, class, at)
.map(|info| info.name)
.map_err(|e| explain(e, at))
}
enum DestFate {
Overwritten,
Swapped,
}
fn peek_dest<T: Transport + Recorded>(
ui: &Ui,
device: &mut Device<T>,
class: ObjectClass,
at: Location,
fate: DestFate,
) -> String {
match (peek_info(device, class, at), fate) {
(Ok(info), DestFate::Overwritten) => {
format!("{} {:?}", ui.danger("OVERWRITING"), info.name)
}
(Ok(info), DestFate::Swapped) => format!("{} {:?}", ui.bold("SWAPPING WITH"), info.name),
(Err(nord_usb::Error::DeviceStatus(1)), _) => "destination reads as empty".into(),
(Err(e), _) => format!("destination could not be read: {}", explain(e, at)),
}
}
fn referring_set_lists(
device: &mut Device<UsbTransport>,
targets: &[Location],
) -> Result<Vec<op::Referrer>, String> {
let class = ObjectClass::SetList;
let banks = declared_banks(device, class)?;
let where_: Vec<String> = targets.iter().map(|&at| addr(at)).collect();
let intent = format!("{} referrers {}", noun(class), where_.join(" "));
transact(device, intent, |d| {
nord_usb::block_on(d.read(class, async |s| {
usb_op::set_lists_referencing(s, &banks, targets).await
}))
})
.map_err(|e| e.to_string())
}
fn set_list_rewrite_lines(ui: &Ui, found: &[op::Referrer]) -> Vec<String> {
if found.is_empty() {
return vec!["no set list references either slot".into()];
}
let mut lines = vec![format!(
"the instrument will also rewrite {} set list{} that reference these slots:",
found.len(),
if found.len() == 1 { "" } else { "s" }
)];
for r in found {
let refs: Vec<String> = r.programs.iter().map(|&l| addr(l)).collect();
lines.push(format!(
" setlist {} {:?} points at {}",
addr(r.at),
r.name,
refs.join(", "),
));
if r.version == 0 {
lines.push(format!(
" {} {} the rewrite migrates it to version 1, and moving the program",
ui.danger("VERSION 0"),
ui.dash(),
));
lines.push(" back does not migrate the set list back".into());
}
}
lines
}
fn describe_set_list_rewrites(
ui: &Ui,
device: &mut Device<UsbTransport>,
targets: &[Location],
) -> Result<(), String> {
let found = referring_set_lists(device, targets).map_err(|e| {
format!(
"could not read which set lists reference these slots ({e}); the move was \
not attempted"
)
})?;
for line in set_list_rewrite_lines(ui, &found) {
ui.note(line);
}
Ok(())
}
pub fn move_object(
ui: &Ui,
from: Location,
to: Location,
class: ObjectClass,
confirmed: bool,
) -> Result<(), String> {
let mut device = open_usb()?;
let name = peek(&mut device, class, from)?;
let dest = peek_dest(ui, &mut device, class, to, DestFate::Swapped);
ui.note(format!(
"moving {:?} from {} to {} {} {}",
name,
shown(from),
shown(to),
ui.dash(),
dest
));
if class == ObjectClass::Program {
describe_set_list_rewrites(ui, &mut device, &[from, to])?;
}
ui.confirm(confirmed)?;
transact(
&mut device,
format!("{} move {} {}", noun(class), addr(from), addr(to)),
|d| {
nord_usb::block_on(
d.destructive(class, async |s| usb_op::move_object(s, from, to).await),
)
},
)
.map_err(|e| explain_pair(e, from, to))?;
ui.note(format!("moved {} -> {}", shown(from), shown(to)));
Ok(())
}
pub fn delete(
ui: &Ui,
slots: &[Location],
class: ObjectClass,
confirmed: bool,
) -> Result<(), String> {
let mut device = open_usb()?;
for &at in slots {
let name = peek(&mut device, class, at)?;
ui.note(format!(
"{} {:?} at {}",
ui.danger("deleting"),
name,
shown(at)
));
}
ui.confirm(confirmed)?;
let addresses: Vec<String> = slots.iter().map(|&at| addr(at)).collect();
let mut done = 0;
let outcome = transact(
&mut device,
format!("{} delete {}", noun(class), addresses.join(" ")),
|d| {
nord_usb::block_on(d.destructive(class, async |s| {
for &at in slots {
usb_op::delete(s, at).await?;
done += 1;
}
Ok(())
}))
},
);
if let Err(e) = outcome {
let at = slots[done.min(slots.len() - 1)];
let gone: Vec<String> = slots[..done].iter().map(|&at| shown(at)).collect();
return Err(match done {
0 => format!("deleting {}: {}", shown(at), explain(e, at)),
_ => format!(
"deleting {}: {} — {} already deleted ({}); {} left alone",
shown(at),
explain(e, at),
done,
gone.join(", "),
slots.len().saturating_sub(done + 1)
),
});
}
ui.note(format!("deleted {} item(s)", slots.len()));
Ok(())
}
pub fn rename(
ui: &Ui,
at: Location,
name: String,
class: ObjectClass,
confirmed: bool,
) -> Result<(), String> {
let mut device = open_usb()?;
let old = peek(&mut device, class, at)?;
ui.note(format!(
"renaming {} from {:?} to {:?}",
shown(at),
old,
name
));
ui.confirm(confirmed)?;
transact(
&mut device,
format!("{} rename {} {name:?}", noun(class), addr(at)),
|d| nord_usb::block_on(d.destructive(class, async |s| usb_op::rename(s, at, &name).await)),
)
.map_err(|e| explain(e, at))?;
ui.note(format!("renamed {} -> {:?}", shown(at), name));
Ok(())
}
pub fn duplicate(
ui: &Ui,
from: Location,
to: Location,
class: ObjectClass,
confirmed: bool,
) -> Result<(), String> {
let mut device = open_usb()?;
let name = peek(&mut device, class, from)?;
let dest = peek_dest(ui, &mut device, class, to, DestFate::Overwritten);
ui.note(format!(
"duplicating {:?} from {} to {} {} {}",
name,
shown(from),
shown(to),
ui.dash(),
dest
));
ui.confirm(confirmed)?;
transact(
&mut device,
format!("{} duplicate {} {}", noun(class), addr(from), addr(to)),
|d| {
nord_usb::block_on(d.destructive(class, async |s| usb_op::duplicate(s, from, to).await))
},
)
.map_err(|e| explain_pair(e, from, to))?;
ui.note(format!("duplicated {} -> {}", shown(from), shown(to)));
Ok(())
}
pub fn select(ui: &Ui, at: Location, class: ObjectClass) -> Result<(), String> {
let mut device = open_usb()?;
transact(
&mut device,
format!("{} select {}", noun(class), addr(at)),
|d| nord_usb::block_on(d.read(class, async |s| usb_op::select(s, at).await)),
)
.map_err(|e| explain(e, at))?;
ui.note(format!("selected {} on the instrument", shown(at)));
Ok(())
}
pub(crate) fn grouped(n: u32) -> String {
let digits = n.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
pub(crate) fn human_size(n: u32) -> Option<String> {
const UNITS: [&str; 3] = ["KiB", "MiB", "GiB"];
if n < 1024 {
return None;
}
let mut value = n as f64 / 1024.0;
let mut unit = 0;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
Some(format!("{value:.1} {}", UNITS[unit]))
}
pub fn deps(ui: &Ui, at: Location, class: ObjectClass) -> Result<(), String> {
let mut device = open_usb()?;
let deps = transact(
&mut device,
format!("{} deps {}", noun(class), addr(at)),
|d| nord_usb::block_on(d.read(class, async |s| usb_op::dependencies(s, at).await)),
)
.map_err(|e| explain(e, at))?;
let (live, idle): (Vec<_>, Vec<_>) = deps.iter().partition(|d| d.flag == 1);
let (live, unassigned): (Vec<_>, Vec<_>) = live.into_iter().partition(|d| d.is_required());
if live.is_empty() {
ui.note(format!("{} depends on nothing", shown(at)));
} else {
ui.out(ui.dim(format!("{:<8} {:<10} name", "class", "id")));
for d in &live {
let loc = match d.location.map(shown) {
Some(at) => format!(" {}", ui.dim(at)),
None => String::new(),
};
ui.out(format!(
"{:<8} {:<10} {}{loc}",
d.class.label(),
crate::summary::dep_id(d.id),
d.name.trim_end(),
));
}
}
if !unassigned.is_empty() {
let which: Vec<String> = unassigned
.iter()
.map(|d| d.class.label().to_string())
.collect();
ui.note("");
ui.note(format!("routed but nothing assigned: {}", which.join(", ")));
}
if !idle.is_empty() {
ui.note("");
ui.note(format!(
"{} further row(s) reported but not in use — the section is not routed to a \
keyboard part, so the instrument names an object this object does not depend on:",
idle.len()
));
for d in &idle {
let named = if d.name.trim_end().is_empty() {
"(no name)".to_string()
} else {
d.name.trim_end().to_string()
};
ui.note(format!(
" {} {} {named}",
d.class.label(),
crate::summary::dep_id(d.id)
));
}
}
Ok(())
}
pub fn recover(ui: &Ui) -> Result<(), String> {
let mut device = open_usb()?;
transact(&mut device, "device recover", |d| {
nord_usb::block_on(usb_op::recover(d.transport()))
})
.map_err(|e| e.to_string())?;
ui.note("released any session the instrument was still holding");
ui.note("if slots were reading as empty, re-check them now");
Ok(())
}
pub fn geometry(ui: &Ui) -> Result<(), String> {
let mut device = open_usb()?;
let geometry = read_geometry(&mut device)?;
ui.out(ui.dim(format!(
"{:<4} {:<18} {:>6} {:>7} {:>10} banks",
"code", "partition", "banks", "slots", "unit"
)));
for (p, banks) in geometry.entries() {
let bounded: Vec<&Bank> = banks.iter().filter(|b| b.is_bounded()).collect();
let slots = match bounded.len() == banks.len() {
true => bounded
.iter()
.map(|bank| u64::from(bank.slots))
.sum::<u64>()
.to_string(),
false => "—".to_string(),
};
let names: Vec<&str> = banks.iter().map(|b| b.name.as_str()).collect();
let unit = match p.allocation_unit() {
Ok(unit) if unit.is_bytes() => "byte".to_string(),
Ok(unit) => format!("{} B", unit.get()),
Err(e) => e.to_string(),
};
ui.out(format!(
"{:<4} {:<18} {:>6} {:>7} {:>10} {}",
p.index,
p.name,
banks.len(),
slots,
unit,
ui.dim(names.join(", ")),
));
}
ui.note("");
ui.note("the partition index is the object class number; (Native) partitions are a");
ui.note("second view of the same library, so their capacity is a sentinel, not a size");
ui.note("the unit is net of the block's own overhead");
Ok(())
}
#[cfg(feature = "wedge")]
pub fn wedge(ui: &Ui, class: ObjectClass, yes: bool) -> Result<(), String> {
if !yes {
return Err("refusing to wedge the instrument without --yes; \
clear it afterwards with `nord device recover`"
.into());
}
let mut device = open_usb()?;
nord_usb::block_on(async {
let s = Session::open(device.transport(), class).await?;
s.abort();
Ok::<(), nord_usb::Error>(())
})
.map_err(|e| e.to_string())?;
ui.note("session abandoned with no GOODBYE — the instrument is now wedged");
ui.note("every slot will read as empty, and read *successfully*, until you run");
ui.note("`nord device recover`");
Ok(())
}
pub fn controls(
ui: &Ui,
from: u8,
to: u8,
len: u16,
interface: bool,
value: u16,
index: u16,
) -> Result<(), String> {
if from > to {
return Err(format!(
"--from {from:#04x} is above --to {to:#04x}; nothing to sweep"
));
}
let mut device = open_usb()?;
let recipient = if interface {
nord_usb::transport::usb::Recipient::Interface
} else {
nord_usb::transport::usb::Recipient::Device
};
ui.out(ui.dim(format!("{:<9} {:>5} {}", "bRequest", "bytes", "response")));
let mut answered = 0;
for request in from..=to {
let got = device.transport().vendor_control_in(
recipient,
request,
value,
index,
usize::from(len),
std::time::Duration::from_millis(500),
);
match got {
Ok(data) if data.is_empty() => {
answered += 1;
ui.out(format!(
"{request:#04x} ({request:>3}) {:>5} (accepted, no data)",
0
));
}
Ok(data) => {
answered += 1;
let (hex, text) = dump(&data[..data.len().min(24)]);
ui.out(format!(
"{request:#04x} ({request:>3}) {:>5} {hex}",
data.len(),
));
ui.out(format!("{:>16} {}", "", ui.dim(text)));
}
Err(_) => ui.out(ui.dim(format!("{request:#04x} ({request:>3}) - —"))),
}
}
ui.note("");
ui.note(format!(
"{answered} of {} request(s) answered",
u16::from(to) - u16::from(from) + 1
));
Ok(())
}
pub fn focus(ui: &Ui, class: ObjectClass) -> Result<(), String> {
let mut device = open_usb()?;
let (at, info) = transact(&mut device, format!("{} focus", noun(class)), |d| {
nord_usb::block_on(d.read(class, async |s| {
let at = usb_op::focus(s).await?;
let info = match usb_op::info(s, at).await {
Ok(i) => Some(i),
Err(nord_usb::Error::DeviceStatus(1)) => None,
Err(e) => return Err(e),
};
Ok((at, info))
}))
})
.map_err(|e| e.to_string())?;
match info {
Some(info) => ui.out(format!("{} {:?}", addr(at), info.name)),
None => ui.out(format!("{} (empty)", addr(at))),
}
Ok(())
}
pub fn list(ui: &Ui, class: ObjectClass) -> Result<(), String> {
let mut device = open_usb()?;
let banks = declared_banks(&mut device, class)?;
let rows = transact(&mut device, format!("{} walk", noun(class)), |d| {
nord_usb::block_on(d.read(class, async |s| {
let mut rows = Vec::new();
for at in usb_op::occupied_slots(s, &banks).await? {
match usb_op::info(s, at).await {
Ok(info) => rows.push((at, info)),
Err(nord_usb::Error::DeviceStatus(1)) => {}
Err(e) => return Err(e),
}
}
Ok(rows)
}))
})
.map_err(explain_walk)?;
if rows.is_empty() {
ui.note(format!("no {} on the instrument", class.label()));
return Ok(());
}
ui.out(ui.dim(format!(
"{:<8} {:<6} {:>9} name",
"slot", "format", "bytes"
)));
for (at, info) in &rows {
ui.out(format!(
"{:<8} {:<6} {:>9} {}",
addr(*at),
info.format,
info.body_len,
info.name.trim_end(),
));
}
ui.note("");
ui.note(format!("{} {}", rows.len(), class.label()));
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn probe(
ui: &Ui,
class: ObjectClass,
op: u32,
args: &[u32],
wait: u64,
yes: bool,
bare: bool,
service: u32,
subsystem: u32,
) -> Result<(), String> {
let mut words = Vec::with_capacity(args.len() * 4);
for a in args {
words.extend_from_slice(&a.to_be_bytes());
}
if op == nord_usb::wire::cmd::NOTIFY_READ_WEDGE {
ui.note(format!(
"{op:#04x} is known to wedge the instrument (no reply, session lost, \
power cycle to recover); nothing stored has ever been harmed by it"
));
}
if op == nord_usb::wire::cmd::ERASE_ALL {
ui.note(format!(
"{op:#04x} is reported to erase an ENTIRE PARTITION — as aimed, all of {}. \
Unlike the wedges this does not cost a power cycle, it costs the data; \
restoring a library means a backup and a long upload",
class.label()
));
}
if op > nord_usb::wire::cmd::HIGHEST_ANSWERING {
ui.note(format!(
"{op:#04x} is above {:#04x}, the highest command this instrument has been \
seen to answer; codes up there are unexplored and at least one is \
destructive",
nord_usb::wire::cmd::HIGHEST_ANSWERING
));
}
ui.note(format!(
"probing command {op:#04x} on {} with {} argument word(s)",
class.label(),
args.len()
));
if !yes {
return Err("refusing to probe without --yes".into());
}
if op == u32::MAX {
return Err(format!(
"{op:#x} has no `op + 1` reply code; the command space ends one below it"
));
}
let svc = nord_usb::Service::from_raw(service);
let mut device = open_usb()?;
if bare {
let reply = nord_usb::block_on(async {
let req = nord_usb::Message::new(svc, subsystem, op, words.clone());
let t = device.transport();
let limit = std::time::Duration::from_secs(wait);
if !t.write_timeout(&req.encode(), limit).await? {
return Err(nord_usb::Error::Transport(format!(
"the device did not accept command {op:#04x} within {wait}s: its bulk \
endpoints are stalled, and a power cycle is the only way out"
)));
}
match t
.read_timeout(nord_usb::transport::READ_BUFFER, limit)
.await?
{
Some(raw) => nord_usb::Message::decode_probe(&raw).map(Some),
None => Ok(None),
}
})
.map_err(|e: nord_usb::Error| e.to_string())?;
match reply {
Some(reply) => report_reply(ui, &reply, op),
None => ui.out(format!("no reply within {wait}s")),
}
return Ok(());
}
let (reply, changed, close_failed) = nord_usb::block_on(async {
let mut s = Session::open(device.transport(), class).await?;
let r = s
.probe(
svc,
subsystem,
op,
&words,
std::time::Duration::from_secs(wait),
)
.await;
let changed = s.instrument_changed();
let closed = s
.commit_with_read_limit(std::time::Duration::from_secs(wait))
.await;
r.map(|reply| (reply, changed, closed.err()))
})
.map_err(|e| e.to_string())?;
if changed {
ui.note("the instrument reported a change during this session");
}
if let Some(e) = close_failed {
ui.note(format!("the session would not close afterwards: {e}"));
}
let Some(reply) = reply else {
ui.out(format!(
"no reply within {wait}s — the device ignored command {op:#04x}"
));
return Ok(());
};
report_reply(ui, &reply, op);
Ok(())
}
fn report_reply(ui: &Ui, reply: &nord_usb::Message, op: u32) {
ui.out(format!(
"reply command {:#04x}{}",
reply.command,
if reply.command == op + 1 {
String::new()
} else {
format!(" (expected {:#04x} by the +1 rule)", op + 1)
}
));
match reply.status() {
Some(0) => ui.out("status 0 (ok)".to_string()),
Some(code) => ui.out(format!("status {code} ({code:#x}) — not success")),
None => ui.out("status absent — reply too short to carry one".to_string()),
}
let payload = reply.payload();
ui.out(format!("payload {} bytes", payload.len()));
for (i, chunk) in payload.chunks(16).enumerate() {
let (hex, text) = dump(chunk);
ui.out(format!(" {:04x} {hex:<47} {text}", i * 16));
}
}
fn dump(bytes: &[u8]) -> (String, String) {
let hex: Vec<String> = bytes.iter().map(|b| format!("{b:02x}")).collect();
let text = bytes
.iter()
.map(|&b| {
if (0x20..0x7f).contains(&b) {
b as char
} else {
'.'
}
})
.collect();
(hex.join(" "), text)
}
pub fn slot_info(ui: &Ui, at: Location, class: ObjectClass) -> Result<(), String> {
let mut device = open_usb()?;
let info = transact(
&mut device,
format!("{} info {}", noun(class), addr(at)),
|d| nord_usb::block_on(d.read(class, async |s| usb_op::info(s, at).await)),
)
.map_err(|e| explain(e, at))?;
let row = |label: &str, value: String| {
ui.out(format!(" {}{value}", ui.dim(format!("{label:<11}"))));
};
row("location:", shown(info.location));
row("name:", format!("{:?}", info.name));
row("format:", info.format.clone());
row("version:", info.version.to_string());
row(
"body:",
format!(
"{} bytes{}",
grouped(info.body_len),
match human_size(info.body_len) {
Some(h) => format!(" {}", ui.dim(format!("({h})"))),
None => String::new(),
}
),
);
match info.crc32 {
Some(crc) => row("crc32:", format!("{crc:#010x}")),
None => row(
"crc32:",
format!("none {}", ui.dim("(not checksummed for this class)")),
),
}
Ok(())
}
pub fn fetch(at: Location, class: ObjectClass) -> Result<Vec<u8>, String> {
let mut device = open_usb()?;
transact(
&mut device,
format!("{} read {}", noun(class), addr(at)),
|d| nord_usb::block_on(d.read(class, async |s| usb_op::read_program(s, at).await)),
)
.map_err(|e| explain(e, at))
}
fn put_intent(class: ObjectClass, what: &str, at: Location, name: &str, stamp: u32) -> String {
let file = Path::new(what)
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_else(|| what.to_string());
format!("{} put {file} {} {name:?} {stamp}", noun(class), addr(at))
}
fn tag(body: &[u8]) -> Option<String> {
body.get(8..12)
.filter(|tag| tag.iter().all(|b| b.is_ascii_alphanumeric()))
.map(|tag| String::from_utf8_lossy(tag).into_owned())
}
fn rescue_name(at: Location, backup: &[u8]) -> String {
let format = tag(backup).unwrap_or_else(|| "bin".to_string());
format!(
"nord-rescued-{}-{}.{format}",
at.user_bank(),
at.user_slot()
)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn replacement_refuses_unusable_geometry_before_deleting() {
use nord_usb::transport::{Direction, ReplayTransport, Script};
use nord_usb::wire::{cmd, Message, Partition, Service};
let script = Script::parse(include_str!(
"../../nord-usb/tests/scripts/device/geometry.script"
))
.unwrap();
for class in [ObjectClass::Program, ObjectClass::Unknown(9)] {
let mut steps = script.steps();
for step in &mut steps {
if step.direction != Direction::In {
continue;
}
let mut reply = Message::decode_response(&step.bytes).unwrap();
if reply.service != Service::Program || reply.command != cmd::PARTITIONS + 1 {
continue;
}
let partitions = Partition::decode_all(&reply).unwrap();
reply.args = vec![0, 0, 0, 0, partitions.len() as u8];
for mut partition in partitions {
if partition.index == ObjectClass::Program.to_raw() {
partition.fields[..4].fill(0);
}
reply
.args
.extend_from_slice(&(partition.name.len() as u32).to_be_bytes());
reply.args.extend_from_slice(partition.name.as_bytes());
reply.args.extend_from_slice(&partition.fields);
}
step.bytes = reply.encode();
}
let mut device = Device::new(ReplayTransport::new(steps));
nord_usb::block_on(async {
device
.geometry()
.await
.expect("the tables themselves are readable");
let error =
delete_for_replacement(&mut device, class, Location { bank: 0, slot: 0 })
.await
.expect_err("an unusable write allocation must leave the occupant alone");
assert!(
matches!(error, nord_usb::Error::InvalidArgument(_)),
"{class:?}: {error}"
);
});
assert!(
device.transport().is_exhausted(),
"{class:?}: the geometry session must close"
);
}
}
#[test]
fn a_control_sweep_cannot_ask_for_more_bytes_than_a_transfer_carries() {
let sweep = |len: &str| {
crate::Cli::try_parse_from(["nord", "device", "controls", "--len", len]).is_ok()
};
assert!(sweep("65535"));
assert!(!sweep("65536"));
assert!(!sweep("4294967296"));
}
#[test]
fn a_rescued_slot_is_named_for_its_location_and_format() {
let mut file = vec![0u8; 45];
file[0..4].copy_from_slice(b"CBIN");
file[4..8].copy_from_slice(&1u32.to_le_bytes());
file[8..12].copy_from_slice(b"ne5p");
let at = Location { bank: 6, slot: 49 };
assert_eq!(rescue_name(at, &file), "nord-rescued-7-50.ne5p");
}
#[test]
fn a_move_preflight_names_each_set_list_and_what_it_points_at() {
let ui = Ui::new(crate::ui::ColorChoice::Never);
let lines = set_list_rewrite_lines(
&ui,
&[
op::Referrer {
at: Location { bank: 0, slot: 42 },
name: "Factory Set".into(),
version: 1,
programs: vec![Location { bank: 0, slot: 6 }],
},
op::Referrer {
at: Location { bank: 1, slot: 6 },
name: "Friday".into(),
version: 1,
programs: vec![Location { bank: 0, slot: 6 }, Location { bank: 6, slot: 9 }],
},
],
);
assert_eq!(lines.len(), 3);
assert!(lines[0].contains("2 set lists"), "{}", lines[0]);
assert!(
lines[1].contains("setlist 1:43 \"Factory Set\""),
"{}",
lines[1]
);
assert!(lines[1].contains("points at 1:7"), "{}", lines[1]);
assert!(lines[2].contains("points at 1:7, 7:10"), "{}", lines[2]);
assert!(!lines.iter().any(|l| l.contains("VERSION 0")));
}
#[test]
fn a_version_zero_set_list_says_the_rewrite_cannot_be_undone() {
let ui = Ui::new(crate::ui::ColorChoice::Never);
let lines = set_list_rewrite_lines(
&ui,
&[op::Referrer {
at: Location { bank: 0, slot: 42 },
name: "Factory Set".into(),
version: 0,
programs: vec![Location { bank: 0, slot: 6 }],
}],
);
assert_eq!(lines.len(), 4);
assert!(lines[0].contains("1 set list "), "{}", lines[0]);
assert!(
lines[1].contains("setlist 1:43 \"Factory Set\""),
"{}",
lines[1]
);
assert!(lines[2].contains("VERSION 0"), "{}", lines[2]);
assert!(
lines[2].contains("migrates it to version 1"),
"{}",
lines[2]
);
assert!(
lines[3].contains("does not migrate the set list back"),
"{}",
lines[3]
);
}
#[test]
fn no_referrer_is_stated_rather_than_left_silent() {
let ui = Ui::new(crate::ui::ColorChoice::Never);
assert_eq!(
set_list_rewrite_lines(&ui, &[]),
vec!["no set list references either slot".to_string()]
);
}
#[test]
fn a_failed_write_says_what_it_left_in_the_slot() {
let at = Location { bank: 0, slot: 1 };
assert_eq!(
aftermath(ObjectClass::Program, at),
"bank 1 slot 2 is empty"
);
assert_eq!(
aftermath(ObjectClass::Live, at),
"bank 1 slot 2 may hold a partly written body"
);
assert_eq!(
aftermath(ObjectClass::Settings, at),
"bank 1 slot 2 may hold a partly written body"
);
}
#[test]
fn a_write_of_another_familys_file_is_refused_and_names_both() {
let refused = admit(Some("Nord Electro 5D"), ObjectClass::Program, "ns4p");
let Admit::Refuse(why) = refused else {
panic!("{refused:?}");
};
assert!(why.contains("Stage 4"), "{why}");
assert!(why.contains("Nord Electro 5D"), "{why}");
}
#[test]
fn only_a_measured_write_is_silent_and_the_rest_warns() {
let warning = |product, class, tag| match admit(product, class, tag) {
Admit::Warn(why) => why,
other => panic!("{other:?}"),
};
assert_eq!(
admit(Some("Nord Electro 5D"), ObjectClass::Program, "ne5p"),
Admit::Takes,
"a row written and read back says nothing"
);
let untried = warning(Some("Nord Stage 2 EX"), ObjectClass::Program, "ns2p");
assert!(untried.contains("untried"), "{untried}");
let unnamed = warning(Some("Nord Modular G2"), ObjectClass::Program, "ne5p");
assert!(unnamed.contains("not in the acceptance table"), "{unnamed}");
let shared = warning(Some("Nord Electro 5D"), ObjectClass::Program, "npno");
assert!(shared.contains("npno"), "{shared}");
let silent = warning(None, ObjectClass::Program, "ne5p");
assert!(silent.contains("no product string"), "{silent}");
}
#[test]
fn the_format_tag_comes_from_the_bytes() {
let mut file = vec![0u8; 45];
file[8..12].copy_from_slice(b"ne5t");
let at = Location { bank: 0, slot: 3 };
assert_eq!(rescue_name(at, &file), "nord-rescued-1-4.ne5t");
}
#[test]
fn unparseable_bytes_still_get_rescued() {
let at = Location { bank: 0, slot: 0 };
assert_eq!(rescue_name(at, b"nonsense"), "nord-rescued-1-1.bin");
}
mod losing_the_occupant {
use super::*;
use nord_usb::transport::{Direction, ReplayTransport, Script, Step};
use nord_usb::wire::Message;
const PUT: &str =
include_str!("../../nord-usb/tests/scripts/program/put_7-10_overwrite.script");
const FILE: &[u8] = include_bytes!("../../nord-usb/tests/scripts/program/prog_8-14.ne5p");
const GEOMETRY: &str = include_str!("../../nord-usb/tests/scripts/device/geometry.script");
const AT: Location = Location { bank: 6, slot: 9 };
const NAME: &str = "prog-8-14";
const STAMP: u32 = 0x6a89_f433;
fn recorded() -> Vec<Vec<Step>> {
let steps = |text| {
Script::parse(text)
.expect("a recorded exchange parses")
.sections
.into_iter()
.map(|section| section.steps)
.filter(|steps: &Vec<Step>| !steps.is_empty())
.collect::<Vec<_>>()
};
let mut out = steps(PUT);
out.splice(3..3, steps(GEOMETRY));
out
}
fn refused_write(steps: &[Step], status: u32) -> Vec<Step> {
let mut refusal = Message::decode_response(&steps[6].bytes).expect("the reply");
refusal.args[..4].copy_from_slice(&status.to_be_bytes());
let mut out = steps[..6].to_vec();
out.push(Step {
direction: Direction::In,
bytes: refusal.encode(),
});
out.extend_from_slice(&steps[12..]);
out
}
fn send_over(steps: Vec<Step>, spill_into: &Path) -> Result<(), String> {
let mut device = Device::new(ReplayTransport::new(steps));
send_with(
&Ui::piped(),
&mut device,
spill_into,
FILE,
AT,
ObjectClass::Program,
true,
"prog_8-14.ne5p",
Some(NAME),
Some(STAMP),
)
}
fn rescued(dir: &Path) -> Vec<String> {
std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect()
}
#[test]
fn a_refused_write_restores_the_occupant() {
let dir = crate::edit::tests::scratch("send-restored");
let put = recorded();
let mut steps: Vec<Step> = put[..5].concat();
steps.extend(refused_write(&put[5], 4));
steps.extend(put[5].clone());
let err = send_over(steps, &dir).unwrap_err();
assert!(err.contains("is occupied"), "{err}");
assert!(err.contains("was restored, and is unchanged"), "{err}");
assert_eq!(rescued(&dir), Vec::<String>::new());
}
#[test]
fn a_refused_restore_leaves_the_occupant_on_disk() {
let dir = crate::edit::tests::scratch("send-rescued");
let put = recorded();
let refused = refused_write(&put[5], 4);
let mut steps: Vec<Step> = put[..5].concat();
steps.extend(refused.clone());
steps.extend(refused);
let err = send_over(steps, &dir).unwrap_err();
assert!(err.contains("restoring failed as well"), "{err}");
assert!(err.contains("were saved to"), "{err}");
assert_eq!(rescued(&dir), ["nord-rescued-7-10.ne5p"]);
let saved = std::fs::read(dir.join("nord-rescued-7-10.ne5p")).unwrap();
assert!(nord_usb::envelope::unwrap(&saved).is_ok());
}
#[test]
fn a_delete_that_fails_after_it_landed_spills_the_occupant() {
let dir = crate::edit::tests::scratch("send-delete-fails");
let put = recorded();
let mut steps: Vec<Step> = put[..4].concat();
steps.extend_from_slice(&put[4][..7]);
let err = send_over(steps, &dir).unwrap_err();
assert!(err.contains("may have been deleted"), "{err}");
assert!(err.contains("were saved to"), "{err}");
assert_eq!(rescued(&dir), ["nord-rescued-7-10.ne5p"]);
}
#[test]
fn a_refused_delete_leaves_the_occupant_where_it_is() {
let dir = crate::edit::tests::scratch("send-delete-refused");
let put = recorded();
let mut delete = put[4][..7].to_vec();
let mut refusal = Message::decode_response(&delete[6].bytes).expect("the reply");
refusal.args[..4].copy_from_slice(&3u32.to_be_bytes());
delete[6].bytes = refusal.encode();
delete.extend_from_slice(&put[4][7..]);
let mut steps: Vec<Step> = put[..4].concat();
steps.extend(delete);
let err = send_over(steps, &dir).unwrap_err();
assert!(err.contains("out of range"), "{err}");
assert!(!err.contains("saved to"), "{err}");
assert_eq!(rescued(&dir), Vec::<String>::new());
}
}
#[test]
fn a_swept_capture_keeps_the_words_it_was_described_with() {
assert_eq!(stem("split point C4").unwrap(), "split-point-C4");
assert_eq!(stem(" transpose +1 ").unwrap(), "transpose-+1");
assert_eq!(stem("organ vol 5 -> 6").unwrap(), "organ-vol-5-6");
}
#[test]
fn a_swept_name_cannot_leave_the_output_directory() {
assert_eq!(stem("../../etc/passwd").unwrap(), "etc-passwd");
assert_eq!(stem("rotary:fast").unwrap(), "rotary-fast");
assert_eq!(stem(".hidden").unwrap(), "hidden");
}
#[test]
fn an_answer_with_no_filename_in_it_is_refused() {
for bad in ["...", "/", " ", "?*", "-", "CON", "lpt1.txt"] {
assert!(stem(bad).is_err(), "{bad:?}");
}
}
}