weavatrix-refactor-plan 0.1.0

Evidence metadata, validation profiles, and canonical fingerprints for Weavatrix refactor plans
Documentation
use super::support::{TextBudget, invalid, too_large};
use crate::{
    NotModified, PlanError, PlanEvidence, RefactorPlanLimits, TypedSubject, UncertainReference,
};
use std::collections::BTreeSet;

pub(super) fn validate_entries(
    evidence: &PlanEvidence,
    operation_count: usize,
    limits: RefactorPlanLimits,
    text: &mut TextBudget,
) -> Result<(), PlanError> {
    let references = evidence.uncertain_references.as_deref().unwrap_or_default();
    let omitted = evidence.not_modified.as_deref().unwrap_or_default();
    let warnings = evidence.warnings.as_deref().unwrap_or_default();
    let scope_entries = evidence.completeness_proof.as_ref().map_or(0, |proof| {
        proof
            .scope
            .roots
            .len()
            .saturating_add(proof.scope.languages.len())
    });
    let total = references
        .len()
        .checked_add(omitted.len())
        .and_then(|value| value.checked_add(warnings.len()))
        .and_then(|value| value.checked_add(scope_entries))
        .ok_or_else(|| too_large("evidence entry count overflow"))?;
    if total > limits.max_evidence_entries {
        return Err(too_large("plan contains too many evidence entries"));
    }
    validate_uncertainties(references, limits, text)?;
    validate_omissions(omitted, operation_count, limits, text)?;
    validate_warnings(warnings, text)
}

fn validate_uncertainties(
    references: &[UncertainReference],
    limits: RefactorPlanLimits,
    text: &mut TextBudget,
) -> Result<(), PlanError> {
    let mut seen = BTreeSet::new();
    for (index, reference) in references.iter().enumerate() {
        let field = format!("uncertainReferences[{index}]");
        let location = validate_locations(
            reference.path.as_deref(),
            reference.file.as_deref(),
            &field,
            limits,
            text,
        )?;
        let has_subject = reference
            .subject
            .as_ref()
            .map(|subject| validate_subject(subject, &field, text))
            .transpose()?
            .is_some();
        if location.is_none() && !has_subject {
            return Err(invalid(
                "uncertainty requires a path/file or typed subject",
                &field,
            ));
        }
        if reference.line == Some(0) {
            return Err(invalid("uncertainty line must be 1-based", &field));
        }
        if let Some(excerpt) = &reference.excerpt {
            text.require(excerpt, &format!("{field}.excerpt"))?;
        }
        if !validate_uncertainty_codes(reference, &field, text)? {
            return Err(invalid("uncertainty requires kind or reason", &field));
        }
        reject_duplicate(
            &mut seen,
            UncertaintyIdentity::new(reference, location),
            &field,
        )?;
    }
    Ok(())
}

fn validate_uncertainty_codes(
    reference: &UncertainReference,
    field: &str,
    text: &mut TextBudget,
) -> Result<bool, PlanError> {
    if let Some(code) = &reference.kind {
        text.code(code.as_str(), &format!("{field}.kind"))?;
    }
    if let Some(code) = &reference.reason {
        text.code(code.as_str(), &format!("{field}.reason"))?;
    }
    Ok(reference.kind.is_some() || reference.reason.is_some())
}

fn validate_omissions(
    entries: &[NotModified],
    operation_count: usize,
    limits: RefactorPlanLimits,
    text: &mut TextBudget,
) -> Result<(), PlanError> {
    let mut seen = BTreeSet::new();
    for (index, entry) in entries.iter().enumerate() {
        let field = format!("notModified[{index}]");
        let location = validate_locations(
            entry.path.as_deref(),
            entry.file.as_deref(),
            &field,
            limits,
            text,
        )?;
        let has_subject = entry
            .subject
            .as_ref()
            .map(|subject| validate_subject(subject, &field, text))
            .transpose()?
            .is_some();
        let has_operation = entry.operation_index.is_some();
        if entry
            .operation_index
            .is_some_and(|index| index as usize >= operation_count)
        {
            return Err(invalid("operationIndex is outside plan operations", &field));
        }
        if location.is_none() && !has_subject && !has_operation {
            return Err(invalid(
                "notModified requires path/file, subject, or operation",
                &field,
            ));
        }
        text.require(&entry.reason, &format!("{field}.reason"))?;
        reject_duplicate(&mut seen, OmissionIdentity::new(entry, location), &field)?;
    }
    Ok(())
}

