patchloom 0.31.0

Structured file editing library and CLI for AI agents: parser-backed JSON/YAML/TOML edits, AST-aware code operations, multi-file batching, markdown operations, and MCP server
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! Format/validate lifecycle steps, collateral snapshot, and strict rollback.

use crate::exec;
use crate::plan::{self, Plan};
use crate::tx::output::{describe_exit_status, describe_lifecycle_cwd};
use crate::write::{WritePolicy, atomic_write};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

const DEFAULT_LIFECYCLE_TIMEOUT_SECS: u64 = 60;

// Lifecycle helpers (extracted from run() for testability)
// ---------------------------------------------------------------------------

pub(crate) struct LifecycleError {
    pub message: String,
    pub kind: &'static str,
}

/// Format a lifecycle step failure message, appending stderr output if available.
pub(crate) fn lifecycle_failure_msg(header: &str, stderr: &str) -> String {
    let trimmed = stderr.trim();
    if trimmed.is_empty() {
        header.to_string()
    } else {
        format!("{header}: {trimmed}")
    }
}

/// Shared runner for format and validation lifecycle steps.
///
/// `label` is used in error messages (e.g. "format step" or "required validation").
/// `error_label` is the shorter variant for the error branch (e.g. "format step" or "validation").
/// `kind` is the `LifecycleError::kind` string.
/// `fail_on_error` controls whether non-required failures are fatal.
fn run_lifecycle_steps(
    steps: impl Iterator<Item = (String, Option<u64>, bool)>,
    base_cwd: &Path,
    cwd: &Path,
    label: &str,
    error_label: &str,
    kind: &'static str,
    quiet: bool,
) -> Result<(), LifecycleError> {
    let lifecycle_cwd = describe_lifecycle_cwd(base_cwd, cwd);
    for (index, (cmd, timeout, required)) in steps.enumerate() {
        crate::verbose!(
            "tx: running {} step {}: {:?} (timeout={}s, required={})",
            label,
            index + 1,
            cmd,
            timeout.unwrap_or(DEFAULT_LIFECYCLE_TIMEOUT_SECS),
            required
        );
        let timeout_secs = timeout.unwrap_or(DEFAULT_LIFECYCLE_TIMEOUT_SECS);
        let result = exec::run_with_timeout(&cmd, timeout_secs, cwd);
        match result {
            Ok(exec::ShellResult {
                status,
                stderr_head,
            }) if !status.success() => {
                let step_label = if required { label } else { error_label };
                let header = format!(
                    "{step_label} failed (step {}, {}, cwd: {})",
                    index + 1,
                    describe_exit_status(status),
                    lifecycle_cwd
                );
                let msg = lifecycle_failure_msg(&header, &stderr_head);
                if !quiet {
                    eprintln!("tx: {msg}");
                }
                if required {
                    return Err(LifecycleError { message: msg, kind });
                }
            }
            Err(e) => {
                let msg = format!(
                    "{error_label} error (step {}, cwd: {}): {e}",
                    index + 1,
                    lifecycle_cwd
                );
                if !quiet {
                    eprintln!("tx: {msg}");
                }
                if required {
                    return Err(LifecycleError { message: msg, kind });
                }
            }
            _ => {}
        }
    }
    Ok(())
}

pub(crate) fn run_format_steps(
    steps: &[plan::FormatStep],
    base_cwd: &Path,
    cwd: &Path,
    quiet: bool,
) -> Result<(), LifecycleError> {
    run_lifecycle_steps(
        steps.iter().map(|s| (s.cmd.clone(), s.timeout, true)),
        base_cwd,
        cwd,
        "format step",
        "format step",
        "format_failed",
        quiet,
    )
}

pub(crate) fn run_validate_steps(
    steps: &[plan::ValidationStep],
    base_cwd: &Path,
    cwd: &Path,
    quiet: bool,
) -> Result<(), LifecycleError> {
    run_lifecycle_steps(
        steps
            .iter()
            .map(|s| (s.cmd.clone(), s.timeout, s.required.unwrap_or(false))),
        base_cwd,
        cwd,
        "required validation",
        "validation",
        "validation_failed",
        quiet,
    )
}

/// Maximum file size (in bytes) to include in the collateral snapshot.
/// Files above this threshold are extremely unlikely to be reformatted by
/// standard formatters and snapshotting them wastes memory.
pub(crate) const COLLATERAL_SNAPSHOT_MAX_SIZE: u64 = 1_048_576; // 1 MiB

