knowledge-base-validation 0.3.0

Validation for the file-based knowledge base
Documentation
use super::Validator;
use super::values::{LocalizedMapRules, valid_partial_date, validate_localized_map, validate_nonempty_metadata, validate_url};
use crate::diagnostic::Diagnostics;
use crate::input::Loaded;
use chrono::DateTime;
use knowledge_base_models::{EntityId, EntityType, EntityTypeId, IdAllocation, PropertyId, Reference, ReferenceId, ValueType};
use language_tags::LanguageTag;
use std::collections::BTreeMap;

pub(super) fn validate(validator: &mut Validator<'_>) {
    validate_entity_types(&validator.repository.entity_types, &validator.indexes.references, &mut validator.diagnostics);
    validate_properties(validator);
    validate_references(&validator.repository.references, &mut validator.diagnostics);

    if let Some(allocation) = validator.repository.allocation.as_ref() {
        validate_allocation(
            allocation,
            validator.indexes.entities.keys().map(EntityId::number).max(),
            validator.indexes.properties.keys().map(PropertyId::number).max(),
            validator.indexes.references.keys().map(ReferenceId::number).max(),
            validator.indexes.entity_types.keys().map(EntityTypeId::number).max(),
            &mut validator.diagnostics,
        );
    }
}

fn validate_entity_types(items: &[Loaded<EntityType>], references: &BTreeMap<ReferenceId, &Loaded<Reference>>, diagnostics: &mut Diagnostics) {
    for item in items {
        let id = item.value.id.to_string();
        validate_localized_map(
            &item.path,
            &id,
            "labels",
            &item.value.labels,
            LocalizedMapRules {
                required: true,
                references_required: false,
            },
            references,
            diagnostics,
        );
        validate_localized_map(
            &item.path,
            &id,
            "descriptions",
            &item.value.descriptions,
            LocalizedMapRules {
                required: false,
                references_required: false,
            },
            references,
            diagnostics,
        );
    }
}

fn validate_properties(validator: &mut Validator<'_>) {
    let references = &validator.indexes.references;
    let types = &validator.indexes.entity_types;
    let properties = &validator.indexes.properties;
    let diagnostics = &mut validator.diagnostics;

    for item in &validator.repository.properties {
        let property = &item.value;
        let id = property.id.to_string();
        validate_localized_map(
            &item.path,
            &id,
            "labels",
            &property.labels,
            LocalizedMapRules {
                required: true,
                references_required: false,
            },
            references,
            diagnostics,
        );
        validate_localized_map(
            &item.path,
            &id,
            "descriptions",
            &property.descriptions,
            LocalizedMapRules {
                required: false,
                references_required: false,
            },
            references,
            diagnostics,
        );

        for (namespace, identifiers) in &property.external_ids {
            if namespace.trim().is_empty() {
                diagnostics.schema(item, &id, "external_ids namespace must not be empty");
            }
            let mut seen = std::collections::BTreeSet::new();
            for identifier in identifiers {
                if identifier.trim().is_empty() {
                    diagnostics.schema(item, &id, format!("external_ids.{namespace} identifier must not be empty"));
                } else if !seen.insert(identifier) {
                    diagnostics.schema(item, &id, format!("external_ids.{namespace} contains duplicate identifier {identifier:?}"));
                }
            }
        }

        if property.subject_types.is_empty() {
            diagnostics.schema(item, &id, "subject_types must not be empty");
        }
        for type_id in &property.subject_types {
            if !types.contains_key(type_id) {
                diagnostics.ontology(item, &id, format!("subject type {type_id} does not exist"));
            }
        }

        match (&property.value_type, &property.target_types) {
            (ValueType::Entity, Some(targets)) if targets.is_empty() => {
                diagnostics.schema(item, &id, "target_types must not be empty");
            }
            (ValueType::Entity, None) => {
                diagnostics.schema(item, &id, "entity-valued property requires target_types");
            }
            (ValueType::Entity, Some(_)) | (_, None) => {}
            (_, Some(_)) => diagnostics.schema(item, &id, "target_types is allowed only for entity-valued properties"),
        }
        if let Some(targets) = &property.target_types {
            for type_id in targets {
                if !types.contains_key(type_id) {
                    diagnostics.ontology(item, &id, format!("target type {type_id} does not exist"));
                }
            }
        }
        for qualifier in &property.allowed_qualifiers {
            if !properties.contains_key(qualifier) {
                diagnostics.ontology(item, &id, format!("allowed qualifier property {qualifier} does not exist"));
            }
        }
    }
}

