use std::path::Path;
use nord_format::fields::{ControlKind, Field, Registry, Unit};
use nord_format::formats::ne5;
use nord_format::{Entity, Live, Program, Settings, Song};
use nord_usb::ObjectClass;
use crate::editors;
use crate::slot::Target;
use crate::ui::Ui;
use crate::EditArgs;
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, what) = match (&mut entity, class) {
(Entity::Program(Program::Electro5(p)), ObjectClass::Program) => {
(stage(ui, &args, p)?, "the edited program")
}
(Entity::Live(Live::Electro5(l)), ObjectClass::Live) => {
(stage(ui, &args, l)?, "the edited live slot")
}
(Entity::Settings(Settings::Electro5(s)), ObjectClass::Settings) => {
(stage(ui, &args, s)?, "the edited settings")
}
(Entity::Song(Song::Electro5(s)), ObjectClass::SetList) => (
editors::stage(ui, args.fields, &args.set, &mut editors::SongEditor(s))?,
"the edited set list",
),
_ => 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) => {
crate::device::send(ui, &edited, at, class, args.yes, what, None, None)
}
(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 first = |e| format!("{e}");
let entity = match class {
ObjectClass::Program => Entity::Program(Program::Electro5(ne5::program::new(
(0, 0).try_into().map_err(first)?,
))),
ObjectClass::Live => Entity::Live(Live::Electro5(ne5::live::new(
(0, 0).try_into().map_err(first)?,
))),
ObjectClass::Settings => Entity::Settings(Settings::Electro5(ne5::settings::new())),
ObjectClass::SetList => Entity::Song(Song::Electro5(ne5::song::new(
(0, 0).try_into().map_err(first)?,
ne5::song::DEFAULT_VERSION,
[(0, 0).try_into().map_err(first)?; 4],
))),
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 {
format!(
"this command edits {} ({}); the target holds {}{}",
class.label(),
crate::file::tag(class).unwrap_or("?"),
crate::file::entity_tag(entity),
steer(entity),
)
}
fn steer(entity: &Entity) -> &'static str {
match crate::file::entity_tag(entity) {
"ne5p" => " — try `nord program edit`",
"ne5l" => " — try `nord live edit`",
"ne5s" => " — try `nord settings edit`",
"ne5t" => " — try `nord setlist edit`",
"nsmp" => " — try `nord sample edit`",
_ if crate::file_edit::editable(entity) => " — try `nord edit`",
_ => "",
}
}
pub(crate) fn stage(
ui: &Ui,
args: &EditArgs,
file: &mut dyn Registry,
) -> 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: &[Field], after: &[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: &dyn Registry) {
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_command_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}");
let song = Entity::Song(Song::Electro5(ne5::song::new(
(0, 0).try_into().unwrap(),
ne5::song::DEFAULT_VERSION,
[(0, 0).try_into().unwrap(); 4],
)));
let err = mismatch(&song, ObjectClass::Program);
assert!(err.contains("nord setlist edit"), "{err}");
let stage = nord_format::from_stream(&mut std::io::Cursor::new(
crate::file_edit::tests::stage3_program(),
))
.unwrap();
let err = mismatch(&stage, ObjectClass::Program);
assert!(err.contains("nord edit"), "{err}");
assert_eq!(
steer(&nord_format::from_stream(&mut std::io::Cursor::new(pipe_library())).unwrap()),
"",
);
}
fn pipe_library() -> Vec<u8> {
let file = nord_format::cbin::Cbin {
header: nord_format::cbin::Header::new("npip", (0xffff, 0xffff), 1),
body: nord_format::cbin::RawBody(vec![0x5a; 16]),
};
let mut out = std::io::Cursor::new(Vec::new());
file.write_to(&mut out).unwrap();
out.into_inner()
}
}