1use serde::{Deserialize, Serialize};
6
7use crate::id::{Blake3Hex, NodeIdHex};
8use crate::util::{canonical_utc_now, is_canonical_utc_timestamp};
9
10#[derive(Debug, thiserror::Error)]
13pub enum MetadataError {
14 #[error("wrong schema: expected \"igc-net/metadata\", got {0:?}")]
15 WrongSchema(String),
16 #[error("unsupported schema_version: {0}")]
17 WrongVersion(u32),
18 #[error("JSON: {0}")]
19 Json(#[from] serde_json::Error),
20 #[error("{field} is not a valid 64-char lowercase hex string")]
21 MalformedLowerHex { field: &'static str },
22 #[error("{field} is not a canonical UTC timestamp: {value}")]
23 MalformedTimestamp { field: &'static str, value: String },
24 #[error("{field} is not a valid YYYY-MM-DD date: {value}")]
25 MalformedDate { field: &'static str, value: String },
26 #[error("{field} is out of range or non-finite: {value}")]
27 InvalidCoordinate { field: &'static str, value: f64 },
28 #[error("{field} has invalid bounds: {message}")]
29 InvalidBounds {
30 field: &'static str,
31 message: &'static str,
32 },
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct BoundingBox {
39 pub min_lat: f64,
40 pub max_lat: f64,
41 pub min_lon: f64,
42 pub max_lon: f64,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct FlightMetadata {
52 pub schema: String,
53 pub schema_version: u32,
54 pub igc_hash: Blake3Hex,
55
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub original_filename: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub flight_date: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub started_at: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub ended_at: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub duration_s: Option<u64>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub pilot_name: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub glider_type: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub glider_id: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub device_id: Option<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub fix_count: Option<u32>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub valid_fix_count: Option<u32>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub bbox: Option<BoundingBox>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub launch_lat: Option<f64>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub launch_lon: Option<f64>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub landing_lat: Option<f64>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub landing_lon: Option<f64>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub max_alt_m: Option<i32>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub min_alt_m: Option<i32>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub publisher_node_id: Option<NodeIdHex>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub published_at: Option<String>,
97}
98
99impl FlightMetadata {
102 pub fn new(igc_hash: Blake3Hex) -> Self {
104 FlightMetadata {
105 schema: "igc-net/metadata".to_string(),
106 schema_version: 1,
107 igc_hash,
108 original_filename: None,
109 flight_date: None,
110 started_at: None,
111 ended_at: None,
112 duration_s: None,
113 pilot_name: None,
114 glider_type: None,
115 glider_id: None,
116 device_id: None,
117 fix_count: None,
118 valid_fix_count: None,
119 bbox: None,
120 launch_lat: None,
121 launch_lon: None,
122 landing_lat: None,
123 landing_lon: None,
124 max_alt_m: None,
125 min_alt_m: None,
126 publisher_node_id: None,
127 published_at: None,
128 }
129 }
130
131 pub fn from_igc_bytes(
142 igc_bytes: &[u8],
143 igc_hash: Blake3Hex,
144 original_filename: Option<&str>,
145 publisher_node_id: Option<NodeIdHex>,
146 ) -> Self {
147 let mut meta = FlightMetadata::new(igc_hash);
148 meta.original_filename = original_filename.map(str::to_string);
149 meta.publisher_node_id = publisher_node_id;
150 meta.published_at = Some(canonical_utc_now());
151
152 let text = match std::str::from_utf8(igc_bytes) {
153 Ok(t) => t,
154 Err(_) => return meta, };
156
157 parse_igc_into(text, &mut meta);
158 meta
159 }
160
161 pub fn to_blob_bytes(&self) -> Result<Vec<u8>, MetadataError> {
165 serde_json::to_vec(self).map_err(MetadataError::from)
166 }
167
168 pub fn validate(&self) -> Result<(), MetadataError> {
170 if self.schema != "igc-net/metadata" {
171 return Err(MetadataError::WrongSchema(self.schema.clone()));
172 }
173 if self.schema_version != 1 {
174 return Err(MetadataError::WrongVersion(self.schema_version));
175 }
176 if let Some(value) = self.flight_date.as_deref()
177 && !is_canonical_date(value)
178 {
179 return Err(MetadataError::MalformedDate {
180 field: "flight_date",
181 value: value.to_string(),
182 });
183 }
184 for (field, value) in [
185 ("started_at", self.started_at.as_deref()),
186 ("ended_at", self.ended_at.as_deref()),
187 ("published_at", self.published_at.as_deref()),
188 ] {
189 if let Some(value) = value
190 && !is_canonical_utc_timestamp(value)
191 {
192 return Err(MetadataError::MalformedTimestamp {
193 field,
194 value: value.to_string(),
195 });
196 }
197 }
198 for (field, value, min, max) in [
199 ("launch_lat", self.launch_lat, -90.0, 90.0),
200 ("launch_lon", self.launch_lon, -180.0, 180.0),
201 ("landing_lat", self.landing_lat, -90.0, 90.0),
202 ("landing_lon", self.landing_lon, -180.0, 180.0),
203 ] {
204 if let Some(value) = value {
205 validate_coordinate(field, value, min, max)?;
206 }
207 }
208 if let Some(bb) = &self.bbox {
209 validate_coordinate("bbox.min_lat", bb.min_lat, -90.0, 90.0)?;
210 validate_coordinate("bbox.max_lat", bb.max_lat, -90.0, 90.0)?;
211 validate_coordinate("bbox.min_lon", bb.min_lon, -180.0, 180.0)?;
212 validate_coordinate("bbox.max_lon", bb.max_lon, -180.0, 180.0)?;
213 if bb.max_lat < bb.min_lat {
214 return Err(MetadataError::InvalidBounds {
215 field: "bbox",
216 message: "max_lat < min_lat",
217 });
218 }
219 if bb.max_lon < bb.min_lon {
220 return Err(MetadataError::InvalidBounds {
221 field: "bbox",
222 message: "max_lon < min_lon",
223 });
224 }
225 }
226 Ok(())
227 }
228}
229
230fn parse_igc_into(text: &str, meta: &mut FlightMetadata) {
233 let mut fix_count: u32 = 0;
234 let mut valid_fix_count: u32 = 0;
235 let mut first_valid_fix_time: Option<String> = None;
236 let mut last_valid_fix_time: Option<String> = None;
237
238 let mut lats: Vec<f64> = Vec::new();
239 let mut lons: Vec<f64> = Vec::new();
240 let mut pressure_alts: Vec<i32> = Vec::new();
241 let mut gps_alts: Vec<i32> = Vec::new();
242 let mut pressure_alt_all_non_zero = true;
243
244 let mut first_lat: Option<f64> = None;
245 let mut first_lon: Option<f64> = None;
246 let mut last_lat: Option<f64> = None;
247 let mut last_lon: Option<f64> = None;
248
249 for raw_line in text.lines() {
250 let line = raw_line.trim_end_matches('\r');
251 if line.is_empty() {
252 continue;
253 }
254 let bytes = line.as_bytes();
255
256 match bytes.first() {
257 Some(b'A') if meta.device_id.is_none() && line.len() >= 7 => {
258 meta.device_id = Some(line[1..7].trim().to_string());
260 }
261 Some(b'H') => {
262 parse_h_record(line, meta);
263 }
264 Some(b'B') if line.len() >= 35 => {
265 fix_count += 1;
266
267 let time_str = &line[1..7]; let valid = bytes[24] == b'A';
269 if valid {
270 valid_fix_count += 1;
271 if first_valid_fix_time.is_none() {
272 first_valid_fix_time = Some(time_str.to_string());
273 }
274 last_valid_fix_time = Some(time_str.to_string());
275 }
276
277 if let (Some(lat), Some(lon)) =
278 (parse_lat(&bytes[7..15]), parse_lon(&bytes[15..24]))
279 {
280 lats.push(lat);
281 lons.push(lon);
282 if first_lat.is_none() {
283 first_lat = Some(lat);
284 first_lon = Some(lon);
285 }
286 last_lat = Some(lat);
287 last_lon = Some(lon);
288 }
289
290 let pressure_alt = parse_altitude(&bytes[25..30]);
291 let gps_alt = parse_altitude(&bytes[30..35]);
292
293 match pressure_alt {
294 Some(alt) if alt != 0 => pressure_alts.push(alt),
295 _ => pressure_alt_all_non_zero = false,
296 }
297 if let Some(alt) = gps_alt {
298 gps_alts.push(alt);
299 }
300 }
301 _ => {}
302 }
303 }
304
305 meta.fix_count = Some(fix_count);
306 meta.valid_fix_count = Some(valid_fix_count);
307
308 meta.started_at = build_timestamp(meta.flight_date.as_deref(), first_valid_fix_time.as_deref());
309
310 let crossed_midnight = is_midnight_crossing(
313 first_valid_fix_time.as_deref(),
314 last_valid_fix_time.as_deref(),
315 );
316 let end_date = if crossed_midnight {
317 next_day(&meta.flight_date)
318 } else {
319 meta.flight_date.clone()
320 };
321 meta.ended_at = build_timestamp(end_date.as_deref(), last_valid_fix_time.as_deref());
322
323 meta.duration_s = compute_duration_s(
324 first_valid_fix_time.as_deref(),
325 last_valid_fix_time.as_deref(),
326 );
327
328 if !lats.is_empty() {
329 meta.bbox = Some(BoundingBox {
330 min_lat: lats.iter().cloned().fold(f64::INFINITY, f64::min),
331 max_lat: lats.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
332 min_lon: lons.iter().cloned().fold(f64::INFINITY, f64::min),
333 max_lon: lons.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
334 });
335 }
336
337 meta.launch_lat = first_lat;
338 meta.launch_lon = first_lon;
339 meta.landing_lat = last_lat;
340 meta.landing_lon = last_lon;
341
342 let altitudes = if pressure_alt_all_non_zero && !pressure_alts.is_empty() {
343 &pressure_alts
344 } else {
345 &gps_alts
346 };
347 meta.max_alt_m = altitudes.iter().copied().max();
348 meta.min_alt_m = altitudes.iter().copied().min();
349}
350
351fn parse_h_record(line: &str, meta: &mut FlightMetadata) {
352 let upper = line.to_ascii_uppercase();
353 if upper.len() < 5 {
355 return;
356 }
357 let code = &upper[2..5];
358 match code {
359 "DTE" => {
360 if meta.flight_date.is_none() {
361 meta.flight_date = parse_hfdte(line);
362 }
363 }
364 "PLT" => {
365 if meta.pilot_name.is_none() {
366 meta.pilot_name = h_colon_value(line);
367 }
368 }
369 "GTY" => {
370 if meta.glider_type.is_none() {
371 meta.glider_type = h_colon_value(line);
372 }
373 }
374 "GID" => {
375 if meta.glider_id.is_none() {
376 meta.glider_id = h_colon_value(line);
377 }
378 }
379 _ => {}
380 }
381}
382
383fn parse_lat(bytes: &[u8]) -> Option<f64> {
385 if bytes.len() < 8 {
386 return None;
387 }
388 let dd: f64 = std::str::from_utf8(&bytes[0..2]).ok()?.parse().ok()?;
389 let mm: f64 = std::str::from_utf8(&bytes[2..4]).ok()?.parse().ok()?;
390 let mmm: f64 = std::str::from_utf8(&bytes[4..7]).ok()?.parse().ok()?;
391 let decimal = dd + (mm + mmm / 1000.0) / 60.0;
392 match bytes[7] {
393 b'S' => Some(-decimal),
394 _ => Some(decimal),
395 }
396}
397
398fn parse_lon(bytes: &[u8]) -> Option<f64> {
400 if bytes.len() < 9 {
401 return None;
402 }
403 let ddd: f64 = std::str::from_utf8(&bytes[0..3]).ok()?.parse().ok()?;
404 let mm: f64 = std::str::from_utf8(&bytes[3..5]).ok()?.parse().ok()?;
405 let mmm: f64 = std::str::from_utf8(&bytes[5..8]).ok()?.parse().ok()?;
406 let decimal = ddd + (mm + mmm / 1000.0) / 60.0;
407 match bytes[8] {
408 b'W' => Some(-decimal),
409 _ => Some(decimal),
410 }
411}
412
413fn parse_altitude(bytes: &[u8]) -> Option<i32> {
414 std::str::from_utf8(bytes).ok()?.parse().ok()
415}
416
417fn is_canonical_date(value: &str) -> bool {
418 chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok()
419}
420
421fn validate_coordinate(
422 field: &'static str,
423 value: f64,
424 min: f64,
425 max: f64,
426) -> Result<(), MetadataError> {
427 if value.is_finite() && (min..=max).contains(&value) {
428 Ok(())
429 } else {
430 Err(MetadataError::InvalidCoordinate { field, value })
431 }
432}
433
434fn parse_hfdte(line: &str) -> Option<String> {
438 let digits: String = line.chars().filter(|c| c.is_ascii_digit()).collect();
440 if digits.len() < 6 {
441 return None;
442 }
443 let d = &digits[digits.len() - 6..];
444 let dd = &d[0..2];
445 let mm = &d[2..4];
446 let yy = &d[4..6];
447 let yyyy = format!("20{yy}");
448 Some(format!("{yyyy}-{mm}-{dd}"))
449}
450
451fn h_colon_value(line: &str) -> Option<String> {
453 line.find(':')
454 .map(|i| line[i + 1..].trim().to_string())
455 .filter(|s| !s.is_empty())
456}
457
458fn build_timestamp(date: Option<&str>, time: Option<&str>) -> Option<String> {
461 match (date, time) {
462 (Some(d), Some(t)) if t.len() == 6 => {
463 let h = &t[0..2];
464 let m = &t[2..4];
465 let s = &t[4..6];
466 Some(format!("{d}T{h}:{m}:{s}Z"))
467 }
468 _ => None,
469 }
470}
471
472fn is_midnight_crossing(start: Option<&str>, end: Option<&str>) -> bool {
475 let to_secs = |t: &str| -> Option<u64> {
476 if t.len() != 6 {
477 return None;
478 }
479 let h: u64 = t[0..2].parse().ok()?;
480 let m: u64 = t[2..4].parse().ok()?;
481 let s: u64 = t[4..6].parse().ok()?;
482 Some(h * 3600 + m * 60 + s)
483 };
484 match (start.and_then(to_secs), end.and_then(to_secs)) {
485 (Some(ss), Some(es)) => es < ss,
486 _ => false,
487 }
488}
489
490fn next_day(date: &Option<String>) -> Option<String> {
493 use chrono::NaiveDate;
494 let d = date.as_deref()?;
495 let parsed = NaiveDate::parse_from_str(d, "%Y-%m-%d").ok()?;
496 Some(parsed.succ_opt()?.format("%Y-%m-%d").to_string())
497}
498
499fn compute_duration_s(start: Option<&str>, end: Option<&str>) -> Option<u64> {
501 let (start, end) = (start?, end?);
502 if start.len() != 6 || end.len() != 6 {
503 return None;
504 }
505 let to_secs = |t: &str| -> Option<u64> {
506 let h: u64 = t[0..2].parse().ok()?;
507 let m: u64 = t[2..4].parse().ok()?;
508 let s: u64 = t[4..6].parse().ok()?;
509 Some(h * 3600 + m * 60 + s)
510 };
511 let ss = to_secs(start)?;
512 let es = to_secs(end)?;
513 if es >= ss {
514 Some(es - ss)
515 } else {
516 Some(es + 86400 - ss) }
518}
519
520#[cfg(test)]
523mod tests {
524 use super::*;
525 use crate::id::{Blake3Hex, NodeIdHex};
526
527 const MINIMAL_IGC: &str = "\
534AXXX001 TestDevice\r\n\
535HFDTE020714\r\n\
536HFPLTPILOTINCHARGE:Jane Doe\r\n\
537HFGTYGLIDERTYPE:Advance Sigma 10\r\n\
538HFGIDGLIDERID:HB-1234\r\n\
539B1200004728000N00836000EV0010001000\r\n\
540B1300004730000N00837000EA0030003000\r\n\
541B1400004732000N00838000EA0150001500\r\n\
542";
543
544 fn fake_hash() -> Blake3Hex {
545 Blake3Hex::parse("abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234")
546 .unwrap()
547 }
548
549 fn fake_node_id() -> NodeIdHex {
550 NodeIdHex::parse("aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd")
551 .unwrap()
552 }
553
554 #[test]
555 fn from_igc_bytes_populates_fields() {
556 let meta = FlightMetadata::from_igc_bytes(
557 MINIMAL_IGC.as_bytes(),
558 fake_hash(),
559 Some("test.igc"),
560 Some(fake_node_id()),
561 );
562 assert_eq!(meta.schema, "igc-net/metadata");
563 assert_eq!(meta.schema_version, 1);
564 assert_eq!(meta.igc_hash, fake_hash());
565 assert_eq!(meta.original_filename.as_deref(), Some("test.igc"));
566 assert_eq!(meta.flight_date.as_deref(), Some("2014-07-02"));
567 assert_eq!(meta.pilot_name.as_deref(), Some("Jane Doe"));
568 assert_eq!(meta.glider_type.as_deref(), Some("Advance Sigma 10"));
569 assert_eq!(meta.glider_id.as_deref(), Some("HB-1234"));
570 assert_eq!(meta.fix_count, Some(3));
571 assert_eq!(meta.valid_fix_count, Some(2)); assert_eq!(meta.launch_lat, Some(47.46666666666667));
573 assert_eq!(meta.launch_lon, Some(8.6));
574 assert_eq!(meta.landing_lat, Some(47.53333333333333));
575 assert_eq!(meta.landing_lon, Some(8.633333333333333));
576 assert_eq!(meta.max_alt_m, Some(1500));
577 assert_eq!(meta.min_alt_m, Some(100));
578 assert!(meta.bbox.is_some());
579 assert_eq!(
580 meta.published_at.as_deref(),
581 meta.published_at
582 .as_deref()
583 .filter(|value| is_canonical_utc_timestamp(value))
584 );
585 }
586
587 #[test]
588 fn to_blob_bytes_round_trip() {
589 let meta = FlightMetadata::new(fake_hash());
590 let bytes = meta.to_blob_bytes().unwrap();
591 let parsed: FlightMetadata = serde_json::from_slice(&bytes).unwrap();
592 assert_eq!(parsed.igc_hash, fake_hash());
593 }
594
595 #[test]
596 fn null_omission_no_null_in_json() {
597 let meta = FlightMetadata::new(fake_hash());
598 let json = String::from_utf8(meta.to_blob_bytes().unwrap()).unwrap();
599 assert!(
600 !json.contains("null"),
601 "JSON must not contain 'null': {json}"
602 );
603 }
604
605 #[test]
606 fn validate_rejects_wrong_schema() {
607 let mut meta = FlightMetadata::new(fake_hash());
608 meta.schema = "wrong".to_string();
609 assert!(matches!(
610 meta.validate(),
611 Err(MetadataError::WrongSchema(_))
612 ));
613 }
614
615 #[test]
616 fn validate_rejects_wrong_version() {
617 let mut meta = FlightMetadata::new(fake_hash());
618 meta.schema_version = 99;
619 assert!(matches!(
620 meta.validate(),
621 Err(MetadataError::WrongVersion(99))
622 ));
623 }
624
625 #[test]
626 fn deserialize_rejects_malformed_hash() {
627 let json = r#"{"schema":"igc-net/metadata","schema_version":1,"igc_hash":"not-a-hash"}"#;
628 assert!(serde_json::from_str::<FlightMetadata>(json).is_err());
629 }
630
631 #[test]
632 fn deserialize_rejects_uppercase_hash() {
633 let json = r#"{"schema":"igc-net/metadata","schema_version":1,"igc_hash":"ABCD1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"}"#;
634 assert!(serde_json::from_str::<FlightMetadata>(json).is_err());
635 }
636
637 #[test]
638 fn validate_rejects_non_canonical_timestamp() {
639 let mut meta = FlightMetadata::new(fake_hash());
640 meta.published_at = Some("2026-03-29T18:07:55+00:00".to_string());
641 assert!(matches!(
642 meta.validate(),
643 Err(MetadataError::MalformedTimestamp {
644 field: "published_at",
645 ..
646 })
647 ));
648 }
649
650 #[test]
651 fn validate_rejects_invalid_flight_date() {
652 let mut meta = FlightMetadata::new(fake_hash());
653 meta.flight_date = Some("2026-02-31".to_string());
654 assert!(matches!(
655 meta.validate(),
656 Err(MetadataError::MalformedDate {
657 field: "flight_date",
658 ..
659 })
660 ));
661 }
662
663 #[test]
664 fn validate_rejects_out_of_range_coordinates() {
665 let mut meta = FlightMetadata::new(fake_hash());
666 meta.launch_lat = Some(91.0);
667 assert!(matches!(
668 meta.validate(),
669 Err(MetadataError::InvalidCoordinate {
670 field: "launch_lat",
671 ..
672 })
673 ));
674 }
675
676 #[test]
677 fn validate_rejects_invalid_bbox_bounds() {
678 let mut meta = FlightMetadata::new(fake_hash());
679 meta.bbox = Some(BoundingBox {
680 min_lat: 10.0,
681 max_lat: 5.0,
682 min_lon: 20.0,
683 max_lon: 25.0,
684 });
685 assert!(matches!(
686 meta.validate(),
687 Err(MetadataError::InvalidBounds { field: "bbox", .. })
688 ));
689 }
690
691 #[test]
692 fn validate_accepts_valid_metadata() {
693 let meta = FlightMetadata::new(fake_hash());
694 assert!(meta.validate().is_ok());
695 }
696
697 #[test]
698 fn midnight_crossing_duration() {
699 let d = compute_duration_s(Some("235900"), Some("000100"));
700 assert_eq!(d, Some(120));
701 }
702
703 #[test]
704 fn midnight_crossing_ended_at_uses_next_day() {
705 let igc = "\
707AXXX001 TestDevice\r\n\
708HFDTE310714\r\n\
709B2350004730000N00837000EA0030003000\r\n\
710B0010004732000N00838000EA0050005000\r\n\
711";
712 let meta = FlightMetadata::from_igc_bytes(
713 igc.as_bytes(),
714 fake_hash(),
715 None,
716 Some(NodeIdHex::parse("cc".repeat(32)).unwrap()),
717 );
718 assert_eq!(meta.flight_date.as_deref(), Some("2014-07-31"));
719 assert_eq!(meta.started_at.as_deref(), Some("2014-07-31T23:50:00Z"));
720 assert_eq!(meta.ended_at.as_deref(), Some("2014-08-01T00:10:00Z"));
721 assert_eq!(meta.duration_s, Some(1200));
722 }
723
724 #[test]
725 fn normal_flight_ended_at_uses_same_day() {
726 let igc = "\
727AXXX001 TestDevice\r\n\
728HFDTE020714\r\n\
729B1200004730000N00837000EA0030003000\r\n\
730B1400004732000N00838000EA0050005000\r\n\
731";
732 let meta = FlightMetadata::from_igc_bytes(
733 igc.as_bytes(),
734 fake_hash(),
735 None,
736 Some(NodeIdHex::parse("cc".repeat(32)).unwrap()),
737 );
738 assert_eq!(meta.started_at.as_deref(), Some("2014-07-02T12:00:00Z"));
739 assert_eq!(meta.ended_at.as_deref(), Some("2014-07-02T14:00:00Z"));
740 }
741
742 #[test]
743 fn next_day_advances_correctly() {
744 assert_eq!(
745 next_day(&Some("2014-07-31".to_string())),
746 Some("2014-08-01".to_string())
747 );
748 assert_eq!(
749 next_day(&Some("2024-12-31".to_string())),
750 Some("2025-01-01".to_string())
751 );
752 assert_eq!(next_day(&None), None);
753 }
754}