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(
309        meta.flight_date.as_deref(),
310        first_valid_fix_time.as_deref(),
311    );
312
313    // Detect midnight crossing: if end time < start time, advance the date by
314    // one day for the ended_at timestamp.
315    let crossed_midnight = is_midnight_crossing(
316        first_valid_fix_time.as_deref(),
317        last_valid_fix_time.as_deref(),
318    );
319    let end_date = if crossed_midnight {
320        next_day(&meta.flight_date)
321    } else {
322        meta.flight_date.clone()
323    };
324    meta.ended_at = build_timestamp(end_date.as_deref(), last_valid_fix_time.as_deref());
325
326    meta.duration_s = compute_duration_s(
327        first_valid_fix_time.as_deref(),
328        last_valid_fix_time.as_deref(),
329    );
330
331    if !lats.is_empty() {
332        meta.bbox = Some(BoundingBox {
333            min_lat: lats.iter().cloned().fold(f64::INFINITY, f64::min),
334            max_lat: lats.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
335            min_lon: lons.iter().cloned().fold(f64::INFINITY, f64::min),
336            max_lon: lons.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
337        });
338    }
339
340    meta.launch_lat = first_lat;
341    meta.launch_lon = first_lon;
342    meta.landing_lat = last_lat;
343    meta.landing_lon = last_lon;
344
345    let altitudes = if pressure_alt_all_non_zero && !pressure_alts.is_empty() {
346        &pressure_alts
347    } else {
348        &gps_alts
349    };
350    meta.max_alt_m = altitudes.iter().copied().max();
351    meta.min_alt_m = altitudes.iter().copied().min();
352}
353
354fn parse_h_record(line: &str, meta: &mut FlightMetadata) {
355    let upper = line.to_ascii_uppercase();
356    // Check the 3-char field code at positions 2..5
357    if upper.len() < 5 {
358        return;
359    }
360    let code = &upper[2..5];
361    match code {
362        "DTE" => {
363            if meta.flight_date.is_none() {
364                meta.flight_date = parse_hfdte(line);
365            }
366        }
367        "PLT" => {
368            if meta.pilot_name.is_none() {
369                meta.pilot_name = h_colon_value(line);
370            }
371        }
372        "GTY" => {
373            if meta.glider_type.is_none() {
374                meta.glider_type = h_colon_value(line);
375            }
376        }
377        "GID" => {
378            if meta.glider_id.is_none() {
379                meta.glider_id = h_colon_value(line);
380            }
381        }
382        _ => {}
383    }
384}
385
386/// Parse latitude from an 8-byte IGC field: `DDMMmmmN` or `DDMMmmmS`
387fn parse_lat(bytes: &[u8]) -> Option<f64> {
388    if bytes.len() < 8 {
389        return None;
390    }
391    let dd: f64 = std::str::from_utf8(&bytes[0..2]).ok()?.parse().ok()?;
392    let mm: f64 = std::str::from_utf8(&bytes[2..4]).ok()?.parse().ok()?;
393    let mmm: f64 = std::str::from_utf8(&bytes[4..7]).ok()?.parse().ok()?;
394    let decimal = dd + (mm + mmm / 1000.0) / 60.0;
395    match bytes[7] {
396        b'S' => Some(-decimal),
397        _ => Some(decimal),
398    }
399}
400
401/// Parse longitude from a 9-byte IGC field: `DDDMMmmmE` or `DDDMMmmmW`
402fn parse_lon(bytes: &[u8]) -> Option<f64> {
403    if bytes.len() < 9 {
404        return None;
405    }
406    let ddd: f64 = std::str::from_utf8(&bytes[0..3]).ok()?.parse().ok()?;
407    let mm: f64 = std::str::from_utf8(&bytes[3..5]).ok()?.parse().ok()?;
408    let mmm: f64 = std::str::from_utf8(&bytes[5..8]).ok()?.parse().ok()?;
409    let decimal = ddd + (mm + mmm / 1000.0) / 60.0;
410    match bytes[8] {
411        b'W' => Some(-decimal),
412        _ => Some(decimal),
413    }
414}
415
416fn parse_altitude(bytes: &[u8]) -> Option<i32> {
417    std::str::from_utf8(bytes).ok()?.parse().ok()
418}
419
420fn is_canonical_date(value: &str) -> bool {
421    chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok()
422}
423
424fn validate_coordinate(
425    field: &'static str,
426    value: f64,
427    min: f64,
428    max: f64,
429) -> Result<(), MetadataError> {
430    if value.is_finite() && (min..=max).contains(&value) {
431        Ok(())
432    } else {
433        Err(MetadataError::InvalidCoordinate { field, value })
434    }
435}
436
437/// Parse an HFDTE record into `YYYY-MM-DD`.
438///
439/// Handles both `HFDTE020714` and `HFDTEDATE:020714` forms.
440fn parse_hfdte(line: &str) -> Option<String> {
441    // Collect all digit runs
442    let digits: String = line.chars().filter(|c| c.is_ascii_digit()).collect();
443    if digits.len() < 6 {
444        return None;
445    }
446    let d = &digits[digits.len() - 6..];
447    let dd = &d[0..2];
448    let mm = &d[2..4];
449    let yy = &d[4..6];
450    let yyyy = format!("20{yy}");
451    Some(format!("{yyyy}-{mm}-{dd}"))
452}
453
454/// Extract the value after the first `:` in a H record, trimmed.
455fn h_colon_value(line: &str) -> Option<String> {
456    line.find(':')
457        .map(|i| line[i + 1..].trim().to_string())
458        .filter(|s| !s.is_empty())
459}
460
461/// Combine a flight date (`YYYY-MM-DD`) and a B-record time (`HHMMSS`) into
462/// an RFC 3339 UTC timestamp.
463fn build_timestamp(date: Option<&str>, time: Option<&str>) -> Option<String> {
464    match (date, time) {
465        (Some(d), Some(t)) if t.len() == 6 => {
466            let h = &t[0..2];
467            let m = &t[2..4];
468            let s = &t[4..6];
469            Some(format!("{d}T{h}:{m}:{s}Z"))
470        }
471        _ => None,
472    }
473}
474
475/// True if the HHMMSS end time is earlier than the HHMMSS start time,
476/// indicating a midnight crossing.
477fn is_midnight_crossing(start: Option<&str>, end: Option<&str>) -> bool {
478    let to_secs = |t: &str| -> Option<u64> {
479        if t.len() != 6 {
480            return None;
481        }
482        let h: u64 = t[0..2].parse().ok()?;
483        let m: u64 = t[2..4].parse().ok()?;
484        let s: u64 = t[4..6].parse().ok()?;
485        Some(h * 3600 + m * 60 + s)
486    };
487    match (start.and_then(to_secs), end.and_then(to_secs)) {
488        (Some(ss), Some(es)) => es < ss,
489        _ => false,
490    }
491}
492
493/// Advance a `YYYY-MM-DD` date string by one day.  Returns `None` if the
494/// input is absent or unparseable.
495fn next_day(date: &Option<String>) -> Option<String> {
496    use chrono::NaiveDate;
497    let d = date.as_deref()?;
498    let parsed = NaiveDate::parse_from_str(d, "%Y-%m-%d").ok()?;
499    Some(parsed.succ_opt()?.format("%Y-%m-%d").to_string())
500}
501
502/// Compute duration in seconds between two HHMMSS strings, handling midnight wrap.
503fn compute_duration_s(start: Option<&str>, end: Option<&str>) -> Option<u64> {
504    let (start, end) = (start?, end?);
505    if start.len() != 6 || end.len() != 6 {
506        return None;
507    }
508    let to_secs = |t: &str| -> Option<u64> {
509        let h: u64 = t[0..2].parse().ok()?;
510        let m: u64 = t[2..4].parse().ok()?;
511        let s: u64 = t[4..6].parse().ok()?;
512        Some(h * 3600 + m * 60 + s)
513    };
514    let ss = to_secs(start)?;
515    let es = to_secs(end)?;
516    if es >= ss {
517        Some(es - ss)
518    } else {
519        Some(es + 86400 - ss) // midnight crossing
520    }
521}
522
523// ── Tests ─────────────────────────────────────────────────────────────────────
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use crate::id::{Blake3Hex, NodeIdHex};
529
530    // Valid IGC B record layout (35 chars minimum):
531    //   B HHMMSS DDMMmmmN DDDMMmmmE/W V PPPPP GGGGG
532    //   0 123456 78901234 567890123   4 56789 01234
533    //   positions in the line (0-indexed)
534    //
535    // Validity 'V' at position 24; 'A' = 3D fix, 'V' = invalid.
536    const MINIMAL_IGC: &str = "\
537AXXX001 TestDevice\r\n\
538HFDTE020714\r\n\
539HFPLTPILOTINCHARGE:Jane Doe\r\n\
540HFGTYGLIDERTYPE:Advance Sigma 10\r\n\
541HFGIDGLIDERID:HB-1234\r\n\
542B1200004728000N00836000EV0010001000\r\n\
543B1300004730000N00837000EA0030003000\r\n\
544B1400004732000N00838000EA0150001500\r\n\
545";
546
547    fn fake_hash() -> Blake3Hex {
548        Blake3Hex::parse(
549            "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
550        )
551        .unwrap()
552    }
553
554    fn fake_node_id() -> NodeIdHex {
555        NodeIdHex::parse(
556            "aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd",
557        )
558        .unwrap()
559    }
560
561    #[test]
562    fn from_igc_bytes_populates_fields() {
563        let meta = FlightMetadata::from_igc_bytes(
564            MINIMAL_IGC.as_bytes(),
565            fake_hash(),
566            Some("test.igc"),
567            Some(fake_node_id()),
568        );
569        assert_eq!(meta.schema, "igc-net/metadata");
570        assert_eq!(meta.schema_version, 1);
571        assert_eq!(meta.igc_hash, fake_hash());
572        assert_eq!(meta.original_filename.as_deref(), Some("test.igc"));
573        assert_eq!(meta.flight_date.as_deref(), Some("2014-07-02"));
574        assert_eq!(meta.pilot_name.as_deref(), Some("Jane Doe"));
575        assert_eq!(meta.glider_type.as_deref(), Some("Advance Sigma 10"));
576        assert_eq!(meta.glider_id.as_deref(), Some("HB-1234"));
577        assert_eq!(meta.fix_count, Some(3));
578        assert_eq!(meta.valid_fix_count, Some(2)); // first record is 'V', last two are 'A'
579        assert_eq!(meta.launch_lat, Some(47.46666666666667));
580        assert_eq!(meta.launch_lon, Some(8.6));
581        assert_eq!(meta.landing_lat, Some(47.53333333333333));
582        assert_eq!(meta.landing_lon, Some(8.633333333333333));
583        assert_eq!(meta.max_alt_m, Some(1500));
584        assert_eq!(meta.min_alt_m, Some(100));
585        assert!(meta.bbox.is_some());
586        assert_eq!(
587            meta.published_at.as_deref(),
588            meta.published_at
589                .as_deref()
590                .filter(|value| is_canonical_utc_timestamp(value))
591        );
592    }
593
594    #[test]
595    fn to_blob_bytes_round_trip() {
596        let meta = FlightMetadata::new(fake_hash());
597        let bytes = meta.to_blob_bytes().unwrap();
598        let parsed: FlightMetadata = serde_json::from_slice(&bytes).unwrap();
599        assert_eq!(parsed.igc_hash, fake_hash());
600    }
601
602    #[test]
603    fn null_omission_no_null_in_json() {
604        let meta = FlightMetadata::new(fake_hash());
605        let json = String::from_utf8(meta.to_blob_bytes().unwrap()).unwrap();
606        assert!(
607            !json.contains("null"),
608            "JSON must not contain 'null': {json}"
609        );
610    }
611
612    #[test]
613    fn validate_rejects_wrong_schema() {
614        let mut meta = FlightMetadata::new(fake_hash());
615        meta.schema = "wrong".to_string();
616        assert!(matches!(
617            meta.validate(),
618            Err(MetadataError::WrongSchema(_))
619        ));
620    }
621
622    #[test]
623    fn validate_rejects_wrong_version() {
624        let mut meta = FlightMetadata::new(fake_hash());
625        meta.schema_version = 99;
626        assert!(matches!(
627            meta.validate(),
628            Err(MetadataError::WrongVersion(99))
629        ));
630    }
631
632    #[test]
633    fn deserialize_rejects_malformed_hash() {
634        let json = r#"{"schema":"igc-net/metadata","schema_version":1,"igc_hash":"not-a-hash"}"#;
635        assert!(serde_json::from_str::<FlightMetadata>(json).is_err());
636    }
637
638    #[test]
639    fn deserialize_rejects_uppercase_hash() {
640        let json = r#"{"schema":"igc-net/metadata","schema_version":1,"igc_hash":"ABCD1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"}"#;
641        assert!(serde_json::from_str::<FlightMetadata>(json).is_err());
642    }
643
644    #[test]
645    fn validate_rejects_non_canonical_timestamp() {
646        let mut meta = FlightMetadata::new(fake_hash());
647        meta.published_at = Some("2026-03-29T18:07:55+00:00".to_string());
648        assert!(matches!(
649            meta.validate(),
650            Err(MetadataError::MalformedTimestamp {
651                field: "published_at",
652                ..
653            })
654        ));
655    }
656
657    #[test]
658    fn validate_rejects_invalid_flight_date() {
659        let mut meta = FlightMetadata::new(fake_hash());
660        meta.flight_date = Some("2026-02-31".to_string());
661        assert!(matches!(
662            meta.validate(),
663            Err(MetadataError::MalformedDate {
664                field: "flight_date",
665                ..
666            })
667        ));
668    }
669
670    #[test]
671    fn validate_rejects_out_of_range_coordinates() {
672        let mut meta = FlightMetadata::new(fake_hash());
673        meta.launch_lat = Some(91.0);
674        assert!(matches!(
675            meta.validate(),
676            Err(MetadataError::InvalidCoordinate {
677                field: "launch_lat",
678                ..
679            })
680        ));
681    }
682
683    #[test]
684    fn validate_rejects_invalid_bbox_bounds() {
685        let mut meta = FlightMetadata::new(fake_hash());
686        meta.bbox = Some(BoundingBox {
687            min_lat: 10.0,
688            max_lat: 5.0,
689            min_lon: 20.0,
690            max_lon: 25.0,
691        });
692        assert!(matches!(
693            meta.validate(),
694            Err(MetadataError::InvalidBounds { field: "bbox", .. })
695        ));
696    }
697
698    #[test]
699    fn validate_accepts_valid_metadata() {
700        let meta = FlightMetadata::new(fake_hash());
701        assert!(meta.validate().is_ok());
702    }
703
704    #[test]
705    fn midnight_crossing_duration() {
706        let d = compute_duration_s(Some("235900"), Some("000100"));
707        assert_eq!(d, Some(120));
708    }
709
710    #[test]
711    fn midnight_crossing_ended_at_uses_next_day() {
712        // IGC file where flight starts at 23:50 and ends at 00:10 the next day.
713        let igc = "\
714AXXX001 TestDevice\r\n\
715HFDTE310714\r\n\
716B2350004730000N00837000EA0030003000\r\n\
717B0010004732000N00838000EA0050005000\r\n\
718";
719        let meta =
720            FlightMetadata::from_igc_bytes(
721                igc.as_bytes(),
722                fake_hash(),
723                None,
724                Some(NodeIdHex::parse("cc".repeat(32)).unwrap()),
725            );
726        assert_eq!(meta.flight_date.as_deref(), Some("2014-07-31"));
727        assert_eq!(meta.started_at.as_deref(), Some("2014-07-31T23:50:00Z"));
728        assert_eq!(meta.ended_at.as_deref(), Some("2014-08-01T00:10:00Z"));
729        assert_eq!(meta.duration_s, Some(1200));
730    }
731
732    #[test]
733    fn normal_flight_ended_at_uses_same_day() {
734        let igc = "\
735AXXX001 TestDevice\r\n\
736HFDTE020714\r\n\
737B1200004730000N00837000EA0030003000\r\n\
738B1400004732000N00838000EA0050005000\r\n\
739";
740        let meta =
741            FlightMetadata::from_igc_bytes(
742                igc.as_bytes(),
743                fake_hash(),
744                None,
745                Some(NodeIdHex::parse("cc".repeat(32)).unwrap()),
746            );
747        assert_eq!(meta.started_at.as_deref(), Some("2014-07-02T12:00:00Z"));
748        assert_eq!(meta.ended_at.as_deref(), Some("2014-07-02T14:00:00Z"));
749    }
750
751    #[test]
752    fn next_day_advances_correctly() {
753        assert_eq!(
754            next_day(&Some("2014-07-31".to_string())),
755            Some("2014-08-01".to_string())
756        );
757        assert_eq!(
758            next_day(&Some("2024-12-31".to_string())),
759            Some("2025-01-01".to_string())
760        );
761        assert_eq!(next_day(&None), None);
762    }
763}