knowledge-base-validation 0.2.0

Validation for the file-based knowledge base
Documentation
use knowledge_base_validation::{Diagnostic, ValidationLayer, validate_repository};
use std::path::{Path, PathBuf};

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

fn diagnostic(layer: ValidationLayer, path: &str, line: Option<usize>, identifier: Option<&str>, message: &str) -> Diagnostic {
    Diagnostic {
        layer,
        path: PathBuf::from(path),
        line,
        identifier: identifier.map(str::to_owned),
        message: message.to_owned(),
    }
}

fn assert_fixture(name: &str, expected: Vec<Diagnostic>) {
    assert_eq!(validate_repository(fixture(name)), expected, "fixture: {name}");
}

#[test]
fn valid_fixtures_cover_minimal_and_all_value_shapes() {
    for name in ["valid/minimal", "valid/all-values"] {
        assert_fixture(name, Vec::new());
    }
}

#[test]
fn schema_fixture_reports_loading_and_shape_errors() {
    assert_fixture(
        "invalid/schema",
        vec![
            diagnostic(
                ValidationLayer::Schema,
                "entities/Q2.yaml",
                Some(3),
                None,
                "invalid YAML: did not find expected node content at line 3 column 1, while parsing a flow node",
            ),
            diagnostic(ValidationLayer::Schema, "entities/Q3.yaml", Some(1), None, "invalid YAML: duplicate entry with key \"id\""),
            diagnostic(
                ValidationLayer::Schema,
                "entities/unexpected.txt",
                None,
                None,
                "unexpected entry; managed directories may contain only .yaml files",
            ),
            diagnostic(
                ValidationLayer::Schema,
                "entity_types/README.txt",
                None,
                None,
                "unexpected entry; managed directories may contain only .yaml files",
            ),
            diagnostic(
                ValidationLayer::Schema,
                "properties/README.txt",
                None,
                None,
                "unexpected entry; managed directories may contain only .yaml files",
            ),
            diagnostic(
                ValidationLayer::Schema,
                "references/README.txt",
                None,
                None,
                "unexpected entry; managed directories may contain only .yaml files",
            ),
        ],
    );
}

#[test]
fn missing_required_directories_are_reported() {
    assert_fixture(
        "invalid/schema-missing",
        ["entities", "entity_types", "properties", "references"]
            .into_iter()
            .map(|path| {
                diagnostic(
                    ValidationLayer::Schema,
                    path,
                    None,
                    None,
                    "required directory cannot be read: No such file or directory (os error 2)",
                )
            })
            .collect(),
    );
}

#[test]
fn localization_and_reference_fixture_reports_complete_diagnostics() {
    assert_fixture(
        "invalid/localization-provenance",
        vec![
            diagnostic(ValidationLayer::Schema, "entities/Q1.yaml", None, Some("Q1"), "entity_types must not be empty"),
            diagnostic(
                ValidationLayer::Schema,
                "entities/Q1.yaml",
                None,
                Some("Q1"),
                "labels contains locale \"tr\" more than once ignoring case",
            ),
            diagnostic(
                ValidationLayer::Schema,
                "entities/Q1.yaml",
                None,
                Some("Q1"),
                "labels locale \"invalid_tag\" is not a well-formed BCP 47 tag",
            ),
            diagnostic(ValidationLayer::Schema, "entities/Q1.yaml", None, Some("Q1"), "labels.TR references must not be empty"),
            diagnostic(ValidationLayer::Provenance, "entities/Q1.yaml", None, Some("Q1"), "labels.tr cites missing reference R99"),
            diagnostic(ValidationLayer::Schema, "entities/Q1.yaml", None, Some("Q1"), "labels.tr text must not be empty"),
            diagnostic(ValidationLayer::Schema, "entity_types/T1.yaml", None, Some("T1"), "labels must not be empty"),
            diagnostic(ValidationLayer::Schema, "properties/P1.yaml", None, Some("P1"), "labels must not be empty"),
            diagnostic(ValidationLayer::Schema, "properties/P1.yaml", None, Some("P1"), "subject_types must not be empty"),
            diagnostic(ValidationLayer::Schema, "references/R1.yaml", None, Some("R1"), "archive_url must be an absolute URL"),
            diagnostic(
                ValidationLayer::Schema,
                "references/R1.yaml",
                None,
                Some("R1"),
                "publication_date must be a valid YYYY, YYYY-MM, or YYYY-MM-DD date",
            ),
            diagnostic(ValidationLayer::Schema, "references/R1.yaml", None, Some("R1"), "publisher must not be empty"),
            diagnostic(
                ValidationLayer::Schema,
                "references/R1.yaml",
                None,
                Some("R1"),
                "retrieved_at must be an RFC 3339 timestamp",
            ),
            diagnostic(
                ValidationLayer::Schema,
                "references/R1.yaml",
                None,
                Some("R1"),
                "source_language must be a well-formed BCP 47 tag",
            ),
            diagnostic(ValidationLayer::Schema, "references/R1.yaml", None, Some("R1"), "title must not be empty"),
            diagnostic(ValidationLayer::Schema, "references/R1.yaml", None, Some("R1"), "url must be an absolute URL"),
        ],
    );
}

