aprs_is/
line.rs

1//! TNC2-format APRS-IS line parsing and formatting.
2
3use crate::q_construct::QConstruct;
4
5/// Parse an APRS-IS server line.
6///
7/// Returns `None` for comment/keepalive lines (starting with `#`),
8/// `Some(packet_str)` for APRS packet lines.
9#[must_use]
10pub fn parse_is_line(line: &str) -> Option<&str> {
11    let trimmed = line.trim_end_matches(['\r', '\n']);
12    if trimmed.is_empty() || trimmed.starts_with('#') {
13        None
14    } else {
15        Some(trimmed)
16    }
17}
18
19/// Format an APRS packet for transmission to APRS-IS.
20///
21/// Builds the `source>destination,path:data\r\n` string. The path
22/// elements are joined with commas.
23///
24/// **Note:** the APRS-IS server ignores / overwrites the Q-construct
25/// element in the path if one isn't present (it adds its own based on
26/// how the packet arrived). For explicit Q-construct handling use
27/// [`crate::format_is_packet_with_qconstruct`].
28#[must_use]
29pub fn format_is_packet(source: &str, destination: &str, path: &[&str], data: &str) -> String {
30    let mut packet = format!("{source}>{destination}");
31    for p in path {
32        packet.push(',');
33        packet.push_str(p);
34    }
35    packet.push(':');
36    packet.push_str(data);
37    packet.push_str("\r\n");
38    packet
39}
40
41/// A parsed APRS-IS packet line.
42///
43/// Wraps the fields of a `source>destination,path:data` line without
44/// interpreting the data portion. Use the parsers in the `aprs` crate
45/// to decode the APRS information field.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct AprsIsLine {
48    /// Source callsign as it appears on the wire.
49    pub source: String,
50    /// Destination callsign (APRS tocall).
51    pub destination: String,
52    /// Path elements (digipeaters + Q-construct + gate).
53    pub path: Vec<String>,
54    /// Raw information field (everything after the `:`).
55    pub data: String,
56    /// Parsed Q-construct if one is present in the path.
57    pub qconstruct: Option<QConstruct>,
58}
59
60impl AprsIsLine {
61    /// Parse an APRS-IS packet line. Returns `None` on malformed input
62    /// (missing `>` or `:`).
63    #[must_use]
64    pub fn parse(line: &str) -> Option<Self> {
65        let trimmed = line.trim_end_matches(['\r', '\n']);
66        let (header, data) = trimmed.split_once(':')?;
67        let (source, rest) = header.split_once('>')?;
68        let mut parts = rest.split(',');
69        let destination = parts.next()?.to_owned();
70        let path: Vec<String> = parts.map(str::to_owned).collect();
71        let qconstruct = path.iter().find_map(|p| QConstruct::from_path_element(p));
72        Some(Self {
73            source: source.to_owned(),
74            destination,
75            path,
76            data: data.to_owned(),
77            qconstruct,
78        })
79    }
80
81    /// `true` if any of the path elements is `NOGATE`, `RFONLY`,
82    /// `TCPIP`, or `TCPXX` (case-insensitive).
83    #[must_use]
84    pub fn has_no_gate_marker(&self) -> bool {
85        self.path.iter().any(|p| {
86            let upper = p.to_ascii_uppercase();
87            matches!(upper.as_str(), "NOGATE" | "RFONLY" | "TCPIP" | "TCPXX")
88        })
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    type TestResult = Result<(), Box<dyn std::error::Error>>;
97
98    #[test]
99    fn parse_comment_line() {
100        assert_eq!(parse_is_line("# javAPRSSrvr 4.2.0b05"), None);
101    }
102
103    #[test]
104    fn parse_empty_line() {
105        assert_eq!(parse_is_line(""), None);
106        assert_eq!(parse_is_line("\r\n"), None);
107    }
108
109    #[test]
110    fn parse_packet_line() {
111        let line = "N0CALL>APK005,WIDE1-1:!4903.50N/07201.75W-Test\r\n";
112        let result = parse_is_line(line);
113        assert_eq!(
114            result,
115            Some("N0CALL>APK005,WIDE1-1:!4903.50N/07201.75W-Test")
116        );
117    }
118
119    #[test]
120    fn format_packet_no_path() {
121        let pkt = format_is_packet("N0CALL", "APK005", &[], "!4903.50N/07201.75W-Test");
122        assert_eq!(pkt, "N0CALL>APK005:!4903.50N/07201.75W-Test\r\n");
123    }
124
125    #[test]
126    fn format_packet_with_path() {
127        let pkt = format_is_packet(
128            "N0CALL",
129            "APK005",
130            &["WIDE1-1", "qAR", "W1AW"],
131            "!4903.50N/07201.75W-Test",
132        );
133        assert_eq!(
134            pkt,
135            "N0CALL>APK005,WIDE1-1,qAR,W1AW:!4903.50N/07201.75W-Test\r\n"
136        );
137    }
138
139    #[test]
140    fn aprs_is_line_parse_basic() -> TestResult {
141        let line = "N0CALL>APK005,WIDE1-1,qAR,W1AW:!4903.50N/07201.75W-Test\r\n";
142        let parsed = AprsIsLine::parse(line).ok_or("parse failed")?;
143        assert_eq!(parsed.source, "N0CALL");
144        assert_eq!(parsed.destination, "APK005");
145        assert_eq!(parsed.path, vec!["WIDE1-1", "qAR", "W1AW"]);
146        assert_eq!(parsed.data, "!4903.50N/07201.75W-Test");
147        assert_eq!(parsed.qconstruct, Some(QConstruct::QAR));
148        Ok(())
149    }
150
151    #[test]
152    fn aprs_is_line_parse_no_path() -> TestResult {
153        let line = "N0CALL>APK005:!test\r\n";
154        let parsed = AprsIsLine::parse(line).ok_or("parse failed")?;
155        assert!(parsed.path.is_empty());
156        assert_eq!(parsed.qconstruct, None);
157        Ok(())
158    }
159
160    #[test]
161    fn aprs_is_line_parse_malformed_returns_none() {
162        assert!(AprsIsLine::parse("no header separator").is_none());
163        assert!(AprsIsLine::parse("only>destination no data").is_none());
164    }
165
166    #[test]
167    fn aprs_is_line_no_gate_marker_detection() -> TestResult {
168        let line = AprsIsLine::parse("A>B,NOGATE:data").ok_or("parse failed")?;
169        assert!(line.has_no_gate_marker());
170        let line = AprsIsLine::parse("A>B,WIDE1-1:data").ok_or("parse failed")?;
171        assert!(!line.has_no_gate_marker());
172        Ok(())
173    }
174}