1use crate::q_construct::QConstruct;
4
5#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct AprsIsLine {
48 pub source: String,
50 pub destination: String,
52 pub path: Vec<String>,
54 pub data: String,
56 pub qconstruct: Option<QConstruct>,
58}
59
60impl AprsIsLine {
61 #[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 #[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}