backhopper-core 0.37.0

Core types, snapshot I/O, store, and compatibility analysis for backhopper
Documentation
// Copyright (C) 2026 Michael S. Klishin and Contributors
// SPDX-License-Identifier: Apache-2.0 OR MIT
// See LICENSE-APACHE and LICENSE-MIT for details.

use backhopper_core::model::names::{
    ApplicationName, FieldName, ModuleName, RecordName, RelativePath,
};
use backhopper_core::model::snapshot::{
    FORMAT_VERSION, LAST_MIGRATABLE_FORMAT_VERSION, Module, RecordDecl, RecordField,
    SUPPORTED_FORMAT_VERSIONS,
};
use backhopper_core::snapshot::{format, parser};
use backhopper_test_support::{canonical_snapshot, snapshot_header};

#[test]
fn format_version_constant_is_four() {
    assert_eq!(FORMAT_VERSION, 5);
}

#[test]
fn supported_versions_include_v1_through_v4() {
    assert!(SUPPORTED_FORMAT_VERSIONS.contains(&1));
    assert!(SUPPORTED_FORMAT_VERSIONS.contains(&2));
    assert!(SUPPORTED_FORMAT_VERSIONS.contains(&3));
    assert!(SUPPORTED_FORMAT_VERSIONS.contains(&4));
    assert!(SUPPORTED_FORMAT_VERSIONS.contains(&5));
}

fn v1_text(body: &str) -> String {
    format!(
        "# backhopper snapshot\n# format-version: 1\n# project: p\n# tag: v1.0.0\n# commit: 0000000000000000000000000000000000000000\n# scanned-paths: src\n# generated-by: test\n# generated-at: 1970-01-01T00:00:00Z\n\n{body}",
    )
}

fn v2_text(body: &str) -> String {
    format!(
        "# backhopper snapshot\n# format-version: {FORMAT_VERSION}\n# project: p\n# tag: v1.0.0\n# commit: 0000000000000000000000000000000000000000\n# scanned-paths: src\n# generated-by: test\n# generated-at: 1970-01-01T00:00:00Z\n\n{body}",
    )
}

#[test]
fn parser_accepts_v1_format() {
    let text = v1_text("module alpha\n  export hello/0\n");
    let parsed = parser::parse(&text).expect("v1 snapshot parses");
    assert_eq!(parsed.modules().len(), 1);
    assert_eq!(parsed.modules()[0].name.as_str(), "alpha");
}

#[test]
fn parser_accepts_v2_format() {
    let text = v2_text("module alpha\n  path src/alpha.erl\n  app rabbit\n  export hello/0\n");
    let parsed = parser::parse(&text).expect("v2 snapshot parses");
    let alpha = parsed
        .module_named(&ModuleName::new("alpha").unwrap())
        .unwrap();
    assert_eq!(
        alpha.path.as_ref().map(RelativePath::as_str),
        Some("src/alpha.erl")
    );
    assert_eq!(alpha.app.as_ref().map(|a| a.as_str()), Some("rabbit"));
}

#[test]
fn parser_rejects_unknown_format_version() {
    let text = "# backhopper snapshot\n# format-version: 99\n# project: p\n# tag: v1.0.0\n# commit: 0000000000000000000000000000000000000000\n# scanned-paths: src\n# generated-by: test\n# generated-at: 1970-01-01T00:00:00Z\n";
    assert!(parser::parse(text).is_err());
}

#[test]
fn writer_emits_the_snapshots_own_format_header() {
    let snap = canonical_snapshot(
        snapshot_header("p", "v1"),
        vec![Module::new(ModuleName::new("alpha").unwrap())],
    );
    let text = format::to_string(&snap).expect("write");
    let expected = format!("# format-version: {LAST_MIGRATABLE_FORMAT_VERSION}");
    assert!(text.contains(&expected), "text was:\n{text}");
}

#[test]
fn module_path_and_app_round_trip_through_format() {
    let mut m = Module::new(ModuleName::new("alpha").unwrap());
    m.path = Some(RelativePath::new("deps/rabbit/src/alpha.erl").unwrap());
    m.app = Some(ApplicationName::new("rabbit").unwrap());
    let snap = canonical_snapshot(snapshot_header("p", "v1"), vec![m]);
    let text = format::to_string(&snap).expect("write");
    let reparsed = parser::parse(&text).expect("re-parse");
    let alpha = reparsed
        .module_named(&ModuleName::new("alpha").unwrap())
        .unwrap();
    assert_eq!(
        alpha.path.as_ref().map(RelativePath::as_str),
        Some("deps/rabbit/src/alpha.erl")
    );
    assert_eq!(alpha.app.as_ref().map(|a| a.as_str()), Some("rabbit"));
}

#[test]
fn module_records_round_trip_through_format() {
    let mut m = Module::new(ModuleName::new("alpha").unwrap());
    m.records.push(RecordDecl {
        name: RecordName::new("state").unwrap(),
        fields: vec![
            RecordField {
                name: FieldName::new("a").unwrap(),
                type_repr: None,
                default: None,
            },
            RecordField {
                name: FieldName::new("b").unwrap(),
                type_repr: Some("integer()".into()),
                default: None,
            },
        ],
    });
    let snap = canonical_snapshot(snapshot_header("p", "v1"), vec![m]);
    let text = format::to_string(&snap).expect("write");
    let reparsed = parser::parse(&text).expect("re-parse");
    let alpha = reparsed
        .module_named(&ModuleName::new("alpha").unwrap())
        .unwrap();
    assert_eq!(alpha.records.len(), 1);
    assert_eq!(alpha.records[0].name.as_str(), "state");
    assert_eq!(alpha.records[0].fields.len(), 2);
    assert_eq!(
        alpha.records[0].fields[1].type_repr.as_deref(),
        Some("integer()")
    );
}