atomwrite 0.1.37

Atomic file operations CLI for LLM agents — read, write, edit, search, replace with NDJSON output
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
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
// SPDX-License-Identifier: MIT OR Apache-2.0

//! Orchestrates the atomic write pipeline (I/O-bound, single-file ordered).

use std::fs;
use std::path::Path;
use std::time::Instant;

use anyhow::{Context, Result};

use crate::checksum;
use crate::ndjson_types::PlatformInfo;
use crate::platform;

use super::inplace::{create_backup_in, delete_backup_quietly, write_inplace_path};
use super::rename_path::write_rename_path;
use super::syntax_heuristic::syntax_heuristic_check;
use super::types::{AtomicWriteOptions, WriteResult, WriteStrategy};

/// Write content atomically via tempfile, fsync, and rename.
///
/// # Errors
///
/// Returns `AtomwriteError::WorkspaceJail` if the target path escapes the workspace.
/// Returns `AtomwriteError::Io` if creating, writing, or renaming the tempfile fails.
/// Returns `AtomwriteError::PermissionDenied` if the target directory is not writable.
/// Returns `AtomwriteError::DiskFull` if the filesystem runs out of space during write.
/// Returns `AtomwriteError::LockTimeout` (exit 83) if another process holds the
/// advisory lock of the target for longer than `LOCK_ACQUIRE_TIMEOUT_MS`.
///
/// # Examples
///
/// ```
/// use atomwrite::atomic::{AtomicWriteOptions, atomic_write};
/// use atomwrite::checksum::hash_bytes;
///
/// let dir = tempfile::tempdir()?;
/// let target = dir.path().join("notes.txt");
/// let opts = AtomicWriteOptions {
///     backup: false,
///     ..Default::default()
/// };
///
/// let first = atomic_write(&target, b"v1\n", &opts, dir.path())?;
/// assert_eq!(first.bytes_written, 3);
/// assert_eq!(first.checksum, hash_bytes(b"v1\n"));
/// assert!(first.checksum_before.is_none());
///
/// let second = atomic_write(&target, b"v2\n", &opts, dir.path())?;
/// assert_eq!(second.checksum_before, Some(first.checksum));
/// assert_eq!(std::fs::read_to_string(&target)?, "v2\n");
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn atomic_write(
    target: &Path,
    content: &[u8],
    opts: &AtomicWriteOptions,
    workspace: &Path,
) -> Result<WriteResult> {
    atomic_write_based(target, content, opts, workspace, None)
}

