Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 262 additions & 26 deletions runtimes/rust/src/result_formatter.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
//! ResultFormatter — pushes structured items onto `DecodeResult.formatted.items`.
//!
//! Mirrors the TS `ResultFormatter` static-class API. Function names match
//! `runtimes/typescript/utils/result_formatter.ts` so the cross-language corpus
//! sees identical output shapes.
//! Mirrors the TS `ResultFormatter` static-class API byte-for-byte: item
//! `type`/`code`/`label`/`value` strings are cross-checked against
//! `runtimes/typescript/utils/result_formatter.ts` (the production reference)
//! so the shared corpus sees identical output from every language.
//!
//! Every method that takes a value accepts `impl Into<Option<JsonValue>>` so
//! callers can pass either `JsonValue` or `Option<JsonValue>` (the latter
//! arises from when-gated fields where the guard may have failed). Methods
//! no-op on None / Null / NaN, matching the original hand-written plugins'
//! pattern of only emitting items when they had a real value.
//! no-op on None / Null / NaN, matching the TS pattern of guarding before
//! emitting.

use serde_json::Value as JsonValue;
use crate::plugin::{DecodeResult, FormattedItem};
Expand All @@ -24,6 +25,39 @@ fn into_value(v: impl Into<Option<JsonValue>>) -> Option<JsonValue> {
}
}

