knowledge-base-validation 0.3.0

Validation for the file-based knowledge base
Documentation
use knowledge_base_validation::{Diagnostic, KnowledgeBaseValidator, ValidationLayer, validate_repository, validate_repository_with};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

fn fixture(name: &str) -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join("fixtures").join(name)
}

#[test]
fn minimal_fixture_is_valid() {
    assert!(validate_repository(fixture("valid/minimal")).is_empty());
}

#[test]
fn missing_root_returns_a_schema_diagnostic() {
    let diagnostics = validate_repository(fixture("does-not-exist"));

    assert_eq!(diagnostics.len(), 1);
    assert_eq!(diagnostics[0].layer, ValidationLayer::Schema);
    assert!(diagnostics[0].message.contains("not a readable directory"));
}

#[test]
fn multiple_diagnostics_are_complete_and_deterministic() {
    let root = tempfile::tempdir().expect("temporary directory");
    let first = validate_repository(root.path());
    let second = validate_repository(root.path());

    assert!(first.len() > 1);
    assert_eq!(first, second);
    assert!(first.windows(2).all(|pair| {
        (&pair[0].path, pair[0].line.unwrap_or(usize::MAX), &pair[0].identifier, &pair[0].message, pair[0].layer)
            <= (&pair[1].path, pair[1].line.unwrap_or(usize::MAX), &pair[1].identifier, &pair[1].message, pair[1].layer)
    }));
}

#[test]
fn additional_validators_share_a_snapshot_and_their_diagnostics_are_sorted_with_built_ins() {
    let root = fixture("valid/minimal");
    let calls = Arc::new(Mutex::new(Vec::new()));
    let first_calls = Arc::clone(&calls);
    let first = move |context: &knowledge_base_validation::ValidationContext<'_>| {
        first_calls
            .lock()
            .unwrap()
            .push(("first", context.repository_root().to_path_buf(), context.snapshot() as *const _ as usize));
        vec![Diagnostic {
            layer: ValidationLayer::Domain,
            path: PathBuf::from("z-domain.yaml"),
            line: None,
            identifier: None,
            message: "last".to_owned(),
        }]
    };
    let second_calls = Arc::clone(&calls);
    let second = move |context: &knowledge_base_validation::ValidationContext<'_>| {
        second_calls
            .lock()
            .unwrap()
            .push(("second", context.repository_root().to_path_buf(), context.snapshot() as *const _ as usize));
        vec![Diagnostic {
            layer: ValidationLayer::Domain,
            path: PathBuf::from("a-domain.yaml"),
            line: None,
            identifier: None,
            message: "first".to_owned(),
        }]
    };
    let validators: [&dyn KnowledgeBaseValidator; 2] = [&first, &second];

    let diagnostics = validate_repository_with(&root, validators);

    assert_eq!(
        calls.lock().unwrap().iter().map(|(name, path, _)| (*name, path.clone())).collect::<Vec<_>>(),
        [("first", root.clone()), ("second", root.clone())]
    );
    let calls = calls.lock().unwrap();
    assert_eq!(calls[0].2, calls[1].2);
    assert!(!diagnostics.iter().any(|diagnostic| diagnostic.layer == ValidationLayer::Schema));
    let domain_paths = diagnostics
        .iter()
        .filter(|diagnostic| diagnostic.layer == ValidationLayer::Domain)
        .map(|diagnostic| diagnostic.path.clone())
        .collect::<Vec<_>>();
    assert_eq!(domain_paths, [PathBuf::from("a-domain.yaml"), PathBuf::from("z-domain.yaml")]);
}

#[test]
fn snapshot_loading_failure_skips_domain_validators_without_duplicate_diagnostics() {
    let root = tempfile::tempdir().expect("temporary directory");
    let calls = Arc::new(Mutex::new(0));
    let validator_calls = Arc::clone(&calls);
    let validator = move |_context: &knowledge_base_validation::ValidationContext<'_>| {
        *validator_calls.lock().unwrap() += 1;
        Vec::new()
    };

    let diagnostics = validate_repository_with(root.path(), [&validator as &dyn KnowledgeBaseValidator]);

    assert_eq!(*calls.lock().unwrap(), 0);
    assert!(diagnostics.len() > 1);
    assert!(diagnostics.iter().all(|diagnostic| !diagnostic.message.contains("shared repository snapshot")));
}

#[test]
fn validation_without_additional_validators_is_unchanged() {
    let root = fixture("valid/minimal");
    assert_eq!(validate_repository(&root), validate_repository_with(&root, std::iter::empty()));
}

#[test]
fn diagnostic_display_includes_all_available_context() {
    let diagnostic = Diagnostic {
        layer: ValidationLayer::Ontology,
        path: PathBuf::from("entities/Q1.yaml"),
        line: Some(12),
        identifier: Some("Q1/S3".to_owned()),
        message: "target entity Q99 does not exist".to_owned(),
    };

    assert_eq!(diagnostic.to_string(), "entities/Q1.yaml:12 [ontology] [Q1/S3] target entity Q99 does not exist");
}

fn copied_minimal_fixture() -> tempfile::TempDir {
    let root = tempfile::tempdir().expect("temporary directory");
    let source = fixture("valid/minimal");
    for directory in ["entities", "entity_types", "properties", "references", "entity_context"] {
        let target = root.path().join(directory);
        fs::create_dir(&target).expect("fixture directory");
        for entry in fs::read_dir(source.join(directory)).expect("fixture entries") {
            let entry = entry.expect("fixture entry");
            fs::copy(entry.path(), target.join(entry.file_name())).expect("copy fixture file");
        }
    }
    fs::copy(source.join("id_allocation.yaml"), root.path().join("id_allocation.yaml")).expect("copy allocation");
    root
}