fn validate_references(items: &[Loaded<Reference>], diagnostics: &mut Diagnostics) {
    for item in items {
        let reference = &item.value;
        let id = reference.id.to_string();
        validate_url(&item.path, &id, "url", &reference.url, diagnostics);
        validate_nonempty_metadata(item, &id, "title", &reference.title, diagnostics);
        if let Some(publisher) = &reference.publisher {
            validate_nonempty_metadata(item, &id, "publisher", publisher, diagnostics);
        }
        if let Some(publication_date) = &reference.publication_date
            && validate_nonempty_metadata(item, &id, "publication_date", publication_date, diagnostics)
            && !valid_partial_date(publication_date)
        {
            diagnostics.schema(item, &id, "publication_date must be a valid YYYY, YYYY-MM, or YYYY-MM-DD date");
        }
        if let Some(source_language) = &reference.source_language
            && validate_nonempty_metadata(item, &id, "source_language", source_language, diagnostics)
            && source_language.parse::<LanguageTag>().is_err()
        {
            diagnostics.schema(item, &id, "source_language must be a well-formed BCP 47 tag");
        }
        if let Some(url) = &reference.archive_url {
            validate_url(&item.path, &id, "archive_url", url, diagnostics);
        }
        if DateTime::parse_from_rfc3339(&reference.retrieved_at).is_err() {
            diagnostics.schema(item, &id, "retrieved_at must be an RFC 3339 timestamp");
        }
    }
}

fn validate_allocation(
    allocation: &Loaded<IdAllocation>,
    max_entity: Option<u64>,
    max_property: Option<u64>,
    max_reference: Option<u64>,
    max_type: Option<u64>,
    diagnostics: &mut Diagnostics,
) {
    if allocation.value.version != 1 {
        diagnostics.schema(allocation, "id_allocation", "version must be 1");
    }
    for (field, next, maximum) in [
        ("entity", allocation.value.next.entity, max_entity),
        ("property", allocation.value.next.property, max_property),
        ("reference", allocation.value.next.reference, max_reference),
        ("entity_type", allocation.value.next.entity_type, max_type),
    ] {
        if next == 0 {
            diagnostics.schema(allocation, "id_allocation", format!("next.{field} must be positive"));
        } else if maximum.is_some_and(|maximum| next <= maximum) {
            diagnostics.schema(
                allocation,
                "id_allocation",
                format!("next.{field} must be greater than the greatest used identifier number ({})", maximum.unwrap_or_default()),
            );
        }
    }
}

#[cfg(test)]
mod tests {
    use super::validate_references;
    use crate::diagnostic::Diagnostics;
    use crate::input::Loaded;
    use knowledge_base_models::Reference;
    use std::path::PathBuf;

    #[test]
    fn validates_reference_metadata() {
        let references = [Loaded {
            path: PathBuf::from("references/R1.yaml"),
            value: Reference {
                id: "R1".parse().expect("valid reference identifier"),
                url: "https://example.org".to_owned(),
                title: " ".to_owned(),
                publisher: Some("".to_owned()),
                publication_date: Some("2025-02-29".to_owned()),
                source_language: Some("en_US".to_owned()),
                retrieved_at: "2025-01-15T10:30:00Z".to_owned(),
                archive_url: None,
            },
        }];
        let mut diagnostics = Diagnostics::default();

        validate_references(&references, &mut diagnostics);

        let diagnostics = diagnostics.finish();
        let messages = diagnostics.iter().map(|item| item.message.as_str()).collect::<Vec<_>>();
        assert!(messages.contains(&"title must not be empty"));
        assert!(messages.contains(&"publisher must not be empty"));
        assert!(messages.contains(&"publication_date must be a valid YYYY, YYYY-MM, or YYYY-MM-DD date"));
        assert!(messages.contains(&"source_language must be a well-formed BCP 47 tag"));
    }
}