verit 0.2.0

Exavian Veritate — zero-copy, self-describing, schema-evolvable binary serialization, safe on untrusted bytes, no unsafe, byte-identical across independent implementations.
Documentation
//! Proof obligation 2: schema evolution in both directions, resolved through
//! access plans built once per (writer, reader) schema pair.

use verit::{encode, Dt, Error, Message, Resolver, Schema, SchemaBuilder, SchemaMode, Value};

/// Original schema.
fn v1() -> Schema {
    SchemaBuilder::new()
        .add_struct("Person", vec![(1, "name", Dt::Str), (2, "age", Dt::U8)])
        .build("Person")
        .unwrap()
}

/// Evolved: field 1 renamed, field 3 added. IDs 1 and 2 keep their meaning.
fn v2() -> Schema {
    SchemaBuilder::new()
        .add_struct(
            "Person",
            vec![
                (1, "full_name", Dt::Str),
                (2, "age", Dt::U8),
                (3, "email", Dt::Str),
            ],
        )
        .build("Person")
        .unwrap()
}

/// Evolved: field 2 widened u8 → u64.
fn v3() -> Schema {
    SchemaBuilder::new()
        .add_struct("Person", vec![(1, "name", Dt::Str), (2, "age", Dt::U64)])
        .build("Person")
        .unwrap()
}

#[test]
fn old_data_new_reader() {
    let (v1, v2) = (v1(), v2());
    let bytes = encode(
        &v1,
        &Value::Struct(vec![(1, Value::str("Ada")), (2, Value::U8(36))]),
        SchemaMode::HashOnly,
    )
    .unwrap();

    let resolver = Resolver::new(&v1, &v2).unwrap();
    let msg = Message::parse(&bytes).unwrap();
    let root = msg.root(&resolver).unwrap();
    // Renamed field: identity is the ID, so "full_name" finds v1's "name".
    assert_eq!(root.get_str(1).unwrap(), Some("Ada"));
    assert_eq!(root.get_u8(2).unwrap(), Some(36));
    // Added field: absent in old data, reads as None — no error, no default garbage.
    assert_eq!(root.get_str(3).unwrap(), None);
}

#[test]
fn new_data_old_reader() {
    let (v1, v2) = (v1(), v2());
    let bytes = encode(
        &v2,
        &Value::Struct(vec![
            (1, Value::str("Grace")),
            (2, Value::U8(51)),
            (3, Value::str("grace@navy.mil")),
        ]),
        SchemaMode::HashOnly,
    )
    .unwrap();

    let resolver = Resolver::new(&v2, &v1).unwrap();
    let msg = Message::parse(&bytes).unwrap();
    let root = msg.root(&resolver).unwrap();
    assert_eq!(root.get_str(1).unwrap(), Some("Grace"));
    assert_eq!(root.get_u8(2).unwrap(), Some(51));
    // Field 3 is not in the old reader's schema: it is simply not addressable.
    // The unknown field costs the old reader nothing — its slot is never visited.
    assert!(matches!(root.get(3), Err(Error::UnknownFieldId(3))));
}

#[test]
fn integer_widening_converts_on_load() {
    let (v1, v3) = (v1(), v3());
    let bytes = encode(
        &v1,
        &Value::Struct(vec![(1, Value::str("Ada")), (2, Value::U8(36))]),
        SchemaMode::HashOnly,
    )
    .unwrap();
    let resolver = Resolver::new(&v1, &v3).unwrap();
    let msg = Message::parse(&bytes).unwrap();
    let root = msg.root(&resolver).unwrap();
    // Old u8 byte, read as the u64 the new schema declares.
    assert_eq!(root.get_u64(2).unwrap(), Some(36));
}

#[test]
fn narrowing_fails_loudly_at_plan_time() {
    let (v1, v3) = (v1(), v3());
    // v3 wrote a u64; a v1 reader expecting u8 must be refused when the plan
    // is built — not silently truncated at read time.
    match Resolver::new(&v3, &v1) {
        Err(Error::Incompatible(msg)) => {
            assert!(msg.contains("age"), "error should name the field: {msg}")
        }
        other => panic!("expected Incompatible, got {other:?}"),
    }
}

