///|
fn json_escape(text : String) -> String {
text.replace_all(old="\\", new="\\\\").replace_all(old="\"", new="\\\"")
}
///|
fn quote(text : String) -> String {
"\"\{json_escape(text)}\""
}
///|
fn opt_int_json(value : Int?) -> String {
match value {
Some(number) => "\{number}"
None => "null"
}
}
///|
fn opt_int_text(value : Int?) -> String {
match value {
Some(number) => "\{number}"
None => ""
}
}
///|
fn weather_description_json(weather : Array[Weather]) -> String {
let mut out = "["
let mut first = true
for item in weather {
if first {
first = false
} else {
out = out + ","
}
out = out + quote(item.description.replace_all(old=" ", new="_"))
}
out + "]"
}
///|
fn cloud_coverage_text(coverage : CloudCoverage) -> String {
match coverage {
Few => "few"
Scattered => "scattered"
Broken => "broken"
Overcast => "overcast"
SkyClear => "sky_clear"
NoSignificantCloud => "no_significant_cloud"
NoCloudDetected => "no_cloud_detected"
}
}
///|
fn clouds_json(clouds : Array[CloudLayer]) -> String {
let mut out = "["
let mut first = true
for cloud in clouds {
if first {
first = false
} else {
out = out + ","
}
out = out +
"{\"coverage\":" +
quote(cloud_coverage_text(cloud.coverage)) +
",\"height_feet\":" +
opt_int_json(cloud.height_feet) +
"}"
}
out + "]"
}
///|
fn wind_json(wind : Wind) -> String {
let unit = match wind.unit {
Knots => "kt"
MetersPerSecond => "mps"
}
"{\"direction_degrees\":\{opt_int_json(wind.direction_degrees)},\"variable\":\{wind.variable_direction},\"speed\":\{wind.speed},\"gust\":\{opt_int_json(wind.gust)},\"unit\":\{quote(unit)}}"
}
///|
fn visibility_json(visibility : Visibility) -> String {
let unit = match visibility.unit {
Meters => "meters"
StatuteMiles => "sm"
}
"{\"value\":\{visibility.value},\"unit\":\{quote(unit)},\"greater_than\":\{visibility.greater_than}}"
}
///|
fn rvr_trend_text(trend : RvrTrend) -> String {
match trend {
RvrIncreasing => "increasing"
RvrDecreasing => "decreasing"
RvrNoChange => "no_change"
}
}
///|
fn rvr_json_item(rvr : RunwayVisualRange) -> String {
let maximum = opt_int_json(rvr.maximum_meters)
let trend = match rvr.trend {
Some(value) => quote(rvr_trend_text(value))
None => "null"
}
"{\"runway\":\{quote(rvr.runway)},\"minimum_meters\":\{rvr.minimum_meters},\"maximum_meters\":\{maximum},\"above_maximum\":\{rvr.above_maximum},\"below_minimum\":\{rvr.below_minimum},\"trend\":\{trend}}"
}
///|
fn rvr_json(rvr_items : Array[RunwayVisualRange]) -> String {
let mut out = "["
let mut first = true
for rvr in rvr_items {
if first {
first = false
} else {
out = out + ","
}
out = out + rvr_json_item(rvr)
}
out + "]"
}
///|
fn rvr_summary_item(rvr : RunwayVisualRange) -> String {
let prefix = if rvr.below_minimum {
"<"
} else if rvr.above_maximum {
">"
} else {
""
}
let value = match rvr.maximum_meters {
Some(maximum) => "\{rvr.minimum_meters}-\{maximum}"
None => prefix + "\{rvr.minimum_meters}"
}
let trend = match rvr.trend {
Some(value) => " " + rvr_trend_text(value)
None => ""
}
"R\{rvr.runway} \{value} m\{trend}"
}
///|
fn rvr_summary(rvr_items : Array[RunwayVisualRange]) -> String {
rvr_items.map(fn(item) { rvr_summary_item(item) }).join(", ")
}
///|
fn pressure_json(pressure : Pressure) -> String {
let unit = match pressure.unit {
HectoPascal => "hpa"
InchesHgHundredths => "inhg_hundredths"
}
"{\"value\":\{pressure.value},\"unit\":\{quote(unit)}}"
}
///|
fn report_kind_text(kind : ReportKind) -> String {
match kind {
Metar => "METAR"
Speci => "SPECI"
UnspecifiedReport => "UNSPECIFIED"
}
}
///|
/// Convert a parsed METAR/SPECI report to compact JSON without external runtime
/// dependencies. This keeps the MVP portable across native, JS and Wasm targets.
pub fn metar_to_json(report : MetarReport) -> String {
let wind = match report.wind {
Some(value) => wind_json(value)
None => "null"
}
let visibility = match report.visibility {
Some(value) => visibility_json(value)
None => "null"
}
let temperature = match report.temperature {
Some(value) =>
"{\"air_celsius\":\{value.air_celsius},\"dew_point_celsius\":\{value.dew_point_celsius}}"
None => "null"
}
let pressure = match report.pressure {
Some(value) => pressure_json(value)
None => "null"
}
let trend = match report.trend {
Some(NoSignificantChange) => quote("no_significant_change")
None => "null"
}
"{\"kind\":\{quote(report_kind_text(report.kind))},\"station\":\{quote(report.station)},\"observation_time\":{\"day\":\{report.observation_time.day},\"hour\":\{report.observation_time.hour},\"minute\":\{report.observation_time.minute}},\"automated\":\{report.automated},\"correction\":\{report.correction},\"wind\":\{wind},\"visibility\":\{visibility},\"rvr\":\{rvr_json(report.rvr)},\"weather\":\{weather_description_json(report.weather)},\"clouds\":\{clouds_json(report.clouds)},\"temperature\":\{temperature},\"pressure\":\{pressure},\"trend\":\{trend}}"
}
///|
pub fn metar_to_summary(report : MetarReport) -> String {
let mut out = "Station: \{report.station}\nTime: \{report.observation_time.to_string()}"
match report.wind {
Some(wind) => {
let direction = match wind.direction_degrees {
Some(value) => "\{value} deg"
None => "VRB"
}
out = out +
"\nWind: \{direction} at \{wind.speed} \{wind.unit.to_string()}"
}
None => ()
}
match report.visibility {
Some(visibility) => {
let unit = match visibility.unit {
Meters => "m"
StatuteMiles => "SM"
}
out = out + "\nVisibility: \{visibility.value} \{unit}"
}
None => ()
}
if report.rvr.length() > 0 {
out = out + "\nRVR: " + rvr_summary(report.rvr)
}
if report.weather.length() > 0 {
out = out +
"\nWeather: " +
report.weather.map(fn(item) { item.description }).join(", ")
}
if report.clouds.length() > 0 {
out = out +
"\nClouds: " +
report.clouds
.map(fn(cloud) {
match cloud.height_feet {
Some(height) => "\{cloud_coverage_text(cloud.coverage)} \{height} ft"
None => cloud_coverage_text(cloud.coverage)
}
})
.join(", ")
}
match report.temperature {
Some(temp) =>
out = out +
"\nTemperature: \{temp.air_celsius} C / Dew point: \{temp.dew_point_celsius} C"
None => ()
}
match report.pressure {
Some(pressure) => {
let unit = match pressure.unit {
HectoPascal => "hPa"
InchesHgHundredths => "inHg/100"
}
out = out + "\nPressure: \{pressure.value} \{unit}"
}
None => ()
}
out
}
///|
fn taf_wind_summary(wind : Wind) -> String {
let direction = match wind.direction_degrees {
Some(value) => "\{value} deg"
None => "VRB"
}
"wind \{direction} at \{wind.speed} \{wind.unit.to_string()}"
}
///|
fn taf_visibility_summary(visibility : Visibility) -> String {
let unit = match visibility.unit {
Meters => "m"
StatuteMiles => "SM"
}
"visibility \{visibility.value} \{unit}"
}
///|
fn taf_conditions_summary(conditions : TafConditions) -> String {
let parts : Array[String] = []
match conditions.wind {
Some(wind) => parts.push(taf_wind_summary(wind))
None => ()
}
match conditions.visibility {
Some(visibility) => parts.push(taf_visibility_summary(visibility))
None => ()
}
if conditions.rvr.length() > 0 {
parts.push("RVR " + rvr_summary(conditions.rvr))
}
if conditions.weather.length() > 0 {
parts.push(
"weather " +
conditions.weather.map(fn(item) { item.description }).join(", "),
)
}
if conditions.clouds.length() > 0 {
parts.push(
"clouds " +
conditions.clouds
.map(fn(cloud) {
match cloud.height_feet {
Some(height) => "\{cloud_coverage_text(cloud.coverage)} \{height} ft"
None => cloud_coverage_text(cloud.coverage)
}
})
.join(", "),
)
}
if parts.length() == 0 {
if conditions.raw_tokens.length() == 0 {
"no decoded conditions"
} else {
"raw " + conditions.raw_tokens.join(" ")
}
} else {
parts.join("; ")
}
}
///|
fn taf_entry_period_summary(period : TafPeriod?) -> String {
match period {
Some(value) => value.to_string()
None => "period unavailable"
}
}
///|
/// Render a parsed TAF timeline as a compact text block for CLI demos and docs.
pub fn taf_timeline_to_summary(report : TafReport) -> String {
let mut out = "TAF " +
report.station +
"\nValid: " +
report.valid_period.to_string()
match report.issue_time {
Some(time) => out = out + "\nIssued: " + time.to_string()
None => ()
}
for entry in taf_timeline(report) {
let probability = match entry.probability_percent {
Some(value) => " PROB\{value}"
None => ""
}
out = out +
"\n" +
entry.label +
probability +
" " +
taf_entry_period_summary(entry.period) +
" -> " +
taf_conditions_summary(entry.conditions)
}
out
}
///|
fn csv_quote(text : String) -> String {
quote(text.replace_all(old="\n", new=" "))
}
///|
fn metar_csv_header() -> String {
"station,kind,category,day,hour,minute,wind_speed,visibility,rvr_count,ceiling_feet,weather,temperature_c,dew_point_c,pressure"
}
///|
fn metar_csv_row(report : MetarReport) -> String {
let category = flight_category_to_string(classify(report))
let wind_speed = match report.wind {
Some(wind) => "\{wind.speed}"
None => ""
}
let visibility = match report.visibility {
Some(value) => "\{value.value}"
None => ""
}
let ceiling = opt_int_text(ceiling_feet(report))
let weather = report.weather.map(fn(item) { item.description }).join("; ")
let temperature = match report.temperature {
Some(value) => "\{value.air_celsius}"
None => ""
}
let dew_point = match report.temperature {
Some(value) => "\{value.dew_point_celsius}"
None => ""
}
let pressure = match report.pressure {
Some(value) => "\{value.value}"
None => ""
}
[
csv_quote(report.station),
csv_quote(report_kind_text(report.kind)),
csv_quote(category),
"\{report.observation_time.day}",
"\{report.observation_time.hour}",
"\{report.observation_time.minute}",
wind_speed,
visibility,
"\{report.rvr.length()}",
ceiling,
csv_quote(weather),
temperature,
dew_point,
pressure,
].join(",")
}
///|
/// Convert one parsed METAR/SPECI report into a one-row CSV document.
pub fn metar_to_csv(report : MetarReport) -> String {
metar_csv_header() + "\n" + metar_csv_row(report)
}
///|
/// Convert one parsed METAR/SPECI report into a Markdown summary card.
pub fn metar_to_markdown(report : MetarReport) -> String {
let mut out = "## " + report_kind_text(report.kind) + " " + report.station
out = out + "\n\n| Field | Value |\n| --- | --- |"
out = out +
"\n| Category | " +
flight_category_to_string(classify(report)) +
" |"
out = out + "\n| Time | " + report.observation_time.to_string() + " |"
match report.wind {
Some(wind) => out = out + "\n| Wind | " + taf_wind_summary(wind) + " |"
None => ()
}
match report.visibility {
Some(visibility) =>
out = out +
"\n| Visibility | " +
taf_visibility_summary(visibility) +
" |"
None => ()
}
if report.rvr.length() > 0 {
out = out + "\n| RVR | " + rvr_summary(report.rvr) + " |"
}
if report.weather.length() > 0 {
out = out +
"\n| Weather | " +
report.weather.map(fn(item) { item.description }).join(", ") +
" |"
}
if report.clouds.length() > 0 {
out = out +
"\n| Clouds | " +
report.clouds
.map(fn(cloud) {
match cloud.height_feet {
Some(height) => "\{cloud_coverage_text(cloud.coverage)} \{height} ft"
None => cloud_coverage_text(cloud.coverage)
}
})
.join(", ") +
" |"
}
match report.temperature {
Some(temp) =>
out = out +
"\n| Temperature | \{temp.air_celsius} C / dew point \{temp.dew_point_celsius} C |"
None => ()
}
match report.pressure {
Some(pressure) => out = out + "\n| Pressure | \{pressure.value} |"
None => ()
}
out
}
///|
fn batch_metric_row(name : String, value : Int) -> String {
"| " + name + " | \{value} |"
}
///|
/// Render aggregate batch statistics as a Markdown table.
pub fn batch_to_markdown(batch : BatchReport) -> String {
[
"## METAR batch summary",
"",
"| Metric | Value |",
"| --- | ---: |",
batch_metric_row("Total reports", batch.total_reports),
batch_metric_row("Parsed reports", batch.parsed_reports),
batch_metric_row("Reports with errors", batch.error_reports),
batch_metric_row("Reports with warnings", batch.warning_reports),
batch_metric_row("Diagnostics", batch.diagnostics_count),
batch_metric_row("VFR", batch.vfr_count),
batch_metric_row("MVFR", batch.mvfr_count),
batch_metric_row("IFR", batch.ifr_count),
batch_metric_row("LIFR", batch.lifr_count),
batch_metric_row("Unknown category", batch.unknown_category_count),
batch_metric_row("Low visibility events", batch.low_visibility_events),
batch_metric_row("Low ceiling events", batch.low_ceiling_events),
].join("\n")
}
///|
/// Render aggregate batch statistics as a single-row CSV document.
pub fn batch_to_csv(batch : BatchReport) -> String {
"total_reports,parsed_reports,error_reports,warning_reports,diagnostics_count,vfr_count,mvfr_count,ifr_count,lifr_count,unknown_category_count,low_visibility_events,low_ceiling_events\n" +
"\{batch.total_reports},\{batch.parsed_reports},\{batch.error_reports},\{batch.warning_reports},\{batch.diagnostics_count},\{batch.vfr_count},\{batch.mvfr_count},\{batch.ifr_count},\{batch.lifr_count},\{batch.unknown_category_count},\{batch.low_visibility_events},\{batch.low_ceiling_events}"
}