dirge-agent 0.7.4

Minimalistic coding agent written in Rust, optimized for memory footprint and performance
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
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
use rig::completion::ToolDefinition;
use rig::tool::Tool;
use serde::Deserialize;
use std::path::Path;

use crate::agent::tools::cache::ToolCache;
use crate::agent::tools::{AskSender, PermCheck, ToolError, check_perm_path_resolve};

/// Max content size for a single create op (1 MiB). Audit L5 noted
/// the dual cap with `MAX_APPLY_PATCH_BYTES` (100 MiB) — both apply
/// to creates and the tighter wins. Intentional: creating a 50 MB
/// file from inside an LLM tool call is almost always a bug; the
/// large cap exists for update / read paths on legitimately large
/// files. The tight create cap protects the LLM from accidentally
/// dumping a multi-MB blob into the repo.
const MAX_CREATE_SIZE: usize = 1_048_576;

#[derive(Deserialize, Debug, Clone)]
#[serde(tag = "action")]
pub enum PatchOp {
    #[serde(rename = "create")]
    Create { path: String, content: String },
    #[serde(rename = "update")]
    Update {
        path: String,
        old_text: String,
        new_text: String,
    },
    #[serde(rename = "delete")]
    Delete { path: String },
    #[serde(rename = "rename")]
    Rename { path: String, new_path: String },
}

pub struct ApplyPatchTool {
    pub permission: Option<PermCheck>,
    pub ask_tx: Option<AskSender>,
    cache: Option<ToolCache>,
}

impl ApplyPatchTool {
    #[allow(dead_code)]
    pub fn new(permission: Option<PermCheck>, ask_tx: Option<AskSender>) -> Self {
        Self {
            permission,
            ask_tx,
            cache: None,
        }
    }

    pub fn with_cache(
        permission: Option<PermCheck>,
        ask_tx: Option<AskSender>,
        cache: ToolCache,
    ) -> Self {
        Self {
            permission,
            ask_tx,
            cache: Some(cache),
        }
    }
}

#[derive(Deserialize)]
pub struct ApplyPatchArgs {
    pub operations: Vec<PatchOp>,
}

/// Cap apply_patch read/write at 100 MiB. The tool isn't meant for
/// binary blobs or generated artifacts; an LLM pointing it at a
/// gigabyte file should fail fast rather than OOM the process.
const MAX_APPLY_PATCH_BYTES: u64 = 100 * 1024 * 1024;

async fn apply_create(path: &str, content: &str) -> Result<String, String> {
    let p = Path::new(path);
    if tokio::fs::try_exists(p).await.unwrap_or(false) {
        return Err(format!("file already exists: {}", path));
    }
    if let Some(parent) = p.parent()
        && !parent.as_os_str().is_empty()
    {
        tokio::fs::create_dir_all(parent)
            .await
            .map_err(|e| format!("failed to create parent dir: {}", e))?;
    }
    if content.len() as u64 > MAX_APPLY_PATCH_BYTES {
        return Err(format!(
            "create content too large: {} bytes (cap {} bytes)",
            content.len(),
            MAX_APPLY_PATCH_BYTES,
        ));
    }
    // Phase-2 tree-sitter validation: refuse to create
    // syntactically-broken files. See docs/AGENTIC_LOOP_PLAN.md §2.
    #[cfg(feature = "semantic")]
    if let Err(errors) = crate::semantic::syntax_validator::check_syntax(p, content) {
        return Err(crate::semantic::syntax_validator::format_errors(
            p, content, &errors,
        ));
    }
    // Snapshot pre-state (absent) for /rewind so restore deletes it.
    crate::agent::tools::snapshots::capture(p);
    crate::fs_atomic::atomic_write(p, content.as_bytes())
        .await
        .map_err(|e| format!("write failed: {}", e))?;
    Ok(format!("created {}", path))
}

