1use 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
21fn 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#[derive(Debug, Clone, PartialEq, Eq)]
43#[must_use = "PatchResult should be checked for success/failure"]
44pub enum PatchResult {
45 Applied { file: PathBuf },
47 AlreadyApplied { file: PathBuf },
49 SkippedVersion { reason: String },
51 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#[derive(Debug)]
76pub enum ApplicationError {
77 Version(VersionError),
79 Io {
81 path: PathBuf,
82 source: std::io::Error,
83 },
84 Edit(EditError),
86 AmbiguousMatch { file: PathBuf, count: usize },
88 NoMatch { file: PathBuf },
90 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
172pub 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
205pub 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
231fn 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 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 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#[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
379fn 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 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 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 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 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 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
523fn 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 let end = content.len();
641 Ok(Edit::new(file_path, end, end, "", ""))
642 }
643 }
644}
645
646fn 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
675fn 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 if !content.contains(search) {
686 if let Operation::Replace { text } = &patch.operation {
688 if content.contains(text.as_str()) {
689 return Ok(Edit::new(file_path, 0, 0, String::new(), ""));
691 }
692 }
693
694 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 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(), });
741 }
742
743 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
780fn 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 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 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 if matches.is_empty() {
819 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 if let Operation::Delete { insert_comment } = &patch.operation {
833 if let Some(comment) = insert_comment {
834 if content.contains(comment) {
836 return Ok(Edit::new(file_path, 0, 0, String::new(), ""));
838 }
839 }
840 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 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 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 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 if matches!(patch.operation, Operation::Replace { .. }) && current_text == new_text {
900 return Ok(Edit::new(file_path, 0, 0, String::new(), ""));
901 }
902
903 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
913fn 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 Ok(TP::AtEnd)
935 }
936}
937
938fn 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
951fn parse_tree_sitter_pattern(pattern: &str) -> Result<StructuralTarget, String> {
975 let pattern = pattern.trim();
976
977 if pattern.starts_with('(') {
979 return Ok(StructuralTarget::Custom {
980 query: pattern.to_string(),
981 });
982 }
983
984 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 if let Some(name) = pattern.strip_prefix("struct ") {
1000 return Ok(StructuralTarget::Struct {
1001 name: name.trim().to_string(),
1002 });
1003 }
1004
1005 if let Some(name) = pattern.strip_prefix("enum ") {
1007 return Ok(StructuralTarget::Enum {
1008 name: name.trim().to_string(),
1009 });
1010 }
1011
1012 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 if let Some(name) = pattern.strip_prefix("static ") {
1028 return Ok(StructuralTarget::Static {
1029 name: name.trim().to_string(),
1030 });
1031 }
1032
1033 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 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
1065fn 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 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 #[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 #[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}