dstar_gateway_core/
hosts.rs1use std::collections::HashMap;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct HostEntry {
18 pub name: String,
20 pub address: String,
22 pub port: u16,
24}
25
26#[derive(Debug, Clone, Default)]
32pub struct HostFile {
33 entries: HashMap<String, HostEntry>,
34}
35
36impl HostFile {
37 #[must_use]
39 pub fn new() -> Self {
40 Self::default()
41 }
42
43 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 #[must_use]
84 pub fn lookup(&self, name: &str) -> Option<&HostEntry> {
85 self.entries.get(&name.to_ascii_uppercase())
86 }
87
88 pub fn insert(&mut self, entry: HostEntry) {
92 drop(self.entries.insert(entry.name.clone(), entry));
93 }
94
95 #[must_use]
97 pub fn len(&self) -> usize {
98 self.entries.len()
99 }
100
101 #[must_use]
103 pub fn is_empty(&self) -> bool {
104 self.entries.is_empty()
105 }
106
107 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}