async fn apply_update(path: &str, old_text: &str, new_text: &str) -> Result<String, String> {
    // Pre-check size before reading the file into memory. The
    // metadata call is cheap (single stat); rejecting here avoids
    // a multi-GB allocation in `read_to_string`.
    if let Ok(meta) = tokio::fs::metadata(path).await
        && meta.len() > MAX_APPLY_PATCH_BYTES
    {
        return Err(format!(
            "file too large for apply_patch: {} bytes (cap {} bytes); use bash + sed/awk for huge files",
            meta.len(),
            MAX_APPLY_PATCH_BYTES,
        ));
    }
    let original = tokio::fs::read_to_string(path)
        .await
        .map_err(|e| format!("read failed: {}", e))?;

    // CRLF normalization to match `edit.rs`. The LLM almost always
    // generates `\n` in `old_text` even when the file is CRLF on
    // disk; without normalization the literal substring match fails.
    // We normalize a working copy for matching but preserve the
    // original's line endings on the write-back.
    let crlf = original.contains("\r\n");
    let normalized = if crlf {
        original.replace("\r\n", "\n")
    } else {
        original.clone()
    };
    let needle = old_text.replace("\r\n", "\n");

    if !normalized.contains(&needle) {
        return Err(format!("text not found in {}", path));
    }

    let matches: Vec<_> = normalized.match_indices(&needle).collect();
    if matches.len() > 1 {
        return Err(format!(
            "text matches {} locations in {} — provide more context to make unique",
            matches.len(),
            path
        ));
    }

    let replacement = if crlf {
        new_text.replace("\r\n", "\n")
    } else {
        new_text.to_string()
    };
    let updated_normalized = normalized.replacen(&needle, &replacement, 1);
    // Restore CRLF line endings on write-back so we don't silently
    // re-format the user's file.
    let to_write = if crlf {
        updated_normalized.replace('\n', "\r\n")
    } else {
        updated_normalized
    };
    // Phase-2 tree-sitter validation on the updated content
    // before write. See docs/AGENTIC_LOOP_PLAN.md §2.
    #[cfg(feature = "semantic")]
    if let Err(errors) =
        crate::semantic::syntax_validator::check_syntax(std::path::Path::new(path), &to_write)
    {
        return Err(crate::semantic::syntax_validator::format_errors(
            std::path::Path::new(path),
            &to_write,
            &errors,
        ));
    }
    // Snapshot pre-update content for /rewind, reusing the bytes we
    // already read into `original` rather than re-reading from disk.
    crate::agent::tools::snapshots::capture_bytes(std::path::Path::new(path), original.as_bytes());
    crate::fs_atomic::atomic_write(std::path::Path::new(path), to_write.as_bytes())
        .await
        .map_err(|e| format!("write failed: {}", e))?;
    Ok(format!("updated {}", path))
}

async fn apply_delete(path: &str) -> Result<String, String> {
    // Snapshot the content before deleting so /rewind recreates it.
    crate::agent::tools::snapshots::capture(std::path::Path::new(path));
    tokio::fs::remove_file(path)
        .await
        .map_err(|e| format!("delete failed: {}", e))?;
    Ok(format!("deleted {}", path))
}

async fn apply_rename(path: &str, new_path: &str) -> Result<String, String> {
    // Snapshot both ends: src content (restore recreates it) and the
    // dst's prior state (restore removes the renamed-in file).
    crate::agent::tools::snapshots::capture(std::path::Path::new(path));
    crate::agent::tools::snapshots::capture(std::path::Path::new(new_path));
    tokio::fs::rename(path, new_path)
        .await
        .map_err(|e| format!("rename failed: {}", e))?;
    Ok(format!("renamed {} -> {}", path, new_path))
}

impl Tool for ApplyPatchTool {
    const NAME: &'static str = "apply_patch";

    type Error = ToolError;
    type Args = ApplyPatchArgs;
    type Output = String;

