1use crate::exit;
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9
10#[derive(Serialize, Deserialize, Debug, Clone)]
20#[non_exhaustive]
21pub struct TxOutput {
22 pub ok: bool,
23 pub status: String,
24 #[serde(default)]
28 pub applied: bool,
29 pub files_changed: usize,
30 pub files_created: usize,
31 pub files_deleted: usize,
32 #[serde(default, skip_serializing_if = "is_zero_usize")]
36 pub files_renamed: usize,
37 pub changes: Vec<TxChange>,
38 #[serde(skip_serializing_if = "Vec::is_empty", default)]
39 pub reads: Vec<TxReadResult>,
40 #[serde(skip_serializing_if = "Vec::is_empty", default)]
41 pub searches: Vec<TxSearchResult>,
42 #[serde(skip_serializing_if = "Vec::is_empty", default)]
43 pub lints: Vec<TxLintResult>,
44 #[serde(skip_serializing_if = "Vec::is_empty", default)]
48 pub mutations: Vec<TxDocMutation>,
49 #[serde(skip_serializing_if = "Option::is_none", default)]
53 pub changed: Option<bool>,
54 #[serde(skip_serializing_if = "Option::is_none", default)]
56 pub removed: Option<usize>,
57 #[serde(skip_serializing_if = "Option::is_none", default)]
58 pub error_kind: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none", default)]
60 pub error: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none", default)]
64 pub suggested_op: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none", default)]
66 pub backup_session: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none", default)]
70 pub match_mode: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none", default)]
73 pub match_score: Option<f64>,
74 #[serde(skip_serializing_if = "Option::is_none", default)]
77 pub match_count: Option<usize>,
78 #[serde(skip_serializing_if = "Option::is_none", default)]
83 pub matched_text: Option<String>,
84 #[serde(skip_serializing_if = "Vec::is_empty", default)]
89 pub refused: Vec<TxRefused>,
90}
91
92#[derive(Serialize, Deserialize, Debug, Clone)]
94pub struct TxRefused {
95 pub path: String,
96 #[serde(skip_serializing_if = "Option::is_none", default)]
97 pub match_mode: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none", default)]
99 pub match_score: Option<f64>,
100 #[serde(skip_serializing_if = "Option::is_none", default)]
101 pub matched_text: Option<String>,
102 pub reason: String,
104}
105
106#[derive(Serialize, Deserialize, Debug, Clone)]
108pub struct TxDocMutation {
109 pub path: String,
110 pub op: String,
112 pub changed: bool,
113 pub removed: usize,
114}
115
116#[derive(Serialize, Deserialize, Debug, Clone)]
121#[non_exhaustive]
122pub struct TxChange {
123 pub path: String,
124 pub action: String,
125 #[serde(skip_serializing_if = "Option::is_none", default)]
127 pub from: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none", default)]
130 pub to: Option<String>,
131 #[serde(skip_serializing_if = "Option::is_none", default)]
134 pub match_mode: Option<String>,
135 #[serde(skip_serializing_if = "Option::is_none", default)]
137 pub match_score: Option<f64>,
138 #[serde(skip_serializing_if = "Option::is_none", default)]
141 pub match_count: Option<usize>,
142 #[serde(skip_serializing_if = "Option::is_none", default)]
144 pub matched_text: Option<String>,
145 #[serde(skip_serializing_if = "std::ops::Not::not", default)]
148 pub style_changed: bool,
149}
150
151fn path_style_changed(path: &Path, original: &str, new_text: &str) -> bool {
153 crate::ops::doc::style_changed_for_path(&path.to_string_lossy(), original, new_text)
154}
155
156#[derive(Serialize, Deserialize, Debug, Clone)]
158pub struct TxSearchMatch {
159 pub line: usize,
160 pub column: usize,
161 pub text: String,
162 #[serde(skip_serializing_if = "Vec::is_empty")]
163 pub context_before: Vec<String>,
164 #[serde(skip_serializing_if = "Vec::is_empty")]
165 pub context_after: Vec<String>,
166}
167
168#[derive(Serialize, Deserialize, Debug, Clone)]
170pub struct TxSearchResult {
171 pub path: String,
172 pub pattern: String,
173 pub match_count: usize,
174 pub matches: Vec<TxSearchMatch>,
175 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
178 pub truncated: bool,
179}
180
181#[derive(Serialize, Deserialize, Debug, Clone)]
183pub struct TxReadResult {
184 pub path: String,
185 pub content: String,
186 pub start_line: usize,
187 pub end_line: usize,
188 pub total_lines: usize,
189}
190
191#[derive(Serialize, Deserialize, Debug, Clone)]
193pub struct TxLintResult {
194 pub path: String,
195 pub issue_count: usize,
196 pub issues: Vec<crate::ops::md::LintIssue>,
197}
198
199pub(crate) struct TxExecResult {
203 pub(crate) changes: Vec<(PathBuf, String, String)>,
204 pub(crate) deletions: HashSet<PathBuf>,
205 pub(crate) existed_before: HashSet<PathBuf>,
206 pub(crate) pending: HashMap<PathBuf, (String, String)>,
208 pub(crate) tx_reads: Vec<TxReadResult>,
209 pub(crate) tx_searches: Vec<TxSearchResult>,
210 pub(crate) tx_lints: Vec<TxLintResult>,
211 pub(crate) tx_mutations: Vec<TxDocMutation>,
212 pub(crate) no_effective_changes: bool,
213 pub(crate) replace_no_matches: bool,
214 pub(crate) replace_hint: Option<String>,
216 pub(crate) replace_match_meta: HashMap<PathBuf, ReplaceMatchMeta>,
218 pub(crate) renames: Vec<(PathBuf, PathBuf)>,
221}
222
223#[derive(Debug, Clone)]
225pub(crate) struct ReplaceMatchMeta {
226 pub mode: crate::api::MatchMode,
227 pub score: Option<f64>,
228 pub match_count: usize,
229 pub matched_text: Option<String>,
231 pub refuse_reason: Option<&'static str>,
235}
236
237pub(crate) fn attach_mutations(output: &mut TxOutput, mutations: Vec<TxDocMutation>) {
239 if mutations.is_empty() {
240 return;
241 }
242 let changed = mutations.iter().any(|m| m.changed);
243 let removed = mutations.iter().map(|m| m.removed).sum();
244 output.changed = Some(changed);
245 output.removed = Some(removed);
246 output.mutations = mutations;
247}
248
249pub(crate) fn match_mode_label(mode: crate::api::MatchMode) -> &'static str {
251 match mode {
252 crate::api::MatchMode::Exact => "exact",
253 crate::api::MatchMode::Fuzzy => "fuzzy",
254 crate::api::MatchMode::Anchored => "anchored",
255 }
256}
257
258pub(crate) use crate::api::merge_match_modes;
263
264fn match_meta_for_path(
265 path: &Path,
266 meta: &HashMap<PathBuf, ReplaceMatchMeta>,
267) -> (Option<String>, Option<f64>, Option<usize>, Option<String>) {
268 match meta.get(path) {
269 Some(m) => (
270 Some(match_mode_label(m.mode).to_string()),
271 m.score,
272 Some(m.match_count),
273 m.matched_text.clone(),
274 ),
275 None => (None, None, None, None),
276 }
277}
278
279fn is_zero_usize(n: &usize) -> bool {
280 *n == 0
281}
282
283pub(crate) struct TxOutputMetaInputs<'a> {
285 pub changes: &'a [(PathBuf, String, String)],
286 pub deletions: &'a HashSet<PathBuf>,
287 pub existed_before: &'a HashSet<PathBuf>,
288 pub replace_match_meta: &'a HashMap<PathBuf, ReplaceMatchMeta>,
289 pub renames: &'a [(PathBuf, PathBuf)],
290}
291
292pub(crate) fn build_tx_output_with_meta(
293 status: &'static str,
294 ok: bool,
295 cwd: &Path,
296 inputs: TxOutputMetaInputs<'_>,
297) -> TxOutput {
298 let TxOutputMetaInputs {
299 changes,
300 deletions,
301 existed_before,
302 replace_match_meta,
303 renames,
304 } = inputs;
305 let mut tx_changes = Vec::new();
306 let mut created = 0usize;
307 let mut deleted_count = 0usize;
308 let mut modified = 0usize;
309 let mut renamed_count = 0usize;
310 let mut agg_mode: Option<crate::api::MatchMode> = None;
311 let mut agg_score: Option<f64> = None;
312 let mut agg_count: usize = 0;
313 let mut any_replace_meta = false;
314 let mut top_matched_text: Option<String> = None;
315
316 let display_path = |p: &Path| -> String {
317 crate::files::relative_display(p, cwd)
318 .to_string_lossy()
319 .into_owned()
320 };
321
322 let mut rename_from: HashSet<PathBuf> = HashSet::new();
324 let mut rename_to: HashSet<PathBuf> = HashSet::new();
325 for (from, to) in renames {
326 rename_from.insert(from.clone());
327 rename_to.insert(to.clone());
328 let (match_mode, match_score, match_count, matched_text) =
329 match_meta_for_path(to, replace_match_meta);
330 if let Some(m) = replace_match_meta.get(to) {
331 any_replace_meta = true;
332 agg_mode = Some(merge_match_modes(agg_mode, m.mode));
333 if matches!(m.mode, crate::api::MatchMode::Fuzzy)
334 && let Some(s) = m.score
335 {
336 agg_score = Some(agg_score.map_or(s, |prev| prev.min(s)));
337 }
338 agg_count = agg_count.saturating_add(m.match_count);
339 top_matched_text =
340 crate::api::prefer_widest_matched_text(top_matched_text, m.matched_text.clone());
341 }
342 let from_str = display_path(from);
343 let to_str = display_path(to);
344 tx_changes.push(TxChange {
345 path: to_str.clone(),
347 action: "renamed".to_string(),
348 from: Some(from_str),
349 to: Some(to_str),
350 match_mode,
351 match_score,
352 match_count,
353 matched_text,
354 style_changed: false,
355 });
356 renamed_count += 1;
357 }
358
359 let change_paths: HashSet<&std::path::Path> =
361 changes.iter().map(|(c, _, _)| c.as_path()).collect();
362
363 for (path, original, new_content) in changes {
364 if rename_from.contains(path) || rename_to.contains(path) {
366 continue;
367 }
368 let path_str = display_path(path);
369 let (match_mode, match_score, match_count, matched_text) =
370 match_meta_for_path(path, replace_match_meta);
371 if let Some(m) = replace_match_meta.get(path) {
372 any_replace_meta = true;
373 agg_mode = Some(merge_match_modes(agg_mode, m.mode));
374 if matches!(m.mode, crate::api::MatchMode::Fuzzy)
375 && let Some(s) = m.score
376 {
377 agg_score = Some(agg_score.map_or(s, |prev| prev.min(s)));
379 }
380 agg_count = agg_count.saturating_add(m.match_count);
381 top_matched_text =
382 crate::api::prefer_widest_matched_text(top_matched_text, m.matched_text.clone());
383 }
384 let style_changed = path_style_changed(path, original, new_content);
385 if deletions.contains(path) {
386 tx_changes.push(TxChange {
387 path: path_str,
388 action: "deleted".to_string(),
389 from: None,
390 to: None,
391 match_mode,
392 match_score,
393 match_count,
394 matched_text,
395 style_changed: false,
396 });
397 deleted_count += 1;
398 } else if !existed_before.contains(path) {
399 tx_changes.push(TxChange {
400 path: path_str,
401 action: "created".to_string(),
402 from: None,
403 to: None,
404 match_mode,
405 match_score,
406 match_count,
407 matched_text,
408 style_changed: false,
409 });
410 created += 1;
411 } else {
412 tx_changes.push(TxChange {
413 path: path_str,
414 action: "modified".to_string(),
415 from: None,
416 to: None,
417 match_mode,
418 match_score,
419 match_count,
420 matched_text,
421 style_changed,
422 });
423 modified += 1;
424 }
425 }
426 for path in deletions {
428 if rename_from.contains(path) {
429 continue;
430 }
431 if !change_paths.contains(path.as_path()) {
432 let (match_mode, match_score, match_count, matched_text) =
433 match_meta_for_path(path, replace_match_meta);
434 if let Some(m) = replace_match_meta.get(path) {
435 any_replace_meta = true;
436 agg_mode = Some(merge_match_modes(agg_mode, m.mode));
437 if matches!(m.mode, crate::api::MatchMode::Fuzzy)
438 && let Some(s) = m.score
439 {
440 agg_score = Some(agg_score.map_or(s, |prev| prev.min(s)));
441 }
442 agg_count = agg_count.saturating_add(m.match_count);
443 top_matched_text = crate::api::prefer_widest_matched_text(
444 top_matched_text,
445 m.matched_text.clone(),
446 );
447 }
448 tx_changes.push(TxChange {
449 path: display_path(path),
450 action: "deleted".to_string(),
451 from: None,
452 to: None,
453 match_mode,
454 match_score,
455 match_count,
456 matched_text,
457 style_changed: false,
458 });
459 deleted_count += 1;
460 }
461 }
462
463 if changes.is_empty() && deletions.is_empty() {
468 for m in replace_match_meta.values() {
469 any_replace_meta = true;
470 agg_mode = Some(merge_match_modes(agg_mode, m.mode));
471 if matches!(m.mode, crate::api::MatchMode::Fuzzy)
472 && let Some(s) = m.score
473 {
474 agg_score = Some(agg_score.map_or(s, |prev| prev.min(s)));
475 }
476 agg_count = agg_count.saturating_add(m.match_count);
477 top_matched_text =
478 crate::api::prefer_widest_matched_text(top_matched_text, m.matched_text.clone());
479 }
480 }
481
482 let mut refused = Vec::new();
486 for (path, m) in replace_match_meta {
487 if change_paths.contains(path.as_path()) || deletions.contains(path) {
488 continue;
489 }
490 if m.match_count != 0 {
492 continue;
493 }
494 if m.matched_text.is_none() && m.refuse_reason != Some("no_matches") {
497 continue;
498 }
499 let reason = m
500 .refuse_reason
501 .unwrap_or(if m.mode == crate::api::MatchMode::Fuzzy {
502 "exact_old_absent"
503 } else {
504 "no_write"
505 });
506 refused.push(TxRefused {
507 path: display_path(path),
508 match_mode: Some(match_mode_label(m.mode).to_string()),
509 match_score: m.score,
510 matched_text: m.matched_text.clone(),
511 reason: reason.to_string(),
512 });
513 }
514 refused.sort_by(|a, b| a.path.cmp(&b.path));
515
516 let (top_mode, top_score) = match agg_mode {
517 Some(m) => (
518 Some(match_mode_label(m).to_string()),
519 if matches!(m, crate::api::MatchMode::Fuzzy) {
520 agg_score
521 } else {
522 None
523 },
524 ),
525 None => (None, None),
526 };
527
528 TxOutput {
529 ok,
530 status: status.to_string(),
531 applied: status == "success" && (modified + created + deleted_count + renamed_count > 0),
535 files_changed: modified,
536 files_created: created,
537 files_deleted: deleted_count,
538 files_renamed: renamed_count,
539 changes: tx_changes,
540 reads: Vec::new(),
541 searches: Vec::new(),
542 lints: Vec::new(),
543 mutations: Vec::new(),
544 changed: None,
545 removed: None,
546 error_kind: None,
547 error: None,
548 suggested_op: None,
549 backup_session: None,
550 match_mode: top_mode,
551 match_score: top_score,
552 match_count: if any_replace_meta {
553 Some(agg_count)
554 } else {
555 None
556 },
557 matched_text: top_matched_text,
561 refused,
562 }
563}
564
565pub(crate) fn build_full_tx_output(
566 status: &'static str,
567 result: &mut TxExecResult,
568 cwd: &Path,
569) -> TxOutput {
570 let mut output = build_tx_output_with_meta(
571 status,
572 true,
573 cwd,
574 TxOutputMetaInputs {
575 changes: &result.changes,
576 deletions: &result.deletions,
577 existed_before: &result.existed_before,
578 replace_match_meta: &result.replace_match_meta,
579 renames: &result.renames,
580 },
581 );
582 output.reads = std::mem::take(&mut result.tx_reads);
583 output.searches = std::mem::take(&mut result.tx_searches);
584 output.lints = std::mem::take(&mut result.tx_lints);
585 attach_mutations(&mut output, std::mem::take(&mut result.tx_mutations));
586 if status == "no_matches" {
590 output.ok = false;
591 output.error_kind = Some("no_matches".to_string());
592 let detail = result
593 .replace_hint
594 .as_deref()
595 .filter(|h| !h.is_empty())
596 .unwrap_or("no matches");
597 output.error = Some(detail.to_string());
598 }
599 if matches!(status, "success" | "changes_detected") {
603 let lint_issues: usize = output.lints.iter().map(|l| l.issue_count).sum();
604 if lint_issues > 0 {
605 output.ok = false;
606 output.status = "changes_detected".to_string();
607 output.error_kind = Some("changes_detected".to_string());
608 output.error = Some(format!(
609 "lint found {lint_issues} issue(s); see lints[] for details"
610 ));
611 if output.files_changed
613 + output.files_created
614 + output.files_deleted
615 + output.files_renamed
616 == 0
617 {
618 output.applied = false;
619 }
620 }
621 }
622 output
623}
624
625pub(crate) fn describe_exit_status(status: std::process::ExitStatus) -> String {
626 match status.code() {
627 Some(code) => format!("exit code {code}"),
628 None => "terminated by signal".to_string(),
629 }
630}
631
632pub(crate) fn describe_lifecycle_cwd(base_cwd: &Path, cwd: &Path) -> String {
633 if cwd == base_cwd {
634 ".".to_string()
635 } else {
636 crate::files::relative_display(cwd, base_cwd)
637 .display()
638 .to_string()
639 }
640}
641
642pub(crate) fn format_error_with_backup_hint(error: &str, backup_session: Option<&str>) -> String {
643 match backup_session {
644 Some(ts) => format!("{error} (backup session {ts}; run `patchloom undo` to restore)"),
645 None => error.to_string(),
646 }
647}
648
649fn format_error_with_kind(error_kind: &str, error: &str) -> String {
652 let prefix = format!("{error_kind}: ");
653 if error.starts_with(&prefix) || error.starts_with(&format!("{error_kind}:")) {
654 error.to_string()
655 } else {
656 format!("{prefix}{error}")
657 }
658}
659
660pub(crate) fn build_error_output(
661 error_kind: &str,
662 error: &str,
663 backup_session: Option<&str>,
664) -> TxOutput {
665 build_error_output_with_suggested_op(error_kind, error, backup_session, None)
666}
667
668pub(crate) fn build_error_output_with_suggested_op(
670 error_kind: &str,
671 error: &str,
672 backup_session: Option<&str>,
673 suggested_op: Option<&str>,
674) -> TxOutput {
675 let body = format_error_with_backup_hint(error, backup_session);
676 TxOutput {
677 ok: false,
678 status: "error".to_string(),
679 applied: false,
680 files_changed: 0,
681 files_created: 0,
682 files_deleted: 0,
683 files_renamed: 0,
684 changes: Vec::new(),
685 reads: Vec::new(),
686 searches: Vec::new(),
687 lints: Vec::new(),
688 mutations: Vec::new(),
689 changed: None,
690 removed: None,
691 error_kind: Some(error_kind.to_string()),
692 error: Some(format_error_with_kind(error_kind, &body)),
693 suggested_op: suggested_op.map(str::to_string),
694 backup_session: backup_session.map(str::to_string),
695 match_mode: None,
696 match_score: None,
697 match_count: None,
698 matched_text: None,
699 refused: Vec::new(),
700 }
701}
702
703pub(crate) fn build_applied_with_error_output(
708 error_kind: &str,
709 error: &str,
710 result: &mut TxExecResult,
711 cwd: &Path,
712 backup_session: Option<&str>,
713) -> TxOutput {
714 let mut output = build_full_tx_output("error", result, cwd);
715 output.ok = false;
716 output.applied = true;
718 output.error_kind = Some(error_kind.to_string());
719 let body = format_error_with_backup_hint(error, backup_session);
720 output.error = Some(format_error_with_kind(error_kind, &body));
721 if output.backup_session.is_none() {
722 output.backup_session = backup_session.map(str::to_string);
723 }
724 output
725}
726
727pub fn exit_code_from_tx_output(report: &TxOutput) -> u8 {
729 if report.ok {
730 match report.status.as_str() {
733 "no_matches" => exit::NO_MATCHES,
734 "changes_detected" => exit::CHANGES_DETECTED,
735 _ => exit::SUCCESS,
736 }
737 } else {
738 match report.error_kind.as_deref() {
739 Some("no_matches") => exit::NO_MATCHES,
740 Some("parse_error") | Some("parse_timeout") => exit::PARSE_ERROR,
741 Some("ambiguous") => exit::AMBIGUOUS,
742 Some("rollback") => exit::ROLLBACK,
743 Some("rollback_failed") => exit::FAILURE,
744 Some("validation_failed") | Some("format_failed") | Some("verification_failed") => {
745 exit::VALIDATION_FAILED
746 }
747 Some("operation_failed") => exit::OPERATION_FAILED,
748 Some("conflicts") => exit::CONFLICTS,
749 Some("changes_detected") => exit::CHANGES_DETECTED,
750 _ => exit::FAILURE,
752 }
753 }
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759 use std::path::PathBuf;
760
761 fn command_for_exit_code(code: i32) -> std::process::Command {
765 #[cfg(windows)]
766 {
767 let mut cmd = std::process::Command::new("cmd");
768 cmd.args(["/C", &format!("exit {code}")]);
769 cmd
770 }
771 #[cfg(not(windows))]
772 {
773 if code == 0 {
774 std::process::Command::new("true")
775 } else {
776 std::process::Command::new("false")
777 }
778 }
779 }
780
781 #[test]
782 fn describe_exit_status_code_zero() {
783 let status = command_for_exit_code(0).status().unwrap();
784 assert_eq!(describe_exit_status(status), "exit code 0");
785 }
786
787 #[test]
788 fn describe_exit_status_code_nonzero() {
789 let status = command_for_exit_code(1).status().unwrap();
790 assert_eq!(describe_exit_status(status), "exit code 1");
791 }
792
793 #[test]
796 fn describe_lifecycle_cwd_same() {
797 let cwd = Path::new("/tmp/project");
798 assert_eq!(describe_lifecycle_cwd(cwd, cwd), ".");
799 }
800
801 #[test]
802 fn describe_lifecycle_cwd_subdir() {
803 let base = Path::new("/tmp/project");
804 let sub = Path::new("/tmp/project/src/lib");
805 assert_eq!(describe_lifecycle_cwd(base, sub), "src/lib");
806 }
807
808 #[test]
811 fn format_error_without_backup() {
812 assert_eq!(format_error_with_backup_hint("oops", None), "oops");
813 }
814
815 #[test]
816 fn format_error_with_backup() {
817 let msg = format_error_with_backup_hint("oops", Some("20260101T120000"));
818 assert!(msg.contains("backup session 20260101T120000"));
819 assert!(msg.contains("patchloom undo"));
820 }
821
822 #[test]
825 fn build_error_output_fields() {
826 let out = build_error_output("parse_error", "bad plan", None);
827 assert!(!out.ok);
828 assert_eq!(out.status, "error");
829 assert_eq!(out.error_kind.as_deref(), Some("parse_error"));
830 assert!(out.error.as_ref().unwrap().contains("bad plan"));
831 assert_eq!(out.files_changed, 0);
832 assert!(out.backup_session.is_none());
833 }
834
835 #[test]
836 fn format_error_with_kind_skips_duplicate_prefix() {
837 let already = "guard_rejected: path rejected by workspace guard: escapes";
838 assert_eq!(
839 format_error_with_kind("guard_rejected", already),
840 already,
841 "must not double-prefix EditError Display"
842 );
843 assert_eq!(
844 format_error_with_kind("parse_error", "bad plan"),
845 "parse_error: bad plan"
846 );
847 }
848
849 #[test]
850 fn build_error_output_with_backup() {
851 let out = build_error_output("rollback", "fail", Some("ts123"));
852 assert_eq!(out.backup_session.as_deref(), Some("ts123"));
853 assert!(out.error.as_ref().unwrap().contains("patchloom undo"));
854 }
855
856 fn ok_output(status: &str) -> TxOutput {
859 TxOutput {
860 ok: true,
861 status: status.to_string(),
862 applied: status == "success",
863 files_changed: 0,
864 files_created: 0,
865 files_deleted: 0,
866 files_renamed: 0,
867 changes: Vec::new(),
868 reads: Vec::new(),
869 searches: Vec::new(),
870 lints: Vec::new(),
871 mutations: Vec::new(),
872 changed: None,
873 removed: None,
874 error_kind: None,
875 error: None,
876 suggested_op: None,
877 backup_session: None,
878 match_mode: None,
879 match_score: None,
880 match_count: None,
881 matched_text: None,
882 refused: Vec::new(),
883 }
884 }
885
886 fn err_output(kind: &str) -> TxOutput {
887 TxOutput {
888 ok: false,
889 status: "error".to_string(),
890 applied: false,
891 files_changed: 0,
892 files_created: 0,
893 files_deleted: 0,
894 files_renamed: 0,
895 changes: Vec::new(),
896 reads: Vec::new(),
897 searches: Vec::new(),
898 lints: Vec::new(),
899 mutations: Vec::new(),
900 changed: None,
901 removed: None,
902 error_kind: Some(kind.to_string()),
903 error: Some("test error".to_string()),
904 suggested_op: None,
905 backup_session: None,
906 match_mode: None,
907 match_score: None,
908 match_count: None,
909 matched_text: None,
910 refused: Vec::new(),
911 }
912 }
913
914 #[test]
915 fn attach_mutations_sets_aggregates() {
916 let mut out = ok_output("success");
917 attach_mutations(
918 &mut out,
919 vec![
920 TxDocMutation {
921 path: "a.json".into(),
922 op: "doc.delete_where".into(),
923 changed: true,
924 removed: 2,
925 },
926 TxDocMutation {
927 path: "b.json".into(),
928 op: "doc.delete".into(),
929 changed: false,
930 removed: 0,
931 },
932 ],
933 );
934 assert_eq!(out.changed, Some(true));
935 assert_eq!(out.removed, Some(2));
936 assert_eq!(out.mutations.len(), 2);
937 assert_eq!(out.mutations[0].removed, 2);
938 }
939
940 #[test]
941 fn attach_mutations_empty_is_noop() {
942 let mut out = ok_output("success");
943 attach_mutations(&mut out, Vec::new());
944 assert_eq!(out.changed, None);
945 assert_eq!(out.removed, None);
946 assert!(out.mutations.is_empty());
947 }
948
949 #[test]
950 fn exit_code_success() {
951 assert_eq!(
952 exit_code_from_tx_output(&ok_output("success")),
953 exit::SUCCESS
954 );
955 }
956
957 #[test]
958 fn exit_code_ok_changes_detected() {
959 assert_eq!(
961 exit_code_from_tx_output(&ok_output("changes_detected")),
962 exit::CHANGES_DETECTED
963 );
964 }
965
966 #[test]
967 fn exit_code_ok_no_matches() {
968 assert_eq!(
969 exit_code_from_tx_output(&ok_output("no_matches")),
970 exit::NO_MATCHES
971 );
972 }
973
974 #[test]
975 fn exit_code_error_kinds() {
976 assert_eq!(
977 exit_code_from_tx_output(&err_output("no_matches")),
978 exit::NO_MATCHES
979 );
980 assert_eq!(
981 exit_code_from_tx_output(&err_output("parse_error")),
982 exit::PARSE_ERROR
983 );
984 assert_eq!(
985 exit_code_from_tx_output(&err_output("parse_timeout")),
986 exit::PARSE_ERROR
987 );
988 assert_eq!(
989 exit_code_from_tx_output(&err_output("rollback")),
990 exit::ROLLBACK
991 );
992 assert_eq!(
993 exit_code_from_tx_output(&err_output("rollback_failed")),
994 exit::FAILURE
995 );
996 assert_eq!(
997 exit_code_from_tx_output(&err_output("validation_failed")),
998 exit::VALIDATION_FAILED
999 );
1000 assert_eq!(
1001 exit_code_from_tx_output(&err_output("format_failed")),
1002 exit::VALIDATION_FAILED
1003 );
1004 assert_eq!(
1005 exit_code_from_tx_output(&err_output("verification_failed")),
1006 exit::VALIDATION_FAILED
1007 );
1008 assert_eq!(
1009 exit_code_from_tx_output(&err_output("operation_failed")),
1010 exit::OPERATION_FAILED
1011 );
1012 assert_eq!(
1013 exit_code_from_tx_output(&err_output("ambiguous")),
1014 exit::AMBIGUOUS
1015 );
1016 assert_eq!(
1017 exit_code_from_tx_output(&err_output("unknown_kind")),
1018 exit::FAILURE
1019 );
1020 }
1021
1022 #[test]
1023 fn exit_code_from_tx_output_parse_timeout_is_parse_error() {
1024 let out = err_output("parse_timeout");
1025 assert!(!out.ok);
1026 assert_eq!(out.error_kind.as_deref(), Some("parse_timeout"));
1027 assert_eq!(exit_code_from_tx_output(&out), exit::PARSE_ERROR);
1028 assert_ne!(exit_code_from_tx_output(&out), exit::FAILURE);
1029 }
1030
1031 #[test]
1034 fn build_tx_output_classifies_changes() {
1035 let cwd = Path::new("/project");
1036 let existed = HashSet::from([PathBuf::from("/project/existing.txt")]);
1037 let deletions = HashSet::from([PathBuf::from("/project/removed.txt")]);
1038 let changes = vec![
1039 (
1040 PathBuf::from("/project/existing.txt"),
1041 "old".to_string(),
1042 "new".to_string(),
1043 ),
1044 (
1045 PathBuf::from("/project/brand_new.txt"),
1046 String::new(),
1047 "content".to_string(),
1048 ),
1049 (
1050 PathBuf::from("/project/removed.txt"),
1051 "was here".to_string(),
1052 String::new(),
1053 ),
1054 ];
1055
1056 let out = build_tx_output_with_meta(
1057 "success",
1058 true,
1059 cwd,
1060 TxOutputMetaInputs {
1061 changes: &changes,
1062 deletions: &deletions,
1063 existed_before: &existed,
1064 replace_match_meta: &HashMap::new(),
1065 renames: &[],
1066 },
1067 );
1068 assert!(out.ok);
1069 assert_eq!(out.files_changed, 1); assert_eq!(out.files_created, 1); assert_eq!(out.files_deleted, 1); assert_eq!(out.changes.len(), 3);
1073
1074 let actions: Vec<&str> = out.changes.iter().map(|c| c.action.as_str()).collect();
1075 assert!(actions.contains(&"modified"));
1076 assert!(actions.contains(&"created"));
1077 assert!(actions.contains(&"deleted"));
1078 }
1079
1080 #[test]
1082 fn build_tx_output_classifies_file_rename() {
1083 let cwd = Path::new("/project");
1084 let from = PathBuf::from("/project/old.txt");
1085 let to = PathBuf::from("/project/new.txt");
1086 let changes = vec![
1087 (to.clone(), String::new(), "body\n".to_string()),
1088 (from.clone(), "body\n".to_string(), String::new()),
1089 ];
1090 let deletions = HashSet::from([from.clone()]);
1091 let renames = vec![(from, to)];
1092 let out = build_tx_output_with_meta(
1093 "success",
1094 true,
1095 cwd,
1096 TxOutputMetaInputs {
1097 changes: &changes,
1098 deletions: &deletions,
1099 existed_before: &HashSet::new(),
1100 replace_match_meta: &HashMap::new(),
1101 renames: &renames,
1102 },
1103 );
1104 assert_eq!(out.files_renamed, 1);
1105 assert_eq!(out.files_created, 0);
1106 assert_eq!(out.files_deleted, 0);
1107 assert_eq!(out.files_changed, 0);
1108 assert_eq!(out.changes.len(), 1);
1109 assert_eq!(out.changes[0].action, "renamed");
1110 assert_eq!(out.changes[0].path, "new.txt");
1111 assert_eq!(out.changes[0].from.as_deref(), Some("old.txt"));
1112 assert_eq!(out.changes[0].to.as_deref(), Some("new.txt"));
1113 }
1114
1115 #[test]
1116 fn build_tx_output_empty_changes() {
1117 let cwd = Path::new("/project");
1118 let out = build_tx_output_with_meta(
1119 "success",
1120 true,
1121 cwd,
1122 TxOutputMetaInputs {
1123 changes: &[],
1124 deletions: &HashSet::new(),
1125 existed_before: &HashSet::new(),
1126 replace_match_meta: &HashMap::new(),
1127 renames: &[],
1128 },
1129 );
1130 assert_eq!(out.files_changed, 0);
1131 assert_eq!(out.files_created, 0);
1132 assert_eq!(out.files_deleted, 0);
1133 assert!(out.changes.is_empty());
1134 }
1135
1136 #[test]
1137 fn build_tx_output_includes_replace_match_mode() {
1138 let cwd = Path::new("/project");
1139 let path = PathBuf::from("/project/a.txt");
1140 let existed = HashSet::from([path.clone()]);
1141 let changes = vec![(path.clone(), "old".into(), "new".into())];
1142 let mut meta = HashMap::new();
1143 meta.insert(
1144 path,
1145 ReplaceMatchMeta {
1146 mode: crate::api::MatchMode::Fuzzy,
1147 score: Some(0.91),
1148 match_count: 1,
1149 matched_text: Some("proccess".into()),
1150 refuse_reason: None,
1151 },
1152 );
1153 let out = build_tx_output_with_meta(
1154 "success",
1155 true,
1156 cwd,
1157 TxOutputMetaInputs {
1158 changes: &changes,
1159 deletions: &HashSet::new(),
1160 existed_before: &existed,
1161 replace_match_meta: &meta,
1162 renames: &[],
1163 },
1164 );
1165 assert_eq!(out.match_mode.as_deref(), Some("fuzzy"));
1166 assert_eq!(out.match_score, Some(0.91));
1167 assert_eq!(out.match_count, Some(1));
1168 assert_eq!(out.changes.len(), 1);
1169 assert_eq!(out.changes[0].match_mode.as_deref(), Some("fuzzy"));
1170 assert_eq!(out.changes[0].match_score, Some(0.91));
1171 assert_eq!(out.changes[0].match_count, Some(1));
1172 assert_eq!(out.matched_text.as_deref(), Some("proccess"));
1173 assert_eq!(out.changes[0].matched_text.as_deref(), Some("proccess"));
1174 let json = serde_json::to_string(&out).unwrap();
1175 assert!(json.contains("\"match_mode\":\"fuzzy\""), "{json}");
1176 assert!(json.contains("\"match_count\":1"), "{json}");
1177 assert!(json.contains("\"matched_text\":\"proccess\""), "{json}");
1178 }
1179
1180 #[test]
1182 fn build_tx_output_fuzzy_agg_score_is_minimum() {
1183 let cwd = Path::new("/project");
1184 let a = PathBuf::from("/project/a.txt");
1185 let b = PathBuf::from("/project/b.txt");
1186 let existed = HashSet::from([a.clone(), b.clone()]);
1187 let changes = vec![
1188 (a.clone(), "old".into(), "new".into()),
1189 (b.clone(), "old".into(), "new".into()),
1190 ];
1191 let mut meta = HashMap::new();
1192 meta.insert(
1193 a,
1194 ReplaceMatchMeta {
1195 mode: crate::api::MatchMode::Fuzzy,
1196 score: Some(0.95),
1197 match_count: 1,
1198 matched_text: Some("short".into()),
1199 refuse_reason: None,
1200 },
1201 );
1202 meta.insert(
1203 b,
1204 ReplaceMatchMeta {
1205 mode: crate::api::MatchMode::Fuzzy,
1206 score: Some(0.80),
1207 match_count: 1,
1208 matched_text: Some("much_wider_matched_span".into()),
1209 refuse_reason: None,
1210 },
1211 );
1212 let out = build_tx_output_with_meta(
1213 "success",
1214 true,
1215 cwd,
1216 TxOutputMetaInputs {
1217 changes: &changes,
1218 deletions: &HashSet::new(),
1219 existed_before: &existed,
1220 replace_match_meta: &meta,
1221 renames: &[],
1222 },
1223 );
1224 assert_eq!(out.match_mode.as_deref(), Some("fuzzy"));
1225 assert_eq!(
1226 out.match_score,
1227 Some(0.80),
1228 "worst-case aggregate score must be the min fuzzy score"
1229 );
1230 assert_eq!(
1231 out.matched_text.as_deref(),
1232 Some("much_wider_matched_span"),
1233 "multi-path top-level matched_text must be widest span (#2007)"
1234 );
1235 assert_eq!(out.match_count, Some(2));
1236 }
1237
1238 #[test]
1241 fn build_tx_output_matched_text_when_single_replace_among_other_changes() {
1242 let cwd = Path::new("/project");
1243 let replaced = PathBuf::from("/project/a.txt");
1244 let other = PathBuf::from("/project/b.txt");
1245 let existed = HashSet::from([replaced.clone(), other.clone()]);
1246 let changes = vec![
1247 (replaced.clone(), "old".into(), "new".into()),
1248 (other, "x".into(), "y".into()),
1249 ];
1250 let mut meta = HashMap::new();
1251 meta.insert(
1252 replaced,
1253 ReplaceMatchMeta {
1254 mode: crate::api::MatchMode::Fuzzy,
1255 score: Some(0.88),
1256 match_count: 1,
1257 matched_text: Some("live_span".into()),
1258 refuse_reason: None,
1259 },
1260 );
1261 let out = build_tx_output_with_meta(
1262 "success",
1263 true,
1264 cwd,
1265 TxOutputMetaInputs {
1266 changes: &changes,
1267 deletions: &HashSet::new(),
1268 existed_before: &existed,
1269 replace_match_meta: &meta,
1270 renames: &[],
1271 },
1272 );
1273 assert_eq!(
1274 out.matched_text.as_deref(),
1275 Some("live_span"),
1276 "single replace path must surface matched_text even when other files changed"
1277 );
1278 assert_eq!(out.match_score, Some(0.88));
1279 }
1280
1281 #[test]
1282 fn applied_true_on_success_status_false_on_preview() {
1283 let preview = build_tx_output_with_meta(
1284 "changes_detected",
1285 true,
1286 Path::new("/tmp"),
1287 TxOutputMetaInputs {
1288 changes: &[],
1289 deletions: &Default::default(),
1290 existed_before: &Default::default(),
1291 replace_match_meta: &Default::default(),
1292 renames: &[],
1293 },
1294 );
1295 assert!(!preview.applied, "preview must set applied=false");
1296 let noop = build_tx_output_with_meta(
1298 "success",
1299 true,
1300 Path::new("/tmp"),
1301 TxOutputMetaInputs {
1302 changes: &[],
1303 deletions: &Default::default(),
1304 existed_before: &Default::default(),
1305 replace_match_meta: &Default::default(),
1306 renames: &[],
1307 },
1308 );
1309 assert!(
1310 !noop.applied,
1311 "success with no file mutations must set applied=false"
1312 );
1313 let mut existed = HashSet::new();
1314 existed.insert(PathBuf::from("/tmp/a.txt"));
1315 let changes = vec![(
1316 PathBuf::from("/tmp/a.txt"),
1317 "old".to_string(),
1318 "new".to_string(),
1319 )];
1320 let applied = build_tx_output_with_meta(
1321 "success",
1322 true,
1323 Path::new("/tmp"),
1324 TxOutputMetaInputs {
1325 changes: &changes,
1326 deletions: &Default::default(),
1327 existed_before: &existed,
1328 replace_match_meta: &Default::default(),
1329 renames: &[],
1330 },
1331 );
1332 assert!(
1333 applied.applied,
1334 "success with real file mutations must set applied=true"
1335 );
1336 let err = build_error_output("invalid_input", "nope", None);
1337 assert!(!err.applied, "pure error must set applied=false");
1338 }
1339
1340 #[test]
1343 fn tx_output_serde_round_trip() {
1344 let out = ok_output("success");
1345 let json = serde_json::to_string(&out).unwrap();
1346 let parsed: TxOutput = serde_json::from_str(&json).unwrap();
1347 assert_eq!(parsed.ok, out.ok);
1348 assert_eq!(parsed.status, out.status);
1349 }
1350
1351 #[test]
1352 fn tx_output_skips_empty_optional_fields() {
1353 let out = ok_output("success");
1354 let json = serde_json::to_string(&out).unwrap();
1355 assert!(!json.contains("\"reads\""));
1357 assert!(!json.contains("\"searches\""));
1358 assert!(!json.contains("\"lints\""));
1359 assert!(!json.contains("\"error\""));
1360 }
1361
1362 #[test]
1364 fn build_full_tx_output_no_matches_includes_hint() {
1365 use std::collections::HashMap;
1366 let cwd = Path::new("/project");
1367 let mut result = TxExecResult {
1368 changes: vec![],
1369 deletions: HashSet::new(),
1370 existed_before: HashSet::new(),
1371 pending: HashMap::new(),
1372 tx_reads: vec![],
1373 tx_searches: vec![],
1374 tx_lints: vec![],
1375 tx_mutations: vec![],
1376 no_effective_changes: true,
1377 replace_no_matches: true,
1378 replace_hint: Some(
1379 "fuzzy match score 0.900 below min_fuzzy_score 1 for \"proccess\"".into(),
1380 ),
1381 replace_match_meta: HashMap::new(),
1382 renames: vec![],
1383 };
1384 let out = build_full_tx_output("no_matches", &mut result, cwd);
1385 assert_eq!(out.status, "no_matches");
1386 assert!(
1387 !out.ok,
1388 "no_matches must set ok:false so MCP/CLI agents agree (#1791)"
1389 );
1390 assert_eq!(out.error_kind.as_deref(), Some("no_matches"));
1391 assert!(
1392 out.error
1393 .as_deref()
1394 .is_some_and(|e| e.contains("min_fuzzy_score")),
1395 "hint must appear in error: {:?}",
1396 out.error
1397 );
1398 assert_eq!(exit_code_from_tx_output(&out), exit::NO_MATCHES);
1399 }
1400
1401 #[test]
1404 fn build_full_tx_output_no_matches_includes_refuse_match_meta() {
1405 use std::collections::HashMap;
1406 let cwd = Path::new("/project");
1407 let mut meta = HashMap::new();
1408 meta.insert(
1409 PathBuf::from("/project/app.py"),
1410 ReplaceMatchMeta {
1411 mode: crate::api::MatchMode::Fuzzy,
1412 score: Some(0.987),
1413 match_count: 0,
1414 matched_text: Some("compute_checksum".into()),
1415 refuse_reason: None,
1416 },
1417 );
1418 let mut result = TxExecResult {
1419 changes: vec![],
1420 deletions: HashSet::new(),
1421 existed_before: HashSet::new(),
1422 pending: HashMap::new(),
1423 tx_reads: vec![],
1424 tx_searches: vec![],
1425 tx_lints: vec![],
1426 tx_mutations: vec![],
1427 no_effective_changes: true,
1428 replace_no_matches: true,
1429 replace_hint: Some("exact old absent; best fuzzy candidate".into()),
1430 replace_match_meta: meta,
1431 renames: vec![],
1432 };
1433 let out = build_full_tx_output("no_matches", &mut result, cwd);
1434 assert_eq!(out.status, "no_matches");
1435 assert_eq!(out.match_mode.as_deref(), Some("fuzzy"));
1436 assert_eq!(out.match_score, Some(0.987));
1437 assert_eq!(out.matched_text.as_deref(), Some("compute_checksum"));
1438 assert_eq!(out.match_count, Some(0));
1439 }
1440
1441 #[test]
1443 fn build_full_tx_output_partial_success_ignores_refuse_meta_in_aggregate() {
1444 use std::collections::HashMap;
1445 let cwd = Path::new("/project");
1446 let changed = PathBuf::from("/project/a.txt");
1447 let refused = PathBuf::from("/project/b.txt");
1448 let mut meta = HashMap::new();
1449 meta.insert(
1450 changed.clone(),
1451 ReplaceMatchMeta {
1452 mode: crate::api::MatchMode::Exact,
1453 score: None,
1454 match_count: 1,
1455 matched_text: None,
1456 refuse_reason: None,
1457 },
1458 );
1459 meta.insert(
1460 refused,
1461 ReplaceMatchMeta {
1462 mode: crate::api::MatchMode::Fuzzy,
1463 score: Some(0.97),
1464 match_count: 0,
1465 matched_text: Some("helo world".into()),
1466 refuse_reason: Some("exact_old_absent"),
1467 },
1468 );
1469 let mut result = TxExecResult {
1470 changes: vec![(changed, "hello world\n".into(), "hi\n".into())],
1471 deletions: HashSet::new(),
1472 existed_before: {
1473 let mut s = HashSet::new();
1474 s.insert(PathBuf::from("/project/a.txt"));
1475 s
1476 },
1477 pending: HashMap::new(),
1478 tx_reads: vec![],
1479 tx_searches: vec![],
1480 tx_lints: vec![],
1481 tx_mutations: vec![],
1482 no_effective_changes: false,
1483 replace_no_matches: false,
1484 replace_hint: Some("exact old absent".into()),
1485 replace_match_meta: meta,
1486 renames: vec![],
1487 };
1488 let out = build_full_tx_output("success", &mut result, cwd);
1489 assert_eq!(out.status, "success");
1490 assert_eq!(out.match_mode.as_deref(), Some("exact"));
1491 assert!(out.match_score.is_none());
1492 assert_eq!(out.match_count, Some(1));
1493 assert_eq!(out.refused.len(), 1);
1494 assert_eq!(out.refused[0].path, "b.txt");
1495 assert_eq!(out.refused[0].match_mode.as_deref(), Some("fuzzy"));
1496 assert_eq!(out.refused[0].reason, "exact_old_absent");
1497 assert_eq!(out.refused[0].matched_text.as_deref(), Some("helo world"));
1498 }
1499
1500 #[test]
1503 fn build_applied_with_error_output_includes_changes_and_backup() {
1504 use std::collections::HashMap;
1505 let cwd = Path::new("/project");
1506 let path = PathBuf::from("/project/a.txt");
1507 let mut meta = HashMap::new();
1508 meta.insert(
1509 path.clone(),
1510 ReplaceMatchMeta {
1511 mode: crate::api::MatchMode::Exact,
1512 score: None,
1513 match_count: 1,
1514 matched_text: None,
1515 refuse_reason: None,
1516 },
1517 );
1518 let mut result = TxExecResult {
1519 changes: vec![(path, "hello\n".into(), "world\n".into())],
1520 deletions: HashSet::new(),
1521 existed_before: {
1522 let mut s = HashSet::new();
1523 s.insert(PathBuf::from("/project/a.txt"));
1524 s
1525 },
1526 pending: HashMap::new(),
1527 tx_reads: vec![],
1528 tx_searches: vec![],
1529 tx_lints: vec![],
1530 tx_mutations: vec![],
1531 no_effective_changes: false,
1532 replace_no_matches: false,
1533 replace_hint: None,
1534 replace_match_meta: meta,
1535 renames: vec![],
1536 };
1537 let out = build_applied_with_error_output(
1538 "format_failed",
1539 "format step failed (step 1, exit code 1, cwd: .)",
1540 &mut result,
1541 cwd,
1542 Some("123_0"),
1543 );
1544 assert!(!out.ok);
1545 assert_eq!(out.error_kind.as_deref(), Some("format_failed"));
1546 assert_eq!(out.files_changed, 1);
1547 assert_eq!(out.changes.len(), 1);
1548 assert_eq!(out.backup_session.as_deref(), Some("123_0"));
1549 assert!(
1550 out.error
1551 .as_deref()
1552 .is_some_and(|e| e.contains("backup session") && e.contains("undo")),
1553 "{:?}",
1554 out.error
1555 );
1556 }
1557
1558 #[test]
1562 fn build_full_tx_output_partial_success_surfaces_exact_soft_no_match() {
1563 use std::collections::HashMap;
1564 let cwd = Path::new("/project");
1565 let created = PathBuf::from("/project/g.txt");
1566 let missed = PathBuf::from("/project/f.txt");
1567 let mut meta = HashMap::new();
1568 meta.insert(
1569 missed,
1570 ReplaceMatchMeta {
1571 mode: crate::api::MatchMode::Exact,
1572 score: None,
1573 match_count: 0,
1574 matched_text: None,
1575 refuse_reason: Some("no_matches"),
1576 },
1577 );
1578 let mut result = TxExecResult {
1579 changes: vec![(created, String::new(), "hi\n".into())],
1580 deletions: HashSet::new(),
1581 existed_before: HashSet::new(),
1582 pending: HashMap::new(),
1583 tx_reads: vec![],
1584 tx_searches: vec![],
1585 tx_lints: vec![],
1586 tx_mutations: vec![],
1587 no_effective_changes: false,
1588 replace_no_matches: false,
1589 replace_hint: Some("no matches for 'missing' in f.txt".into()),
1590 replace_match_meta: meta,
1591 renames: vec![],
1592 };
1593 let out = build_full_tx_output("success", &mut result, cwd);
1594 assert_eq!(out.status, "success");
1595 assert_eq!(out.files_created, 1);
1596 assert_eq!(
1597 out.refused.len(),
1598 1,
1599 "exact soft miss must surface: {out:?}"
1600 );
1601 assert_eq!(out.refused[0].path, "f.txt");
1602 assert_eq!(out.refused[0].reason, "no_matches");
1603 assert_eq!(out.refused[0].match_mode.as_deref(), Some("exact"));
1604 assert!(out.refused[0].matched_text.is_none());
1605 }
1606
1607 #[test]
1610 fn tx_output_deserializes_minimal_json_without_match_fields() {
1611 let json = r#"{"ok":true,"status":"success","files_changed":0,"files_created":0,"files_deleted":0,"changes":[]}"#;
1612 let parsed: TxOutput = serde_json::from_str(json).expect("minimal TxOutput JSON");
1613 assert!(parsed.ok);
1614 assert!(parsed.match_mode.is_none());
1615 assert!(parsed.match_score.is_none());
1616 assert!(parsed.match_count.is_none());
1617 assert!(parsed.matched_text.is_none());
1618 assert!(parsed.backup_session.is_none());
1619 assert!(parsed.error_kind.is_none());
1620 }
1621}