weavatrix-refactor-plan 0.1.0

Evidence metadata, validation profiles, and canonical fingerprints for Weavatrix refactor plans
Documentation
mod entries;
mod support;

use crate::{
    Completeness, GraphRevision, PlanError, PlanEvidence, RefactorPlan, RefactorPlanLimits,
};
use entries::validate_entries;
use std::collections::BTreeSet;
use support::{TextBudget, invalid, missing};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum EvidenceProfile {
    Consumer,
    Planner,
}

pub(crate) fn validate_evidence(
    plan: &RefactorPlan,
    limits: RefactorPlanLimits,
    profile: EvidenceProfile,
) -> Result<(), PlanError> {
    validate_completeness(plan, profile)?;
    let evidence = &plan.evidence;
    let mut text = TextBudget::new(limits);
    validate_created_at(evidence, profile, &mut text)?;
    validate_revision(evidence, profile, &mut text)?;
    validate_proof(evidence, profile, limits, &mut text)?;
    validate_misc(evidence, profile, &mut text)?;
    validate_entries(evidence, plan.operations.len(), limits, &mut text)?;
    validate_completeness_truth(plan, profile)
}

fn validate_completeness(plan: &RefactorPlan, profile: EvidenceProfile) -> Result<(), PlanError> {
    let Some(completeness) = &plan.completeness else {
        if profile == EvidenceProfile::Planner {
            return Err(missing("completeness"));
        }
        return Ok(());
    };
    if !matches!(
        completeness.as_str(),
        Completeness::COMPLETE | Completeness::PARTIAL
    ) {
        return Err(invalid(
            "completeness must be COMPLETE or PARTIAL",
            "completeness",
        ));
    }
    Ok(())
}

fn validate_created_at(
    evidence: &PlanEvidence,
    profile: EvidenceProfile,
    text: &mut TextBudget,
) -> Result<(), PlanError> {
    let Some(created_at) = evidence.created_at.as_deref() else {
        if profile == EvidenceProfile::Planner {
            return Err(missing("createdAt"));
        }
        return Ok(());
    };
    text.require(created_at, "createdAt")?;
    OffsetDateTime::parse(created_at, &Rfc3339)
        .map_err(|_| invalid("createdAt must be valid RFC 3339", "createdAt"))?;
    Ok(())
}

fn validate_revision(
    evidence: &PlanEvidence,
    profile: EvidenceProfile,
    text: &mut TextBudget,
) -> Result<(), PlanError> {
    match &evidence.graph_revision {
        GraphRevision::Missing if profile == EvidenceProfile::Planner => {
            Err(missing("graphRevision"))
        }
        GraphRevision::Value(value) => text.require(value, "graphRevision"),
        GraphRevision::Missing | GraphRevision::Null => Ok(()),
    }
}

fn validate_proof(
    evidence: &PlanEvidence,
    profile: EvidenceProfile,
    limits: RefactorPlanLimits,
    text: &mut TextBudget,
) -> Result<(), PlanError> {
    let Some(proof) = &evidence.completeness_proof else {
        if profile == EvidenceProfile::Planner {
            return Err(missing("completenessProof"));
        }
        return Ok(());
    };
    text.code(proof.scope.kind.as_str(), "completenessProof.scope.kind")?;
    text.require(&proof.scope.value, "completenessProof.scope.value")?;
    validate_unique_paths(
        &proof.scope.roots,
        "completenessProof.scope.roots",
        limits,
        text,
    )?;
    validate_unique_texts(
        &proof.scope.languages,
        "completenessProof.scope.languages",
        text,
    )?;
    for (field, value) in [
        (
            "completenessProof.planner.name",
            proof.planner.name.as_str(),
        ),
        (
            "completenessProof.planner.version",
            proof.planner.version.as_str(),
        ),
        (
            "completenessProof.planner.backend",
            proof.planner.backend.as_str(),
        ),
    ] {
        text.require(value, field)?;
    }
    if let Some(version) = proof.planner.backend_version.as_deref() {
        text.require(version, "completenessProof.planner.backendVersion")?;
    }
    Ok(())
}

fn validate_unique_texts(
    values: &[String],
    field: &str,
    text: &mut TextBudget,
) -> Result<(), PlanError> {
    let mut seen = BTreeSet::new();
    for (index, value) in values.iter().enumerate() {
        text.require(value, &format!("{field}[{index}]"))?;
        if !seen.insert(value) {
            return Err(invalid("duplicate scope value", field));
        }
    }
    Ok(())
}

fn validate_unique_paths(
    values: &[String],
    field: &str,
    limits: RefactorPlanLimits,
    text: &mut TextBudget,
) -> Result<(), PlanError> {
    let mut seen = BTreeSet::new();
    for (index, value) in values.iter().enumerate() {
        text.require(value, &format!("{field}[{index}]"))?;
        crate::validate_plan_path(value, limits.max_path_bytes)
            .map_err(|error| error.at_field(format!("{field}[{index}]")))?;
        if !seen.insert(crate::portable_path_key(value)) {
            return Err(invalid("duplicate portable scope root", field));
        }
    }
    Ok(())
}

fn validate_misc(
    evidence: &PlanEvidence,
    profile: EvidenceProfile,
    text: &mut TextBudget,
) -> Result<(), PlanError> {
    if profile == EvidenceProfile::Planner {
        for (present, field) in [
            (
                evidence.uncertain_references.is_some(),
                "uncertainReferences",
            ),
            (evidence.not_modified.is_some(), "notModified"),
            (evidence.warnings.is_some(), "warnings"),
        ] {
            if !present {
                return Err(missing(field));
            }
        }
        text.require_option(evidence.follow_up.as_deref(), "followUp")?;
    } else if let Some(follow_up) = evidence.follow_up.as_deref() {
        text.require(follow_up, "followUp")?;
    }
    if let Some(code) = &evidence.syntax_check {
        text.code(code.as_str(), "syntaxCheck")?;
    }
    Ok(())
}

fn validate_completeness_truth(
    plan: &RefactorPlan,
    profile: EvidenceProfile,
) -> Result<(), PlanError> {
    let references = plan
        .evidence
        .uncertain_references
        .as_ref()
        .is_some_and(|values| !values.is_empty());
    let omitted = plan
        .evidence
        .not_modified
        .as_ref()
        .is_some_and(|values| !values.is_empty());
    match plan.completeness.as_ref().map(Completeness::as_str) {
        Some(Completeness::COMPLETE) => {
            if references || omitted {
                return Err(invalid(
                    "COMPLETE plans cannot contain evidence gaps",
                    "completeness",
                ));
            }
            if plan.evidence.completeness_proof.is_none() {
                return Err(missing("completenessProof"));
            }
        }
        Some(Completeness::PARTIAL) if profile == EvidenceProfile::Planner => {
            if !references && !omitted {
                return Err(invalid(
                    "PARTIAL plans require uncertainty or omissions",
                    "completeness",
                ));
            }
        }
        Some(_) | None => {}
    }
    Ok(())
}