#[test]
fn declaration_order_never_changes_schema_id() {
    let a = SchemaBuilder::new()
        .add_struct("B", vec![(1, "x", Dt::U32), (2, "y", Dt::Str)])
        .add_enum("A", vec![(0, "Off"), (1, "On")])
        .build("B")
        .unwrap();
    let b = SchemaBuilder::new()
        .add_enum("A", vec![(1, "On"), (0, "Off")])
        .add_struct("B", vec![(2, "y", Dt::Str), (1, "x", Dt::U32)])
        .build("B")
        .unwrap();
    assert_eq!(a.id(), b.id());
    assert_eq!(a.canonical_bytes(), b.canonical_bytes());
}

#[test]
fn rename_changes_id_but_still_resolves() {
    let (v1, v2) = (v1(), v2());
    // The hash identifies the exact schema (names included, for
    // self-description)...
    assert_ne!(v1.id(), v2.id());
    // ...but compatibility is structural, by field ID.
    assert!(Resolver::new(&v1, &v2).is_ok());
    assert!(Resolver::new(&v2, &v1).is_ok());
}

#[test]
fn wrong_resolver_is_rejected_by_schema_id() {
    let (v1, v2) = (v1(), v2());
    let bytes = encode(
        &v2,
        &Value::Struct(vec![(1, Value::str("Grace"))]),
        SchemaMode::HashOnly,
    )
    .unwrap();
    let msg = Message::parse(&bytes).unwrap();
    // An identity resolver for v1 must not open a v2 message.
    let wrong = Resolver::identity(&v1).unwrap();
    assert!(matches!(
        msg.root(&wrong),
        Err(Error::SchemaIdMismatch { .. })
    ));
}

#[test]
fn nested_struct_evolution() {
    let old = SchemaBuilder::new()
        .add_struct("Point", vec![(1, "x", Dt::F64)])
        .add_struct("Shape", vec![(1, "center", Dt::named("Point"))])
        .build("Shape")
        .unwrap();
    let new = SchemaBuilder::new()
        .add_struct("Point", vec![(1, "x", Dt::F64), (2, "z", Dt::F64)])
        .add_struct(
            "Shape",
            vec![
                (1, "center", Dt::named("Point")),
                (2, "corners", Dt::list(Dt::named("Point"))),
            ],
        )
        .build("Shape")
        .unwrap();

    let bytes = encode(
        &old,
        &Value::Struct(vec![(1, Value::Struct(vec![(1, Value::F64(4.5))]))]),
        SchemaMode::HashOnly,
    )
    .unwrap();
    let resolver = Resolver::new(&old, &new).unwrap();
    let msg = Message::parse(&bytes).unwrap();
    let root = msg.root(&resolver).unwrap();
    let center = root.get_struct(1).unwrap().unwrap();
    assert_eq!(center.get_f64(1).unwrap(), Some(4.5));
    assert_eq!(center.get_f64(2).unwrap(), None, "added nested field");
    assert_eq!(
        root.get_list(2).unwrap().map(|_| ()),
        None,
        "added list field"
    );
}

#[test]
fn list_element_widening() {
    let old = SchemaBuilder::new()
        .add_struct("Data", vec![(1, "readings", Dt::list(Dt::U16))])
        .build("Data")
        .unwrap();
    let new = SchemaBuilder::new()
        .add_struct("Data", vec![(1, "readings", Dt::list(Dt::U32))])
        .build("Data")
        .unwrap();
    let bytes = encode(
        &old,
        &Value::Struct(vec![(
            1,
            Value::List(vec![Value::U16(7), Value::U16(65535)]),
        )]),
        SchemaMode::HashOnly,
    )
    .unwrap();
    let resolver = Resolver::new(&old, &new).unwrap();
    let msg = Message::parse(&bytes).unwrap();
    let root = msg.root(&resolver).unwrap();
    let readings = root.get_list(1).unwrap().unwrap();
    assert_eq!(readings.len(), 2);
    // Stride comes from the writer layout (u16), values surface as reader u32.
    assert!(matches!(readings.get(0).unwrap(), verit::Ref::U32(7)));
    assert!(matches!(readings.get(1).unwrap(), verit::Ref::U32(65535)));
}