Skip to main content

patchloom/tx/
output.rs

1//! size-waiver: accepted single-domain bulk (policy #1408). Tx JSON output
2//! assembly and match honesty aggregation for plan/CLI/MCP is one unit; do not
3//! split for LOC alone.
4
5use crate::exit;
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9
10/// Structured report from `execute_plan` (and the `tx` command).
11///
12/// Library users can deserialize the JSON string returned by `execute_plan`
13/// into this type for typed access instead of string parsing.
14/// See #805 and the embedding docs.
15///
16/// Marked `non_exhaustive` so new honesty fields can land in minor releases
17/// without breaking external struct literals. Serde deserialization is
18/// unaffected (`#[serde(default)]` on optional fields).
19#[derive(Serialize, Deserialize, Debug, Clone)]
20#[non_exhaustive]
21pub struct TxOutput {
22    pub ok: bool,
23    pub status: String,
24    /// Whether bytes were written to disk for this report (#1808 parity for
25    /// plan/batch/tx). True after successful apply or post-commit lifecycle
26    /// errors; false for preview/check and pure failures.
27    #[serde(default)]
28    pub applied: bool,
29    pub files_changed: usize,
30    pub files_created: usize,
31    pub files_deleted: usize,
32    /// Number of `file.rename` pairs reported as a single `action: "renamed"`
33    /// change (not double-counted as create+delete). Fixrealloop 2026-07-20.
34    /// Omitted when zero so agents without renames keep the prior JSON shape.
35    #[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    /// Per-op doc delete / delete-where summaries (#1439). Empty when the plan
45    /// had no such ops. Prefer this for multi-op plans; top-level `changed` /
46    /// `removed` are aggregates when present.
47    #[serde(skip_serializing_if = "Vec::is_empty", default)]
48    pub mutations: Vec<TxDocMutation>,
49    /// Aggregate of [`TxDocMutation::changed`] when `mutations` is non-empty.
50    /// Mirrors CLI doc write JSON so agents can treat exit 0 + `removed: 0`
51    /// as an idempotent no-op without re-reading the file.
52    #[serde(skip_serializing_if = "Option::is_none", default)]
53    pub changed: Option<bool>,
54    /// Sum of [`TxDocMutation::removed`] when `mutations` is non-empty.
55    #[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    /// Machine-stable alternate plan op (e.g. `"doc.update"`) on fail-closed
62    /// write-nav predicate errors (#2133). Omitted when none applies.
63    #[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    /// Aggregate replace match honesty when every replace-backed change agrees
68    /// (or worst-case: fuzzy > anchored > exact). See #1674.
69    #[serde(skip_serializing_if = "Option::is_none", default)]
70    pub match_mode: Option<String>,
71    /// Similarity score when aggregate [`Self::match_mode`] is fuzzy.
72    #[serde(skip_serializing_if = "Option::is_none", default)]
73    pub match_score: Option<f64>,
74    /// Sum of per-path replace match counts when any replace meta was recorded.
75    /// Lets MCP/CLI agents read honesty without a second content pass.
76    #[serde(skip_serializing_if = "Option::is_none", default)]
77    pub match_count: Option<usize>,
78    /// Widest fuzzy/anchored matched span across replace paths (Unicode chars),
79    /// same worst-case rollup as multi-op content_edits (#1736 / #2007).
80    /// Aggregate [`Self::match_score`] is min and may come from a different path;
81    /// use `changes[].matched_text` / plan `old` for refuse pairing.
82    #[serde(skip_serializing_if = "Option::is_none", default)]
83    pub matched_text: Option<String>,
84    /// Soft-refuse / soft-skip replace paths that did not write. Includes
85    /// fuzzy fail-closed (`exact_old_absent`) and exact soft no-match
86    /// (`no_matches`) so multi-op success does not hide a silent miss
87    /// (fixrealloop 2026-07-16). Parity with CLI `replace` `refused[]`.
88    #[serde(skip_serializing_if = "Vec::is_empty", default)]
89    pub refused: Vec<TxRefused>,
90}
91
92/// One soft-refuse path in a plan/tx report (fuzzy fail-closed without a write).
93#[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    /// Machine-readable reason (`exact_old_absent` or `no_write`).
103    pub reason: String,
104}
105
106/// One doc delete / delete-where outcome inside a plan/tx report (#1439).
107#[derive(Serialize, Deserialize, Debug, Clone)]
108pub struct TxDocMutation {
109    pub path: String,
110    /// Plan op name, e.g. `doc.delete` or `doc.delete_where`.
111    pub op: String,
112    pub changed: bool,
113    pub removed: usize,
114}
115
116/// A single file change in a plan/tx report.
117///
118/// Marked `non_exhaustive` so new honesty fields can land in minor releases
119/// without breaking external struct literals.
120#[derive(Serialize, Deserialize, Debug, Clone)]
121#[non_exhaustive]
122pub struct TxChange {
123    pub path: String,
124    pub action: String,
125    /// Source path when [`Self::action`] is `renamed` (display-relative).
126    #[serde(skip_serializing_if = "Option::is_none", default)]
127    pub from: Option<String>,
128    /// Destination path when [`Self::action`] is `renamed` (display-relative).
129    #[serde(skip_serializing_if = "Option::is_none", default)]
130    pub to: Option<String>,
131    /// Replace match honesty for this path (`exact` / `fuzzy` / `anchored`).
132    /// Omitted for non-replace changes. See #1674 / #1669.
133    #[serde(skip_serializing_if = "Option::is_none", default)]
134    pub match_mode: Option<String>,
135    /// Similarity score when [`Self::match_mode`] is fuzzy.
136    #[serde(skip_serializing_if = "Option::is_none", default)]
137    pub match_score: Option<f64>,
138    /// Number of replace matches for this path (from engine meta). Omitted for
139    /// non-replace changes. Prefer this over re-deriving after Apply.
140    #[serde(skip_serializing_if = "Option::is_none", default)]
141    pub match_count: Option<usize>,
142    /// Text actually matched for fuzzy/anchored replace on this path (#1736).
143    #[serde(skip_serializing_if = "Option::is_none", default)]
144    pub matched_text: Option<String>,
145    /// YAML presentation style shifted (e.g. block-sequence indent collapse)
146    /// while values may still be correct (#2070). Omitted when false.
147    #[serde(skip_serializing_if = "std::ops::Not::not", default)]
148    pub style_changed: bool,
149}
150
151/// Presentation honesty for a path write (#2070 / single helper in ops::doc).
152fn 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/// A search match in the tx output.
157#[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/// A search result in the tx output.
169#[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    /// True when `matches` was capped by `max_results` while `match_count` is
176    /// the full total (same honesty as CLI `search --json` truncated).
177    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
178    pub truncated: bool,
179}
180
181/// A file read result in the tx output.
182#[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/// A lint result in the tx output.
192#[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
199/// Intermediate result from executing all operations in a plan and applying
200/// write policy. Contains everything needed for callers to decide on output
201/// mode, commit changes, and run lifecycle steps.
202pub(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    /// Original pending map, retained for `rollback_strict`.
207    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    /// "Did you mean?" hints when a replace found zero matches.
215    pub(crate) replace_hint: Option<String>,
216    /// Per-path replace match honesty recorded during plan execution (#1674).
217    pub(crate) replace_match_meta: HashMap<PathBuf, ReplaceMatchMeta>,
218    /// Explicit `file.rename` pairs `(from, to)` for hardlink-preserving
219    /// commit via `fs::rename` (including rename-then-edit in one plan).
220    pub(crate) renames: Vec<(PathBuf, PathBuf)>,
221}
222
223/// Per-path replace match honesty recorded during plan execution.
224#[derive(Debug, Clone)]
225pub(crate) struct ReplaceMatchMeta {
226    pub mode: crate::api::MatchMode,
227    pub score: Option<f64>,
228    pub match_count: usize,
229    /// Fuzzy/anchored span text actually matched (may differ from plan `old`). #1736
230    pub matched_text: Option<String>,
231    /// Soft-no-write reason when `match_count` is 0 (`exact_old_absent`,
232    /// `below_min_fuzzy_score`, `no_matches`). Used for CLI/tx `refused[]`
233    /// honesty.
234    pub refuse_reason: Option<&'static str>,
235}
236
237/// Apply mutation summaries onto a [`TxOutput`] (aggregates + list).
238pub(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
249/// JSON label for [`crate::api::MatchMode`] (CLI/MCP parity: snake_case strings).
250pub(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
258/// Worst-case rollup: fuzzy > anchored > exact (#1674 / #1673).
259///
260/// Thin re-export of [`crate::api::merge_match_modes`] so `tx::replace_op`
261/// and callers keep a stable import path.
262pub(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
283/// Staged path/meta inputs for [`build_tx_output_with_meta`] (keeps arg count low).
284pub(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    // Explicit file.rename pairs: one "renamed" row (not create+delete).
323    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            // Destination is the surviving path agents care about.
346            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    // O(1) membership for deletion/refuse loops (AI finding + large-tx scale).
360    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        // Covered by a renamed entry (source deleted / dest created).
365        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                // Worst-case confidence: keep the lowest fuzzy score across paths.
378                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    // Deletions not captured in changes (empty files).
427    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    // Soft full refuses (fuzzy fail-closed #1758) store honesty without a write.
464    // Fold only when there is no write surface: otherwise refuse meta would poison
465    // success aggregates (e.g. exact multi-file apply + one soft refuse → match_mode
466    // "fuzzy"). Partial refuses are listed in `refused[]` instead.
467    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    // Paths with recorded meta but no write (fuzzy refuse / floor skip /
483    // exact soft no-match). Multi-op success must still list these so agents
484    // do not treat overall ok as "every replace applied".
485    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        // Only surface zero-match soft skips (writes already listed in changes).
491        if m.match_count != 0 {
492            continue;
493        }
494        // Fuzzy/anchored candidates carry matched_text; exact soft no-match
495        // carries refuse_reason "no_matches" without a candidate span.
496        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        // Only claim applied when a real commit mutated files. Status
532        // "success" is also used for dry-run no-ops and lint-only clean
533        // plans; agents branch on applied for undo.
534        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        // Worst-case (widest) span across replace paths (#2007), same as
558        // content_edits multi-op rollup. Hosts that need per-path pairing use
559        // `changes[].matched_text` / `old` at the plan layer.
560        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    // Soft no-match replaces: status=no_matches, exit 3. Agents and MCP hosts
587    // branch on `ok` first (#1791); keep ok:false so MCP is_error and body agree
588    // with CLI --json replace. Still surface error_kind + replace_hint (#1753).
589    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    // Lint-only (or any plan with lint issues): match CLI md lint-agents
600    // exit 2 / ok:false so agents branching on ok/exit do not treat dirty
601    // AGENTS.md as clean. Clear applied: lint never writes.
602    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            // Lint does not mutate files; never claim applied.
612            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
649/// Prefix `error` with `error_kind:` unless it already starts with that kind
650/// (EditError Display is already `{kind}: {message}`).
651fn 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
668/// Like [`build_error_output`], with optional machine-stable `suggested_op` (#2133).
669pub(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
703/// Non-strict lifecycle failure after commit: files are already on disk.
704///
705/// Agents must see `files_changed` / `changes` / `backup_session` so they do
706/// not treat `ok: false` as "nothing wrote" (fixrealloop 2026-07-16).
707pub(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    // Writes already committed before this error path.
717    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
727/// Map a `TxOutput` (PlanReport) to the traditional exit code for CLI/MCP compat.
728pub fn exit_code_from_tx_output(report: &TxOutput) -> u8 {
729    if report.ok {
730        // Preview/check with mutations keeps ok:true (not an error) but exit 2.
731        // Lint issues force ok:false + error_kind=changes_detected (handled below).
732        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            // parse_error already handled above; keep exhaustive for clarity
751            _ => exit::FAILURE,
752        }
753    }
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759    use std::path::PathBuf;
760
761    // ---- describe_exit_status ----
762
763    /// Spawn a process that exits with the given code on both Unix and Windows.
764    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    // ---- describe_lifecycle_cwd ----
794
795    #[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    // ---- format_error_with_backup_hint ----
809
810    #[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    // ---- build_error_output ----
823
824    #[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    // ---- exit_code_from_tx_output ----
857
858    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        // Dry-run/check with file changes: ok:true, status changes_detected, exit 2.
960        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    // ---- build_tx_output ----
1032
1033    #[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); // existing.txt modified
1070        assert_eq!(out.files_created, 1); // brand_new.txt
1071        assert_eq!(out.files_deleted, 1); // removed.txt
1072        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    /// Explicit file.rename must surface as one `renamed` change, not create+delete.
1081    #[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    /// Multi-path fuzzy aggregate must report the minimum score (worst case).
1181    #[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    /// Single replace path still surfaces top-level matched_text among other
1239    /// non-replace changes (#2007 multi-path widest still covers the lone span).
1240    #[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        // success with zero mutations is a no-op / dry-run identity: applied=false
1297        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    // ---- TxOutput serde round-trip ----
1341
1342    #[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        // Empty reads/searches/lints should be omitted
1356        assert!(!json.contains("\"reads\""));
1357        assert!(!json.contains("\"searches\""));
1358        assert!(!json.contains("\"lints\""));
1359        assert!(!json.contains("\"error\""));
1360    }
1361
1362    /// Soft no_matches reports must carry error_kind + replace_hint for agents.
1363    #[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    /// Soft refuse (#1758) records replace_match_meta without a write; no_matches
1402    /// JSON must still expose match_mode / match_score / matched_text.
1403    #[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    /// Partial apply + soft refuse must not report aggregate match_mode=fuzzy.
1442    #[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    /// Non-strict format/validation failure after commit must list applied
1501    /// changes (not empty files_changed=0).
1502    #[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    /// Multi-op success with an exact soft no-match must list refused[] so
1559    /// agents do not treat overall ok as every replace having applied
1560    /// (fixrealloop 2026-07-16).
1561    #[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    /// Hosts may deserialize older plan/tx JSON that never had match honesty
1608    /// fields. Missing keys must default to None (parity with TxChange).
1609    #[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}