Skip to main content

nika_engine/io/
atomic.rs

1//! Atomic file operations for safe writes
2//!
3//! Shared by WriteTool and ArtifactWriter.
4//! All functions use tokio::fs for async I/O.
5//!
6//! # Guarantees
7//!
8//! - **Atomicity**: temp file → flush → sync → rename
9//! - **Durability**: `sync_all()` ensures data reaches disk
10//! - **Safety**: cleanup on failure, no partial writes
11//!
12//! # Usage
13//!
14//! ```ignore
15//! use nika::io::atomic;
16//!
17//! // Atomic overwrite
18//! atomic::write_atomic(path, content).await?;
19//!
20//! // Append to file
21//! atomic::write_append(path, content).await?;
22//!
23//! // Generate unique filename if exists
24//! let actual_path = atomic::write_unique(path, content).await?;
25//!
26//! // Fail if file exists (atomic check)
27//! atomic::write_fail(path, content).await?;
28//! ```
29
30use std::path::{Path, PathBuf};
31use tokio::fs::{self, File, OpenOptions};
32use tokio::io::AsyncWriteExt;
33use uuid::Uuid;
34
35// ═══════════════════════════════════════════════════════════════════════════
36// ATOMIC WRITE FUNCTIONS
37// ═══════════════════════════════════════════════════════════════════════════
38
39/// Atomic write: temp file → flush → sync → rename
40///
41/// # Guarantees
42///
43/// - All-or-nothing semantics
44/// - Data reaches disk before file appears
45/// - No partial writes on crash
46///
47/// # Arguments
48///
49/// * `path` - Target file path
50/// * `content` - Content to write
51///
52/// # Errors
53///
54/// Returns `std::io::Error` if:
55/// - Cannot create temp file
56/// - Cannot write content
57/// - Cannot rename (different filesystem)
58pub async fn write_atomic(path: &Path, content: &[u8]) -> std::io::Result<()> {
59    let parent = path.parent().unwrap_or(Path::new("."));
60
61    // Unique temp filename: .nika-tmp-{pid}-{uuid}
62    let temp_path = parent.join(format!(
63        ".nika-tmp-{}-{}",
64        std::process::id(),
65        Uuid::new_v4().simple()
66    ));
67
68    // Write to temp file
69    let mut file = File::create(&temp_path).await?;
70    file.write_all(content).await?;
71    file.flush().await?;
72    file.sync_all().await?;
73    drop(file); // Ensure closed before rename
74
75    // Atomic rename (POSIX guarantees atomicity on same filesystem)
76    match fs::rename(&temp_path, path).await {
77        Ok(()) => Ok(()),
78        Err(e) => {
79            // Best-effort cleanup
80            let _ = fs::remove_file(&temp_path).await;
81            Err(e)
82        }
83    }
84}
85
86/// Write with append semantics
87///
88/// Creates file if it doesn't exist, appends if it does.
89/// Uses flush + sync for durability.
90///
91/// # Arguments
92///
93/// * `path` - Target file path
94/// * `content` - Content to append
95pub async fn write_append(path: &Path, content: &[u8]) -> std::io::Result<()> {
96    let mut file = OpenOptions::new()
97        .create(true)
98        .append(true)
99        .open(path)
100        .await?;
101
102    file.write_all(content).await?;
103    file.flush().await?;
104    file.sync_all().await?;
105    Ok(())
106}
107
108/// Write with unique filename if exists
109///
110/// If `path` exists, generates `path-1.ext`, `path-2.ext`, etc.
111/// Returns the actual path written.
112///
113/// # Arguments
114///
115/// * `path` - Preferred file path
116/// * `content` - Content to write
117///
118/// # Returns
119///
120/// The actual path where content was written.
121///
122/// # Errors
123///
124/// Returns error if:
125/// - Cannot find unique name after 1000 attempts
126/// - Write operation fails
127pub async fn write_unique(path: &Path, content: &[u8]) -> std::io::Result<PathBuf> {
128    // Try original path first
129    if !path_exists(path).await {
130        write_atomic(path, content).await?;
131        return Ok(path.to_path_buf());
132    }
133
134    let stem = path
135        .file_stem()
136        .unwrap_or_default()
137        .to_string_lossy()
138        .to_string();
139    let ext = path
140        .extension()
141        .map(|e| format!(".{}", e.to_string_lossy()))
142        .unwrap_or_default();
143    let parent = path.parent().unwrap_or(Path::new("."));
144
145    for i in 1..1000 {
146        let new_path = parent.join(format!("{}-{}{}", stem, i, ext));
147        if !path_exists(&new_path).await {
148            write_atomic(&new_path, content).await?;
149            return Ok(new_path);
150        }
151    }
152
153    Err(std::io::Error::new(
154        std::io::ErrorKind::AlreadyExists,
155        "Could not generate unique filename after 1000 attempts",
156    ))
157}
158
159/// Write with fail-if-exists semantics (atomic check + create)
160///
161/// Uses `create_new(true)` for atomic check+create (O_EXCL on POSIX).
162/// This avoids TOCTOU race conditions.
163///
164/// # Arguments
165///
166/// * `path` - Target file path (must not exist)
167/// * `content` - Content to write
168///
169/// # Errors
170///
171/// Returns `ErrorKind::AlreadyExists` if file exists.
172pub async fn write_fail(path: &Path, content: &[u8]) -> std::io::Result<()> {
173    // Use create_new for atomic check + create (avoids TOCTOU race)
174    let mut file = OpenOptions::new()
175        .write(true)
176        .create_new(true) // Fails if exists - atomic!
177        .open(path)
178        .await?;
179
180    file.write_all(content).await?;
181    file.flush().await?;
182    file.sync_all().await?;
183    Ok(())
184}
185
186// ═══════════════════════════════════════════════════════════════════════════
187// HELPERS
188// ═══════════════════════════════════════════════════════════════════════════
189
190/// Check if path exists (async)
191async fn path_exists(path: &Path) -> bool {
192    fs::metadata(path).await.is_ok()
193}
194
195// ═══════════════════════════════════════════════════════════════════════════
196// TESTS
197// ═══════════════════════════════════════════════════════════════════════════
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use tempfile::TempDir;
203
204    // ───────────────────────────────────────────────────────────────────────
205    // write_atomic tests
206    // ───────────────────────────────────────────────────────────────────────
207
208    #[tokio::test]
209    async fn test_write_atomic_creates_file() {
210        let temp_dir = TempDir::new().unwrap();
211        let path = temp_dir.path().join("test.txt");
212
213        write_atomic(&path, b"Hello, World!").await.unwrap();
214
215        let content = fs::read_to_string(&path).await.unwrap();
216        assert_eq!(content, "Hello, World!");
217    }
218
219    #[tokio::test]
220    async fn test_write_atomic_overwrites_existing() {
221        let temp_dir = TempDir::new().unwrap();
222        let path = temp_dir.path().join("test.txt");
223
224        // Create initial file
225        fs::write(&path, "original").await.unwrap();
226
227        // Overwrite
228        write_atomic(&path, b"updated").await.unwrap();
229
230        let content = fs::read_to_string(&path).await.unwrap();
231        assert_eq!(content, "updated");
232    }
233
234    #[tokio::test]
235    async fn test_write_atomic_no_temp_file_left() {
236        let temp_dir = TempDir::new().unwrap();
237        let path = temp_dir.path().join("test.txt");
238
239        write_atomic(&path, b"content").await.unwrap();
240
241        // Check no .nika-tmp-* files remain
242        let entries: Vec<_> = std::fs::read_dir(temp_dir.path())
243            .unwrap()
244            .filter_map(|e| e.ok())
245            .filter(|e| e.file_name().to_string_lossy().starts_with(".nika-tmp-"))
246            .collect();
247
248        assert!(entries.is_empty(), "Temp files should be cleaned up");
249    }
250
251    #[tokio::test]
252    async fn test_write_atomic_binary_content() {
253        let temp_dir = TempDir::new().unwrap();
254        let path = temp_dir.path().join("binary.bin");
255
256        let binary_data: Vec<u8> = (0..=255).collect();
257        write_atomic(&path, &binary_data).await.unwrap();
258
259        let content = fs::read(&path).await.unwrap();
260        assert_eq!(content, binary_data);
261    }
262
263    // ───────────────────────────────────────────────────────────────────────
264    // write_append tests
265    // ───────────────────────────────────────────────────────────────────────
266
267    #[tokio::test]
268    async fn test_write_append_creates_new_file() {
269        let temp_dir = TempDir::new().unwrap();
270        let path = temp_dir.path().join("log.txt");
271
272        write_append(&path, b"line 1\n").await.unwrap();
273
274        let content = fs::read_to_string(&path).await.unwrap();
275        assert_eq!(content, "line 1\n");
276    }
277
278    #[tokio::test]
279    async fn test_write_append_appends_to_existing() {
280        let temp_dir = TempDir::new().unwrap();
281        let path = temp_dir.path().join("log.txt");
282
283        write_append(&path, b"line 1\n").await.unwrap();
284        write_append(&path, b"line 2\n").await.unwrap();
285        write_append(&path, b"line 3\n").await.unwrap();
286
287        let content = fs::read_to_string(&path).await.unwrap();
288        assert_eq!(content, "line 1\nline 2\nline 3\n");
289    }
290
291    // ───────────────────────────────────────────────────────────────────────
292    // write_unique tests
293    // ───────────────────────────────────────────────────────────────────────
294
295    #[tokio::test]
296    async fn test_write_unique_uses_original_if_available() {
297        let temp_dir = TempDir::new().unwrap();
298        let path = temp_dir.path().join("data.json");
299
300        let actual = write_unique(&path, b"{}").await.unwrap();
301
302        assert_eq!(actual, path);
303        assert!(path.exists());
304    }
305
306    #[tokio::test]
307    async fn test_write_unique_generates_suffix() {
308        let temp_dir = TempDir::new().unwrap();
309        let path = temp_dir.path().join("data.json");
310
311        // Create original
312        fs::write(&path, "original").await.unwrap();
313
314        // Should create data-1.json
315        let actual = write_unique(&path, b"new").await.unwrap();
316
317        assert_eq!(actual, temp_dir.path().join("data-1.json"));
318        assert!(actual.exists());
319
320        // Original unchanged
321        let original_content = fs::read_to_string(&path).await.unwrap();
322        assert_eq!(original_content, "original");
323    }
324
325    #[tokio::test]
326    async fn test_write_unique_increments_suffix() {
327        let temp_dir = TempDir::new().unwrap();
328        let path = temp_dir.path().join("file.txt");
329
330        // Create file.txt, file-1.txt, file-2.txt
331        fs::write(&path, "0").await.unwrap();
332        fs::write(temp_dir.path().join("file-1.txt"), "1")
333            .await
334            .unwrap();
335        fs::write(temp_dir.path().join("file-2.txt"), "2")
336            .await
337            .unwrap();
338
339        // Should create file-3.txt
340        let actual = write_unique(&path, b"3").await.unwrap();
341
342        assert_eq!(actual, temp_dir.path().join("file-3.txt"));
343    }
344
345    #[tokio::test]
346    async fn test_write_unique_no_extension() {
347        let temp_dir = TempDir::new().unwrap();
348        let path = temp_dir.path().join("README");
349
350        // Create original
351        fs::write(&path, "original").await.unwrap();
352
353        // Should create README-1 (no extension)
354        let actual = write_unique(&path, b"new").await.unwrap();
355
356        assert_eq!(actual, temp_dir.path().join("README-1"));
357    }
358
359    // ───────────────────────────────────────────────────────────────────────
360    // write_fail tests
361    // ───────────────────────────────────────────────────────────────────────
362
363    #[tokio::test]
364    async fn test_write_fail_creates_new_file() {
365        let temp_dir = TempDir::new().unwrap();
366        let path = temp_dir.path().join("new.txt");
367
368        write_fail(&path, b"content").await.unwrap();
369
370        let content = fs::read_to_string(&path).await.unwrap();
371        assert_eq!(content, "content");
372    }
373
374    #[tokio::test]
375    async fn test_write_fail_errors_if_exists() {
376        let temp_dir = TempDir::new().unwrap();
377        let path = temp_dir.path().join("existing.txt");
378
379        // Create file first
380        fs::write(&path, "existing").await.unwrap();
381
382        // Should fail
383        let result = write_fail(&path, b"new").await;
384
385        assert!(result.is_err());
386        assert_eq!(
387            result.unwrap_err().kind(),
388            std::io::ErrorKind::AlreadyExists
389        );
390
391        // Original unchanged
392        let content = fs::read_to_string(&path).await.unwrap();
393        assert_eq!(content, "existing");
394    }
395
396    #[tokio::test]
397    async fn test_write_fail_atomic_check() {
398        // This test verifies that write_fail uses O_EXCL (create_new)
399        // which provides atomic check-and-create semantics
400        let temp_dir = TempDir::new().unwrap();
401        let path = temp_dir.path().join("race.txt");
402
403        // Two concurrent writes - only one should succeed
404        let path1 = path.clone();
405        let path2 = path.clone();
406
407        let (r1, r2) = tokio::join!(
408            write_fail(&path1, b"writer 1"),
409            write_fail(&path2, b"writer 2"),
410        );
411
412        // Exactly one should succeed
413        let successes = [r1.is_ok(), r2.is_ok()];
414        assert_eq!(
415            successes.iter().filter(|&&x| x).count(),
416            1,
417            "Exactly one writer should succeed"
418        );
419    }
420
421    // ───────────────────────────────────────────────────────────────────────
422    // Edge cases
423    // ───────────────────────────────────────────────────────────────────────
424
425    #[tokio::test]
426    async fn test_write_atomic_empty_content() {
427        let temp_dir = TempDir::new().unwrap();
428        let path = temp_dir.path().join("empty.txt");
429
430        write_atomic(&path, b"").await.unwrap();
431
432        let content = fs::read(&path).await.unwrap();
433        assert!(content.is_empty());
434    }
435
436    #[tokio::test]
437    async fn test_write_atomic_large_content() {
438        let temp_dir = TempDir::new().unwrap();
439        let path = temp_dir.path().join("large.bin");
440
441        // 1MB of data
442        let large_content: Vec<u8> = (0..1024 * 1024).map(|i| (i % 256) as u8).collect();
443        write_atomic(&path, &large_content).await.unwrap();
444
445        let content = fs::read(&path).await.unwrap();
446        assert_eq!(content.len(), 1024 * 1024);
447        assert_eq!(content, large_content);
448    }
449
450    #[tokio::test]
451    async fn test_write_to_nested_existing_dir() {
452        let temp_dir = TempDir::new().unwrap();
453        let nested = temp_dir.path().join("a/b/c");
454        fs::create_dir_all(&nested).await.unwrap();
455
456        let path = nested.join("file.txt");
457        write_atomic(&path, b"nested").await.unwrap();
458
459        assert!(path.exists());
460    }
461}