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/benches/brio/points.brio b/benches/brio/points.brio new file mode 100644 index 0000000..535252a Binary files /dev/null and b/benches/brio/points.brio differ diff --git a/benches/gen_brio_fixture.rs b/benches/gen_brio_fixture.rs new file mode 100644 index 0000000..c42a44d --- /dev/null +++ b/benches/gen_brio_fixture.rs @@ -0,0 +1,28 @@ +// Copyright (C) 2020 - 2022, J2 Innovations + +//! Generates `benches/brio/points.brio` from `benches/json/points.json`. +//! +//! Run with: +//! +//! ``` +//! cargo run --benches gen_brio_fixture --features brio +//! ``` + +use libhaystack::encoding::brio::encode::ToBrio; +use libhaystack::haystack::val::{Grid, Value}; +use std::fs; + +fn main() { + let json = fs::read_to_string("benches/json/points.json") + .expect("cannot read benches/json/points.json"); + + let value: Value = serde_json::from_str(&json).expect("JSON parse failed"); + let grid = Grid::try_from(&value).expect("not a Grid"); + + let bytes = Value::from(grid).to_brio_vec().expect("brio encode failed"); + + fs::create_dir_all("benches/brio").expect("cannot create benches/brio"); + fs::write("benches/brio/points.brio", &bytes).expect("cannot write benches/brio/points.brio"); + + println!("Wrote {} bytes to benches/brio/points.brio", bytes.len()); +} diff --git a/benches/main.rs b/benches/main.rs index 26818a3..c6cfee9 100644 --- a/benches/main.rs +++ b/benches/main.rs @@ -7,6 +7,7 @@ use criterion::{criterion_group, criterion_main, Criterion}; +use libhaystack::haystack::encoding::brio::decode::from_brio; use libhaystack::haystack::encoding::zinc::decode::*; use libhaystack::haystack::val::*; use std::fs; @@ -37,7 +38,25 @@ fn criterion_zinc_parse(bench: &mut Criterion) { }); } -criterion_group!(benches, criterion_zinc_parse, criterion_json_parse); +fn criterion_brio_parse(bench: &mut Criterion) { + let bytes = fs::read("benches/brio/points.brio").expect("Invalid brio test file"); + bench.bench_function("Brio parse points", |b| { + b.iter(|| { + let value = from_brio(&mut bytes.as_slice()).expect("Value"); + + let grid = Grid::try_from(&value).expect("Grid"); + + assert!(!grid.is_empty()); + }); + }); +} + +criterion_group!( + benches, + criterion_zinc_parse, + criterion_json_parse, + criterion_brio_parse +); criterion_main!(benches); #[cfg(never)] diff --git a/fan/BrioGen.fan b/fan/BrioGen.fan new file mode 100644 index 0000000..c7a340c --- /dev/null +++ b/fan/BrioGen.fan @@ -0,0 +1,173 @@ +// +// 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]) + + // 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/consts.rs b/src/haystack/encoding/brio/consts.rs new file mode 100644 index 0000000..cc10012 --- /dev/null +++ b/src/haystack/encoding/brio/consts.rs @@ -0,0 +1,1172 @@ +// 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; + +/// 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)` +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..2457db4 --- /dev/null +++ b/src/haystack/encoding/brio/decode.rs @@ -0,0 +1,1140 @@ +// 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, 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, +}; + +// --------------------------------------------------------------------------- +// 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); + } + if v == 0xe0 { + return 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. +/// +/// | 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: {fantom_nanos}")))?; + 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_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(""); + 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_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)) }); + 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_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. + 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..5358560 --- /dev/null +++ b/src/haystack/encoding/brio/encode.rs @@ -0,0 +1,991 @@ +// 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, 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, +}; + +// --------------------------------------------------------------------------- +// 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; + +// --------------------------------------------------------------------------- +// 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..db719a5 --- /dev/null +++ b/src/haystack/encoding/brio/haxall_fixtures.rs @@ -0,0 +1,833 @@ +// 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::{ + Column, Coord, Date, DateTime, Dict, Grid, List, Number, Ref, Symbol, Time, Uri, Value, + XStr, GRID_FORMAT_VERSION, + }; + 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)); + } + + // ----------------------------------------------------------------------- + // 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, + ); + } +} 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..56528f8 100644 --- a/src/haystack/encoding/mod.rs +++ b/src/haystack/encoding/mod.rs @@ -3,6 +3,8 @@ //! //! Haystack encodings //! +#[cfg(feature = "brio")] +pub mod brio; #[cfg(feature = "json")] pub mod json; #[cfg(feature = "zinc")]