Skip to main content

weavatrix_edit/
validation.rs

1mod files;
2
3use std::collections::BTreeMap;
4
5use blazingly_json::Value;
6
7use crate::{
8    envelope::{EDIT_PLAN_FIELDS, FILE_EDIT_FIELDS},
9    error::{EditError, ErrorCode},
10    limits::{MAX_PLAN_OPERATION_BYTES, PlanLimits},
11    model::{Completeness, EDIT_PLAN_SCHEMA, EditPlan, FileEdit, TextEdit},
12};
13
14pub(crate) use files::validate_text_edit;
15
16/// Reserved JSON member names for a [`FileEdit`] extension map.
17///
18/// These are exactly the declared wire members of a `FileEdit`, so an
19/// extension key can never silently shadow one.
20pub const FILE_EDIT_RESERVED_EXTENSION_KEYS: &[&str] = &FILE_EDIT_FIELDS;
21
22/// Zero-copy view of one exact file edit set.
23#[derive(Clone, Copy, Debug)]
24pub struct BorrowedFileEdit<'file> {
25    pub path: &'file str,
26    pub sha256: &'file str,
27    pub edits: &'file [TextEdit],
28    pub extensions: &'file BTreeMap<String, Value>,
29    /// Member names which the source envelope owns and extensions may not shadow.
30    pub reserved_extension_keys: &'file [&'file str],
31}
32
33impl<'file> From<&'file FileEdit> for BorrowedFileEdit<'file> {
34    fn from(file: &'file FileEdit) -> Self {
35        Self {
36            path: &file.path,
37            sha256: &file.sha256,
38            edits: &file.edits,
39            extensions: &file.extensions,
40            reserved_extension_keys: FILE_EDIT_RESERVED_EXTENSION_KEYS,
41        }
42    }
43}
44
45/// Owned statistics produced by zero-copy file-edit validation.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub struct EditValidationStats {
48    total_edits: usize,
49    total_text_bytes: usize,
50}
51
52impl EditValidationStats {
53    #[must_use]
54    pub const fn total_edits(self) -> usize {
55        self.total_edits
56    }
57
58    #[must_use]
59    pub const fn total_text_bytes(self) -> usize {
60        self.total_text_bytes
61    }
62}
63
64/// Proof that an edit plan passed structural, evidence, path, and budget checks.
65#[derive(Clone, Copy, Debug)]
66pub struct ValidatedEditPlan<'plan> {
67    plan: &'plan EditPlan,
68    stats: EditValidationStats,
69}
70
71impl<'plan> ValidatedEditPlan<'plan> {
72    #[must_use]
73    pub const fn plan(self) -> &'plan EditPlan {
74        self.plan
75    }
76
77    #[must_use]
78    pub const fn total_edits(self) -> usize {
79        self.stats.total_edits()
80    }
81
82    #[must_use]
83    pub const fn total_text_bytes(self) -> usize {
84        self.stats.total_text_bytes()
85    }
86}
87
88/// Validates a frozen edit-plan envelope and its borrowed file/edit contents.
89pub fn validate_edit_plan(
90    plan: &EditPlan,
91    limits: PlanLimits,
92) -> Result<ValidatedEditPlan<'_>, EditError> {
93    if plan.schema_version != EDIT_PLAN_SCHEMA {
94        return Err(EditError::new(
95            ErrorCode::SchemaMismatch,
96            format!("schemaVersion must be {EDIT_PLAN_SCHEMA}"),
97        ));
98    }
99    files::validate_extension_keys(&plan.extensions, &EDIT_PLAN_FIELDS, ErrorCode::InvalidPlan)?;
100    validate_collection(&plan.operation, plan.files.len(), limits)?;
101    validate_completeness(plan.completeness.as_ref())?;
102    let stats = files::validate_file_views(plan.files.iter().map(BorrowedFileEdit::from), limits)?;
103    Ok(ValidatedEditPlan { plan, stats })
104}
105
106/// Validates arbitrary borrowed file edits with the same engine as [`EditPlan`].
107///
108/// This entry point owns no schema envelope or completeness claim. It validates
109/// the operation label, file/edit structures, paths, hashes, provenance,
110/// uniqueness, and every [`PlanLimits`] budget without cloning edit text.
111pub fn validate_file_edits(
112    operation: &str,
113    files: &[BorrowedFileEdit<'_>],
114    limits: PlanLimits,
115) -> Result<EditValidationStats, EditError> {
116    validate_collection(operation, files.len(), limits)?;
117    files::validate_file_views(files.iter().copied(), limits)
118}
119
120fn validate_collection(
121    operation: &str,
122    file_count: usize,
123    limits: PlanLimits,
124) -> Result<(), EditError> {
125    if operation.is_empty() {
126        return Err(EditError::new(
127            ErrorCode::InvalidPlan,
128            "plan.operation is required",
129        ));
130    }
131    if operation.len() > MAX_PLAN_OPERATION_BYTES {
132        return Err(too_large(format!(
133            "plan.operation exceeds the {MAX_PLAN_OPERATION_BYTES}-byte limit"
134        )));
135    }
136    if file_count == 0 {
137        return Err(EditError::new(
138            ErrorCode::InvalidPlan,
139            "plan.files must be non-empty",
140        ));
141    }
142    if file_count > limits.max_files {
143        return Err(too_large(format!(
144            "plan touches more than {} files",
145            limits.max_files
146        )));
147    }
148    Ok(())
149}
150
151fn validate_completeness(completeness: Option<&Completeness>) -> Result<(), EditError> {
152    let Some(completeness) = completeness else {
153        return Ok(());
154    };
155    if !matches!(
156        completeness.as_str(),
157        Completeness::COMPLETE | Completeness::PARTIAL
158    ) {
159        return Err(EditError::new(
160            ErrorCode::InvalidPlan,
161            "plan.completeness must be COMPLETE or PARTIAL",
162        ));
163    }
164    Ok(())
165}
166
167pub(super) fn too_large(message: impl Into<String>) -> EditError {
168    EditError::new(ErrorCode::PlanTooLarge, message)
169}