polyvers 0.1.1

Single-macro schema versioning for Rust: declare a struct family with #[add]/#[edit]/#[delete] mutations across versions and parse them at runtime without serde(flatten) overhead. Optional binary codecs: rkyv, bincode, postcard.
Documentation
use polyvers::versioned;

versioned! {
    family the_config;
    derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq);

    version "0.1" {
        struct Main {
            some_prop: String,
            sub: JustAStruct,
            another: Option<usize>,
        }
        struct JustAStruct {
            prop: String,
        }
    }

    version "0.2" extends "0.1" {
        struct Main {
            #[add]    added: String,
            #[edit]   sub: JustAStruct,
            #[delete] some_prop,
        }
        struct JustAStruct {
            #[edit] prop: u64,
        }
    }
}

#[test]
fn smoke_unknown_version_error() {
    let err = polyvers::Error::unknown_version("9.9", &["0.1", "0.2"]);
    assert!(err.to_string().contains("9.9"));
}

#[test]
fn parses_v0_1_via_runtime_dispatch() {
    let json = r#"{"some_prop":"x","sub":{"prop":"y"},"another":1}"#;
    let any = the_config::parse_at_version("0.1", json).expect("parses 0.1");
    assert_eq!(any.version(), "0.1");
    let v01 = any.into_v0_1().expect("variant V0_1");
    assert_eq!(v01.some_prop, "x");
    assert_eq!(v01.sub.prop, "y");
    assert_eq!(v01.another, Some(1));
}

#[test]
fn parses_v0_2_via_runtime_dispatch() {
    let json = r#"{"sub":{"prop":42},"another":1,"added":"z"}"#;
    let any = the_config::parse_at_version("0.2", json).expect("parses 0.2");
    assert_eq!(any.version(), "0.2");
    let v02 = any.into_v0_2().expect("variant V0_2");
    assert_eq!(v02.sub.prop, 42);
    assert_eq!(v02.another, Some(1));
    assert_eq!(v02.added, "z");
}

#[test]
fn parses_v0_2_directly_via_serde_json() {
    let json = r#"{"sub":{"prop":7},"another":null,"added":"hi"}"#;
    let v02: the_config::v0_2::Main = serde_json::from_str(json).expect("direct parse");
    assert_eq!(v02.sub.prop, 7);
    assert_eq!(v02.another, None);
    assert_eq!(v02.added, "hi");
}

#[test]
fn round_trip_v0_2() {
    let original = the_config::v0_2::Main {
        sub: the_config::v0_2::JustAStruct { prop: 100 },
        another: Some(5),
        added: "round-trip".into(),
    };
    let json = serde_json::to_string(&original).expect("serialize");
    let parsed: the_config::v0_2::Main = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(parsed, original);
}

#[test]
fn unknown_version_returns_error() {
    let err = the_config::parse_at_version("9.9", "{}").expect_err("rejected");
    match err {
        polyvers::Error::UnknownVersion { requested, known } => {
            assert_eq!(requested, "9.9");
            assert_eq!(known, &["0.1", "0.2"]);
        }
        _ => panic!("expected UnknownVersion, got {err}"),
    }
}

#[test]
fn malformed_json_returns_format_error() {
    let err = the_config::parse_at_version("0.1", "{not json").expect_err("rejected");
    assert!(matches!(err, polyvers::Error::Format(_)));
}

#[test]
fn latest_alias_points_at_v0_2() {
    let value: the_config::Latest = the_config::v0_2::Main {
        sub: the_config::v0_2::JustAStruct { prop: 0 },
        another: None,
        added: String::new(),
    };
    assert_eq!(value.added, "");
}

#[test]
fn version_constants() {
    assert_eq!(the_config::VERSIONS, &["0.1", "0.2"]);
    assert_eq!(the_config::LATEST_VERSION, "0.2");
}

#[test]
fn sub_struct_resolves_to_correct_version() {
    let v01 = the_config::v0_1::Main {
        some_prop: "a".into(),
        sub: the_config::v0_1::JustAStruct {
            prop: "string".into(),
        },
        another: None,
    };
    let _v02 = the_config::v0_2::Main {
        sub: the_config::v0_2::JustAStruct { prop: 999u64 },
        another: None,
        added: "b".into(),
    };
    assert_eq!(v01.sub.prop, "string");
}

