use std::path::Path;
use nord_format::cbin::Cbin;
use nord_format::fields::{ControlKind, FieldError, Unit};
use nord_format::formats::ne5;
use nord_format::formats::ne5::program::Field;
use nord_format::{Entity, Live, Program, Settings};
use nord_usb::ObjectClass;
use crate::slot::Target;
use crate::ui::Ui;
use crate::EditArgs;
trait Editable {
fn fields(&self) -> Vec<Field>;
fn set_field(&mut self, path: &str, value: &str) -> Result<(), FieldError>;
}
impl Editable for Cbin<ne5::Program> {
fn fields(&self) -> Vec<Field> {
self.body.fields()
}
fn set_field(&mut self, path: &str, value: &str) -> Result<(), FieldError> {
self.body.set_field(path, value)
}
}
impl Editable for Cbin<ne5::Settings> {
fn fields(&self) -> Vec<Field> {
self.body.fields()
}
fn set_field(&mut self, path: &str, value: &str) -> Result<(), FieldError> {
self.body.set_field(path, value)
}
}
pub fn run(ui: &Ui, args: EditArgs, class: ObjectClass) -> Result<(), String> {
let target = args
.target
.as_deref()
.map(crate::slot::target)
.transpose()?;
let original = match &target {
Some(Target::File(path)) => {
std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?
}
Some(Target::Slot(at)) => crate::device::fetch(*at, class)?,
None => fresh(class)?,
};
let mut entity = nord_format::from_stream(&mut std::io::Cursor::new(&original))
.map_err(|e| e.to_string())?;
let staged = match (&mut entity, class) {
(Entity::Program(Program::Electro5(p)), ObjectClass::Program) => stage(ui, &args, p)?,
(Entity::Live(Live::Electro5(l)), ObjectClass::Live) => stage(ui, &args, l)?,
(Entity::Settings(Settings::Electro5(s)), ObjectClass::Settings) => stage(ui, &args, s)?,
_ => return Err(mismatch(&entity, class)),
};
let Some(changed) = staged else {
return Ok(());
};
if changed == 0 {
ui.note("no field changed; writing nothing");
return Ok(());
}
let edited = nord_format::to_bytes(&entity).map_err(|e| e.to_string())?;
print_byte_diff(ui, &original, &edited);
if args.dry_run {
ui.note("--dry-run: nothing written");
return Ok(());
}
match (target, args.out) {
(_, Some(out)) => write_file(ui, &out, &edited),
(Some(Target::File(path)), None) => {
ui.note(format!(
"about to {} {} in place",
ui.danger("overwrite"),
path.display()
));
ui.confirm(args.yes)?;
write_file(ui, &path, &edited)
}
(Some(Target::Slot(at)), None) => match class {
ObjectClass::Program => crate::device::send(
ui,
&edited,
at,
class,
args.yes,
"the edited program",
None,
None,
),
_ => Err(format!(
"writing {} back over USB is unproven; give -o a path to save the edit \
as a .{} file",
class.label(),
crate::file::tag(class).unwrap_or("bin"),
)),
},
(None, None) => {
Err("editing a fresh default needs -o: there is nothing to write back to".into())
}
}
}
fn fresh(class: ObjectClass) -> Result<Vec<u8>, String> {
let entity = match class {
ObjectClass::Program => Entity::Program(Program::Electro5(ne5::program::new(
(0, 0).try_into().map_err(|e| format!("{e}"))?,
))),
ObjectClass::Live => Entity::Live(Live::Electro5(ne5::live::new(
(0, 0).try_into().map_err(|e| format!("{e}"))?,
))),
ObjectClass::Settings => Entity::Settings(Settings::Electro5(ne5::settings::new())),
other => return Err(format!("edit does not exist for {}", other.label())),
};
nord_format::to_bytes(&entity).map_err(|e| e.to_string())
}
fn mismatch(entity: &Entity, class: ObjectClass) -> String {
let got = crate::file::entity_tag(entity);
format!(
"this command edits {} ({}); the target holds {got}{}",
class.label(),
crate::file::tag(class).unwrap_or("?"),
steer(got),
)
}
fn steer(tag: &str) -> &'static str {
match tag {
"ne5p" => " — try `nord program edit`",
"ne5l" => " — try `nord live edit`",
"ne5s" => " — try `nord settings edit`",
"nsmp" => " — try `nord sample edit`",
_ => "",
}
}
fn stage(ui: &Ui, args: &EditArgs, file: &mut impl Editable) -> Result<Option<usize>, String> {
if args.fields {
if !args.set.is_empty() {
return Err("--fields lists and writes nothing; drop it to apply --set".into());
}
list_fields(ui, file);
return Ok(None);
}
if args.set.is_empty() {
return Err("nothing to do: pass --set PATH=VALUE, or --fields to see what exists".into());
}
let before = file.fields();
for assignment in &args.set {
let (path, value) = assignment
.split_once('=')
.ok_or_else(|| format!("expected PATH=VALUE, got {assignment:?}"))?;
file.set_field(path.trim(), value)
.map_err(|e| e.to_string())?;
}
warn_on_sticky_pairs(ui, &args.set);
let after = file.fields();
Ok(Some(report_changes(ui, &before, &after)))
}
pub(crate) fn write_file(ui: &Ui, path: &Path, bytes: &[u8]) -> Result<(), String> {
std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display()))?;
ui.note(format!("wrote {} ({} bytes)", path.display(), bytes.len()));
Ok(())
}
const STICKY_PAIRS: [(&str, &str); 1] =
[("center_panel.transpose", "center_panel.transpose_enabled")];
fn warn_on_sticky_pairs(ui: &Ui, sets: &[String]) {
let paths: Vec<&str> = sets
.iter()
.filter_map(|s| s.split_once('=').map(|(p, _)| p.trim()))
.collect();
for (field, companion) in STICKY_PAIRS {
if paths.contains(&field) && !paths.contains(&companion) {
ui.warn(format!(
"{field} was set but {companion} was not; the instrument reads the pair, not \
either half alone",
));
}
}
}
fn report_changes(ui: &Ui, before: &[ne5::program::Field], after: &[ne5::program::Field]) -> usize {
let mut changed = 0;
for (b, a) in before.iter().zip(after) {
if b.display == a.display {
continue;
}
changed += 1;
ui.out(format!(
"{:<40} {} -> {}",
a.path,
b.display,
ui.bold(&a.display),
));
}
changed
}
fn checksum_bytes(file: &[u8]) -> Option<(std::ops::Range<usize>, &'static str)> {
if file.len() < 8 || &file[0..4] != nord_format::cbin::MAGIC {
return None;
}
match u32::from_le_bytes(file[4..8].try_into().unwrap()) {
0 => Some((file.len() - 2..file.len(), " (file crc16)")),
1 => Some((0x18..0x1c, " (body crc32)")),
_ => None,
}
}
pub(crate) fn print_byte_diff(ui: &Ui, before: &[u8], after: &[u8]) {
if before.len() != after.len() {
ui.warn(format!(
"length changed: {} -> {} bytes",
before.len(),
after.len()
));
return;
}
let checksum = checksum_bytes(after);
for (i, (b, a)) in before.iter().zip(after).enumerate() {
if b == a {
continue;
}
let note = match &checksum {
Some((at, label)) if at.contains(&i) => *label,
_ => "",
};
ui.out(ui.dim(format!(" byte {i:#06x} {b:#04x} -> {a:#04x}{note}")));
}
}
fn control(kind: ControlKind) -> String {
let unit = |u: Unit| match u {
Unit::Panel10 => "0-10",
Unit::Decibels => "dB",
Unit::Milliseconds => "ms",
Unit::Hertz => "Hz",
Unit::Bpm => "bpm",
Unit::ClockDivision => "clock",
Unit::Semitones => "semi",
Unit::Octaves => "oct",
Unit::Pan => "pan",
Unit::None => "",
};
match kind {
ControlKind::Toggle => "toggle".to_string(),
ControlKind::Selector => "selector".to_string(),
ControlKind::Knob(u) => format!("knob {}", unit(u)),
ControlKind::Bipolar(u) => format!("bipolar {}", unit(u)),
ControlKind::Shift(u) => format!("shift {}", unit(u)),
ControlKind::Drawbar => "drawbar".to_string(),
ControlKind::Morph => "morph".to_string(),
ControlKind::Pattern => "pattern".to_string(),
ControlKind::Reference => "library ref".to_string(),
ControlKind::Number => "number".to_string(),
}
}
fn list_fields(ui: &Ui, file: &impl Editable) {
ui.out(format!(
"{:<40} {:<12} {:<14} {:<28} {}",
"path", "bits", "control", "value", "accepts"
));
for f in file.fields() {
let accepts = match (f.spec.legal)() {
v if v.is_empty() => "stored bits, decimal or 0x…".to_string(),
v if v.len() > 12 => format!("{} .. {}", v.first().unwrap(), v.last().unwrap()),
v => v.join(", "),
};
let value = if f.value == f.display {
f.value.clone()
} else {
format!("{} {}", f.value, ui.dim(&f.display))
};
ui.out(format!(
"{:<40} {:<12} {:<14} {value:<28} {accepts}",
f.path,
f.spec.placement,
ui.dim(control(f.spec.control)),
));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_mismatched_target_steers_to_the_noun_that_edits_it() {
let live = Entity::Live(Live::Electro5(ne5::live::new((0, 0).try_into().unwrap())));
let err = mismatch(&live, ObjectClass::Program);
assert!(err.contains("nord live edit"), "{err}");
let program = Entity::Program(Program::Electro5(ne5::program::new(
(0, 0).try_into().unwrap(),
)));
let err = mismatch(&program, ObjectClass::Live);
assert!(err.contains("nord program edit"), "{err}");
let settings = Entity::Settings(Settings::Electro5(ne5::settings::new()));
let err = mismatch(&settings, ObjectClass::Program);
assert!(err.contains("nord settings edit"), "{err}");
assert!(steer("nsmp").contains("nord sample edit"));
for tag in ["ne5t", "npno", "zip"] {
assert_eq!(steer(tag), "", "{tag}");
}
}
}