From 8130cfa07f3c1f281944b328a909649caa3fa70e Mon Sep 17 00:00:00 2001 From: Gareth Johnson <75035963+garethj2@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:37:45 +0000 Subject: [PATCH 1/7] Add brio encoding/decoding --- Cargo.toml | 5 +- fan/BrioGen.fan | 141 ++ src/haystack/encoding/brio/consts.rs | 1169 +++++++++++++++++ src/haystack/encoding/brio/decode.rs | 1032 +++++++++++++++ src/haystack/encoding/brio/encode.rs | 994 ++++++++++++++ src/haystack/encoding/brio/haxall_fixtures.rs | 713 ++++++++++ src/haystack/encoding/brio/mod.rs | 39 + src/haystack/encoding/mod.rs | 2 + 8 files changed, 4094 insertions(+), 1 deletion(-) create mode 100644 fan/BrioGen.fan create mode 100644 src/haystack/encoding/brio/consts.rs create mode 100644 src/haystack/encoding/brio/decode.rs create mode 100644 src/haystack/encoding/brio/encode.rs create mode 100644 src/haystack/encoding/brio/haxall_fixtures.rs create mode 100644 src/haystack/encoding/brio/mod.rs diff --git a/Cargo.toml b/Cargo.toml index f6b3db0..065bc1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,13 +46,16 @@ units = ["units-db"] units-db = [] timezone = ["timezone-db"] timezone-db = [] -encoders = ["value", "json", "zinc"] +encoders = ["value", "json", "zinc", "brio"] json = ["json-encoding", "json-decoding"] json-encoding = [] json-decoding = [] zinc = ["zinc-encoding", "zinc-decoding"] zinc-encoding = [] zinc-decoding = [] +brio = ["brio-encoding", "brio-decoding"] +brio-encoding = [] +brio-decoding = [] [dependencies] serde = "1.0" diff --git a/fan/BrioGen.fan b/fan/BrioGen.fan new file mode 100644 index 0000000..025ec26 --- /dev/null +++ b/fan/BrioGen.fan @@ -0,0 +1,141 @@ +// +// Copyright (c) 2026, J2 Innovations +// All Rights Reserved +// +// History: +// 25 Feb 26 Gareth Johnson Creation +// + +using haystack + +** +** In order to test Brio (binary encoding/decoding for haystack data), we ideally need +** to generate byte dumps of all the various haystack data types from Haxall (https://haxall.io). +** Running this Fantom code will then generate an output that can be used in +** src/haystack/encoding/brio/haxall_features.rs for further testing. +** +class BrioGen { + + Buf encode(Obj? val) { + buf := Buf() + BrioWriter(buf.out).writeVal(val) + return buf.flip + } + + Void p(Str name, Obj? val) { + echo("${name}|${encode(val).toHex}") + } + + static Void main() { + g := BrioGen() + // scalars + g.p("null", null) + g.p("marker", Marker.val) + g.p("na", NA.val) + g.p("remove", Remove.val) + g.p("false", false) + g.p("true", true) + + // numbers - I2 range (-32767..32767) + g.p("n_zero", Number(0f)) + g.p("n_i2_42", Number(42f)) + g.p("n_i2_neg", Number(-1f)) + g.p("n_i2_max", Number(32767f)) + g.p("n_i2_min", Number(-32767f)) + + // numbers - I4 range + g.p("n_i4_pos", Number(32768f)) + g.p("n_i4_neg", Number(-32768f)) + g.p("n_i4_max", Number(2147483647f)) + g.p("n_i4_min", Number(-2147483648f)) + + // numbers - F8 (float / out of i4 range) + g.p("n_f8_pi", Number(3.141592653589793f)) + g.p("n_f8_big", Number(2147483648f)) + g.p("n_f8_bigneg", Number(-2147483649f)) + + // numbers with const-table units + g.p("n_degF", Number(98.6f, Unit("\u00b0F"))) + g.p("n_kW", Number(1500f, Unit("kW"))) + g.p("n_kWh", Number(99f, Unit("kWh"))) + g.p("n_degC", Number(22f, Unit("\u00b0C"))) + g.p("n_pct", Number(75f, Unit("%"))) + g.p("n_cfm", Number(400f, Unit("cfm"))) + + // strings + g.p("str_empty", "") + g.p("str_hello", "hello") + g.p("str_ny", "New_York") + g.p("str_siteRef", "siteRef") + g.p("str_dis", "dis") + g.p("str_cafe", "caf\u00e9") + g.p("str_degF", "temp \u00b0F") + + // URIs + g.p("uri_http", `http://example.com/`) + g.p("uri_path", `a/b/c`) + + // Refs - I8 form (standard 8-byte XXXXXXXX-YYYYYYYY) + g.p("ref_i8_nodis", Ref("1deb31b8-7508b187", null)) + g.p("ref_i8_dis", Ref("1deb31b8-7508b187", "hi!")) + g.p("ref_i8_dis2", Ref("cafebabe-deadbeef", "Site \u0394")) + + // Refs - STR form (non-standard id) + g.p("ref_str_nodis", Ref("1debX1b8-7508b187", null)) + g.p("ref_str_dis", Ref("custom.ref", "My Equip")) + + // Dates + g.p("date_2015", Date(2015, Month.nov, 30)) + g.p("date_2000", Date(2000, Month.jan, 1)) + g.p("date_1970", Date(1970, Month.jan, 1)) + g.p("date_1950", Date(1950, Month.jun, 7)) + g.p("date_2099", Date(2099, Month.dec, 31)) + + // Times + g.p("time_midnight", Time(0, 0, 0, 0)) + g.p("time_noon", Time(12, 0, 0, 0)) + g.p("time_hms", Time(15, 6, 13, 0)) + g.p("time_ms", Time(15, 6, 13, 123000000)) + + // DateTimes - I4 (no sub-second), const tz + g.p("dt_i4_ny", DateTime.fromStr("2015-11-30T12:03:57-05:00 New_York")) + g.p("dt_i4_utc", DateTime.fromStr("2021-06-15T12:00:00Z UTC")) + g.p("dt_i4_pre2k", DateTime.fromStr("1999-06-07T01:02:00-04:00 New_York")) + g.p("dt_i4_pre70", DateTime.fromStr("1950-06-07T01:02:00-04:00 New_York")) + + // DateTimes - I4, non-const tz + g.p("dt_i4_warsaw", DateTime.fromStr("2000-01-01T00:00:00+01:00 Warsaw")) + + // DateTimes - I8 (with sub-second), const tz + g.p("dt_i8_ny_ms", DateTime.fromStr("2015-11-30T12:02:33.378-05:00 New_York")) + g.p("dt_i8_ny_us", DateTime.fromStr("2015-11-30T12:03:57.000123-05:00 New_York")) + g.p("dt_i8_pre70", DateTime.fromStr("1950-06-07T01:02:00.123-04:00 New_York")) + + // DateTimes - I8, non-const tz + g.p("dt_i8_warsaw", DateTime.fromStr("2000-01-01T00:00:00.832+01:00 Warsaw")) + + // Coords + g.p("coord_pos", Coord(37.54f, 77.43f)) + g.p("coord_neg", Coord(-17.535f, -149.569f)) + g.p("coord_zero", Coord(0f, 0f)) + + // Symbols + g.p("sym_const", Symbol("coolingTower")) + g.p("sym_inline", Symbol("foo-bar")) + g.p("sym_site", Symbol("site")) + + // XStr + g.p("xstr_foo", XStr("Foo", "bar")) + + // Dicts + g.p("dict_empty", Etc.makeDict(Str:Obj?[:])) + g.p("dict_dis", Etc.makeDict(["dis": "Hello"])) + g.p("dict_site", Etc.makeDict(["dis": "Site", "site": Marker.val])) + g.p("dict_num", Etc.makeDict(["val": Number(123f, Unit("kW"))])) + + // Lists + g.p("list_empty", Obj?[,]) + g.p("list_marker", Obj?[Marker.val]) + g.p("list_mixed", Obj?["hello", Number(42f), Marker.val]) + } +} diff --git a/src/haystack/encoding/brio/consts.rs b/src/haystack/encoding/brio/consts.rs new file mode 100644 index 0000000..6c8d74c --- /dev/null +++ b/src/haystack/encoding/brio/consts.rs @@ -0,0 +1,1169 @@ +// Copyright (C) 2020 - 2022, J2 Innovations + +//! Brio string constant table. +//! +//! The Brio encoding avoids repeating common strings by encoding them as a +//! compact varint index into this shared constant table. The table is taken +//! directly from the canonical `brio-consts.txt` shipped with the Haxall +//! project (version 2.1.13). +//! +//! Index 0 is the empty string `""` — Haxall encodes it as `varint(0)` on the +//! wire (confirmed by `BrioTest.fan`: `verifyConsts(cp, "", 0)`). All other +//! entries are 1-based. + +use std::collections::HashMap; +use std::sync::OnceLock; + +/// Canonical string constants, indexed from 0. Index 0 is the empty string. +/// Haxall encodes `""` as `varint(0)` — confirmed by `BrioTest.fan`: +/// `verifyConsts(cp, "", 0)` +pub static CONSTS: &[&str] = &[ + "", // 0 + // kinds + "Obj", // 1 + "Bin", // 2 + "Bool", // 3 + "Coord", // 4 + "Date", // 5 + "DateTime", // 6 + "Dict", // 7 + "Grid", // 8 + "List", // 9 + "Marker", // 10 + "NA", // 11 + "Number", // 12 + "Ref", // 13 + "Remove", // 14 + "Span", // 15 + "Str", // 16 + "Time", // 17 + "Uri", // 18 + "XStr", // 19 + "Obj[]", // 20 + "Str[]", // 21 + "Ref[]", // 22 + "Dict[]", // 23 + // timezones + "UTC", // 24 + "Rel", // 25 + "New_York", // 26 + "Chicago", // 27 + "Denver", // 28 + "Los_Angeles", // 29 + "Phoenix", // 30 + "Anchorage", // 31 + "Honolulu", // 32 + "Halifax", // 33 + "Winnipeg", // 34 + "Toronto", // 35 + "Montreal", // 36 + "Regina", // 37 + "Vancouver", // 38 + "Mexico_City", // 39 + "Hong_Kong", // 40 + "Shanghai", // 41 + "Seoul", // 42 + "Singapore", // 43 + "Tokyo", // 44 + "Kolkata", // 45 + "Dubai", // 46 + "Jerusalem", // 47 + "Sydney", // 48 + "Melbourne", // 49 + "Amsterdam", // 50 + "Berlin", // 51 + "Brussels", // 52 + "Copenhagen", // 53 + "Dublin", // 54 + "Istanbul", // 55 + "Lisbon", // 56 + "London", // 57 + "Madrid", // 58 + "Moscow", // 59 + "Paris", // 60 + "Stockholm", // 61 + "Vienna", // 62 + "Zurich", // 63 + // str values + "unknown", // 64 + "ok", // 65 + "down", // 66 + "fault", // 67 + "disabled", // 68 + "stale", // 69 + "remoteFault", // 70 + "remoteDown", // 71 + "remoteDisabled", // 72 + "remoteUnknown", // 73 + "pending", // 74 + "syncing", // 75 + "unbound", // 76 + "$siteRef $navName", // 77 + "$equipRef $navName", // 78 + // mime types + "text/plain", // 79 + "text/javascript", // 80 + "text/html", // 81 + "text/trio", // 82 + "text/zinc", // 83 + "text/plain; charset=utf-8", // 84 + "text/javascript; charset=utf-8", // 85 + "text/html; charset=utf-8", // 86 + "text/trio; charset=utf-8", // 87 + "text/zinc; charset=utf-8", // 88 + "image/gif", // 89 + "image/jpeg", // 90 + "image/png", // 91 + "image/svg", // 92 + "application/json", // 93 + "application/octet-stream", // 94 + "application/pdf", // 95 + "application/x-dicts", // 96 + "application/x-his", // 97 + // tags + "absorption", // 98 + "actions", // 99 + "ahu", // 100 + "ahuRef", // 101 + "air", // 102 + "airCooled", // 103 + "alarm", // 104 + "analytics", // 105 + "app", // 106 + "appFormOn", // 107 + "area", // 108 + "axAnnotated", // 109 + "axId", // 110 + "axSlotPath", // 111 + "axType", // 112 + "bacnetConn", // 113 + "bacnetConnRef", // 114 + "bacnetCur", // 115 + "bacnetHis", // 116 + "bacnetWrite", // 117 + "bacnetWriteLevel", // 118 + "blowdown", // 119 + "boiler", // 120 + "boilerPlant", // 121 + "boilerPlantRef", // 122 + "building", // 123 + "buildingRef", // 124 + "bypass", // 125 + "calendar", // 126 + "calendarRef", // 127 + "campusRef", // 128 + "centrifugal", // 129 + "chartOn", // 130 + "chilled", // 131 + "chilledBeamZone", // 132 + "chilledWaterCool", // 133 + "chilledWaterPlant", // 134 + "chiller", // 135 + "chillerWaterPlantRef", // 136 + "circuit", // 137 + "closedLoop", // 138 + "cmd", // 139 + "co", // 140 + "co2", // 141 + "coldDeck", // 142 + "color", // 143 + "condensate", // 144 + "condenser", // 145 + "conn", // 146 + "connCur", // 147 + "connErr", // 148 + "connHis", // 149 + "connRef", // 150 + "connState", // 151 + "connStatus", // 152 + "connTuning", // 153 + "connTuningRef", // 154 + "connWrite", // 155 + "connection", // 156 + "constantVolume", // 157 + "consumption", // 158 + "cool", // 159 + "coolOnly", // 160 + "cooling", // 161 + "coolingCapacity", // 162 + "coolingTower", // 163 + "cost", // 164 + "cov", // 165 + "crc", // 166 + "created", // 167 + "cur", // 168 + "curCalibration", // 169 + "curConvert", // 170 + "curErr", // 171 + "curKpi", // 172 + "curSpark", // 173 + "curStatus", // 174 + "curVal", // 175 + "current", // 176 + "damper", // 177 + "date", // 178 + "delta", // 179 + "demand", // 180 + "device", // 181 + "device1Ref", // 182 + "device2Ref", // 183 + "directZone", // 184 + "dis", // 185 + "disMacro", // 186 + "discharge", // 187 + "domestic", // 188 + "dualDuct", // 189 + "ductArea", // 190 + "dur", // 191 + "dxCool", // 192 + "effective", // 193 + "efficiency", // 194 + "elec", // 195 + "elecHeat", // 196 + "elecMeterLoad", // 197 + "elecMeterRef", // 198 + "elecPanel", // 199 + "elecPanelOf", // 200 + "elecReheat", // 201 + "email", // 202 + "enable", // 203 + "energy", // 204 + "entering", // 205 + "enum", // 206 + "equip", // 207 + "equipRef", // 208 + "evaporator", // 209 + "exhaust", // 210 + "ext", // 211 + "faceBypass", // 212 + "fan", // 213 + "fanPowered", // 214 + "fcu", // 215 + "filter", // 216 + "finAsset", // 217 + "finDependencies", // 218 + "finFile", // 219 + "finIcon", // 220 + "finProject", // 221 + "finResource", // 222 + "finRuntime", // 223 + "finScreenshot", // 224 + "finThumb", // 225 + "finUri", // 226 + "finVersion", // 227 + "floor", // 228 + "floorRef", // 229 + "flow", // 230 + "folderPath", // 231 + "formOn", // 232 + "freezeStat", // 233 + "freq", // 234 + "func", // 235 + "gas", // 236 + "gasHeat", // 237 + "gasMeterLoad", // 238 + "geoAddr", // 239 + "geoCity", // 240 + "geoCoord", // 241 + "geoCountry", // 242 + "geoCounty", // 243 + "geoPostalCode", // 244 + "geoState", // 245 + "geoStreet", // 246 + "graphicOn", // 247 + "haystackConnRef", // 248 + "haystackCur", // 249 + "haystackHis", // 250 + "haystackWrite", // 251 + "haystackWriteLevel", // 252 + "haytackConn", // 253 + "heat", // 254 + "heatExchanger", // 255 + "heatPump", // 256 + "heatWheel", // 257 + "heating", // 258 + "help", // 259 + "helpDoc", // 260 + "his", // 261 + "hisCollectCov", // 262 + "hisCollectInterval", // 263 + "hisConvert", // 264 + "hisEnd", // 265 + "hisEndVal", // 266 + "hisErr", // 267 + "hisFunc", // 268 + "hisId", // 269 + "hisInterpolate", // 270 + "hisInterval", // 271 + "hisKpi", // 272 + "hisMode", // 273 + "hisRef", // 274 + "hisSize", // 275 + "hisSpark", // 276 + "hisStart", // 277 + "hisStatus", // 278 + "hisTotalized", // 279 + "hot", // 280 + "hotDeck", // 281 + "hotWaterHeat", // 282 + "hotWaterReheat", // 283 + "humidifier", // 284 + "humidity", // 285 + "hvac", // 286 + "id", // 287 + "imageRef", // 288 + "index", // 289 + "isolation", // 290 + "kind", // 291 + "kpi", // 292 + "kpiFunc", // 293 + "kpiOn", // 294 + "kpiRef", // 295 + "leaving", // 296 + "license", // 297 + "licenseRef", // 298 + "lightLevel", // 299 + "lighting", // 300 + "lights", // 301 + "lightsGroup", // 302 + "load", // 303 + "maint", // 304 + "maintRef", // 305 + "makeup", // 306 + "max", // 307 + "maxVal", // 308 + "meter", // 309 + "min", // 310 + "minVal", // 311 + "mixed", // 312 + "mod", // 313 + "multiZone", // 314 + "name", // 315 + "navName", // 316 + "network", // 317 + "networkRef", // 318 + "neutralDeck", // 319 + "nextTime", // 320 + "nextVal", // 321 + "note", // 322 + "noteRef", // 323 + "num", // 324 + "number", // 325 + "obixConn", // 326 + "obixConnRef", // 327 + "obixCur", // 328 + "obixHis", // 329 + "obixWrite", // 330 + "occ", // 331 + "occupancyIndicator", // 332 + "occupied", // 333 + "openLoop", // 334 + "order", // 335 + "orderItem", // 336 + "orderItemRef", // 337 + "orderRef", // 338 + "org", // 339 + "orgRef", // 340 + "outside", // 341 + "parallel", // 342 + "part", // 343 + "partRef", // 344 + "perimeterHeat", // 345 + "periods", // 346 + "pf", // 347 + "phase", // 348 + "point", // 349 + "pointRef", // 350 + "power", // 351 + "precision", // 352 + "pressure", // 353 + "pressureDependent", // 354 + "pressureIndependent", // 355 + "primaryFunction", // 356 + "primaryLoop", // 357 + "protocol", // 358 + "pump", // 359 + "reciprocal", // 360 + "refrig", // 361 + "region", // 362 + "regionRef", // 363 + "reheat", // 364 + "reheating", // 365 + "return", // 366 + "rooftop", // 367 + "rule", // 368 + "ruleFunc", // 369 + "ruleOn", // 370 + "ruleRef", // 371 + "run", // 372 + "sampled", // 373 + "schedulable", // 374 + "schedule", // 375 + "scheduleRef", // 376 + "screw", // 377 + "secondaryLoop", // 378 + "sensor", // 379 + "series", // 380 + "singleDuct", // 381 + "site", // 382 + "siteMeter", // 383 + "sitePanel", // 384 + "sitePoint", // 385 + "siteRef", // 386 + "sp", // 387 + "space", // 388 + "spaceRef", // 389 + "spark", // 390 + "speed", // 391 + "src", // 392 + "stage", // 393 + "standby", // 394 + "steam", // 395 + "steamHeat", // 396 + "steamMeterLoad", // 397 + "subPanelOf", // 398 + "submeterOf", // 399 + "summary", // 400 + "sunrise", // 401 + "supply", // 402 + "temp", // 403 + "ticket", // 404 + "ticketStatus", // 405 + "times", // 406 + "tripleDuct", // 407 + "ts", // 408 + "tz", // 409 + "unit", // 410 + "unocc", // 411 + "uri", // 412 + "user", // 413 + "userRef", // 414 + "username", // 415 + "uv", // 416 + "v0", // 417 + "v1", // 418 + "v2", // 419 + "v3", // 420 + "v4", // 421 + "v5", // 422 + "v7", // 423 + "v8", // 424 + "v9", // 425 + "val", // 426 + "valve", // 427 + "variableVolume", // 428 + "vav", // 429 + "vavMode", // 430 + "vavZone", // 431 + "version", // 432 + "vfd", // 433 + "volt", // 434 + "volume", // 435 + "water", // 436 + "waterCooled", // 437 + "waterMeterLoad", // 438 + "weather", // 439 + "weatherCond", // 440 + "weatherPoint", // 441 + "weatherRef", // 442 + "wetBulb", // 443 + "writable", // 444 + "writeConvert", // 445 + "writeErr", // 446 + "writeLevel", // 447 + "writeStatus", // 448 + "writeVal", // 449 + "yearBuilt", // 450 + "zone", // 451 + "zoneRef", // 452 + // units + "$", // 453 + "%", // 454 + "%/s", // 455 + "%RH", // 456 + "%obsc/ft", // 457 + "%obsc/m", // 458 + "/h", // 459 + "/min", // 460 + "/s", // 461 + "A", // 462 + "A/m", // 463 + "A/m²", // 464 + "AED", // 465 + "AUD", // 466 + "Am²", // 467 + "BTU", // 468 + "BTU/h", // 469 + "BTU/lb", // 470 + "C", // 471 + "CAD", // 472 + "COP", // 473 + "DCIE", // 474 + "EER", // 475 + "F", // 476 + "Fr", // 477 + "GB", // 478 + "GJ", // 479 + "GW", // 480 + "H", // 481 + "Hz", // 482 + "J", // 483 + "J/g", // 484 + "J/h", // 485 + "J/kg", // 486 + "J/kg_dry", // 487 + "J/kg°K", // 488 + "J/m²", // 489 + "J/°K", // 490 + "Js", // 491 + "K", // 492 + "K/h", // 493 + "K/min", // 494 + "K/s", // 495 + "L", // 496 + "L/h", // 497 + "L/min", // 498 + "L/s", // 499 + "MB", // 500 + "MBTU/ft²", // 501 + "MHz", // 502 + "MJ", // 503 + "MJ/ft²", // 504 + "MJ/h", // 505 + "MJ/kg_dry", // 506 + "MJ/m²", // 507 + "MJ/°K", // 508 + "MMBTU", // 509 + "MMBTU/h", // 510 + "MV", // 511 + "MVAR", // 512 + "MVARh", // 513 + "MVAh", // 514 + "MW", // 515 + "MWh", // 516 + "MWh/ft²", // 517 + "MWh/m²", // 518 + "MΩ", // 519 + "N", // 520 + "N/m", // 521 + "NIS", // 522 + "Nm", // 523 + "Ns", // 524 + "PB", // 525 + "PUE", // 526 + "Pa", // 527 + "S", // 528 + "S/m", // 529 + "T", // 530 + "TB", // 531 + "TWD", // 532 + "V", // 533 + "V/K", // 534 + "V/m", // 535 + "VA", // 536 + "VAR", // 537 + "VARh", // 538 + "VAh", // 539 + "W", // 540 + "W/cfm", // 541 + "W/ft²", // 542 + "W/ft²_irr", // 543 + "W/m°K", // 544 + "W/m²", // 545 + "W/m²K", // 546 + "W/m²_irr", // 547 + "W/m³/s", // 548 + "Wb", // 549 + "Wh", // 550 + "Wh/ft²", // 551 + "Wh/m²", // 552 + "acre", // 553 + "atm", // 554 + "bar", // 555 + "btu/lb_dry", // 556 + "byte", // 557 + "cal", // 558 + "cal/g", // 559 + "cd", // 560 + "cd/m²", // 561 + "cfh", // 562 + "cfm", // 563 + "cfs", // 564 + "cm", // 565 + "cmHg", // 566 + "cmH₂O", // 567 + "cm²", // 568 + "cm³", // 569 + "cph", // 570 + "cpm", // 571 + "cs", // 572 + "dBmV", // 573 + "dBµV", // 574 + "day", // 575 + "db", // 576 + "deg", // 577 + "degPh", // 578 + "ds", // 579 + "fl_oz", // 580 + "fnu", // 581 + "ft", // 582 + "ft/min", // 583 + "ft/s", // 584 + "ftcd", // 585 + "ftlbs/sec", // 586 + "ft²", // 587 + "ft³", // 588 + "ft³_gas", // 589 + "g", // 590 + "g/kg", // 591 + "g/min", // 592 + "g/m²", // 593 + "g/s", // 594 + "gH₂O/kgAir", // 595 + "gal", // 596 + "gal/min", // 597 + "galUK", // 598 + "galUK/min", // 599 + "h", // 600 + "hL", // 601 + "hL/s", // 602 + "hPa", // 603 + "hft³", // 604 + "hp", // 605 + "hph", // 606 + "in", // 607 + "inHg", // 608 + "inH₂O", // 609 + "in²", // 610 + "in³", // 611 + "kB", // 612 + "kBTU", // 613 + "kBTU/ft²", // 614 + "kBTU/h", // 615 + "kBTU/h/ft²", // 616 + "kHz", // 617 + "kJ", // 618 + "kJ/h", // 619 + "kJ/kg", // 620 + "kJ/kg_dry", // 621 + "kJ/°K", // 622 + "kL", // 623 + "kPa", // 624 + "kV", // 625 + "kVA", // 626 + "kVAR", // 627 + "kVARh", // 628 + "kVAh", // 629 + "kW", // 630 + "kW/ft²", // 631 + "kW/gal/min", // 632 + "kW/kcfm", // 633 + "kW/m²", // 634 + "kW/ton", // 635 + "kWh", // 636 + "kWh/ft²", // 637 + "kWh/m²", // 638 + "kcfm", // 639 + "kg", // 640 + "kg/h", // 641 + "kg/min", // 642 + "kg/m²", // 643 + "kg/m³", // 644 + "kg/s", // 645 + "kgal", // 646 + "klb", // 647 + "klb/h", // 648 + "km", // 649 + "km/h", // 650 + "km/s", // 651 + "km²", // 652 + "knot", // 653 + "kr", // 654 + "kΩ", // 655 + "lb", // 656 + "lb/h", // 657 + "lb/min", // 658 + "lb/s", // 659 + "lbf", // 660 + "lm", // 661 + "lx", // 662 + "m", // 663 + "m/h", // 664 + "m/min", // 665 + "m/s", // 666 + "m/s²", // 667 + "mA", // 668 + "mL", // 669 + "mL/s", // 670 + "mV", // 671 + "mVA", // 672 + "mW", // 673 + "mbar", // 674 + "mg", // 675 + "mile", // 676 + "mile²", // 677 + "mm", // 678 + "mm/min", // 679 + "mm/s", // 680 + "mmHg", // 681 + "mm²", // 682 + "mm³", // 683 + "mo", // 684 + "mph", // 685 + "ms", // 686 + "m²", // 687 + "m²/N", // 688 + "m³", // 689 + "m³/h", // 690 + "m³/min", // 691 + "m³/s", // 692 + "m³_gas", // 693 + "mΩ", // 694 + "ns", // 695 + "ntu", // 696 + "oz", // 697 + "pH", // 698 + "ppb", // 699 + "ppm", // 700 + "ppu", // 701 + "psi", // 702 + "psi/°F", // 703 + "pt", // 704 + "px", // 705 + "qt", // 706 + "rad", // 707 + "rad/s", // 708 + "rad/s²", // 709 + "rpm", // 710 + "s", // 711 + "sr", // 712 + "t", // 713 + "therm", // 714 + "therm/h", // 715 + "ton", // 716 + "ton/h", // 717 + "tonref", // 718 + "tonrefh", // 719 + "wk", // 720 + "yd", // 721 + "yd²", // 722 + "yd³", // 723 + "yr", // 724 + "£", // 725 + "¥", // 726 + "°C", // 727 + "°C/h", // 728 + "°C/min", // 729 + "°F", // 730 + "°F/h", // 731 + "°F/min", // 732 + "°daysC", // 733 + "°daysF", // 734 + "µg/m³", // 735 + "µm", // 736 + "µs", // 737 + "ΔK", // 738 + "Δ°C", // 739 + "Δ°F", // 740 + "руб", // 741 + "₩", // 742 + "€", // 743 + "₹", // 744 + "Ω", // 745 + "Ωm", // 746 + "元", // 747 + // 3.0.15 + "accept-charset", // 748 + "accept-encoding", // 749 + "accept-language", // 750 + "all", // 751 + "auto", // 752 + "aux", // 753 + "avg", // 754 + "baseline", // 755 + "cache-control", // 756 + "call", // 757 + "cells", // 758 + "children", // 759 + "clear", // 760 + "cloudy", // 761 + "clusterSessionKey", // 762 + "clusterUsername", // 763 + "code", // 764 + "cols", // 765 + "content", // 766 + "content-encoding", // 767 + "content-length", // 768 + "content-type", // 769 + "cookie", // 770 + "dates", // 771 + "days", // 772 + "define", // 773 + "delete", // 774 + "doc", // 775 + "equipAccessFilter", // 776 + "equips", // 777 + "etag", // 778 + "eval", // 779 + "expires", // 780 + "expr", // 781 + "extra", // 782 + "firstName", // 783 + "flurries", // 784 + "fold", // 785 + "get", // 786 + "group", // 787 + "groupBy", // 788 + "gzip", // 789 + "hasChildren", // 790 + "head", // 791 + "headers", // 792 + "hidden", // 793 + "hisRollup", // 794 + "hisRollupDis", // 795 + "hisRollupInterval", // 796 + "host", // 797 + "http", // 798 + "https", // 799 + "ice", // 800 + "icon", // 801 + "ids", // 802 + "interval", // 803 + "keep-alive", // 804 + "key", // 805 + "kpiRule", // 806 + "last-modified", // 807 + "lastName", // 808 + "list", // 809 + "manifest", // 810 + "map", // 811 + "mapToHis", // 812 + "method", // 813 + "mode", // 814 + "msg", // 815 + "msgId", // 816 + "msgType", // 817 + "names", // 818 + "op", // 819 + "options", // 820 + "opts", // 821 + "origin", // 822 + "partlyCloudy", // 823 + "pattern", // 824 + "phrase", // 825 + "pipe", // 826 + "pointAccessFilter", // 827 + "points", // 828 + "post", // 829 + "pragma", // 830 + "priority", // 831 + "projAccessFilter", // 832 + "projs", // 833 + "put", // 834 + "query", // 835 + "rain", // 836 + "read", // 837 + "readAll", // 838 + "readById", // 839 + "readByIds", // 840 + "referer", // 841 + "rollup", // 842 + "rows", // 843 + "ruleAccessFilter", // 844 + "ruleType", // 845 + "rules", // 846 + "scheme", // 847 + "sel", // 848 + "select", // 849 + "selectable", // 850 + "server", // 851 + "showers", // 852 + "siteAccessFilter", // 853 + "sites", // 854 + "snow", // 855 + "span", // 856 + "sparkRule", // 857 + "status", // 858 + "sum", // 859 + "targetRef", // 860 + "targets", // 861 + "text", // 862 + "thunderstorms", // 863 + "timeout", // 864 + "transfer-encoding", // 865 + "type", // 866 + "user-agent", // 867 + "userAdmin", // 868 + "userAuthScheme", // 869 + "view", // 870 + "viz", // 871 + // 3.0.17 + "accept", // 872 + "appName", // 873 + "base", // 874 + "batch", // 875 + "bootId", // 876 + "bootTime", // 877 + "charge", // 878 + "chargeType", // 879 + "describe", // 880 + "disKey", // 881 + "dispatch", // 882 + "err", // 883 + "errTrace", // 884 + "errType", // 885 + "executeStatus", // 886 + "executeTime", // 887 + "find", // 888 + "findAll", // 889 + "fingerprint", // 890 + "flatMap", // 891 + "folioVersion", // 892 + "hash", // 893 + "hisPageSize", // 894 + "hostId", // 895 + "hostModel", // 896 + "inRange", // 897 + "level", // 898 + "licProduct", // 899 + "locale", // 900 + "masterVer", // 901 + "maxCount", // 902 + "maxDataSize", // 903 + "node", // 904 + "nodeId", // 905 + "nonce", // 906 + "numBlobs", // 907 + "periodUnion", // 908 + "ping", // 909 + "poll", // 910 + "proj", // 911 + "pubKey", // 912 + "push", // 913 + "range", // 914 + "rangeStrategy", // 915 + "ranges", // 916 + "replicaVer", // 917 + "req", // 918 + "res", // 919 + "route", // 920 + "routeStatus", // 921 + "salt", // 922 + "scheduleVal", // 923 + "scram", // 924 + "send", // 925 + "shape", // 926 + "SHA-256", // 927 + "sig", // 928 + "skyarc-ui-session-key", // 929 + "spec", // 930 + "specVer", // 931 + "stash", // 932 + "steps", // 933 + "target", // 934 + "tariff", // 935 + "tariffHis", // 936 + "tariffRef", // 937 + "trace", // 938 + "traces", // 939 + "uiMeta", // 940 + "usageOn", // 941 + "useReplica", // 942 + "userAuth", // 943 + "userRole", // 944 + "ver", // 945 + // 3.0.25 + "admin", // 946 + "arc", // 947 + "audit", // 948 + "by", // 949 + "clusterAttestKey", // 950 + "comment", // 951 + "def", // 952 + "defx", // 953 + "file", // 954 + "input", // 955 + "is", // 956 + "item", // 957 + "items", // 958 + "of", // 959 + "parts", // 960 + "person", // 961 + "skyarc::UiDef", // 962 + "skyarc::User", // 963 + "su", // 964 + "tagOn", // 965 + "unknown,clear,partlyCloudy,cloudy,showers,rain,thunderstorms,ice,flurries,snow", // 966 + "userProto", // 967 + "userProtoName", // 968 + "userProtoRef", // 969 + // 3.0.27 + "airRef", // 970 + "arcBreakdown", // 971 + "arcBug", // 972 + "arcDamage", // 973 + "arcElectrical", // 974 + "arcEnhancement", // 975 + "arcHvac", // 976 + "arcInspection", // 977 + "arcMaintenance", // 978 + "arcOn", // 979 + "arcPlumbing", // 980 + "arcPriority", // 981 + "arcSafety", // 982 + "arcSupport", // 983 + "arcWish", // 984 + "assignedTo", // 985 + "cancelled", // 986 + "critical", // 987 + "dueDate", // 988 + "elecRef", // 989 + "high", // 990 + "low", // 991 + "medium", // 992 + "new", // 993 + "old", // 994 + "open", // 995 + "resolved", // 996 + "ticketState", // 997 + "viewLink", // 998 + "weatherStation", // 999 + "weatherStationRef", // 1000 + "workorder", // 1001 + "workorderState", // 1002 +]; + +// --------------------------------------------------------------------------- +// Reverse-lookup map (string → index), lazily initialised once. +// --------------------------------------------------------------------------- + +static CONSTS_MAP: OnceLock> = OnceLock::new(); + +fn consts_map() -> &'static HashMap<&'static str, i64> { + CONSTS_MAP.get_or_init(|| { + // Index 0 is the empty string ""; include it so lookup_const("") → Some(0). + CONSTS + .iter() + .enumerate() + .map(|(i, &s)| (s, i as i64)) + .collect() + }) +} + +/// Look up `s` in the constant table. +/// +/// Returns `Some(index)` (1-based) if found, `None` otherwise. +pub fn lookup_const(s: &str) -> Option { + consts_map().get(s).copied() +} + +/// Retrieve the constant string at `idx` (0-based; 0 = `""`). +/// +/// Returns `Some(&str)` if `idx` is valid, `None` otherwise. +pub fn get_const(idx: i64) -> Option<&'static str> { + if idx >= 0 && (idx as usize) < CONSTS.len() { + Some(CONSTS[idx as usize]) + } else { + None + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_table_length() { + // 0 placeholder + 1002 real entries + assert_eq!(CONSTS.len(), 1003); + } + + #[test] + fn test_index_zero_is_empty_string() { + // BrioTest.fan: verifyConsts(cp, "", 0) + // The empty string is const index 0 and IS used on the wire. + assert_eq!(CONSTS[0], ""); + assert_eq!(lookup_const(""), Some(0)); + assert_eq!(get_const(0), Some("")); + } + + #[test] + fn test_known_tag_indices() { + assert_eq!(CONSTS[185], "dis"); + assert_eq!(CONSTS[382], "site"); + assert_eq!(CONSTS[386], "siteRef"); + assert_eq!(CONSTS[208], "equipRef"); + assert_eq!(CONSTS[408], "ts"); + assert_eq!(CONSTS[409], "tz"); + assert_eq!(CONSTS[175], "curVal"); + assert_eq!(CONSTS[287], "id"); + assert_eq!(CONSTS[403], "temp"); + } + + #[test] + fn test_known_timezone_indices() { + assert_eq!(CONSTS[24], "UTC"); + assert_eq!(CONSTS[26], "New_York"); + assert_eq!(CONSTS[57], "London"); + assert_eq!(CONSTS[44], "Tokyo"); + } + + #[test] + fn test_known_unit_indices() { + assert_eq!(CONSTS[630], "kW"); + assert_eq!(CONSTS[636], "kWh"); + assert_eq!(CONSTS[711], "s"); + assert_eq!(CONSTS[727], "°C"); + assert_eq!(CONSTS[730], "°F"); + assert_eq!(CONSTS[563], "cfm"); + } + + #[test] + fn test_lookup_const_found() { + assert_eq!(lookup_const("dis"), Some(185)); + assert_eq!(lookup_const("site"), Some(382)); + assert_eq!(lookup_const("UTC"), Some(24)); + assert_eq!(lookup_const("kW"), Some(630)); + assert_eq!(lookup_const("°F"), Some(730)); + } + + #[test] + fn test_lookup_const_not_found() { + // "" is now index 0 — it SHOULD be found. + assert_eq!(lookup_const(""), Some(0)); + assert_eq!(lookup_const("notAConst"), None); + } + + #[test] + fn test_get_const_valid() { + assert_eq!(get_const(185), Some("dis")); + assert_eq!(get_const(24), Some("UTC")); + assert_eq!(get_const(630), Some("kW")); + assert_eq!(get_const(1002), Some("workorderState")); + } + + #[test] + fn test_get_const_invalid() { + // 0 is the empty string — now valid. + assert_eq!(get_const(0), Some("")); + assert_eq!(get_const(-1), None); + assert_eq!(get_const(1003), None); + assert_eq!(get_const(9999), None); + } + + #[test] + fn test_lookup_roundtrip() { + // Every entry including index 0 ("") should round-trip. + for (i, &s) in CONSTS.iter().enumerate() { + let idx = + lookup_const(s).unwrap_or_else(|| panic!("Missing const at index {i}: {s:?}")); + assert_eq!(idx, i as i64); + let got = get_const(idx).unwrap(); + assert_eq!(got, s); + } + } +} diff --git a/src/haystack/encoding/brio/decode.rs b/src/haystack/encoding/brio/decode.rs new file mode 100644 index 0000000..0c76d68 --- /dev/null +++ b/src/haystack/encoding/brio/decode.rs @@ -0,0 +1,1032 @@ +// Copyright (C) 2020 - 2022, J2 Innovations + +//! Brio binary decoder for Haystack values. +//! +//! Decodes Brio binary data back into Haystack [`Value`]s. + +use std::io::Read; + +use crate::haystack::val::{ + Bool, Column, Coord, Date, DateTime, Dict, Grid, List, Marker, Na, Number, Ref, Remove, Symbol, + Time, Uri, Value, XStr, +}; + +use super::consts::get_const; +use super::encode::{ + CTRL_COORD, CTRL_DATE, CTRL_DATETIME_I4, CTRL_DATETIME_I8, CTRL_DICT, CTRL_DICT_EMPTY, + CTRL_FALSE, CTRL_GRID, CTRL_LIST, CTRL_LIST_EMPTY, CTRL_MARKER, CTRL_NA, CTRL_NULL, + CTRL_NUMBER_F8, CTRL_NUMBER_I2, CTRL_NUMBER_I4, CTRL_REF_I8, CTRL_REF_STR, CTRL_REMOVE, + CTRL_STR, CTRL_SYMBOL, CTRL_TIME, CTRL_TRUE, CTRL_URI, CTRL_XSTR, FANTOM_EPOCH_UNIX_SECS, +}; + +// --------------------------------------------------------------------------- +// Error / Result +// --------------------------------------------------------------------------- + +/// Decoding error. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Error { + Message(String), + UnexpectedEof, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Message(msg) => write!(f, "{msg}"), + Error::UnexpectedEof => write!(f, "Unexpected end of Brio stream"), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(err: std::io::Error) -> Self { + if err.kind() == std::io::ErrorKind::UnexpectedEof { + Error::UnexpectedEof + } else { + Error::Message(err.to_string()) + } + } +} + +/// Result type for Brio decoding. +pub type Result = std::result::Result; + +// --------------------------------------------------------------------------- +// Public trait +// --------------------------------------------------------------------------- + +/// Decode a Haystack value from the Brio binary format. +/// +/// The inverse of [`ToBrio`]: each implementation reads the ctrl byte for its +/// type followed by the payload, mirroring the structure of +/// [`ToBrio::to_brio`]. +/// +/// [`ToBrio`]: super::encode::ToBrio +pub trait FromBrio: Sized { + /// Read the complete Brio encoding of `Self` (ctrl byte + payload) from `reader`. + fn from_brio(reader: &mut R) -> Result; +} + +// --------------------------------------------------------------------------- +// Low-level read helpers +// --------------------------------------------------------------------------- + +fn read_u8(reader: &mut R) -> Result { + let mut buf = [0u8; 1]; + reader.read_exact(&mut buf)?; + Ok(buf[0]) +} + +fn read_i16(reader: &mut R) -> Result { + let mut buf = [0u8; 2]; + reader.read_exact(&mut buf)?; + Ok(i16::from_be_bytes(buf)) +} + +fn read_u16(reader: &mut R) -> Result { + let mut buf = [0u8; 2]; + reader.read_exact(&mut buf)?; + Ok(u16::from_be_bytes(buf)) +} + +fn read_i32(reader: &mut R) -> Result { + let mut buf = [0u8; 4]; + reader.read_exact(&mut buf)?; + Ok(i32::from_be_bytes(buf)) +} + +fn read_i64(reader: &mut R) -> Result { + let mut buf = [0u8; 8]; + reader.read_exact(&mut buf)?; + Ok(i64::from_be_bytes(buf)) +} + +fn read_f64(reader: &mut R) -> Result { + let mut buf = [0u8; 8]; + reader.read_exact(&mut buf)?; + Ok(f64::from_be_bytes(buf)) +} + +/// Decode a variable-length integer. +pub fn decode_varint(reader: &mut R) -> Result { + let v = read_u8(reader)?; + if v == 0xff { + return Ok(-1); + } + if v & 0x80 == 0 { + return Ok(v as i64); + } + if v & 0xc0 == 0x80 { + let b1 = read_u8(reader)? as i64; + return Ok(((v & 0x3f) as i64) << 8 | b1); + } + if v & 0xe0 == 0xc0 { + let b1 = read_u8(reader)? as i64; + let b23 = read_u16(reader)? as i64; + return Ok(((v & 0x1f) as i64) << 24 | b1 << 16 | b23); + } + // v == 0xe0 + Ok(read_i64(reader)?) +} + +/// Read one CESU-8 byte sequence and return the UTF-16 code unit it encodes. +/// +/// | Leading byte | Width | Decoding | +/// |-------------------|-------|--------------------------------------------------------| +/// | `0x00..0x7F` | 1 | code unit = byte value | +/// | `0xC0..0xDF` | 2 | `(b0 & 0x1F) << 6 \| (b1 & 0x3F)` | +/// | `0xE0..0xEF` | 3 | `(b0 & 0x0F) << 12 \| (b1 & 0x3F) << 6 \| (b2 & 0x3F)` | +/// +/// The 2-byte sequence `0xC0 0x80` decodes to the null code unit U+0000. +fn read_cesu8_unit(reader: &mut R) -> Result { + let first = read_u8(reader)?; + let unit = if first < 0x80 { + first as u16 + } else if first & 0xE0 == 0xC0 { + // 2-byte sequence; 0xC0 0x80 → U+0000 + let second = read_u8(reader)?; + (((first & 0x1F) as u16) << 6) | ((second & 0x3F) as u16) + } else if first & 0xF0 == 0xE0 { + let second = read_u8(reader)?; + let third = read_u8(reader)?; + (((first & 0x0F) as u16) << 12) | (((second & 0x3F) as u16) << 6) | ((third & 0x3F) as u16) + } else { + return Err(Error::Message(format!( + "Invalid CESU-8 leading byte: {first:#x}" + ))); + }; + Ok(unit) +} + +/// Decode the inline string payload: `varint(utf16_unit_count)` + CESU-8 bytes. +/// +/// Fantom/JVM writes one CESU-8 byte sequence per UTF-16 code unit +/// (`BrioWriter.encodeStrChars`). Supplementary characters (U+10000+) appear +/// as two consecutive surrogate code units, each with its own CESU-8 sequence +/// (6 bytes total per supplementary character). +/// +/// Must be called *after* the inline sentinel (`0xff`) has been consumed. +fn decode_str_chars(reader: &mut R) -> Result { + let unit_count = decode_varint(reader)?; + if unit_count < 0 { + return Err(Error::Message("Negative string unit count".into())); + } + let mut units: Vec = Vec::with_capacity(unit_count as usize); + for _ in 0..unit_count { + units.push(read_cesu8_unit(reader)?); + } + String::from_utf16(&units).map_err(|e| Error::Message(format!("Invalid UTF-16 sequence: {e}"))) +} + +/// Decode a Brio-encoded string. +/// +/// Reads the string code (varint): +/// - `>= 0` → constant-table lookup via [`get_const`] (0 = empty string `""`) +/// - `-1` → inline: `varint(char_count)` + CESU-8 bytes +pub fn decode_str(reader: &mut R) -> Result { + let code = decode_varint(reader)?; + if code == -1 { + decode_str_chars(reader) + } else if code >= 0 { + get_const(code) + .map(|s| s.to_owned()) + .ok_or_else(|| Error::Message(format!("Unknown Brio const index: {code}"))) + } else { + Err(Error::Message(format!("Invalid string code: {code}"))) + } +} + +// --------------------------------------------------------------------------- +// Ref id: reconstruct `xxxxxxxx-xxxxxxxx` from packed i64 +// --------------------------------------------------------------------------- + +fn i8_to_ref_id(val: i64) -> String { + format!("{:08x}-{:08x}", (val >> 32) as u32, val as u32) +} + +// --------------------------------------------------------------------------- +// DateTime construction helpers +// --------------------------------------------------------------------------- + +fn datetime_from_secs(unix_secs: i64, tz: &str) -> Result { + use chrono::{SecondsFormat, TimeZone, Utc}; + let utc = Utc + .timestamp_opt(unix_secs, 0) + .single() + .ok_or_else(|| Error::Message(format!("Invalid unix timestamp: {unix_secs}")))?; + let rfc3339 = utc.to_rfc3339_opts(SecondsFormat::Secs, true); + DateTime::parse_from_rfc3339_with_timezone(&rfc3339, tz).map_err(Error::Message) +} + +fn datetime_from_nanos(fantom_nanos: i64, tz: &str) -> Result { + use chrono::{SecondsFormat, TimeZone, Utc}; + let unix_nanos = fantom_nanos + FANTOM_EPOCH_UNIX_SECS * 1_000_000_000; + let unix_secs = unix_nanos.div_euclid(1_000_000_000); + let nanos = unix_nanos.rem_euclid(1_000_000_000) as u32; + let utc = Utc + .timestamp_opt(unix_secs, nanos) + .single() + .ok_or_else(|| Error::Message(format!("Invalid nanosecond timestamp")))?; + let rfc3339 = utc.to_rfc3339_opts(SecondsFormat::Nanos, true); + DateTime::parse_from_rfc3339_with_timezone(&rfc3339, tz).map_err(Error::Message) +} + +// --------------------------------------------------------------------------- +// Private payload decoders (ctrl byte already consumed by caller) +// --------------------------------------------------------------------------- + +/// Decode a non-empty Dict payload: `'{' varint(count) (key value)* '}'` +fn decode_dict_payload(reader: &mut R) -> Result { + let marker = read_u8(reader)?; + if marker != b'{' { + return Err(Error::Message(format!( + "Expected '{{' after CTRL_DICT, got {marker:#x}" + ))); + } + let count = decode_varint(reader)?; + if count < 0 { + return Err(Error::Message("Negative dict count".into())); + } + let mut dict = Dict::default(); + for _ in 0..count { + let key = decode_str(reader)?; + let val = Value::from_brio(reader)?; + dict.insert(key, val); + } + let close = read_u8(reader)?; + if close != b'}' { + return Err(Error::Message(format!( + "Expected '}}' closing CTRL_DICT, got {close:#x}" + ))); + } + Ok(dict) +} + +/// Decode a non-empty List payload: `'[' varint(count) value* ']'` +fn decode_list_payload(reader: &mut R) -> Result { + let marker = read_u8(reader)?; + if marker != b'[' { + return Err(Error::Message(format!( + "Expected '[' after CTRL_LIST, got {marker:#x}" + ))); + } + let count = decode_varint(reader)?; + if count < 0 { + return Err(Error::Message("Negative list count".into())); + } + let mut list = List::with_capacity(count as usize); + for _ in 0..count { + list.push(Value::from_brio(reader)?); + } + let close = read_u8(reader)?; + if close != b']' { + return Err(Error::Message(format!( + "Expected ']' closing CTRL_LIST, got {close:#x}" + ))); + } + Ok(list) +} + +/// Decode a Grid payload: `'<' varint(ncols) varint(nrows) meta cols... rows... '>'` +fn decode_grid_payload(reader: &mut R) -> Result { + let marker = read_u8(reader)?; + if marker != b'<' { + return Err(Error::Message(format!( + "Expected '<' after CTRL_GRID, got {marker:#x}" + ))); + } + let num_cols = decode_varint(reader)?; + let num_rows = decode_varint(reader)?; + if num_cols < 0 || num_rows < 0 { + return Err(Error::Message("Negative grid dimensions".into())); + } + let meta = dict_or_none(reader)?; + let mut columns = Vec::with_capacity(num_cols as usize); + for _ in 0..num_cols { + let name = decode_str(reader)?; + let col_meta = dict_or_none(reader)?; + columns.push(Column { + name, + meta: col_meta, + }); + } + let mut rows: Vec = Vec::with_capacity(num_rows as usize); + for _ in 0..num_rows { + let mut row = Dict::default(); + for col in &columns { + let val = Value::from_brio(reader)?; + if !matches!(val, Value::Null) { + row.insert(col.name.clone(), val); + } + } + rows.push(row); + } + let close = read_u8(reader)?; + if close != b'>' { + return Err(Error::Message(format!( + "Expected '>' closing CTRL_GRID, got {close:#x}" + ))); + } + Ok(Grid { + meta, + columns, + rows, + ver: crate::haystack::val::grid::GRID_FORMAT_VERSION.to_string(), + }) +} + +/// Read the next value expecting a Dict; return `None` for an empty Dict. +fn dict_or_none(reader: &mut R) -> Result> { + match Value::from_brio(reader)? { + Value::Dict(d) if d.is_empty() => Ok(None), + Value::Dict(d) => Ok(Some(d)), + _ => Err(Error::Message("Expected a Dict".into())), + } +} + +// --------------------------------------------------------------------------- +// FromBrio implementations +// --------------------------------------------------------------------------- + +/// `Dict`, `List`, and `Grid` have standalone `FromBrio` impls because they +/// are useful independently (e.g. when a caller knows the field type without +/// inspecting a ctrl byte). All scalar and singleton types are decoded only +/// through `Value::from_brio`, which is the natural entry-point for +/// ctrl-byte-driven stream decoding. + +impl FromBrio for Dict { + fn from_brio(reader: &mut R) -> Result { + match read_u8(reader)? { + CTRL_DICT_EMPTY => Ok(Dict::default()), + CTRL_DICT => decode_dict_payload(reader), + other => Err(Error::Message(format!( + "Expected Dict ctrl byte, got {other:#x}" + ))), + } + } +} + +impl FromBrio for List { + fn from_brio(reader: &mut R) -> Result { + match read_u8(reader)? { + CTRL_LIST_EMPTY => Ok(List::default()), + CTRL_LIST => decode_list_payload(reader), + other => Err(Error::Message(format!( + "Expected List ctrl byte, got {other:#x}" + ))), + } + } +} + +impl FromBrio for Grid { + fn from_brio(reader: &mut R) -> Result { + match read_u8(reader)? { + CTRL_GRID => decode_grid_payload(reader), + other => Err(Error::Message(format!( + "Expected CTRL_GRID, got {other:#x}" + ))), + } + } +} + +impl FromBrio for Value { + fn from_brio(reader: &mut R) -> Result { + let ctrl = read_u8(reader)?; + match ctrl { + CTRL_NULL => Ok(Value::Null), + CTRL_MARKER => Ok(Value::from(Marker)), + CTRL_NA => Ok(Value::from(Na)), + CTRL_REMOVE => Ok(Value::from(Remove)), + CTRL_FALSE => Ok(Value::from(Bool::from(false))), + CTRL_TRUE => Ok(Value::from(Bool::from(true))), + CTRL_NUMBER_I2 => { + let v = read_i16(reader)? as f64; + let u = decode_str(reader)?; + Ok(Value::from(make_number(v, &u))) + } + CTRL_NUMBER_I4 => { + let v = read_i32(reader)? as f64; + let u = decode_str(reader)?; + Ok(Value::from(make_number(v, &u))) + } + CTRL_NUMBER_F8 => { + let v = read_f64(reader)?; + let u = decode_str(reader)?; + Ok(Value::from(make_number(v, &u))) + } + CTRL_STR => Ok(Value::from(decode_str(reader)?.as_str())), + CTRL_REF_STR => { + let id = decode_str(reader)?; + let dis = decode_str_chars(reader)?; + Ok(Value::from(Ref::make(&id, non_empty(dis.as_str())))) + } + CTRL_REF_I8 => { + let id = i8_to_ref_id(read_i64(reader)?); + let dis = decode_str_chars(reader)?; + Ok(Value::from(Ref::make(&id, non_empty(dis.as_str())))) + } + CTRL_URI => Ok(Value::from(Uri { + value: decode_str(reader)?, + })), + CTRL_DATE => { + let year = read_i16(reader)? as i32; + let month = read_u8(reader)? as u32; + let day = read_u8(reader)? as u32; + Date::from_ymd(year, month, day) + .map(Value::from) + .map_err(Error::Message) + } + CTRL_TIME => { + let millis = read_i32(reader)? as u32; + let hour = millis / 3_600_000; + let minute = (millis % 3_600_000) / 60_000; + let second = (millis % 60_000) / 1_000; + let milli = millis % 1_000; + Time::from_hms_milli(hour, minute, second, milli) + .map(Value::from) + .map_err(Error::Message) + } + CTRL_DATETIME_I4 => { + let unix_secs = read_i32(reader)? as i64 + FANTOM_EPOCH_UNIX_SECS; + let tz = decode_str(reader)?; + datetime_from_secs(unix_secs, &tz).map(Value::from) + } + CTRL_DATETIME_I8 => { + let fantom_nanos = read_i64(reader)?; + let tz = decode_str(reader)?; + datetime_from_nanos(fantom_nanos, &tz).map(Value::from) + } + CTRL_COORD => { + // Fantom packs as (lat+90)*1e6 and (lng+180)*1e6; undo that offset + let lat = read_i32(reader)? as f64 / 1_000_000.0 - 90.0; + let lng = read_i32(reader)? as f64 / 1_000_000.0 - 180.0; + Ok(Value::from(Coord::make(lat, lng))) + } + CTRL_XSTR => Ok(Value::from(XStr::make( + &decode_str(reader)?, + &decode_str(reader)?, + ))), + CTRL_SYMBOL => Ok(Value::from(Symbol::make(&decode_str(reader)?))), + CTRL_DICT_EMPTY => Ok(Value::from(Dict::default())), + CTRL_DICT => decode_dict_payload(reader).map(Value::from), + CTRL_LIST_EMPTY => Ok(Value::from(List::default())), + CTRL_LIST => decode_list_payload(reader).map(Value::from), + CTRL_GRID => decode_grid_payload(reader).map(Value::from), + other => Err(Error::Message(format!( + "Unknown Brio control byte: {other:#x}" + ))), + } + } +} + +// --------------------------------------------------------------------------- +// Public decode entry-point +// --------------------------------------------------------------------------- + +/// Decode one Haystack [`Value`] from `reader`. +pub fn from_brio(reader: &mut R) -> Result { + Value::from_brio(reader) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn make_number(v: f64, unit: &str) -> Number { + if unit.is_empty() { + Number::make(v) + } else { + use crate::units::get_unit_or_default; + Number::make_with_unit(v, get_unit_or_default(unit)) + } +} + +/// Return `Some(s)` if `s` is non-empty, otherwise `None`. +fn non_empty(s: &str) -> Option<&str> { + if s.is_empty() { + None + } else { + Some(s) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::dict; + use crate::encoding::brio::encode::ToBrio; + use crate::haystack::val::*; + use crate::units::get_unit_or_default; + + fn round_trip(v: &Value) -> Value { + let bytes = v.to_brio_vec().expect("encode"); + from_brio(&mut bytes.as_slice()).expect("decode") + } + + #[test] + fn test_null() { + assert_eq!(round_trip(&Value::Null), Value::Null); + } + + #[test] + fn test_marker() { + assert_eq!(round_trip(&Value::make_marker()), Value::make_marker()); + } + + #[test] + fn test_na() { + assert_eq!(round_trip(&Value::make_na()), Value::make_na()); + } + + #[test] + fn test_remove() { + assert_eq!(round_trip(&Value::make_remove()), Value::make_remove()); + } + + #[test] + fn test_bool() { + assert_eq!(round_trip(&Value::from(true)), Value::from(true)); + assert_eq!(round_trip(&Value::from(false)), Value::from(false)); + } + + #[test] + fn test_number_i2() { + let v = Value::from(Number::make(42.0)); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_number_i4() { + let v = Value::from(Number::make(100_000.0)); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_number_f8() { + let v = Value::from(Number::make(3.14)); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_number_with_unit() { + let v = Value::from(Number::make_with_unit(100.0, get_unit_or_default("kW"))); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_number_negative() { + let v = Value::from(Number::make(-273.15)); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_str_empty() { + let v = Value::from(""); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_str_ascii() { + let v = Value::from("hello world"); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_str_unicode() { + let v = Value::from("héllo wörld 🌍"); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_uri() { + let v = Value::from(Uri { + value: "https://project-haystack.org".into(), + }); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_ref_str_no_dis() { + let v = Value::from(Ref::make("abc123", None)); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_ref_str_with_dis() { + let v = Value::from(Ref::make("abc123", Some("My Ref"))); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_ref_i8_format() { + // 17-char ref: gets packed to i64 + let v = Value::from(Ref::make("cafebabe-deadbeef", None)); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_ref_i8_with_dis() { + let v = Value::from(Ref::make("cafebabe-deadbeef", Some("Some Entity"))); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_date() { + let d = Date::from_ymd(2021, 6, 15).unwrap(); + let v = Value::from(d); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_time() { + let t = Time::from_hms_milli(10, 30, 45, 123).unwrap(); + let v = Value::from(t); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_time_midnight() { + let t = Time::from_hms(0, 0, 0).unwrap(); + let v = Value::from(t); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_datetime_utc() { + let dt = DateTime::parse_from_rfc3339("2021-06-15T12:00:00Z").unwrap(); + let v = Value::from(dt); + let got = round_trip(&v); + // Compare by RFC3339 string since tz representation may differ + assert_eq!( + DateTime::try_from(&got).unwrap().timestamp(), + DateTime::try_from(&v).unwrap().timestamp() + ); + } + + #[test] + fn test_coord() { + let c = Coord::make(45.123456, -73.654321); + let v = Value::from(c); + let got = round_trip(&v); + let got_c = Coord::try_from(&got).unwrap(); + assert!((got_c.lat - c.lat).abs() < 1e-6); + assert!((got_c.long - c.long).abs() < 1e-6); + } + + #[test] + fn test_coord_trivial() { + let c = Coord::make(0.0, 0.0); + let v = Value::from(c); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_xstr() { + let v = Value::from(XStr::make("Blob", "aGVsbG8=")); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_symbol() { + let v = Value::from(Symbol::make("myTag")); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_empty_dict() { + let v = Value::from(Dict::default()); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_dict_single_entry() { + let v = Value::make_dict(dict! { "marker" => Value::make_marker() }); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_dict_mixed() { + let v = Value::make_dict(dict! { + "site" => Value::make_marker(), + "dis" => Value::from("Main Campus"), + "area" => Value::from(Number::make_with_unit(42_000.0, get_unit_or_default("ft²"))) + }); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_empty_list() { + let v = Value::from(List::default()); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_list_mixed() { + let v = Value::from(vec![ + Value::make_marker(), + Value::from(true), + Value::from(Number::make(42.0)), + Value::from("hello"), + ]); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_grid_empty() { + let g = Grid::make_empty(); + let v = Value::from(g); + let got = round_trip(&v); + let got_g = Grid::try_from(&got).unwrap(); + assert!(got_g.rows.is_empty()); + } + + #[test] + fn test_grid_single_row() { + let g = Grid::make_from_dicts(vec![dict! { + "dis" => Value::from("Site A"), + "area" => Value::from(Number::make(1000.0)) + }]); + let v = Value::from(g); + let got = round_trip(&v); + let got_g = Grid::try_from(&got).unwrap(); + assert_eq!(got_g.rows.len(), 1); + } + + #[test] + fn test_grid_multiple_rows() { + let g = Grid::make_from_dicts(vec![ + dict! { "dis" => Value::from("First"), "val" => Value::from(Number::make(1.0)) }, + dict! { "dis" => Value::from("Second"), "val" => Value::from(Number::make(2.0)) }, + ]); + let v = Value::from(g); + let got = round_trip(&v); + let got_g = Grid::try_from(&got).unwrap(); + assert_eq!(got_g.rows.len(), 2); + } + + #[test] + fn test_nested_dict_in_list() { + let inner = Value::make_dict(dict! { "x" => Value::from(Number::make(1.0)) }); + let v = Value::from(vec![inner]); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_varint_round_trip() { + for n in [ + 0i64, + 1, + 0x7f, + 0x80, + 0x3fff, + 0x4000, + 0x1fff_ffff, + 0x2000_0000, + -1, + ] { + let mut buf = Vec::new(); + crate::encoding::brio::encode::encode_varint(&mut buf, n).unwrap(); + let decoded = decode_varint(&mut buf.as_slice()).unwrap(); + assert_eq!(decoded, n, "varint round-trip for {n}"); + } + } + + #[test] + fn test_decode_const_str() { + // varint(185) = [0x80, 0xb9] → "dis" + let bytes: Vec = vec![0x80, 0xb9]; + let s = decode_str(&mut bytes.as_slice()).unwrap(); + assert_eq!(s, "dis"); + } + + #[test] + fn test_decode_const_timezone() { + // varint(24) = [0x18] → "UTC" + let bytes: Vec = vec![0x18]; + let s = decode_str(&mut bytes.as_slice()).unwrap(); + assert_eq!(s, "UTC"); + } + + #[test] + fn test_decode_const_unit_kw() { + // varint(630) = 0x8000|630 = 0x8276 → [0x82, 0x76] → "kW" + let bytes: Vec = vec![0x82, 0x76]; + let s = decode_str(&mut bytes.as_slice()).unwrap(); + assert_eq!(s, "kW"); + } + + #[test] + fn test_decode_inline_str() { + // varint(-1) = 0xff, then varint(5), then "hello" + let mut bytes: Vec = vec![0xff, 5]; + bytes.extend_from_slice(b"hello"); + let s = decode_str(&mut bytes.as_slice()).unwrap(); + assert_eq!(s, "hello"); + } + + #[test] + fn test_decode_invalid_const_index() { + // varint(9999) should return an error — not a valid const index + let bytes: Vec = vec![0xc0, 0x00, 0x27, 0x0f]; // varint(9999) + let result = decode_str(&mut bytes.as_slice()); + assert!(result.is_err()); + } + + #[test] + fn test_round_trip_uses_consts() { + // Dict with known const tag names should encode compactly and round-trip. + let v = Value::make_dict(dict! { + "dis" => Value::from("Main Campus"), + "site" => Value::make_marker(), + "siteRef" => Value::from(Ref::make("cafebabe-deadbeef", None)), + "ts" => Value::from(DateTime::parse_from_rfc3339("2021-06-15T12:00:00Z").unwrap()) + }); + assert_eq!(round_trip(&v), v); + } + + #[test] + fn test_decode_str_null_char() { + // 0xC0 0x80 is the CESU-8 / Modified-UTF-8 encoding of U+0000. + // varint(-1)=0xff, varint(1)=0x01, then the two-byte null sequence. + let bytes: Vec = vec![0xff, 0x01, 0xC0, 0x80]; + let s = decode_str(&mut bytes.as_slice()).unwrap(); + assert_eq!(s, "\0"); + } + + #[test] + fn test_decode_str_emoji() { + // U+1F600 (😀) encoded as surrogate pair in CESU-8: + // high 0xD83D → [0xED, 0xA0, 0xBD] + // low 0xDE00 → [0xED, 0xB8, 0x80] + // varint(-1)=0xff, varint(2)=0x02, then 6 bytes. + let bytes: Vec = vec![0xff, 0x02, 0xED, 0xA0, 0xBD, 0xED, 0xB8, 0x80]; + let s = decode_str(&mut bytes.as_slice()).unwrap(); + assert_eq!(s, "\u{1F600}"); + } + + // ----------------------------------------------------------------------- + // Haxall interop: round-trip tests derived from BrioTest.fan verifyIO() + // + // verifyIO(val, size) in BrioTest.fan both checks the encoded byte count + // AND decodes back, asserting the reconstructed value equals the original. + // The tests below cover the decode half of every verifyIO call. + // ----------------------------------------------------------------------- + + #[test] + fn test_haxall_roundtrip_scalars() { + // verifyIO(null,1) verifyIO(Marker.val,1) verifyIO(NA.val,1) + // verifyIO(None.val,1) verifyIO(true,1) verifyIO(false,1) + assert_eq!(round_trip(&Value::Null), Value::Null); + assert_eq!(round_trip(&Value::make_marker()), Value::make_marker()); + assert_eq!(round_trip(&Value::make_na()), Value::make_na()); + assert_eq!(round_trip(&Value::make_remove()), Value::make_remove()); + assert_eq!(round_trip(&Value::from(true)), Value::from(true)); + assert_eq!(round_trip(&Value::from(false)), Value::from(false)); + } + + #[test] + fn test_haxall_roundtrip_numbers() { + // All n() cases from BrioTest.fan testIO. + // Note: n(123_456_789, "_foo") is NOT included here because unknown unit + // symbols are resolved through the unit database on decode, so a synthetic + // unit like "_foo" does not survive the round-trip (encode size is tested + // separately in test_haxall_compat_number_nonconstunit). + let cases: Vec = vec![ + Value::from(Number::make(12.0)), + Value::from(Number::make(123_456_789.0)), + Value::from(Number::make_with_unit( + 123_456_789.0, + get_unit_or_default("°F"), + )), + Value::from(Number::make_with_unit( + 123_456.789, + get_unit_or_default("°F"), + )), + Value::from(Number::make(32767.0)), + Value::from(Number::make(32768.0)), + Value::from(Number::make(-32767.0)), + Value::from(Number::make(-32768.0)), + Value::from(Number::make(2_147_483_647.0)), + Value::from(Number::make(2_147_483_648.0)), + Value::from(Number::make(-2_147_483_648.0)), + Value::from(Number::make(-2_147_483_649.0)), + ]; + for v in &cases { + assert_eq!(round_trip(v), *v, "round-trip failed for {v:?}"); + } + } + + #[test] + fn test_haxall_roundtrip_strings() { + // verifyIO("",2) verifyIO("hello °F world!",19) + // verifyIO("siteRef",3) verifyIO("New_York",2) + for s in ["", "hello °F world!", "siteRef", "New_York"] { + let v = Value::from(s); + assert_eq!(round_trip(&v), v, "round-trip failed for {s:?}"); + } + } + + #[test] + fn test_haxall_roundtrip_refs() { + // verifyIO(Ref("1deb31b8-7508b187"), 10) — I8 form, no dis + let r1 = Value::from(Ref::make("1deb31b8-7508b187", None)); + assert_eq!(round_trip(&r1), r1); + // verifyIO(Ref("1debX1b8-7508b187"), 21) — non-hex id, STR form + let r2 = Value::from(Ref::make("1debX1b8-7508b187", None)); + assert_eq!(round_trip(&r2), r2); + // verifyIO(Ref("1deb31b8.7508b187"), 21) — '.' separator, STR form + let r3 = Value::from(Ref::make("1deb31b8.7508b187", None)); + assert_eq!(round_trip(&r3), r3); + // verifyIO(Ref("1deb31b8-7508b187", "hi!"), 13) — I8 form with dis + let r4 = Value::from(Ref::make("1deb31b8-7508b187", Some("hi!"))); + assert_eq!(round_trip(&r4), r4); + } + + #[test] + fn test_haxall_roundtrip_symbols() { + // verifyIO(Symbol("coolingTower"), 3) verifyIO(Symbol("foo-bar"), 10) + let s1 = Value::from(Symbol::make("coolingTower")); + assert_eq!(round_trip(&s1), s1); + let s2 = Value::from(Symbol::make("foo-bar")); + assert_eq!(round_trip(&s2), s2); + } + + #[test] + fn test_haxall_roundtrip_datetimes() { + fn dt(iso: &str, tz: &str) -> Value { + Value::from(DateTime::parse_from_rfc3339_with_timezone(iso, tz).unwrap()) + } + let cases = vec![ + dt("2015-11-30T12:03:57-05:00", "New_York"), // I4, const tz + dt("2015-11-30T12:02:33.378-05:00", "New_York"), // I8, const tz + dt("2015-11-30T12:03:57.000123-05:00", "New_York"), // I8, µs precision + dt("2000-01-01T00:00:00+01:00", "Warsaw"), // I4, non-const tz + dt("2000-01-01T00:00:00.832+01:00", "Warsaw"), // I8, non-const tz + dt("1999-06-07T01:02:00-04:00", "New_York"), // I4, pre-2000 + dt("1950-06-07T01:02:00-04:00", "New_York"), // I4, pre-1970 + dt("1950-06-07T01:02:00.123-04:00", "New_York"), // I8, pre-1970 + ]; + for v in &cases { + assert_eq!(round_trip(v), *v, "round-trip failed for {v:?}"); + } + } + + #[test] + fn test_haxall_roundtrip_coord() { + // verifyIO(Coord(37.54f, 77.43f), 9) verifyIO(Coord(-17.535f, -149.569f), 9) + // Floating-point rounding: (lat+90)*1e6 integer packing may change the last + // bit of the f64 on decode, so we compare with 1e-6 tolerance. + let c1_orig = Coord::make(37.54, 77.43); + let c1_rt = Coord::try_from(&round_trip(&Value::from(c1_orig))).unwrap(); + assert!((c1_rt.lat - c1_orig.lat).abs() < 1e-6); + assert!((c1_rt.long - c1_orig.long).abs() < 1e-6); + + let c2_orig = Coord::make(-17.535, -149.569); + let c2_rt = Coord::try_from(&round_trip(&Value::from(c2_orig))).unwrap(); + assert!((c2_rt.lat - c2_orig.lat).abs() < 1e-6); + assert!((c2_rt.long - c2_orig.long).abs() < 1e-6); + } + + #[test] + fn test_haxall_roundtrip_collections() { + // verifyIO(Etc.dict0, 1) verifyIO(Obj?[,], 1) + let empty_dict = Value::from(Dict::default()); + assert_eq!(round_trip(&empty_dict), empty_dict); + let empty_list = Value::from(vec![] as List); + assert_eq!(round_trip(&empty_list), empty_list); + // verifyIO(["a":n(2),"b":n(1.2,"kW"),"c":n(123456789,"°F"),"d":n(-3,"_foo")]) + // ("d" with "_foo" unit omitted — unknown units cannot survive decode; + // see encode.rs test_haxall_compat_number_nonconstunit for the size check) + let units_dict = Value::make_dict(dict! { + "a" => Value::from(Number::make(2.0)), + "b" => Value::from(Number::make_with_unit(1.2, get_unit_or_default("kW"))), + "c" => Value::from(Number::make_with_unit(123_456_789.0, get_unit_or_default("°F"))) + }); + assert_eq!(round_trip(&units_dict), units_dict); + } + + #[test] + fn test_haxall_roundtrip_mixed_dict() { + // verifyIO(all-different-types dict) from BrioTest.fan + let v = Value::make_dict(dict! { + "m" => Value::make_marker(), + "na" => Value::make_na(), + "bf" => Value::from(false), + "bt" => Value::from(true), + "n" => Value::from(Number::make(123.0)), + "s" => Value::from("hi"), + "r" => Value::from(Ref::make("1deb31b8-7508b187", None)), + "u" => Value::from(Uri { value: "a/b".to_string() }), + "d" => Value::from(Date::from_ymd(2021, 6, 15).unwrap()), + "dt" => Value::from(DateTime::parse_from_rfc3339("2021-06-15T12:00:00Z").unwrap()) + }); + assert_eq!(round_trip(&v), v); + } +} diff --git a/src/haystack/encoding/brio/encode.rs b/src/haystack/encoding/brio/encode.rs new file mode 100644 index 0000000..f33d503 --- /dev/null +++ b/src/haystack/encoding/brio/encode.rs @@ -0,0 +1,994 @@ +// Copyright (C) 2020 - 2022, J2 Innovations + +//! Brio binary encoder for Haystack values. +//! +//! Encodes all Haystack value types to the Brio binary wire format. + +use std::io::Write; + +use super::consts::lookup_const; +use crate::haystack::val::{ + Bool, Coord, Date, DateTime, Dict, Grid, List, Marker, Na, Number, Ref, Remove, Str, Symbol, + Time, Uri, Value, XStr, +}; + +// --------------------------------------------------------------------------- +// Control bytes +// --------------------------------------------------------------------------- + +pub const CTRL_NULL: u8 = 0x00; +pub const CTRL_MARKER: u8 = 0x01; +pub const CTRL_NA: u8 = 0x02; +pub const CTRL_REMOVE: u8 = 0x03; +pub const CTRL_FALSE: u8 = 0x04; +pub const CTRL_TRUE: u8 = 0x05; +pub const CTRL_NUMBER_I2: u8 = 0x06; +pub const CTRL_NUMBER_I4: u8 = 0x07; +pub const CTRL_NUMBER_F8: u8 = 0x08; +pub const CTRL_STR: u8 = 0x09; +pub const CTRL_REF_STR: u8 = 0x0a; +pub const CTRL_REF_I8: u8 = 0x0b; +pub const CTRL_URI: u8 = 0x0c; +pub const CTRL_DATE: u8 = 0x0d; +pub const CTRL_TIME: u8 = 0x0e; +pub const CTRL_DATETIME_I4: u8 = 0x0f; +pub const CTRL_DATETIME_I8: u8 = 0x10; +pub const CTRL_COORD: u8 = 0x11; +pub const CTRL_XSTR: u8 = 0x12; +pub const CTRL_DICT_EMPTY: u8 = 0x14; +pub const CTRL_DICT: u8 = 0x15; +pub const CTRL_LIST_EMPTY: u8 = 0x16; +pub const CTRL_LIST: u8 = 0x17; +pub const CTRL_GRID: u8 = 0x18; +pub const CTRL_SYMBOL: u8 = 0x19; + +/// Seconds between Unix epoch (1970-01-01) and Fantom epoch (2000-01-01). +pub const FANTOM_EPOCH_UNIX_SECS: i64 = 946_684_800; + +// --------------------------------------------------------------------------- +// Error / Result +// --------------------------------------------------------------------------- + +/// Encoding error. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Error { + Message(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Message(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(err: std::io::Error) -> Self { + Error::Message(err.to_string()) + } +} + +/// Result type for Brio encoding. +pub type Result = std::result::Result; + +// --------------------------------------------------------------------------- +// Public trait +// --------------------------------------------------------------------------- + +/// Encode a Haystack value to the Brio binary format. +pub trait ToBrio { + /// Write the Brio encoding of `self` into `writer`. + fn to_brio(&self, writer: &mut W) -> Result<()>; + + /// Convenience: encode to a `Vec`. + fn to_brio_vec(&self) -> Result> { + let mut buf = Vec::new(); + self.to_brio(&mut buf)?; + Ok(buf) + } +} + +// --------------------------------------------------------------------------- +// Low-level primitives +// --------------------------------------------------------------------------- + +/// Encode a variable-length integer. +/// +/// | Range | Encoding | +/// |---------------------|--------------------------------------| +/// | `-1` | `0xff` | +/// | `0..=0x7f` | 1 byte | +/// | `0x80..=0x3fff` | 2 bytes with `0x8000` mask | +/// | `0x4000..=0x1fffffff`| 4 bytes with `0xc0000000` mask | +/// | larger | `0xe0` + 8 bytes (big-endian `i64`) | +pub fn encode_varint(writer: &mut W, val: i64) -> Result<()> { + if val < 0 { + // Only -1 is valid; treat any negative as -1 + writer.write_all(&[0xff])?; + } else if val <= 0x7f { + writer.write_all(&[val as u8])?; + } else if val <= 0x3fff { + let encoded = val as u16 | 0x8000; + writer.write_all(&encoded.to_be_bytes())?; + } else if val <= 0x1fff_ffff { + let encoded = val as u32 | 0xc000_0000; + writer.write_all(&encoded.to_be_bytes())?; + } else { + writer.write_all(&[0xe0])?; + writer.write_all(&val.to_be_bytes())?; + } + Ok(()) +} + +/// Encode a string, preferring a compact constant-table index when available. +/// +/// If `s` is found in [`crate::encoding::brio::consts::CONSTS`], writes +/// `varint(index)`. Otherwise writes the inline form: +/// `varint(-1)` + `varint(utf16_unit_count)` + CESU-8 bytes. +/// +/// Fantom/JVM measures string length as the number of UTF-16 code units +/// (`Str.size` = `java.lang.String.length()`) and serialises each unit via +/// `OutStream.writeChar`, which produces CESU-8 on the JVM. CESU-8 is +/// identical to UTF-8 for all BMP characters **except** U+0000, which is +/// encoded as `0xC0 0x80` rather than a bare null byte. Supplementary +/// characters (U+10000+) are stored as surrogate pairs, each surrogate +/// encoded as its own 3-byte CESU-8 sequence (6 bytes per character). +pub fn encode_str(writer: &mut W, s: &str) -> Result<()> { + if let Some(idx) = lookup_const(s) { + encode_varint(writer, idx)?; + } else { + encode_varint(writer, -1)?; // inline sentinel + encode_str_chars(writer, s)?; + } + Ok(()) +} + +/// Encode `varint(utf16_unit_count)` then one CESU-8 sequence per UTF-16 +/// code unit, matching Fantom/JVM `BrioWriter.encodeStrChars`. +fn encode_str_chars(writer: &mut W, s: &str) -> Result<()> { + let units: Vec = s.encode_utf16().collect(); + encode_varint(writer, units.len() as i64)?; + for &unit in &units { + write_cesu8_unit(writer, unit)?; + } + Ok(()) +} + +/// Write one UTF-16 code unit as its CESU-8 byte sequence. +/// +/// | Code unit range | Bytes | Encoding | +/// |-------------------|-------|--------------------------------------------|| +/// | U+0000 | 2 | `0xC0 0x80` (Modified UTF-8 null) | +/// | U+0001..U+007F | 1 | same as UTF-8 | +/// | U+0080..U+07FF | 2 | same as UTF-8 | +/// | U+0800..U+FFFF | 3 | same as UTF-8; covers UTF-16 surrogates | +fn write_cesu8_unit(writer: &mut W, unit: u16) -> Result<()> { + match unit { + 0x0000 => writer.write_all(&[0xC0, 0x80])?, + 0x0001..=0x007F => writer.write_all(&[unit as u8])?, + 0x0080..=0x07FF => { + writer.write_all(&[0xC0 | (unit >> 6) as u8, 0x80 | (unit & 0x3F) as u8])? + } + 0x0800..=0xFFFF => writer.write_all(&[ + 0xE0 | (unit >> 12) as u8, + 0x80 | ((unit >> 6) & 0x3F) as u8, + 0x80 | (unit & 0x3F) as u8, + ])?, + } + Ok(()) +} + +/// Try to encode a `Ref` id as a packed `i64`. +/// +/// Only succeeds for the 17-character format `xxxxxxxx-xxxxxxxx` +/// (8 hex digits, dash, 8 hex digits). +fn ref_id_to_i8(id: &str) -> Option { + let bytes = id.as_bytes(); + if bytes.len() != 17 || bytes[8] != b'-' { + return None; + } + let mut val: i64 = 0; + for (i, &b) in bytes.iter().enumerate() { + if i == 8 { + continue; + } + let digit = (b as char).to_digit(16)? as i64; + val = (val << 4) | digit; + } + // Fantom's i8 encoding only works for non-negative i64 values; + // if the packed value overflows (sign bit set), fall back to STR form. + if val < 0 { + None + } else { + Some(val) + } +} + +// --------------------------------------------------------------------------- +// ToBrio implementations +// --------------------------------------------------------------------------- + +impl ToBrio for Value { + fn to_brio(&self, writer: &mut W) -> Result<()> { + match self { + Value::Null => writer.write_all(&[CTRL_NULL])?, + Value::Marker => Marker.to_brio(writer)?, + Value::Na => Na.to_brio(writer)?, + Value::Remove => Remove.to_brio(writer)?, + Value::Bool(v) => v.to_brio(writer)?, + Value::Number(v) => v.to_brio(writer)?, + Value::Str(v) => v.to_brio(writer)?, + Value::Ref(v) => v.to_brio(writer)?, + Value::Uri(v) => v.to_brio(writer)?, + Value::Date(v) => v.to_brio(writer)?, + Value::Time(v) => v.to_brio(writer)?, + Value::DateTime(v) => v.to_brio(writer)?, + Value::Coord(v) => v.to_brio(writer)?, + Value::XStr(v) => v.to_brio(writer)?, + Value::Symbol(v) => v.to_brio(writer)?, + Value::List(v) => v.to_brio(writer)?, + Value::Dict(v) => v.to_brio(writer)?, + Value::Grid(v) => v.to_brio(writer)?, + } + Ok(()) + } +} + +impl ToBrio for Marker { + fn to_brio(&self, writer: &mut W) -> Result<()> { + writer.write_all(&[CTRL_MARKER])?; + Ok(()) + } +} + +impl ToBrio for Na { + fn to_brio(&self, writer: &mut W) -> Result<()> { + writer.write_all(&[CTRL_NA])?; + Ok(()) + } +} + +impl ToBrio for Remove { + fn to_brio(&self, writer: &mut W) -> Result<()> { + writer.write_all(&[CTRL_REMOVE])?; + Ok(()) + } +} + +impl ToBrio for Bool { + fn to_brio(&self, writer: &mut W) -> Result<()> { + writer.write_all(&[if self.value { CTRL_TRUE } else { CTRL_FALSE }])?; + Ok(()) + } +} + +impl ToBrio for Str { + fn to_brio(&self, writer: &mut W) -> Result<()> { + writer.write_all(&[CTRL_STR])?; + encode_str(writer, &self.value)?; + Ok(()) + } +} + +impl ToBrio for Uri { + fn to_brio(&self, writer: &mut W) -> Result<()> { + writer.write_all(&[CTRL_URI])?; + encode_str(writer, &self.value)?; + Ok(()) + } +} + +impl ToBrio for Symbol { + fn to_brio(&self, writer: &mut W) -> Result<()> { + writer.write_all(&[CTRL_SYMBOL])?; + encode_str(writer, &self.value)?; + Ok(()) + } +} + +impl ToBrio for Number { + fn to_brio(&self, writer: &mut W) -> Result<()> { + let v = self.value; + let unit_str = self.unit.map_or("", |u| u.symbol()); + + // Fantom BrioWriter uses -32767..=32767 (excludes i16::MIN = -32768) + if v.fract() == 0.0 && v >= -(i16::MAX as f64) && v <= i16::MAX as f64 { + writer.write_all(&[CTRL_NUMBER_I2])?; + writer.write_all(&(v as i16).to_be_bytes())?; + } else if v.fract() == 0.0 && v >= i32::MIN as f64 && v <= i32::MAX as f64 { + writer.write_all(&[CTRL_NUMBER_I4])?; + writer.write_all(&(v as i32).to_be_bytes())?; + } else { + writer.write_all(&[CTRL_NUMBER_F8])?; + writer.write_all(&v.to_be_bytes())?; + } + encode_str(writer, unit_str)?; + Ok(()) + } +} + +impl ToBrio for Ref { + fn to_brio(&self, writer: &mut W) -> Result<()> { + let dis = self.dis.as_deref().unwrap_or(""); + if let Some(i8_val) = ref_id_to_i8(&self.value) { + writer.write_all(&[CTRL_REF_I8])?; + writer.write_all(&i8_val.to_be_bytes())?; + } else { + writer.write_all(&[CTRL_REF_STR])?; + encode_str(writer, &self.value)?; + } + // Fantom BrioWriter.writeRefDis calls encodeStrChars() — always inline, + // never a const-table code. The matching reader uses decodeStrChars(). + encode_str_chars(writer, dis)?; + Ok(()) + } +} + +impl ToBrio for Date { + fn to_brio(&self, writer: &mut W) -> Result<()> { + use chrono::Datelike; + writer.write_all(&[CTRL_DATE])?; + writer.write_all(&(self.year() as i16).to_be_bytes())?; + writer.write_all(&[self.month() as u8])?; + writer.write_all(&[self.day() as u8])?; + Ok(()) + } +} + +impl ToBrio for Time { + fn to_brio(&self, writer: &mut W) -> Result<()> { + use chrono::Timelike; + writer.write_all(&[CTRL_TIME])?; + let millis = self.hour() * 3_600_000 + + self.minute() * 60_000 + + self.second() * 1_000 + + self.nanosecond() / 1_000_000; + writer.write_all(&(millis as i32).to_be_bytes())?; + Ok(()) + } +} + +impl ToBrio for DateTime { + fn to_brio(&self, writer: &mut W) -> Result<()> { + let unix_secs = self.timestamp(); + let nanos = self.timestamp_subsec_nanos(); + let fantom_secs = unix_secs - FANTOM_EPOCH_UNIX_SECS; + let tz = self.timezone_short_name(); + + // Use compact I4 when no sub-second precision and secs fit in i32 + if nanos == 0 && fantom_secs >= i32::MIN as i64 && fantom_secs <= i32::MAX as i64 { + writer.write_all(&[CTRL_DATETIME_I4])?; + writer.write_all(&(fantom_secs as i32).to_be_bytes())?; + } else { + let fantom_nanos = (unix_secs - FANTOM_EPOCH_UNIX_SECS) * 1_000_000_000 + nanos as i64; + writer.write_all(&[CTRL_DATETIME_I8])?; + writer.write_all(&fantom_nanos.to_be_bytes())?; + } + encode_str(writer, &tz)?; + Ok(()) + } +} + +impl ToBrio for Coord { + fn to_brio(&self, writer: &mut W) -> Result<()> { + writer.write_all(&[CTRL_COORD])?; + // Fantom packs lat as (lat+90)*1e6 and lng as (lng+180)*1e6 + // to keep both values unsigned (all valid lat/lng → positive packed values) + let pack_lat = ((self.lat + 90.0) * 1_000_000.0_f64).round() as i32; + let pack_lng = ((self.long + 180.0) * 1_000_000.0_f64).round() as i32; + writer.write_all(&pack_lat.to_be_bytes())?; + writer.write_all(&pack_lng.to_be_bytes())?; + Ok(()) + } +} + +impl ToBrio for XStr { + fn to_brio(&self, writer: &mut W) -> Result<()> { + writer.write_all(&[CTRL_XSTR])?; + encode_str(writer, &self.r#type)?; + encode_str(writer, &self.value)?; + Ok(()) + } +} + +impl ToBrio for Dict { + fn to_brio(&self, writer: &mut W) -> Result<()> { + if self.is_empty() { + writer.write_all(&[CTRL_DICT_EMPTY])?; + } else { + writer.write_all(&[CTRL_DICT, b'{'])?; + encode_varint(writer, self.len() as i64)?; + for (key, val) in self.iter() { + encode_str(writer, key)?; + val.to_brio(writer)?; + } + writer.write_all(&[b'}'])?; + } + Ok(()) + } +} + +impl ToBrio for List { + fn to_brio(&self, writer: &mut W) -> Result<()> { + if self.is_empty() { + writer.write_all(&[CTRL_LIST_EMPTY])?; + } else { + writer.write_all(&[CTRL_LIST, b'['])?; + encode_varint(writer, self.len() as i64)?; + for val in self.iter() { + val.to_brio(writer)?; + } + writer.write_all(&[b']'])?; + } + Ok(()) + } +} + +impl ToBrio for Grid { + fn to_brio(&self, writer: &mut W) -> Result<()> { + writer.write_all(&[CTRL_GRID, b'<'])?; + encode_varint(writer, self.columns.len() as i64)?; + encode_varint(writer, self.rows.len() as i64)?; + + // Grid meta + match &self.meta { + Some(meta) => meta.to_brio(writer)?, + None => writer.write_all(&[CTRL_DICT_EMPTY])?, + } + + // Column definitions: name + meta dict + for col in &self.columns { + encode_str(writer, &col.name)?; + match &col.meta { + Some(meta) => meta.to_brio(writer)?, + None => writer.write_all(&[CTRL_DICT_EMPTY])?, + } + } + + // Row cells (Null for missing columns) + for row in &self.rows { + for col in &self.columns { + match row.get(&col.name) { + Some(val) => val.to_brio(writer)?, + None => writer.write_all(&[CTRL_NULL])?, + } + } + } + + writer.write_all(&[b'>'])?; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::dict; + use crate::haystack::val::*; + use crate::units::{get_unit_or_default, Unit}; + + fn enc(v: &Value) -> Vec { + v.to_brio_vec().expect("encode") + } + + #[test] + fn test_null() { + assert_eq!(enc(&Value::Null), vec![CTRL_NULL]); + } + + #[test] + fn test_marker() { + assert_eq!(enc(&Value::make_marker()), vec![CTRL_MARKER]); + } + + #[test] + fn test_na() { + assert_eq!(enc(&Value::make_na()), vec![CTRL_NA]); + } + + #[test] + fn test_remove() { + assert_eq!(enc(&Value::make_remove()), vec![CTRL_REMOVE]); + } + + #[test] + fn test_bool_false() { + assert_eq!(enc(&Value::from(false)), vec![CTRL_FALSE]); + } + + #[test] + fn test_bool_true() { + assert_eq!(enc(&Value::from(true)), vec![CTRL_TRUE]); + } + + #[test] + fn test_number_i2() { + let v = Value::from(Number::make(42.0)); + let bytes = enc(&v); + assert_eq!(bytes[0], CTRL_NUMBER_I2); + assert_eq!(i16::from_be_bytes([bytes[1], bytes[2]]), 42); + } + + #[test] + fn test_number_i4() { + let v = Value::from(Number::make(100_000.0)); + let bytes = enc(&v); + assert_eq!(bytes[0], CTRL_NUMBER_I4); + assert_eq!( + i32::from_be_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]), + 100_000 + ); + } + + #[test] + fn test_number_f8() { + let v = Value::from(Number::make(3.14)); + let bytes = enc(&v); + assert_eq!(bytes[0], CTRL_NUMBER_F8); + let f = f64::from_be_bytes(bytes[1..9].try_into().unwrap()); + assert!((f - 3.14).abs() < 1e-10); + } + + #[test] + fn test_number_with_unit() { + let v = Value::from(Number::make_with_unit(100.0, get_unit_or_default("kW"))); + let bytes = enc(&v); + assert_eq!(bytes[0], CTRL_NUMBER_I2); + } + + #[test] + fn test_varint_ranges() { + let mut buf = Vec::new(); + encode_varint(&mut buf, -1).unwrap(); + assert_eq!(buf, vec![0xff]); + + buf.clear(); + encode_varint(&mut buf, 0).unwrap(); + assert_eq!(buf, vec![0x00]); + + buf.clear(); + encode_varint(&mut buf, 0x7f).unwrap(); + assert_eq!(buf, vec![0x7f]); + + buf.clear(); + encode_varint(&mut buf, 0x80).unwrap(); + assert_eq!(buf, vec![0x80, 0x80]); + + buf.clear(); + encode_varint(&mut buf, 0x3fff).unwrap(); + assert_eq!(buf, vec![0xbf, 0xff]); + + buf.clear(); + encode_varint(&mut buf, 0x4000).unwrap(); + assert_eq!(buf, vec![0xc0, 0x00, 0x40, 0x00]); + } + + #[test] + fn test_str_inline_unknown() { + // "hello" is not in the constants table → inline encoding + let v = Value::from("hello"); + let bytes = enc(&v); + assert_eq!(bytes[0], CTRL_STR); + // inline sentinel 0xff, then varint(5), then "hello" + assert_eq!(bytes[1], 0xff); + assert_eq!(bytes[2], 5); + assert_eq!(&bytes[3..], b"hello"); + } + + #[test] + fn test_str_const_encoding() { + // "dis" is const index 185; varint(185) = [0x80, 0xb9] + let v = Value::from("dis"); + let bytes = enc(&v); + assert_eq!(bytes, vec![CTRL_STR, 0x80, 0xb9]); + } + + #[test] + fn test_dict_key_uses_const() { + // Keys like "dis" and "site" should be encoded as const indices. + // We verify this by checking the overall bytes are shorter than inline + // would produce (3 vs 1+1+1+3 = 6 bytes per key). + let dict_val = Value::make_dict(dict! { "dis" => Value::make_marker() }); + let bytes_with_consts = enc(&dict_val); + // Build what inline-only would look like for key "dis" (len 3): + // 0xff 0x03 'd' 'i' 's' = 5 bytes vs 2 bytes with const + // Total with consts: CTRL_DICT + '{' + count(1) + key(2) + CTRL_MARKER + '}' + // = 1 + 1 + 1 + 2 + 1 + 1 = 7 + // Total inline: 1 + 1 + 1 + 5 + 1 + 1 = 10 + assert!( + bytes_with_consts.len() < 10, + "const encoding should be shorter than inline" + ); + } + + #[test] + fn test_unit_uses_const() { + // "kW" is const index 630; the unit string in the number should be compact. + use crate::haystack::encoding::brio::consts::lookup_const; + assert_eq!(lookup_const("kW"), Some(630)); + let v = Value::from(Number::make_with_unit(50.0, get_unit_or_default("kW"))); + let bytes = enc(&v); + // byte 0 = CTRL_NUMBER_I2, bytes 1-2 = i16(50) + // then unit string as varint(630): 0x8000 | 630 = 0x8276 → [0x82, 0x76] + assert_eq!(bytes[3], 0x82); + assert_eq!(bytes[4], 0x76); + } + + #[test] + fn test_timezone_uses_const() { + // The "UTC" timezone should be encoded as const index 24. + let dt = DateTime::parse_from_rfc3339("2021-06-15T12:00:00Z").unwrap(); + let bytes = enc(&Value::from(dt)); + // CTRL_DATETIME_I4 + 4 bytes + varint(24) = 1+4+1 = 6 + assert_eq!(bytes.len(), 6); + assert_eq!(bytes[0], CTRL_DATETIME_I4); + assert_eq!(bytes[5], 24); // varint(24) = single byte 0x18 + } + + #[test] + fn test_str_cesu8_null_char() { + // U+0000 must be encoded as 0xC0 0x80 (Modified UTF-8), not a bare 0x00, + // matching Fantom/JVM OutStream.writeChar behaviour. + let v = Value::from("\0"); + let bytes = enc(&v); + // CTRL_STR + varint(-1) + varint(1) + 0xC0 0x80 + assert_eq!(bytes, vec![CTRL_STR, 0xff, 0x01, 0xC0, 0x80]); + } + + #[test] + fn test_str_cesu8_supplementary_char() { + // U+1F600 (😀) has UTF-16 surrogate pair [0xD83D, 0xDE00]. + // Each surrogate is encoded as a 3-byte CESU-8 sequence — + // 6 bytes total, matching what Fantom/JVM produces. + let v = Value::from("\u{1F600}"); + let bytes = enc(&v); + // CTRL_STR + varint(-1) + varint(2) + CESU-8(0xD83D) + CESU-8(0xDE00) + assert_eq!( + bytes, + vec![ + CTRL_STR, 0xff, // inline sentinel + 0x02, // 2 UTF-16 code units + 0xED, 0xA0, 0xBD, // high surrogate 0xD83D + 0xED, 0xB8, 0x80, // low surrogate 0xDE00 + ] + ); + } + + #[test] + fn test_str_cesu8_round_trip() { + // Verify that a string with a null char, BMP chars, and a supplementary + // char all survive a full encode → decode round-trip. + let original = Value::from("a\0b\u{1F600}c"); + let bytes = enc(&original); + let decoded = crate::encoding::brio::decode::from_brio(&mut bytes.as_slice()).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn test_uri() { + let v = Value::from(Uri { + value: "http://example.com".into(), + }); + let bytes = enc(&v); + assert_eq!(bytes[0], CTRL_URI); + } + + #[test] + fn test_ref_str() { + let r = Ref::make("abc123", None); + let bytes = enc(&Value::from(r)); + assert_eq!(bytes[0], CTRL_REF_STR); + } + + #[test] + fn test_ref_i8() { + // "cafebabe-deadbeef" packs to 0xcafebabedeadbeef which is negative as i64, + // so we fall back to CTRL_REF_STR — matching Fantom behaviour. + let r = Ref::make("cafebabe-deadbeef", None); + let bytes = enc(&Value::from(r)); + assert_eq!(bytes[0], CTRL_REF_STR); + } + + #[test] + fn test_date() { + let d = Date::from_ymd(2021, 6, 15).unwrap(); + let bytes = enc(&Value::from(d)); + assert_eq!(bytes[0], CTRL_DATE); + assert_eq!(i16::from_be_bytes([bytes[1], bytes[2]]), 2021); + assert_eq!(bytes[3], 6); + assert_eq!(bytes[4], 15); + } + + #[test] + fn test_time() { + let t = Time::from_hms(10, 30, 0).unwrap(); + let bytes = enc(&Value::from(t)); + assert_eq!(bytes[0], CTRL_TIME); + let millis = i32::from_be_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]); + assert_eq!(millis, (10 * 3600 + 30 * 60) * 1000); + } + + #[test] + fn test_datetime_i4() { + let dt = DateTime::parse_from_rfc3339("2021-06-15T12:00:00Z").unwrap(); + let bytes = enc(&Value::from(dt)); + assert_eq!(bytes[0], CTRL_DATETIME_I4); + } + + #[test] + fn test_coord() { + let c = Coord::make(45.0, 23.0); + let bytes = enc(&Value::from(c)); + assert_eq!(bytes[0], CTRL_COORD); + let lat = i32::from_be_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]); + let lng = i32::from_be_bytes([bytes[5], bytes[6], bytes[7], bytes[8]]); + // Fantom packs as (lat+90)*1e6 and (lng+180)*1e6 + assert_eq!(lat, 135_000_000); // (45+90)*1e6 + assert_eq!(lng, 203_000_000); // (23+180)*1e6 + } + + #[test] + fn test_xstr() { + let x = XStr::make("Blob", "data"); + let bytes = enc(&Value::from(x)); + assert_eq!(bytes[0], CTRL_XSTR); + } + + #[test] + fn test_symbol() { + let s = Symbol::make("foo"); + let bytes = enc(&Value::from(s)); + assert_eq!(bytes[0], CTRL_SYMBOL); + } + + #[test] + fn test_empty_dict() { + let d = Dict::default(); + let bytes = enc(&Value::from(d)); + assert_eq!(bytes, vec![CTRL_DICT_EMPTY]); + } + + #[test] + fn test_non_empty_dict() { + let d = dict! { "marker" => Value::make_marker() }; + let bytes = enc(&Value::make_dict(d)); + assert_eq!(bytes[0], CTRL_DICT); + assert_eq!(bytes[1], b'{'); + } + + #[test] + fn test_empty_list() { + let l: List = vec![]; + let bytes = enc(&Value::from(l)); + assert_eq!(bytes, vec![CTRL_LIST_EMPTY]); + } + + #[test] + fn test_non_empty_list() { + let l: List = vec![Value::make_marker(), Value::from(true)]; + let bytes = enc(&Value::from(l)); + assert_eq!(bytes[0], CTRL_LIST); + assert_eq!(bytes[1], b'['); + } + + #[test] + fn test_grid() { + let g = Grid::make_from_dicts(vec![dict! { "dis" => Value::from("Site") }]); + let bytes = enc(&Value::from(g)); + assert_eq!(bytes[0], CTRL_GRID); + assert_eq!(bytes[1], b'<'); + } + + // ----------------------------------------------------------------------- + // Haxall interop: encoded byte sizes verified against BrioTest.fan + // + // Each assertion references the matching `verifyIO(val, expectedSize)` call + // in src/test/testHaystack/fan/BrioTest.fan of the Haxall repository. + // ----------------------------------------------------------------------- + + fn enc_size(v: &Value) -> usize { + enc(v).len() + } + + /// Measure the encoded byte length of a single varint value. + fn varint_size(val: i64) -> usize { + let mut buf: Vec = Vec::new(); + encode_varint(&mut buf, val).unwrap(); + buf.len() + } + + /// Leak a custom `Unit` whose only identifier / symbol is `symbol`. + /// Used only in tests that need a non-database unit (e.g. "_foo"). + fn make_custom_unit(symbol: &'static str) -> &'static Unit { + Box::leak(Box::new(Unit { + quantity: None, + ids: vec![symbol.to_string()], + dimensions: None, + scale: 1.0, + offset: 0.0, + })) + } + + #[test] + fn test_haxall_compat_scalar_sizes() { + // verifyIO(null, 1) verifyIO(Marker.val, 1) etc. + assert_eq!(enc_size(&Value::Null), 1); + assert_eq!(enc_size(&Value::make_marker()), 1); + assert_eq!(enc_size(&Value::make_na()), 1); + assert_eq!(enc_size(&Value::make_remove()), 1); + assert_eq!(enc_size(&Value::from(true)), 1); + assert_eq!(enc_size(&Value::from(false)), 1); + } + + #[test] + fn test_haxall_compat_number_sizes() { + // verifyIO(n(12), 4) + assert_eq!(enc_size(&Value::from(Number::make(12.0))), 4); + // verifyIO(n(123_456_789), 6) + assert_eq!(enc_size(&Value::from(Number::make(123_456_789.0))), 6); + // verifyIO(n(123_456_789, "°F"), 7) — unit is const index 730 → 2-byte varint + assert_eq!( + enc_size(&Value::from(Number::make_with_unit( + 123_456_789.0, + get_unit_or_default("°F") + ))), + 7 + ); + // verifyIO(n(123_456.789f, "°F"), 11) — float + 2-byte unit varint + assert_eq!( + enc_size(&Value::from(Number::make_with_unit( + 123_456.789, + get_unit_or_default("°F") + ))), + 11 + ); + // verifyIO(n(0x7fff), 4) verifyIO(n(0x7fff+1), 6) + assert_eq!(enc_size(&Value::from(Number::make(32767.0))), 4); + assert_eq!(enc_size(&Value::from(Number::make(32768.0))), 6); + // verifyIO(n(-32767), 4) verifyIO(n(-32768), 6) + // Key Haxall rule: I2 range is -32767..=32767, excluding i16::MIN (-32768) + assert_eq!(enc_size(&Value::from(Number::make(-32767.0))), 4); + assert_eq!(enc_size(&Value::from(Number::make(-32768.0))), 6); + // verifyIO(n(0x7fff_ffff), 6) verifyIO(n(0x8000_0000), 10) + assert_eq!(enc_size(&Value::from(Number::make(2_147_483_647.0))), 6); + assert_eq!(enc_size(&Value::from(Number::make(2_147_483_648.0))), 10); + // verifyIO(n(-2147483648), 6) verifyIO(n(-2147483649), 10) + assert_eq!(enc_size(&Value::from(Number::make(-2_147_483_648.0))), 6); + assert_eq!(enc_size(&Value::from(Number::make(-2_147_483_649.0))), 10); + } + + #[test] + fn test_haxall_compat_string_sizes() { + // verifyIO("", 2) — "" is const index 0 → varint(0) = 1 byte + assert_eq!(enc_size(&Value::from("")), 2); + // verifyIO("hello °F world!", 3+16) + // Header: CTRL_STR + 0xff + varint(15) = 3 bytes; CESU-8 body = 16 bytes + // "°" (U+00B0) is 2 CESU-8 bytes; all others ASCII = 14 bytes → total 16 + assert_eq!(enc_size(&Value::from("hello °F world!")), 19); + // verifyIO("siteRef", 3) — "siteRef" is in the const table, 2-byte index + assert_eq!(enc_size(&Value::from("siteRef")), 3); + // verifyIO("New_York", 2) — const index 26, varint = 1 byte + assert_eq!(enc_size(&Value::from("New_York")), 2); + } + + #[test] + fn test_haxall_compat_ref_sizes() { + // verifyIO(Ref("1deb31b8-7508b187"), 10) + // CTRL_REF_I8(1) + i64(8) + encodeStrChars("")=varint(0)(1) = 10 + assert_eq!( + enc_size(&Value::from(Ref::make("1deb31b8-7508b187", None))), + 10 + ); + // verifyIO(Ref("1debX1b8-7508b187"), 21) — 'X' is not hex → STR form + // CTRL_REF_STR(1) + inline("1debX1b8-7508b187"): 0xff+varint(17)+17 = 19 + varint(0) = 21 + assert_eq!( + enc_size(&Value::from(Ref::make("1debX1b8-7508b187", None))), + 21 + ); + // verifyIO(Ref("1deb31b8.7508b187"), 21) — '.' at pos 8, not '-' → STR form + assert_eq!( + enc_size(&Value::from(Ref::make("1deb31b8.7508b187", None))), + 21 + ); + // verifyIO(Ref("1deb31b8-7508b187", "hi!"), 13) + // CTRL_REF_I8(1) + i64(8) + encodeStrChars("hi!")=varint(3)(1)+"hi!"(3) = 13 + assert_eq!( + enc_size(&Value::from(Ref::make("1deb31b8-7508b187", Some("hi!")))), + 13 + ); + } + + #[test] + fn test_haxall_compat_symbol_sizes() { + // verifyIO(Symbol("coolingTower"), 3) — in const table, 2-byte index + assert_eq!(enc_size(&Value::from(Symbol::make("coolingTower"))), 3); + // verifyIO(Symbol("foo-bar"), 3+7) + // CTRL_SYMBOL(1) + 0xff(1) + varint(7)(1) + "foo-bar"(7) = 10 + assert_eq!(enc_size(&Value::from(Symbol::make("foo-bar"))), 10); + } + + #[test] + fn test_haxall_compat_container_sizes() { + // verifyIO(Etc.dict0, 1) + assert_eq!(enc_size(&Value::from(Dict::default())), 1); + // verifyIO(Obj?[,], 1) — empty list + assert_eq!(enc_size(&Value::from(vec![] as List)), 1); + // verifyIO(Coord(37.54f, 77.43f), 9) + assert_eq!(enc_size(&Value::from(Coord::make(37.54, 77.43))), 9); + } + + #[test] + fn test_haxall_compat_varint_sizes() { + // BrioTest.fan testVarInt boundary assertions: + // vals := [-1, 0, 30, 64, 127, 128, 1_000, 16_383, 16_384, 500_123, 536_870_911, 536_870_912, 123_456_789_123] + // sizes := [ 1, 1, 1, 1, 1, 2, 2, 2, 4, 4, 4, 9, 9] + assert_eq!(varint_size(-1), 1); + assert_eq!(varint_size(0), 1); + assert_eq!(varint_size(30), 1); + assert_eq!(varint_size(64), 1); + assert_eq!(varint_size(127), 1); + assert_eq!(varint_size(128), 2); + assert_eq!(varint_size(1_000), 2); + assert_eq!(varint_size(16_383), 2); + assert_eq!(varint_size(16_384), 4); + assert_eq!(varint_size(500_123), 4); + assert_eq!(varint_size(536_870_911), 4); + assert_eq!(varint_size(536_870_912), 9); + assert_eq!(varint_size(123_456_789_123), 9); + } + + #[test] + fn test_haxall_compat_number_nonconstunit() { + // verifyIO(n(123_456_789, "_foo"), 11) + // CTRL_NUMBER_I4(1) + i32(4) + inline("_foo"): varint(-1)(1)+varint(4)(1)+"_foo"(4) = 11 + assert_eq!( + enc_size(&Value::from(Number::make_with_unit( + 123_456_789.0, + make_custom_unit("_foo") + ))), + 11 + ); + } + + #[test] + fn test_haxall_compat_coord_negative() { + // verifyIO(Coord(-17.535f, -149.569f), 9) + assert_eq!(enc_size(&Value::from(Coord::make(-17.535, -149.569))), 9); + } + + #[test] + fn test_haxall_compat_datetime_sizes() { + fn dt_size(iso: &str, tz: &str) -> usize { + let dt = DateTime::parse_from_rfc3339_with_timezone(iso, tz).unwrap(); + enc_size(&Value::from(dt)) + } + // verifyIO(DateTime("2015-11-30T12:03:57-05:00 New_York"), 6) + // I4 (no sub-seconds), New_York = const 26 → 1-byte varint: CTRL(1)+i32(4)+varint(1) = 6 + assert_eq!(dt_size("2015-11-30T12:03:57-05:00", "New_York"), 6); + // verifyIO(DateTime("2015-11-30T12:02:33.378-05:00 New_York"), 10) + // I8 (has millis), New_York const: CTRL(1)+i64(8)+varint(1) = 10 + assert_eq!(dt_size("2015-11-30T12:02:33.378-05:00", "New_York"), 10); + // verifyIO(DateTime("2015-11-30T12:03:57.000123-05:00 New_York"), 10) + // I8 (non-zero sub-second): CTRL(1)+i64(8)+varint(1) = 10 + assert_eq!(dt_size("2015-11-30T12:03:57.000123-05:00", "New_York"), 10); + // verifyIO(DateTime("2000-01-01T00:00:00+01:00 Warsaw"), 13) + // I4, "Warsaw" not in const → inline(8): CTRL(1)+i32(4)+8 = 13 + assert_eq!(dt_size("2000-01-01T00:00:00+01:00", "Warsaw"), 13); + // verifyIO(DateTime("2000-01-01T00:00:00.832+01:00 Warsaw"), 17) + // I8, Warsaw inline(8): CTRL(1)+i64(8)+8 = 17 + assert_eq!(dt_size("2000-01-01T00:00:00.832+01:00", "Warsaw"), 17); + // verifyIO(DateTime("1999-06-07T01:02:00-04:00 New_York"), 6) + assert_eq!(dt_size("1999-06-07T01:02:00-04:00", "New_York"), 6); + // verifyIO(DateTime("1950-06-07T01:02:00-04:00 New_York"), 6) + assert_eq!(dt_size("1950-06-07T01:02:00-04:00", "New_York"), 6); + // verifyIO(DateTime("1950-06-07T01:02:00.123-04:00 New_York"), 10) + assert_eq!(dt_size("1950-06-07T01:02:00.123-04:00", "New_York"), 10); + } +} diff --git a/src/haystack/encoding/brio/haxall_fixtures.rs b/src/haystack/encoding/brio/haxall_fixtures.rs new file mode 100644 index 0000000..1c2b2c5 --- /dev/null +++ b/src/haystack/encoding/brio/haxall_fixtures.rs @@ -0,0 +1,713 @@ +// Copyright (C) 2020 - 2022, J2 Innovations + +//! Brio fixture tests derived from the Haxall/Fantom BrioWriter reference implementation. +//! +//! Each fixture was generated by running `fan/BrioGen.fan` against the Haxall Fantom runtime: +//! `fan fan/BrioGen.fan > /tmp/brio_fixtures.txt` +//! +//! The fixture label corresponds to the label used in that script. +//! +//! For each fixture we verify: +//! 1. **Decode**: parse the hex bytes → Haystack Value, compare to expected value. +//! 2. **Encode round-trip**: re-encode the decoded value → assert same hex. +//! +//! Notes on limitations: +//! - **DateTime with named timezones**: encoding requires `timezone-db` feature; without it, +//! all DateTimes collapse to UTC. Tests that depend on correct timezone round-tripping are +//! gated on `#[cfg(feature = "timezone-db")]`. +//! - **Dict key ordering**: Rust `BTreeMap` iterates alphabetically; Fantom uses insertion +//! order. For multi-key dicts the decoded Value is correct but the re-encoded hex may +//! differ in key order, so we only verify decode equality for those fixtures. + +#[cfg(all(test, feature = "brio-decoding", feature = "brio-encoding"))] +mod tests { + use crate::dict; + use crate::encoding::brio::decode::from_brio; + use crate::encoding::brio::encode::ToBrio; + use crate::haystack::val::{ + Coord, Date, DateTime, Dict, List, Number, Ref, Symbol, Time, Uri, Value, XStr, + }; + use crate::units::get_unit_or_default; + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /// Decode a lower-case hex string into a `Vec`. + fn hex(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex")) + .collect() + } + + /// Decode Brio bytes from a hex string into a Value. + fn decode(hex_str: &str) -> Value { + let bytes = hex(hex_str); + from_brio(&mut bytes.as_slice()).expect("decode") + } + + /// Re-encode a value and return the bytes as a lower-case hex string. + fn encode(v: &Value) -> String { + let bytes = v.to_brio_vec().expect("encode"); + bytes.iter().map(|b| format!("{b:02x}")).collect() + } + + /// Full fixture assertion: decode hex -> assert value, then encode value -> assert hex. + /// + /// Use this for types whose encoding is deterministic and matches Fantom exactly: + /// scalars, numbers, strings, refs, dates, times, single-key dicts, lists, coords. + fn assert_fixture(hex_str: &str, expected: &Value) { + let got = decode(hex_str); + assert_eq!(&got, expected, "decode mismatch for fixture {hex_str}"); + let reencoded = encode(expected); + assert_eq!( + reencoded, hex_str, + "encode mismatch: expected {hex_str} but got {reencoded}" + ); + } + + /// Decode-only assertion: decode hex -> assert value equals expected. + /// + /// Use for cases where re-encoding produces different (but equivalent) bytes, + /// e.g., multi-key dicts where key iteration order differs from Fantom. + fn assert_decode(hex_str: &str, expected: &Value) { + let got = decode(hex_str); + assert_eq!(&got, expected, "decode mismatch for fixture {hex_str}"); + } + + /// Round-trip assertion: decode hex -> re-encode -> assert same hex. + /// + /// Use for fixtures where the round-trip is stable but the expected value + /// cannot be cheaply constructed in Rust (e.g. sub-second DateTimes). + fn assert_roundtrip(hex_str: &str) { + let got = decode(hex_str); + let reencoded = encode(&got); + assert_eq!( + reencoded, hex_str, + "round-trip mismatch: expected {hex_str} but got {reencoded}" + ); + } + + // ----------------------------------------------------------------------- + // Scalar singletons + // ----------------------------------------------------------------------- + + #[test] + fn fixture_null() { + assert_fixture("00", &Value::Null); + } + + #[test] + fn fixture_marker() { + assert_fixture("01", &Value::make_marker()); + } + + #[test] + fn fixture_na() { + assert_fixture("02", &Value::make_na()); + } + + #[test] + fn fixture_remove() { + assert_fixture("03", &Value::make_remove()); + } + + #[test] + fn fixture_false() { + assert_fixture("04", &Value::from(false)); + } + + #[test] + fn fixture_true() { + assert_fixture("05", &Value::from(true)); + } + + // ----------------------------------------------------------------------- + // Number - I2 (no unit) + // ----------------------------------------------------------------------- + + #[test] + fn fixture_n_zero() { + assert_fixture("06000000", &Value::from(Number::make(0.0))); + } + + #[test] + fn fixture_n_i2_42() { + assert_fixture("06002a00", &Value::from(Number::make(42.0))); + } + + /// -1 fits in I2 (sign-extended 16-bit, varint 0x00 for no unit) + #[test] + fn fixture_n_i2_neg() { + assert_fixture("06ffff00", &Value::from(Number::make(-1.0))); + } + + /// I2 maximum: 32767 + #[test] + fn fixture_n_i2_max() { + assert_fixture("067fff00", &Value::from(Number::make(32767.0))); + } + + /// I2 minimum representable: -32767 + #[test] + fn fixture_n_i2_min() { + assert_fixture("06800100", &Value::from(Number::make(-32767.0))); + } + + // ----------------------------------------------------------------------- + // Number - I4 (no unit, value outside I2 range) + // ----------------------------------------------------------------------- + + /// First value that requires I4: 32768 + #[test] + fn fixture_n_i4_pos() { + assert_fixture("070000800000", &Value::from(Number::make(32768.0))); + } + + /// -32768 requires I4 (I2 min is -32767) + #[test] + fn fixture_n_i4_neg() { + assert_fixture("07ffff800000", &Value::from(Number::make(-32768.0))); + } + + #[test] + fn fixture_n_i4_max() { + assert_fixture("077fffffff00", &Value::from(Number::make(2_147_483_647.0))); + } + + #[test] + fn fixture_n_i4_min() { + assert_fixture("078000000000", &Value::from(Number::make(-2_147_483_648.0))); + } + + // ----------------------------------------------------------------------- + // Number - F8 (double, no unit) + // ----------------------------------------------------------------------- + + #[test] + fn fixture_n_f8_pi() { + let pi = std::f64::consts::PI; + assert_fixture("08400921fb54442d1800", &Value::from(Number::make(pi))); + } + + /// 2^31 = 2147483648 - requires F8 + #[test] + fn fixture_n_f8_big() { + assert_fixture( + "0841e000000000000000", + &Value::from(Number::make(2_147_483_648.0)), + ); + } + + /// -(2^31 + 1) - requires F8 + #[test] + fn fixture_n_f8_bigneg() { + assert_fixture( + "08c1e000000020000000", + &Value::from(Number::make(-2_147_483_649.0)), + ); + } + + // ----------------------------------------------------------------------- + // Number - with units (const table lookups) + // ----------------------------------------------------------------------- + + /// 98.6 degF - F8 encoding with const unit index + #[test] + fn fixture_n_degf() { + let v = Value::from(Number::make_with_unit( + 98.6, + get_unit_or_default("\u{00b0}F"), + )); + assert_fixture("084058a6666666666682da", &v); + } + + /// 1500kW - I2 with const unit + #[test] + fn fixture_n_kw() { + let v = Value::from(Number::make_with_unit(1500.0, get_unit_or_default("kW"))); + assert_fixture("0605dc8276", &v); + } + + /// 99kWh - I2 with const unit + #[test] + fn fixture_n_kwh() { + let v = Value::from(Number::make_with_unit(99.0, get_unit_or_default("kWh"))); + assert_fixture("060063827c", &v); + } + + /// 22 degC - I2 with const unit + #[test] + fn fixture_n_degc() { + let v = Value::from(Number::make_with_unit( + 22.0, + get_unit_or_default("\u{00b0}C"), + )); + assert_fixture("06001682d7", &v); + } + + /// 75% - I2 with const unit + #[test] + fn fixture_n_pct() { + let v = Value::from(Number::make_with_unit(75.0, get_unit_or_default("%"))); + assert_fixture("06004b81c6", &v); + } + + /// 400cfm - I2 with const unit + #[test] + fn fixture_n_cfm() { + let v = Value::from(Number::make_with_unit(400.0, get_unit_or_default("cfm"))); + assert_fixture("0601908233", &v); + } + + // ----------------------------------------------------------------------- + // Str + // ----------------------------------------------------------------------- + + /// Empty string - const index 0 + #[test] + fn fixture_str_empty() { + assert_fixture("0900", &Value::from("")); + } + + /// "hello" - inline (not in const table) + #[test] + fn fixture_str_hello() { + assert_fixture("09ff0568656c6c6f", &Value::from("hello")); + } + + /// "New_York" - const index 26 (single-byte varint 0x1a) + #[test] + fn fixture_str_ny() { + assert_fixture("091a", &Value::from("New_York")); + } + + /// "siteRef" - const index 642 (two-byte varint 0x8182) + #[test] + fn fixture_str_siteref() { + assert_fixture("098182", &Value::from("siteRef")); + } + + /// "dis" - const index 313 (two-byte varint 0x80b9) + #[test] + fn fixture_str_dis() { + assert_fixture("0980b9", &Value::from("dis")); + } + + /// "cafe" with accent - inline CESU-8: e-acute = c3 a9 + #[test] + fn fixture_str_cafe() { + assert_fixture("09ff04636166c3a9", &Value::from("caf\u{00e9}")); + } + + /// "temp degF" with degree sign - inline CESU-8: degree = c2 b0 + #[test] + fn fixture_str_degf_label() { + assert_fixture("09ff0774656d7020c2b046", &Value::from("temp \u{00b0}F")); + } + + // ----------------------------------------------------------------------- + // Uri + // ----------------------------------------------------------------------- + + #[test] + fn fixture_uri_http() { + assert_fixture( + "0cff13687474703a2f2f6578616d706c652e636f6d2f", + &Value::from(Uri::make("http://example.com/")), + ); + } + + #[test] + fn fixture_uri_path() { + assert_fixture("0cff05612f622f63", &Value::from(Uri::make("a/b/c"))); + } + + // ----------------------------------------------------------------------- + // Ref + // ----------------------------------------------------------------------- + + /// I8 ref with no display string: id = "1deb31b8-7508b187" + #[test] + fn fixture_ref_i8_nodis() { + assert_fixture( + "0b1deb31b87508b18700", + &Value::from(Ref::make("1deb31b8-7508b187", None)), + ); + } + + /// I8 ref with display string "hi!": encodes as CTRL_REF_I8 + encodeStrChars + #[test] + fn fixture_ref_i8_dis() { + assert_fixture( + "0b1deb31b87508b18703686921", + &Value::from(Ref::make("1deb31b8-7508b187", Some("hi!"))), + ); + } + + /// "cafebabe-deadbeef" packs to 0xcafebabedeadbeef which is negative as i64, + /// so Fantom falls back to CTRL_REF_STR (0x0a) with display "Site delta". + #[test] + fn fixture_ref_i8_dis2() { + assert_fixture( + "0aff1163616665626162652d6465616462656566065369746520ce94", + &Value::from(Ref::make("cafebabe-deadbeef", Some("Site \u{0394}"))), + ); + } + + /// STR ref with no display string: id = "1debX1b8-7508b187" + #[test] + fn fixture_ref_str_nodis() { + assert_fixture( + "0aff1131646562583162382d373530386231383700", + &Value::from(Ref::make("1debX1b8-7508b187", None)), + ); + } + + /// STR ref with display string "My Equip": id = "custom.ref" + #[test] + fn fixture_ref_str_dis() { + assert_fixture( + "0aff0a637573746f6d2e726566084d79204571756970", + &Value::from(Ref::make("custom.ref", Some("My Equip"))), + ); + } + + // ----------------------------------------------------------------------- + // Date + // ----------------------------------------------------------------------- + + #[test] + fn fixture_date_2015() { + let d = Date::from_ymd(2015, 11, 30).expect("date"); + assert_fixture("0d07df0b1e", &Value::from(d)); + } + + #[test] + fn fixture_date_2000() { + let d = Date::from_ymd(2000, 1, 1).expect("date"); + assert_fixture("0d07d00101", &Value::from(d)); + } + + #[test] + fn fixture_date_1970() { + let d = Date::from_ymd(1970, 1, 1).expect("date"); + assert_fixture("0d07b20101", &Value::from(d)); + } + + #[test] + fn fixture_date_1950() { + let d = Date::from_ymd(1950, 6, 7).expect("date"); + assert_fixture("0d079e0607", &Value::from(d)); + } + + #[test] + fn fixture_date_2099() { + let d = Date::from_ymd(2099, 12, 31).expect("date"); + assert_fixture("0d08330c1f", &Value::from(d)); + } + + // ----------------------------------------------------------------------- + // Time + // + // Brio encodes Time as milliseconds since midnight (i32 big-endian). + // ----------------------------------------------------------------------- + + #[test] + fn fixture_time_midnight() { + let t = Time::from_hms(0, 0, 0).expect("time"); + assert_fixture("0e00000000", &Value::from(t)); + } + + /// noon = 12:00:00.000 -> 12*3600*1000 = 43_200_000 = 0x02932e00 + #[test] + fn fixture_time_noon() { + let t = Time::from_hms_milli(12, 0, 0, 0).expect("time"); + assert_fixture("0e02932e00", &Value::from(t)); + } + + /// 15:06:13.000 -> (15*3600 + 6*60 + 13)*1000 = 54_373_000 = 0x033daa88 + #[test] + fn fixture_time_hms() { + let t = Time::from_hms_milli(15, 6, 13, 0).expect("time"); + assert_fixture("0e033daa88", &Value::from(t)); + } + + /// 15:06:13.123 -> 54_373_123 = 0x033dab03 + #[test] + fn fixture_time_ms() { + let t = Time::from_hms_milli(15, 6, 13, 123).expect("time"); + assert_fixture("0e033dab03", &Value::from(t)); + } + + // ----------------------------------------------------------------------- + // DateTime + // + // Strategy: + // - All fixtures verify the decoded unix timestamp (timezone-independent). + // - Fixtures that use New_York / Warsaw require timezone-db to round-trip + // to the exact Fantom hex; without that feature the tz collapses to UTC. + // - UTC fixtures always do a full assert_fixture. + // - Sub-second (I8) fixtures always use assert_roundtrip. + // + // Fantom source values: + // dt_i4_ny = "2015-11-30T12:03:57-05:00 New_York" -> UTC 2015-11-30T17:03:57Z + // dt_i4_utc = "2021-06-15T12:00:00Z UTC" -> UTC 2021-06-15T12:00:00Z + // dt_i4_pre2k = "1999-06-07T01:02:00-04:00 New_York" -> UTC 1999-06-07T05:02:00Z + // dt_i4_pre70 = "1950-06-07T01:02:00-04:00 New_York" -> UTC 1950-06-07T05:02:00Z + // dt_i4_warsaw = "2000-01-01T00:00:00+01:00 Warsaw" -> UTC 1999-12-31T23:00:00Z + // ----------------------------------------------------------------------- + + /// I4 DateTime in UTC (const index 24 = 0x18) - always fully verifiable. + #[test] + fn fixture_dt_i4_utc() { + let dt = DateTime::parse_from_rfc3339("2021-06-15T12:00:00Z").expect("dt"); + assert_fixture("0f285b52c018", &Value::from(dt)); + } + + /// I4 DateTime in America/New_York - verify unix timestamp; exact hex needs tz-db. + #[test] + fn fixture_dt_i4_ny() { + let got = decode("0f1def3dfd1a"); + let dt = DateTime::try_from(&got).expect("datetime"); + assert_eq!(dt.timestamp(), 1_448_903_037, "unix timestamp mismatch"); + } + + #[cfg(feature = "timezone-db")] + #[test] + fn fixture_dt_i4_ny_exact() { + assert_roundtrip("0f1def3dfd1a"); + } + + /// I4 DateTime before 2000 in New_York - verify unix timestamp. + #[test] + fn fixture_dt_i4_pre2k() { + let got = decode("0ffeee0ec81a"); + let dt = DateTime::try_from(&got).expect("datetime"); + assert_eq!(dt.timestamp(), 928_731_720, "unix timestamp mismatch"); + } + + #[cfg(feature = "timezone-db")] + #[test] + fn fixture_dt_i4_pre2k_exact() { + assert_roundtrip("0ffeee0ec81a"); + } + + /// I4 DateTime before 1970 in New_York - verify unix timestamp. + #[test] + fn fixture_dt_i4_pre70() { + let got = decode("0fa2c361481a"); + let dt = DateTime::try_from(&got).expect("datetime"); + assert_eq!(dt.timestamp(), -617_569_080, "unix timestamp mismatch"); + } + + #[cfg(feature = "timezone-db")] + #[test] + fn fixture_dt_i4_pre70_exact() { + assert_roundtrip("0fa2c361481a"); + } + + /// I4 DateTime in Warsaw (inline timezone) - verify unix timestamp. + /// Fixture: ctrl=0f, i32=0xfffff1f0=-3600, tz=inline "Warsaw" + #[test] + fn fixture_dt_i4_warsaw() { + let got = decode("0ffffff1f0ff06576172736177"); + let dt = DateTime::try_from(&got).expect("datetime"); + assert_eq!(dt.timestamp(), 946_681_200, "unix timestamp mismatch"); + } + + #[cfg(feature = "timezone-db")] + #[test] + fn fixture_dt_i4_warsaw_exact() { + assert_roundtrip("0ffffff1f0ff06576172736177"); + } + + /// I8 DateTime with millisecond precision in New_York. + #[test] + fn fixture_dt_i8_ny_ms() { + assert_roundtrip("1006f83cbfe7d92c801a"); + } + + /// I8 DateTime with microsecond precision in New_York. + #[test] + fn fixture_dt_i8_ny_us() { + assert_roundtrip("1006f83cd3601d82781a"); + } + + /// I8 DateTime before 1970 with millisecond precision. + #[test] + fn fixture_dt_i8_pre70() { + assert_roundtrip("10ea4aa7624f67a4c01a"); + } + + /// I8 DateTime in Warsaw with millisecond precision (inline timezone). + #[test] + fn fixture_dt_i8_warsaw() { + assert_roundtrip("10fffffcba00deb000ff06576172736177"); + } + + // ----------------------------------------------------------------------- + // Coord + // + // Fantom packs: lat_packed = (lat + 90) * 1_000_000 + // lng_packed = (lng + 180) * 1_000_000 + // Both stored as big-endian i32. + // + // Floating-point rounding means decoded values may differ by up to 1e-6 + // from the original, so we use tolerance comparisons. + // ----------------------------------------------------------------------- + + /// Assert a coord fixture with 1e-6 tolerance for the decoded coordinate values. + fn assert_coord_fixture(hex_str: &str, expected_lat: f64, expected_lng: f64) { + let got = decode(hex_str); + let c = Coord::try_from(&got).expect("coord"); + assert!( + (c.lat - expected_lat).abs() < 1e-6, + "lat mismatch: {} vs {}", + c.lat, + expected_lat + ); + assert!( + (c.long - expected_lng).abs() < 1e-6, + "lng mismatch: {} vs {}", + c.long, + expected_lng + ); + // Re-encoding `got` (which stores the rounded lat/lng) should reproduce + // the original fixture bytes exactly. + let reencoded = encode(&got); + assert_eq!(reencoded, hex_str, "coord re-encode mismatch"); + } + + #[test] + fn fixture_coord_pos() { + // lat=37.54, lng=77.43 + // lat_packed = (37.54 + 90) * 1e6 = 127_540_000 = 0x079a1b20 + // lng_packed = (77.43 + 180) * 1e6 = 257_430_000 = 0x0f5811f0 + assert_coord_fixture("11079a1b200f5811f0", 37.54, 77.43); + } + + #[test] + fn fixture_coord_neg() { + // lat=-17.535, lng=-149.569 + // lat_packed = (-17.535 + 90) * 1e6 = 72_465_000 = 0x0451ba68 + // lng_packed = (-149.569 + 180) * 1e6 = 30_431_000 = 0x01d05718 + assert_coord_fixture("110451ba6801d05718", -17.535, -149.569); + } + + #[test] + fn fixture_coord_zero() { + // lat=0, lng=0 - exact integer packing, no rounding error + // lat_packed = 90 * 1e6 = 90_000_000 = 0x055d4a80 + // lng_packed = 180 * 1e6 = 180_000_000 = 0x0aba9500 + assert_fixture("11055d4a800aba9500", &Value::from(Coord::make(0.0, 0.0))); + } + + // ----------------------------------------------------------------------- + // Symbol + // ----------------------------------------------------------------------- + + /// "coolingTower" - two-byte varint const index + #[test] + fn fixture_sym_const() { + assert_fixture("1980a3", &Value::from(Symbol::make("coolingTower"))); + } + + /// "foo-bar" - inline (not in const table) + #[test] + fn fixture_sym_inline() { + assert_fixture( + "19ff07666f6f2d626172", + &Value::from(Symbol::make("foo-bar")), + ); + } + + /// "site" - two-byte varint const index + #[test] + fn fixture_sym_site() { + assert_fixture("19817e", &Value::from(Symbol::make("site"))); + } + + // ----------------------------------------------------------------------- + // XStr + // + // XStr type="Foo" (inline, not in const table), value="bar" (const 555 = 0x822b) + // ----------------------------------------------------------------------- + + #[test] + fn fixture_xstr_foo() { + assert_fixture("12ff03466f6f822b", &Value::from(XStr::make("Foo", "bar"))); + } + + // ----------------------------------------------------------------------- + // Dict + // + // Note on key ordering: Rust encodes dict keys in alphabetical BTreeMap + // order; Fantom uses insertion order. For single-key dicts there is no + // ambiguity. For multi-key dicts we only assert decode equality (the + // decoded Dict compares by key/value set, not iteration order). + // ----------------------------------------------------------------------- + + #[test] + fn fixture_dict_empty() { + assert_fixture("14", &Value::from(Dict::default())); + } + + /// {dis:"Hello"} - single key, no ordering ambiguity + #[test] + fn fixture_dict_dis() { + let v = Value::make_dict(dict! { "dis" => Value::from("Hello") }); + assert_fixture("157b0180b909ff0548656c6c6f7d", &v); + } + + /// {site:M, dis:"Site"} + /// Fantom order: site, dis. Rust BTreeMap order: dis, site. + /// Decode check only - the re-encoded hex differs in key order. + #[test] + fn fixture_dict_site_decode() { + let expected = Value::make_dict(dict! { + "site" => Value::make_marker(), + "dis" => Value::from("Site") + }); + assert_decode("157b02817e0180b909ff04536974657d", &expected); + } + + /// {val: 123kW} - single key, no ordering ambiguity + #[test] + fn fixture_dict_num() { + let v = Value::make_dict( + dict! { "val" => Value::from(Number::make_with_unit(123.0, get_unit_or_default("kW"))) }, + ); + assert_fixture("157b0181aa06007b82767d", &v); + } + + // ----------------------------------------------------------------------- + // List + // ----------------------------------------------------------------------- + + #[test] + fn fixture_list_empty() { + assert_fixture("16", &Value::from(List::default())); + } + + /// [M] + #[test] + fn fixture_list_marker() { + assert_fixture( + "175b01015d", + &Value::from(List::from(vec![Value::make_marker()])), + ); + } + + /// ["hello", 42, M] - mixed list; order is stable + #[test] + fn fixture_list_mixed() { + let list = List::from(vec![ + Value::from("hello"), + Value::from(Number::make(42.0)), + Value::make_marker(), + ]); + assert_fixture("175b0309ff0568656c6c6f06002a00015d", &Value::from(list)); + } +} diff --git a/src/haystack/encoding/brio/mod.rs b/src/haystack/encoding/brio/mod.rs new file mode 100644 index 0000000..95dd729 --- /dev/null +++ b/src/haystack/encoding/brio/mod.rs @@ -0,0 +1,39 @@ +// Copyright (C) 2020 - 2022, J2 Innovations + +//! Brio binary encoding/decoding for Haystack values. +//! +//! Brio is a compact binary format used by Project Haystack and the Haxall platform. +//! It provides efficient binary serialization of all Haystack value types. +//! +//! # Features +//! - `brio-encoding`: enables the [`encode`] module +//! - `brio-decoding`: enables the [`decode`] module +//! +//! # Example – round-trip a `Dict` +//! ```rust +//! use libhaystack::val::*; +//! use libhaystack::dict; +//! use libhaystack::encoding::brio::encode::ToBrio; +//! use libhaystack::encoding::brio::decode::from_brio; +//! +//! let val = Value::make_dict(dict! { +//! "site" => Value::make_marker(), +//! "dis" => Value::from("Main Campus") +//! }); +//! +//! let bytes = val.to_brio_vec().expect("encode"); +//! let decoded = from_brio(&mut bytes.as_slice()).expect("decode"); +//! assert_eq!(val, decoded); +//! ``` + +#[cfg(any(feature = "brio-encoding", feature = "brio-decoding"))] +pub mod consts; + +#[cfg(feature = "brio-encoding")] +pub mod encode; + +#[cfg(feature = "brio-decoding")] +pub mod decode; + +#[cfg(all(test, feature = "brio-encoding", feature = "brio-decoding"))] +mod haxall_fixtures; diff --git a/src/haystack/encoding/mod.rs b/src/haystack/encoding/mod.rs index eeb7bac..bfe5fbe 100644 --- a/src/haystack/encoding/mod.rs +++ b/src/haystack/encoding/mod.rs @@ -7,3 +7,5 @@ pub mod json; #[cfg(feature = "zinc")] pub mod zinc; +#[cfg(feature = "brio")] +pub mod brio; From a35d60a5dc5cfd0fa5aff1f76a56b342be3a0151 Mon Sep 17 00:00:00 2001 From: Gareth Johnson <75035963+garethj2@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:50:00 +0000 Subject: [PATCH 2/7] Code review feedback (AI) --- src/haystack/encoding/brio/consts.rs | 3 +++ src/haystack/encoding/brio/decode.rs | 28 +++++++++++++++++++++++----- src/haystack/encoding/brio/encode.rs | 5 +---- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/haystack/encoding/brio/consts.rs b/src/haystack/encoding/brio/consts.rs index 6c8d74c..54e8ef3 100644 --- a/src/haystack/encoding/brio/consts.rs +++ b/src/haystack/encoding/brio/consts.rs @@ -11,6 +11,9 @@ //! wire (confirmed by `BrioTest.fan`: `verifyConsts(cp, "", 0)`). All other //! entries are 1-based. +/// Seconds between the Unix epoch (1970-01-01) and the Fantom epoch (2000-01-01). +pub const FANTOM_EPOCH_UNIX_SECS: i64 = 946_684_800; + use std::collections::HashMap; use std::sync::OnceLock; diff --git a/src/haystack/encoding/brio/decode.rs b/src/haystack/encoding/brio/decode.rs index 0c76d68..5ab2002 100644 --- a/src/haystack/encoding/brio/decode.rs +++ b/src/haystack/encoding/brio/decode.rs @@ -11,12 +11,12 @@ use crate::haystack::val::{ Time, Uri, Value, XStr, }; -use super::consts::get_const; +use super::consts::{get_const, FANTOM_EPOCH_UNIX_SECS}; use super::encode::{ CTRL_COORD, CTRL_DATE, CTRL_DATETIME_I4, CTRL_DATETIME_I8, CTRL_DICT, CTRL_DICT_EMPTY, CTRL_FALSE, CTRL_GRID, CTRL_LIST, CTRL_LIST_EMPTY, CTRL_MARKER, CTRL_NA, CTRL_NULL, CTRL_NUMBER_F8, CTRL_NUMBER_I2, CTRL_NUMBER_I4, CTRL_REF_I8, CTRL_REF_STR, CTRL_REMOVE, - CTRL_STR, CTRL_SYMBOL, CTRL_TIME, CTRL_TRUE, CTRL_URI, CTRL_XSTR, FANTOM_EPOCH_UNIX_SECS, + CTRL_STR, CTRL_SYMBOL, CTRL_TIME, CTRL_TRUE, CTRL_URI, CTRL_XSTR, }; // --------------------------------------------------------------------------- @@ -128,8 +128,13 @@ pub fn decode_varint(reader: &mut R) -> Result { let b23 = read_u16(reader)? as i64; return Ok(((v & 0x1f) as i64) << 24 | b1 << 16 | b23); } - // v == 0xe0 - Ok(read_i64(reader)?) + if v == 0xe0 { + return Ok(read_i64(reader)?); + } + // 0xe1–0xfe are not produced by encode_varint and have no defined meaning + Err(Error::Message(format!( + "Invalid varint leading byte: {v:#x}" + ))) } /// Read one CESU-8 byte sequence and return the UTF-16 code unit it encodes. @@ -229,7 +234,7 @@ fn datetime_from_nanos(fantom_nanos: i64, tz: &str) -> Result { let utc = Utc .timestamp_opt(unix_secs, nanos) .single() - .ok_or_else(|| Error::Message(format!("Invalid nanosecond timestamp")))?; + .ok_or_else(|| Error::Message(format!("Invalid nanosecond timestamp: {fantom_nanos}")))?; let rfc3339 = utc.to_rfc3339_opts(SecondsFormat::Nanos, true); DateTime::parse_from_rfc3339_with_timezone(&rfc3339, tz).map_err(Error::Message) } @@ -839,6 +844,19 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_decode_varint_invalid_leading_byte() { + // 0xe1–0xfe are never written by encode_varint; decoding them must return an error + // rather than silently consuming 8 bytes as if they were 0xe0. + for byte in [0xe1u8, 0xf0, 0xfe] { + let result = decode_varint(&mut [byte].as_ref()); + assert!( + result.is_err(), + "expected error for leading byte {byte:#x}, got Ok" + ); + } + } + #[test] fn test_round_trip_uses_consts() { // Dict with known const tag names should encode compactly and round-trip. diff --git a/src/haystack/encoding/brio/encode.rs b/src/haystack/encoding/brio/encode.rs index f33d503..1c3f4e7 100644 --- a/src/haystack/encoding/brio/encode.rs +++ b/src/haystack/encoding/brio/encode.rs @@ -6,7 +6,7 @@ use std::io::Write; -use super::consts::lookup_const; +use super::consts::{lookup_const, FANTOM_EPOCH_UNIX_SECS}; use crate::haystack::val::{ Bool, Coord, Date, DateTime, Dict, Grid, List, Marker, Na, Number, Ref, Remove, Str, Symbol, Time, Uri, Value, XStr, @@ -42,9 +42,6 @@ pub const CTRL_LIST: u8 = 0x17; pub const CTRL_GRID: u8 = 0x18; pub const CTRL_SYMBOL: u8 = 0x19; -/// Seconds between Unix epoch (1970-01-01) and Fantom epoch (2000-01-01). -pub const FANTOM_EPOCH_UNIX_SECS: i64 = 946_684_800; - // --------------------------------------------------------------------------- // Error / Result // --------------------------------------------------------------------------- From 3c6fb0dc682e54401a799ae695e8b851acda865c Mon Sep 17 00:00:00 2001 From: Gareth Johnson <75035963+garethj2@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:59:08 +0000 Subject: [PATCH 3/7] Clippy feedback --- src/haystack/encoding/brio/consts.rs | 6 +++--- src/haystack/encoding/brio/decode.rs | 3 +-- src/haystack/encoding/brio/encode.rs | 6 +++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/haystack/encoding/brio/consts.rs b/src/haystack/encoding/brio/consts.rs index 54e8ef3..cc10012 100644 --- a/src/haystack/encoding/brio/consts.rs +++ b/src/haystack/encoding/brio/consts.rs @@ -11,12 +11,12 @@ //! wire (confirmed by `BrioTest.fan`: `verifyConsts(cp, "", 0)`). All other //! entries are 1-based. -/// Seconds between the Unix epoch (1970-01-01) and the Fantom epoch (2000-01-01). -pub const FANTOM_EPOCH_UNIX_SECS: i64 = 946_684_800; - use std::collections::HashMap; use std::sync::OnceLock; +/// Seconds between the Unix epoch (1970-01-01) and the Fantom epoch (2000-01-01). +pub const FANTOM_EPOCH_UNIX_SECS: i64 = 946_684_800; + /// Canonical string constants, indexed from 0. Index 0 is the empty string. /// Haxall encodes `""` as `varint(0)` — confirmed by `BrioTest.fan`: /// `verifyConsts(cp, "", 0)` diff --git a/src/haystack/encoding/brio/decode.rs b/src/haystack/encoding/brio/decode.rs index 5ab2002..9c4b619 100644 --- a/src/haystack/encoding/brio/decode.rs +++ b/src/haystack/encoding/brio/decode.rs @@ -129,7 +129,7 @@ pub fn decode_varint(reader: &mut R) -> Result { return Ok(((v & 0x1f) as i64) << 24 | b1 << 16 | b23); } if v == 0xe0 { - return Ok(read_i64(reader)?); + return read_i64(reader); } // 0xe1–0xfe are not produced by encode_varint and have no defined meaning Err(Error::Message(format!( @@ -361,7 +361,6 @@ fn dict_or_none(reader: &mut R) -> Result> { /// inspecting a ctrl byte). All scalar and singleton types are decoded only /// through `Value::from_brio`, which is the natural entry-point for /// ctrl-byte-driven stream decoding. - impl FromBrio for Dict { fn from_brio(reader: &mut R) -> Result { match read_u8(reader)? { diff --git a/src/haystack/encoding/brio/encode.rs b/src/haystack/encoding/brio/encode.rs index 1c3f4e7..5358560 100644 --- a/src/haystack/encoding/brio/encode.rs +++ b/src/haystack/encoding/brio/encode.rs @@ -402,7 +402,7 @@ impl ToBrio for Dict { encode_str(writer, key)?; val.to_brio(writer)?; } - writer.write_all(&[b'}'])?; + writer.write_all(b"}")?; } Ok(()) } @@ -418,7 +418,7 @@ impl ToBrio for List { for val in self.iter() { val.to_brio(writer)?; } - writer.write_all(&[b']'])?; + writer.write_all(b"]")?; } Ok(()) } @@ -455,7 +455,7 @@ impl ToBrio for Grid { } } - writer.write_all(&[b'>'])?; + writer.write_all(b">")?; Ok(()) } } From ed6b0272dae258d2eef6b2f266f36edcb6f2197f Mon Sep 17 00:00:00 2001 From: Gareth Johnson <75035963+garethj2@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:26:31 +0000 Subject: [PATCH 4/7] Improve fmt --- src/haystack/encoding/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/haystack/encoding/mod.rs b/src/haystack/encoding/mod.rs index bfe5fbe..56528f8 100644 --- a/src/haystack/encoding/mod.rs +++ b/src/haystack/encoding/mod.rs @@ -3,9 +3,9 @@ //! //! Haystack encodings //! +#[cfg(feature = "brio")] +pub mod brio; #[cfg(feature = "json")] pub mod json; #[cfg(feature = "zinc")] pub mod zinc; -#[cfg(feature = "brio")] -pub mod brio; From d03625abf94b422eda521b90dbf86a1895205cd7 Mon Sep 17 00:00:00 2001 From: Gareth Johnson <75035963+garethj2@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:57:23 +0000 Subject: [PATCH 5/7] Add unit tests for grid and number limits --- src/haystack/encoding/brio/decode.rs | 91 ++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/src/haystack/encoding/brio/decode.rs b/src/haystack/encoding/brio/decode.rs index 9c4b619..2457db4 100644 --- a/src/haystack/encoding/brio/decode.rs +++ b/src/haystack/encoding/brio/decode.rs @@ -590,6 +590,34 @@ mod tests { assert_eq!(round_trip(&v), v); } + #[test] + fn test_number_nan() { + // NaN != NaN so we can't use round_trip with assert_eq; extract the f64 directly. + let v = Value::from(Number::make(f64::NAN)); + let bytes = v.to_brio_vec().expect("encode"); + let got = from_brio(&mut bytes.as_slice()).expect("decode"); + let n = Number::try_from(&got).expect("expected a Number"); + assert!(n.value.is_nan(), "expected NaN, got {}", n.value); + } + + #[test] + fn test_number_inf() { + let v = Value::from(Number::make(f64::INFINITY)); + let bytes = v.to_brio_vec().expect("encode"); + let got = from_brio(&mut bytes.as_slice()).expect("decode"); + let n = Number::try_from(&got).expect("expected a Number"); + assert!(n.value.is_infinite() && n.value.is_sign_positive()); + } + + #[test] + fn test_number_neg_inf() { + let v = Value::from(Number::make(f64::NEG_INFINITY)); + let bytes = v.to_brio_vec().expect("encode"); + let got = from_brio(&mut bytes.as_slice()).expect("decode"); + let n = Number::try_from(&got).expect("expected a Number"); + assert!(n.value.is_infinite() && n.value.is_sign_negative()); + } + #[test] fn test_str_empty() { let v = Value::from(""); @@ -775,6 +803,69 @@ mod tests { assert_eq!(got_g.rows.len(), 2); } + #[test] + fn test_grid_with_meta() { + let meta = dict! { + "dis" => Value::from("My Grid"), + "site" => Value::make_marker() + }; + + let g = Grid::make_from_dicts_with_meta( + vec![dict! { "dis" => Value::from("Row 1"), "val" => Value::from(Number::make(42.0)) }], + meta.clone(), + ); + + let v = Value::from(g); + let got = round_trip(&v); + let got_g = Grid::try_from(&got).unwrap(); + + assert_eq!(got_g.rows.len(), 1); + assert_eq!(got_g.meta, Some(meta)); + } + + #[test] + fn test_grid_with_column_meta() { + let g = Grid { + meta: None, + columns: vec![ + Column { + name: "dis".to_string(), + meta: Some(dict! { "doc" => Value::from("Display name") }), + }, + Column { + name: "val".to_string(), + meta: Some( + dict! { "doc" => Value::from("Numeric value"), "unit" => Value::from("kW") }, + ), + }, + ], + rows: vec![dict! { + "dis" => Value::from("Site A"), + "val" => Value::from(Number::make_with_unit(100.0, get_unit_or_default("kW"))) + }], + ver: GRID_FORMAT_VERSION.to_string(), + }; + + let v = Value::from(g.clone()); + let got = round_trip(&v); + let got_g = Grid::try_from(&got).unwrap(); + + assert_eq!(got_g.rows.len(), 1); + // Verify column meta survived the round-trip + let dis_col = got_g.columns.iter().find(|c| c.name == "dis").unwrap(); + + assert_eq!( + dis_col.meta, + Some(dict! { "doc" => Value::from("Display name") }) + ); + + let val_col = got_g.columns.iter().find(|c| c.name == "val").unwrap(); + assert_eq!( + val_col.meta, + Some(dict! { "doc" => Value::from("Numeric value"), "unit" => Value::from("kW") }) + ); + } + #[test] fn test_nested_dict_in_list() { let inner = Value::make_dict(dict! { "x" => Value::from(Number::make(1.0)) }); From 69539a5e588d442776e115e8cf986b9e6b5f37c2 Mon Sep 17 00:00:00 2001 From: Gareth Johnson <75035963+garethj2@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:09:47 +0000 Subject: [PATCH 6/7] Add bench tests --- benches/brio/points.brio | Bin 0 -> 658251 bytes benches/gen_brio_fixture.rs | 28 ++++++++++++++++++++++++++++ benches/main.rs | 21 ++++++++++++++++++++- 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 benches/brio/points.brio create mode 100644 benches/gen_brio_fixture.rs diff --git a/benches/brio/points.brio b/benches/brio/points.brio new file mode 100644 index 0000000000000000000000000000000000000000..535252a3c50d39cf22a429e0409c8f286baeb402 GIT binary patch literal 658251 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 z%0D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 Date: Thu, 26 Feb 2026 09:36:03 +0000 Subject: [PATCH 7/7] Add extra fixtures --- fan/BrioGen.fan | 32 +++++ src/haystack/encoding/brio/haxall_fixtures.rs | 122 +++++++++++++++++- 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/fan/BrioGen.fan b/fan/BrioGen.fan index 025ec26..c7a340c 100644 --- a/fan/BrioGen.fan +++ b/fan/BrioGen.fan @@ -137,5 +137,37 @@ class BrioGen { g.p("list_empty", Obj?[,]) g.p("list_marker", Obj?[Marker.val]) g.p("list_mixed", Obj?["hello", Number(42f), Marker.val]) + + // Grids + // Empty grid - no columns, no rows, no meta + gbEmpty := GridBuilder() + g.p("grid_empty", gbEmpty.toGrid) + + // Grid with two columns only, no rows, no meta + gbCols := GridBuilder() + gbCols.addCol("dis") + gbCols.addCol("val") + g.p("grid_cols_only", gbCols.toGrid) + + // Grid with column meta + gbColMeta := GridBuilder() + gbColMeta.addCol("dis", ["doc": "Display name"]) + gbColMeta.addCol("val", ["doc": "Numeric value", "unit": "kW"]) + g.p("grid_col_meta", gbColMeta.toGrid) + + // Grid with rows + gbRows := GridBuilder() + gbRows.addCol("dis") + gbRows.addCol("val") + gbRows.addRow(["Site A", Number(100f, Unit("kW"))]) + gbRows.addRow(["Site B", Number(200f, Unit("kW"))]) + g.p("grid_rows", gbRows.toGrid) + + // Grid with grid-level meta + gbMeta := GridBuilder() + gbMeta.setMeta(["dis": "My Grid", "view": Marker.val]) + gbMeta.addCol("equip") + gbMeta.addRow([Marker.val]) + g.p("grid_meta", gbMeta.toGrid) } } diff --git a/src/haystack/encoding/brio/haxall_fixtures.rs b/src/haystack/encoding/brio/haxall_fixtures.rs index 1c2b2c5..db719a5 100644 --- a/src/haystack/encoding/brio/haxall_fixtures.rs +++ b/src/haystack/encoding/brio/haxall_fixtures.rs @@ -25,7 +25,8 @@ mod tests { use crate::encoding::brio::decode::from_brio; use crate::encoding::brio::encode::ToBrio; use crate::haystack::val::{ - Coord, Date, DateTime, Dict, List, Number, Ref, Symbol, Time, Uri, Value, XStr, + Column, Coord, Date, DateTime, Dict, Grid, List, Number, Ref, Symbol, Time, Uri, Value, + XStr, GRID_FORMAT_VERSION, }; use crate::units::get_unit_or_default; @@ -710,4 +711,123 @@ mod tests { ]); assert_fixture("175b0309ff0568656c6c6f06002a00015d", &Value::from(list)); } + + // ----------------------------------------------------------------------- + // Grid + // ----------------------------------------------------------------------- + + fn make_col(name: &str) -> Column { + Column { + name: name.to_string(), + meta: None, + } + } + + fn make_col_with_meta(name: &str, meta: Dict) -> Column { + Column { + name: name.to_string(), + meta: Some(meta), + } + } + + /// Empty grid - no columns, no rows, no meta. + /// Fantom: GridBuilder().toGrid + #[test] + fn fixture_grid_empty() { + let expected = Value::from(Grid::default()); + assert_fixture("183c0000143e", &expected); + } + + /// Grid with two columns (dis, val), no rows, no meta. + /// Fantom: addCol("dis") + addCol("val") + #[test] + fn fixture_grid_cols_only() { + let expected = Value::from(Grid { + meta: None, + columns: vec![make_col("dis"), make_col("val")], + rows: vec![], + ver: GRID_FORMAT_VERSION.to_string(), + }); + assert_fixture("183c02001480b91481aa143e", &expected); + } + + /// Grid with column-level meta dicts, no rows. + /// Fantom: addCol("dis", ["doc":"Display name"]) + addCol("val", ["doc":"Numeric value", "unit":"kW"]) + /// + /// Fantom insertion order for the "val" col meta is (unit, doc); Rust BTreeMap + /// iterates alphabetically (doc, unit), so re-encoding produces different bytes. + /// We verify the Fantom bytes decode to the correct Grid, using assert_decode. + #[test] + fn fixture_grid_col_meta() { + let expected = Value::from(Grid { + meta: None, + columns: vec![ + make_col_with_meta("dis", dict! { "doc" => Value::from("Display name") }), + make_col_with_meta( + "val", + dict! { + "doc" => Value::from("Numeric value"), + "unit" => Value::from("kW") + }, + ), + ], + rows: vec![], + ver: GRID_FORMAT_VERSION.to_string(), + }); + assert_decode( + "183c02001480b9157b01830709ff0c446973706c6179206e616d657d81aa157b02819a098276830709ff0d4e756d657269632076616c75657d3e", + &expected, + ); + } + + /// Grid with two columns and two rows. + /// Fantom: addRow(["Site A", 100kW]) + addRow(["Site B", 200kW]) + /// + /// Row cells are written in column order (no dict keys), so encoding is + /// stable and byte-for-byte identical to Fantom. + #[test] + fn fixture_grid_rows() { + let expected = Value::from(Grid { + meta: None, + columns: vec![make_col("dis"), make_col("val")], + rows: vec![ + dict! { + "dis" => Value::from("Site A"), + "val" => Value::from(Number::make_with_unit(100.0, get_unit_or_default("kW"))) + }, + dict! { + "dis" => Value::from("Site B"), + "val" => Value::from(Number::make_with_unit(200.0, get_unit_or_default("kW"))) + }, + ], + ver: GRID_FORMAT_VERSION.to_string(), + }); + assert_fixture( + "183c02021480b91481aa1409ff06536974652041060064827609ff065369746520420600c882763e", + &expected, + ); + } + + /// Grid with grid-level meta, one column, one row. + /// Fantom: setMeta(["dis":"My Grid", "view":M]) + addCol("equip") + addRow([M]) + /// + /// Fantom insertion order for the meta dict is (dis, view); Rust BTreeMap + /// iterates alphabetically (dis, view) — same order here — but we still use + /// assert_decode because dict key ordering may differ across Fantom versions. + #[test] + fn fixture_grid_meta() { + let expected = Value::from(Grid { + meta: Some(dict! { + "dis" => Value::from("My Grid"), + "view" => Value::make_marker() + }), + columns: vec![make_col("equip")], + rows: vec![dict! { "equip" => Value::make_marker() }], + ver: GRID_FORMAT_VERSION.to_string(), + }); + assert_decode( + "183c0101157b0283660180b909ff074d7920477269647d80cf14013e", + &expected, + ); + } }