Skip to main content

xcsoar_tasks/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use std::fmt::Write as FmtWrite;
5use std::io::BufRead;
6
7#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
8#[serde(rename = "Task")]
9pub struct Task {
10    #[serde(rename = "@type")]
11    pub task_type: TaskType,
12
13    #[serde(
14        rename = "@aat_min_time",
15        default,
16        skip_serializing_if = "Option::is_none"
17    )]
18    pub aat_min_time: Option<u32>,
19
20    #[serde(
21        rename = "@start_requires_arm",
22        default,
23        deserialize_with = "de_opt_bool",
24        serialize_with = "ser_opt_bool",
25        skip_serializing_if = "Option::is_none"
26    )]
27    pub start_requires_arm: Option<bool>,
28
29    #[serde(
30        rename = "@start_score_exit",
31        default,
32        deserialize_with = "de_opt_bool",
33        serialize_with = "ser_opt_bool",
34        skip_serializing_if = "Option::is_none"
35    )]
36    pub start_score_exit: Option<bool>,
37
38    #[serde(
39        rename = "@start_max_speed",
40        default,
41        deserialize_with = "de_opt_f64",
42        serialize_with = "ser_opt_f64",
43        skip_serializing_if = "Option::is_none"
44    )]
45    pub start_max_speed: Option<f64>,
46
47    #[serde(
48        rename = "@start_max_height",
49        default,
50        skip_serializing_if = "Option::is_none"
51    )]
52    pub start_max_height: Option<u32>,
53
54    #[serde(
55        rename = "@start_max_height_ref",
56        default,
57        skip_serializing_if = "Option::is_none"
58    )]
59    pub start_max_height_ref: Option<AltitudeReference>,
60
61    #[serde(
62        rename = "@start_open_time",
63        default,
64        skip_serializing_if = "Option::is_none"
65    )]
66    pub start_open_time: Option<u32>,
67
68    #[serde(
69        rename = "@start_close_time",
70        default,
71        skip_serializing_if = "Option::is_none"
72    )]
73    pub start_close_time: Option<u32>,
74
75    #[serde(
76        rename = "@finish_min_height",
77        default,
78        skip_serializing_if = "Option::is_none"
79    )]
80    pub finish_min_height: Option<u32>,
81
82    #[serde(
83        rename = "@finish_min_height_ref",
84        default,
85        skip_serializing_if = "Option::is_none"
86    )]
87    pub finish_min_height_ref: Option<AltitudeReference>,
88
89    #[serde(
90        rename = "@fai_finish",
91        default,
92        deserialize_with = "de_opt_bool",
93        serialize_with = "ser_opt_bool",
94        skip_serializing_if = "Option::is_none"
95    )]
96    pub fai_finish: Option<bool>,
97
98    #[serde(
99        rename = "@pev_start_wait_time",
100        default,
101        skip_serializing_if = "Option::is_none"
102    )]
103    pub pev_start_wait_time: Option<u32>,
104
105    #[serde(
106        rename = "@pev_start_window",
107        default,
108        skip_serializing_if = "Option::is_none"
109    )]
110    pub pev_start_window: Option<u32>,
111
112    #[serde(rename = "Point", default)]
113    pub points: Vec<Point>,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
117pub enum TaskType {
118    AAT,
119    RT,
120    FAIGeneral,
121    FAITriangle,
122    FAIOR,
123    FAIGoal,
124    MAT,
125    Mixed,
126    Touring,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
130pub enum AltitudeReference {
131    AGL,
132    MSL,
133}
134
135impl<'de> Deserialize<'de> for AltitudeReference {
136    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
137        let s: String = Deserialize::deserialize(deserializer)?;
138        // XCSoar treats anything that's not "MSL" as AGL
139        if s == "MSL" {
140            Ok(AltitudeReference::MSL)
141        } else {
142            Ok(AltitudeReference::AGL)
143        }
144    }
145}
146
147#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
148pub struct Point {
149    #[serde(rename = "@type")]
150    pub point_type: PointType,
151
152    #[serde(
153        rename = "@score_exit",
154        default,
155        deserialize_with = "de_opt_bool",
156        serialize_with = "ser_opt_bool",
157        skip_serializing_if = "Option::is_none"
158    )]
159    pub score_exit: Option<bool>,
160
161    #[serde(rename = "Waypoint")]
162    pub waypoint: Waypoint,
163
164    #[serde(rename = "ObservationZone")]
165    pub observation_zone: ObservationZone,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
169pub enum PointType {
170    Start,
171    Turn,
172    Area,
173    Finish,
174    OptionalStart,
175}
176
177#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
178pub struct Waypoint {
179    #[serde(rename = "@name")]
180    pub name: String,
181
182    #[serde(
183        rename = "@altitude",
184        default,
185        deserialize_with = "de_opt_f64",
186        serialize_with = "ser_opt_f64",
187        skip_serializing_if = "Option::is_none"
188    )]
189    pub altitude: Option<f64>,
190
191    #[serde(rename = "@id", default, skip_serializing_if = "Option::is_none")]
192    pub id: Option<String>,
193
194    #[serde(rename = "@comment", default, skip_serializing_if = "Option::is_none")]
195    pub comment: Option<String>,
196
197    #[serde(rename = "Location")]
198    pub location: Location,
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
202pub struct Location {
203    #[serde(
204        rename = "@longitude",
205        deserialize_with = "de_f64",
206        serialize_with = "ser_f64"
207    )]
208    pub longitude: f64,
209
210    #[serde(
211        rename = "@latitude",
212        deserialize_with = "de_f64",
213        serialize_with = "ser_f64"
214    )]
215    pub latitude: f64,
216}
217
218#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
219#[serde(tag = "@type")]
220pub enum ObservationZone {
221    /// A cylinder with configurable radius. Scored from center.
222    Cylinder {
223        #[serde(
224            rename = "@radius",
225            deserialize_with = "de_f64",
226            serialize_with = "ser_f64"
227        )]
228        radius: f64,
229    },
230
231    /// A straight line gate, typically used for start/finish.
232    Line {
233        #[serde(
234            rename = "@length",
235            deserialize_with = "de_f64",
236            serialize_with = "ser_f64"
237        )]
238        length: f64,
239    },
240
241    /// DAeC keyhole: 500m cylinder or 10km 90° sector. Scored from center.
242    Keyhole,
243
244    /// FAI 90° sector with infinite length sides. Scored from corner.
245    FAISector,
246
247    /// A sector with configurable radius and radial angles.
248    ///
249    /// If `inner_radius` is set, creates an annular sector.
250    Sector {
251        #[serde(
252            rename = "@radius",
253            deserialize_with = "de_f64",
254            serialize_with = "ser_f64"
255        )]
256        radius: f64,
257        #[serde(
258            rename = "@start_radial",
259            deserialize_with = "de_f64",
260            serialize_with = "ser_f64"
261        )]
262        start_radial: f64,
263        #[serde(
264            rename = "@end_radial",
265            deserialize_with = "de_f64",
266            serialize_with = "ser_f64"
267        )]
268        end_radial: f64,
269        #[serde(
270            rename = "@inner_radius",
271            default,
272            deserialize_with = "de_opt_f64",
273            serialize_with = "ser_opt_f64",
274            skip_serializing_if = "Option::is_none"
275        )]
276        inner_radius: Option<f64>,
277    },
278
279    /// A symmetric quadrant with configurable radius and angle.
280    ///
281    /// Defaults: `radius = 10000m`.
282    SymmetricQuadrant {
283        #[serde(
284            rename = "@radius",
285            default,
286            deserialize_with = "de_opt_f64",
287            serialize_with = "ser_opt_f64",
288            skip_serializing_if = "Option::is_none"
289        )]
290        radius: Option<f64>,
291        #[serde(
292            rename = "@angle",
293            default,
294            deserialize_with = "de_opt_f64",
295            serialize_with = "ser_opt_f64",
296            skip_serializing_if = "Option::is_none"
297        )]
298        angle: Option<f64>,
299    },
300
301    /// A keyhole with configurable outer radius, inner radius, and sector angle.
302    ///
303    /// Defaults: `radius=10000m, inner_radius=500m, angle=90°`.
304    CustomKeyhole {
305        #[serde(
306            rename = "@radius",
307            default,
308            deserialize_with = "de_opt_f64",
309            serialize_with = "ser_opt_f64",
310            skip_serializing_if = "Option::is_none"
311        )]
312        radius: Option<f64>,
313        #[serde(
314            rename = "@angle",
315            default,
316            deserialize_with = "de_opt_f64",
317            serialize_with = "ser_opt_f64",
318            skip_serializing_if = "Option::is_none"
319        )]
320        angle: Option<f64>,
321        #[serde(
322            rename = "@inner_radius",
323            default,
324            deserialize_with = "de_opt_f64",
325            serialize_with = "ser_opt_f64",
326            skip_serializing_if = "Option::is_none"
327        )]
328        inner_radius: Option<f64>,
329    },
330
331    /// Fixed 1-mile radius cylinder for Modified Area Tasks.
332    MatCylinder,
333
334    /// BGA start sector: 5km 180° sector.
335    BGAStartSector,
336
337    /// BGA fixed course: 500m cylinder or 20km 90° sector.
338    BGAFixedCourse,
339
340    /// BGA enhanced option: 500m cylinder or 10km 180° sector.
341    BGAEnhancedOption,
342}
343
344fn de_f64<'de, D: Deserializer<'de>>(deserializer: D) -> Result<f64, D::Error> {
345    let s: String = Deserialize::deserialize(deserializer)?;
346    s.parse().map_err(serde::de::Error::custom)
347}
348
349fn de_opt_f64<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<f64>, D::Error> {
350    let s: Option<String> = Deserialize::deserialize(deserializer)?;
351    match s {
352        Some(s) => s.parse().map(Some).map_err(serde::de::Error::custom),
353        None => Ok(None),
354    }
355}
356
357fn de_opt_bool<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<bool>, D::Error> {
358    let s: Option<String> = Deserialize::deserialize(deserializer)?;
359    match s {
360        Some(s) => match s.as_str() {
361            "1" | "true" => Ok(Some(true)),
362            "0" | "false" => Ok(Some(false)),
363            _ => Err(serde::de::Error::custom(format!("invalid bool: {s}"))),
364        },
365        None => Ok(None),
366    }
367}
368
369fn ser_f64<S: Serializer>(value: &f64, serializer: S) -> Result<S::Ok, S::Error> {
370    serializer.serialize_str(&value.to_string())
371}
372
373fn ser_opt_f64<S: Serializer>(value: &Option<f64>, serializer: S) -> Result<S::Ok, S::Error> {
374    match value {
375        Some(v) => serializer.serialize_str(&v.to_string()),
376        None => serializer.serialize_none(),
377    }
378}
379
380fn ser_opt_bool<S: Serializer>(value: &Option<bool>, serializer: S) -> Result<S::Ok, S::Error> {
381    match value {
382        Some(true) => serializer.serialize_str("1"),
383        Some(false) => serializer.serialize_str("0"),
384        None => serializer.serialize_none(),
385    }
386}
387
388#[derive(Debug, thiserror::Error)]
389pub enum ParseError {
390    #[error("XML parsing failed: {0}")]
391    Xml(#[from] quick_xml::DeError),
392}
393
394#[derive(Debug, thiserror::Error)]
395pub enum SerializeError {
396    #[error("XML serialization failed: {0}")]
397    Xml(#[from] quick_xml::SeError),
398}
399
400pub fn from_str(xml: &str) -> Result<Task, ParseError> {
401    Ok(quick_xml::de::from_str(xml)?)
402}
403
404pub fn from_reader(reader: impl BufRead) -> Result<Task, ParseError> {
405    Ok(quick_xml::de::from_reader(reader)?)
406}
407
408pub fn to_writer(mut writer: impl FmtWrite, task: &Task) -> Result<(), SerializeError> {
409    let serializer = quick_xml::se::Serializer::new(&mut writer);
410    task.serialize(serializer)?;
411    Ok(())
412}
413
414pub fn to_writer_pretty(mut writer: impl FmtWrite, task: &Task) -> Result<(), SerializeError> {
415    let mut serializer = quick_xml::se::Serializer::new(&mut writer);
416    serializer.indent(' ', 4);
417    task.serialize(serializer)?;
418    Ok(())
419}
420
421pub fn to_string(task: &Task) -> Result<String, SerializeError> {
422    let mut buffer = String::new();
423    to_writer(&mut buffer, task)?;
424    Ok(buffer)
425}
426
427pub fn to_string_pretty(task: &Task) -> Result<String, SerializeError> {
428    let mut buffer = String::new();
429    to_writer_pretty(&mut buffer, task)?;
430    Ok(buffer)
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use insta::{assert_debug_snapshot, assert_snapshot};
437
438    #[test]
439    fn parse_aat_task() {
440        let xml = include_str!("../fixtures/aat-task.tsk");
441        let task = from_str(xml).unwrap();
442        assert_debug_snapshot!(task);
443    }
444
445    #[test]
446    fn parse_racing_task() {
447        let xml = include_str!("../fixtures/racing-task.tsk");
448        let task = from_str(xml).unwrap();
449        assert_debug_snapshot!(task);
450    }
451
452    #[test]
453    fn parse_fai_task() {
454        let xml = include_str!("../fixtures/fai-task.tsk");
455        let task = from_str(xml).unwrap();
456        assert_debug_snapshot!(task);
457    }
458
459    #[test]
460    fn parse_all_oz_types() {
461        let xml = include_str!("../fixtures/all-oz-types.tsk");
462        let task = from_str(xml).unwrap();
463        assert_debug_snapshot!(task);
464    }
465
466    #[test]
467    fn roundtrip_aat_task() {
468        let xml = include_str!("../fixtures/aat-task.tsk");
469        let task = from_str(xml).unwrap();
470
471        let serialized = to_string_pretty(&task).unwrap();
472        assert_snapshot!(serialized);
473
474        let roundtripped = from_str(&serialized).unwrap();
475        assert_eq!(task, roundtripped);
476    }
477
478    #[test]
479    fn roundtrip_racing_task() {
480        let xml = include_str!("../fixtures/racing-task.tsk");
481        let task = from_str(xml).unwrap();
482
483        let serialized = to_string_pretty(&task).unwrap();
484        assert_snapshot!(serialized);
485
486        let roundtripped = from_str(&serialized).unwrap();
487        assert_eq!(task, roundtripped);
488    }
489
490    #[test]
491    fn roundtrip_fai_task() {
492        let xml = include_str!("../fixtures/fai-task.tsk");
493        let task = from_str(xml).unwrap();
494
495        let serialized = to_string_pretty(&task).unwrap();
496        assert_snapshot!(serialized);
497
498        let roundtripped = from_str(&serialized).unwrap();
499        assert_eq!(task, roundtripped);
500    }
501
502    #[test]
503    fn roundtrip_all_oz_types() {
504        let xml = include_str!("../fixtures/all-oz-types.tsk");
505        let task = from_str(xml).unwrap();
506
507        let serialized = to_string_pretty(&task).unwrap();
508        assert_snapshot!(serialized);
509
510        let roundtripped = from_str(&serialized).unwrap();
511        assert_eq!(task, roundtripped);
512    }
513
514    #[test]
515    fn serialize_ugly() {
516        let xml = include_str!("../fixtures/aat-task.tsk");
517        let task = from_str(xml).unwrap();
518
519        let serialized = to_string(&task).unwrap();
520        assert_snapshot!(serialized);
521    }
522}