Skip to main content

codex_patcher/config/
applicator.rs

1//! Patch applicator - applies patch definitions with idempotency checks
2//!
3//! This module provides high-level patch application that:
4//! - Filters patches by version constraints
5//! - Checks if patches are already applied
6//! - Applies patches using the appropriate locator (ast-grep, tree-sitter, toml)
7//! - Reports detailed results for each patch
8
9use crate::config::schema::{Operation, PatchConfig, PatchDefinition, Positioning, Query};
10use crate::config::version::{matches_requirement, VersionError};
11use crate::edit::{Edit, EditError, EditResult, EditVerification};
12use crate::sg::PatternMatcher;
13use crate::toml::{
14    Constraints, KeyPath, SectionPath, TomlEditor, TomlOperation, TomlPlan, TomlQuery,
15};
16use crate::ts::StructuralTarget;
17use std::fmt;
18use std::fs;
19use std::path::{Path, PathBuf};
20
21/// Check if a patch should be skipped based on its per-patch version constraint.
22/// Returns `Some(reason)` if the patch should be skipped, `None` if it should be applied.
23fn check_patch_version(
24    patch: &PatchDefinition,
25    workspace_version: &str,
26) -> Result<Option<String>, ApplicationError> {
27    let version_req = match patch.version.as_deref() {
28        Some(r) => r,
29        None => return Ok(None),
30    };
31    match matches_requirement(workspace_version, Some(version_req)) {
32        Ok(true) => Ok(None),
33        Ok(false) => Ok(Some(format!(
34            "patch version {} not satisfied by workspace {}",
35            version_req, workspace_version
36        ))),
37        Err(e) => Err(ApplicationError::Version(e)),
38    }
39}
40
41/// Result of applying a single patch
42#[derive(Debug, Clone, PartialEq, Eq)]
43#[must_use = "PatchResult should be checked for success/failure"]
44pub enum PatchResult {
45    /// Patch was successfully applied
46    Applied { file: PathBuf },
47    /// Patch was already applied (idempotent check passed)
48    AlreadyApplied { file: PathBuf },
49    /// Patch was skipped due to version constraint
50    SkippedVersion { reason: String },
51    /// Patch failed to apply
52    Failed { file: PathBuf, reason: String },
53}
54
55impl fmt::Display for PatchResult {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            PatchResult::Applied { file } => {
59                write!(f, "Applied patch to {}", file.display())
60            }
61            PatchResult::AlreadyApplied { file } => {
62                write!(f, "Already applied to {}", file.display())
63            }
64            PatchResult::SkippedVersion { reason } => {
65                write!(f, "Skipped (version): {}", reason)
66            }
67            PatchResult::Failed { file, reason } => {
68                write!(f, "Failed on {}: {}", file.display(), reason)
69            }
70        }
71    }
72}
73
74/// Errors during patch application
75#[derive(Debug)]
76pub enum ApplicationError {
77    /// Version filtering error
78    Version(VersionError),
79    /// File I/O error
80    Io {
81        path: PathBuf,
82        source: std::io::Error,
83    },
84    /// Edit application error
85    Edit(EditError),
86    /// Query matched multiple locations (ambiguous)
87    AmbiguousMatch { file: PathBuf, count: usize },
88    /// Query matched no locations
89    NoMatch { file: PathBuf },
90    /// TOML operation failed
91    TomlOperation { file: PathBuf, reason: String },
92}
93
94impl fmt::Display for ApplicationError {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            ApplicationError::Version(e) => write!(f, "version error: {}", e),
98            ApplicationError::Io { path, source } => {
99                write!(f, "I/O error on {}: {}", path.display(), source)
100            }
101            ApplicationError::Edit(e) => write!(f, "edit error: {}", e),
102            ApplicationError::AmbiguousMatch { file, count } => {
103                write!(
104                    f,
105                    "ambiguous query match in {} ({} matches, expected 1)",
106                    file.display(),
107                    count
108                )
109            }
110            ApplicationError::NoMatch { file } => {
111                write!(f, "query matched no locations in {}", file.display())
112            }
113            ApplicationError::TomlOperation { file, reason } => {
114                write!(f, "TOML operation failed on {}: {}", file.display(), reason)
115            }
116        }
117    }
118}
119
120impl std::error::Error for ApplicationError {
121    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
122        match self {
123            ApplicationError::Version(e) => Some(e),
124            ApplicationError::Io { source, .. } => Some(source),
125            ApplicationError::Edit(e) => Some(e),
126            _ => None,
127        }
128    }
129}
130
131impl From<VersionError> for ApplicationError {
132    fn from(e: VersionError) -> Self {
133        ApplicationError::Version(e)
134    }
135}
136
137impl From<EditError> for ApplicationError {
138    fn from(e: EditError) -> Self {
139        ApplicationError::Edit(e)
140    }
141}
142
143fn skip_all_patches(
144    config: &PatchConfig,
145    reason: String,
146) -> Vec<(String, Result<PatchResult, ApplicationError>)> {
147    config
148        .patches
149        .iter()
150        .map(|patch| {
151            (
152                patch.id.clone(),
153                Ok(PatchResult::SkippedVersion {
154                    reason: reason.clone(),
155                }),
156            )
157        })
158        .collect()
159}
160
161fn error_all_patches(
162    config: &PatchConfig,
163    e: VersionError,
164) -> Vec<(String, Result<PatchResult, ApplicationError>)> {
165    config
166        .patches
167        .iter()
168        .map(|patch| (patch.id.clone(), Err(ApplicationError::Version(e.clone()))))
169        .collect()
170}
171
172/// Apply a patch configuration to a workspace
173///
174/// # Arguments
175///
176/// * `config` - The patch configuration to apply
177/// * `workspace_root` - Root directory of the workspace
178/// * `workspace_version` - Version of the workspace (e.g., "0.88.0")
179///
180/// # Returns
181///
182/// A vector of results, one per patch in the configuration
183pub fn apply_patches(
184    config: &PatchConfig,
185    workspace_root: &Path,
186    workspace_version: &str,
187) -> Vec<(String, Result<PatchResult, ApplicationError>)> {
188    match matches_requirement(workspace_version, config.meta.version_range.as_deref()) {
189        Ok(true) => apply_patches_batched(config, workspace_root, workspace_version),
190        Ok(false) => {
191            let req = config.meta.version_range.as_deref().unwrap_or("").trim();
192            let reason = if req.is_empty() {
193                format!("workspace version {workspace_version} does not satisfy patch version constraints")
194            } else {
195                format!(
196                    "workspace version {workspace_version} does not satisfy version_range {req}"
197                )
198            };
199            skip_all_patches(config, reason)
200        }
201        Err(e) => error_all_patches(config, e),
202    }
203}
204
205/// Check patch status without mutating the workspace.
206///
207/// This mirrors `apply_patches` result semantics (`Applied` means "would apply"),
208/// while running all edit operations against temporary files.
209pub fn check_patches(
210    config: &PatchConfig,
211    workspace_root: &Path,
212    workspace_version: &str,
213) -> Vec<(String, Result<PatchResult, ApplicationError>)> {
214    match matches_requirement(workspace_version, config.meta.version_range.as_deref()) {
215        Ok(true) => check_patches_batched(config, workspace_root, workspace_version),
216        Ok(false) => {
217            let req = config.meta.version_range.as_deref().unwrap_or("").trim();
218            let reason = if req.is_empty() {
219                format!("workspace version {workspace_version} does not satisfy patch version constraints")
220            } else {
221                format!(
222                    "workspace version {workspace_version} does not satisfy version_range {req}"
223                )
224            };
225            skip_all_patches(config, reason)
226        }
227        Err(e) => error_all_patches(config, e),
228    }
229}
230
231/// Read-only status evaluation that groups patches by file.
232fn check_patches_batched(
233    config: &PatchConfig,
234    workspace_root: &Path,
235    workspace_version: &str,
236) -> Vec<(String, Result<PatchResult, ApplicationError>)> {
237    use std::collections::HashMap;
238
239    let mut patches_by_file: HashMap<PathBuf, Vec<&PatchDefinition>> = HashMap::new();
240
241    for patch in &config.patches {
242        let file_path = if config.meta.workspace_relative {
243            workspace_root.join(&patch.file)
244        } else {
245            PathBuf::from(&patch.file)
246        };
247        patches_by_file.entry(file_path).or_default().push(patch);
248    }
249
250    let mut all_results = Vec::new();
251
252    for (file_path, patches) in patches_by_file {
253        if !file_path.exists() {
254            for patch in patches {
255                all_results.push((
256                    patch.id.clone(),
257                    Err(ApplicationError::NoMatch {
258                        file: file_path.clone(),
259                    }),
260                ));
261            }
262            continue;
263        }
264
265        let content = match fs::read_to_string(&file_path) {
266            Ok(c) => c,
267            Err(source) => {
268                let kind = source.kind();
269                let msg = source.to_string();
270                for patch in patches {
271                    all_results.push((
272                        patch.id.clone(),
273                        Err(ApplicationError::Io {
274                            path: file_path.clone(),
275                            source: std::io::Error::new(kind, msg.clone()),
276                        }),
277                    ));
278                }
279                continue;
280            }
281        };
282
283        let mut edits_with_ids = Vec::new();
284        let mut immediate_results = Vec::new();
285
286        for patch in patches {
287            match check_patch_version(patch, workspace_version) {
288                Err(e) => {
289                    immediate_results.push((patch.id.clone(), Err(e)));
290                    continue;
291                }
292                Ok(Some(reason)) => {
293                    immediate_results
294                        .push((patch.id.clone(), Ok(PatchResult::SkippedVersion { reason })));
295                    continue;
296                }
297                Ok(None) => {}
298            }
299
300            match compute_edit_for_patch(patch, &file_path, &content) {
301                Ok(edit) => edits_with_ids.push((patch.id.clone(), edit)),
302                Err(e) => immediate_results.push((patch.id.clone(), Err(e))),
303            }
304        }
305
306        if !edits_with_ids.is_empty() {
307            // Sort to match apply_batch's internal descending byte_start order so
308            // the zip in simulate_batch_edits correctly pairs IDs with results.
309            edits_with_ids.sort_by(|(_, a), (_, b)| b.byte_start.cmp(&a.byte_start));
310
311            match simulate_batch_edits(&file_path, &content, &edits_with_ids) {
312                Ok(results) => all_results.extend(results),
313                Err(err) => {
314                    let err_clone = err.clone();
315                    for (patch_id, _) in &edits_with_ids {
316                        all_results.push((
317                            patch_id.clone(),
318                            Err(ApplicationError::Edit(err_clone.clone())),
319                        ));
320                    }
321                }
322            }
323        }
324
325        all_results.extend(immediate_results);
326    }
327
328    // Restore config.patches order — HashMap iteration is unordered.
329    let patch_order: std::collections::HashMap<&str, usize> = config
330        .patches
331        .iter()
332        .enumerate()
333        .map(|(i, p)| (p.id.as_str(), i))
334        .collect();
335    all_results.sort_by_key(|(id, _)| patch_order.get(id.as_str()).copied().unwrap_or(usize::MAX));
336
337    all_results
338}
339
340/// Simulate a batch of edits against a temporary file, preserving result semantics.
341#[allow(clippy::type_complexity)]
342fn simulate_batch_edits(
343    file_path: &Path,
344    content: &str,
345    edits_with_ids: &[(String, Edit)],
346) -> Result<Vec<(String, Result<PatchResult, ApplicationError>)>, EditError> {
347    let temp_dir = tempfile::tempdir().map_err(EditError::Io)?;
348    let temp_file = temp_dir.path().join("patch-check.tmp");
349    fs::write(&temp_file, content).map_err(EditError::Io)?;
350
351    let simulated_edits: Vec<Edit> = edits_with_ids
352        .iter()
353        .map(|(_, edit)| {
354            let mut simulated = edit.clone();
355            simulated.file = temp_file.clone();
356            simulated
357        })
358        .collect();
359
360    let results = Edit::apply_batch(simulated_edits)?;
361
362    Ok(edits_with_ids
363        .iter()
364        .zip(results.iter())
365        .map(|((patch_id, _), result)| {
366            let patch_result = match result {
367                EditResult::Applied { .. } => Ok(PatchResult::Applied {
368                    file: file_path.to_path_buf(),
369                }),
370                EditResult::AlreadyApplied { .. } => Ok(PatchResult::AlreadyApplied {
371                    file: file_path.to_path_buf(),
372                }),
373            };
374            (patch_id.clone(), patch_result)
375        })
376        .collect())
377}
378
379/// Optimized batch application that groups patches by file.
380///
381/// All 4 query types (Text, AstGrep, TreeSitter, Toml) flow through
382/// `compute_edit_for_patch` → `Edit::apply_batch`. Each file is read once,
383/// all edits are computed, then applied atomically in a single write.
384fn apply_patches_batched(
385    config: &PatchConfig,
386    workspace_root: &Path,
387    workspace_version: &str,
388) -> Vec<(String, Result<PatchResult, ApplicationError>)> {
389    use std::collections::HashMap;
390
391    let mut patches_by_file: HashMap<PathBuf, Vec<&PatchDefinition>> = HashMap::new();
392
393    for patch in &config.patches {
394        let file_path = if config.meta.workspace_relative {
395            workspace_root.join(&patch.file)
396        } else {
397            PathBuf::from(&patch.file)
398        };
399        patches_by_file.entry(file_path).or_default().push(patch);
400    }
401
402    let mut all_results = Vec::new();
403
404    for (file_path, patches) in patches_by_file {
405        // Drain version-skipped patches before the file-existence check so a
406        // patch targeting a file removed in a newer version returns
407        // SkippedVersion instead of NoMatch.
408        let patches: Vec<_> = patches
409            .into_iter()
410            .filter(|patch| {
411                match check_patch_version(patch, workspace_version) {
412                    Err(e) => {
413                        all_results.push((patch.id.clone(), Err(e)));
414                        false
415                    }
416                    Ok(Some(reason)) => {
417                        all_results.push((
418                            patch.id.clone(),
419                            Ok(PatchResult::SkippedVersion { reason }),
420                        ));
421                        false
422                    }
423                    Ok(None) => true,
424                }
425            })
426            .collect();
427
428        if patches.is_empty() {
429            continue;
430        }
431
432        if !file_path.exists() {
433            for patch in patches {
434                all_results.push((
435                    patch.id.clone(),
436                    Err(ApplicationError::NoMatch {
437                        file: file_path.clone(),
438                    }),
439                ));
440            }
441            continue;
442        }
443
444        let content = match fs::read_to_string(&file_path) {
445            Ok(c) => c,
446            Err(source) => {
447                // Preserve kind + message; std::io::Error is not Clone so we
448                // reconstruct one per patch from the original error's text.
449                let kind = source.kind();
450                let msg = source.to_string();
451                for patch in patches {
452                    all_results.push((
453                        patch.id.clone(),
454                        Err(ApplicationError::Io {
455                            path: file_path.clone(),
456                            source: std::io::Error::new(kind, msg.clone()),
457                        }),
458                    ));
459                }
460                continue;
461            }
462        };
463
464        let mut edits_with_ids = Vec::new();
465        let mut patch_errors = Vec::new();
466
467        for patch in patches {
468            match compute_edit_for_patch(patch, &file_path, &content) {
469                Ok(edit) => edits_with_ids.push((patch.id.clone(), edit)),
470                Err(e) => patch_errors.push((patch.id.clone(), Err(e))),
471            }
472        }
473
474        if !edits_with_ids.is_empty() {
475            // apply_batch sorts by byte_start descending internally.
476            // Sort edits_with_ids the same way so zip() aligns correctly.
477            edits_with_ids.sort_by(|(_, a), (_, b)| b.byte_start.cmp(&a.byte_start));
478
479            let edits: Vec<Edit> = edits_with_ids.iter().map(|(_, e)| e.clone()).collect();
480
481            match Edit::apply_batch(edits) {
482                Ok(results) => {
483                    for ((patch_id, _), result) in edits_with_ids.iter().zip(results.iter()) {
484                        let patch_result = match result {
485                            EditResult::Applied { .. } => Ok(PatchResult::Applied {
486                                file: file_path.clone(),
487                            }),
488                            EditResult::AlreadyApplied { .. } => Ok(PatchResult::AlreadyApplied {
489                                file: file_path.clone(),
490                            }),
491                        };
492                        all_results.push((patch_id.clone(), patch_result));
493                    }
494                }
495                Err(e) => {
496                    // Reconstruct per-patch errors using Clone (kind+message preserved).
497                    let e_clone = e.clone();
498                    for (patch_id, _) in &edits_with_ids {
499                        all_results.push((
500                            patch_id.clone(),
501                            Err(ApplicationError::Edit(e_clone.clone())),
502                        ));
503                    }
504                }
505            }
506        }
507
508        all_results.extend(patch_errors);
509    }
510
511    // Restore config.patches order — HashMap iteration is unordered.
512    let patch_order: std::collections::HashMap<&str, usize> = config
513        .patches
514        .iter()
515        .enumerate()
516        .map(|(i, p)| (p.id.as_str(), i))
517        .collect();
518    all_results.sort_by_key(|(id, _)| patch_order.get(id.as_str()).copied().unwrap_or(usize::MAX));
519
520    all_results
521}
522
523/// Convert a TOML patch into an `Edit` (or a sentinel no-op `Edit` when the
524/// operation is already satisfied).
525///
526/// Passes `patch.constraint` through to `TomlEditor::plan` so that
527/// `ensure_absent` / `ensure_present` constraints are enforced at runtime.
528fn compute_toml_edit(
529    patch: &PatchDefinition,
530    file_path: &Path,
531    content: &str,
532) -> Result<Edit, ApplicationError> {
533    let editor =
534        TomlEditor::from_path(file_path, content).map_err(|e| ApplicationError::TomlOperation {
535            file: file_path.to_path_buf(),
536            reason: e.to_string(),
537        })?;
538
539    let toml_query = match &patch.query {
540        Query::Toml {
541            section,
542            key,
543            ensure_absent,
544            ensure_present,
545            ..
546        } => {
547            if *ensure_absent || *ensure_present {
548                return Err(ApplicationError::TomlOperation {
549                    file: file_path.to_path_buf(),
550                    reason: "query-level ensure_absent/ensure_present are not supported; \
551                             use patch.constraint instead"
552                        .to_string(),
553                });
554            }
555            if let Some(key_val) = key {
556                let section_path = if let Some(sec) = section {
557                    SectionPath::parse(sec).map_err(|e| ApplicationError::TomlOperation {
558                        file: file_path.to_path_buf(),
559                        reason: format!("Invalid section path: {}", e),
560                    })?
561                } else {
562                    SectionPath::parse("").map_err(|e| ApplicationError::TomlOperation {
563                        file: file_path.to_path_buf(),
564                        reason: format!("Invalid section path: {}", e),
565                    })?
566                };
567                let key_path =
568                    KeyPath::parse(key_val).map_err(|e| ApplicationError::TomlOperation {
569                        file: file_path.to_path_buf(),
570                        reason: format!("Invalid key path: {}", e),
571                    })?;
572                TomlQuery::Key {
573                    section: section_path,
574                    key: key_path,
575                }
576            } else if let Some(section_val) = section {
577                let section_path = SectionPath::parse(section_val).map_err(|e| {
578                    ApplicationError::TomlOperation {
579                        file: file_path.to_path_buf(),
580                        reason: format!("Invalid section path: {}", e),
581                    }
582                })?;
583                TomlQuery::Section { path: section_path }
584            } else {
585                return Err(ApplicationError::TomlOperation {
586                    file: file_path.to_path_buf(),
587                    reason: "TOML query must specify section or key".to_string(),
588                });
589            }
590        }
591        _ => unreachable!("compute_toml_edit called with non-TOML query"),
592    };
593
594    let toml_operation = match &patch.operation {
595        Operation::InsertSection { text, positioning } => TomlOperation::InsertSection {
596            text: text.clone(),
597            positioning: convert_positioning(positioning).map_err(|e| {
598                ApplicationError::TomlOperation {
599                    file: file_path.to_path_buf(),
600                    reason: format!("Invalid positioning: {}", e),
601                }
602            })?,
603        },
604        Operation::AppendSection { text } => TomlOperation::AppendSection { text: text.clone() },
605        Operation::ReplaceValue { value } => TomlOperation::ReplaceValue {
606            value: value.clone(),
607        },
608        Operation::DeleteSection => TomlOperation::DeleteSection,
609        Operation::ReplaceKey { new_key } => TomlOperation::ReplaceKey {
610            new_key: new_key.clone(),
611        },
612        _ => {
613            return Err(ApplicationError::TomlOperation {
614                file: file_path.to_path_buf(),
615                reason: format!("Unsupported operation for TOML: {:?}", patch.operation),
616            });
617        }
618    };
619
620    let constraints = patch
621        .constraint
622        .as_ref()
623        .map(|c| Constraints {
624            ensure_absent: c.ensure_absent,
625            ensure_present: c.ensure_present,
626        })
627        .unwrap_or_else(Constraints::none);
628
629    let plan = editor
630        .plan(&toml_query, &toml_operation, constraints)
631        .map_err(|e| ApplicationError::TomlOperation {
632            file: file_path.to_path_buf(),
633            reason: e.to_string(),
634        })?;
635
636    match plan {
637        TomlPlan::Edit(edit) => Ok(edit),
638        TomlPlan::NoOp(_) => {
639            // Anchor the sentinel at EOF to avoid colliding with real edits at byte 0.
640            let end = content.len();
641            Ok(Edit::new(file_path, end, end, "", ""))
642        }
643    }
644}
645
646/// Compute an Edit for a patch without applying it.
647fn compute_edit_for_patch(
648    patch: &PatchDefinition,
649    file_path: &Path,
650    content: &str,
651) -> Result<Edit, ApplicationError> {
652    match &patch.query {
653        Query::Text {
654            search,
655            fuzzy_threshold,
656            fuzzy_expansion,
657        } => compute_text_edit(
658            patch,
659            file_path,
660            content,
661            search,
662            *fuzzy_threshold,
663            *fuzzy_expansion,
664        ),
665        Query::AstGrep { pattern } => {
666            compute_structural_edit(patch, file_path, content, pattern, true)
667        }
668        Query::TreeSitter { pattern } => {
669            compute_structural_edit(patch, file_path, content, pattern, false)
670        }
671        Query::Toml { .. } => compute_toml_edit(patch, file_path, content),
672    }
673}
674
675/// Compute a text edit without applying it (for batching).
676fn compute_text_edit(
677    patch: &PatchDefinition,
678    file_path: &Path,
679    content: &str,
680    search: &str,
681    fuzzy_threshold: Option<f64>,
682    fuzzy_expansion: Option<usize>,
683) -> Result<Edit, ApplicationError> {
684    // Check if the search text exists in the file
685    if !content.contains(search) {
686        // Check if the replacement text already exists (idempotency)
687        if let Operation::Replace { text } = &patch.operation {
688            if content.contains(text.as_str()) {
689                // Return a no-op edit for idempotency
690                return Ok(Edit::new(file_path, 0, 0, String::new(), ""));
691            }
692        }
693
694        // Fuzzy fallback: only when the user has explicitly opted in via threshold or expansion.
695        if fuzzy_threshold.is_none() && fuzzy_expansion.is_none() {
696            return Err(ApplicationError::NoMatch {
697                file: file_path.to_path_buf(),
698            });
699        }
700        let threshold = fuzzy_threshold.unwrap_or(0.85);
701        let fuzzy_result = match fuzzy_expansion {
702            Some(expansion) => {
703                crate::fuzzy::find_best_match_elastic(search, content, threshold, expansion)
704            }
705            None => crate::fuzzy::find_best_match(search, content, threshold),
706        };
707        if let Some(fuzzy) = fuzzy_result {
708            eprintln!(
709                "  [fuzzy] patch '{}': exact match failed, using fuzzy match (score: {:.2})",
710                patch.id, fuzzy.score
711            );
712
713            return match &patch.operation {
714                Operation::Replace { text } => Ok(Edit::new(
715                    file_path,
716                    fuzzy.start,
717                    fuzzy.end,
718                    text.clone(),
719                    fuzzy.matched_text,
720                )),
721                _ => Err(ApplicationError::TomlOperation {
722                    file: file_path.to_path_buf(),
723                    reason: "Text queries only support 'replace' operation".to_string(),
724                }),
725            };
726        }
727
728        return Err(ApplicationError::NoMatch {
729            file: file_path.to_path_buf(),
730        });
731    }
732
733    // O(1) ambiguity check: bail if more than one match exists
734    let mut occurrences = content.match_indices(search);
735    let first = occurrences.next();
736    if first.is_some() && occurrences.next().is_some() {
737        return Err(ApplicationError::AmbiguousMatch {
738            file: file_path.to_path_buf(),
739            count: content.matches(search).count(), // full count only for error message
740        });
741    }
742
743    // Create edit
744    match &patch.operation {
745        Operation::Replace { text } => {
746            let byte_start = first.expect("existence checked above").0;
747            let byte_end = byte_start + search.len();
748            let verification = if let Some(verify) = &patch.verify {
749                match verify {
750                    crate::config::schema::Verify::ExactMatch { expected_text } => {
751                        EditVerification::ExactMatch(expected_text.clone())
752                    }
753                    crate::config::schema::Verify::Hash { expected, .. } => {
754                        let hash = u64::from_str_radix(expected.trim_start_matches("0x"), 16)
755                            .map_err(|_| ApplicationError::TomlOperation {
756                                file: file_path.to_path_buf(),
757                                reason: format!("invalid hash value: {}", expected),
758                            })?;
759                        EditVerification::Hash(hash)
760                    }
761                }
762            } else {
763                EditVerification::from_text(search)
764            };
765            Ok(Edit::with_verification(
766                file_path,
767                byte_start,
768                byte_end,
769                text.clone(),
770                verification,
771            ))
772        }
773        _ => Err(ApplicationError::TomlOperation {
774            file: file_path.to_path_buf(),
775            reason: "Text queries only support 'replace' operation".to_string(),
776        }),
777    }
778}
779
780/// Compute a structural edit without applying it (for batching).
781fn compute_structural_edit(
782    patch: &PatchDefinition,
783    file_path: &Path,
784    content: &str,
785    pattern: &str,
786    use_ast_grep: bool,
787) -> Result<Edit, ApplicationError> {
788    fn align_trailing_newline(current_text: &str, replacement: &str) -> String {
789        // ast-grep spans typically exclude the following newline. Many patch definitions
790        // use triple-quoted strings that include a trailing '\n'. Align to the matched
791        // span so replace patches are idempotent.
792        match (current_text.ends_with('\n'), replacement.ends_with('\n')) {
793            (true, false) => {
794                let mut s = replacement.to_string();
795                s.push('\n');
796                s
797            }
798            (false, true) => replacement
799                .strip_suffix('\n')
800                .unwrap_or(replacement)
801                .to_string(),
802            _ => replacement.to_string(),
803        }
804    }
805
806    // Find matches
807    let matches = if use_ast_grep {
808        find_ast_grep_matches(content, pattern)
809    } else {
810        find_tree_sitter_matches(content, pattern)
811    }
812    .map_err(|e| ApplicationError::TomlOperation {
813        file: file_path.to_path_buf(),
814        reason: e,
815    })?;
816
817    // Special handling for Delete operations
818    if matches.is_empty() {
819        // Structural replace patches can still be already applied if the target
820        // shape changed but the replacement text is present in the file.
821        if let Operation::Replace { text } = &patch.operation {
822            let replacement = text.as_str();
823            let replacement_without_trailing_newline = replacement.trim_end_matches('\n');
824            if content.contains(replacement)
825                || content.contains(replacement_without_trailing_newline)
826            {
827                return Ok(Edit::new(file_path, 0, 0, String::new(), ""));
828            }
829        }
830
831        // For Delete operations, check if the deletion was already applied
832        if let Operation::Delete { insert_comment } = &patch.operation {
833            if let Some(comment) = insert_comment {
834                // Check if the comment exists in the file
835                if content.contains(comment) {
836                    // Return a no-op edit for idempotency
837                    return Ok(Edit::new(file_path, 0, 0, String::new(), ""));
838                }
839            }
840            // If no comment or comment not found, return no-op edit
841            return Ok(Edit::new(file_path, 0, 0, String::new(), ""));
842        }
843
844        return Err(ApplicationError::NoMatch {
845            file: file_path.to_path_buf(),
846        });
847    }
848    if matches.len() > 1 {
849        return Err(ApplicationError::AmbiguousMatch {
850            file: file_path.to_path_buf(),
851            count: matches.len(),
852        });
853    }
854
855    let (byte_start, byte_end) = matches[0];
856    let current_text = &content[byte_start..byte_end];
857
858    // Build verification
859    let verification = if let Some(verify) = &patch.verify {
860        match verify {
861            crate::config::schema::Verify::ExactMatch { expected_text } => {
862                EditVerification::ExactMatch(expected_text.clone())
863            }
864            crate::config::schema::Verify::Hash { expected, .. } => {
865                // Parse hex string to u64
866                let hash =
867                    u64::from_str_radix(expected.trim_start_matches("0x"), 16).map_err(|_| {
868                        ApplicationError::TomlOperation {
869                            file: file_path.to_path_buf(),
870                            reason: format!("invalid hash value: {}", expected),
871                        }
872                    })?;
873                EditVerification::Hash(hash)
874            }
875        }
876    } else {
877        EditVerification::ExactMatch(current_text.to_string())
878    };
879
880    // Get new text based on operation
881    let new_text = match &patch.operation {
882        Operation::Replace { text } => align_trailing_newline(current_text, text.as_str()),
883        Operation::Delete { insert_comment } => {
884            if let Some(comment) = insert_comment {
885                comment.clone()
886            } else {
887                String::new()
888            }
889        }
890        _ => {
891            return Err(ApplicationError::TomlOperation {
892                file: file_path.to_path_buf(),
893                reason: "unsupported operation for structural patch".to_string(),
894            });
895        }
896    };
897
898    // Check idempotency for Replace operation (after normalizing trailing newline).
899    if matches!(patch.operation, Operation::Replace { .. }) && current_text == new_text {
900        return Ok(Edit::new(file_path, 0, 0, String::new(), ""));
901    }
902
903    // Create edit without applying
904    Ok(Edit {
905        file: file_path.to_path_buf(),
906        byte_start,
907        byte_end,
908        new_text,
909        expected_before: verification,
910    })
911}
912
913/// Convert config::Positioning to toml::Positioning.
914///
915/// Positioning validation (at-most-one directive) is enforced at load time via
916/// `Positioning::validate()` in schema.rs, so no re-validation is needed here.
917fn convert_positioning(pos: &Positioning) -> Result<crate::toml::Positioning, String> {
918    use crate::toml::Positioning as TP;
919
920    if let Some(after) = &pos.after_section {
921        let path =
922            SectionPath::parse(after).map_err(|e| format!("Invalid after_section: {}", e))?;
923        Ok(TP::AfterSection(path))
924    } else if let Some(before) = &pos.before_section {
925        let path =
926            SectionPath::parse(before).map_err(|e| format!("Invalid before_section: {}", e))?;
927        Ok(TP::BeforeSection(path))
928    } else if pos.at_end {
929        Ok(TP::AtEnd)
930    } else if pos.at_beginning {
931        Ok(TP::AtBeginning)
932    } else {
933        // Default to AtEnd if nothing specified
934        Ok(TP::AtEnd)
935    }
936}
937
938/// Find matches using ast-grep
939fn find_ast_grep_matches(content: &str, pattern: &str) -> Result<Vec<(usize, usize)>, String> {
940    let matcher = PatternMatcher::new(content);
941    let matches = matcher
942        .find_all(pattern)
943        .map_err(|e| format!("ast-grep pattern error: {}", e))?;
944
945    Ok(matches
946        .into_iter()
947        .map(|m| (m.byte_start, m.byte_end))
948        .collect())
949}
950
951/// Parse a tree-sitter pattern string into a `StructuralTarget`.
952///
953/// Accepts two forms:
954///
955/// **S-expression** (starts with `(`): passed directly to the tree-sitter query
956/// engine as a `Custom` target. The query must include at least one capture
957/// that spans the desired replacement range.
958///
959/// **DSL shorthand**: a human-readable prefix syntax that maps to well-known
960/// `StructuralTarget` variants:
961///
962/// | Pattern | Target |
963/// |---|---|
964/// | `fn name` | `Function { name }` |
965/// | `fn Type::method` | `Method { type_name, method_name }` |
966/// | `struct Name` | `Struct { name }` |
967/// | `enum Name` | `Enum { name }` |
968/// | `const NAME` | `Const { name }` |
969/// | `const /regex/` | `ConstMatching { pattern }` |
970/// | `static NAME` | `Static { name }` |
971/// | `impl Type` | `Impl { type_name }` |
972/// | `impl Trait for Type` | `ImplTrait { trait_name, type_name }` |
973/// | `use path_pattern` | `Use { path_pattern }` |
974fn parse_tree_sitter_pattern(pattern: &str) -> Result<StructuralTarget, String> {
975    let pattern = pattern.trim();
976
977    // Raw S-expression tree-sitter query
978    if pattern.starts_with('(') {
979        return Ok(StructuralTarget::Custom {
980            query: pattern.to_string(),
981        });
982    }
983
984    // DSL: `fn Type::method` or `fn name`
985    if let Some(rest) = pattern.strip_prefix("fn ") {
986        let rest = rest.trim();
987        if let Some((type_name, method_name)) = rest.split_once("::") {
988            return Ok(StructuralTarget::Method {
989                type_name: type_name.trim().to_string(),
990                method_name: method_name.trim().to_string(),
991            });
992        }
993        return Ok(StructuralTarget::Function {
994            name: rest.to_string(),
995        });
996    }
997
998    // DSL: `struct Name`
999    if let Some(name) = pattern.strip_prefix("struct ") {
1000        return Ok(StructuralTarget::Struct {
1001            name: name.trim().to_string(),
1002        });
1003    }
1004
1005    // DSL: `enum Name`
1006    if let Some(name) = pattern.strip_prefix("enum ") {
1007        return Ok(StructuralTarget::Enum {
1008            name: name.trim().to_string(),
1009        });
1010    }
1011
1012    // DSL: `const /regex/` or `const NAME`
1013    if let Some(rest) = pattern.strip_prefix("const ") {
1014        let rest = rest.trim();
1015        if rest.starts_with('/') && rest.ends_with('/') && rest.len() > 1 {
1016            let regex_pattern = &rest[1..rest.len() - 1];
1017            return Ok(StructuralTarget::ConstMatching {
1018                pattern: regex_pattern.to_string(),
1019            });
1020        }
1021        return Ok(StructuralTarget::Const {
1022            name: rest.to_string(),
1023        });
1024    }
1025
1026    // DSL: `static NAME`
1027    if let Some(name) = pattern.strip_prefix("static ") {
1028        return Ok(StructuralTarget::Static {
1029            name: name.trim().to_string(),
1030        });
1031    }
1032
1033    // DSL: `impl Trait for Type` or `impl Type`
1034    if let Some(rest) = pattern.strip_prefix("impl ") {
1035        let rest = rest.trim();
1036        if let Some(for_pos) = rest.find(" for ") {
1037            let trait_name = rest[..for_pos].trim();
1038            let type_name = rest[for_pos + 5..].trim();
1039            return Ok(StructuralTarget::ImplTrait {
1040                trait_name: trait_name.to_string(),
1041                type_name: type_name.to_string(),
1042            });
1043        }
1044        return Ok(StructuralTarget::Impl {
1045            type_name: rest.to_string(),
1046        });
1047    }
1048
1049    // DSL: `use path_pattern`
1050    if let Some(path_pattern) = pattern.strip_prefix("use ") {
1051        return Ok(StructuralTarget::Use {
1052            path_pattern: path_pattern.trim().to_string(),
1053        });
1054    }
1055
1056    Err(format!(
1057        "unrecognized tree-sitter pattern: {:?}. \
1058        Use S-expression syntax (starting with '(') or a DSL shorthand: \
1059        fn name, fn Type::method, struct Name, enum Name, const NAME, \
1060        const /regex/, static NAME, impl Type, impl Trait for Type, use path_pattern",
1061        pattern
1062    ))
1063}
1064
1065/// Find matches using tree-sitter (pooled parser for performance).
1066///
1067/// Accepts the DSL shorthand or raw S-expression syntax described in
1068/// [`parse_tree_sitter_pattern`].
1069fn find_tree_sitter_matches(content: &str, pattern: &str) -> Result<Vec<(usize, usize)>, String> {
1070    use crate::ts::locator::pooled;
1071
1072    let target = parse_tree_sitter_pattern(pattern)?;
1073
1074    // For Method targets the query engine's union span runs from the impl's type
1075    // identifier all the way to the method body — wider than the method alone.
1076    // Extract the dedicated `@method` capture to get the correct replacement span.
1077    let is_method = matches!(target, StructuralTarget::Method { .. });
1078
1079    let results =
1080        pooled::locate_all(content, &target).map_err(|e| format!("tree-sitter error: {}", e))?;
1081
1082    Ok(results
1083        .into_iter()
1084        .map(|r| {
1085            if is_method {
1086                r.captures
1087                    .get("method")
1088                    .map(|c| (c.byte_start, c.byte_end))
1089                    .unwrap_or((r.byte_start, r.byte_end))
1090            } else {
1091                (r.byte_start, r.byte_end)
1092            }
1093        })
1094        .collect())
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099    use super::*;
1100    use crate::config::schema::Metadata;
1101
1102    // -------------------------------------------------------------------------
1103    // parse_tree_sitter_pattern unit tests
1104    // -------------------------------------------------------------------------
1105
1106    #[test]
1107    fn ts_parse_fn_name() {
1108        assert!(matches!(
1109            parse_tree_sitter_pattern("fn hello"),
1110            Ok(StructuralTarget::Function { name }) if name == "hello"
1111        ));
1112    }
1113
1114    #[test]
1115    fn ts_parse_method() {
1116        assert!(matches!(
1117            parse_tree_sitter_pattern("fn Foo::bar"),
1118            Ok(StructuralTarget::Method { type_name, method_name })
1119                if type_name == "Foo" && method_name == "bar"
1120        ));
1121    }
1122
1123    #[test]
1124    fn ts_parse_struct() {
1125        assert!(matches!(
1126            parse_tree_sitter_pattern("struct Config"),
1127            Ok(StructuralTarget::Struct { name }) if name == "Config"
1128        ));
1129    }
1130
1131    #[test]
1132    fn ts_parse_enum() {
1133        assert!(matches!(
1134            parse_tree_sitter_pattern("enum Status"),
1135            Ok(StructuralTarget::Enum { name }) if name == "Status"
1136        ));
1137    }
1138
1139    #[test]
1140    fn ts_parse_const_by_name() {
1141        assert!(matches!(
1142            parse_tree_sitter_pattern("const MAX_SIZE"),
1143            Ok(StructuralTarget::Const { name }) if name == "MAX_SIZE"
1144        ));
1145    }
1146
1147    #[test]
1148    fn ts_parse_const_regex() {
1149        assert!(matches!(
1150            parse_tree_sitter_pattern("const /^STATSIG_/"),
1151            Ok(StructuralTarget::ConstMatching { pattern }) if pattern == "^STATSIG_"
1152        ));
1153    }
1154
1155    #[test]
1156    fn ts_parse_static() {
1157        assert!(matches!(
1158            parse_tree_sitter_pattern("static COUNTER"),
1159            Ok(StructuralTarget::Static { name }) if name == "COUNTER"
1160        ));
1161    }
1162
1163    #[test]
1164    fn ts_parse_impl() {
1165        assert!(matches!(
1166            parse_tree_sitter_pattern("impl Foo"),
1167            Ok(StructuralTarget::Impl { type_name }) if type_name == "Foo"
1168        ));
1169    }
1170
1171    #[test]
1172    fn ts_parse_impl_trait() {
1173        assert!(matches!(
1174            parse_tree_sitter_pattern("impl Display for Foo"),
1175            Ok(StructuralTarget::ImplTrait { trait_name, type_name })
1176                if trait_name == "Display" && type_name == "Foo"
1177        ));
1178    }
1179
1180    #[test]
1181    fn ts_parse_use() {
1182        assert!(matches!(
1183            parse_tree_sitter_pattern("use std::collections"),
1184            Ok(StructuralTarget::Use { path_pattern }) if path_pattern == "std::collections"
1185        ));
1186    }
1187
1188    #[test]
1189    fn ts_parse_sexpr() {
1190        let q = "(function_item) @func";
1191        assert!(matches!(
1192            parse_tree_sitter_pattern(q),
1193            Ok(StructuralTarget::Custom { query }) if query == q
1194        ));
1195    }
1196
1197    #[test]
1198    fn ts_parse_unknown_errors() {
1199        assert!(parse_tree_sitter_pattern("xyz unknown").is_err());
1200        let err = parse_tree_sitter_pattern("xyz unknown").unwrap_err();
1201        assert!(
1202            err.contains("unrecognized"),
1203            "error message should be descriptive: {err}"
1204        );
1205    }
1206
1207    // -------------------------------------------------------------------------
1208    // Applicator integration tests
1209    // -------------------------------------------------------------------------
1210
1211    #[test]
1212    fn test_apply_patches_version_filtering() {
1213        let config = PatchConfig {
1214            meta: Metadata {
1215                name: "test".to_string(),
1216                description: None,
1217                version_range: Some(">=0.88.0".to_string()),
1218                workspace_relative: true,
1219            },
1220            patches: vec![],
1221        };
1222
1223        let results = apply_patches(&config, Path::new("/tmp"), "0.88.0");
1224        assert_eq!(results.len(), 0);
1225    }
1226
1227    #[test]
1228    fn test_patch_result_display() {
1229        let applied = PatchResult::Applied {
1230            file: PathBuf::from("/tmp/test.rs"),
1231        };
1232        assert!(applied.to_string().contains("Applied"));
1233
1234        let already = PatchResult::AlreadyApplied {
1235            file: PathBuf::from("/tmp/test.rs"),
1236        };
1237        assert!(already.to_string().contains("Already applied"));
1238
1239        let skipped = PatchResult::SkippedVersion {
1240            reason: "version too old".to_string(),
1241        };
1242        assert!(skipped.to_string().contains("Skipped"));
1243
1244        let failed = PatchResult::Failed {
1245            file: PathBuf::from("/tmp/test.rs"),
1246            reason: "parse error".to_string(),
1247        };
1248        assert!(failed.to_string().contains("Failed"));
1249    }
1250}