fn image_fixture_block() -> &'static str {
    "images:\n  - url: https://example.org/bilecik.jpg\n    alt: Bilecik city centre\n    source_url: https://example.org/bilecik-image\n    creator: Example Archive\n    license: CC BY 4.0\n    references: [R1]"
}

#[test]
fn images_validate_metadata_urls_and_optional_references() {
    for (original, replacement, expected_message) in [
        ("url: https://example.org/bilecik.jpg", "url: relative.jpg", "image url must be an absolute URL"),
        (
            "source_url: https://example.org/bilecik-image",
            "source_url: relative.jpg",
            "image source_url must be an absolute URL",
        ),
        ("alt: Bilecik city centre", "alt: '   '", "image alt must not be empty"),
        ("creator: Example Archive", "creator: '   '", "image creator must not be empty"),
        ("license: CC BY 4.0", "license: '   '", "image license must not be empty"),
        ("references: [R1]", "references: [R2]", "image cites missing reference R2"),
    ] {
        let root = copied_minimal_fixture();
        let entity_path = root.path().join("entities/Q1.yaml");
        let entity = fs::read_to_string(&entity_path).expect("entity fixture");
        let updated_image = image_fixture_block().replacen(original, replacement, 1);
        fs::write(&entity_path, entity.replace(image_fixture_block(), &updated_image)).expect("updated entity fixture");

        assert!(
            validate_repository(root.path()).iter().any(|diagnostic| diagnostic.message == expected_message),
            "missing diagnostic: {expected_message}"
        );
    }

    let root = copied_minimal_fixture();
    let entity_path = root.path().join("entities/Q1.yaml");
    let entity = fs::read_to_string(&entity_path).expect("entity fixture");
    let uncited_image = image_fixture_block().replace("\n    references: [R1]", "");
    fs::write(&entity_path, entity.replace(image_fixture_block(), &uncited_image)).expect("entity without image references");
    assert!(validate_repository(root.path()).is_empty());
}

#[test]
fn property_usage_restricts_statement_and_qualifier_positions() {
    for (property, replacement, expected_message) in [
        ("P1.yaml", "usage: qualifier", "property P1 cannot be used as a statement"),
        ("P2.yaml", "usage: statement", "property P2 cannot be used as a qualifier"),
    ] {
        let root = copied_minimal_fixture();
        let path = root.path().join("properties").join(property);
        let source = fs::read_to_string(&path).expect("property fixture");
        fs::write(&path, source.replacen("usage: statement", replacement, 1).replacen("usage: qualifier", replacement, 1)).expect("updated property fixture");

        assert!(
            validate_repository(root.path()).iter().any(|diagnostic| diagnostic.message == expected_message),
            "missing diagnostic: {expected_message}"
        );
    }

    let root = copied_minimal_fixture();
    let path = root.path().join("properties/P2.yaml");
    let source = fs::read_to_string(&path).expect("property fixture");
    fs::write(&path, source.replace("usage: qualifier", "usage: both")).expect("updated property fixture");
    assert!(validate_repository(root.path()).is_empty());
}

#[test]
fn external_ids_require_meaningful_unique_identifiers() {
    for (block, expected_message) in [
        ("external_ids:\n  '   ': [P1082]\n", "external_ids namespace must not be empty"),
        ("external_ids:\n  wikidata: ['   ']\n", "external_ids.wikidata identifier must not be empty"),
        (
            "external_ids:\n  wikidata: [P1082, P1082]\n",
            "external_ids.wikidata contains duplicate identifier \"P1082\"",
        ),
    ] {
        let root = copied_minimal_fixture();
        let path = root.path().join("properties/P1.yaml");
        let source = fs::read_to_string(&path).expect("property fixture");
        fs::write(&path, format!("{source}{block}")).expect("updated property fixture");

        assert!(
            validate_repository(root.path()).iter().any(|diagnostic| diagnostic.message == expected_message),
            "missing diagnostic: {expected_message}"
        );
    }

    let root = copied_minimal_fixture();
    let path = root.path().join("properties/P1.yaml");
    let source = fs::read_to_string(&path).expect("property fixture");
    fs::write(&path, format!("{source}external_ids:\n  wikidata: [P1082, P2046]\n  osm: []\n")).expect("updated property fixture");
    assert!(validate_repository(root.path()).is_empty());
}

#[test]
fn ontology_text_may_be_uncited_but_entity_text_must_be_cited() {
    let root = copied_minimal_fixture();
    for path in [root.path().join("properties/P1.yaml"), root.path().join("entity_types/T1.yaml")] {
        let source = fs::read_to_string(&path).expect("ontology fixture");
        fs::write(&path, source.replace("references: [R1]", "references: []")).expect("updated ontology fixture");
    }
    assert!(validate_repository(root.path()).is_empty());

    let path = root.path().join("entities/Q1.yaml");
    let source = fs::read_to_string(&path).expect("entity fixture");
    fs::write(&path, source.replacen("references: [R1]", "references: []", 1)).expect("updated entity fixture");
    assert!(
        validate_repository(root.path())
            .iter()
            .any(|diagnostic| diagnostic.message == "labels.tr references must not be empty")
    );
}