fn validate_warnings(
    warnings: &[crate::WarningCode],
    text: &mut TextBudget,
) -> Result<(), PlanError> {
    let mut seen = BTreeSet::new();
    for (index, warning) in warnings.iter().enumerate() {
        text.code(warning.as_str(), &format!("warnings[{index}]"))?;
        if !seen.insert(warning.as_str()) {
            return Err(invalid("duplicate warning", &format!("warnings[{index}]")));
        }
    }
    Ok(())
}

fn validate_locations(
    path: Option<&str>,
    file: Option<&str>,
    parent: &str,
    limits: RefactorPlanLimits,
    text: &mut TextBudget,
) -> Result<Option<String>, PlanError> {
    for (name, value) in [("path", path), ("file", file)] {
        if let Some(value) = value {
            text.require(value, &format!("{parent}.{name}"))?;
            crate::validate_plan_path(value, limits.max_path_bytes)
                .map_err(|error| error.at_field(format!("{parent}.{name}")))?;
        }
    }
    let path_key = path.map(crate::portable_path_key);
    let file_key = file.map(crate::portable_path_key);
    if path_key.is_some() && file_key.is_some() && path_key != file_key {
        return Err(invalid(
            "path and legacy file identify different locations",
            parent,
        ));
    }
    Ok(path_key.or(file_key))
}

fn validate_subject(
    subject: &TypedSubject,
    parent: &str,
    text: &mut TextBudget,
) -> Result<(), PlanError> {
    text.code(subject.kind.as_str(), &format!("{parent}.subject.kind"))?;
    text.require(&subject.value, &format!("{parent}.subject.value"))
}

#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct SubjectIdentity<'a> {
    kind: &'a str,
    value: &'a str,
}

impl<'a> From<&'a TypedSubject> for SubjectIdentity<'a> {
    fn from(value: &'a TypedSubject) -> Self {
        Self {
            kind: value.kind.as_str(),
            value: &value.value,
        }
    }
}

#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct UncertaintyIdentity<'a> {
    location: Option<String>,
    line: Option<u32>,
    subject: Option<SubjectIdentity<'a>>,
    kind: Option<&'a str>,
    reason: Option<&'a str>,
    excerpt: Option<&'a str>,
}

impl<'a> UncertaintyIdentity<'a> {
    fn new(value: &'a UncertainReference, location: Option<String>) -> Self {
        Self {
            location,
            line: value.line,
            subject: value.subject.as_ref().map(Into::into),
            kind: value.kind.as_ref().map(crate::UncertaintyCode::as_str),
            reason: value.reason.as_ref().map(crate::UncertaintyCode::as_str),
            excerpt: value.excerpt.as_deref(),
        }
    }
}

#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct OmissionIdentity<'a> {
    location: Option<String>,
    subject: Option<SubjectIdentity<'a>>,
    operation_index: Option<u32>,
    reason: &'a str,
}

impl<'a> OmissionIdentity<'a> {
    fn new(value: &'a NotModified, location: Option<String>) -> Self {
        Self {
            location,
            subject: value.subject.as_ref().map(Into::into),
            operation_index: value.operation_index,
            reason: &value.reason,
        }
    }
}

fn reject_duplicate<T: Ord>(
    seen: &mut BTreeSet<T>,
    value: T,
    field: &str,
) -> Result<(), PlanError> {
    if !seen.insert(value) {
        return Err(invalid("duplicate evidence entry", field));
    }
    Ok(())
}