1use crate::error::AprsError;
4use crate::packet::MessageKind;
5
6pub const MAX_APRS_MESSAGE_TEXT_LEN: usize = 67;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct AprsMessage {
24 pub addressee: String,
26 pub text: String,
28 pub message_id: Option<String>,
30 pub reply_ack: Option<String>,
35}
36
37impl AprsMessage {
38 #[must_use]
41 pub fn kind(&self) -> MessageKind {
42 let addr = self.addressee.trim();
43 if classify_ack_rej(&self.text).is_some() {
46 return MessageKind::AckRej;
47 }
48 if addr.starts_with("NWS-")
50 || addr.starts_with("SKY-")
51 || addr.starts_with("CWA-")
52 || addr.starts_with("BOM-")
53 {
54 return MessageKind::NwsBulletin;
55 }
56 if let Some(rest) = addr.strip_prefix("BLN") {
58 if rest.len() == 1
59 && let Some(n) = rest.bytes().next()
60 && n.is_ascii_digit()
61 {
62 return MessageKind::Bulletin { number: n - b'0' };
63 }
64 if (1..=5).contains(&rest.len()) && rest.bytes().all(|b| b.is_ascii_alphanumeric()) {
66 return MessageKind::GroupBulletin {
67 group: rest.to_owned(),
68 };
69 }
70 }
71 MessageKind::Direct
72 }
73}
74
75#[must_use]
86pub fn classify_ack_rej(text: &str) -> Option<(bool, &str)> {
87 let trimmed = text.trim_end_matches(['\r', '\n', ' ']);
88 let (is_ack, rest) = if let Some(rest) = trimmed.strip_prefix("ack") {
89 (true, rest)
90 } else if let Some(rest) = trimmed.strip_prefix("rej") {
91 (false, rest)
92 } else {
93 return None;
94 };
95 if !(1..=5).contains(&rest.len()) {
96 return None;
97 }
98 if !rest.bytes().all(|b| b.is_ascii_alphanumeric()) {
99 return None;
100 }
101 Some((is_ack, rest))
102}
103
104pub fn parse_aprs_message(info: &[u8]) -> Result<AprsMessage, AprsError> {
112 if info.first() != Some(&b':') {
114 return Err(AprsError::InvalidFormat);
115 }
116
117 let addressee_raw = info.get(1..10).ok_or(AprsError::InvalidFormat)?;
119 let addressee = String::from_utf8_lossy(addressee_raw).trim().to_string();
120
121 if info.get(10) != Some(&b':') {
122 return Err(AprsError::InvalidFormat);
123 }
124
125 let body = info.get(11..).unwrap_or(&[]);
126 let body_str = String::from_utf8_lossy(body).into_owned();
127 let trimmed_body = body_str.trim_end_matches(['\r', '\n']);
128
129 let (text, message_id, reply_ack) = parse_message_trailer(trimmed_body);
135
136 Ok(AprsMessage {
137 addressee,
138 text,
139 message_id,
140 reply_ack,
141 })
142}
143
144fn parse_message_trailer(body: &str) -> (String, Option<String>, Option<String>) {
149 let Some(brace_idx) = body.rfind('{') else {
150 return (body.to_owned(), None, None);
151 };
152 let Some(after_brace) = body.get(brace_idx + 1..) else {
153 return (body.to_owned(), None, None);
154 };
155 let Some(prefix) = body.get(..brace_idx) else {
156 return (body.to_owned(), None, None);
157 };
158
159 if let Some(close_idx) = after_brace.find('}') {
161 let mm = after_brace.get(..close_idx).unwrap_or("");
162 let aa = after_brace.get(close_idx + 1..).unwrap_or("");
163 if (1..=5).contains(&mm.len())
164 && mm.bytes().all(|b| b.is_ascii_alphanumeric())
165 && (1..=5).contains(&aa.len())
166 && aa.bytes().all(|b| b.is_ascii_alphanumeric())
167 {
168 return (prefix.to_owned(), Some(mm.to_owned()), Some(aa.to_owned()));
169 }
170 }
171
172 if (1..=5).contains(&after_brace.len())
174 && after_brace.bytes().all(|b| b.is_ascii_alphanumeric())
175 {
176 return (prefix.to_owned(), Some(after_brace.to_owned()), None);
177 }
178
179 (body.to_owned(), None, None)
181}
182
183#[cfg(test)]
188mod tests {
189 use super::*;
190
191 type TestResult = Result<(), Box<dyn std::error::Error>>;
192
193 #[test]
196 fn parse_message_basic() -> TestResult {
197 let info = b":N0CALL :Hello World{123";
198 let msg = parse_aprs_message(info)?;
199 assert_eq!(msg.addressee, "N0CALL");
200 assert_eq!(msg.text, "Hello World");
201 assert_eq!(msg.message_id, Some("123".to_string()));
202 Ok(())
203 }
204
205 #[test]
206 fn parse_message_no_id() -> TestResult {
207 let info = b":KQ4NIT :Test message";
208 let msg = parse_aprs_message(info)?;
209 assert_eq!(msg.addressee, "KQ4NIT");
210 assert_eq!(msg.text, "Test message");
211 assert_eq!(msg.message_id, None);
212 Ok(())
213 }
214
215 #[test]
216 fn parse_message_ack() -> TestResult {
217 let info = b":N0CALL :ack123";
218 let msg = parse_aprs_message(info)?;
219 assert_eq!(msg.text, "ack123");
220 Ok(())
221 }
222
223 #[test]
224 fn parse_message_too_short() {
225 assert!(
226 parse_aprs_message(b":SHORT:hi").is_err(),
227 "short input rejected",
228 );
229 }
230
231 #[test]
232 fn parse_message_does_not_misinterpret_brace_in_text() -> TestResult {
233 let info = b":N0CALL :reply {soon}";
236 let msg = parse_aprs_message(info)?;
237 assert_eq!(msg.text, "reply {soon}");
238 assert_eq!(msg.message_id, None);
239 Ok(())
240 }
241
242 #[test]
243 fn parse_message_accepts_valid_id_with_text_containing_brace() -> TestResult {
244 let info = b":N0CALL :json {foo}{42";
245 let msg = parse_aprs_message(info)?;
246 assert_eq!(msg.text, "json {foo}");
247 assert_eq!(msg.message_id, Some("42".to_owned()));
248 Ok(())
249 }
250
251 #[test]
252 fn parse_message_reply_ack() -> TestResult {
253 let info = b":N0CALL :Hello back{3}7";
254 let msg = parse_aprs_message(info)?;
255 assert_eq!(msg.text, "Hello back");
256 assert_eq!(msg.message_id, Some("3".to_owned()));
257 assert_eq!(msg.reply_ack, Some("7".to_owned()));
258 Ok(())
259 }
260
261 #[test]
262 fn parse_message_plain_id_no_reply_ack() -> TestResult {
263 let info = b":N0CALL :Hello{3";
264 let msg = parse_aprs_message(info)?;
265 assert_eq!(msg.text, "Hello");
266 assert_eq!(msg.message_id, Some("3".to_owned()));
267 assert_eq!(msg.reply_ack, None);
268 Ok(())
269 }
270
271 #[test]
274 fn message_kind_direct() {
275 let msg = AprsMessage {
276 addressee: "N0CALL".to_owned(),
277 text: "hello".to_owned(),
278 message_id: None,
279 reply_ack: None,
280 };
281 assert_eq!(msg.kind(), MessageKind::Direct);
282 }
283
284 #[test]
285 fn message_kind_numeric_bulletin() {
286 let msg = AprsMessage {
287 addressee: "BLN3".to_owned(),
288 text: "event update".to_owned(),
289 message_id: None,
290 reply_ack: None,
291 };
292 assert_eq!(msg.kind(), MessageKind::Bulletin { number: 3 });
293 }
294
295 #[test]
296 fn message_kind_group_bulletin() {
297 let msg = AprsMessage {
298 addressee: "BLNWX".to_owned(),
299 text: "weather watch".to_owned(),
300 message_id: None,
301 reply_ack: None,
302 };
303 assert_eq!(
304 msg.kind(),
305 MessageKind::GroupBulletin {
306 group: "WX".to_owned()
307 }
308 );
309 }
310
311 #[test]
312 fn message_kind_nws_bulletin() {
313 let msg = AprsMessage {
314 addressee: "NWS-TOR".to_owned(),
315 text: "tornado warning".to_owned(),
316 message_id: None,
317 reply_ack: None,
318 };
319 assert_eq!(msg.kind(), MessageKind::NwsBulletin);
320 }
321
322 #[test]
323 fn message_kind_ack_rej_frame() {
324 let msg = AprsMessage {
325 addressee: "N0CALL".to_owned(),
326 text: "ack42".to_owned(),
327 message_id: None,
328 reply_ack: None,
329 };
330 assert_eq!(msg.kind(), MessageKind::AckRej);
331 }
332
333 #[test]
336 fn classify_ack_rej_ack_numeric() {
337 assert_eq!(classify_ack_rej("ack42"), Some((true, "42")));
338 assert_eq!(classify_ack_rej("ack1"), Some((true, "1")));
339 assert_eq!(classify_ack_rej("ack12345"), Some((true, "12345")));
340 assert_eq!(classify_ack_rej("ackABC"), Some((true, "ABC")));
341 }
342
343 #[test]
344 fn classify_ack_rej_rej() {
345 assert_eq!(classify_ack_rej("rej42"), Some((false, "42")));
346 assert_eq!(classify_ack_rej("rej1"), Some((false, "1")));
347 }
348
349 #[test]
350 fn classify_ack_rej_rejects_non_control() {
351 assert_eq!(classify_ack_rej("acknowledge receipt"), None);
352 assert_eq!(classify_ack_rej("rejection letter"), None);
353 assert_eq!(classify_ack_rej("ack with space"), None);
354 }
355
356 #[test]
357 fn classify_ack_rej_rejects_overlong_id() {
358 assert_eq!(classify_ack_rej("ack123456"), None);
359 }
360
361 #[test]
362 fn classify_ack_rej_rejects_empty_id() {
363 assert_eq!(classify_ack_rej("ack"), None);
364 assert_eq!(classify_ack_rej("rej"), None);
365 }
366
367 #[test]
368 fn classify_ack_rej_rejects_non_alnum() {
369 assert_eq!(classify_ack_rej("ack-12"), None);
370 assert_eq!(classify_ack_rej("ack 42"), None);
371 }
372
373 #[test]
374 fn classify_ack_rej_strips_trailing_whitespace() {
375 assert_eq!(classify_ack_rej("ack42\r\n"), Some((true, "42")));
376 }
377}