Skip to main content

igc_net/
metadata.rs

1//! igc-net metadata blob (schema_version 1).
2//!
3//! See the igc-net protocol specification, Part I metadata schema.
4
5use serde::{Deserialize, Serialize};
6
7use crate::id::{Blake3Hex, NodeIdHex};
8use crate::util::{canonical_utc_now, is_canonical_utc_timestamp};
9
10// ── Error types ──────────────────────────────────────────────────────────────
11
12#[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// ── Structs ───────────────────────────────────────────────────────────────────
36
37#[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/// igc-net metadata blob (schema_version 1).
46///
47/// Serialises to/from the JSON wire format defined in specs_meta.md §2.
48/// Optional fields absent from the struct MUST be omitted from JSON (not null)
49/// — enforced by `#[serde(skip_serializing_if = "Option::is_none")]`.
50#[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    /// Count of B records with validity flag 'A'. Always ≤ fix_count.
77    #[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
99// ── impl FlightMetadata ───────────────────────────────────────────────────────
100
101impl FlightMetadata {
102    /// Construct a minimal valid metadata blob with only the required fields.
103    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    /// Parse IGC headers and B-records to build a fully-populated metadata blob.
132    ///
133    /// Falls back gracefully: if a field cannot be parsed it is omitted rather
134    /// than causing an error.  Only requires at least one B-record to be present.
135    ///
136    /// # Arguments
137    /// - `igc_bytes`           — raw IGC file bytes (unmodified)
138    /// - `igc_hash`            — pre-computed BLAKE3 hex string of those bytes
139    /// - `original_filename`   — the upload filename, if available
140    /// - `publisher_node_id`   — hex-encoded public key of the publishing node
141    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, // not UTF-8, return minimal
155        };
156
157        parse_igc_into(text, &mut meta);
158        meta
159    }
160
161    /// Serialize to canonical UTF-8 JSON bytes (no BOM, no trailing newline).
162    ///
163    /// These bytes are what get content-addressed as the metadata blob.
164    pub fn to_blob_bytes(&self) -> Result<Vec<u8>, MetadataError> {
165        serde_json::to_vec(self).map_err(MetadataError::from)
166    }
167
168    /// Validate the wire-format constraints for a received metadata blob.
169    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
230// ── IGC parsing helpers ───────────────────────────────────────────────────────
231
232fn 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                // First A record: manufacturer (3 chars) + serial (3 chars)
259                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]; // HHMMSS
268                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    // Detect midnight crossing: if end time < start time, advance the date by
311    // one day for the ended_at timestamp.
312    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    // Check the 3-char field code at positions 2..5
354    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
383/// Parse latitude from an 8-byte IGC field: `DDMMmmmN` or `DDMMmmmS`
384fn 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
398/// Parse longitude from a 9-byte IGC field: `DDDMMmmmE` or `DDDMMmmmW`
399fn 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
434/// Parse an HFDTE record into `YYYY-MM-DD`.
435///
436/// Handles both `HFDTE020714` and `HFDTEDATE:020714` forms.
437fn parse_hfdte(line: &str) -> Option<String> {
438    // Collect all digit runs
439    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
451/// Extract the value after the first `:` in a H record, trimmed.
452fn 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
458/// Combine a flight date (`YYYY-MM-DD`) and a B-record time (`HHMMSS`) into
459/// an RFC 3339 UTC timestamp.
460fn 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
472/// True if the HHMMSS end time is earlier than the HHMMSS start time,
473/// indicating a midnight crossing.
474fn 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
490/// Advance a `YYYY-MM-DD` date string by one day.  Returns `None` if the
491/// input is absent or unparseable.
492fn 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
499/// Compute duration in seconds between two HHMMSS strings, handling midnight wrap.
500fn 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) // midnight crossing
517    }
518}
519
520// ── Tests ─────────────────────────────────────────────────────────────────────
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use crate::id::{Blake3Hex, NodeIdHex};
526
527    // Valid IGC B record layout (35 chars minimum):
528    //   B HHMMSS DDMMmmmN DDDMMmmmE/W V PPPPP GGGGG
529    //   0 123456 78901234 567890123   4 56789 01234
530    //   positions in the line (0-indexed)
531    //
532    // Validity 'V' at position 24; 'A' = 3D fix, 'V' = invalid.
533    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)); // first record is 'V', last two are 'A'
572        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        // IGC file where flight starts at 23:50 and ends at 00:10 the next day.
706        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}