1use std::cell::RefCell;
14use std::io::{IsTerminal, Write};
15use std::path::PathBuf;
16use std::rc::Rc;
17use std::time::Duration;
18
19use anyhow::{Context as _, Result, anyhow, bail};
20
21use umsh::ulcp::{FrameLink, UlcpDevice, UlcpDeviceConfig, UlcpError};
22
23use crate::command::capture::pcap::{PcapDirection, PcapWriter};
24use crate::output;
25
26const DISCOVERY_WINDOW: Duration = Duration::from_secs(2);
28
29const DISCOVERY_EXTENSION: Duration = Duration::from_secs(3);
33
34pub fn attach_config() -> UlcpDeviceConfig {
38 let profile = umsh::ulcp_wire::profiles::DEFAULT;
39 UlcpDeviceConfig::new(
40 profile.freq_khz,
41 profile.bw_hz,
42 profile.sf,
43 profile.cr_denom,
44 )
45}
46
47const MESH_RESPONSE_TIMEOUT: Duration = Duration::from_secs(200);
54
55pub fn mesh_attach_config(phy: UlcpDeviceConfig) -> UlcpDeviceConfig {
61 UlcpDeviceConfig {
62 response_timeout: MESH_RESPONSE_TIMEOUT,
63 ..phy
64 }
65}
66
67#[derive(Clone, Debug, PartialEq, Eq)]
70pub enum Target {
71 Serial {
72 port: String,
73 baud: u32,
74 },
75 Ble {
79 selector: String,
80 name: Option<String>,
81 },
82 Tcp {
86 host: String,
87 port: u16,
88 },
89 Mesh {
94 key: [u8; 32],
95 },
96}
97
98impl Target {
99 pub fn transport(&self) -> &'static str {
101 match self {
102 Self::Serial { .. } => "serial",
103 Self::Ble { .. } => "ble",
104 Self::Tcp { .. } => "tcp",
105 Self::Mesh { .. } => "mesh",
106 }
107 }
108
109 pub fn provisional_label(&self) -> String {
111 match self {
112 Self::Serial { port, .. } => {
113 port.rsplit('/').next().unwrap_or(port.as_str()).to_string()
114 }
115 Self::Ble { selector, name } => name.clone().unwrap_or_else(|| selector.clone()),
116 Self::Tcp { host, port } => format_endpoint(host, *port),
117 Self::Mesh { key } => umsh::core::PublicKey(*key).to_string(),
118 }
119 }
120}
121
122pub fn format_endpoint(host: &str, port: u16) -> String {
125 if host.contains(':') {
126 format!("[{host}]:{port}")
127 } else {
128 format!("{host}:{port}")
129 }
130}
131
132pub fn parse_endpoint(spec: &str) -> Result<(String, u16)> {
137 let spec = spec.trim();
138 let (host, port) = if let Some(rest) = spec.strip_prefix('[') {
139 let (host, rest) = rest
140 .split_once(']')
141 .ok_or_else(|| anyhow!("unterminated IPv6 literal in {spec:?}"))?;
142 let port = rest
143 .strip_prefix(':')
144 .ok_or_else(|| anyhow!("{spec:?} names no port (expected [host]:port)"))?;
145 (host, port)
146 } else {
147 spec.rsplit_once(':')
148 .ok_or_else(|| anyhow!("{spec:?} names no port (expected host:port)"))?
149 };
150 if host.is_empty() {
151 bail!("{spec:?} names no host");
152 }
153 let port: u16 = port
154 .parse()
155 .with_context(|| format!("{port:?} is not a port number"))?;
156 if port == 0 {
157 bail!("port 0 is not a destination");
158 }
159 Ok((host.to_string(), port))
160}
161
162pub enum AnyLink {
169 #[cfg(feature = "serial-radio")]
170 Serial(umsh::ulcp::SerialFrameLink<tokio_serial::SerialStream>),
171 #[cfg(feature = "ble-radio")]
172 Ble(umsh::ulcp::BleFrameLink),
173 Tcp(umsh::ulcp::SerialFrameLink<tokio::net::TcpStream>),
176 Mesh(umsh::ulcp_mesh::MeshFrameLink),
179 #[allow(dead_code)]
182 Unavailable,
183}
184
185impl FrameLink for AnyLink {
186 async fn send_frame(&mut self, frame: &[u8]) -> Result<(), UlcpError> {
187 match self {
188 #[cfg(feature = "serial-radio")]
189 Self::Serial(link) => link.send_frame(frame).await,
190 #[cfg(feature = "ble-radio")]
191 Self::Ble(link) => link.send_frame(frame).await,
192 Self::Tcp(link) => link.send_frame(frame).await,
193 Self::Mesh(link) => link.send_frame(frame).await,
194 Self::Unavailable => Err(UlcpError::Disconnected),
195 }
196 }
197
198 fn poll_recv_frame(
199 &mut self,
200 cx: &mut core::task::Context<'_>,
201 ) -> core::task::Poll<Result<Vec<u8>, UlcpError>> {
202 match self {
203 #[cfg(feature = "serial-radio")]
204 Self::Serial(link) => link.poll_recv_frame(cx),
205 #[cfg(feature = "ble-radio")]
206 Self::Ble(link) => link.poll_recv_frame(cx),
207 Self::Tcp(link) => link.poll_recv_frame(cx),
208 Self::Mesh(link) => link.poll_recv_frame(cx),
209 Self::Unavailable => core::task::Poll::Ready(Err(UlcpError::Disconnected)),
210 }
211 }
212}
213
214pub type FrameTap = Rc<RefCell<Option<PcapWriter>>>;
221
222pub fn new_tap() -> FrameTap {
223 Rc::new(RefCell::new(None))
224}
225
226pub struct SessionLink {
229 inner: AnyLink,
230 tap: FrameTap,
231}
232
233impl SessionLink {
234 pub fn new(inner: AnyLink, tap: FrameTap) -> Self {
235 Self { inner, tap }
236 }
237
238 fn record(&self, direction: PcapDirection, frame: &[u8]) -> std::io::Result<()> {
239 if let Some(writer) = self.tap.borrow_mut().as_mut() {
240 writer.write_ulcp(direction, frame)?;
241 }
242 Ok(())
243 }
244}
245
246impl FrameLink for SessionLink {
247 async fn send_frame(&mut self, frame: &[u8]) -> Result<(), UlcpError> {
248 self.record(PcapDirection::HostToDevice, frame)?;
249 self.inner.send_frame(frame).await
250 }
251
252 fn poll_recv_frame(
253 &mut self,
254 cx: &mut core::task::Context<'_>,
255 ) -> core::task::Poll<Result<Vec<u8>, UlcpError>> {
256 match self.inner.poll_recv_frame(cx) {
257 core::task::Poll::Ready(Ok(frame)) => {
258 match self.record(PcapDirection::DeviceToHost, &frame) {
259 Ok(()) => core::task::Poll::Ready(Ok(frame)),
260 Err(error) => core::task::Poll::Ready(Err(error.into())),
261 }
262 }
263 other => other,
264 }
265 }
266}
267
268pub struct Session {
275 pub device: UlcpDevice<SessionLink>,
276 pub target: Target,
277 pub label: String,
278 pub tap: FrameTap,
279}
280
281impl Session {
282 pub fn is_mesh(&self) -> bool {
284 matches!(self.target, Target::Mesh { .. })
285 }
286
287 pub async fn reconnect(self, trace: bool) -> Result<Self> {
293 let Self {
294 device,
295 target,
296 label,
297 tap,
298 } = self;
299 drop(device);
300 let link = open(&target).await?;
301 let mut device = attach_tapped(link, tap.clone()).await?;
302 if trace {
303 install_trace(&mut device);
304 }
305 Ok(Self {
306 device,
307 target,
308 label,
309 tap,
310 })
311 }
312}
313
314pub fn install_trace(device: &mut UlcpDevice<SessionLink>) {
316 device.set_frame_trace(Some(Box::new(|direction, line| {
317 eprintln!("trace {direction} {line}");
318 })));
319}
320
321pub async fn open(target: &Target) -> Result<AnyLink> {
323 match target {
324 Target::Serial { port, baud } => open_serial(port, *baud).await,
325 Target::Ble { selector, .. } => open_ble(selector).await,
326 Target::Tcp { host, port } => open_tcp(host, *port).await,
327 Target::Mesh { .. } => bail!("a mesh session cannot be reopened on its own"),
330 }
331}
332
333async fn open_tcp(host: &str, port: u16) -> Result<AnyLink> {
338 let endpoint = format_endpoint(host, port);
339 let stream = tokio::net::TcpStream::connect((host, port))
340 .await
341 .with_context(|| format!("connecting to {endpoint}"))?;
342 stream
343 .set_nodelay(true)
344 .with_context(|| format!("disabling Nagle on {endpoint}"))?;
345 Ok(AnyLink::Tcp(umsh::ulcp::SerialFrameLink::new(stream)))
346}
347
348#[cfg(feature = "serial-radio")]
354async fn open_serial(port: &str, baud: u32) -> Result<AnyLink> {
355 use tokio_serial::SerialPortBuilderExt as _;
356 let stream = tokio_serial::new(port, baud)
357 .open_native_async()
358 .with_context(|| format!("opening {port}"))?;
359 Ok(AnyLink::Serial(umsh::ulcp::SerialFrameLink::new(stream)))
360}
361
362#[cfg(not(feature = "serial-radio"))]
363async fn open_serial(_port: &str, _baud: u32) -> Result<AnyLink> {
364 bail!("this build has no serial support (build with the serial-radio feature)")
365}
366
367#[cfg(feature = "ble-radio")]
368async fn open_ble(selector: &str) -> Result<AnyLink> {
369 use umsh::ulcp::{BleFrameLink, BleFrameLinkConfig};
370 let link = BleFrameLink::connect(Some(selector), BleFrameLinkConfig::default())
371 .await
372 .with_context(|| format!("connecting to BLE radio {selector:?}"))?;
373 Ok(AnyLink::Ble(link))
374}
375
376#[cfg(not(feature = "ble-radio"))]
377async fn open_ble(_selector: &str) -> Result<AnyLink> {
378 bail!("this build has no BLE support (build with the ble-radio feature)")
379}
380
381async fn attach_tapped(link: AnyLink, tap: FrameTap) -> Result<UlcpDevice<SessionLink>> {
387 let device =
388 UlcpDevice::attach_administrative(SessionLink::new(link, tap), attach_config()).await?;
389 Ok(device)
390}
391
392pub async fn connect(target: Target, trace: bool) -> Result<Session> {
394 let tap = new_tap();
395 let link = open(&target).await?;
396 let mut device = attach_tapped(link, tap.clone()).await?;
397 if trace {
398 install_trace(&mut device);
399 }
400 let label = match device.device_name().await {
402 Ok(name) if !name.is_empty() => name,
403 _ => target.provisional_label(),
404 };
405 Ok(Session {
406 device,
407 target,
408 label,
409 tap,
410 })
411}
412
413#[derive(Clone, Debug, PartialEq, Eq)]
420pub struct Found {
421 pub id: String,
422 pub name: Option<String>,
423 pub rssi: Option<i16>,
424}
425
426#[cfg(feature = "ble-radio")]
428pub async fn scan(timeout: Duration) -> Result<Vec<Found>> {
429 let results = umsh::ulcp::BleFrameLink::scan(timeout)
430 .await
431 .context("scanning for BLE radios")?;
432 let mut found: Vec<Found> = results
433 .into_iter()
434 .map(|result| Found {
435 id: result.id,
436 name: result.name,
437 rssi: result.rssi,
438 })
439 .collect();
440 sort_found(&mut found);
441 Ok(found)
442}
443
444#[cfg(not(feature = "ble-radio"))]
446pub async fn scan(_timeout: Duration) -> Result<Vec<Found>> {
447 bail!("this build has no BLE support (build with the ble-radio feature)")
448}
449
450pub fn sort_found(found: &mut [Found]) {
456 found.sort_by(|left, right| {
457 let key = |entry: &Found| {
458 (
459 entry.name.is_none(),
460 entry.name.clone().unwrap_or_default(),
461 entry.id.clone(),
462 )
463 };
464 key(left).cmp(&key(right))
465 });
466}
467
468pub fn merge_found(into: &mut Vec<Found>, more: Vec<Found>) {
471 for entry in more {
472 match into.iter_mut().find(|existing| existing.id == entry.id) {
473 Some(existing) => {
474 existing.name = entry.name.or_else(|| existing.name.take());
475 existing.rssi = entry.rssi.or(existing.rssi);
476 }
477 None => into.push(entry),
478 }
479 }
480 sort_found(into);
481}
482
483pub fn render_found(found: &[Found]) {
485 if found.is_empty() {
486 println!("no ULCP radios found");
487 return;
488 }
489 let width = found.len().to_string().len();
490 for (index, entry) in found.iter().enumerate() {
491 let name = entry.name.as_deref().unwrap_or("(no name)");
492 match entry.rssi {
493 Some(rssi) => println!(
494 "{:>width$}) {name} {} rssi {rssi} dBm",
495 index + 1,
496 entry.id
497 ),
498 None => println!("{:>width$}) {name} {}", index + 1, entry.id),
499 }
500 }
501}
502
503impl From<&Found> for Target {
504 fn from(found: &Found) -> Self {
505 Target::Ble {
506 selector: found.id.clone(),
507 name: found.name.clone(),
508 }
509 }
510}
511
512#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
514pub enum Discovery {
515 #[default]
518 Auto,
519 Ask,
523}
524
525pub async fn discover(prefs: &Prefs, interactive: bool, how: Discovery) -> Result<Option<Target>> {
530 let mut seen = scan(DISCOVERY_WINDOW).await?;
531 let mut extended = false;
532
533 if let (Discovery::Auto, Some(saved)) = (how, &prefs.default_device) {
534 if let Some(found) = saved.find_in(&seen) {
535 return Ok(Some(Target::from(found)));
536 }
537 merge_found(&mut seen, scan(DISCOVERY_EXTENSION).await?);
538 extended = true;
539 if let Some(found) = saved.find_in(&seen) {
540 return Ok(Some(Target::from(found)));
541 }
542 output::warn(format!(
544 "default radio {} not found; discovering instead",
545 saved.display()
546 ));
547 }
548
549 if seen.is_empty() && !extended {
550 merge_found(&mut seen, scan(DISCOVERY_EXTENSION).await?);
551 }
552
553 choose(seen, interactive, how)
554}
555
556pub fn choose(found: Vec<Found>, interactive: bool, how: Discovery) -> Result<Option<Target>> {
560 match found.len() {
561 0 => Ok(None),
562 1 if how == Discovery::Auto => Ok(Some(Target::from(&found[0]))),
565 _ => {
566 render_found(&found);
567 if !interactive {
568 match how {
569 Discovery::Ask => bail!(
570 "choosing a radio needs a terminal; name one with --ble=SELECTOR instead"
571 ),
572 Discovery::Auto => bail!(
573 "{} ULCP radios are in range; name one with --ble=SELECTOR (or set a \
574 default with `default set`)",
575 found.len()
576 ),
577 }
578 }
579 let index = prompt_index(found.len())?;
580 Ok(index.map(|index| Target::from(&found[index])))
581 }
582 }
583}
584
585fn prompt_index(count: usize) -> Result<Option<usize>> {
588 loop {
589 print!("select radio [1-{count}, or blank to cancel]: ");
590 std::io::stdout().flush().ok();
591 let mut line = String::new();
592 if std::io::stdin().read_line(&mut line)? == 0 {
593 println!();
594 return Ok(None);
595 }
596 let line = line.trim();
597 if line.is_empty() {
598 return Ok(None);
599 }
600 match line.parse::<usize>() {
601 Ok(choice) if (1..=count).contains(&choice) => return Ok(Some(choice - 1)),
602 _ => eprintln!("expected a number from 1 to {count}"),
603 }
604 }
605}
606
607pub fn confirm(question: &str) -> Result<bool> {
609 if !std::io::stdin().is_terminal() {
610 return Ok(false);
611 }
612 print!("{question} [y/N]: ");
613 std::io::stdout().flush().ok();
614 let mut line = String::new();
615 if std::io::stdin().read_line(&mut line)? == 0 {
616 println!();
617 return Ok(false);
618 }
619 Ok(matches!(line.trim(), "y" | "Y" | "yes" | "Yes"))
620}
621
622#[derive(Clone, Debug, Default, PartialEq, Eq)]
632pub struct DefaultDevice {
633 pub selector: String,
634 pub name: Option<String>,
635}
636
637impl DefaultDevice {
638 pub fn display(&self) -> String {
639 match &self.name {
640 Some(name) => format!("{name:?} ({})", self.selector),
641 None => self.selector.clone(),
642 }
643 }
644
645 fn find_in<'a>(&self, found: &'a [Found]) -> Option<&'a Found> {
646 found
647 .iter()
648 .find(|entry| entry.id == self.selector)
649 .or_else(|| {
650 found.iter().find(|entry| {
651 entry
652 .name
653 .as_deref()
654 .is_some_and(|name| Some(name) == self.name.as_deref())
655 })
656 })
657 .or_else(|| {
658 found.iter().find(|entry| {
659 entry
660 .name
661 .as_deref()
662 .is_some_and(|name| name.contains(&self.selector))
663 })
664 })
665 }
666}
667
668#[derive(Clone, Debug, Default, PartialEq, Eq)]
671pub struct Prefs {
672 pub default_device: Option<DefaultDevice>,
673}
674
675impl Prefs {
676 pub fn parse(text: &str) -> Self {
677 let mut prefs = Self::default();
678 let mut selector = None;
679 let mut name = None;
680 for raw in text.lines() {
681 let line = raw.split('#').next().unwrap_or("").trim();
682 let Some((setting, value)) = line.split_once('=') else {
683 continue;
684 };
685 match setting.trim() {
686 "default-selector" => selector = Some(value.trim().to_string()),
687 "default-name" => name = Some(value.trim().to_string()),
688 _ => {}
691 }
692 }
693 if let Some(selector) = selector {
694 prefs.default_device = Some(DefaultDevice { selector, name });
695 }
696 prefs
697 }
698
699 pub fn render(&self) -> String {
700 let mut text = String::from("# umshctl preferences\n");
701 if let Some(device) = &self.default_device {
702 text.push_str(&format!("default-selector = {}\n", device.selector));
703 if let Some(name) = &device.name {
704 text.push_str(&format!("default-name = {name}\n"));
705 }
706 }
707 text
708 }
709
710 pub fn load() -> Self {
711 config_path()
712 .and_then(|path| std::fs::read_to_string(path).ok())
713 .map(|text| Self::parse(&text))
714 .unwrap_or_default()
715 }
716
717 pub fn store(&self) -> Result<PathBuf> {
718 let path =
719 config_path().ok_or_else(|| anyhow!("no HOME directory to store settings in"))?;
720 if let Some(parent) = path.parent() {
721 std::fs::create_dir_all(parent)
722 .with_context(|| format!("creating {}", parent.display()))?;
723 }
724 std::fs::write(&path, self.render())
725 .with_context(|| format!("writing {}", path.display()))?;
726 Ok(path)
727 }
728}
729
730fn state_dir() -> Option<PathBuf> {
733 if let Some(state) = std::env::var_os("XDG_STATE_HOME").filter(|value| !value.is_empty()) {
734 return Some(PathBuf::from(state).join("umsh"));
735 }
736 let home = std::env::var_os("HOME").filter(|value| !value.is_empty())?;
737 Some(PathBuf::from(home).join(".local/state/umsh"))
738}
739
740pub fn config_path() -> Option<PathBuf> {
741 state_dir().map(|dir| dir.join("umshctl.conf"))
742}
743
744pub fn history_path() -> Option<PathBuf> {
745 state_dir().map(|dir| dir.join("umshctl-history"))
746}
747
748pub fn admin_identity_path() -> Option<PathBuf> {
757 state_dir().map(|dir| dir.join("umshctl-admin.key"))
758}
759
760pub fn admin_counter_path() -> Option<PathBuf> {
761 state_dir().map(|dir| dir.join("umshctl-admin.counters"))
762}
763
764pub fn routes_path() -> Option<PathBuf> {
766 state_dir().map(|dir| dir.join("umshctl-routes"))
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772
773 fn found(id: &str, name: Option<&str>, rssi: Option<i16>) -> Found {
774 Found {
775 id: id.to_string(),
776 name: name.map(str::to_string),
777 rssi,
778 }
779 }
780
781 #[test]
782 fn a_mesh_target_names_itself_by_its_key() {
783 let target = Target::Mesh { key: [0xC4; 32] };
784 assert_eq!(target.transport(), "mesh");
785 assert_eq!(
788 target.provisional_label(),
789 umsh::core::PublicKey([0xC4; 32]).to_string()
790 );
791 }
792
793 #[test]
794 fn endpoints_round_trip_through_their_written_form() {
795 for (spec, host, port) in [
796 ("127.0.0.1:9000", "127.0.0.1", 9000u16),
797 ("localhost:9000", "localhost", 9000),
798 ("[::1]:9000", "::1", 9000),
799 ("[fe80::1%en0]:65535", "fe80::1%en0", 65535),
800 ] {
801 let parsed = parse_endpoint(spec).unwrap();
802 assert_eq!(parsed, (host.to_string(), port), "parsing {spec}");
803 assert_eq!(format_endpoint(host, port), spec, "rendering {spec}");
804 }
805 }
806
807 #[test]
808 fn an_endpoint_without_a_usable_port_is_refused() {
809 for spec in [
812 "127.0.0.1",
813 "localhost",
814 "[::1]",
815 "[::1:9000",
816 "127.0.0.1:",
817 "127.0.0.1:0",
818 "127.0.0.1:70000",
819 ":9000",
820 ] {
821 assert!(parse_endpoint(spec).is_err(), "{spec} should not parse");
822 }
823 }
824
825 #[test]
826 fn a_tcp_target_is_labeled_by_its_endpoint() {
827 let target = Target::Tcp {
828 host: "::1".into(),
829 port: 9000,
830 };
831 assert_eq!(target.transport(), "tcp");
832 assert_eq!(target.provisional_label(), "[::1]:9000");
833 }
834
835 #[test]
836 fn scan_results_sort_by_name_never_by_signal() {
837 let mut list = vec![
838 found("id-c", Some("T-Echo"), Some(-30)),
839 found("id-a", Some("Solar"), Some(-90)),
840 found("id-b", None, Some(-40)),
841 ];
842 sort_found(&mut list);
843 assert_eq!(
844 list.iter()
845 .map(|entry| entry.id.as_str())
846 .collect::<Vec<_>>(),
847 ["id-a", "id-c", "id-b"],
848 );
849
850 list[0].rssi = Some(-10);
852 let before = list.clone();
853 sort_found(&mut list);
854 assert_eq!(list, before);
855 }
856
857 #[test]
858 fn merging_a_second_scan_keeps_one_entry_per_radio() {
859 let mut list = vec![found("id-a", None, None)];
860 merge_found(&mut list, vec![found("id-a", Some("T-Echo"), Some(-55))]);
861 assert_eq!(list.len(), 1);
862 assert_eq!(list[0].name.as_deref(), Some("T-Echo"));
863 assert_eq!(list[0].rssi, Some(-55));
864 }
865
866 #[test]
867 fn a_single_radio_needs_no_chooser() {
868 let target = choose(
869 vec![found("id-a", Some("T-Echo"), None)],
870 false,
871 Discovery::Auto,
872 )
873 .unwrap()
874 .unwrap();
875 assert_eq!(
876 target,
877 Target::Ble {
878 selector: "id-a".into(),
879 name: Some("T-Echo".into())
880 }
881 );
882 }
883
884 #[test]
885 fn asking_explicitly_asks_even_about_a_single_radio() {
886 let error = choose(
889 vec![found("id-a", Some("T-Echo"), None)],
890 false,
891 Discovery::Ask,
892 )
893 .unwrap_err();
894 assert!(error.to_string().contains("terminal"), "{error}");
895 }
896
897 #[test]
898 fn several_radios_fail_loudly_without_a_terminal() {
899 let error = choose(
900 vec![
901 found("id-a", Some("T-Echo"), None),
902 found("id-b", Some("Solar"), None),
903 ],
904 false,
905 Discovery::Auto,
906 )
907 .unwrap_err();
908 assert!(error.to_string().contains("--ble"), "{error}");
909 }
910
911 #[test]
912 fn no_radios_is_not_an_error_here() {
913 assert_eq!(choose(Vec::new(), false, Discovery::Auto).unwrap(), None);
914 assert_eq!(choose(Vec::new(), false, Discovery::Ask).unwrap(), None);
915 }
916
917 #[test]
918 fn preferences_round_trip() {
919 let prefs = Prefs {
920 default_device: Some(DefaultDevice {
921 selector: "1234-ABCD".into(),
922 name: Some("UMSH T-Echo".into()),
923 }),
924 };
925 assert_eq!(Prefs::parse(&prefs.render()), prefs);
926 }
927
928 #[test]
929 fn preferences_ignore_comments_and_unknown_settings() {
930 let prefs = Prefs::parse(
931 "# comment\n\
932 default-selector = id-a # trailing\n\
933 mystery = 7\n",
934 );
935 assert_eq!(
936 prefs.default_device,
937 Some(DefaultDevice {
938 selector: "id-a".into(),
939 name: None
940 })
941 );
942 }
943
944 #[test]
945 fn the_saved_default_matches_by_id_then_by_name() {
946 let saved = DefaultDevice {
947 selector: "id-a".into(),
948 name: Some("UMSH T-Echo".into()),
949 };
950 let list = vec![
951 found("id-z", Some("UMSH T-Echo"), None),
952 found("id-a", Some("renamed"), None),
953 ];
954 assert_eq!(saved.find_in(&list).unwrap().id, "id-a");
956
957 assert_eq!(saved.find_in(&list[..1]).unwrap().id, "id-z");
959 assert_eq!(saved.find_in(&[]), None);
960 }
961}