//! 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, /// 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, /// Which kind `to` is, on the same terms. #[serde(default, skip_serializing_if = "Option::is_none")] pub to_kind: Option, /// 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, /// 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, /// 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, 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 { 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, /// 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, /// Peers the user has seen or added. #[serde(default)] pub peers: Vec, /// The MIDI connections between endpoints. Persisted, so a setup survives a reboot. #[serde(default)] pub routes: Vec, } /// 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> { 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 { 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> { 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 { 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| 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 { 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, } /// 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 { 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::(&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 { 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 { 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 { 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| 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" ); } }