#[test]
fn ontology_fixture_reports_complete_diagnostics() {
    assert_fixture(
        "invalid/ontology",
        vec![
            diagnostic(ValidationLayer::Ontology, "entities/Q1.yaml", None, Some("Q1"), "classified entity type T99 does not exist"),
            diagnostic(ValidationLayer::Ontology, "entities/Q1.yaml", None, Some("Q1"), "statement property P99 does not exist"),
            diagnostic(
                ValidationLayer::Schema,
                "id_allocation.yaml",
                None,
                Some("id_allocation"),
                "next.entity must be greater than the greatest used identifier number (1)",
            ),
            diagnostic(
                ValidationLayer::Schema,
                "id_allocation.yaml",
                None,
                Some("id_allocation"),
                "next.entity_type must be greater than the greatest used identifier number (1)",
            ),
            diagnostic(
                ValidationLayer::Schema,
                "id_allocation.yaml",
                None,
                Some("id_allocation"),
                "next.property must be greater than the greatest used identifier number (1)",
            ),
            diagnostic(
                ValidationLayer::Schema,
                "id_allocation.yaml",
                None,
                Some("id_allocation"),
                "next.reference must be greater than the greatest used identifier number (1)",
            ),
            diagnostic(ValidationLayer::Schema, "id_allocation.yaml", None, Some("id_allocation"), "version must be 1"),
            diagnostic(
                ValidationLayer::Ontology,
                "properties/P1.yaml",
                None,
                Some("P1"),
                "allowed qualifier property P99 does not exist",
            ),
            diagnostic(ValidationLayer::Schema, "properties/P1.yaml", None, Some("P1"), "subject_types must not be empty"),
            diagnostic(ValidationLayer::Schema, "properties/P1.yaml", None, Some("P1"), "target_types must not be empty"),
        ],
    );
}

#[test]
fn entity_context_fixture_keeps_known_gaps_visible() {
    assert_fixture(
        "invalid/entity-context",
        vec![
            diagnostic(
                ValidationLayer::Provenance,
                "entity_context/Q1.md",
                Some(1),
                Some("Q1"),
                "footnote \"R2\" has no definition",
            ),
            diagnostic(
                ValidationLayer::Provenance,
                "entity_context/Q1.md",
                Some(1),
                Some("Q1"),
                "footnote \"R99\" cites a reference that does not exist",
            ),
            diagnostic(
                ValidationLayer::Provenance,
                "entity_context/Q1.md",
                Some(3),
                Some("Q1"),
                "footnote \"R99\" cites a reference that does not exist",
            ),
            diagnostic(
                ValidationLayer::Provenance,
                "entity_context/Q1.md",
                Some(3),
                Some("Q1"),
                "footnote \"R99\" must contain exactly one link to ../references/R99.yaml",
            ),
            diagnostic(ValidationLayer::Provenance, "entity_context/Q1.md", Some(5), Some("Q1"), "footnote \"R1\" is unused"),
            diagnostic(
                ValidationLayer::Provenance,
                "entity_context/Q99.md",
                None,
                Some("Q99"),
                "context document names an entity that does not exist",
            ),
            diagnostic(
                ValidationLayer::Schema,
                "id_allocation.yaml",
                None,
                Some("id_allocation"),
                "next.property must be greater than the greatest used identifier number (1)",
            ),
        ],
    );
}

#[test]
fn diagnostic_display_and_sorting_cover_all_context_combinations() {
    let diagnostics = [
        diagnostic(ValidationLayer::Schema, "a", None, None, "m"),
        diagnostic(ValidationLayer::Ontology, "a", Some(2), None, "m"),
        diagnostic(ValidationLayer::Provenance, "a", None, Some("Q1"), "m"),
        diagnostic(ValidationLayer::Domain, "a", Some(2), Some("Q1"), "m"),
    ];
    assert_eq!(diagnostics[0].to_string(), "a [schema] m");
    assert_eq!(diagnostics[1].to_string(), "a:2 [ontology] m");
    assert_eq!(diagnostics[2].to_string(), "a [provenance] [Q1] m");
    assert_eq!(diagnostics[3].to_string(), "a:2 [domain] [Q1] m");
}