rototo 0.1.0-alpha.5

Control plane for runtime configuration of your application.
Documentation
use std::collections::BTreeSet;

use crate::diagnostics::{RototoRuleId, SemanticEntity, SemanticField, Severity};

use super::super::engine::LintContext;
use super::super::references::{ReferenceSource, ReferenceTarget};
use super::super::request_context::{
    compatibility_for as request_context_compatibility_for, qualifier_uses_context_attribute,
    variable_rule_condition_reference_count,
};
use super::super::stages::{push_graph_diagnostic, push_project_diagnostic, push_value_diagnostic};

pub(super) fn lint_request_context_schemas(ctx: &mut LintContext) {
    let diagnostics = &mut ctx.diagnostics;
    for request_context in ctx.index.request_contexts.values() {
        if let Some(message) = &request_context.invalid_message {
            push_project_diagnostic(
                diagnostics,
                RototoRuleId::RequestContextSchemaInvalid,
                request_context.field_target(SemanticField::SchemaJson),
                request_context.location.clone(),
                format!("request context schema is invalid: {message}"),
            );
        }
    }
}

pub(super) fn lint_request_context_reserved_fields(ctx: &mut LintContext) {
    let diagnostics = &mut ctx.diagnostics;
    for request_context in ctx.index.request_contexts.values() {
        let Some(json) = request_context.json.as_ref() else {
            continue;
        };
        if !json
            .get("properties")
            .and_then(serde_json::Value::as_object)
            .is_some_and(|properties| properties.contains_key("qualifier"))
        {
            continue;
        }
        push_project_diagnostic(
            diagnostics,
            RototoRuleId::RequestContextReservedField,
            request_context.field_target(SemanticField::SchemaJson),
            request_context.location.clone(),
            "request context schema declares reserved top-level field: qualifier",
        );
    }
}

pub(super) fn lint_request_context_entries(ctx: &mut LintContext) {
    let mut diagnostics = Vec::new();
    for (request_context_id, entries) in &ctx.index.request_context_entries {
        let context = ctx.index.request_contexts.get(request_context_id);
        for entry in entries.values() {
            let Some(value) = entry.value.as_ref() else {
                continue;
            };
            if !value.is_object() {
                push_value_diagnostic(
                    &mut diagnostics,
                    RototoRuleId::RequestContextEntryShape,
                    entry.field_target(SemanticField::RequestContextEntry),
                    entry.location.clone(),
                    format!("request context sample {} must be a JSON object", entry.key),
                );
                continue;
            }

            let Some(context) = context else {
                push_value_diagnostic(
                    &mut diagnostics,
                    RototoRuleId::RequestContextEntryShape,
                    entry.field_target(SemanticField::RequestContextEntry),
                    entry.location.clone(),
                    format!(
                        "request context sample {} has no owning request context: {}",
                        entry.key, entry.request_context_id
                    ),
                );
                continue;
            };
            let Some(validator) = context.validator.as_ref() else {
                continue;
            };
            if let Err(err) = validator.validate(value) {
                push_value_diagnostic(
                    &mut diagnostics,
                    RototoRuleId::RequestContextEntrySchemaMismatch,
                    entry.field_target(SemanticField::RequestContextEntry),
                    entry.location.clone(),
                    format!(
                        "request context sample {} does not match {} schema: {err}",
                        entry.key, entry.request_context_id
                    ),
                );
            }
        }
    }
    ctx.diagnostics.extend(diagnostics);
}

pub(super) fn lint_request_context_compatibility(ctx: &mut LintContext) {
    let compatibility = request_context_compatibility_for(&ctx.index, &ctx.references);
    let mut diagnostics = Vec::new();
    let qualifiers_with_errors = qualifiers_with_existing_errors(ctx);
    let variables_with_errors = variables_with_existing_errors(ctx);
    let mut qualifiers_without_context = BTreeSet::new();

    for qualifier in ctx.index.qualifiers.values() {
        if qualifiers_with_errors.contains(&qualifier.id) {
            continue;
        }
        let contexts = compatibility
            .qualifiers
            .get(&qualifier.id)
            .cloned()
            .unwrap_or_default();
        if contexts.is_empty() && qualifier_uses_context_attribute(&ctx.references, &qualifier.id) {
            qualifiers_without_context.insert(qualifier.id.clone());
            push_graph_diagnostic(
                &mut diagnostics,
                RototoRuleId::QualifierNoCompatibleRequestContext,
                qualifier.target(),
                qualifier.location.clone(),
                format!(
                    "qualifier {} has no compatible request context",
                    qualifier.id
                ),
            );
        }
    }

    for variable in ctx.index.variables.values() {
        if variables_with_errors.contains(&variable.id)
            || variable_references_error_qualifier(ctx, &variable.id, &qualifiers_with_errors)
            || variable_references_error_qualifier(ctx, &variable.id, &qualifiers_without_context)
        {
            continue;
        }
        if variable_rule_condition_reference_count(&ctx.index, &variable.id) == 0 {
            continue;
        }
        let contexts = compatibility
            .variables
            .get(&variable.id)
            .cloned()
            .unwrap_or_default();
        if contexts.is_empty() {
            push_graph_diagnostic(
                &mut diagnostics,
                RototoRuleId::VariableRequestContextConflict,
                variable.target(),
                variable.location.clone(),
                format!(
                    "variable {} has no request context shared by all rule conditions",
                    variable.id
                ),
            );
        }
    }

    ctx.diagnostics.extend(diagnostics);
}

fn qualifiers_with_existing_errors(ctx: &LintContext) -> BTreeSet<String> {
    ctx.diagnostics
        .iter()
        .filter(|diagnostic| diagnostic.severity == Severity::Error)
        .filter_map(|diagnostic| match &diagnostic.target.entity {
            SemanticEntity::Qualifier { id } => Some(id.clone()),
            SemanticEntity::Predicate { qualifier, .. } => Some(qualifier.clone()),
            _ => None,
        })
        .collect()
}

fn variables_with_existing_errors(ctx: &LintContext) -> BTreeSet<String> {
    ctx.diagnostics
        .iter()
        .filter(|diagnostic| diagnostic.severity == Severity::Error)
        .filter_map(|diagnostic| match &diagnostic.target.entity {
            SemanticEntity::Variable { id } => Some(id.clone()),
            SemanticEntity::Value { variable, .. } => Some(variable.clone()),
            SemanticEntity::Rule { variable, .. } => Some(variable.clone()),
            _ => None,
        })
        .collect()
}

fn variable_references_error_qualifier(
    ctx: &LintContext,
    variable_id: &str,
    qualifiers_with_errors: &BTreeSet<String>,
) -> bool {
    ctx.references.edges().iter().any(|edge| {
        matches!(
            &edge.source,
            ReferenceSource::VariableRuleConditionQualifier { variable, .. }
                if variable == variable_id
        ) && matches!(
            &edge.target,
            ReferenceTarget::Qualifier(qualifier) if qualifiers_with_errors.contains(qualifier)
        )
    })
}