use crate::exit;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct TxOutput {
pub ok: bool,
pub status: String,
#[serde(default)]
pub applied: bool,
pub files_changed: usize,
pub files_created: usize,
pub files_deleted: usize,
#[serde(default, skip_serializing_if = "is_zero_usize")]
pub files_renamed: usize,
pub changes: Vec<TxChange>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub reads: Vec<TxReadResult>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub searches: Vec<TxSearchResult>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub lints: Vec<TxLintResult>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub mutations: Vec<TxDocMutation>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub changed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub removed: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub error_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub backup_session: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub match_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub match_score: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub match_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub matched_text: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub refused: Vec<TxRefused>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TxRefused {
pub path: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub match_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub match_score: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub matched_text: Option<String>,
pub reason: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TxDocMutation {
pub path: String,
pub op: String,
pub changed: bool,
pub removed: usize,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct TxChange {
pub path: String,
pub action: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub match_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub match_score: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub match_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub matched_text: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TxSearchMatch {
pub line: usize,
pub column: usize,
pub text: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub context_before: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub context_after: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TxSearchResult {
pub path: String,
pub pattern: String,
pub match_count: usize,
pub matches: Vec<TxSearchMatch>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub truncated: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TxReadResult {
pub path: String,
pub content: String,
pub start_line: usize,
pub end_line: usize,
pub total_lines: usize,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TxLintResult {
pub path: String,
pub issue_count: usize,
pub issues: Vec<crate::ops::md::LintIssue>,
}
pub(crate) struct TxExecResult {
pub(crate) changes: Vec<(PathBuf, String, String)>,
pub(crate) deletions: HashSet<PathBuf>,
pub(crate) existed_before: HashSet<PathBuf>,
pub(crate) pending: HashMap<PathBuf, (String, String)>,
pub(crate) tx_reads: Vec<TxReadResult>,
pub(crate) tx_searches: Vec<TxSearchResult>,
pub(crate) tx_lints: Vec<TxLintResult>,
pub(crate) tx_mutations: Vec<TxDocMutation>,
pub(crate) no_effective_changes: bool,
pub(crate) replace_no_matches: bool,
pub(crate) replace_hint: Option<String>,
pub(crate) replace_match_meta: HashMap<PathBuf, ReplaceMatchMeta>,
pub(crate) renames: Vec<(PathBuf, PathBuf)>,
}
#[derive(Debug, Clone)]
pub(crate) struct ReplaceMatchMeta {
pub mode: crate::api::MatchMode,
pub score: Option<f64>,
pub match_count: usize,
pub matched_text: Option<String>,
pub refuse_reason: Option<&'static str>,
}
pub(crate) fn attach_mutations(output: &mut TxOutput, mutations: Vec<TxDocMutation>) {
if mutations.is_empty() {
return;
}
let changed = mutations.iter().any(|m| m.changed);
let removed = mutations.iter().map(|m| m.removed).sum();
output.changed = Some(changed);
output.removed = Some(removed);
output.mutations = mutations;
}
pub(crate) fn match_mode_label(mode: crate::api::MatchMode) -> &'static str {
match mode {
crate::api::MatchMode::Exact => "exact",
crate::api::MatchMode::Fuzzy => "fuzzy",
crate::api::MatchMode::Anchored => "anchored",
}
}
pub(crate) use crate::api::merge_match_modes;
fn match_meta_for_path(
path: &Path,
meta: &HashMap<PathBuf, ReplaceMatchMeta>,
) -> (Option<String>, Option<f64>, Option<usize>, Option<String>) {
match meta.get(path) {
Some(m) => (
Some(match_mode_label(m.mode).to_string()),
m.score,
Some(m.match_count),
m.matched_text.clone(),
),
None => (None, None, None, None),
}
}
fn is_zero_usize(n: &usize) -> bool {
*n == 0
}
pub(crate) struct TxOutputMetaInputs<'a> {
pub changes: &'a [(PathBuf, String, String)],
pub deletions: &'a HashSet<PathBuf>,
pub existed_before: &'a HashSet<PathBuf>,
pub replace_match_meta: &'a HashMap<PathBuf, ReplaceMatchMeta>,
pub renames: &'a [(PathBuf, PathBuf)],
}
pub(crate) fn build_tx_output_with_meta(
status: &'static str,
ok: bool,
cwd: &Path,
inputs: TxOutputMetaInputs<'_>,
) -> TxOutput {
let TxOutputMetaInputs {
changes,
deletions,
existed_before,
replace_match_meta,
renames,
} = inputs;
let mut tx_changes = Vec::new();
let mut created = 0usize;
let mut deleted_count = 0usize;
let mut modified = 0usize;
let mut renamed_count = 0usize;
let mut agg_mode: Option<crate::api::MatchMode> = None;
let mut agg_score: Option<f64> = None;
let mut agg_count: usize = 0;
let mut any_replace_meta = false;
let mut top_matched_text: Option<String> = None;
let display_path = |p: &Path| -> String {
crate::files::relative_display(p, cwd)
.to_string_lossy()
.into_owned()
};
let mut rename_from: HashSet<PathBuf> = HashSet::new();
let mut rename_to: HashSet<PathBuf> = HashSet::new();
for (from, to) in renames {
rename_from.insert(from.clone());
rename_to.insert(to.clone());
let (match_mode, match_score, match_count, matched_text) =
match_meta_for_path(to, replace_match_meta);
if let Some(m) = replace_match_meta.get(to) {
any_replace_meta = true;
agg_mode = Some(merge_match_modes(agg_mode, m.mode));
if matches!(m.mode, crate::api::MatchMode::Fuzzy)
&& let Some(s) = m.score
{
agg_score = Some(agg_score.map_or(s, |prev| prev.min(s)));
}
agg_count = agg_count.saturating_add(m.match_count);
top_matched_text =
crate::api::prefer_widest_matched_text(top_matched_text, m.matched_text.clone());
}
let from_str = display_path(from);
let to_str = display_path(to);
tx_changes.push(TxChange {
path: to_str.clone(),
action: "renamed".to_string(),
from: Some(from_str),
to: Some(to_str),
match_mode,
match_score,
match_count,
matched_text,
});
renamed_count += 1;
}
for (path, _original, _) in changes {
if rename_from.contains(path) || rename_to.contains(path) {
continue;
}
let path_str = display_path(path);
let (match_mode, match_score, match_count, matched_text) =
match_meta_for_path(path, replace_match_meta);
if let Some(m) = replace_match_meta.get(path) {
any_replace_meta = true;
agg_mode = Some(merge_match_modes(agg_mode, m.mode));
if matches!(m.mode, crate::api::MatchMode::Fuzzy)
&& let Some(s) = m.score
{
agg_score = Some(agg_score.map_or(s, |prev| prev.min(s)));
}
agg_count = agg_count.saturating_add(m.match_count);
top_matched_text =
crate::api::prefer_widest_matched_text(top_matched_text, m.matched_text.clone());
}
if deletions.contains(path) {
tx_changes.push(TxChange {
path: path_str,
action: "deleted".to_string(),
from: None,
to: None,
match_mode,
match_score,
match_count,
matched_text,
});
deleted_count += 1;
} else if !existed_before.contains(path) {
tx_changes.push(TxChange {
path: path_str,
action: "created".to_string(),
from: None,
to: None,
match_mode,
match_score,
match_count,
matched_text,
});
created += 1;
} else {
tx_changes.push(TxChange {
path: path_str,
action: "modified".to_string(),
from: None,
to: None,
match_mode,
match_score,
match_count,
matched_text,
});
modified += 1;
}
}
for path in deletions {
if rename_from.contains(path) {
continue;
}
if !changes.iter().any(|(c, _, _)| c == path) {
let (match_mode, match_score, match_count, matched_text) =
match_meta_for_path(path, replace_match_meta);
if let Some(m) = replace_match_meta.get(path) {
any_replace_meta = true;
agg_mode = Some(merge_match_modes(agg_mode, m.mode));
if matches!(m.mode, crate::api::MatchMode::Fuzzy)
&& let Some(s) = m.score
{
agg_score = Some(agg_score.map_or(s, |prev| prev.min(s)));
}
agg_count = agg_count.saturating_add(m.match_count);
top_matched_text = crate::api::prefer_widest_matched_text(
top_matched_text,
m.matched_text.clone(),
);
}
tx_changes.push(TxChange {
path: display_path(path),
action: "deleted".to_string(),
from: None,
to: None,
match_mode,
match_score,
match_count,
matched_text,
});
deleted_count += 1;
}
}
if changes.is_empty() && deletions.is_empty() {
for m in replace_match_meta.values() {
any_replace_meta = true;
agg_mode = Some(merge_match_modes(agg_mode, m.mode));
if matches!(m.mode, crate::api::MatchMode::Fuzzy)
&& let Some(s) = m.score
{
agg_score = Some(agg_score.map_or(s, |prev| prev.min(s)));
}
agg_count = agg_count.saturating_add(m.match_count);
top_matched_text =
crate::api::prefer_widest_matched_text(top_matched_text, m.matched_text.clone());
}
}
let mut refused = Vec::new();
for (path, m) in replace_match_meta {
if changes.iter().any(|(c, _, _)| c == path) || deletions.contains(path) {
continue;
}
if m.match_count != 0 {
continue;
}
if m.matched_text.is_none() && m.refuse_reason != Some("no_matches") {
continue;
}
let reason = m
.refuse_reason
.unwrap_or(if m.mode == crate::api::MatchMode::Fuzzy {
"exact_old_absent"
} else {
"no_write"
});
refused.push(TxRefused {
path: display_path(path),
match_mode: Some(match_mode_label(m.mode).to_string()),
match_score: m.score,
matched_text: m.matched_text.clone(),
reason: reason.to_string(),
});
}
refused.sort_by(|a, b| a.path.cmp(&b.path));
let (top_mode, top_score) = match agg_mode {
Some(m) => (
Some(match_mode_label(m).to_string()),
if matches!(m, crate::api::MatchMode::Fuzzy) {
agg_score
} else {
None
},
),
None => (None, None),
};
TxOutput {
ok,
status: status.to_string(),
applied: status == "success",
files_changed: modified,
files_created: created,
files_deleted: deleted_count,
files_renamed: renamed_count,
changes: tx_changes,
reads: Vec::new(),
searches: Vec::new(),
lints: Vec::new(),
mutations: Vec::new(),
changed: None,
removed: None,
error_kind: None,
error: None,
backup_session: None,
match_mode: top_mode,
match_score: top_score,
match_count: if any_replace_meta {
Some(agg_count)
} else {
None
},
matched_text: top_matched_text,
refused,
}
}
pub(crate) fn build_full_tx_output(
status: &'static str,
result: &mut TxExecResult,
cwd: &Path,
) -> TxOutput {
let mut output = build_tx_output_with_meta(
status,
true,
cwd,
TxOutputMetaInputs {
changes: &result.changes,
deletions: &result.deletions,
existed_before: &result.existed_before,
replace_match_meta: &result.replace_match_meta,
renames: &result.renames,
},
);
output.reads = std::mem::take(&mut result.tx_reads);
output.searches = std::mem::take(&mut result.tx_searches);
output.lints = std::mem::take(&mut result.tx_lints);
attach_mutations(&mut output, std::mem::take(&mut result.tx_mutations));
if status == "no_matches" {
output.ok = false;
output.error_kind = Some("no_matches".to_string());
let detail = result
.replace_hint
.as_deref()
.filter(|h| !h.is_empty())
.unwrap_or("no matches");
output.error = Some(detail.to_string());
}
output
}
pub(crate) fn describe_exit_status(status: std::process::ExitStatus) -> String {
match status.code() {
Some(code) => format!("exit code {code}"),
None => "terminated by signal".to_string(),
}
}
pub(crate) fn describe_lifecycle_cwd(base_cwd: &Path, cwd: &Path) -> String {
if cwd == base_cwd {
".".to_string()
} else {
crate::files::relative_display(cwd, base_cwd)
.display()
.to_string()
}
}
pub(crate) fn format_error_with_backup_hint(error: &str, backup_session: Option<&str>) -> String {
match backup_session {
Some(ts) => format!("{error} (backup session {ts}; run `patchloom undo` to restore)"),
None => error.to_string(),
}
}
fn format_error_with_kind(error_kind: &str, error: &str) -> String {
let prefix = format!("{error_kind}: ");
if error.starts_with(&prefix) || error.starts_with(&format!("{error_kind}:")) {
error.to_string()
} else {
format!("{prefix}{error}")
}
}
pub(crate) fn build_error_output(
error_kind: &str,
error: &str,
backup_session: Option<&str>,
) -> TxOutput {
let body = format_error_with_backup_hint(error, backup_session);
TxOutput {
ok: false,
status: "error".to_string(),
applied: false,
files_changed: 0,
files_created: 0,
files_deleted: 0,
files_renamed: 0,
changes: Vec::new(),
reads: Vec::new(),
searches: Vec::new(),
lints: Vec::new(),
mutations: Vec::new(),
changed: None,
removed: None,
error_kind: Some(error_kind.to_string()),
error: Some(format_error_with_kind(error_kind, &body)),
backup_session: backup_session.map(str::to_string),
match_mode: None,
match_score: None,
match_count: None,
matched_text: None,
refused: Vec::new(),
}
}
pub(crate) fn build_applied_with_error_output(
error_kind: &str,
error: &str,
result: &mut TxExecResult,
cwd: &Path,
backup_session: Option<&str>,
) -> TxOutput {
let mut output = build_full_tx_output("error", result, cwd);
output.ok = false;
output.applied = true;
output.error_kind = Some(error_kind.to_string());
let body = format_error_with_backup_hint(error, backup_session);
output.error = Some(format_error_with_kind(error_kind, &body));
if output.backup_session.is_none() {
output.backup_session = backup_session.map(str::to_string);
}
output
}
pub fn exit_code_from_tx_output(report: &TxOutput) -> u8 {
if report.ok {
if report.status == "no_matches" {
exit::NO_MATCHES
} else {
exit::SUCCESS
}
} else {
match report.error_kind.as_deref() {
Some("no_matches") => exit::NO_MATCHES,
Some("parse_error") => exit::PARSE_ERROR,
Some("ambiguous") => exit::AMBIGUOUS,
Some("rollback") => exit::ROLLBACK,
Some("rollback_failed") => exit::FAILURE,
Some("validation_failed") | Some("format_failed") | Some("verification_failed") => {
exit::VALIDATION_FAILED
}
Some("operation_failed") => exit::OPERATION_FAILED,
Some("conflicts") => exit::CONFLICTS,
Some("changes_detected") => exit::CHANGES_DETECTED,
_ => exit::FAILURE,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn command_for_exit_code(code: i32) -> std::process::Command {
#[cfg(windows)]
{
let mut cmd = std::process::Command::new("cmd");
cmd.args(["/C", &format!("exit {code}")]);
cmd
}
#[cfg(not(windows))]
{
if code == 0 {
std::process::Command::new("true")
} else {
std::process::Command::new("false")
}
}
}
#[test]
fn describe_exit_status_code_zero() {
let status = command_for_exit_code(0).status().unwrap();
assert_eq!(describe_exit_status(status), "exit code 0");
}
#[test]
fn describe_exit_status_code_nonzero() {
let status = command_for_exit_code(1).status().unwrap();
assert_eq!(describe_exit_status(status), "exit code 1");
}
#[test]
fn describe_lifecycle_cwd_same() {
let cwd = Path::new("/tmp/project");
assert_eq!(describe_lifecycle_cwd(cwd, cwd), ".");
}
#[test]
fn describe_lifecycle_cwd_subdir() {
let base = Path::new("/tmp/project");
let sub = Path::new("/tmp/project/src/lib");
assert_eq!(describe_lifecycle_cwd(base, sub), "src/lib");
}
#[test]
fn format_error_without_backup() {
assert_eq!(format_error_with_backup_hint("oops", None), "oops");
}
#[test]
fn format_error_with_backup() {
let msg = format_error_with_backup_hint("oops", Some("20260101T120000"));
assert!(msg.contains("backup session 20260101T120000"));
assert!(msg.contains("patchloom undo"));
}
#[test]
fn build_error_output_fields() {
let out = build_error_output("parse_error", "bad plan", None);
assert!(!out.ok);
assert_eq!(out.status, "error");
assert_eq!(out.error_kind.as_deref(), Some("parse_error"));
assert!(out.error.as_ref().unwrap().contains("bad plan"));
assert_eq!(out.files_changed, 0);
assert!(out.backup_session.is_none());
}
#[test]
fn format_error_with_kind_skips_duplicate_prefix() {
let already = "guard_rejected: path rejected by workspace guard: escapes";
assert_eq!(
format_error_with_kind("guard_rejected", already),
already,
"must not double-prefix EditError Display"
);
assert_eq!(
format_error_with_kind("parse_error", "bad plan"),
"parse_error: bad plan"
);
}
#[test]
fn build_error_output_with_backup() {
let out = build_error_output("rollback", "fail", Some("ts123"));
assert_eq!(out.backup_session.as_deref(), Some("ts123"));
assert!(out.error.as_ref().unwrap().contains("patchloom undo"));
}
fn ok_output(status: &str) -> TxOutput {
TxOutput {
ok: true,
status: status.to_string(),
applied: status == "success",
files_changed: 0,
files_created: 0,
files_deleted: 0,
files_renamed: 0,
changes: Vec::new(),
reads: Vec::new(),
searches: Vec::new(),
lints: Vec::new(),
mutations: Vec::new(),
changed: None,
removed: None,
error_kind: None,
error: None,
backup_session: None,
match_mode: None,
match_score: None,
match_count: None,
matched_text: None,
refused: Vec::new(),
}
}
fn err_output(kind: &str) -> TxOutput {
TxOutput {
ok: false,
status: "error".to_string(),
applied: false,
files_changed: 0,
files_created: 0,
files_deleted: 0,
files_renamed: 0,
changes: Vec::new(),
reads: Vec::new(),
searches: Vec::new(),
lints: Vec::new(),
mutations: Vec::new(),
changed: None,
removed: None,
error_kind: Some(kind.to_string()),
error: Some("test error".to_string()),
backup_session: None,
match_mode: None,
match_score: None,
match_count: None,
matched_text: None,
refused: Vec::new(),
}
}
#[test]
fn attach_mutations_sets_aggregates() {
let mut out = ok_output("success");
attach_mutations(
&mut out,
vec![
TxDocMutation {
path: "a.json".into(),
op: "doc.delete_where".into(),
changed: true,
removed: 2,
},
TxDocMutation {
path: "b.json".into(),
op: "doc.delete".into(),
changed: false,
removed: 0,
},
],
);
assert_eq!(out.changed, Some(true));
assert_eq!(out.removed, Some(2));
assert_eq!(out.mutations.len(), 2);
assert_eq!(out.mutations[0].removed, 2);
}
#[test]
fn attach_mutations_empty_is_noop() {
let mut out = ok_output("success");
attach_mutations(&mut out, Vec::new());
assert_eq!(out.changed, None);
assert_eq!(out.removed, None);
assert!(out.mutations.is_empty());
}
#[test]
fn exit_code_success() {
assert_eq!(
exit_code_from_tx_output(&ok_output("success")),
exit::SUCCESS
);
}
#[test]
fn exit_code_ok_no_matches() {
assert_eq!(
exit_code_from_tx_output(&ok_output("no_matches")),
exit::NO_MATCHES
);
}
#[test]
fn exit_code_error_kinds() {
assert_eq!(
exit_code_from_tx_output(&err_output("no_matches")),
exit::NO_MATCHES
);
assert_eq!(
exit_code_from_tx_output(&err_output("parse_error")),
exit::PARSE_ERROR
);
assert_eq!(
exit_code_from_tx_output(&err_output("rollback")),
exit::ROLLBACK
);
assert_eq!(
exit_code_from_tx_output(&err_output("rollback_failed")),
exit::FAILURE
);
assert_eq!(
exit_code_from_tx_output(&err_output("validation_failed")),
exit::VALIDATION_FAILED
);
assert_eq!(
exit_code_from_tx_output(&err_output("format_failed")),
exit::VALIDATION_FAILED
);
assert_eq!(
exit_code_from_tx_output(&err_output("verification_failed")),
exit::VALIDATION_FAILED
);
assert_eq!(
exit_code_from_tx_output(&err_output("operation_failed")),
exit::OPERATION_FAILED
);
assert_eq!(
exit_code_from_tx_output(&err_output("ambiguous")),
exit::AMBIGUOUS
);
assert_eq!(
exit_code_from_tx_output(&err_output("unknown_kind")),
exit::FAILURE
);
}
#[test]
fn build_tx_output_classifies_changes() {
let cwd = Path::new("/project");
let existed = HashSet::from([PathBuf::from("/project/existing.txt")]);
let deletions = HashSet::from([PathBuf::from("/project/removed.txt")]);
let changes = vec![
(
PathBuf::from("/project/existing.txt"),
"old".to_string(),
"new".to_string(),
),
(
PathBuf::from("/project/brand_new.txt"),
String::new(),
"content".to_string(),
),
(
PathBuf::from("/project/removed.txt"),
"was here".to_string(),
String::new(),
),
];
let out = build_tx_output_with_meta(
"success",
true,
cwd,
TxOutputMetaInputs {
changes: &changes,
deletions: &deletions,
existed_before: &existed,
replace_match_meta: &HashMap::new(),
renames: &[],
},
);
assert!(out.ok);
assert_eq!(out.files_changed, 1); assert_eq!(out.files_created, 1); assert_eq!(out.files_deleted, 1); assert_eq!(out.changes.len(), 3);
let actions: Vec<&str> = out.changes.iter().map(|c| c.action.as_str()).collect();
assert!(actions.contains(&"modified"));
assert!(actions.contains(&"created"));
assert!(actions.contains(&"deleted"));
}
#[test]
fn build_tx_output_classifies_file_rename() {
let cwd = Path::new("/project");
let from = PathBuf::from("/project/old.txt");
let to = PathBuf::from("/project/new.txt");
let changes = vec![
(to.clone(), String::new(), "body\n".to_string()),
(from.clone(), "body\n".to_string(), String::new()),
];
let deletions = HashSet::from([from.clone()]);
let renames = vec![(from, to)];
let out = build_tx_output_with_meta(
"success",
true,
cwd,
TxOutputMetaInputs {
changes: &changes,
deletions: &deletions,
existed_before: &HashSet::new(),
replace_match_meta: &HashMap::new(),
renames: &renames,
},
);
assert_eq!(out.files_renamed, 1);
assert_eq!(out.files_created, 0);
assert_eq!(out.files_deleted, 0);
assert_eq!(out.files_changed, 0);
assert_eq!(out.changes.len(), 1);
assert_eq!(out.changes[0].action, "renamed");
assert_eq!(out.changes[0].path, "new.txt");
assert_eq!(out.changes[0].from.as_deref(), Some("old.txt"));
assert_eq!(out.changes[0].to.as_deref(), Some("new.txt"));
}
#[test]
fn build_tx_output_empty_changes() {
let cwd = Path::new("/project");
let out = build_tx_output_with_meta(
"success",
true,
cwd,
TxOutputMetaInputs {
changes: &[],
deletions: &HashSet::new(),
existed_before: &HashSet::new(),
replace_match_meta: &HashMap::new(),
renames: &[],
},
);
assert_eq!(out.files_changed, 0);
assert_eq!(out.files_created, 0);
assert_eq!(out.files_deleted, 0);
assert!(out.changes.is_empty());
}
#[test]
fn build_tx_output_includes_replace_match_mode() {
let cwd = Path::new("/project");
let path = PathBuf::from("/project/a.txt");
let existed = HashSet::from([path.clone()]);
let changes = vec![(path.clone(), "old".into(), "new".into())];
let mut meta = HashMap::new();
meta.insert(
path,
ReplaceMatchMeta {
mode: crate::api::MatchMode::Fuzzy,
score: Some(0.91),
match_count: 1,
matched_text: Some("proccess".into()),
refuse_reason: None,
},
);
let out = build_tx_output_with_meta(
"success",
true,
cwd,
TxOutputMetaInputs {
changes: &changes,
deletions: &HashSet::new(),
existed_before: &existed,
replace_match_meta: &meta,
renames: &[],
},
);
assert_eq!(out.match_mode.as_deref(), Some("fuzzy"));
assert_eq!(out.match_score, Some(0.91));
assert_eq!(out.match_count, Some(1));
assert_eq!(out.changes.len(), 1);
assert_eq!(out.changes[0].match_mode.as_deref(), Some("fuzzy"));
assert_eq!(out.changes[0].match_score, Some(0.91));
assert_eq!(out.changes[0].match_count, Some(1));
assert_eq!(out.matched_text.as_deref(), Some("proccess"));
assert_eq!(out.changes[0].matched_text.as_deref(), Some("proccess"));
let json = serde_json::to_string(&out).unwrap();
assert!(json.contains("\"match_mode\":\"fuzzy\""), "{json}");
assert!(json.contains("\"match_count\":1"), "{json}");
assert!(json.contains("\"matched_text\":\"proccess\""), "{json}");
}
#[test]
fn build_tx_output_fuzzy_agg_score_is_minimum() {
let cwd = Path::new("/project");
let a = PathBuf::from("/project/a.txt");
let b = PathBuf::from("/project/b.txt");
let existed = HashSet::from([a.clone(), b.clone()]);
let changes = vec![
(a.clone(), "old".into(), "new".into()),
(b.clone(), "old".into(), "new".into()),
];
let mut meta = HashMap::new();
meta.insert(
a,
ReplaceMatchMeta {
mode: crate::api::MatchMode::Fuzzy,
score: Some(0.95),
match_count: 1,
matched_text: Some("short".into()),
refuse_reason: None,
},
);
meta.insert(
b,
ReplaceMatchMeta {
mode: crate::api::MatchMode::Fuzzy,
score: Some(0.80),
match_count: 1,
matched_text: Some("much_wider_matched_span".into()),
refuse_reason: None,
},
);
let out = build_tx_output_with_meta(
"success",
true,
cwd,
TxOutputMetaInputs {
changes: &changes,
deletions: &HashSet::new(),
existed_before: &existed,
replace_match_meta: &meta,
renames: &[],
},
);
assert_eq!(out.match_mode.as_deref(), Some("fuzzy"));
assert_eq!(
out.match_score,
Some(0.80),
"worst-case aggregate score must be the min fuzzy score"
);
assert_eq!(
out.matched_text.as_deref(),
Some("much_wider_matched_span"),
"multi-path top-level matched_text must be widest span (#2007)"
);
assert_eq!(out.match_count, Some(2));
}
#[test]
fn build_tx_output_matched_text_when_single_replace_among_other_changes() {
let cwd = Path::new("/project");
let replaced = PathBuf::from("/project/a.txt");
let other = PathBuf::from("/project/b.txt");
let existed = HashSet::from([replaced.clone(), other.clone()]);
let changes = vec![
(replaced.clone(), "old".into(), "new".into()),
(other, "x".into(), "y".into()),
];
let mut meta = HashMap::new();
meta.insert(
replaced,
ReplaceMatchMeta {
mode: crate::api::MatchMode::Fuzzy,
score: Some(0.88),
match_count: 1,
matched_text: Some("live_span".into()),
refuse_reason: None,
},
);
let out = build_tx_output_with_meta(
"success",
true,
cwd,
TxOutputMetaInputs {
changes: &changes,
deletions: &HashSet::new(),
existed_before: &existed,
replace_match_meta: &meta,
renames: &[],
},
);
assert_eq!(
out.matched_text.as_deref(),
Some("live_span"),
"single replace path must surface matched_text even when other files changed"
);
assert_eq!(out.match_score, Some(0.88));
}
#[test]
fn applied_true_on_success_status_false_on_preview() {
let preview = build_tx_output_with_meta(
"changes_detected",
true,
Path::new("/tmp"),
TxOutputMetaInputs {
changes: &[],
deletions: &Default::default(),
existed_before: &Default::default(),
replace_match_meta: &Default::default(),
renames: &[],
},
);
assert!(!preview.applied, "preview must set applied=false");
let applied = build_tx_output_with_meta(
"success",
true,
Path::new("/tmp"),
TxOutputMetaInputs {
changes: &[],
deletions: &Default::default(),
existed_before: &Default::default(),
replace_match_meta: &Default::default(),
renames: &[],
},
);
assert!(applied.applied, "success must set applied=true");
let err = build_error_output("invalid_input", "nope", None);
assert!(!err.applied, "pure error must set applied=false");
}
#[test]
fn tx_output_serde_round_trip() {
let out = ok_output("success");
let json = serde_json::to_string(&out).unwrap();
let parsed: TxOutput = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.ok, out.ok);
assert_eq!(parsed.status, out.status);
}
#[test]
fn tx_output_skips_empty_optional_fields() {
let out = ok_output("success");
let json = serde_json::to_string(&out).unwrap();
assert!(!json.contains("\"reads\""));
assert!(!json.contains("\"searches\""));
assert!(!json.contains("\"lints\""));
assert!(!json.contains("\"error\""));
}
#[test]
fn build_full_tx_output_no_matches_includes_hint() {
use std::collections::HashMap;
let cwd = Path::new("/project");
let mut result = TxExecResult {
changes: vec![],
deletions: HashSet::new(),
existed_before: HashSet::new(),
pending: HashMap::new(),
tx_reads: vec![],
tx_searches: vec![],
tx_lints: vec![],
tx_mutations: vec![],
no_effective_changes: true,
replace_no_matches: true,
replace_hint: Some(
"fuzzy match score 0.900 below min_fuzzy_score 1 for \"proccess\"".into(),
),
replace_match_meta: HashMap::new(),
renames: vec![],
};
let out = build_full_tx_output("no_matches", &mut result, cwd);
assert_eq!(out.status, "no_matches");
assert!(
!out.ok,
"no_matches must set ok:false so MCP/CLI agents agree (#1791)"
);
assert_eq!(out.error_kind.as_deref(), Some("no_matches"));
assert!(
out.error
.as_deref()
.is_some_and(|e| e.contains("min_fuzzy_score")),
"hint must appear in error: {:?}",
out.error
);
assert_eq!(exit_code_from_tx_output(&out), exit::NO_MATCHES);
}
#[test]
fn build_full_tx_output_no_matches_includes_refuse_match_meta() {
use std::collections::HashMap;
let cwd = Path::new("/project");
let mut meta = HashMap::new();
meta.insert(
PathBuf::from("/project/app.py"),
ReplaceMatchMeta {
mode: crate::api::MatchMode::Fuzzy,
score: Some(0.987),
match_count: 0,
matched_text: Some("compute_checksum".into()),
refuse_reason: None,
},
);
let mut result = TxExecResult {
changes: vec![],
deletions: HashSet::new(),
existed_before: HashSet::new(),
pending: HashMap::new(),
tx_reads: vec![],
tx_searches: vec![],
tx_lints: vec![],
tx_mutations: vec![],
no_effective_changes: true,
replace_no_matches: true,
replace_hint: Some("exact old absent; best fuzzy candidate".into()),
replace_match_meta: meta,
renames: vec![],
};
let out = build_full_tx_output("no_matches", &mut result, cwd);
assert_eq!(out.status, "no_matches");
assert_eq!(out.match_mode.as_deref(), Some("fuzzy"));
assert_eq!(out.match_score, Some(0.987));
assert_eq!(out.matched_text.as_deref(), Some("compute_checksum"));
assert_eq!(out.match_count, Some(0));
}
#[test]
fn build_full_tx_output_partial_success_ignores_refuse_meta_in_aggregate() {
use std::collections::HashMap;
let cwd = Path::new("/project");
let changed = PathBuf::from("/project/a.txt");
let refused = PathBuf::from("/project/b.txt");
let mut meta = HashMap::new();
meta.insert(
changed.clone(),
ReplaceMatchMeta {
mode: crate::api::MatchMode::Exact,
score: None,
match_count: 1,
matched_text: None,
refuse_reason: None,
},
);
meta.insert(
refused,
ReplaceMatchMeta {
mode: crate::api::MatchMode::Fuzzy,
score: Some(0.97),
match_count: 0,
matched_text: Some("helo world".into()),
refuse_reason: Some("exact_old_absent"),
},
);
let mut result = TxExecResult {
changes: vec![(changed, "hello world\n".into(), "hi\n".into())],
deletions: HashSet::new(),
existed_before: {
let mut s = HashSet::new();
s.insert(PathBuf::from("/project/a.txt"));
s
},
pending: HashMap::new(),
tx_reads: vec![],
tx_searches: vec![],
tx_lints: vec![],
tx_mutations: vec![],
no_effective_changes: false,
replace_no_matches: false,
replace_hint: Some("exact old absent".into()),
replace_match_meta: meta,
renames: vec![],
};
let out = build_full_tx_output("success", &mut result, cwd);
assert_eq!(out.status, "success");
assert_eq!(out.match_mode.as_deref(), Some("exact"));
assert!(out.match_score.is_none());
assert_eq!(out.match_count, Some(1));
assert_eq!(out.refused.len(), 1);
assert_eq!(out.refused[0].path, "b.txt");
assert_eq!(out.refused[0].match_mode.as_deref(), Some("fuzzy"));
assert_eq!(out.refused[0].reason, "exact_old_absent");
assert_eq!(out.refused[0].matched_text.as_deref(), Some("helo world"));
}
#[test]
fn build_applied_with_error_output_includes_changes_and_backup() {
use std::collections::HashMap;
let cwd = Path::new("/project");
let path = PathBuf::from("/project/a.txt");
let mut meta = HashMap::new();
meta.insert(
path.clone(),
ReplaceMatchMeta {
mode: crate::api::MatchMode::Exact,
score: None,
match_count: 1,
matched_text: None,
refuse_reason: None,
},
);
let mut result = TxExecResult {
changes: vec![(path, "hello\n".into(), "world\n".into())],
deletions: HashSet::new(),
existed_before: {
let mut s = HashSet::new();
s.insert(PathBuf::from("/project/a.txt"));
s
},
pending: HashMap::new(),
tx_reads: vec![],
tx_searches: vec![],
tx_lints: vec![],
tx_mutations: vec![],
no_effective_changes: false,
replace_no_matches: false,
replace_hint: None,
replace_match_meta: meta,
renames: vec![],
};
let out = build_applied_with_error_output(
"format_failed",
"format step failed (step 1, exit code 1, cwd: .)",
&mut result,
cwd,
Some("123_0"),
);
assert!(!out.ok);
assert_eq!(out.error_kind.as_deref(), Some("format_failed"));
assert_eq!(out.files_changed, 1);
assert_eq!(out.changes.len(), 1);
assert_eq!(out.backup_session.as_deref(), Some("123_0"));
assert!(
out.error
.as_deref()
.is_some_and(|e| e.contains("backup session") && e.contains("undo")),
"{:?}",
out.error
);
}
#[test]
fn build_full_tx_output_partial_success_surfaces_exact_soft_no_match() {
use std::collections::HashMap;
let cwd = Path::new("/project");
let created = PathBuf::from("/project/g.txt");
let missed = PathBuf::from("/project/f.txt");
let mut meta = HashMap::new();
meta.insert(
missed,
ReplaceMatchMeta {
mode: crate::api::MatchMode::Exact,
score: None,
match_count: 0,
matched_text: None,
refuse_reason: Some("no_matches"),
},
);
let mut result = TxExecResult {
changes: vec![(created, String::new(), "hi\n".into())],
deletions: HashSet::new(),
existed_before: HashSet::new(),
pending: HashMap::new(),
tx_reads: vec![],
tx_searches: vec![],
tx_lints: vec![],
tx_mutations: vec![],
no_effective_changes: false,
replace_no_matches: false,
replace_hint: Some("no matches for 'missing' in f.txt".into()),
replace_match_meta: meta,
renames: vec![],
};
let out = build_full_tx_output("success", &mut result, cwd);
assert_eq!(out.status, "success");
assert_eq!(out.files_created, 1);
assert_eq!(
out.refused.len(),
1,
"exact soft miss must surface: {out:?}"
);
assert_eq!(out.refused[0].path, "f.txt");
assert_eq!(out.refused[0].reason, "no_matches");
assert_eq!(out.refused[0].match_mode.as_deref(), Some("exact"));
assert!(out.refused[0].matched_text.is_none());
}
#[test]
fn tx_output_deserializes_minimal_json_without_match_fields() {
let json = r#"{"ok":true,"status":"success","files_changed":0,"files_created":0,"files_deleted":0,"changes":[]}"#;
let parsed: TxOutput = serde_json::from_str(json).expect("minimal TxOutput JSON");
assert!(parsed.ok);
assert!(parsed.match_mode.is_none());
assert!(parsed.match_score.is_none());
assert!(parsed.match_count.is_none());
assert!(parsed.matched_text.is_none());
assert!(parsed.backup_session.is_none());
assert!(parsed.error_kind.is_none());
}
}