dstar_gateway_core/
hosts.rs

1//! D-STAR reflector host file parser.
2//!
3//! Host files map reflector names (e.g. "REF030", "XRF012", "DCS003")
4//! to their network addresses and ports. The format is one entry per
5//! line: `name address port` or `name address` (Pi-Star format).
6//!
7//! Lines starting with `#` are comments. Empty lines are skipped.
8//!
9//! Sources for host files:
10//! - <https://hosts.pistar.uk/hosts/>
11//! - Local reflector operators
12
13use std::collections::HashMap;
14
15/// A resolved reflector host entry.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct HostEntry {
18    /// Reflector name (e.g. "REF030").
19    pub name: String,
20    /// Hostname or IP address.
21    pub address: String,
22    /// UDP port number.
23    pub port: u16,
24}
25
26/// Collection of host file entries keyed by reflector name.
27///
28/// Lookups are case-insensitive — the query is upper-cased before
29/// the `HashMap` lookup. Parsed insertion likewise upper-cases the
30/// name. Duplicate names use last-wins semantics.
31#[derive(Debug, Clone, Default)]
32pub struct HostFile {
33    entries: HashMap<String, HostEntry>,
34}
35
36impl HostFile {
37    /// Create an empty host file.
38    #[must_use]
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Parse host entries from text content.
44    ///
45    /// Supports two formats:
46    /// - 3 columns: `name address port`
47    /// - 2 columns: `name address` (port from `default_port`)
48    ///
49    /// Lines starting with `#` are comments. Empty lines skipped.
50    /// Unparseable ports fall back to `default_port` rather than
51    /// dropping the entry.
52    pub fn parse(&mut self, content: &str, default_port: u16) {
53        for line in content.lines() {
54            let line = line.trim();
55            if line.is_empty() || line.starts_with('#') {
56                continue;
57            }
58            let mut parts = line.split_whitespace();
59            let Some(name_raw) = parts.next() else {
60                continue;
61            };
62            let Some(address) = parts.next() else {
63                tracing::debug!(line, "host file: line has fewer than 2 fields, skipped");
64                continue;
65            };
66            let port = parts
67                .next()
68                .and_then(|p| p.parse::<u16>().ok())
69                .unwrap_or(default_port);
70            let name = name_raw.to_ascii_uppercase();
71            drop(self.entries.insert(
72                name.clone(),
73                HostEntry {
74                    name,
75                    address: address.to_owned(),
76                    port,
77                },
78            ));
79        }
80    }
81
82    /// Look up an entry by name (case-insensitive).
83    #[must_use]
84    pub fn lookup(&self, name: &str) -> Option<&HostEntry> {
85        self.entries.get(&name.to_ascii_uppercase())
86    }
87
88    /// Insert a host entry directly. Uses `entry.name` verbatim as
89    /// the key, so callers should pass an upper-case name to remain
90    /// reachable via [`Self::lookup`].
91    pub fn insert(&mut self, entry: HostEntry) {
92        drop(self.entries.insert(entry.name.clone(), entry));
93    }
94
95    /// Number of entries in the host file.
96    #[must_use]
97    pub fn len(&self) -> usize {
98        self.entries.len()
99    }
100
101    /// True if the host file has zero entries.
102    #[must_use]
103    pub fn is_empty(&self) -> bool {
104        self.entries.is_empty()
105    }
106
107    /// Iterate all entries.
108    pub fn iter(&self) -> impl Iterator<Item = &HostEntry> + '_ {
109        self.entries.values()
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    type TestResult = Result<(), Box<dyn std::error::Error>>;
118
119    #[test]
120    fn parse_three_column_format() -> TestResult {
121        let mut hf = HostFile::new();
122        hf.parse("REF001 ref001.dstargateway.org 20001\n", 0);
123        let entry = hf.lookup("REF001").ok_or("REF001 present")?;
124        assert_eq!(entry.name, "REF001");
125        assert_eq!(entry.address, "ref001.dstargateway.org");
126        assert_eq!(entry.port, 20001);
127        Ok(())
128    }
129
130    #[test]
131    fn parse_two_column_format_uses_default_port() -> TestResult {
132        let mut hf = HostFile::new();
133        hf.parse("XRF012 xrf012.dstar.su\n", 30001);
134        let entry = hf.lookup("XRF012").ok_or("XRF012 present")?;
135        assert_eq!(entry.port, 30001);
136        Ok(())
137    }
138
139    #[test]
140    fn parse_skips_comments() {
141        let mut hf = HostFile::new();
142        hf.parse("# this is a comment\nREF001 a 20001\n", 0);
143        assert!(hf.lookup("REF001").is_some());
144        assert_eq!(hf.len(), 1);
145    }
146
147    #[test]
148    fn parse_skips_empty_lines() {
149        let mut hf = HostFile::new();
150        hf.parse("\n\nREF001 a 20001\n\n", 0);
151        assert_eq!(hf.len(), 1);
152    }
153
154    #[test]
155    fn lookup_is_case_insensitive() {
156        let mut hf = HostFile::new();
157        hf.parse("REF001 a 20001\n", 0);
158        assert!(hf.lookup("ref001").is_some());
159        assert!(hf.lookup("Ref001").is_some());
160    }
161
162    #[test]
163    fn parse_duplicates_last_wins() -> TestResult {
164        let mut hf = HostFile::new();
165        hf.parse("REF001 first 20001\nREF001 second 20002\n", 0);
166        let entry = hf.lookup("REF001").ok_or("REF001 present")?;
167        assert_eq!(entry.address, "second");
168        Ok(())
169    }
170
171    #[test]
172    fn parse_unparseable_port_falls_back_to_default() -> TestResult {
173        let mut hf = HostFile::new();
174        hf.parse("REF001 a notaport\n", 12345);
175        let entry = hf.lookup("REF001").ok_or("REF001 present")?;
176        assert_eq!(entry.port, 12345);
177        Ok(())
178    }
179
180    #[test]
181    fn parse_short_line_skipped() {
182        let mut hf = HostFile::new();
183        hf.parse("REF001\n", 0);
184        assert_eq!(hf.len(), 0);
185    }
186}