/// Paths the transaction already owns: change targets, deletions, and
/// rename from/to. Used as the skip set for [`snapshot_non_tx_files`].
///
/// Rename dests are often also in `changes`, but force-overwrite dests
/// with identical content (and empty-file deletes historically) can be
/// absent from `changes`. Omitting them lets the post-commit dest be
/// snapshotted as collateral and written back after rollback.
pub(crate) fn tx_paths_for_collateral(
    changes: &[(PathBuf, String, String)],
    deletions: &HashSet<PathBuf>,
    renames: &[(PathBuf, PathBuf)],
) -> HashSet<PathBuf> {
    let mut tx_paths: HashSet<PathBuf> = changes.iter().map(|(p, _, _)| p.clone()).collect();
    tx_paths.extend(deletions.iter().cloned());
    for (from, to) in renames {
        tx_paths.insert(from.clone());
        tx_paths.insert(to.clone());
    }
    tx_paths
}

/// Snapshot the content of non-binary text files under `cwd` that are NOT
/// already tracked by the transaction. This captures the pre-format state of
/// files that a format step (e.g. `cargo fmt`) might modify as a side effect.
///
/// Only called when `strict` mode is active and the plan has format or
/// validate steps, so the cost is opt-in.
#[cfg(any(feature = "cli", feature = "files"))]
pub(crate) fn snapshot_non_tx_files(
    cwd: &Path,
    tx_paths: &HashSet<PathBuf>,
) -> HashMap<PathBuf, String> {
    let mut snapshot = HashMap::new();
    crate::verbose!(
        "tx: collateral snapshot: walking {} (tx_paths: {})",
        cwd.display(),
        tx_paths.len()
    );
    // include_hidden=true matches WalkBuilder.hidden(false); the collector
    // still prunes .git / .patchloom via attach_walk_entry_filter.
    // Walk errors stay soft (empty snapshot), matching flatten() swallow.
    let walked = match crate::files::collect_file_paths_with_ignores(cwd, &[], &[], true) {
        Ok(paths) => paths,
        Err(_) => return snapshot,
    };
    for path in walked {
        if tx_paths.contains(&path) {
            crate::verbose!(
                "tx: collateral snapshot: skipping tx file {}",
                path.display()
            );
            continue;
        }
        // Skip files above the size threshold.
        if let Ok(meta) = std::fs::metadata(&path)
            && meta.len() > COLLATERAL_SNAPSHOT_MAX_SIZE
        {
            crate::verbose!(
                "tx: collateral snapshot: skipping large file {} ({} bytes)",
                path.display(),
                meta.len()
            );
            continue;
        }
        // Read bytes and skip binary files.
        let bytes = match std::fs::read(&path) {
            Ok(b) => b,
            Err(_) => continue,
        };
        if crate::files::is_binary(&bytes) {
            continue;
        }
        if let Ok(content) = String::from_utf8(bytes) {
            crate::verbose!(
                "tx: collateral snapshot: captured {} ({} bytes)",
                path.display(),
                content.len()
            );
            snapshot.insert(path, content);
        }
    }
    crate::verbose!("tx: collateral snapshot: {} files captured", snapshot.len());
    snapshot
}

/// Restore any files that were modified by format/validate steps but were
/// not part of the transaction. Compares current content against the
/// snapshot and writes back the original content for any files that changed.
///
/// Returns `Ok(())` when every needed write succeeded. Returns `Err` with
/// the paths that failed so callers can emit `rollback_failed` instead of
/// claiming a full revert.
#[cfg(any(feature = "cli", feature = "files"))]
pub(crate) fn restore_collateral_files(
    snapshot: &HashMap<PathBuf, String>,
) -> Result<(), Vec<PathBuf>> {
    let noop_policy = WritePolicy::default();
    let mut restored = 0usize;
    let mut failed = Vec::new();
    for (path, original) in snapshot {
        // Soft content load: skip binary / unreadable collateral (do not
        // rewrite non-text via atomic_write as UTF-8).
        let current = match crate::files::try_read_text_file(path) {
            Ok(c) => c,
            Err(_) => continue,
        };
        if current != *original {
            crate::verbose!(
                "tx: collateral restore: reverting {} (changed by formatter)",
                path.display()
            );
            if atomic_write(path, original, &noop_policy).is_err() {
                failed.push(path.clone());
            } else {
                restored += 1;
            }
        }
    }
    if restored > 0 {
        crate::verbose!("tx: collateral restore: reverted {} file(s)", restored);
    }
    if failed.is_empty() {
        Ok(())
    } else {
        Err(failed)
    }
}

