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#[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}