bible-io 1.1.0

A Rust library for working with Bible text data structures
Documentation
use std::{
    collections::hash_map::DefaultHasher,
    error::Error,
    hash::{Hash, Hasher},
    io,
};

use bible_io::{
    BibleCatalog, BibleDataFormatErrorCode, BibleMetadata, BibleSource, TextDirectionHint,
};
use serde_json::{json, Value};

fn value_hash(value: &impl Hash) -> u64 {
    let mut hasher = DefaultHasher::new();
    value.hash(&mut hasher);
    hasher.finish()
}

fn valid_source(id: &str) -> BibleSource {
    BibleSource::from_value(&json!({
        "id": id,
        "assetPath": format!("bibles/English/{id}.json"),
        "languageName": "English",
        "languageCode": "en",
        "translationName": "Example",
        "abbreviation": "EX",
        "extension": {"nested": [1, {"value": true}]}
    }))
    .unwrap()
}

#[test]
fn format_error_codes_have_stable_serialized_names() {
    let cases = [
        (BibleDataFormatErrorCode::InvalidJson, "invalid_json"),
        (BibleDataFormatErrorCode::InvalidType, "invalid_type"),
        (BibleDataFormatErrorCode::MissingField, "missing_field"),
        (BibleDataFormatErrorCode::InvalidValue, "invalid_value"),
        (BibleDataFormatErrorCode::DuplicateId, "duplicate_id"),
        (BibleDataFormatErrorCode::ReservedField, "reserved_field"),
        (BibleDataFormatErrorCode::NonJsonValue, "non_json_value"),
    ];
    for (code, name) in cases {
        assert_eq!(code.as_str(), name);
        assert_eq!(serde_json::to_value(code).unwrap(), name);
        assert_eq!(
            serde_json::from_value::<BibleDataFormatErrorCode>(json!(name)).unwrap(),
            code
        );
    }

    let error = bible_io::BibleDataFormatError::new(
        BibleDataFormatErrorCode::InvalidValue,
        "$.value",
        "custom failure",
    )
    .with_value(json!(7))
    .with_cause(io::Error::new(io::ErrorKind::InvalidData, "typed cause"));
    assert_eq!(error.cause(), Some("typed cause"));
    assert!(error.to_string().contains("typed cause"));
    assert_eq!(
        error
            .source()
            .and_then(|source| source.downcast_ref::<io::Error>())
            .map(io::Error::kind),
        Some(io::ErrorKind::InvalidData)
    );
}

#[test]
fn source_and_metadata_clones_deeply_separate_extension_values() {
    let source = valid_source("example");
    let mut cloned_source = source.clone();
    cloned_source.additional["extension"]["nested"][1]["value"] = json!(false);
    assert_eq!(source.additional["extension"]["nested"][1]["value"], true);
    assert_ne!(source, cloned_source);

    let metadata = BibleMetadata::from_value(&json!({
        "id": "example",
        "custom": {"nested": ["original"]}
    }))
    .unwrap();
    let equal = metadata.clone();
    assert_eq!(metadata, equal);
    assert_eq!(value_hash(&metadata), value_hash(&equal));

    let mut changed = equal;
    changed.additional["custom"]["nested"][0] = json!("changed");
    assert_eq!(metadata.additional["custom"]["nested"][0], "original");
    assert_ne!(metadata, changed);
}

#[test]
fn source_typed_copy_can_clear_nullable_fields_without_mutating_the_original() {
    let original = BibleSource {
        description: Some("Original description".to_string()),
        ..valid_source("edition")
    };
    let mut changed = original.clone();
    changed.description = None;
    changed.translation_name = "Changed translation".to_string();
    changed.validate("$.source").unwrap();

    assert_eq!(changed.id, "edition");
    assert_eq!(changed.description, None);
    assert_eq!(changed.translation_name, "Changed translation");
    assert_eq!(
        original.description.as_deref(),
        Some("Original description")
    );
    assert_eq!(original.translation_name, "Example");
}