/// Undo a committed transaction after a strict format/validate failure.
///
/// Prefers [`crate::backup::restore_session`]. Falls back to
/// [`rollback_strict`] only when no backup session exists. A failed
/// `restore_session` (or the test [`super::commit::RestoreFailGuard`])
/// does **not** run string rollback: a partial backup restore must not be
/// overwritten.
///
/// Returns `Ok(())` only when backup restore (when attempted) and
/// collateral restore both succeed. Callers must emit `rollback_failed`
/// on `Err` and must not claim that all changes were reverted.
#[cfg(any(feature = "cli", feature = "files"))]
pub(crate) fn revert_strict_lifecycle(
    cwd: &Path,
    changes: &[(PathBuf, String, String)],
    pending: &HashMap<PathBuf, (String, String)>,
    deletions: &HashSet<PathBuf>,
    existed_before: &HashSet<PathBuf>,
    backup_session: Option<&str>,
    collateral: &HashMap<PathBuf, String>,
) -> Result<(), String> {
    let mut errors: Vec<String> = Vec::new();
    if let Some(ts) = backup_session {
        let force_fail =
            super::commit::FORCE_RESTORE_FAIL.with(|f| f.load(std::sync::atomic::Ordering::SeqCst));
        if force_fail {
            errors.push(format!("backup restore failed for session {ts}"));
        } else if let Err(e) = crate::backup::restore_session(cwd, ts) {
            errors.push(format!("backup restore failed for session {ts}: {e}"));
        }
    } else {
        rollback_strict(changes, pending, deletions, existed_before, true);
    }
    if let Err(failed) = restore_collateral_files(collateral) {
        for path in failed {
            errors.push(format!(
                "failed to restore collateral file {}",
                path.display()
            ));
        }
    }
    if errors.is_empty() {
        Ok(())
    } else {
        Err(errors.join("; "))
    }
}

pub(crate) fn rollback_strict(
    changes: &[(PathBuf, String, String)],
    pending: &HashMap<PathBuf, (String, String)>,
    deletions: &HashSet<PathBuf>,
    existed_before: &HashSet<PathBuf>,
    quiet: bool,
) {
    let noop_policy = WritePolicy::default();
    for (path, original, _) in changes {
        if !existed_before.contains(path) {
            // File was created during this tx. Whether it was also deleted
            // does not matter: it should not exist after rollback.
            if let Err(e) = std::fs::remove_file(path)
                && !quiet
            {
                eprintln!(
                    "tx: rollback: failed to remove created file {}: {e}",
                    path.display()
                );
            }
        } else if !deletions.contains(path) {
            // File existed before and was modified (not deleted): restore.
            if let Err(e) = atomic_write(path, original, &noop_policy)
                && !quiet
            {
                eprintln!("tx: rollback: failed to restore {}: {e}", path.display());
            }
        }
        // If existed_before AND in deletions: handled by the deletions loop below.
    }
    for path in deletions {
        if let Some((orig, _)) = pending.get(path)
            && existed_before.contains(path)
        {
            // Ensure parent directory exists before restoring; the directory
            // may have been removed if the deletion was the last file in it.
            if let Some(parent) = path.parent()
                && !parent.as_os_str().is_empty()
                && !parent.exists()
                && let Err(e) = std::fs::create_dir_all(parent)
            {
                if !quiet {
                    eprintln!(
                        "tx: rollback: failed to create dir {}: {e}",
                        parent.display()
                    );
                }
                continue;
            }
            if let Err(e) = atomic_write(path, orig, &noop_policy)
                && !quiet
            {
                eprintln!(
                    "tx: rollback: failed to restore deleted {}: {e}",
                    path.display()
                );
            }
        }
    }
}

/// Run format and validation lifecycle steps. Returns `None` on success.
pub(crate) fn run_lifecycle(
    plan: &Plan,
    base_cwd: &Path,
    cwd: &Path,
    quiet: bool,
) -> Option<LifecycleError> {
    plan.format
        .as_deref()
        .map(|steps| run_format_steps(steps, base_cwd, cwd, quiet))
        .unwrap_or(Ok(()))
        .err()
        .or_else(|| {
            plan.validate
                .as_deref()
                .and_then(|steps| run_validate_steps(steps, base_cwd, cwd, quiet).err())
        })
}

pub(crate) fn resolve_plan_cwd(base_cwd: &Path, plan_cwd: Option<&str>) -> PathBuf {
    match plan_cwd {
        Some(plan_cwd) => {
            let path = Path::new(plan_cwd);
            if path.is_absolute() {
                path.to_path_buf()
            } else {
                base_cwd.join(path)
            }
        }
        None => base_cwd.to_path_buf(),
    }
}