asun
Rust support for ASUN, a schema-driven format for compact structured data. Serde-free: types opt in with the crate's own #[derive(AsunEncode, AsunDecode)] macros.
Why ASUN?
json
Standard JSON repeats every field name in every record. When you send structured data to an LLM, over an API, or across services, that repetition wastes tokens, bytes, and attention:
asun
ASUN declares the schema once and streams data as compact tuples:
[{id, name, active}]:
(1,Alice,true),
(2,Bob,false),
(3,Carol,true)
Fewer tokens. Smaller payloads. Clearer structure, and faster parsing than repeated-object JSON.
Highlights
- Serde-free — its own
#[derive(AsunEncode, AsunDecode)]macros, no serde dependency - Text and binary wire formats from a single pair of derives
- Zero-copy binary decode (
&strfields borrow from the input) - Current API uses
encode/decode, not the olderto_string/from_strnames - Optional scalar-hint schema output
- Pretty text output
- Works well for structs, vectors, options, enums, nested data, and entry-list based keyed collections
- serde-aligned field attributes:
rename,skip,skip_serializing,skip_deserializing,skip_serializing_if,default
Install
[]
= "1.2"
No serde in your dependency tree — ASUN ships its own derive macros. If you
already use serde elsewhere it stays independent; the two never interact.
Usage
1. Derive the traits
You opt a type into ASUN with the crate's own derives — #[derive(AsunEncode, AsunDecode)].
There is no serde involved: AsunEncode produces both the text and binary
encoders, and AsunDecode produces both the text and binary decoders, from a
single annotation. Derive only the direction you need (both is the common case).
use ;
AsunEncode / AsunDecode are re-exported from the asun crate root, so a
single use asun::{AsunEncode, AsunDecode}; brings in both the trait and the
matching derive macro — you do not depend on asun-derive directly.
2. Text: encode / decode
encode writes the compact schema-driven text; decode reads it back into your
type. The turbofish (::<User>) or an explicit binding tells decode what to
build.
use ;
let user = User ;
let text: String = encode?; // "{id,name,active}:(1,Alice,true)"
let back: User = decode?; // decode infers the type from the binding
assert_eq!;
3. Self-describing text: encode_typed
encode_typed embeds scalar hints (@int, @str, @bool, …) in the schema so
the payload is readable without the Rust type on hand. decode accepts both the
plain and the annotated forms.
use ;
let typed = encode_typed?;
assert_eq!;
let back: User = decode?; // annotated text decodes the same way
assert_eq!;
4. Vectors, options, nested types
The same two calls work for any derived type — vectors share one schema across
all rows, Option maps to an empty slot, and nested structs/enums compose
automatically.
let users = vec!;
let text: String = encode?; // "[{id,name,active}]:(1,Alice,true),(2,Bob,false)"
let back: = decode?;
assert_eq!;
Enums derive the same way and encode by variant:
let text = encode?;
let back: Event = decode?;
5. Pretty text: encode_pretty / encode_pretty_typed
For logs and human review, the pretty encoders add indentation and line breaks.
Output stays valid ASUN and round-trips through decode.
use ;
let pretty = encode_pretty?; // indented, one row per line
let pretty_typed = encode_pretty_typed?;
let back: = decode?;
6. Binary: encode_binary / decode_binary
The binary format is the smallest and fastest wire form (LEB128 varints, zigzag
signed integers, fixed-width floats). It is schema-less: fields are read in
declaration order, so encoder and decoder must share the same type definition.
&str / &[u8] fields decode zero-copy, borrowing straight from the input
buffer.
use ;
let bytes: = encode_binary?;
let back: = decode_binary?;
assert_eq!;
For hot loops or untrusted input there are two extra entry points:
use ;
// Reuse one buffer across many encodes — keeps the allocation, no per-call Vec.
let mut buf = Vecnew;
encode_binary_into?; // buf is cleared, then filled
encode_binary_into?; // same allocation reused
// Decode exactly one value and reject any trailing bytes (stricter than decode_binary).
let one: User = decode_binary_exact?;
decode_binary caps decoded sequence lengths at DEFAULT_MAX_SEQUENCE_LEN
(16 MiB) as a guard against hostile length prefixes; construct a
BinaryDecoder::with_max_sequence_len if your protocol needs a different bound.
7. Errors
Every fallible call returns asun::Result<T> (alias for Result<T, asun::Error>).
Error implements std::error::Error + Display, so it slots into ? and any
error-handling crate.
API Reference
| Function | Purpose |
|---|---|
encode |
Encode to compact text |
encode_typed |
Encode to text with scalar type hints |
decode |
Decode from text (plain or annotated) |
encode_pretty / encode_pretty_typed |
Pretty (indented) text output |
encode_binary |
Encode to binary |
encode_binary_into |
Encode to binary into a reused buffer |
decode_binary |
Decode from binary (accepts trailing bytes) |
decode_binary_exact |
Decode one binary value, reject trailing bytes |
Traits & derives: AsunEncode, AsunDecode (text + binary via one derive each).
Types: Error, Result<T>, DEFAULT_MAX_SEQUENCE_LEN.
Which format?
encode/decode— human-readable, token-efficient text. Best for LLM prompts, APIs, config, anywhere you want to eyeball the payload.encode_typed— same text, but self-describing; use when the reader may not have the Rust type.encode_binary/decode_binary— smallest and fastest; use for storage and service-to-service traffic where both ends share the type.
Field Attributes
Fields and enum variants accept #[asun(...)] attributes, aligned with serde:
| Attribute | Effect |
|---|---|
rename = "name" |
Use name on the wire instead of the Rust identifier |
skip |
Never written, never read; decodes to the default |
skip_serializing |
Not written; still read from text when present |
skip_deserializing |
Not read; always decodes to the default |
skip_serializing_if = "path" |
Omit from text when the predicate fn(&T) -> bool returns true |
default = "path" |
Value source fn() -> T for a field skipped on decode (else Default) |
Binary note: the binary format has no schema and reads fields in declaration
order, so any skip is forced symmetric — a field skipped on one side is
skipped on both. skip_serializing_if is ignored in binary (the field is always
written) to keep fixed-order decoding reliable. A custom default applies only
to fields skipped on decode (skip / skip_deserializing).
Run Examples
Contributors
Benchmark Snapshot
Run the benchmark example with:
The Rust benchmark now uses the same two-line summary style as the Go example:
Flat struct × 1000 (8 fields, vec)
Serialize: JSON 411.05ms / 121675 B | ASUN 175.25ms (2.3x) / 56718 B (46.6%) | BIN 41.32ms (9.9x) / 74454 B (61.2%)
Deserialize: JSON 287.06ms | ASUN 195.57ms (1.5x) | BIN 64.62ms (4.4x)
ASUN / BIN ratios are measured against JSON, and size percentages show the remaining size relative to JSON.
License
MIT