Skip to main content

codex_patcher/config/
schema.rs

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    /// Per-patch version constraint (e.g., ">=0.105.0, <0.108.0").
249    /// If specified, patch is skipped when workspace version doesn't match.
250    #[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    /// Simple text search - finds exact string match (with optional fuzzy fallback)
274    Text {
275        /// The exact text to search for
276        search: String,
277        /// Optional fuzzy match threshold (0.0-1.0). When set, enables fuzzy
278        /// matching as a fallback when exact match fails. Higher values require
279        /// closer matches. Typical values: 0.85-0.95. Default: None (exact only).
280        #[serde(default)]
281        fuzzy_threshold: Option<f64>,
282        /// Max lines to expand fuzzy window beyond needle size.
283        /// Handles cases where code was inserted inside the needle's span.
284        /// Default: None (fixed window = needle size, current behavior).
285        #[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}