1use serde::Deserialize;
2use std::fmt;
3
4#[derive(Debug, Deserialize, Default, Clone)]
5pub struct PatchConfig {
6 #[serde(default)]
7 pub meta: Metadata,
8 #[serde(default)]
9 pub patches: Vec<PatchDefinition>,
10}
11
12impl PatchConfig {
13 pub fn validate(&self) -> Result<(), ValidationError> {
14 let mut issues = Vec::new();
15
16 if self.patches.is_empty() {
17 issues.push(ValidationIssue::EmptyPatchList);
18 }
19
20 for patch in &self.patches {
21 if patch.id.trim().is_empty() {
22 issues.push(ValidationIssue::MissingField {
23 patch_id: None,
24 field: "id",
25 });
26 }
27 if patch.file.trim().is_empty() {
28 issues.push(ValidationIssue::MissingField {
29 patch_id: Some(patch.id.clone()),
30 field: "file",
31 });
32 }
33
34 match &patch.query {
35 Query::Toml {
36 section,
37 key,
38 ensure_absent,
39 ensure_present,
40 } => {
41 if section.as_deref().unwrap_or("").is_empty() && key.is_none() {
42 issues.push(ValidationIssue::MissingField {
43 patch_id: Some(patch.id.clone()),
44 field: "query.section",
45 });
46 }
47 if section.is_none() && key.is_some() {
48 issues.push(ValidationIssue::InvalidCombo {
49 patch_id: Some(patch.id.clone()),
50 message: "toml query with key requires section".to_string(),
51 });
52 }
53 if *ensure_absent && *ensure_present {
54 issues.push(ValidationIssue::InvalidCombo {
55 patch_id: Some(patch.id.clone()),
56 message: "ensure_absent and ensure_present cannot both be true"
57 .to_string(),
58 });
59 }
60 }
61 Query::AstGrep { pattern } | Query::TreeSitter { pattern } => {
62 if pattern.trim().is_empty() {
63 issues.push(ValidationIssue::MissingField {
64 patch_id: Some(patch.id.clone()),
65 field: "query.pattern",
66 });
67 }
68 }
69 Query::Text {
70 search,
71 fuzzy_threshold,
72 fuzzy_expansion,
73 } => {
74 if search.trim().is_empty() {
75 issues.push(ValidationIssue::MissingField {
76 patch_id: Some(patch.id.clone()),
77 field: "query.search",
78 });
79 }
80 if let Some(threshold) = fuzzy_threshold {
81 if !(*threshold >= 0.0 && *threshold <= 1.0) {
82 issues.push(ValidationIssue::InvalidCombo {
83 patch_id: Some(patch.id.clone()),
84 message: format!(
85 "fuzzy_threshold must be between 0.0 and 1.0, got {}",
86 threshold
87 ),
88 });
89 }
90 }
91 if let Some(expansion) = fuzzy_expansion {
92 if *expansion > 200 {
93 issues.push(ValidationIssue::InvalidCombo {
94 patch_id: Some(patch.id.clone()),
95 message: format!(
96 "fuzzy_expansion must be <= 200, got {}",
97 expansion
98 ),
99 });
100 }
101 }
102 }
103 }
104
105 match &patch.operation {
106 Operation::InsertSection { text, positioning } => {
107 if text.trim().is_empty() {
108 issues.push(ValidationIssue::MissingField {
109 patch_id: Some(patch.id.clone()),
110 field: "operation.text",
111 });
112 }
113 if let Err(message) = positioning.validate() {
114 issues.push(ValidationIssue::InvalidCombo {
115 patch_id: Some(patch.id.clone()),
116 message,
117 });
118 }
119 }
120 Operation::AppendSection { text } => {
121 if text.trim().is_empty() {
122 issues.push(ValidationIssue::MissingField {
123 patch_id: Some(patch.id.clone()),
124 field: "operation.text",
125 });
126 }
127 }
128 Operation::ReplaceValue { value } => {
129 if value.trim().is_empty() {
130 issues.push(ValidationIssue::MissingField {
131 patch_id: Some(patch.id.clone()),
132 field: "operation.value",
133 });
134 }
135 if !patch.query.is_key_query() {
136 issues.push(ValidationIssue::InvalidCombo {
137 patch_id: Some(patch.id.clone()),
138 message: "replace_value requires toml key query".to_string(),
139 });
140 }
141 }
142 Operation::ReplaceKey { new_key } => {
143 if new_key.trim().is_empty() {
144 issues.push(ValidationIssue::MissingField {
145 patch_id: Some(patch.id.clone()),
146 field: "operation.new_key",
147 });
148 }
149 if !patch.query.is_key_query() {
150 issues.push(ValidationIssue::InvalidCombo {
151 patch_id: Some(patch.id.clone()),
152 message: "replace_key requires toml key query".to_string(),
153 });
154 }
155 }
156 Operation::DeleteSection => {
157 if !patch.query.is_section_query() {
158 issues.push(ValidationIssue::InvalidCombo {
159 patch_id: Some(patch.id.clone()),
160 message: "delete_section requires toml section query".to_string(),
161 });
162 }
163 }
164 Operation::Replace { text } => {
165 if text.trim().is_empty() {
166 issues.push(ValidationIssue::MissingField {
167 patch_id: Some(patch.id.clone()),
168 field: "operation.text",
169 });
170 }
171 }
172 Operation::Delete { insert_comment: _ } => {}
173 }
174
175 let query_kind = match &patch.query {
176 Query::Toml { .. } => "toml",
177 Query::AstGrep { .. } => "ast-grep",
178 Query::TreeSitter { .. } => "tree-sitter",
179 Query::Text { .. } => "text",
180 };
181 let operation_kind = match &patch.operation {
182 Operation::InsertSection { .. } => "insert-section",
183 Operation::AppendSection { .. } => "append-section",
184 Operation::ReplaceValue { .. } => "replace-value",
185 Operation::DeleteSection => "delete-section",
186 Operation::ReplaceKey { .. } => "replace-key",
187 Operation::Replace { .. } => "replace",
188 Operation::Delete { .. } => "delete",
189 };
190
191 let supports_combo = matches!(
192 (&patch.query, &patch.operation),
193 (Query::Text { .. }, Operation::Replace { .. })
194 | (
195 Query::AstGrep { .. } | Query::TreeSitter { .. },
196 Operation::Replace { .. } | Operation::Delete { .. }
197 )
198 | (
199 Query::Toml { .. },
200 Operation::InsertSection { .. }
201 | Operation::AppendSection { .. }
202 | Operation::ReplaceValue { .. }
203 | Operation::DeleteSection
204 | Operation::ReplaceKey { .. }
205 )
206 );
207
208 if !supports_combo {
209 issues.push(ValidationIssue::InvalidCombo {
210 patch_id: Some(patch.id.clone()),
211 message: format!(
212 "query type '{query_kind}' does not support operation '{operation_kind}'"
213 ),
214 });
215 }
216 }
217
218 if issues.is_empty() {
219 Ok(())
220 } else {
221 Err(ValidationError { issues })
222 }
223 }
224}
225
226#[derive(Debug, Deserialize, Default, Clone)]
227pub struct Metadata {
228 #[serde(default)]
229 pub name: String,
230 #[serde(default)]
231 pub description: Option<String>,
232 #[serde(default)]
233 pub version_range: Option<String>,
234 #[serde(default)]
235 pub workspace_relative: bool,
236}
237
238#[derive(Debug, Deserialize, Clone)]
239pub struct PatchDefinition {
240 pub id: String,
241 pub file: String,
242 pub query: Query,
243 pub operation: Operation,
244 #[serde(default)]
245 pub verify: Option<Verify>,
246 #[serde(default)]
247 pub constraint: Option<Constraints>,
248 #[serde(default)]
251 pub version: Option<String>,
252}
253
254#[derive(Debug, Deserialize, Clone)]
255#[serde(tag = "type", rename_all = "kebab-case")]
256pub enum Query {
257 Toml {
258 #[serde(default)]
259 section: Option<String>,
260 #[serde(default)]
261 key: Option<String>,
262 #[serde(default)]
263 ensure_absent: bool,
264 #[serde(default)]
265 ensure_present: bool,
266 },
267 AstGrep {
268 pattern: String,
269 },
270 TreeSitter {
271 pattern: String,
272 },
273 Text {
275 search: String,
277 #[serde(default)]
281 fuzzy_threshold: Option<f64>,
282 #[serde(default)]
286 fuzzy_expansion: Option<usize>,
287 },
288}
289
290impl Query {
291 pub fn is_key_query(&self) -> bool {
292 matches!(self, Query::Toml { key: Some(_), .. })
293 }
294
295 pub fn is_section_query(&self) -> bool {
296 matches!(
297 self,
298 Query::Toml {
299 section: Some(_),
300 ..
301 }
302 )
303 }
304}
305
306#[derive(Debug, Deserialize, Clone)]
307#[serde(tag = "type", rename_all = "kebab-case")]
308pub enum Operation {
309 InsertSection {
310 text: String,
311 #[serde(flatten)]
312 positioning: Positioning,
313 },
314 AppendSection {
315 text: String,
316 },
317 ReplaceValue {
318 value: String,
319 },
320 DeleteSection,
321 ReplaceKey {
322 new_key: String,
323 },
324 Replace {
325 text: String,
326 },
327 Delete {
328 #[serde(default)]
329 insert_comment: Option<String>,
330 },
331}
332
333#[derive(Debug, Deserialize, Clone, Default)]
334pub struct Positioning {
335 #[serde(default)]
336 pub after_section: Option<String>,
337 #[serde(default)]
338 pub before_section: Option<String>,
339 #[serde(default)]
340 pub at_end: bool,
341 #[serde(default)]
342 pub at_beginning: bool,
343}
344
345impl Positioning {
346 pub fn validate(&self) -> Result<(), String> {
347 let mut count = 0;
348 if self.after_section.is_some() {
349 count += 1;
350 }
351 if self.before_section.is_some() {
352 count += 1;
353 }
354 if self.at_end {
355 count += 1;
356 }
357 if self.at_beginning {
358 count += 1;
359 }
360 if count > 1 {
361 return Err("only one positioning directive is allowed".to_string());
362 }
363 Ok(())
364 }
365
366 pub fn relative_position(&self) -> RelativePosition {
367 if let Some(path) = &self.after_section {
368 return RelativePosition::After(path.clone());
369 }
370 if let Some(path) = &self.before_section {
371 return RelativePosition::Before(path.clone());
372 }
373 if self.at_beginning {
374 return RelativePosition::AtBeginning;
375 }
376 RelativePosition::AtEnd
377 }
378}
379
380#[derive(Debug, Deserialize, Clone)]
381pub enum RelativePosition {
382 After(String),
383 Before(String),
384 AtEnd,
385 AtBeginning,
386}
387
388#[derive(Debug, Deserialize, Clone, Default)]
389pub struct Constraints {
390 #[serde(default)]
391 pub ensure_absent: bool,
392 #[serde(default)]
393 pub ensure_present: bool,
394 #[serde(default)]
395 pub function_context: Option<String>,
396}
397
398#[derive(Debug, Deserialize, Clone)]
399#[serde(tag = "method", rename_all = "snake_case")]
400pub enum Verify {
401 ExactMatch {
402 expected_text: String,
403 },
404 Hash {
405 algorithm: Option<HashAlgorithm>,
406 expected: String,
407 },
408}
409
410#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
411#[serde(rename_all = "kebab-case")]
412pub enum HashAlgorithm {
413 Xxh3,
414}
415
416#[derive(Debug, Clone)]
417pub struct ValidationError {
418 pub issues: Vec<ValidationIssue>,
419}
420
421impl fmt::Display for ValidationError {
422 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
423 for (idx, issue) in self.issues.iter().enumerate() {
424 if idx > 0 {
425 writeln!(f)?;
426 }
427 write!(f, "{issue}")?;
428 }
429 Ok(())
430 }
431}
432
433impl std::error::Error for ValidationError {}
434
435#[derive(Debug, Clone)]
436pub enum ValidationIssue {
437 EmptyPatchList,
438 MissingField {
439 patch_id: Option<String>,
440 field: &'static str,
441 },
442 InvalidCombo {
443 patch_id: Option<String>,
444 message: String,
445 },
446}
447
448impl fmt::Display for ValidationIssue {
449 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
450 match self {
451 ValidationIssue::EmptyPatchList => write!(f, "patch config contains no patches"),
452 ValidationIssue::MissingField { patch_id, field } => match patch_id {
453 Some(id) => write!(f, "patch '{id}' missing required field '{field}'"),
454 None => write!(f, "patch missing required field '{field}'"),
455 },
456 ValidationIssue::InvalidCombo { patch_id, message } => match patch_id {
457 Some(id) => write!(f, "patch '{id}' has invalid configuration: {message}"),
458 None => write!(f, "invalid patch configuration: {message}"),
459 },
460 }
461 }
462}