3 Commits
Author SHA1 Message Date
Dario LipicarandClaude Opus 5 72754ab9b2 feat(codec): Codec<std::optional<T>> — the ?T slot, two-state and canonicalising (#37)
`?T` had no C++ codec, so an optional slot could not cross the canonical JSON
wire at all: every spelling of "empty" landed on Codec<T>, which correctly
refuses null, and the value became a type error instead of an absence.

The contract this implements:

  * TWO-state, never three. Every target has exactly ONE empty inhabitant (Rust
    None, std::nullopt, an invalid QVariant, JS undefined), so "one LIDL type <->
    one type per language" leaves nowhere to put a third state. std::nullopt is
    that inhabitant.
  * DECODE IS LIBERAL. Absent and explicit null are the SAME state coming in.
    They cannot be told apart even in principle here — the record decoder
    materialises a missing field as a null json (`j.contains(f) ? j.at(f) :
    nlohmann::json()`) before a Codec ever sees it.
  * ENCODE IS CANONICAL. Empty has one spelling out: null. A round trip
    therefore CANONICALISES rather than reproducing its input.
  * A PRESENT VALUE IS STILL TYPE-CHECKED. Optional widens the domain by exactly
    one inhabitant; it does not switch checking off. Anything non-null goes
    through Codec<T> unchanged and throws with the same path it would have in a
    required slot. A required slot is untouched — null there still means "wrong
    type", which is the only reason absent-means-empty is safe to allow here.

KEY OMISSION IS NOT IN THIS LAYER, and the comment says so at the definition.
Empty is spelled by omitting the key where the slot is NAMED (a record field)
and by null where it is POSITIONAL (argument, return, event parameter — no key
to omit, and arity must never change). A Codec is handed a VALUE and cannot see
the slot it sits in, so it emits the positional spelling; skipping the key for a
nullopt field belongs to the record emitter in logos-cpp-sdk, the only code that
knows there IS a key. It is also unimplementable one level down: an optional
inside a [T] must still occupy its array position.

Ten tests: absent, explicit null, present, present-but-wrong-typed (including
the path inside a container), null still rejected in a required slot, ?bstr
(tagged at depth, and present-but-EMPTY bytes staying present), ?[T] / ?{tstr:T}
separating `[]` from missing, [?T] / {tstr:?T} keeping position and key, and ??T
collapsing.

The tenth pins a trap rather than a feature: JsonArg cannot deliver an optional.
std::optional's converting constructor optional(U&&) binds an rvalue reference
to the proxy prvalue, which out-ranks JsonArg's const-qualified conversion
function before partial ordering is consulted, so the compiler decodes X instead
of std::optional<X> and null throws. Both alternatives were tried and measured:
an rvalue-qualified conversion operator ties with the constructor (ambiguity
error), and one written specifically for std::optional still loses. There is no
signature that wins, so optional parameters must NAME the type —
fromJson<std::optional<X>>(j, path), which is what the cdylib backend already
emits. A present value survives the proxy by accident, which is exactly why the
empty case is pinned.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:05:28 -03:00
Dario LipicarandClaude Opus 5 4ee85b26a6 test(codec): adopt the tagged-bytes coverage from logos-cpp-sdk (#34)
logos-cpp-sdk's tests/sdk/test_logos_json_bytes.cpp tested b64UrlEncode /
bytesToJson back when logos_json.h carried its own copies. Those copies are gone
(logos-cpp-sdk#117), so the coverage belongs with the canonical definitions
rather than in a repo that has to reach across for the header — reaching across
is what broke that test target after the dedupe.

Six cases: all 256 byte values, the URL-safe alphabet and no padding, every tail
length 0-5, an embedded NUL, the canonical tag shape, and padded input decoding.

That last one is the one with history. The cdylib backend used to carry a SECOND
decoder that bailed on any non-alphabet character, so padded input silently
produced an EMPTY vector while this test pinned the opposite for the shared
helper — two copies contradicting a committed test in the same repo. There is one
decoder now, and this is what it must satisfy.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:16:05 -03:00
Dario LipicarandClaude Opus 5 362b03fb1e feat(codec): one canonical LIDL ↔ JSON codec, generic over composition (#29)
* feat(codec): one canonical LIDL <-> JSON codec, generic over composition

The tagged-bytes encoding {"_bytes": "<base64url, unpadded>"} was implemented
SIX times — the Qt conversion here, the plain wire's json_mapping, the lp helper
in logos-cpp-sdk, a copy emitted into every generated cdylib module, the Rust
SDK and the Python client — and they disagreed on which inputs they accept:

  - {"_bytes":"AA","x":1} decoded as BYTES on the lp path (no size()==1 check)
    but as a MAP on the plain wire and in the glue.
  - Padded "AH-A_w==" gave correct bytes in one copy, empty in another, None in
    Rust.
  - A plain string / number / number-array argument was accepted by C++
    providers (Qt and CLI parity) and rejected by Rust ones.

logos_codec.h is the single implementation. Leaves: tstr, bstr, every signed and
unsigned integral spelling, every floating spelling, bool, any (recursion stops).
Composition is GENERIC — std::vector<T> and std::map/unordered_map<std::string,T>
for any supported T, at any depth — so [bstr], [[bstr]], {tstr: [bstr]} and bytes
nested in a map all encode canonically without anything enumerating combinations.

Codec<T> is a trait, so an unsupported T is an incomplete type: a compile error
naming the type, never a silent fallback. Decode throws CodecError carrying the
path ("[0][1]", ".k") instead of substituting a default — a mangled value must
not reach business logic. bstr keeps a documented lenient form for provider-side
arguments, because the Qt consumer path and the logoscore CLI both produce plain
strings and number arrays for byte parameters.

JsonArg exists for generated dispatch: it converts itself into whatever the
callee's parameter type is. Naming the type instead is a trap — spelling [uint]
as std::vector<uint64_t> (the LIDL mapping) does not bind to an author's
std::vector<uint32_t>, since distinct vector instantiations do not convert.

logos_codec.h joins the installed header set; nix/include.nix already globs
cpp/*.h.

Tests: 198/198. 15 new ones pin the contract rather than the happy path —
[[bstr]] tagged at depth, map-of-bytes, empty elements surviving as elements,
uint64 past 2^63, an integral JSON number decoding as float64, padded base64,
the multi-key {"_bytes":...} case being a map, and path-carrying failures.

Not yet converged onto this header (follow-ups): the Qt conversion in
logos_json_convert.cpp, and the plain wire's copy in json_mapping.cpp — the
latter needs a strict variant first, because it THROWS on malformed base64
(via its own logos::plain::CodecError) where every other copy is tolerant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(codec): fold the Qt and plain-wire copies into the shared codec

The two remaining in-repo implementations now delegate:

  - logos_json_convert.cpp (the Qt CONSUMER path — argument encoding and return
    decoding) dropped Qt's toBase64/fromBase64 and its own tagged-bytes
    predicate. Only the QByteArray <-> std::vector<uint8_t> hop stays local, so
    the Qt path cannot drift from the wire or from providers: same alphabet, same
    padding rule, same single-key shape.
  - implementations/plain/json_mapping.cpp dropped its anonymous-namespace
    b64url_encode/decode.

The wire needed something the tolerant decode does not give it: it REJECTS a
corrupt frame rather than silently decoding fewer bytes. Hence
b64UrlDecodeChecked — strict about the alphabet and the length, tolerant of '='
padding — which json_mapping uses to keep throwing its own
logos::plain::CodecError. Consumer-facing decodes stay tolerant. Both behaviours
now come from one implementation instead of four that disagreed.

Also removed the local isTaggedBytes wrapper, which shadowed the shared one and
made unqualified calls ambiguous.

Tests: 199/199, with the strict decode's accept/reject set pinned (padding
tolerated, stray character rejected, impossible length rejected).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:01:49 -03:00