    async fn definition(&self, _prompt: String) -> ToolDefinition {
        ToolDefinition {
            name: "apply_patch".to_string(),
            description: crate::agent::agent_loop::tool_input_repair::with_contract_hint(
                "apply_patch",
                "Apply multiple file operations in a single call. Supports create, update (by exact text match), delete, and rename. Operations execute in order and stop on first failure — prior operations that succeeded remain applied.",
            ),
            parameters: serde_json::json!({
                "type": "object",
                "properties": {
                    "operations": {
                        "type": "array",
                        "description": "Ordered list of file operations to execute",
                        "items": {
                            "type": "object",
                            "properties": {
                                "action": {
                                    "type": "string",
                                    "enum": ["create", "update", "delete", "rename"],
                                    "description": "The type of operation"
                                },
                                "path": {
                                    "type": "string",
                                    "description": "Target file path"
                                },
                                "content": {
                                    "type": "string",
                                    "description": "File content (required for create)"
                                },
                                "old_text": {
                                    "type": "string",
                                    "description": "Exact text to find and replace (required for update)"
                                },
                                "new_text": {
                                    "type": "string",
                                    "description": "Replacement text (required for update)"
                                },
                                "new_path": {
                                    "type": "string",
                                    "description": "New file path (required for rename)"
                                }
                            },
                            "required": ["action", "path"]
                        }
                    }
                },
                "required": ["operations"]
            }),
        }
    }