#[test]
fn source_identity_and_catalog_identity_are_always_validated() {
    let mut root_blank = valid_source("valid");
    root_blank.id.clear();
    let error = root_blank.validate("$").unwrap_err();
    assert_eq!(error.code(), BibleDataFormatErrorCode::MissingField);
    assert_eq!(error.path(), "$.id");
    assert_eq!(error.value(), Some(&json!("")));

    for (id, expected_code) in [
        ("", BibleDataFormatErrorCode::MissingField),
        (" ", BibleDataFormatErrorCode::MissingField),
        (" padded ", BibleDataFormatErrorCode::InvalidValue),
    ] {
        let mut source = valid_source("valid");
        source.id = id.to_string();
        let error = source.validate("$.source").unwrap_err();
        assert_eq!(error.code(), expected_code);
        assert_eq!(error.path(), "$.source.id");
        assert_eq!(error.value(), Some(&json!(id)));
    }

    let duplicate =
        BibleCatalog::new(vec![valid_source("same"), valid_source("same")]).unwrap_err();
    assert_eq!(duplicate.code(), BibleDataFormatErrorCode::DuplicateId);
    assert_eq!(duplicate.path(), "$.sources[1].id");

    let catalog = BibleCatalog::new(vec![valid_source("Alpha"), valid_source("beta")]).unwrap();
    assert!(catalog.find_by_id("Alpha").is_some());
    assert!(catalog.find_by_id("alpha").is_none());
    assert_eq!(catalog.for_language("EN").len(), 2);
    assert_eq!(catalog.for_language("english").len(), 2);
}

#[test]
fn source_and_catalog_json_failures_retain_typed_causes() {
    let source_error = BibleSource::from_json("{").unwrap_err();
    assert_eq!(source_error.code(), BibleDataFormatErrorCode::InvalidJson);
    assert_eq!(source_error.path(), "$");
    assert!(source_error
        .source()
        .and_then(|cause| cause.downcast_ref::<serde_json::Error>())
        .is_some());

    let catalog_error = BibleCatalog::from_json("[").unwrap_err();
    assert_eq!(catalog_error.code(), BibleDataFormatErrorCode::InvalidJson);
    assert!(catalog_error
        .source()
        .and_then(|cause| cause.downcast_ref::<serde_json::Error>())
        .is_some());

    let utf8_error = BibleCatalog::from_json_slice(&[0xff]).unwrap_err();
    assert_eq!(utf8_error.code(), BibleDataFormatErrorCode::InvalidJson);
    assert!(utf8_error
        .source()
        .and_then(|cause| cause.downcast_ref::<std::str::Utf8Error>())
        .is_some());
}

#[test]
fn source_alias_types_and_reserved_extensions_fail_at_their_exact_paths() {
    let error = BibleSource::from_value(&json!({
        "id": "example",
        "assetPath": "example.json",
        "languageName": "English",
        "languageCode": "en",
        "translationName": "Example",
        "abbreviation": "EX",
        "year": true
    }))
    .unwrap_err();
    assert_eq!(error.code(), BibleDataFormatErrorCode::InvalidType);
    assert_eq!(error.path(), "$.year");
    assert_eq!(error.value(), Some(&Value::Bool(true)));

    let mut source = valid_source("example");
    source.additional.insert("id".to_string(), json!("hidden"));
    let error = source.validate("$.source").unwrap_err();
    assert_eq!(error.code(), BibleDataFormatErrorCode::ReservedField);
    assert_eq!(error.path(), "$.source.id");

    let metadata = BibleMetadata {
        direction: TextDirectionHint::Auto,
        id: Some(" example ".to_string()),
        ..BibleMetadata::default()
    };
    let error = metadata.validate("$.metadata").unwrap_err();
    assert_eq!(error.code(), BibleDataFormatErrorCode::InvalidValue);
    assert_eq!(error.path(), "$.metadata.id");
    assert_eq!(error.value(), Some(&json!(" example ")));
}

#[test]
fn catalog_accepts_all_documented_container_aliases_and_rejects_ambiguity() {
    for container in ["sources", "bibles", "translations"] {
        let catalog = BibleCatalog::from_value(&json!({
            container: [{
                "id": "example",
                "assetPath": "bibles/English/example.json",
                "languageName": "English",
                "languageCode": "en",
                "translationName": "Example",
                "abbreviation": "EX"
            }]
        }))
        .unwrap();
        assert_eq!(catalog.sources().len(), 1, "{container}");
    }

    let error = BibleCatalog::from_value(&json!({"sources": [], "bibles": []})).unwrap_err();
    assert_eq!(error.code(), BibleDataFormatErrorCode::InvalidValue);
    assert_eq!(error.path(), "$");

    let error = BibleCatalog::from_value(&json!({
        "sources": [{
            "id": "   ",
            "assetPath": "bibles/English/example.json",
            "languageName": "English",
            "languageCode": "en",
            "translationName": "Example",
            "abbreviation": "EX"
        }]
    }))
    .unwrap_err();
    assert_eq!(error.code(), BibleDataFormatErrorCode::InvalidValue);
    assert_eq!(error.path(), "$.sources[0].id");
    assert_eq!(error.value(), Some(&json!("   ")));
}