Skip to main content

qrz_xml/
types.rs

1//! Type definitions for QRZ API responses.
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6/// API version enum for specifying which version of the QRZ XML interface to use
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum ApiVersion {
9    /// Use the current/latest version
10    Current,
11    /// Use a specific version (e.g., "1.34")
12    Specific(String),
13    /// Use legacy version (no version specified, defaults to 1.24)
14    Legacy,
15}
16
17impl fmt::Display for ApiVersion {
18    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19        match self {
20            ApiVersion::Current => write!(f, "current"),
21            ApiVersion::Specific(version) => write!(f, "{}", version),
22            ApiVersion::Legacy => write!(f, ""),
23        }
24    }
25}
26
27impl ApiVersion {
28    /// Create a specific version
29    pub fn version(version: impl Into<String>) -> Self {
30        Self::Specific(version.into())
31    }
32}
33
34/// Root response container for all QRZ XML responses
35#[derive(Debug, Clone, Deserialize, Serialize)]
36#[serde(rename = "QRZDatabase")]
37pub struct QrzXmlResponse {
38    /// API version
39    #[serde(rename = "@version")]
40    pub version: Option<String>,
41
42    /// XML namespace
43    #[serde(rename = "@xmlns")]
44    pub xmlns: Option<String>,
45
46    /// Session information (always present)
47    #[serde(rename = "Session")]
48    pub session: SessionInfo,
49
50    /// Callsign information (present for callsign lookups)
51    #[serde(rename = "Callsign")]
52    pub callsign: Option<CallsignInfo>,
53
54    /// DXCC information (present for DXCC lookups)
55    #[serde(rename = "DXCC")]
56    pub dxcc: Option<DxccInfo>,
57}
58
59/// Session information and status
60#[derive(Debug, Clone, Deserialize, Serialize)]
61pub struct SessionInfo {
62    /// Session key for authenticated requests
63    #[serde(rename = "Key")]
64    pub key: Option<String>,
65
66    /// Number of lookups performed in current 24-hour period
67    #[serde(rename = "Count")]
68    pub count: Option<u32>,
69
70    /// Subscription expiration date or "non-subscriber"
71    #[serde(rename = "SubExp")]
72    pub sub_exp: Option<String>,
73
74    /// Current GMT time
75    #[serde(rename = "GMTime")]
76    pub gm_time: Option<String>,
77
78    /// Informational message
79    #[serde(rename = "Message")]
80    pub message: Option<String>,
81
82    /// Error message
83    #[serde(rename = "Error")]
84    pub error: Option<String>,
85}
86
87impl SessionInfo {
88    /// Check if session has a valid key
89    pub fn has_valid_session(&self) -> bool {
90        self.key.is_some()
91    }
92
93    /// Check if there's an error
94    pub fn has_error(&self) -> bool {
95        self.error.is_some()
96    }
97
98    /// Get the error message if present
99    pub fn error_message(&self) -> Option<&str> {
100        self.error.as_deref()
101    }
102
103    /// Get the informational message if present
104    pub fn info_message(&self) -> Option<&str> {
105        self.message.as_deref()
106    }
107}
108
109/// Comprehensive callsign information
110#[derive(Debug, Clone, Deserialize, Serialize)]
111pub struct CallsignInfo {
112    /// Primary callsign
113    #[serde(rename = "call")]
114    pub call: String,
115
116    /// Cross reference callsign that returned this record
117    #[serde(rename = "xref")]
118    pub xref: Option<String>,
119
120    /// Other callsigns that resolve to this record
121    #[serde(rename = "aliases")]
122    pub aliases: Option<String>,
123
124    /// DXCC entity ID (country code)
125    #[serde(rename = "dxcc")]
126    pub dxcc: Option<u32>,
127
128    /// First name
129    #[serde(rename = "fname")]
130    pub fname: Option<String>,
131
132    /// Last name
133    #[serde(rename = "name")]
134    pub name: Option<String>,
135
136    /// Address line 1 (house number and street)
137    #[serde(rename = "addr1")]
138    pub addr1: Option<String>,
139
140    /// Address line 2 (city)
141    #[serde(rename = "addr2")]
142    pub addr2: Option<String>,
143
144    /// State (USA only)
145    #[serde(rename = "state")]
146    pub state: Option<String>,
147
148    /// ZIP/postal code
149    #[serde(rename = "zip")]
150    pub zip: Option<String>,
151
152    /// Country name for QSL mailing address
153    #[serde(rename = "country")]
154    pub country: Option<String>,
155
156    /// DXCC entity code for mailing address country
157    #[serde(rename = "ccode")]
158    pub ccode: Option<u32>,
159
160    /// Latitude (signed decimal, S < 0 > N)
161    #[serde(rename = "lat")]
162    pub lat: Option<f64>,
163
164    /// Longitude (signed decimal, W < 0 > E)
165    #[serde(rename = "lon")]
166    pub lon: Option<f64>,
167
168    /// Grid locator
169    #[serde(rename = "grid")]
170    pub grid: Option<String>,
171
172    /// County name (USA)
173    #[serde(rename = "county")]
174    pub county: Option<String>,
175
176    /// FIPS county identifier (USA)
177    #[serde(rename = "fips")]
178    pub fips: Option<String>,
179
180    /// DXCC country name of the callsign
181    #[serde(rename = "land")]
182    pub land: Option<String>,
183
184    /// License effective date (USA)
185    #[serde(rename = "efdate")]
186    pub efdate: Option<String>,
187
188    /// License expiration date (USA)
189    #[serde(rename = "expdate")]
190    pub expdate: Option<String>,
191
192    /// Previous callsign
193    #[serde(rename = "p_call")]
194    pub p_call: Option<String>,
195
196    /// License class
197    #[serde(rename = "class")]
198    pub class: Option<String>,
199
200    /// License type codes (USA)
201    #[serde(rename = "codes")]
202    pub codes: Option<String>,
203
204    /// QSL manager info
205    #[serde(rename = "qslmgr")]
206    pub qslmgr: Option<String>,
207
208    /// Email address
209    #[serde(rename = "email")]
210    pub email: Option<String>,
211
212    /// Web page address
213    #[serde(rename = "url")]
214    pub url: Option<String>,
215
216    /// QRZ web page views
217    #[serde(rename = "u_views")]
218    pub u_views: Option<u32>,
219
220    /// Biography size in bytes
221    #[serde(rename = "bio")]
222    pub bio: Option<String>,
223
224    /// Biography last update date
225    #[serde(rename = "biodate")]
226    pub biodate: Option<String>,
227
228    /// Full URL of primary image
229    #[serde(rename = "image")]
230    pub image: Option<String>,
231
232    /// Image dimensions (height:width:size)
233    #[serde(rename = "imageinfo")]
234    pub imageinfo: Option<String>,
235
236    /// QRZ database serial number
237    #[serde(rename = "serial")]
238    pub serial: Option<u32>,
239
240    /// Last modified date
241    #[serde(rename = "moddate")]
242    pub moddate: Option<String>,
243
244    /// Metro Service Area (USPS)
245    #[serde(rename = "MSA")]
246    pub msa: Option<String>,
247
248    /// Telephone area code (USA)
249    #[serde(rename = "AreaCode")]
250    pub area_code: Option<String>,
251
252    /// Time zone (USA)
253    #[serde(rename = "TimeZone")]
254    pub time_zone: Option<String>,
255
256    /// GMT time offset
257    #[serde(rename = "GMTOffset")]
258    pub gmt_offset: Option<String>,
259
260    /// Daylight saving time observed
261    #[serde(rename = "DST")]
262    pub dst: Option<String>,
263
264    /// Will accept eQSL (Y/N or blank)
265    #[serde(rename = "eqsl")]
266    pub eqsl: Option<String>,
267
268    /// Will return paper QSL (Y/N or blank)
269    #[serde(rename = "mqsl")]
270    pub mqsl: Option<String>,
271
272    /// CQ Zone identifier
273    #[serde(rename = "cqzone")]
274    pub cqzone: Option<u32>,
275
276    /// ITU Zone identifier
277    #[serde(rename = "ituzone")]
278    pub ituzone: Option<u32>,
279
280    /// Operator's birth year
281    #[serde(rename = "born")]
282    pub born: Option<u32>,
283
284    /// User who manages this callsign on QRZ
285    #[serde(rename = "user")]
286    pub user: Option<String>,
287
288    /// Will accept LOTW (Y/N or blank)
289    #[serde(rename = "lotw")]
290    pub lotw: Option<String>,
291
292    /// IOTA designator
293    #[serde(rename = "iota")]
294    pub iota: Option<String>,
295
296    /// Source of lat/long data
297    #[serde(rename = "geoloc")]
298    pub geoloc: Option<String>,
299
300    /// Attention address line (new in v1.34)
301    #[serde(rename = "attn")]
302    pub attn: Option<String>,
303
304    /// Nickname (new in v1.34)
305    #[serde(rename = "nickname")]
306    pub nickname: Option<String>,
307
308    /// Combined full name and nickname (new in v1.34)
309    #[serde(rename = "name_fmt")]
310    pub name_fmt: Option<String>,
311}
312
313impl CallsignInfo {
314    /// Get the full name (combining first and last name)
315    pub fn full_name(&self) -> Option<String> {
316        match (&self.fname, &self.name) {
317            (Some(first), Some(last)) => Some(format!("{} {}", first, last)),
318            (Some(first), None) => Some(first.clone()),
319            (None, Some(last)) => Some(last.clone()),
320            (None, None) => None,
321        }
322    }
323
324    /// Get coordinates as a tuple (lat, lon) if both are present
325    pub fn coordinates(&self) -> Option<(f64, f64)> {
326        match (self.lat, self.lon) {
327            (Some(lat), Some(lon)) => Some((lat, lon)),
328            _ => None,
329        }
330    }
331
332    /// Check if QSL information indicates acceptance of eQSL
333    pub fn accepts_eqsl(&self) -> Option<bool> {
334        self.eqsl.as_ref().map(|s| s.eq_ignore_ascii_case("y"))
335    }
336
337    /// Check if QSL information indicates will return paper QSL
338    pub fn returns_paper_qsl(&self) -> Option<bool> {
339        self.mqsl.as_ref().map(|s| s.eq_ignore_ascii_case("y"))
340    }
341
342    /// Check if LOTW is accepted
343    pub fn accepts_lotw(&self) -> Option<bool> {
344        self.lotw.as_ref().map(|s| s.eq_ignore_ascii_case("y"))
345    }
346}
347
348/// DXCC entity information
349#[derive(Debug, Clone, Deserialize, Serialize)]
350pub struct DxccInfo {
351    /// DXCC entity number
352    #[serde(rename = "dxcc")]
353    pub dxcc: u32,
354
355    /// 2-letter country code (ISO-3166)
356    #[serde(rename = "cc")]
357    pub cc: Option<String>,
358
359    /// 3-letter country code (ISO-3166)
360    #[serde(rename = "ccc")]
361    pub ccc: Option<String>,
362
363    /// Long country name
364    #[serde(rename = "name")]
365    pub name: String,
366
367    /// 2-letter continent designator
368    #[serde(rename = "continent")]
369    pub continent: Option<String>,
370
371    /// ITU Zone
372    #[serde(rename = "ituzone")]
373    pub ituzone: Option<u32>,
374
375    /// CQ Zone
376    #[serde(rename = "cqzone")]
377    pub cqzone: Option<u32>,
378
379    /// UTC timezone offset +/-
380    #[serde(rename = "timezone")]
381    pub timezone: Option<String>,
382
383    /// Latitude (approximate center)
384    #[serde(rename = "lat")]
385    pub lat: Option<f64>,
386
387    /// Longitude (approximate center)
388    #[serde(rename = "lon")]
389    pub lon: Option<f64>,
390
391    /// Special notes and exceptions
392    #[serde(rename = "notes")]
393    pub notes: Option<String>,
394}
395
396impl DxccInfo {
397    /// Get coordinates as a tuple (lat, lon) if both are present
398    pub fn coordinates(&self) -> Option<(f64, f64)> {
399        match (self.lat, self.lon) {
400            (Some(lat), Some(lon)) => Some((lat, lon)),
401            _ => None,
402        }
403    }
404
405    /// Parse timezone offset as hours (may include fractions)
406    pub fn timezone_hours(&self) -> Option<f32> {
407        self.timezone.as_ref().and_then(|tz| {
408            // Handle formats like "+5", "-8", "545" (5 hours 45 minutes)
409            let tz = tz.trim_start_matches('+');
410            if tz.len() >= 3 {
411                // Format like "545" means 5:45
412                if let (Ok(hours), Ok(minutes)) = (
413                    tz[..tz.len() - 2].parse::<i32>(),
414                    tz[tz.len() - 2..].parse::<i32>(),
415                ) {
416                    return Some(hours as f32 + minutes as f32 / 60.0);
417                }
418            }
419            tz.parse::<f32>().ok()
420        })
421    }
422}
423
424/// Biography/HTML data container
425#[derive(Debug, Clone)]
426pub struct BiographyData {
427    /// The callsign this biography belongs to
428    pub callsign: String,
429    /// Raw HTML content
430    pub html_content: String,
431}
432
433impl BiographyData {
434    /// Create new biography data
435    pub fn new(callsign: impl Into<String>, html_content: impl Into<String>) -> Self {
436        Self {
437            callsign: callsign.into(),
438            html_content: html_content.into(),
439        }
440    }
441
442    /// Get the HTML content
443    pub fn html(&self) -> &str {
444        &self.html_content
445    }
446
447    /// Check if the biography is empty
448    pub fn is_empty(&self) -> bool {
449        self.html_content.trim().is_empty()
450    }
451}
452
453// Implement Default for CallsignInfo to help with testing
454#[allow(clippy::derivable_impls)]
455impl Default for CallsignInfo {
456    fn default() -> Self {
457        Self {
458            call: String::new(),
459            xref: None,
460            aliases: None,
461            dxcc: None,
462            fname: None,
463            name: None,
464            addr1: None,
465            addr2: None,
466            state: None,
467            zip: None,
468            country: None,
469            ccode: None,
470            lat: None,
471            lon: None,
472            grid: None,
473            county: None,
474            fips: None,
475            land: None,
476            efdate: None,
477            expdate: None,
478            p_call: None,
479            class: None,
480            codes: None,
481            qslmgr: None,
482            email: None,
483            url: None,
484            u_views: None,
485            bio: None,
486            biodate: None,
487            image: None,
488            imageinfo: None,
489            serial: None,
490            moddate: None,
491            msa: None,
492            area_code: None,
493            time_zone: None,
494            gmt_offset: None,
495            dst: None,
496            eqsl: None,
497            mqsl: None,
498            cqzone: None,
499            ituzone: None,
500            born: None,
501            user: None,
502            lotw: None,
503            iota: None,
504            geoloc: None,
505            attn: None,
506            nickname: None,
507            name_fmt: None,
508        }
509    }
510}
511
512#[allow(clippy::derivable_impls)]
513impl Default for DxccInfo {
514    fn default() -> Self {
515        Self {
516            dxcc: 0,
517            cc: None,
518            ccc: None,
519            name: String::new(),
520            continent: None,
521            ituzone: None,
522            cqzone: None,
523            timezone: None,
524            lat: None,
525            lon: None,
526            notes: None,
527        }
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[test]
536    fn test_api_version_display() {
537        assert_eq!(ApiVersion::Current.to_string(), "current");
538        assert_eq!(ApiVersion::version("1.34").to_string(), "1.34");
539        assert_eq!(ApiVersion::Legacy.to_string(), "");
540    }
541
542    #[test]
543    fn test_callsign_full_name() {
544        let mut info = CallsignInfo {
545            call: "TEST".to_string(),
546            fname: Some("John".to_string()),
547            name: Some("Doe".to_string()),
548            ..Default::default()
549        };
550
551        assert_eq!(info.full_name(), Some("John Doe".to_string()));
552
553        info.name = None;
554        assert_eq!(info.full_name(), Some("John".to_string()));
555    }
556
557    #[test]
558    fn test_coordinates() {
559        let info = CallsignInfo {
560            call: "TEST".to_string(),
561            lat: Some(40.7128),
562            lon: Some(-74.0060),
563            ..Default::default()
564        };
565
566        assert_eq!(info.coordinates(), Some((40.7128, -74.0060)));
567    }
568
569    #[test]
570    fn test_qsl_flags() {
571        let info = CallsignInfo {
572            call: "TEST".to_string(),
573            eqsl: Some("Y".to_string()),
574            mqsl: Some("N".to_string()),
575            lotw: Some("y".to_string()),
576            ..Default::default()
577        };
578
579        assert_eq!(info.accepts_eqsl(), Some(true));
580        assert_eq!(info.returns_paper_qsl(), Some(false));
581        assert_eq!(info.accepts_lotw(), Some(true));
582    }
583
584    #[test]
585    fn test_dxcc_timezone_parsing() {
586        let mut dxcc = DxccInfo {
587            dxcc: 291,
588            name: "Test".to_string(),
589            timezone: Some("-5".to_string()),
590            ..Default::default()
591        };
592
593        assert_eq!(dxcc.timezone_hours(), Some(-5.0));
594
595        dxcc.timezone = Some("545".to_string());
596        assert_eq!(dxcc.timezone_hours(), Some(5.75)); // 5 hours 45 minutes
597    }
598}