/// `DateTimeUtils.timestampToString(time)`:
/// - < 86400 → "HH:MM:SS" (time of day)
/// - < 2678400 → "YYYY-MM-DDTHH:MM:SS" (day-of-month known, month unknown)
/// - otherwise → full ISO-8601 without millis, "Z" suffix
pub fn timestamp_to_string(time: i64) -> String {
if time < 86400 {
let h = time / 3600;
let m = (time % 3600) / 60;
let s = time % 60;
return format!("{:02}:{:02}:{:02}", h, m, s);
}
// Civil-from-days (Howard Hinnant) to render an ISO string without chrono.
let days = time.div_euclid(86400);
let secs = time.rem_euclid(86400);
let (h, mi, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
let z = days + 719468;
let era = z.div_euclid(146097);
let doe = z.rem_euclid(146097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
if time < 2678400 {
// TS: `YYYY-MM-${iso.slice(8, 19)}` — keeps day+time, masks year/month.
format!("YYYY-MM-{:02}T{:02}:{:02}:{:02}", d, h, mi, s)
} else {
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, m, d, h, mi, s)
}
}

impl ResultFormatter {
fn push(result: &mut DecodeResult, kind: &'static str, code: &'static str, label: &str, value: String) {
result.formatted.items.push(FormattedItem {
Expand All @@ -34,6 +68,14 @@ impl ResultFormatter {
});
}

/// Generic structured-item push for hatches that emit custom item shapes
/// (OHMA, WRN, SQ, ATIS, …). Stores no raw field.
pub fn push_item(result: &mut DecodeResult, kind: &'static str, code: &'static str, label: &str, value: String) {
Self::push(result, kind, code, label, value);
}

// ─── Position ────────────────────────────────────────────────────────────

pub fn position(
result: &mut DecodeResult,
latitude: impl Into<Option<JsonValue>>,
Expand All @@ -47,10 +89,11 @@ impl ResultFormatter {
}));
let lat = lat_v.as_f64().unwrap_or(0.0);
let lon = lon_v.as_f64().unwrap_or(0.0);
let lat_dir = if lat >= 0.0 { "N" } else { "S" };
let lon_dir = if lon >= 0.0 { "E" } else { "W" };
// TS CoordinateUtils.coordinateString: strictly > 0 → N/E; 0 → S/W.
let lat_dir = if lat > 0.0 { "N" } else { "S" };
let lon_dir = if lon > 0.0 { "E" } else { "W" };
let display = format!("{:.3} {}, {:.3} {}", lat.abs(), lat_dir, lon.abs(), lon_dir);
Self::push(result, "aircraft_position", "ARP", "Aircraft Position", display);
Self::push(result, "aircraft_position", "POS", "Aircraft Position", display);
}

pub fn position_value(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
Expand All @@ -60,6 +103,8 @@ impl ResultFormatter {
Self::position(result, JsonValue::from(lat), JsonValue::from(lon));
}

// ─── Numerics ────────────────────────────────────────────────────────────

pub fn altitude(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
let v = match into_value(value) { Some(v) => v, None => return };
let n = v.as_f64().unwrap_or(0.0);
Expand All @@ -78,70 +123,261 @@ impl ResultFormatter {
let v = match into_value(value) { Some(v) => v, None => return };
let n = v.as_f64().unwrap_or(0.0);
result.raw.insert("heading", v);
Self::push(result, "heading", "HDG", "Heading", format!("{}°", n));
Self::push(result, "heading", "HDG", "Heading", format!("{}", n));
}

pub fn mach(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
let v = match into_value(value) { Some(v) => v, None => return };
let n = v.as_f64().unwrap_or(0.0);
result.raw.insert("mach", v);
Self::push(result, "mach", "MACH", "Mach Number", format!("{} mach", n));
}

pub fn groundspeed(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
let v = match into_value(value) { Some(v) => v, None => return };
let n = v.as_f64().unwrap_or(0.0);
result.raw.insert("groundspeed", v);
Self::push(result, "aircraft_groundspeed", "GSPD", "Aircraft Groundspeed", format!("{} knots", n));
}

pub fn airspeed(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
let v = match into_value(value) { Some(v) => v, None => return };
let n = v.as_f64().unwrap_or(0.0);
result.raw.insert("airspeed", v);
Self::push(result, "airspeed", "ASPD", "True Airspeed", format!("{} knots", n));
}

/// TS signature takes a *string* and converts M→- / P→+ before Number().
/// No-ops on empty input.
pub fn temperature(result: &mut DecodeResult, value: &str) {
if value.is_empty() { return; }
let n: f64 = value.replace('M', "-").replace('P', "+").parse().unwrap_or(f64::NAN);
result.raw.insert("outside_air_temperature", serde_json::json!(n));
Self::push(result, "outside_air_temperature", "OATEMP", "Outside Air Temperature (C)", format!("{} degrees", n));
}

pub fn total_air_temp(result: &mut DecodeResult, value: &str) {
if value.is_empty() { return; }
let n: f64 = value.replace('M', "-").replace('P', "+").parse().unwrap_or(f64::NAN);
result.raw.insert("total_air_temperature", serde_json::json!(n));
Self::push(result, "temperature", "TATEMP", "Total Air Temperature (C)", format!("{} degrees", n));
}

// ─── Time-of-day family (value = seconds since midnight) ────────────────

fn push_tod(
result: &mut DecodeResult,
value: impl Into<Option<JsonValue>>,
raw_key: &'static str,
code: &'static str,
label: &str,
) {
let v = match into_value(value) { Some(v) => v, None => return };
let n = v.as_i64().unwrap_or(0);
result.raw.insert(raw_key, v);
Self::push(result, "time", code, label, timestamp_to_string(n));
}

pub fn timestamp(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
Self::push_tod(result, value, "message_timestamp", "TIMESTAMP", "Message Timestamp");
}

pub fn eta(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
Self::push_tod(result, value, "eta_time", "ETA", "Estimated Time of Arrival");
}

pub fn out(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
Self::push_tod(result, value, "out_time", "OUT", "Out of Gate Time");
}

pub fn off(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
Self::push_tod(result, value, "off_time", "OFF", "Takeoff Time");
}

pub fn on(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
Self::push_tod(result, value, "on_time", "ON", "Landing Time");
}

pub fn r#in(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
Self::push_tod(result, value, "in_time", "IN", "In Gate Time");
}

// ─── Calendar ────────────────────────────────────────────────────────────

fn push_int(
result: &mut DecodeResult,
value: impl Into<Option<JsonValue>>,
raw_key: &'static str,
kind: &'static str,
code: &'static str,
label: &str,
) {
let v = match into_value(value) { Some(v) => v, None => return };
let n = v.as_i64().unwrap_or(0);
result.raw.insert("message_timestamp", v);
Self::push(result, "timestamp", "TS", "Timestamp", n.to_string());
result.raw.insert(raw_key, v);
Self::push(result, kind, code, label, n.to_string());
}

pub fn day(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
Self::push_int(result, value, "day", "day", "MSG_DAY", "Day of Month");
}

pub fn month(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
Self::push_int(result, value, "month", "month", "MSG_MON", "Month of Year");
}

pub fn departure_day(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
Self::push_int(result, value, "departure_day", "day", "DEP_DAY", "Departure Day");
}

pub fn arrival_day(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
Self::push_int(result, value, "arrival_day", "day", "ARR_DAY", "Arrival Day");
}

// ─── Identifiers ─────────────────────────────────────────────────────────

pub fn callsign(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
let v = match into_value(value) { Some(v) => v, None => return };
let s = v.as_str().unwrap_or("").to_string();
result.raw.insert("callsign", v);
Self::push(result, "callsign", "CS", "Callsign", s);
Self::push(result, "callsign", "CALLSIGN", "Callsign", s);
}

pub fn flight_number(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
let v = match into_value(value) { Some(v) => v, None => return };
let s = v.as_str().unwrap_or("").to_string();
if s.is_empty() { return; }
result.raw.insert("flight_number", v);
Self::push(result, "flight_number", "FLT", "Flight Number", s);
Self::push(result, "flight_number", "FLIGHT", "Flight Number", s);
}

pub fn tail(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
let v = match into_value(value) { Some(v) => v, None => return };
let s = v.as_str().unwrap_or("").to_string();
result.raw.insert("tail", v);
Self::push(result, "tail", "TAIL", "Tail Number", s);
Self::push(result, "tail", "TAIL", "Tail", s);
}

// ─── Airports / runways ──────────────────────────────────────────────────

pub fn departure_airport(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
let v = match into_value(value) { Some(v) => v, None => return };
let s = v.as_str().unwrap_or("").to_string();
result.raw.insert("departure_icao", v);
Self::push(result, "airport_origin", "DEP", "Origin", s);
Self::push(result, "icao", "ORG", "Origin", s);
}

pub fn arrival_airport(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
let v = match into_value(value) { Some(v) => v, None => return };
let s = v.as_str().unwrap_or("").to_string();
result.raw.insert("arrival_icao", v);
Self::push(result, "airport_destination", "ARR", "Destination", s);
Self::push(result, "icao", "DST", "Destination", s);
}

pub fn fuel(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
pub fn alternate_airport(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
let v = match into_value(value) { Some(v) => v, None => return };
let s = v.as_str().unwrap_or("").to_string();
result.raw.insert("alternate_icao", v);
Self::push(result, "icao", "ALT_DST", "Alternate Destination", s);
}

pub fn arrival_runway(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the missing departure runway formatter

This runway formatter block adds arrival_runway and alternate_runway, but still omits the TS departureRunway counterpart (runway/DEPRWY/Departure Runway) even though the corpus contains DEPRWY/departure_runway outputs in arinc_702 samples. Any Rust hatch or future codegen path that needs parity for departure runway must either duplicate raw insertion plus item pushing or cannot call the parity API, so add departure_runway alongside these methods.

Useful? React with 👍 / 👎.

let v = match into_value(value) { Some(v) => v, None => return };
let s = v.as_str().unwrap_or("").to_string();
result.raw.insert("arrival_runway", v);
Self::push(result, "runway", "ARWY", "Arrival Runway", s);
}

pub fn alternate_runway(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
let v = match into_value(value) { Some(v) => v, None => return };
let s = v.as_str().unwrap_or("").to_string();
result.raw.insert("alternate_runway", v);
Self::push(result, "runway", "ALT_ARWY", "Alternate Runway", s);
}

// ─── Fuel ────────────────────────────────────────────────────────────────

pub fn current_fuel(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
let v = match into_value(value) { Some(v) => v, None => return };
let n = v.as_f64().unwrap_or(0.0);
result.raw.insert("fuel_on_board", v);
Self::push(result, "fuel", "FUEL", "Fuel", n.to_string());
Self::push(result, "fuel_on_board", "FOB", "Fuel On Board", format!("{}", n));
}

pub fn unknown_arr(result: &mut DecodeResult, values: Vec<String>) {
let joined = values.join(",");
match result.remaining.text.as_mut() {
Some(t) => { t.push(','); t.push_str(&joined); }
None => result.remaining.text = Some(joined),
}
/// Alias kept for the codegen's `fuel` formatter type.
pub fn fuel(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
Self::current_fuel(result, value);
}

pub fn remaining_fuel(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
let v = match into_value(value) { Some(v) => v, None => return };
let n = v.as_f64().unwrap_or(0.0);
result.raw.insert("fuel_remaining", v);
// The leading space in " FUEL_REM" is present in the TS source; kept
// for byte parity until fixed upstream.
Self::push(result, "fuel_remaining", " FUEL_REM", "Fuel Remaining", format!("{}", n));
}

// ─── Events ──────────────────────────────────────────────────────────────

pub fn state_change(result: &mut DecodeResult, from: &str, to: &str) {
result.raw.insert("state_change", serde_json::json!({ "from": from, "to": to }));
// TS routes from/to through RouteUtils.formatFlightState before
// display; the Rust runtime doesn't have that table yet, so raw codes
// are emitted. Revisit when RouteUtils lands (corpus will flag it).
Self::push(result, "state_change", "STATE_CHANGE", "State Change", format!("{} -> {}", from, to));
}

pub fn door_event(result: &mut DecodeResult, door: &str, state: &str) {
result.raw.insert("door_event", serde_json::json!({ "door": door, "state": state }));
Self::push(result, "door_event", "DOOR", "Door Event", format!("{} {}", door, state));
}

// ─── Free text ───────────────────────────────────────────────────────────

pub fn text(result: &mut DecodeResult, value: &str) {
result.raw.insert("text", serde_json::json!(value));
Self::push(result, "text", "TEXT", "Text Message", value.to_string());
}

// ─── Diagnostics ─────────────────────────────────────────────────────────

pub fn checksum(result: &mut DecodeResult, value: impl Into<Option<JsonValue>>) {
let v = match into_value(value) { Some(v) => v, None => return };
let n = v.as_i64().unwrap_or(0);
result.raw.insert("checksum", v);
// TS: '0x' + ('0000' + hex).slice(-4) — always exactly 4 lowercase hex chars.
let hex = format!("{:04x}", n);
let tail4 = &hex[hex.len().saturating_sub(4)..];
Self::push(result, "message_checksum", "CHECKSUM", "Message Checksum", format!("0x{}", tail4));
}

/// TS stores the raw field but pushes NO formatted item (the item block
/// is commented out in the reference). Mirror that exactly.
pub fn checksum_algorithm(result: &mut DecodeResult, value: &str) {
result.raw.insert("checksum_algorithm", serde_json::json!(value));
}

// ─── Remaining-text accumulation ─────────────────────────────────────────

pub fn unknown(result: &mut DecodeResult, value: String) {
Self::unknown_sep(result, &value, ",");
}

pub fn unknown_sep(result: &mut DecodeResult, value: &str, sep: &str) {
match result.remaining.text.as_mut() {
Some(t) => { t.push(','); t.push_str(&value); }
None => result.remaining.text = Some(value),
Some(t) => { t.push_str(sep); t.push_str(value); }
None => result.remaining.text = Some(value.to_string()),
}
}

pub fn unknown_arr(result: &mut DecodeResult, values: Vec<String>) {
Self::unknown_arr_sep(result, values, ",");
}

pub fn unknown_arr_sep(result: &mut DecodeResult, values: Vec<String>, sep: &str) {
if values.is_empty() { return; }
let joined = values.join(sep);
Self::unknown_sep(result, &joined, sep);
}
}
Loading