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(())
}