Skip to main content

weavatrix_edit/
validation.rs

1use std::collections::BTreeSet;
2
3use blazingly_json::Value;
4
5use crate::{
6    error::{EditError, ErrorCode},
7    limits::{MAX_PLAN_OPERATION_BYTES, PlanLimits},
8    model::{Completeness, EDIT_PLAN_SCHEMA, EditPlan, FileEdit, TextEdit},
9    path::{portable_path_key, validate_plan_path},
10};
11
12/// Proof that an edit plan passed structural, evidence, path, and budget checks.
13#[derive(Clone, Copy, Debug)]
14pub struct ValidatedEditPlan<'plan> {
15    plan: &'plan EditPlan,
16    total_edits: usize,
17    total_text_bytes: usize,
18}
19
20impl<'plan> ValidatedEditPlan<'plan> {
21    #[must_use]
22    pub const fn plan(self) -> &'plan EditPlan {
23        self.plan
24    }
25
26    #[must_use]
27    pub const fn total_edits(self) -> usize {
28        self.total_edits
29    }
30
31    #[must_use]
32    pub const fn total_text_bytes(self) -> usize {
33        self.total_text_bytes
34    }
35}
36
37pub fn validate_edit_plan(
38    plan: &EditPlan,
39    limits: PlanLimits,
40) -> Result<ValidatedEditPlan<'_>, EditError> {
41    if plan.schema_version != EDIT_PLAN_SCHEMA {
42        return Err(EditError::new(
43            ErrorCode::SchemaMismatch,
44            format!("schemaVersion must be {EDIT_PLAN_SCHEMA}"),
45        ));
46    }
47    validate_extension_keys(
48        &plan.extensions,
49        &["schemaVersion", "operation", "files", "completeness"],
50        ErrorCode::InvalidPlan,
51    )?;
52    if plan.operation.is_empty() {
53        return Err(EditError::new(
54            ErrorCode::InvalidPlan,
55            "plan.operation is required",
56        ));
57    }
58    if plan.operation.len() > MAX_PLAN_OPERATION_BYTES {
59        return Err(too_large(format!(
60            "plan.operation exceeds the {MAX_PLAN_OPERATION_BYTES}-byte limit"
61        )));
62    }
63    if plan.files.is_empty() {
64        return Err(EditError::new(
65            ErrorCode::InvalidPlan,
66            "plan.files must be non-empty",
67        ));
68    }
69    if plan.files.len() > limits.max_files {
70        return Err(too_large(format!(
71            "plan touches more than {} files",
72            limits.max_files
73        )));
74    }
75    validate_completeness(plan.completeness.as_ref())?;
76
77    let mut exact_paths = BTreeSet::new();
78    let mut portable_paths = BTreeSet::new();
79    let mut total_edits = 0_usize;
80    let mut total_text_bytes = 0_usize;
81
82    for (file_index, file) in plan.files.iter().enumerate() {
83        validate_file(file, file_index, limits)?;
84        if !exact_paths.insert(file.path.as_str()) {
85            return Err(EditError::new(
86                ErrorCode::InvalidPlan,
87                format!("duplicate file entry: {}", file.path),
88            )
89            .at_file(file_index));
90        }
91        if !portable_paths.insert(portable_path_key(&file.path)) {
92            return Err(EditError::new(
93                ErrorCode::InvalidPlan,
94                format!(
95                    "file path aliases another entry on a portable worktree: {}",
96                    file.path
97                ),
98            )
99            .at_file(file_index));
100        }
101        total_edits = total_edits
102            .checked_add(file.edits.len())
103            .ok_or_else(|| too_large("total edit count overflow"))?;
104        if total_edits > limits.max_total_edits {
105            return Err(too_large(format!(
106                "plan contains more than {} total edits",
107                limits.max_total_edits
108            )));
109        }
110        for edit in &file.edits {
111            total_text_bytes = total_text_bytes
112                .checked_add(edit.before.len())
113                .and_then(|size| size.checked_add(edit.after.len()))
114                .ok_or_else(|| too_large("total edit text size overflow"))?;
115            if total_text_bytes > limits.max_total_text_bytes {
116                return Err(too_large(format!(
117                    "plan edit text exceeds the {}-byte limit",
118                    limits.max_total_text_bytes
119                )));
120            }
121        }
122    }
123
124    Ok(ValidatedEditPlan {
125        plan,
126        total_edits,
127        total_text_bytes,
128    })
129}
130
131pub(crate) fn validate_text_edit(edit: &TextEdit, index: usize) -> Result<(), EditError> {
132    validate_extension_keys(
133        &edit.extensions,
134        &[
135            "startLine",
136            "startChar",
137            "endLine",
138            "endChar",
139            "before",
140            "after",
141            "provenance",
142        ],
143        ErrorCode::InvalidEdit,
144    )
145    .map_err(|error| error.at_edit(index))?;
146    let range = edit.range();
147    if range.start.line == 0 || range.end.line == 0 {
148        return Err(invalid_edit("lines are 1-based", index));
149    }
150    if range.end < range.start {
151        return Err(invalid_edit("edit end precedes its start", index));
152    }
153    if edit.before == edit.after {
154        return Err(invalid_edit("before and after are identical", index));
155    }
156    if !edit.provenance.is_applicable() {
157        return Err(
158            EditError::new(ErrorCode::UnprovenEdit, "edit provenance is not applicable")
159                .at_edit(index),
160        );
161    }
162    Ok(())
163}
164
165fn validate_file(file: &FileEdit, file_index: usize, limits: PlanLimits) -> Result<(), EditError> {
166    validate_extension_keys(
167        &file.extensions,
168        &["path", "sha256", "edits"],
169        ErrorCode::InvalidFile,
170    )
171    .map_err(|error| error.at_file(file_index))?;
172    validate_plan_path(&file.path, limits.max_path_bytes)
173        .map_err(|error| error.at_file(file_index))?;
174    if !valid_sha256(&file.sha256) {
175        return Err(EditError::new(
176            ErrorCode::InvalidFile,
177            "sha256 must be 64 lowercase hexadecimal characters",
178        )
179        .at_file(file_index));
180    }
181    if file.edits.is_empty() {
182        return Err(
183            EditError::new(ErrorCode::InvalidFile, "file edits must be non-empty")
184                .at_file(file_index),
185        );
186    }
187    if file.edits.len() > limits.max_edits_per_file {
188        return Err(too_large(format!(
189            "{} contains more than {} edits",
190            file.path, limits.max_edits_per_file
191        ))
192        .at_file(file_index));
193    }
194    for (edit_index, edit) in file.edits.iter().enumerate() {
195        validate_text_edit(edit, edit_index).map_err(|error| error.at_file(file_index))?;
196    }
197    Ok(())
198}
199
200fn validate_completeness(completeness: Option<&Completeness>) -> Result<(), EditError> {
201    let Some(completeness) = completeness else {
202        return Ok(());
203    };
204    if !matches!(
205        completeness.as_str(),
206        Completeness::COMPLETE | Completeness::PARTIAL
207    ) {
208        return Err(EditError::new(
209            ErrorCode::InvalidPlan,
210            "plan.completeness must be COMPLETE or PARTIAL",
211        ));
212    }
213    Ok(())
214}
215
216fn valid_sha256(value: &str) -> bool {
217    value.len() == 64
218        && value
219            .bytes()
220            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
221}
222
223fn invalid_edit(message: impl Into<String>, index: usize) -> EditError {
224    EditError::new(ErrorCode::InvalidEdit, message).at_edit(index)
225}
226
227fn too_large(message: impl Into<String>) -> EditError {
228    EditError::new(ErrorCode::PlanTooLarge, message)
229}
230
231fn validate_extension_keys(
232    extensions: &std::collections::BTreeMap<String, Value>,
233    reserved: &[&str],
234    code: ErrorCode,
235) -> Result<(), EditError> {
236    if let Some(key) = extensions
237        .keys()
238        .find(|key| reserved.contains(&key.as_str()))
239    {
240        return Err(EditError::new(
241            code,
242            format!("extension field {key:?} collides with a reserved field"),
243        ));
244    }
245    Ok(())
246}