/// Write content atomically, refusing to overwrite a file that changed after the
/// caller read it.
///
/// For read-modify-write verbs: `expected_base` is the BLAKE3 hex checksum of the
/// content the new content was computed from. Under the advisory lock the
/// current file is hashed and compared; a different file (or a missing one)
/// fails with `AtomwriteError::StateDrift` (exit 82, retryable) and writes
/// nothing. This is the net for filesystems without `flock` and for callers that
/// did not hold the lock while reading. `None` behaves like [`atomic_write`].
///
/// # Errors
///
/// Same as [`atomic_write`], plus `AtomwriteError::StateDrift` (exit 82) when the
/// file no longer matches `expected_base`.
#[tracing::instrument(skip_all, fields(path = %target.display()))]
pub fn atomic_write_based(
    target: &Path,
    content: &[u8],
    opts: &AtomicWriteOptions,
    workspace: &Path,
    expected_base: Option<&str>,
) -> Result<WriteResult> {
    let start = Instant::now();

    // Step 1: validate path
    let target = crate::path_safety::validate_path(target, workspace)?;
    let resolved_durability = opts.durability.resolve(&target);
    let mut backup_method_used: Option<&'static str> = None;

    // Step 1-lock: R23 — create the parent first (the lock sidecar lives in
    // it), then take the advisory lock BEFORE anything reads the target.
    // `_lock` lives to the end of the function: it covers the rename, the
    // directory fsync and the commit, and releases on every error path.
    ensure_parent_dir(&target)?;
    let _lock =
        crate::lock::acquire_exclusive(&target, crate::config::tuning().lock_acquire_timeout_ms)?;

    // Step 1-base: the caller computed the new content from the file as it read
    // it. Under the lock, refuse to overwrite a file that has changed since.
    // Checked before any journal is opened so a refusal leaves nothing behind.
    if let Some(expected) = expected_base {
        let actual = if target.exists() {
            Some(checksum::hash_file(&target, u64::MAX)?)
        } else {
            None
        };
        if actual.as_deref() != Some(expected) {
            return Err(crate::error::AtomwriteError::StateDrift {
                path: target,
                expected: expected.to_string(),
                actual: actual
                    .unwrap_or_else(|| crate::constants::MISSING_FILE_CHECKSUM.to_string()),
            }
            .into());
        }
    }

    // Step 1a: G119 L1 — decide whether to create a sidecar at all. The
    // heuristic short-circuits for trivial writes (small file in a git
    // dir, plain write, set/del). This is the first line of defence
    // against sidecar pollution and prevents 60-80% of unnecessary
    // sidecar writes in agent LLM workloads.
    // H3 throttles creation only (and only after L1 said yes), so a flood of
    // writes in one process stops opening journals instead of leaving them
    // behind after the commit.
    let sidecar_wanted =
        crate::wal::should_create_sidecar(&target, crate::wal::JournalOp::Write, opts.wal_policy)
            && !crate::wal::creation_throttled(opts.wal_policy);

    // Step 1b: G114 — append a `Started` WAL entry. On crash, the
    // orphan journal surfaces `expected_new_checksum` for recovery.
    // G119 L2 — wrap the sidecar in a `JournalGuard` so the sidecar is
    // automatically removed on normal scope exit (after `Committed`).
    // `wal_guard.keep_on_drop` starts as `true` (safe-by-default) and is
    // flipped to `false` by `wal_guard.release()` only after the rename
    // and the `Committed` entry succeed. We swallow errors here because
    // journaling is best-effort and must never block the actual write.
    let new_checksum = blake3::hash(content);
    let (wal_op_id, mut wal_guard) = if sidecar_wanted {
        match crate::wal::journal_started_with_guard(
            &target,
            crate::wal::JournalOp::Write,
            None, // checksum_before filled in just below
            new_checksum,
        ) {
            Ok(pair) => pair,
            Err(_) => (String::new(), crate::wal::JournalGuard::inert()),
        }
    } else {
        // L1 suppression: no sidecar, no guard, no recovery metadata.
        // The write is still atomic via the tempfile+rename pipeline;
        // only the WAL layer is bypassed.
        tracing::debug!(
            path = %target.display(),
            policy = opts.wal_policy.as_str(),
            "G119 L1: sidecar suppressed by wal-policy"
        );
        (String::new(), crate::wal::JournalGuard::inert())
    };
    let wal_op_id_opt: Option<String> = if wal_op_id.is_empty() {
        None
    } else {
        Some(wal_op_id)
    };

    // Step 2: capture metadata of existing file
    let (checksum_before, original_meta) = if target.exists() {
        let meta =
            fs::metadata(&target).with_context(|| format!("cannot stat {}", target.display()))?;
        let hash = checksum::hash_file(&target, u64::MAX)?;
        (Some(hash), Some(meta))
    } else {
        (None, None)
    };

    // Step 2b: detect hardlinks that will be broken by rename
    #[cfg(unix)]
    let hardlink_nlink = if let Some(ref meta) = original_meta {
        use std::os::unix::fs::MetadataExt;
        let nlink = meta.nlink();
        if nlink > 1 { Some(nlink) } else { None }
    } else {
        None
    };
    #[cfg(not(unix))]
    let hardlink_nlink: Option<u64> = None;

    // Step 2c: G39 — capture xattrs before any modification
    let saved_xattrs = crate::xattr_restore::save_xattrs(&target).unwrap_or_else(|e| {
        tracing::warn!(path = %target.display(), error = %e, "xattr save failed; continuing");
        Vec::new()
    });
    let xattr_count = saved_xattrs.len() as u32;

    // Step 2d: G55 — auto-detect strategy. Hardlinks and symlinks force InPlace.
    let is_symlink = {
        let sm = fs::symlink_metadata(&target);
        sm.as_ref().map(fs::Metadata::is_symlink).unwrap_or(false)
    };
    let strategy = match opts.strategy {
        Some(s) => s,
        None => {
            if hardlink_nlink.is_some_and(|n| n > 1) || is_symlink {
                WriteStrategy::InPlace
            } else {
                WriteStrategy::Rename
            }
        }
    };
    // `--strict-atomic` promises a single atomic step. The rename path delivers
    // it, and it also refuses the EXDEV copy fallback on its own. The in-place
    // path cannot: it truncates and rewrites the same inode, so a crash between
    // the two leaves a half-written file. Refusing it here keeps the flag's
    // promise true wherever the strategy was chosen for us.
    if opts.strict_atomic && matches!(strategy, WriteStrategy::InPlace) {
        return Err(crate::error::AtomwriteError::ExdevFallbackDisabled {
            path: target,
            reason: if is_symlink {
                "the target is a symlink, so the write must keep the existing inode"
            } else {
                "the target has more than one hardlink, so the write must keep the existing inode"
            }
            .into(),
        }
        .into());
    }
    if matches!(strategy, WriteStrategy::InPlace) {
        if let Some(n) = hardlink_nlink {
            tracing::info!(
                path = %target.display(),
                nlink = n,
                "auto-switched to InPlace to preserve hardlink(s)"
            );
        } else if is_symlink {
            tracing::info!(
                path = %target.display(),
                "auto-switched to InPlace because target is a symlink"
            );
        }
    }

    // Step 3: capture timestamps for preservation
    let (mtime, atime) = if let Some(ref meta) = original_meta {
        (
            filetime::FileTime::from_last_modification_time(meta),
            filetime::FileTime::from_last_access_time(meta),
        )
    } else {
        let now = filetime::FileTime::now();
        (now, now)
    };

    // Step 4: create backup if requested
    let backup_path = if opts.backup && target.exists() {
        // v0.1.30: create_backup_in ALWAYS uses reflink_or_copy (never hardlink).
        // Do not probe nlink — a hardlink target can make nlink>1 on the copy
        // path only if create failed into hardlink, which we forbid.
        let bp = create_backup_in(&target, opts.retention, opts.backup_output_dir.as_deref())?;
        backup_method_used = Some("reflink_or_copy");
        Some(bp)
    } else {
        None
    };

    // Step 5: parent directories already exist (see `ensure_parent_dir`).

    // Step 5.5: G72 — optional post-write syntax check.
    // v0.1.12: real tree-sitter parse via `crate::syntax_check`. Falls
    // back to a lightweight bracket-balance heuristic for unknown
    // languages. Auto-skips when no parser is available for the
    // detected extension. See `src/syntax_check.rs` for the full
    // algorithm.
    let syntax_errors: u32 = 0;
    if opts.syntax_check {
        match crate::syntax_check::syntax_check(&target, content) {
            Ok(crate::syntax_check::SyntaxCheckResult::Ok) => {}
            Ok(crate::syntax_check::SyntaxCheckResult::Skipped { .. }) => {
                // No parser for this language; keep the lightweight
                // heuristic as a final safety net.
                if let Some(reason) = syntax_heuristic_check(content) {
                    tracing::warn!(
                        path = %target.display(),
                        reason = %reason,
                        "G72 syntax heuristic (no tree-sitter parser) failed"
                    );
                    return Err(crate::error::AtomwriteError::SyntaxError {
                        path: target.to_path_buf(),
                        count: 1,
                    }
                    .into());
                }
            }
            Ok(crate::syntax_check::SyntaxCheckResult::Errors { count, first }) => {
                tracing::warn!(
                    path = %target.display(),
                    count = count,
                    line = first.line,
                    column = first.column,
                    kind = %first.kind,
                    message = %first.message,
                    "G72 tree-sitter syntax check failed"
                );
                return Err(crate::error::AtomwriteError::SyntaxError {
                    path: target.to_path_buf(),
                    count: count as u32,
                }
                .into());
            }
            Err(e) => {
                tracing::warn!(
                    path = %target.display(),
                    error = %e,
                    "G72 tree-sitter check errored; falling back to heuristic"
                );
                if let Some(_reason) = syntax_heuristic_check(content) {
                    return Err(crate::error::AtomwriteError::SyntaxError {
                        path: target.to_path_buf(),
                        count: 1,
                    }
                    .into());
                }
            }
        }
    }

    // Step 6–9: dispatch by strategy
    let (exdev_fallback, rename_method_used) = match strategy {
        WriteStrategy::Rename => match write_rename_path(
            target.as_path(),
            content,
            opts.strict_atomic,
            resolved_durability,
        ) {
            Ok(done) => done,
            Err(e) => {
                // A cooperative cancel raised on the rename path happens before
                // the rename: the target is intact and the tempfile is removed
                // on drop, so the journal has nothing to recover. Every other
                // failure keeps it for `wal-heal`.
                if matches!(
                    e.downcast_ref::<crate::error::AtomwriteError>(),
                    Some(crate::error::AtomwriteError::Cancelled { .. })
                ) {
                    wal_guard.discard();
                }
                return Err(e);
            }
        },
        WriteStrategy::InPlace | WriteStrategy::CopyBack => (
            write_inplace_path(target.as_path(), content)?,
            strategy.as_str(),
        ),
    };

    // Step 10: fsync parent directory (Full durability; skip for Fast)
    if !matches!(resolved_durability, crate::platform::Durability::Fast) {
        if let Some(parent) = target.parent() {
            if let Err(e) = platform::fsync_dir(parent) {
                tracing::warn!(
                    path = %parent.display(),
                    error = %e,
                    "fsync_dir after persist failed"
                );
            }
        }
    }

    // Step 11: restore permissions
    if let Some(ref meta) = original_meta {
        if let Err(e) = fs::set_permissions(&target, meta.permissions()) {
            tracing::warn!(path = %target.display(), error = %e, "permission restore failed");
        }
    }

    // Step 12: G39 — restore xattrs on the freshly written target
    let xattr_preserved = crate::xattr_restore::restore_xattrs(&target, &saved_xattrs)
        .unwrap_or_else(|e| {
            tracing::warn!(path = %target.display(), error = %e, "xattr restore failed");
            0
        });

    // Step 13: restore timestamps
    if opts.preserve_timestamps && original_meta.is_some() {
        if let Err(e) = platform::preserve_timestamps(&target, mtime, atime) {
            tracing::warn!(path = %target.display(), error = %e, "timestamp restore failed");
        }
    }

    let checksum = checksum::hash_bytes(content);

    // G114: append a `Committed` journal entry to mark the write complete.
    // G119 L2: release the guard so the Drop runs and removes the sidecar.
    // We ignore errors here (best-effort) since the file is already on disk
    // and a recovery-time report will surface any orphan.
    if let Some(ref op_id) = wal_op_id_opt {
        crate::best_effort::best_effort(
            crate::wal::journal_committed(&target, op_id),
            "append Committed journal entry",
        );
        wal_guard.release();
    } else {
        // No WAL was created (best-effort fallback) — guard is inert.
        wal_guard.keep();
    }

    // v0.1.21 GAP-014 v2: delete backup quietly after successful write
    // when the caller did not request retention. Idempotent: NotFound is
    // treated as success. Errors other than NotFound are logged at WARN
    // level but do NOT propagate — the user's write already succeeded.
    // GAP-101: clear backup_path when the file is deleted so NDJSON
    // never reports a path that does not exist on disk.
    let backup_path = if let Some(ref bp) = backup_path {
        if !opts.keep_backup {
            delete_backup_quietly(bp);
            None
        } else {
            Some(bp.clone())
        }
    } else {
        None
    };

    Ok(WriteResult {
        bytes_written: content.len() as u64,
        checksum,
        checksum_before,
        backup_path: backup_path.map(|p| p.display().to_string()),
        elapsed_ms: start.elapsed().as_millis() as u64,
        platform: PlatformInfo {
            fsync: platform::platform_fsync_name(),
            dir_fsync: platform::platform_dir_fsync_name(),
            durability: Some(resolved_durability.as_str()),
            rename_method: Some(rename_method_used),
            backup_method: backup_method_used,
        },
        hardlink_nlink,
        write_strategy: strategy.as_str(),
        xattr_preserved,
        xattr_count,
        exdev_fallback,
        syntax_errors,
        durability: resolved_durability.as_str(),
        rename_method: rename_method_used,
        backup_method: backup_method_used,
    })
}

/// Step 5: create missing parent directories (mode `DIR_PERMISSIONS` on Unix).
fn ensure_parent_dir(target: &Path) -> Result<()> {
    if let Some(parent) = target.parent() {
        if !parent.exists() {
            fs::create_dir_all(parent)
                .with_context(|| format!("cannot create directories for {}", target.display()))?;
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                crate::best_effort::best_effort(
                    fs::set_permissions(
                        parent,
                        fs::Permissions::from_mode(crate::constants::DIR_PERMISSIONS),
                    ),
                    "set mode on created parent directory",
                );
            }
        }
    }
    Ok(())
}