midi-harbor/crates/core/src/config.rs
2026-09-28 13:59:10 -05:00

1018 lines
38 KiB
Rust

//! The persisted setup: endpoints, peers, routes and preferences.
use crate::endpoint::{Endpoint, EndpointKind, EndpointName, InvitationPolicy, KindTag};
use crate::ids::{EndpointId, PeerId, RouteId};
use crate::paths::Paths;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
/// Schema version written by this build.
pub const CURRENT_SCHEMA: u32 = 1;
/// Why configuration could not be loaded or saved.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
/// The file could not be read or written.
#[error("could not {operation} {path}: {source}")]
Io {
/// What was being attempted.
operation: &'static str,
/// Which file it concerned.
path: PathBuf,
/// The underlying failure.
#[source]
source: std::io::Error,
},
/// The document could not be serialised.
#[error("could not encode configuration: {0}")]
Encode(#[from] serde_yaml_ng::Error),
/// A document offered for import or reload could not be read as configuration.
///
/// The message is the parser's alone, because it always reaches the user inside a
/// `config_invalid` failure that already says what kind of problem this is.
#[error("{0}")]
Decode(serde_yaml_ng::Error),
/// A document offered for import or reload reads, but describes something impossible.
#[error("{0}")]
Invalid(String),
/// The stored schema is newer than this build understands.
#[error("configuration schema {found} is newer than this build supports ({CURRENT_SCHEMA})")]
SchemaTooNew {
/// The version found in the file.
found: u32,
},
}
/// A remembered network peer.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerConfig {
/// Stable identity for this peer.
///
/// Generated when absent, like an endpoint's, so a peer can be written by hand without one.
#[serde(default)]
pub id: PeerId,
/// The name the peer advertises.
pub name: String,
/// Addresses it was last reachable at, in `host:port` form.
#[serde(default)]
pub addresses: Vec<String>,
/// Whether invitations from this peer are accepted without asking.
#[serde(default)]
pub trusted: bool,
}
/// A persisted MIDI connection between two endpoints.
///
/// Endpoints are referenced by name rather than by identifier, so the stored document can be
/// read and edited by hand. Identity is not lost by doing so: renaming an endpoint through the
/// daemon rewrites every route that names it, in the same operation. A name that matches nothing
/// leaves the route visible and marked broken rather than silently dropped.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RouteConfig {
/// The name of the endpoint MIDI comes from.
pub from: String,
/// The name of the endpoint MIDI goes to.
pub to: String,
/// Which kind `from` is, written only when another kind has an endpoint of that name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from_kind: Option<KindTag>,
/// Which kind `to` is, on the same terms.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to_kind: Option<KindTag>,
/// Which of the source's MIDI In connectors MIDI comes from, counting from one. Written only
/// for a virtual port with more than one; absent means the first.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from_connector: Option<u8>,
/// Which of the destination's MIDI Out connectors MIDI goes to, on the same terms.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to_connector: Option<u8>,
/// Whether it also carries MIDI back, from the destination's MIDI In of the same number to
/// the source's MIDI Out of the same number, as one route (FR-034a).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub both_ways: bool,
/// Whether the user wants it delivering.
#[serde(default = "default_true")]
pub enabled: bool,
}
/// Namespace for deriving stable route identifiers from the endpoints a route joins.
const ROUTE_NAMESPACE: uuid::Uuid = uuid::Uuid::from_bytes([
0x6d, 0x69, 0x64, 0x69, 0x68, 0x61, 0x72, 0x62, 0x6f, 0x72, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73,
]);
impl RouteConfig {
/// Returns this route's identity, derived from the pair of endpoints it joins.
///
/// Derived rather than stored, because the pair is already unique — duplicates are rejected —
/// so persisting an identifier would put a value in the file that the user cannot read and
/// must not edit. Deriving keeps it stable across restarts without writing it down.
pub fn id(&self) -> RouteId {
// Kinds join the key only when written, so every route that needs none keeps the
// identity it always had.
let mut key = if self.from_kind.is_none() && self.to_kind.is_none() {
format!("{}\u{1}{}", self.from, self.to)
} else {
format!(
"{}\u{1}{}\u{1}{:?}\u{1}{:?}",
self.from, self.to, self.from_kind, self.to_kind
)
};
// Connectors join the key on the same terms: only past the first, so a route written
// before ports had connectors keeps its identity.
let (from, to) = (self.from_index(), self.to_index());
if from > 0 || to > 0 {
key.push_str(&format!("\u{1}{from}\u{1}{to}"));
}
RouteId::from_uuid(uuid::Uuid::new_v5(&ROUTE_NAMESPACE, key.as_bytes()))
}
/// Returns which of the source's MIDI In connectors this route starts from, counting from
/// zero.
pub fn from_index(&self) -> u8 {
self.from_connector.unwrap_or(1).saturating_sub(1)
}
/// Returns which of the destination's MIDI Out connectors this route ends at, counting from
/// zero.
pub fn to_index(&self) -> u8 {
self.to_connector.unwrap_or(1).saturating_sub(1)
}
/// Reports whether `endpoint` is this route's source.
pub fn comes_from(&self, endpoint: &Endpoint) -> bool {
names(&self.from, self.from_kind, endpoint)
}
/// Reports whether `endpoint` is this route's destination.
pub fn goes_to(&self, endpoint: &Endpoint) -> bool {
names(&self.to, self.to_kind, endpoint)
}
/// Reports whether `endpoint` is either end of this route.
pub fn touches(&self, endpoint: &Endpoint) -> bool {
self.comes_from(endpoint) || self.goes_to(endpoint)
}
/// Reports whether this route joins the same two endpoints as `other`.
pub fn same_ends(&self, other: &RouteConfig) -> bool {
self.id() == other.id()
}
}
/// Reports whether a name, and a kind when one is written, designate `endpoint`.
fn names(name: &str, kind: Option<KindTag>, endpoint: &Endpoint) -> bool {
endpoint.name.as_str() == name && kind.is_none_or(|kind| endpoint.kind.tag() == kind)
}
/// Returns the kind to write beside `endpoint`'s name on a route, which is its own kind when
/// another endpoint shares its name and nothing otherwise.
pub fn kind_to_write(endpoints: &[Endpoint], endpoint: &Endpoint) -> Option<KindTag> {
endpoints
.iter()
.any(|other| other.id != endpoint.id && other.name == endpoint.name)
.then(|| endpoint.kind.tag())
}
/// Returns the default for fields that should be on unless stated otherwise.
fn default_true() -> bool {
true
}
/// Settings that are not about any one endpoint.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Preferences {
/// The name advertised for network sessions and Bluetooth advertising.
///
/// Absent means "use this computer's name", which is what other machines expect to see and
/// what distinguishes two machines on one network. Resolving it is the daemon's job, since
/// asking the operating system its name is not something this crate does.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub machine_name: Option<String>,
/// How invitations are treated when a session does not say.
#[serde(default)]
pub default_invitation_policy: InvitationPolicy,
/// Whether to advertise this computer as a Bluetooth LE MIDI peripheral.
#[serde(default)]
pub bluetooth_advertising: bool,
/// Whether network sessions are announced to other machines on the network.
///
/// Off, a session still works and can be connected to by address, but no machine browsing
/// the network sees it: for a network where announcing is unwelcome, and for test runs.
#[serde(default = "default_true")]
pub advertise_sessions: bool,
}
impl Default for Preferences {
fn default() -> Self {
Self {
machine_name: None,
default_invitation_policy: InvitationPolicy::default(),
bluetooth_advertising: false,
advertise_sessions: true,
}
}
}
/// The whole persisted setup.
///
/// Every MIDI connection the user has defined lives here — the endpoints themselves and the
/// routes between them — so the file is the complete description of a setup and is what
/// export and import move between machines.
///
/// Endpoints of every kind live in one list rather than four, because they already carry a kind
/// tag and share every other field. Splitting them would mean four parallel code paths for
/// validation, lookup and renaming, for no gain in the file's readability.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Configuration {
/// Which schema this document was written against.
#[serde(default = "current_schema")]
pub schema_version: u32,
/// Settings not tied to an endpoint.
#[serde(default)]
pub preferences: Preferences,
/// Every configured endpoint, of every kind.
#[serde(default)]
pub endpoints: Vec<Endpoint>,
/// Peers the user has seen or added.
#[serde(default)]
pub peers: Vec<PeerConfig>,
/// The MIDI connections between endpoints. Persisted, so a setup survives a reboot.
#[serde(default)]
pub routes: Vec<RouteConfig>,
}
/// Returns the schema version this build writes.
fn current_schema() -> u32 {
CURRENT_SCHEMA
}
impl Default for Configuration {
fn default() -> Self {
Self {
schema_version: CURRENT_SCHEMA,
preferences: Preferences::default(),
endpoints: Vec::new(),
peers: Vec::new(),
routes: Vec::new(),
}
}
}
impl Configuration {
/// Finds an endpoint by identity.
pub fn endpoint(&self, id: EndpointId) -> Option<&Endpoint> {
self.endpoints.iter().find(|e| e.id == id)
}
/// Finds an endpoint by name, optionally restricted to one kind.
///
/// Returns `Err` listing the candidates when the name is ambiguous, because silently picking
/// one would mean acting on hardware the user did not mean.
pub fn endpoint_by_name(
&self,
name: &str,
kind_slug: Option<&str>,
) -> Result<&Endpoint, Vec<EndpointId>> {
let matches: Vec<&Endpoint> = self
.endpoints
.iter()
.filter(|e| e.name.as_str() == name)
.filter(|e| kind_slug.is_none_or(|slug| e.kind.slug() == slug))
.collect();
match matches.as_slice() {
[only] => Ok(only),
many => Err(many.iter().map(|e| e.id).collect()),
}
}
/// Returns every endpoint of one kind.
pub fn endpoints_of_kind<'a>(&'a self, slug: &'a str) -> impl Iterator<Item = &'a Endpoint> {
self.endpoints.iter().filter(move |e| e.kind.slug() == slug)
}
/// Resolves a route's endpoints, returning the names that matched nothing.
///
/// A route naming a missing endpoint is reported rather than dropped, so the interface can
/// show it as broken and restore it if that endpoint comes back (FR-035).
pub fn resolve_route(
&self,
route: &RouteConfig,
) -> Result<(EndpointId, EndpointId), Vec<String>> {
let source = self
.endpoints
.iter()
.find(|e| e.name.as_str() == route.from);
let destination = self.endpoints.iter().find(|e| e.name.as_str() == route.to);
let mut missing = Vec::new();
if source.is_none() {
missing.push(route.from.clone());
}
if destination.is_none() {
missing.push(route.to.clone());
}
match (source, destination) {
(Some(from), Some(to)) if missing.is_empty() => Ok((from.id, to.id)),
_ => Err(missing),
}
}
/// Renames an endpoint and rewrites every route that names it.
///
/// Doing both in one operation is what keeps connections intact across a rename now that
/// routes reference names (FR-004).
pub fn rename_endpoint(&mut self, id: EndpointId, new_name: EndpointName) -> Option<String> {
let old_name = self
.endpoints
.iter()
.find(|e| e.id == id)?
.name
.as_str()
.to_owned();
if old_name == new_name.as_str() {
return Some(old_name);
}
// Only the routes that mean this endpoint: a route naming another kind's endpoint of the
// same name, or naming a name several endpoints share without saying which, is not about
// this one.
let renamed = self.endpoints.iter().find(|e| e.id == id)?.clone();
let shared = self
.endpoints
.iter()
.any(|other| other.id != id && other.name == renamed.name);
let means = |name: &str, kind: Option<KindTag>| match kind {
Some(kind) => name == old_name && kind == renamed.kind.tag(),
None => name == old_name && !shared,
};
for route in &mut self.routes {
if means(&route.from, route.from_kind) {
route.from = new_name.as_str().to_owned();
}
if means(&route.to, route.to_kind) {
route.to = new_name.as_str().to_owned();
}
}
for endpoint in &mut self.endpoints {
if endpoint.id == id {
endpoint.name = new_name.clone();
}
}
Some(old_name)
}
/// Adds an endpoint, first writing the kind onto any route that named an existing endpoint
/// by the name the new one shares.
pub fn add_endpoint(&mut self, endpoint: Endpoint) {
let before = self.endpoints.clone();
self.endpoints.push(endpoint);
self.qualify_routes(&before);
}
/// Writes the kind onto every route end whose name designated one endpoint in `before` and
/// now matches several.
///
/// Only then is it still known which endpoint the route meant. Left alone, a route to a
/// session named "Studio" would break the moment hardware of the same name was plugged in.
pub fn qualify_routes(&mut self, before: &[Endpoint]) {
let now = &self.endpoints;
let meant = |name: &str| {
let mut earlier = before.iter().filter(|e| e.name.as_str() == name);
let only = earlier.next().filter(|_| earlier.next().is_none())?;
let shared = now.iter().filter(|e| e.name.as_str() == name).count() > 1;
shared.then(|| only.kind.tag())
};
let mut qualified = Vec::new();
for (index, route) in self.routes.iter().enumerate() {
let from = route.from_kind.or_else(|| meant(&route.from));
let to = route.to_kind.or_else(|| meant(&route.to));
qualified.push((index, from, to));
}
for (index, from, to) in qualified {
if let Some(route) = self.routes.get_mut(index) {
route.from_kind = from;
route.to_kind = to;
}
}
}
/// Returns the virtual ports, which are what must be recreated on every startup.
pub fn virtual_ports(&self) -> impl Iterator<Item = &Endpoint> {
self.endpoints
.iter()
.filter(|e| matches!(e.kind, EndpointKind::VirtualPort(_)))
}
}
/// What happened while loading configuration.
#[derive(Debug)]
pub struct LoadOutcome {
/// The configuration to use.
pub config: Configuration,
/// Set when the stored file was unreadable and was preserved rather than lost.
pub repaired: Option<Repair>,
}
/// A record of configuration that could not be read.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repair {
/// Where the unreadable file was moved to.
pub preserved_at: PathBuf,
/// What was wrong with it.
pub reason: String,
}
/// Loads configuration, falling back to defaults when the file is missing or unreadable.
///
/// A file that cannot be parsed is never deleted and never silently replaced: it is renamed aside
/// so the user can recover their setup, and the caller is told so it can be reported.
pub fn load(paths: &Paths) -> Result<LoadOutcome, ConfigError> {
let path = paths.config_file();
// Absent configuration is the normal first-run case, not a problem.
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(LoadOutcome {
config: Configuration::default(),
repaired: None,
});
}
Err(source) => {
return Err(ConfigError::Io {
operation: "read",
path,
source,
});
}
};
match serde_yaml_ng::from_str::<Configuration>(&text) {
Ok(config) if config.schema_version > CURRENT_SCHEMA => {
// A newer schema is not corruption. Refusing loudly is safer than dropping fields we
// do not understand and then writing the file back without them.
Err(ConfigError::SchemaTooNew {
found: config.schema_version,
})
}
Ok(config) => Ok(LoadOutcome {
config,
repaired: None,
}),
Err(parse_error) => {
let preserved_at = preserve_unreadable(&path)?;
Ok(LoadOutcome {
config: Configuration::default(),
repaired: Some(Repair {
preserved_at,
reason: parse_error.to_string(),
}),
})
}
}
}
/// Reads a configuration document offered for import or reload.
///
/// Unlike `load`, nothing here falls back to defaults or moves a file aside. At startup an
/// unreadable file must not stop the daemon, but a document offered while it runs is a request to
/// change a working setup, and one that cannot be read is refused so the setup stays as it was.
pub fn parse(text: &str) -> Result<Configuration, ConfigError> {
let config: Configuration = serde_yaml_ng::from_str(text).map_err(ConfigError::Decode)?;
if config.schema_version > CURRENT_SCHEMA {
return Err(ConfigError::SchemaTooNew {
found: config.schema_version,
});
}
// Two entries under one name make every route to that name ambiguous, and two under one
// identifier make the entries themselves indistinguishable.
let mut seen_ids = std::collections::HashSet::new();
for endpoint in &config.endpoints {
if !seen_ids.insert(endpoint.id) {
return Err(ConfigError::Invalid(format!(
"two endpoints share the identifier {}",
endpoint.id
)));
}
}
check_names(&config)?;
Ok(config)
}
/// Refuses a setup in which two endpoints hold a name only one of them may have.
///
/// Every route to that name would be ambiguous, and two ports of one name cannot be told apart by
/// other applications.
pub fn check_names(config: &Configuration) -> Result<(), ConfigError> {
match crate::endpoint::name_clashes(&config.endpoints).first() {
None => Ok(()),
Some((first, second)) => Err(ConfigError::Invalid(clash_message(first, second))),
}
}
/// Describes two endpoints that hold one name.
pub fn clash_message(first: &Endpoint, second: &Endpoint) -> String {
if first.kind.slug() == second.kind.slug() {
format!(
"two {} endpoints are both named '{}'",
first.kind.slug(),
first.name
)
} else {
format!(
"a {} endpoint and a {} endpoint are both named '{}'",
first.kind.slug(),
second.kind.slug(),
first.name
)
}
}
/// Returns the document exactly as `save` would write it.
pub fn to_text(config: &Configuration) -> Result<String, ConfigError> {
Ok(serde_yaml_ng::to_string(config)?)
}
/// Writes configuration atomically, so an interrupted write cannot corrupt the stored setup.
///
/// The document is written to a temporary file in the same directory, flushed to disk, and then
/// renamed over the target. Rename within a directory is atomic, so a reader sees either the old
/// document or the new one and never a partial write.
pub fn save(paths: &Paths, config: &Configuration) -> Result<(), ConfigError> {
let dir = paths.config_dir();
fs::create_dir_all(dir).map_err(|source| ConfigError::Io {
operation: "create",
path: dir.to_path_buf(),
source,
})?;
let encoded = serde_yaml_ng::to_string(config)?;
let target = paths.config_file();
let temporary = dir.join(format!(
"{}.tmp-{}",
crate::paths::CONFIG_FILE,
std::process::id()
));
// Write and flush the whole document before anything points at it.
{
let mut file = fs::File::create(&temporary).map_err(|source| ConfigError::Io {
operation: "create",
path: temporary.clone(),
source,
})?;
file.write_all(encoded.as_bytes())
.map_err(|source| ConfigError::Io {
operation: "write",
path: temporary.clone(),
source,
})?;
file.sync_all().map_err(|source| ConfigError::Io {
operation: "flush",
path: temporary.clone(),
source,
})?;
}
fs::rename(&temporary, &target).map_err(|source| ConfigError::Io {
operation: "replace",
path: target,
source,
})
}
/// Moves an unreadable configuration file aside, returning where it was put.
fn preserve_unreadable(path: &Path) -> Result<PathBuf, ConfigError> {
let stamp = jiff::Timestamp::now().as_second();
let mut preserved = path.to_path_buf();
preserved.set_extension(format!("corrupt-{stamp}"));
fs::rename(path, &preserved).map_err(|source| ConfigError::Io {
operation: "preserve",
path: path.to_path_buf(),
source,
})?;
Ok(preserved)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::endpoint::{MAX_CONNECTORS, NetworkSession, PhysicalDevice, VirtualPort};
use crate::fingerprint::{DeviceFingerprint, MatchConfidence};
/// Creates a paths root under a unique temporary directory.
///
/// The shared root is emptied on first use in each run, so scratch from earlier runs does not
/// pile up.
fn temp_paths(label: &str) -> Paths {
static EMPTIED: std::sync::Once = std::sync::Once::new();
let shared = std::env::temp_dir().join("midi-harbor-tests");
EMPTIED.call_once(|| {
let _ = std::fs::remove_dir_all(&shared);
});
Paths::rooted_at(shared.join(format!("{label}-{}", uuid::Uuid::new_v4())))
}
fn name(text: &str) -> EndpointName {
EndpointName::new(text).expect("a valid endpoint name")
}
fn port(text: &str) -> Endpoint {
Endpoint::new(
name(text),
EndpointKind::VirtualPort(VirtualPort::default()),
)
}
fn session(text: &str, session: NetworkSession) -> Endpoint {
Endpoint::new(name(text), EndpointKind::NetworkSession(session))
}
fn hardware(text: &str) -> Endpoint {
Endpoint::new(
name(text),
EndpointKind::PhysicalDevice(PhysicalDevice {
fingerprint: DeviceFingerprint::from_name(text),
present: true,
confidence: MatchConfidence::Exact,
claimed_by: None,
software: false,
}),
)
}
fn route(from: &str, to: &str) -> RouteConfig {
RouteConfig {
from: from.to_owned(),
to: to.to_owned(),
from_kind: None,
to_kind: None,
from_connector: None,
to_connector: None,
both_ways: false,
enabled: true,
}
}
/// Returns the configuration with every generated identifier replaced by the nil UUID, so a
/// document read without identifiers can be compared whole.
fn without_ids(mut config: Configuration) -> Configuration {
for endpoint in &mut config.endpoints {
endpoint.id = EndpointId::from_uuid(uuid::Uuid::nil());
}
for peer in &mut config.peers {
peer.id = PeerId::from_uuid(uuid::Uuid::nil());
}
config
}
/// A route's identity is derived from its ends and never changes for a route that existed
/// before connectors did.
///
/// Identity is derived, not stored, so a change to the derivation gives every existing route
/// a new identifier and a client holding one loses track of it. The key of a route with no
/// kinds and first connectors is `from`, U+0001, `to`, hashed as a UUID v5 under
/// `ROUTE_NAMESPACE`, which is what routes were keyed by before connectors existed.
#[test]
fn a_route_identity_is_derived_from_its_ends_as_it_was_before_connectors() {
let before_connectors = RouteId::from_uuid(uuid::Uuid::new_v5(
&ROUTE_NAMESPACE,
"Keys\u{1}Synth".as_bytes(),
));
assert_eq!(
route("Keys", "Synth").id(),
before_connectors,
"a route without connectors must keep the identity derived before connectors"
);
let cases = [
(
"naming the first connectors is the route naming none",
RouteConfig {
from_connector: Some(1),
to_connector: Some(1),
..route("Keys", "Synth")
},
true,
),
(
"a second MIDI Out is another route",
RouteConfig {
to_connector: Some(2),
..route("Keys", "Synth")
},
false,
),
(
"the reverse direction is another route",
route("Synth", "Keys"),
false,
),
];
for (case, other, same) in cases {
assert_eq!(
other.id() == before_connectors,
same,
"{case}: identity must follow the ends and connectors, not the spelling"
);
}
}
/// A whole setup saved to disk loads back identical, written as block YAML a person can edit.
///
/// The file is the complete description of a setup and is what export and import carry, so
/// every field must survive: connector identifiers, a network port's peers and its automatic
/// port, routes and preferences. Flow style (`{...}`) is valid YAML but unreadable by hand.
#[test]
fn a_setup_round_trips_through_disk_as_block_yaml() {
let paths = temp_paths("round-trip");
let studio_pc = PeerId::new();
let beside = PeerId::new();
let original = Configuration {
preferences: Preferences {
machine_name: Some("Studio Mac".to_owned()),
default_invitation_policy: InvitationPolicy::AcceptKnown,
bluetooth_advertising: true,
advertise_sessions: false,
},
endpoints: vec![
Endpoint::new(
name("Sequencer Bus"),
EndpointKind::VirtualPort(VirtualPort {
input_ids: vec![11, 12],
output_ids: vec![21],
..VirtualPort::with_connectors(2, 1)
}),
),
session(
"Stage",
NetworkSession {
peer: Some(studio_pc),
other_peers: vec![beside],
automatic_port: false,
port_input_id: Some(7),
port_output_id: Some(8),
..NetworkSession::new(
name("Front of House"),
5104,
InvitationPolicy::AcceptAll,
)
},
),
],
peers: vec![PeerConfig {
id: studio_pc,
name: "Studio PC".to_owned(),
addresses: vec!["192.0.2.13:5004".to_owned()],
trusted: true,
}],
routes: vec![RouteConfig {
from_connector: Some(2),
both_ways: true,
enabled: false,
..route("Sequencer Bus", "Stage")
}],
..Configuration::default()
};
save(&paths, &original).expect("the configuration saves");
let loaded = load(&paths).expect("the configuration loads");
assert!(
loaded.repaired.is_none(),
"a file this build wrote must never be set aside as unreadable"
);
assert_eq!(
loaded.config, original,
"every field of the setup must survive a save and load"
);
let text = fs::read_to_string(paths.config_file()).expect("the file reads");
assert!(
!text.trim_start().starts_with('{'),
"the file must be block YAML a person can edit, not flow style: {text}"
);
}
/// A document written by hand, or by an older build, reads with every omitted field filled in
/// as the daemon would have filled it.
///
/// People write this file from scratch, so nothing in it may require a UUID or a field the
/// author would not think of, and files from before a rename or a new field must still load.
/// An unreadable file is set aside for an empty one at startup, which loses the whole setup,
/// so each of these refusing is the failure being guarded.
#[test]
fn hand_written_and_older_documents_read_with_their_gaps_filled() {
let earlier_peer =
PeerId::parse("6f1c2d3e-0000-4000-8000-000000000001").expect("a valid peer identifier");
let with = |endpoints: Vec<Endpoint>| Configuration {
endpoints,
..Configuration::default()
};
let cases = [
(
"endpoints and a route written with no identifiers",
"endpoints:\n\
- name: Sequencer Bus\n\
\x20 kind: virtual_port\n\
- name: Stage Laptop\n\
\x20 kind: network_session\n\
\x20 local_name: Studio Mac\n\
\x20 control_port: 5004\n\
routes:\n\
- from: Sequencer Bus\n\
\x20 to: Stage Laptop\n",
Configuration {
routes: vec![route("Sequencer Bus", "Stage Laptop")],
..with(vec![
port("Sequencer Bus"),
session(
"Stage Laptop",
NetworkSession::new(name("Studio Mac"), 5004, InvitationPolicy::Prompt),
),
])
},
),
(
"a peer written with no identifier",
"peers:\n\
- name: Studio PC\n\
\x20 addresses: [\"192.0.2.13:5004\"]\n\
\x20 trusted: true\n",
Configuration {
peers: vec![PeerConfig {
id: PeerId::from_uuid(uuid::Uuid::nil()),
name: "Studio PC".to_owned(),
addresses: vec!["192.0.2.13:5004".to_owned()],
trusted: true,
}],
..Configuration::default()
},
),
(
"a session written as a name and a kind advertises its name on a system port",
"endpoints:\n\
- name: Stage\n\
\x20 kind: network_session\n",
with(vec![session(
"Stage",
NetworkSession::new(name("Stage"), 0, InvitationPolicy::Prompt),
)]),
),
(
"a network port from before other peers has none",
"endpoints:\n\
- name: Stage\n\
\x20 kind: network_port\n\
\x20 control_port: 5004\n\
\x20 peer: 6f1c2d3e-0000-4000-8000-000000000001\n\
\x20 automatic_port: false\n",
with(vec![session(
"Stage",
NetworkSession {
peer: Some(earlier_peer),
automatic_port: false,
..NetworkSession::new(name("Stage"), 5004, InvitationPolicy::Prompt)
},
)]),
),
(
"an out-only port from before connectors gains a MIDI In and keeps its identifier",
"endpoints:\n\
- name: Keys\n\
\x20 kind: virtual_port\n\
\x20 platform_unique_id: 1234\n\
\x20 direction: output\n",
with(vec![Endpoint::new(
name("Keys"),
EndpointKind::VirtualPort(VirtualPort {
output_ids: vec![1234],
..VirtualPort::default()
}),
)]),
),
(
"connector counts out of range are kept between one and sixteen",
"endpoints:\n\
- name: Keys\n\
\x20 kind: virtual_port\n\
\x20 inputs: 0\n\
\x20 outputs: 40\n",
with(vec![Endpoint::new(
name("Keys"),
EndpointKind::VirtualPort(VirtualPort {
inputs: 1,
outputs: MAX_CONNECTORS,
..VirtualPort::default()
}),
)]),
),
];
for (case, text, want) in cases {
let read = parse(text).unwrap_or_else(|err| panic!("{case}: refused with {err}"));
assert_eq!(
without_ids(read),
without_ids(want),
"{case}: omitted fields must take the values the daemon would have given them"
);
}
}
/// A document read under an older spelling is written back in the current one, and says
/// nothing about fields it does not use.
///
/// Network sessions were renamed network ports, and a port's single identifier moved into
/// `output_ids` when ports gained connectors. Writing the old spellings back would keep them
/// alive forever; writing empty fields would clutter a file people read.
#[test]
fn older_spellings_are_written_back_in_the_current_form() {
let cases = [
(
"a session under its old kind name",
"endpoints:\n\
- name: Stage\n\
\x20 kind: network_session\n",
&["kind: network_port"][..],
&["network_session", "other_peers"][..],
),
(
"a port with the identifier it had before connectors",
"endpoints:\n\
- name: Keys\n\
\x20 kind: virtual_port\n\
\x20 platform_unique_id: 1234\n",
&["output_ids"][..],
&["platform_unique_id"][..],
),
(
"a route naming the old kind",
"routes:\n\
- from: Stage\n\
\x20 to: Keys\n\
\x20 from_kind: network_session\n",
&["from_kind: network_port"][..],
&["network_session"][..],
),
];
for (case, text, has, lacks) in cases {
let read = parse(text).unwrap_or_else(|err| panic!("{case}: refused with {err}"));
let written = to_text(&read).expect("the configuration encodes");
for wanted in has {
assert!(
written.contains(wanted),
"{case}: the current spelling {wanted:?} must be written: {written}"
);
}
for unwanted in lacks {
assert!(
!written.contains(unwanted),
"{case}: {unwanted:?} must not be written back: {written}"
);
}
}
}
/// A route keeps meaning the endpoint it meant when an endpoint of another kind arrives under
/// the same name, and renaming one of the two rewrites only the routes that mean it.
///
/// Hardware plugged in under a session's name would otherwise break the route to the session,
/// since a name several endpoints share designates none of them. The kind is written onto the
/// route while it is still known which endpoint the route meant.
#[test]
fn a_route_keeps_meaning_its_endpoint_when_another_kind_takes_the_name() {
let mut config = Configuration::default();
let studio = session(
"Studio",
NetworkSession::new(name("Studio"), 0, InvitationPolicy::Prompt),
);
let studio_id = studio.id;
config.add_endpoint(studio);
config.add_endpoint(port("Synth"));
config.routes.push(route("Studio", "Synth"));
config.add_endpoint(hardware("Studio"));
assert_eq!(
(config.routes[0].from_kind, config.routes[0].to_kind),
(Some(KindTag::NetworkSession), None),
"only the end whose name is now shared must be given the kind it meant"
);
config.routes.push(RouteConfig {
from_kind: Some(KindTag::PhysicalDevice),
..route("Studio", "Synth")
});
config.rename_endpoint(studio_id, name("Stage"));
assert_eq!(
config.routes[0].from, "Stage",
"the route meaning the session must follow its rename"
);
assert_eq!(
config.routes[1].from, "Studio",
"the route meaning the hardware must keep the name the hardware still has"
);
}
}