#[test]
fn as_v0_1_returns_some_for_matching_variant() {
    let any = the_config::parse_at_version(
        "0.1",
        r#"{"some_prop":"x","sub":{"prop":"y"},"another":null}"#,
    )
    .expect("ok");
    assert!(any.as_v0_1().is_some());
    assert!(any.as_v0_2().is_none());
}

versioned! {
    family with_attrs;
    derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq);

    version "0.1" {
        struct Item {
            #[serde(rename = "kind")]
            type_: String,
            count: u32,
        }
    }

    version "0.2" extends "0.1" {
        struct Item {
            #[edit] count: i64,
        }
    }
}

#[test]
fn serde_rename_attribute_is_forwarded_to_v0_1() {
    let json = r#"{"kind":"foo","count":3}"#;
    let item: with_attrs::v0_1::Item = serde_json::from_str(json).expect("parses");
    assert_eq!(item.type_, "foo");
    assert_eq!(item.count, 3);
}

#[test]
fn serde_rename_attribute_inherits_into_v0_2() {
    let json = r#"{"kind":"bar","count":-7}"#;
    let item: with_attrs::v0_2::Item = serde_json::from_str(json).expect("parses");
    assert_eq!(item.type_, "bar");
    assert_eq!(item.count, -7);
}

versioned! {
    family with_generics;
    derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq);

    version "0.1" {
        struct Outer {
            items: Vec<Inner>,
            maybe: Option<Inner>,
        }
        struct Inner {
            id: u32,
        }
    }

    version "0.2" extends "0.1" {
        struct Inner {
            #[add] label: String,
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct ReleaseInfo {
    pub released_iso: String,
    pub author: &'static str,
    pub breaking: bool,
    pub notes: Option<&'static str>,
}

versioned! {
    family release_log;
    derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq);
    meta crate::ReleaseInfo;

    version "0.1" {
        meta {
            released_iso: "2024-01-15T00:00:00Z".to_string(),
            author: "alice",
            breaking: false,
            notes: None,
        }
        struct Main {
            id: u32,
        }
    }

    version "0.2" extends "0.1" {
        meta {
            released_iso: "2024-06-10T00:00:00Z".to_string(),
            author: "bob",
            breaking: true,
            notes: Some("id is now a String UUID"),
        }
        struct Main {
            #[edit] id: String,
        }
    }
}

#[test]
fn meta_per_version_module_function() {
    let m1 = release_log::v0_1::meta();
    assert_eq!(m1.released_iso, "2024-01-15T00:00:00Z");
    assert_eq!(m1.author, "alice");
    assert!(!m1.breaking);

    let m2 = release_log::v0_2::meta();
    assert!(m2.breaking);
    assert_eq!(m2.notes, Some("id is now a String UUID"));
}

#[test]
fn meta_at_version_dispatches_at_runtime() {
    let m = release_log::meta_at_version("0.2").expect("known version");
    assert_eq!(m.author, "bob");
    assert!(release_log::meta_at_version("9.9").is_none());
}

#[test]
fn any_version_meta_method() {
    let any = release_log::parse_at_version("0.1", r#"{"id":7}"#).expect("ok");
    assert_eq!(any.meta().author, "alice");

    let any2 = release_log::parse_at_version("0.2", r#"{"id":"u-1"}"#).expect("ok");
    assert!(any2.meta().breaking);
}

#[test]
fn nested_generic_resolves_to_correct_version() {
    let json_v01 = r#"{"items":[{"id":1},{"id":2}],"maybe":null}"#;
    let outer_v01: with_generics::v0_1::Outer = serde_json::from_str(json_v01).expect("v01");
    assert_eq!(outer_v01.items.len(), 2);
    assert_eq!(outer_v01.items[0].id, 1);
    assert!(outer_v01.maybe.is_none());

    let json_v02 = r#"{"items":[{"id":7,"label":"x"}],"maybe":{"id":9,"label":"y"}}"#;
    let outer_v02: with_generics::v0_2::Outer = serde_json::from_str(json_v02).expect("v02");
    assert_eq!(outer_v02.items[0].id, 7);
    assert_eq!(outer_v02.items[0].label, "x");
    assert_eq!(outer_v02.maybe.unwrap().label, "y");
}