    async fn call(&self, args: ApplyPatchArgs) -> Result<String, ToolError> {
        if args.operations.is_empty() {
            return Err(ToolError::Msg("no operations provided".to_string()));
        }

        let mut results = Vec::new();

        for op in &args.operations {
            // Plan-mode restriction is enforced at the permission-
            // checker layer now (the active prompt's frontmatter
            // `deny_tools: [apply_patch, ...]` blocks the tool
            // entirely). The previous in-tool PLAN.md path gate is
            // gone.

            // TOOL-3: require absolute paths up front, matching the
            // sibling tools (`read`, `write`, `edit`). The permission
            // checker would resolve a relative path against cwd, but
            // the agent-facing schema asks for absolute paths and
            // silently accepting `./foo` masks bugs / makes intent
            // ambiguous when the agent's mental model is "absolute".
            let op_path: &str = match op {
                PatchOp::Create { path, .. }
                | PatchOp::Update { path, .. }
                | PatchOp::Delete { path }
                | PatchOp::Rename { path, .. } => path,
            };
            // Shared absolute-path guard (the schema requires absolute
            // paths for both fields).
            crate::agent::tools::require_absolute_path(op_path, "the apply_patch path")
                .map_err(ToolError::Msg)?;
            if let PatchOp::Rename { new_path, .. } = op {
                crate::agent::tools::require_absolute_path(
                    new_path,
                    "the apply_patch rename target",
                )
                .map_err(ToolError::Msg)?;
            }

            // C1 (audit fix): resolve the path THROUGH the permission
            // checker so symlinks are pinned to their canonical target.
            // The check returns the canonical form; subsequent
            // apply_create/update/delete/rename operate on that
            // resolved path, defeating any symlink swap between
            // check-time and open-time. Matches the H12 pattern
            // already applied to read/write/edit.
            let resolved_path = match op {
                PatchOp::Create { path, .. }
                | PatchOp::Update { path, .. }
                | PatchOp::Delete { path }
                | PatchOp::Rename { path, .. } => {
                    check_perm_path_resolve(&self.permission, &self.ask_tx, "apply_patch", path)
                        .await?
                }
            };
            // Rename also requires permission on the new path; pin
            // its canonical form too.
            let resolved_new_path = if let PatchOp::Rename { new_path, .. } = op {
                Some(
                    check_perm_path_resolve(
                        &self.permission,
                        &self.ask_tx,
                        "apply_patch",
                        new_path,
                    )
                    .await?,
                )
            } else {
                None
            };
            // Validate create content size
            if let PatchOp::Create { content, .. } = op
                && content.len() > MAX_CREATE_SIZE
            {
                results.push(format!(
                    "FAILED: create content exceeds {} bytes ({} bytes provided)",
                    MAX_CREATE_SIZE,
                    content.len()
                ));
                break;
            }

            // Read-before-edit gate (vix session_read_gate): an `update` op
            // matches `old_text` against existing content, so it must have
            // been read this session. create/delete/rename don't match content
            // and aren't gated. Skipped when no session cache is present.
            if let PatchOp::Update { .. } = op
                && let Some(ref cache) = self.cache
                && !cache.has_been_read(std::path::Path::new(&resolved_path))
            {
                results.push(format!(
                    "FAILED: \"{}\" has not been read in this session yet; read it first so the \
                     update matches the current on-disk contents",
                    op_path
                ));
                break;
            }

            let result = match op {
                PatchOp::Create { content, .. } => apply_create(&resolved_path, content).await,
                PatchOp::Update {
                    old_text, new_text, ..
                } => apply_update(&resolved_path, old_text, new_text).await,
                PatchOp::Delete { .. } => apply_delete(&resolved_path).await,
                PatchOp::Rename { .. } => {
                    apply_rename(
                        &resolved_path,
                        resolved_new_path
                            .as_deref()
                            .expect("resolved_new_path set for Rename"),
                    )
                    .await
                }
            };

            match result {
                Ok(msg) => {
                    // Record the touched path(s) for the info panel.
                    // Use the RESOLVED paths so info-panel state
                    // matches what was actually written on disk.
                    match op {
                        PatchOp::Create { .. }
                        | PatchOp::Update { .. }
                        | PatchOp::Delete { .. } => {
                            let p = std::path::Path::new(&resolved_path);
                            crate::agent::tools::modified::mark_modified(p);
                            // create/update leave the model with accurate
                            // on-disk knowledge → satisfy the read gate for
                            // follow-up edits (delete removes the file, so
                            // marking it is harmless).
                            if let Some(ref cache) = self.cache {
                                cache.mark_read(p);
                            }
                        }
                        PatchOp::Rename { .. } => {
                            if let Some(ref np) = resolved_new_path {
                                let p = std::path::Path::new(np);
                                crate::agent::tools::modified::mark_modified(p);
                                if let Some(ref cache) = self.cache {
                                    cache.mark_read(p);
                                }
                            }
                        }
                    }
                    results.push(msg);
                }
                Err(e) => {
                    results.push(format!("FAILED: {}", e));
                    break;
                }
            }
        }

        // Clear the cache once after the batch instead of once per op.
        // Per-op clearing was correct but wasteful — a 5-op batch
        // would clear 5 times. Subsequent tool calls within the same
        // turn now see a single clean cache.
        if let Some(ref cache) = self.cache {
            cache.clear();
        }

        Ok(results.join("\n"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct TestFile {
        path: String,
    }

    impl TestFile {
        fn new(name: &str) -> Self {
            let path = format!("/tmp/dirge-test-{}", name);
            // Clean up any leftover
            let _ = std::fs::remove_file(&path);
            Self { path }
        }
    }

    impl Drop for TestFile {
        fn drop(&mut self) {
            let _ = std::fs::remove_file(&self.path);
        }
    }

    #[tokio::test]
    async fn test_create_and_read() {
        let tf = TestFile::new("create-test.txt");
        let result = apply_create(&tf.path, "hello world").await;
        assert!(result.is_ok());
        let content = std::fs::read_to_string(&tf.path).unwrap();
        assert_eq!(content, "hello world");
    }

    #[tokio::test]
    async fn test_create_existing_file_fails() {
        let tf = TestFile::new("create-exists.txt");
        std::fs::write(&tf.path, "existing").unwrap();
        let result = apply_create(&tf.path, "new").await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn test_update_text() {
        let tf = TestFile::new("update-test.txt");
        std::fs::write(&tf.path, "before after").unwrap();
        let result = apply_update(&tf.path, "before", "replaced").await;
        assert!(result.is_ok());
        let content = std::fs::read_to_string(&tf.path).unwrap();
        assert_eq!(content, "replaced after");
    }

    #[tokio::test]
    async fn test_update_text_not_found() {
        let tf = TestFile::new("update-notfound.txt");
        std::fs::write(&tf.path, "some content").unwrap();
        let result = apply_update(&tf.path, "nonexistent", "replacement").await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn test_delete_file() {
        let tf = TestFile::new("delete-test.txt");
        std::fs::write(&tf.path, "to delete").unwrap();
        assert!(Path::new(&tf.path).exists());
        let result = apply_delete(&tf.path).await;
        assert!(result.is_ok());
        assert!(!Path::new(&tf.path).exists());
    }

    #[tokio::test]
    async fn test_rename_file() {
        let src = TestFile::new("rename-src.txt");
        let dst = "/tmp/dirge-test-rename-dst.txt";
        let _ = std::fs::remove_file(dst);
        std::fs::write(&src.path, "rename me").unwrap();

        let result = apply_rename(&src.path, dst).await;
        assert!(result.is_ok());
        assert!(!Path::new(&src.path).exists());
        assert!(Path::new(dst).exists());
        let _ = std::fs::remove_file(dst);
    }

    #[tokio::test]
    async fn test_rejects_empty_operations() {
        let tool = ApplyPatchTool::new(None, None);
        let result = tool.call(ApplyPatchArgs { operations: vec![] }).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("no operations"));
    }

    #[tokio::test]
    async fn test_definition_has_correct_name() {
        let tool = ApplyPatchTool::new(None, None);
        let def = tool.definition(String::new()).await;
        assert_eq!(def.name, "apply_patch");
    }

    // Regression: update is documented as text-find-and-replace and must reject
    // ambiguous matches rather than silently replacing the first one. Without
    // this guard the agent could clobber wrong code in a file with repeated
    // boilerplate (use statements, similar function bodies, etc.).
    #[tokio::test]
    async fn regression_update_rejects_multiple_matches() {
        let tf = TestFile::new("update-ambiguous.txt");
        std::fs::write(&tf.path, "foo bar foo baz foo").unwrap();
        let result = apply_update(&tf.path, "foo", "qux").await;
        assert!(result.is_err());
        let msg = result.unwrap_err();
        assert!(msg.contains("3 locations"), "got: {msg}");
        // File should be untouched.
        assert_eq!(
            std::fs::read_to_string(&tf.path).unwrap(),
            "foo bar foo baz foo"
        );
    }

    // Regression: prior to the fix, multi-op patches were documented as
    // "atomic" but in fact left earlier successful ops applied when a later op
    // failed. We now stop on first failure AND the prior ops MUST stay applied
    // (no rollback). The error report must explicitly call out which op failed
    // and ops after the failure must NOT execute.
    #[tokio::test]
    async fn regression_multi_op_stops_on_failure_prior_ops_remain() {
        let a = TestFile::new("multi-op-a.txt");
        let b_existing = TestFile::new("multi-op-b.txt");
        let c_should_not_exist = TestFile::new("multi-op-c.txt");

        // Pre-create B so the second op (create B) fails.
        std::fs::write(&b_existing.path, "already here").unwrap();

        let tool = ApplyPatchTool::new(None, None);
        let result = tool
            .call(ApplyPatchArgs {
                operations: vec![
                    PatchOp::Create {
                        path: a.path.clone(),
                        content: "A content".into(),
                    },
                    PatchOp::Create {
                        path: b_existing.path.clone(),
                        content: "B content".into(),
                    },
                    PatchOp::Create {
                        path: c_should_not_exist.path.clone(),
                        content: "C content".into(),
                    },
                ],
            })
            .await
            .unwrap();

        // A was created.
        assert!(Path::new(&a.path).exists(), "A must remain applied");
        assert_eq!(std::fs::read_to_string(&a.path).unwrap(), "A content");
        // B was not overwritten.
        assert_eq!(
            std::fs::read_to_string(&b_existing.path).unwrap(),
            "already here"
        );
        // C was never attempted.
        assert!(
            !Path::new(&c_should_not_exist.path).exists(),
            "C must not run after failure"
        );
        // Report names both the success and the failure.
        assert!(result.contains("created"), "got: {result}");
        assert!(result.contains("FAILED"), "got: {result}");
    }

    // Regression: create previously had no size cap; the agent could write
    // multi-GB files by accident. 1MB limit must be enforced before touching
    // the filesystem, and the operation must not produce a partial write.
    #[tokio::test]
    async fn regression_create_rejects_oversized_content() {
        let tf = TestFile::new("oversize.txt");
        let too_big = "x".repeat(1_048_577); // 1MB + 1 byte

        let tool = ApplyPatchTool::new(None, None);
        let result = tool
            .call(ApplyPatchArgs {
                operations: vec![PatchOp::Create {
                    path: tf.path.clone(),
                    content: too_big,
                }],
            })
            .await
            .unwrap();

        assert!(result.contains("FAILED"), "got: {result}");
        assert!(result.contains("exceeds"), "got: {result}");
        assert!(
            !Path::new(&tf.path).exists(),
            "no file should exist after size-limit rejection"
        );
    }

    // Right at the limit must succeed; off-by-one boundary check.
    #[tokio::test]
    async fn create_accepts_content_at_size_limit() {
        let tf = TestFile::new("at-limit.txt");
        let at_limit = "x".repeat(1_048_576); // exactly 1MB

        let tool = ApplyPatchTool::new(None, None);
        let result = tool
            .call(ApplyPatchArgs {
                operations: vec![PatchOp::Create {
                    path: tf.path.clone(),
                    content: at_limit,
                }],
            })
            .await
            .unwrap();

        assert!(!result.contains("FAILED"), "got: {result}");
        assert!(Path::new(&tf.path).exists());
        assert_eq!(std::fs::metadata(&tf.path).unwrap().len(), 1_048_576);
    }

    // create_dir_all is called on the parent — confirms nested-path creates work.
    #[tokio::test]
    async fn create_creates_parent_dirs() {
        let dir = std::env::temp_dir().join(format!("dirge-test-nested-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let nested = dir.join("a/b/c/file.txt");
        let path_str = nested.to_str().unwrap();

        let result = apply_create(path_str, "deep content").await;
        assert!(result.is_ok());
        assert_eq!(std::fs::read_to_string(&nested).unwrap(), "deep content");

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn delete_missing_file_returns_err() {
        let path = format!("/tmp/dirge-test-delete-ghost-{}.txt", std::process::id());
        let _ = std::fs::remove_file(&path);
        let result = apply_delete(&path).await;
        assert!(result.is_err());
    }

    // Multi-op happy path: create + update + rename + delete in sequence,
    // touching different files. Regression-tests that the loop applies each op
    // in declaration order and the report lists each.
    #[tokio::test]
    async fn multi_op_happy_path_executes_in_order() {
        let a = TestFile::new("multi-happy-a.txt");
        let b = TestFile::new("multi-happy-b.txt");
        let renamed = format!(
            "/tmp/dirge-test-multi-happy-renamed-{}.txt",
            std::process::id()
        );
        let _ = std::fs::remove_file(&renamed);

        let tool = ApplyPatchTool::new(None, None);
        let result = tool
            .call(ApplyPatchArgs {
                operations: vec![
                    PatchOp::Create {
                        path: a.path.clone(),
                        content: "hello".into(),
                    },
                    PatchOp::Update {
                        path: a.path.clone(),
                        old_text: "hello".into(),
                        new_text: "HELLO".into(),
                    },
                    PatchOp::Create {
                        path: b.path.clone(),
                        content: "scratch".into(),
                    },
                    PatchOp::Rename {
                        path: a.path.clone(),
                        new_path: renamed.clone(),
                    },
                    PatchOp::Delete {
                        path: b.path.clone(),
                    },
                ],
            })
            .await
            .unwrap();

        assert!(!result.contains("FAILED"), "got: {result}");
        assert!(!Path::new(&a.path).exists()); // renamed away
        assert!(!Path::new(&b.path).exists()); // deleted
        assert_eq!(std::fs::read_to_string(&renamed).unwrap(), "HELLO");
        let _ = std::fs::remove_file(&renamed);

        // Each successful op contributes a line to the report.
        assert_eq!(
            result.lines().filter(|l| !l.is_empty()).count(),
            5,
            "report: {result}"
        );
    }

    // Regression: PatchOp deserializes via internally-tagged `action` enum.
    // Schema mismatch (e.g. missing `content` for create) must fail at deserialize.
    #[test]
    fn patch_op_deserializes_each_variant() {
        let json = serde_json::json!([
            {"action": "create", "path": "/tmp/x", "content": "hi"},
            {"action": "update", "path": "/tmp/x", "old_text": "a", "new_text": "b"},
            {"action": "delete", "path": "/tmp/x"},
            {"action": "rename", "path": "/tmp/x", "new_path": "/tmp/y"},
        ]);
        let ops: Vec<PatchOp> = serde_json::from_value(json).unwrap();
        assert!(matches!(ops[0], PatchOp::Create { .. }));
        assert!(matches!(ops[1], PatchOp::Update { .. }));
        assert!(matches!(ops[2], PatchOp::Delete { .. }));
        assert!(matches!(ops[3], PatchOp::Rename { .. }));
    }
}