1use async_trait::async_trait;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::HashSet;
6use std::fs;
7use std::io::{Read, Write};
8use std::path::{Component, Path, PathBuf};
9use uuid::Uuid;
10
11use ai_agents_core::{
12 PathAccessMode, PathBindingKind, PathPolicyBinding, ResultLimitBinding, ResultLimitKind, Tool,
13 ToolCallClassification, ToolExecutionContext, ToolOperationKind, ToolPolicyBindings,
14 ToolResult, ToolSafetyMetadata, ToolSideEffectLevel,
15};
16
17use crate::generate_schema;
18use crate::security::path::PathPolicyResolver;
19use crate::types::{FileVersionEvidence, FileVersionStore, file_version_evidence};
20
21const DEFAULT_MAX_OUTPUT_CHARS: usize = 20_000;
22const DEFAULT_MAX_REPLACEMENTS: usize = 20;
23const DEFAULT_MAX_CHANGED_FILES: usize = 10;
24const DEFAULT_MAX_CHANGED_LINES: usize = 500;
25
26pub struct FileWriteTool {
28 versions: FileVersionStore,
29}
30
31impl FileWriteTool {
32 pub fn new() -> Self {
34 Self::with_version_store(FileVersionStore::default())
35 }
36
37 pub fn with_version_store(versions: FileVersionStore) -> Self {
39 Self { versions }
40 }
41}
42
43impl Default for FileWriteTool {
44 fn default() -> Self {
45 Self::new()
46 }
47}
48
49pub struct FileEditTool {
51 versions: FileVersionStore,
52}
53
54impl FileEditTool {
55 pub fn new() -> Self {
57 Self::with_version_store(FileVersionStore::default())
58 }
59
60 pub fn with_version_store(versions: FileVersionStore) -> Self {
62 Self { versions }
63 }
64}
65
66impl Default for FileEditTool {
67 fn default() -> Self {
68 Self::new()
69 }
70}
71
72pub struct PatchTool {
74 versions: FileVersionStore,
75}
76
77impl PatchTool {
78 pub fn new() -> Self {
80 Self::with_version_store(FileVersionStore::default())
81 }
82
83 pub fn with_version_store(versions: FileVersionStore) -> Self {
85 Self { versions }
86 }
87}
88
89impl Default for PatchTool {
90 fn default() -> Self {
91 Self::new()
92 }
93}
94
95#[derive(Debug, Deserialize, JsonSchema)]
96struct FileWriteInput {
97 path: String,
99 content: String,
101 #[serde(default)]
103 overwrite: bool,
104 #[serde(default)]
106 create_parent_dirs: bool,
107 #[serde(default)]
109 dry_run: bool,
110}
111
112#[derive(Debug, Deserialize, JsonSchema)]
113struct FileEditInput {
114 path: String,
116 old_text: String,
118 new_text: String,
120 #[serde(default)]
122 replace_all: bool,
123 #[serde(default)]
125 dry_run: bool,
126 #[serde(default)]
128 max_replacements: Option<usize>,
129}
130
131#[derive(Debug, Deserialize, JsonSchema)]
132struct PatchInput {
133 patch: String,
135 #[serde(default)]
137 base_path: Option<String>,
138 #[serde(default)]
140 dry_run: bool,
141 #[serde(default)]
143 allow_new_files: Option<bool>,
144 #[serde(default)]
146 allow_delete: bool,
147}
148
149#[derive(Debug, Serialize)]
150struct MutationOutput {
151 path: Option<String>,
152 dry_run: bool,
153 mutation_performed: bool,
154 changed_files: usize,
155 changed_lines: usize,
156 replacements: usize,
157 bytes_written: usize,
158 created: bool,
159 overwritten: bool,
160 truncated: bool,
161 approval_required: bool,
162 diff_summary: String,
163 changed_paths: Vec<String>,
164 version: Option<FileVersionEvidence>,
165 near_matches: Vec<String>,
166}
167
168#[derive(Debug, Default)]
169struct MutationPolicySnapshot {
170 write_paths: Vec<String>,
171 allowed_paths: Vec<String>,
172 blocked_paths: Vec<String>,
173 overwrite_existing: bool,
174 create_parent_dirs: bool,
175 require_read_before_write: bool,
176 no_write_policy: String,
177 allow_without_confirmation: bool,
178}
179
180impl MutationPolicySnapshot {
181 fn from_context(value: &Value) -> Self {
182 let no_write_policy = value
183 .get("no_write_policy")
184 .and_then(Value::as_str)
185 .unwrap_or("dry_run_only")
186 .to_string();
187 let mut snapshot = Self {
188 write_paths: strings_at(value, "write_paths"),
189 allowed_paths: strings_at(value, "allowed_paths"),
190 blocked_paths: strings_at(value, "blocked_paths"),
191 overwrite_existing: bool_at(value, "overwrite_existing"),
192 create_parent_dirs: bool_at(value, "create_parent_dirs"),
193 require_read_before_write: bool_at(value, "require_read_before_write"),
194 no_write_policy,
195 allow_without_confirmation: bool_at(value, "allow_without_confirmation"),
196 };
197 if let Some(paths) = value.get("paths") {
198 snapshot.write_paths.extend(strings_at(paths, "allow"));
199 snapshot.blocked_paths.extend(strings_at(paths, "deny"));
200 }
201 snapshot
202 }
203
204 fn has_write_policy(&self) -> bool {
205 !self.write_paths.is_empty() || !self.allowed_paths.is_empty()
206 }
207
208 fn approval_required(&self) -> bool {
209 !self.allow_without_confirmation
210 }
211}
212
213#[async_trait]
214impl Tool for FileWriteTool {
215 fn id(&self) -> &str {
216 "file_write"
217 }
218
219 fn name(&self) -> &str {
220 "File Write"
221 }
222
223 fn description(&self) -> &str {
224 "Create or overwrite a file with policy-gated atomic writes and dry-run summaries."
225 }
226
227 fn input_schema(&self) -> Value {
228 generate_schema::<FileWriteInput>()
229 }
230
231 fn safety_metadata(&self) -> ToolSafetyMetadata {
232 mutation_metadata(ToolOperationKind::Write)
233 }
234
235 fn classify_call(&self, args: &Value) -> ToolCallClassification {
236 mutation_classification(&self.safety_metadata(), args)
237 }
238
239 fn policy_bindings(&self) -> ToolPolicyBindings {
240 ToolPolicyBindings {
241 path_fields: vec![PathPolicyBinding::write("path")],
242 result_limit_fields: vec![
243 ResultLimitBinding::new("max_changed_files", ResultLimitKind::MaxChangedFiles),
244 ResultLimitBinding::new("max_changed_lines", ResultLimitKind::MaxChangedLines),
245 ],
246 ..Default::default()
247 }
248 }
249
250 async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
251 let input: FileWriteInput = match serde_json::from_value(args) {
252 Ok(input) => input,
253 Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
254 };
255 let path = PathBuf::from(&input.path);
256 if let Err(reason) = validate_safe_target(&path) {
257 return ToolResult::error(reason);
258 }
259 let policy = MutationPolicySnapshot::from_context(&ctx.policy_snapshot);
260 if let Err(reason) = ensure_write_allowed(&path, input.dry_run, &policy) {
261 return ToolResult::error(reason);
262 }
263 let exists = path.exists();
264 if exists && !input.overwrite {
265 return ToolResult::error("overwrite must be true to replace an existing file");
266 }
267 if exists && !policy.overwrite_existing && !input.dry_run {
268 return ToolResult::error("overwrite_existing policy is false for this target");
269 }
270 if let Some(parent) = path.parent()
271 && !parent.exists()
272 {
273 if !(input.create_parent_dirs && policy.create_parent_dirs) {
274 return ToolResult::error(
275 "parent directory does not exist or create_parent_dirs is not allowed",
276 );
277 }
278 if !input.dry_run
279 && let Err(error) = fs::create_dir_all(parent)
280 {
281 return ToolResult::error(format!("Create parent directory error: {}", error));
282 }
283 }
284 if exists
285 && !input.dry_run
286 && let Err(reason) = enforce_read_before_write(&self.versions, &path, &policy)
287 {
288 return ToolResult::error(reason);
289 }
290 let changed_lines = input
291 .content
292 .lines()
293 .count()
294 .max(usize::from(!input.content.is_empty()));
295 if exceeds(ctx.limits.max_changed_files, 1)
296 || exceeds(ctx.limits.max_changed_lines, changed_lines)
297 {
298 return ToolResult::error(
299 "mutation exceeds configured changed-file or changed-line cap",
300 );
301 }
302 let action = match (input.dry_run, exists) {
303 (true, true) => "plan to overwrite",
304 (true, false) => "plan to create",
305 (false, true) => "overwrote",
306 (false, false) => "created",
307 };
308 let diff_summary = format!(
309 "{} {} with {} bytes",
310 action,
311 input.path,
312 input.content.len()
313 );
314 let version = if input.dry_run {
315 None
316 } else {
317 if let Err(error) = atomic_write(&path, input.content.as_bytes()) {
318 return ToolResult::error(format!("Write error: {}", error));
319 }
320 match file_version_evidence(&path, input.content.as_bytes()) {
321 Ok(version) => {
322 self.versions.record(version.clone());
323 Some(version)
324 }
325 Err(_) => None,
326 }
327 };
328 json_result(&MutationOutput {
329 path: Some(input.path.clone()),
330 dry_run: input.dry_run,
331 mutation_performed: !input.dry_run,
332 changed_files: 1,
333 changed_lines,
334 replacements: 0,
335 bytes_written: if input.dry_run {
336 0
337 } else {
338 input.content.len()
339 },
340 created: !input.dry_run && !exists,
341 overwritten: !input.dry_run && exists,
342 truncated: false,
343 approval_required: !input.dry_run && policy.approval_required(),
344 diff_summary,
345 changed_paths: vec![input.path],
346 version,
347 near_matches: Vec::new(),
348 })
349 }
350}
351
352#[async_trait]
353impl Tool for FileEditTool {
354 fn id(&self) -> &str {
355 "file_edit"
356 }
357
358 fn name(&self) -> &str {
359 "File Edit"
360 }
361
362 fn description(&self) -> &str {
363 "Replace exact text in a file with uniqueness checks, dry-run diff summaries, and policy-gated writes."
364 }
365
366 fn input_schema(&self) -> Value {
367 generate_schema::<FileEditInput>()
368 }
369
370 fn safety_metadata(&self) -> ToolSafetyMetadata {
371 mutation_metadata(ToolOperationKind::Edit)
372 }
373
374 fn classify_call(&self, args: &Value) -> ToolCallClassification {
375 mutation_classification(&self.safety_metadata(), args)
376 }
377
378 fn policy_bindings(&self) -> ToolPolicyBindings {
379 ToolPolicyBindings {
380 path_fields: vec![PathPolicyBinding::write("path")],
381 result_limit_fields: vec![
382 ResultLimitBinding::new("max_replacements", ResultLimitKind::MaxReplacements),
383 ResultLimitBinding::new("max_changed_lines", ResultLimitKind::MaxChangedLines),
384 ],
385 ..Default::default()
386 }
387 }
388
389 async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
390 let input: FileEditInput = match serde_json::from_value(args) {
391 Ok(input) => input,
392 Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
393 };
394 if input.old_text == input.new_text {
395 return ToolResult::error("old_text and new_text must differ");
396 }
397 let path = PathBuf::from(&input.path);
398 if let Err(reason) = validate_safe_target(&path) {
399 return ToolResult::error(reason);
400 }
401 let policy = MutationPolicySnapshot::from_context(&ctx.policy_snapshot);
402 if let Err(reason) = ensure_write_allowed(&path, input.dry_run, &policy) {
403 return ToolResult::error(reason);
404 }
405 let original = match fs::read_to_string(&path) {
406 Ok(content) => content,
407 Err(error) => return ToolResult::error(format!("Read error: {}", error)),
408 };
409 let matches: Vec<_> = original.match_indices(&input.old_text).collect();
410 if matches.is_empty() {
411 let output = MutationOutput {
412 path: Some(input.path),
413 dry_run: input.dry_run,
414 mutation_performed: false,
415 changed_files: 0,
416 changed_lines: 0,
417 replacements: 0,
418 bytes_written: 0,
419 created: false,
420 overwritten: false,
421 truncated: false,
422 approval_required: false,
423 diff_summary: "old_text was not found".to_string(),
424 changed_paths: Vec::new(),
425 version: None,
426 near_matches: near_matches(&original, &input.old_text),
427 };
428 return match serde_json::to_string(&output) {
429 Ok(json) => ToolResult {
430 success: false,
431 output: json,
432 metadata: None,
433 },
434 Err(error) => ToolResult::error(format!("Serialization error: {}", error)),
435 };
436 }
437 if matches.len() > 1 && !input.replace_all {
438 return ToolResult::error(
439 "old_text appears multiple times; set replace_all to true to replace all occurrences",
440 );
441 }
442 let replacements = if input.replace_all { matches.len() } else { 1 };
443 let max_replacements = input
444 .max_replacements
445 .unwrap_or(DEFAULT_MAX_REPLACEMENTS)
446 .min(
447 ctx.limits
448 .max_replacements
449 .unwrap_or(DEFAULT_MAX_REPLACEMENTS),
450 );
451 if replacements > max_replacements {
452 return ToolResult::error("replacement count exceeds configured max_replacements");
453 }
454 if !input.dry_run
455 && let Err(reason) = enforce_read_before_write(&self.versions, &path, &policy)
456 {
457 return ToolResult::error(reason);
458 }
459 let edited = if input.replace_all {
460 original.replace(&input.old_text, &input.new_text)
461 } else {
462 original.replacen(&input.old_text, &input.new_text, 1)
463 };
464 let changed_lines = changed_line_count(&original, &edited);
465 if exceeds(ctx.limits.max_changed_lines, changed_lines) {
466 return ToolResult::error("edit exceeds configured max_changed_lines");
467 }
468 let diff_summary = preview_diff(&original, &edited, DEFAULT_MAX_OUTPUT_CHARS);
469 let version = if input.dry_run {
470 None
471 } else {
472 if let Err(error) = atomic_write(&path, edited.as_bytes()) {
473 return ToolResult::error(format!("Write error: {}", error));
474 }
475 match file_version_evidence(&path, edited.as_bytes()) {
476 Ok(version) => {
477 self.versions.record(version.clone());
478 Some(version)
479 }
480 Err(_) => None,
481 }
482 };
483 json_result(&MutationOutput {
484 path: Some(input.path.clone()),
485 dry_run: input.dry_run,
486 mutation_performed: !input.dry_run,
487 changed_files: 1,
488 changed_lines,
489 replacements,
490 bytes_written: if input.dry_run { 0 } else { edited.len() },
491 created: false,
492 overwritten: !input.dry_run,
493 truncated: diff_summary.chars().count() >= DEFAULT_MAX_OUTPUT_CHARS,
494 approval_required: !input.dry_run && policy.approval_required(),
495 diff_summary,
496 changed_paths: vec![input.path],
497 version,
498 near_matches: Vec::new(),
499 })
500 }
501}
502
503#[async_trait]
504impl Tool for PatchTool {
505 fn id(&self) -> &str {
506 "patch"
507 }
508
509 fn name(&self) -> &str {
510 "Patch"
511 }
512
513 fn description(&self) -> &str {
514 "Validate or apply bounded unified diffs with per-file write policy checks."
515 }
516
517 fn input_schema(&self) -> Value {
518 generate_schema::<PatchInput>()
519 }
520
521 fn safety_metadata(&self) -> ToolSafetyMetadata {
522 mutation_metadata(ToolOperationKind::Patch)
523 }
524
525 fn classify_call(&self, args: &Value) -> ToolCallClassification {
526 mutation_classification(&self.safety_metadata(), args)
527 }
528
529 fn policy_bindings(&self) -> ToolPolicyBindings {
530 ToolPolicyBindings {
531 path_fields: vec![
532 PathPolicyBinding::new(
533 "base_path",
534 PathAccessMode::Write,
535 PathBindingKind::PatchBase,
536 )
537 .with_default_path("."),
538 ],
539 result_limit_fields: vec![
540 ResultLimitBinding::new("max_changed_files", ResultLimitKind::MaxChangedFiles),
541 ResultLimitBinding::new("max_changed_lines", ResultLimitKind::MaxChangedLines),
542 ],
543 ..Default::default()
544 }
545 }
546
547 async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
548 let input: PatchInput = match serde_json::from_value(args) {
549 Ok(input) => input,
550 Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
551 };
552 let base_path = PathBuf::from(input.base_path.unwrap_or_else(|| ".".to_string()));
553 if let Err(reason) = validate_safe_target(&base_path) {
554 return ToolResult::error(reason);
555 }
556 let policy = MutationPolicySnapshot::from_context(&ctx.policy_snapshot);
557 let files = match parse_unified_diff(&input.patch) {
558 Ok(files) => files,
559 Err(error) => return ToolResult::error(error),
560 };
561 if files.is_empty() {
562 return ToolResult::error("patch contains no changed files");
563 }
564 if exceeds(ctx.limits.max_changed_files, files.len())
565 || files.len() > DEFAULT_MAX_CHANGED_FILES
566 {
567 return ToolResult::error("patch exceeds configured max_changed_files");
568 }
569 let mut changed_lines = 0usize;
570 let mut changed_paths = Vec::new();
571 let mut target_paths = HashSet::new();
572 let mut outputs = Vec::new();
573 for file in &files {
574 changed_lines += file.changed_lines();
575 if changed_lines
576 > ctx
577 .limits
578 .max_changed_lines
579 .unwrap_or(DEFAULT_MAX_CHANGED_LINES)
580 {
581 return ToolResult::error("patch exceeds configured max_changed_lines");
582 }
583 if file.is_delete() && !input.allow_delete {
584 return ToolResult::error("patch deletes a file but allow_delete is false");
585 }
586 let path = base_path.join(strip_patch_prefix(file.target_path()));
587 if let Err(reason) = validate_safe_target(&path) {
588 return ToolResult::error(reason);
589 }
590 if let Err(reason) = ensure_write_allowed(&path, input.dry_run, &policy) {
591 return ToolResult::error(reason);
592 }
593 if !target_paths.insert(normalize_path(&path)) {
594 return ToolResult::error("patch contains duplicate target paths");
595 }
596 let exists = path.exists();
597 if exists {
598 match fs::symlink_metadata(&path) {
599 Ok(metadata) if metadata.file_type().is_symlink() => {
600 return ToolResult::error("patch targets must not be symbolic links");
601 }
602 Ok(_) => {}
603 Err(error) => {
604 return ToolResult::error(format!("Patch metadata error: {}", error));
605 }
606 }
607 }
608 if file.is_delete() && !exists {
609 return ToolResult::error("patch deletes a file that does not exist");
610 }
611 if file.is_new_file() {
612 if !input.allow_new_files.unwrap_or(false) {
613 return ToolResult::error("patch creates a file but allow_new_files is false");
614 }
615 if exists {
616 return ToolResult::error("new-file patch target already exists");
617 }
618 } else if !file.is_delete() && !exists {
619 return ToolResult::error("patch updates a file that does not exist");
620 }
621 if let Some(parent) = path.parent() {
622 if parent.exists() && !parent.is_dir() {
623 return ToolResult::error("patch target parent is not a directory");
624 }
625 if !parent.exists() && !policy.create_parent_dirs {
626 return ToolResult::error(
627 "patch parent directory creation is not allowed by policy",
628 );
629 }
630 }
631 if exists
632 && !input.dry_run
633 && let Err(reason) = enforce_read_before_write(&self.versions, &path, &policy)
634 {
635 return ToolResult::error(reason);
636 }
637 let (original, expected) = if exists {
638 let snapshot = match read_file_snapshot(&path) {
639 Ok(snapshot) => snapshot,
640 Err(error) => return ToolResult::error(format!("Read error: {}", error)),
641 };
642 let content = match String::from_utf8(snapshot.bytes.clone()) {
643 Ok(content) => content,
644 Err(error) => {
645 return ToolResult::error(format!("Read error: {}", error));
646 }
647 };
648 (content, ExpectedPathState::Present(snapshot))
649 } else {
650 (String::new(), ExpectedPathState::Absent)
651 };
652 let edited = match apply_file_patch(&original, file) {
653 Ok(edited) => edited,
654 Err(error) => return ToolResult::error(error),
655 };
656 changed_paths.push(path.to_string_lossy().to_string());
657 if file.is_delete() {
658 let ExpectedPathState::Present(expected) = expected else {
659 return ToolResult::error("patch delete target disappeared during preflight");
660 };
661 outputs.push(PatchApply::Delete { path, expected });
662 } else {
663 outputs.push(PatchApply::Write {
664 path,
665 content: edited,
666 expected,
667 });
668 }
669 }
670 if !input.dry_run {
671 if let Err(error) = apply_patch_transaction(&outputs) {
672 return ToolResult::error(error);
673 }
674 for output in &outputs {
675 if let Some((path, content)) = output.written_file()
676 && let Ok(version) = file_version_evidence(path, content.as_bytes())
677 {
678 self.versions.record(version);
679 }
680 }
681 }
682 let diff_summary = summarize_patch(&files, DEFAULT_MAX_OUTPUT_CHARS);
683 json_result(&MutationOutput {
684 path: Some(base_path.to_string_lossy().to_string()),
685 dry_run: input.dry_run,
686 mutation_performed: !input.dry_run && !files.is_empty(),
687 changed_files: files.len(),
688 changed_lines,
689 replacements: 0,
690 bytes_written: if input.dry_run {
691 0
692 } else {
693 outputs.iter().map(PatchApply::bytes_written).sum()
694 },
695 created: !input.dry_run && files.iter().any(PatchFile::is_new_file),
696 overwritten: !input.dry_run
697 && files
698 .iter()
699 .any(|file| !file.is_new_file() && !file.is_delete()),
700 truncated: diff_summary.chars().count() >= DEFAULT_MAX_OUTPUT_CHARS,
701 approval_required: !input.dry_run && policy.approval_required(),
702 diff_summary,
703 changed_paths,
704 version: None,
705 near_matches: Vec::new(),
706 })
707 }
708}
709
710#[derive(Debug, Clone)]
711struct PatchFile {
712 old_path: String,
713 new_path: String,
714 hunks: Vec<PatchHunk>,
715}
716
717impl PatchFile {
718 fn target_path(&self) -> &str {
719 if self.new_path == "/dev/null" {
720 &self.old_path
721 } else {
722 &self.new_path
723 }
724 }
725
726 fn is_delete(&self) -> bool {
727 self.new_path == "/dev/null"
728 }
729
730 fn is_new_file(&self) -> bool {
731 self.old_path == "/dev/null"
732 }
733
734 fn changed_lines(&self) -> usize {
735 self.hunks
736 .iter()
737 .flat_map(|hunk| hunk.lines.iter())
738 .filter(|line| matches!(line.kind, PatchLineKind::Add | PatchLineKind::Remove))
739 .count()
740 }
741}
742
743#[derive(Debug, Clone)]
744struct FileSnapshot {
745 bytes: Vec<u8>,
746 permissions: fs::Permissions,
747}
748
749#[derive(Debug, Clone)]
750enum ExpectedPathState {
751 Absent,
752 Present(FileSnapshot),
753}
754
755#[derive(Debug, Clone)]
756enum PatchApply {
757 Write {
758 path: PathBuf,
759 content: String,
760 expected: ExpectedPathState,
761 },
762 Delete {
763 path: PathBuf,
764 expected: FileSnapshot,
765 },
766}
767
768impl PatchApply {
769 fn bytes_written(&self) -> usize {
770 match self {
771 Self::Write { content, .. } => content.len(),
772 Self::Delete { .. } => 0,
773 }
774 }
775
776 fn written_file(&self) -> Option<(&Path, &str)> {
777 match self {
778 Self::Write { path, content, .. } => Some((path, content)),
779 Self::Delete { .. } => None,
780 }
781 }
782}
783
784#[derive(Debug, Clone)]
785enum PatchRollback {
786 RestoreWrite {
787 path: PathBuf,
788 original: FileSnapshot,
789 written: Vec<u8>,
790 written_permissions: fs::Permissions,
791 },
792 RestoreDelete {
793 path: PathBuf,
794 original: FileSnapshot,
795 },
796 RemoveCreated {
797 path: PathBuf,
798 written: Vec<u8>,
799 },
800}
801
802fn apply_patch_transaction(outputs: &[PatchApply]) -> Result<(), String> {
803 let mut rollbacks = Vec::new();
804 let mut created_directories = Vec::new();
805 for output in outputs {
806 match output {
807 PatchApply::Delete { path, expected } => {
808 if let Err(error) = verify_present_snapshot(path, expected, "pre-apply") {
809 return fail_patch_transaction(error, &rollbacks, &created_directories);
810 }
811 if let Err(error) = fs::remove_file(path) {
812 return fail_patch_transaction(
813 format!("Patch delete error: {}", error),
814 &rollbacks,
815 &created_directories,
816 );
817 }
818 rollbacks.push(PatchRollback::RestoreDelete {
819 path: path.clone(),
820 original: expected.clone(),
821 });
822 }
823 PatchApply::Write {
824 path,
825 content,
826 expected,
827 } => {
828 if let Some(parent) = path.parent()
829 && !parent.exists()
830 {
831 let missing = missing_directories(parent);
832 if let Err(error) = fs::create_dir_all(parent) {
833 let cleanup_error = cleanup_created_directories(&missing).err();
834 let error = match cleanup_error {
835 Some(cleanup_error) => format!(
836 "Create parent directory error: {}; partial directory creation may remain: {}",
837 error, cleanup_error
838 ),
839 None => format!("Create parent directory error: {}", error),
840 };
841 return fail_patch_transaction(error, &rollbacks, &created_directories);
842 }
843 created_directories.extend(missing);
844 }
845 if let Err(error) = verify_expected_state(path, expected, "pre-apply") {
846 return fail_patch_transaction(error, &rollbacks, &created_directories);
847 }
848 if let Err(error) = atomic_write(path, content.as_bytes()) {
849 return fail_patch_transaction(
850 format!("Patch write error: {}", error),
851 &rollbacks,
852 &created_directories,
853 );
854 }
855 let written = content.as_bytes().to_vec();
856 match expected {
857 ExpectedPathState::Present(original) => {
858 let rollback = PatchRollback::RestoreWrite {
859 path: path.clone(),
860 original: original.clone(),
861 written,
862 written_permissions: original.permissions.clone(),
863 };
864 if let Err(error) = fs::set_permissions(path, original.permissions.clone())
865 {
866 rollbacks.push(rollback);
867 return fail_patch_transaction(
868 format!("Patch permission restore error: {}", error),
869 &rollbacks,
870 &created_directories,
871 );
872 }
873 rollbacks.push(rollback);
874 }
875 ExpectedPathState::Absent => {
876 rollbacks.push(PatchRollback::RemoveCreated {
877 path: path.clone(),
878 written,
879 });
880 }
881 }
882 }
883 }
884 }
885 Ok(())
886}
887
888fn fail_patch_transaction(
889 error: String,
890 rollbacks: &[PatchRollback],
891 created_directories: &[PathBuf],
892) -> Result<(), String> {
893 match rollback_patch(rollbacks, created_directories) {
894 Ok(()) => Err(format!("{}; no patch changes were retained", error)),
895 Err(rollback_error) => Err(format!(
896 "{}; partial patch application may remain because rollback failed: {}",
897 error, rollback_error
898 )),
899 }
900}
901
902fn rollback_patch(
903 rollbacks: &[PatchRollback],
904 created_directories: &[PathBuf],
905) -> Result<(), String> {
906 let mut errors = Vec::new();
907 for rollback in rollbacks.iter().rev() {
908 let result = match rollback {
909 PatchRollback::RestoreWrite {
910 path,
911 original,
912 written,
913 written_permissions,
914 } => verify_present_content_and_permissions(
915 path,
916 written,
917 written_permissions,
918 "rollback",
919 )
920 .and_then(|_| restore_file_snapshot(path, original)),
921 PatchRollback::RestoreDelete { path, original } => {
922 verify_absent(path, "rollback").and_then(|_| restore_file_snapshot(path, original))
923 }
924 PatchRollback::RemoveCreated { path, written } => {
925 verify_present_content(path, written, "rollback").and_then(|_| {
926 fs::remove_file(path).map_err(|error| {
927 format!(
928 "Patch rollback remove error for {}: {}",
929 path.display(),
930 error
931 )
932 })
933 })
934 }
935 };
936 if let Err(error) = result {
937 errors.push(error);
938 }
939 }
940 if let Err(error) = cleanup_created_directories(created_directories) {
941 errors.push(error);
942 }
943 if errors.is_empty() {
944 Ok(())
945 } else {
946 Err(errors.join("; "))
947 }
948}
949
950fn read_file_snapshot(path: &Path) -> std::io::Result<FileSnapshot> {
951 let mut file = fs::File::open(path)?;
952 let permissions = file.metadata()?.permissions();
953 let mut bytes = Vec::new();
954 file.read_to_end(&mut bytes)?;
955 Ok(FileSnapshot { bytes, permissions })
956}
957
958fn verify_expected_state(
959 path: &Path,
960 expected: &ExpectedPathState,
961 stage: &str,
962) -> Result<(), String> {
963 match expected {
964 ExpectedPathState::Absent => verify_absent(path, stage),
965 ExpectedPathState::Present(expected) => verify_present_snapshot(path, expected, stage),
966 }
967}
968
969fn verify_absent(path: &Path, stage: &str) -> Result<(), String> {
970 if path.exists() {
971 Err(format!(
972 "Patch {} conflict for {}: expected path to be absent",
973 stage,
974 path.display()
975 ))
976 } else {
977 Ok(())
978 }
979}
980
981fn verify_present_snapshot(
982 path: &Path,
983 expected: &FileSnapshot,
984 stage: &str,
985) -> Result<(), String> {
986 let current = read_file_snapshot(path).map_err(|error| {
987 format!(
988 "Patch {} conflict for {}: expected file could not be read: {}",
989 stage,
990 path.display(),
991 error
992 )
993 })?;
994 if current.bytes != expected.bytes
995 || !permissions_match(¤t.permissions, &expected.permissions)
996 {
997 return Err(format!(
998 "Patch {} conflict for {}: file changed since the expected state",
999 stage,
1000 path.display()
1001 ));
1002 }
1003 Ok(())
1004}
1005
1006fn verify_present_content(path: &Path, expected: &[u8], stage: &str) -> Result<(), String> {
1007 let current = fs::read(path).map_err(|error| {
1008 format!(
1009 "Patch {} conflict for {}: expected file could not be read: {}",
1010 stage,
1011 path.display(),
1012 error
1013 )
1014 })?;
1015 if current != expected {
1016 return Err(format!(
1017 "Patch {} conflict for {}: file content changed since the transaction write",
1018 stage,
1019 path.display()
1020 ));
1021 }
1022 Ok(())
1023}
1024
1025fn verify_present_content_and_permissions(
1026 path: &Path,
1027 expected: &[u8],
1028 expected_permissions: &fs::Permissions,
1029 stage: &str,
1030) -> Result<(), String> {
1031 verify_present_content(path, expected, stage)?;
1032 let current = fs::metadata(path).map_err(|error| {
1033 format!(
1034 "Patch {} conflict for {}: expected file metadata could not be read: {}",
1035 stage,
1036 path.display(),
1037 error
1038 )
1039 })?;
1040 if !permissions_match(¤t.permissions(), expected_permissions) {
1041 return Err(format!(
1042 "Patch {} conflict for {}: file permissions changed since the transaction write",
1043 stage,
1044 path.display()
1045 ));
1046 }
1047 Ok(())
1048}
1049
1050fn restore_file_snapshot(path: &Path, snapshot: &FileSnapshot) -> Result<(), String> {
1051 atomic_write(path, &snapshot.bytes).map_err(|error| {
1052 format!(
1053 "Patch rollback restore error for {}: {}",
1054 path.display(),
1055 error
1056 )
1057 })?;
1058 fs::set_permissions(path, snapshot.permissions.clone()).map_err(|error| {
1059 format!(
1060 "Patch rollback permission restore error for {}: {}",
1061 path.display(),
1062 error
1063 )
1064 })
1065}
1066
1067fn permissions_match(left: &fs::Permissions, right: &fs::Permissions) -> bool {
1068 #[cfg(unix)]
1069 {
1070 use std::os::unix::fs::PermissionsExt;
1071 left.mode() == right.mode()
1072 }
1073 #[cfg(not(unix))]
1074 {
1075 left.readonly() == right.readonly()
1076 }
1077}
1078
1079fn missing_directories(path: &Path) -> Vec<PathBuf> {
1080 let mut missing = Vec::new();
1081 let mut current = path;
1082 while !current.exists() {
1083 missing.push(current.to_path_buf());
1084 let Some(parent) = current.parent() else {
1085 break;
1086 };
1087 current = parent;
1088 }
1089 missing.reverse();
1090 missing
1091}
1092
1093fn cleanup_created_directories(paths: &[PathBuf]) -> Result<(), String> {
1094 let mut errors = Vec::new();
1095 for path in paths.iter().rev() {
1096 if let Err(error) = fs::remove_dir(path)
1097 && error.kind() != std::io::ErrorKind::NotFound
1098 {
1099 errors.push(format!(
1100 "Patch rollback directory cleanup conflict for {}: {}",
1101 path.display(),
1102 error
1103 ));
1104 }
1105 }
1106 if errors.is_empty() {
1107 Ok(())
1108 } else {
1109 Err(errors.join("; "))
1110 }
1111}
1112
1113#[derive(Debug, Clone)]
1114struct PatchHunk {
1115 old_start: usize,
1116 lines: Vec<PatchLine>,
1117}
1118
1119#[derive(Debug, Clone)]
1120struct PatchLine {
1121 kind: PatchLineKind,
1122 text: String,
1123}
1124
1125#[derive(Debug, Clone, Copy)]
1126enum PatchLineKind {
1127 Context,
1128 Add,
1129 Remove,
1130}
1131
1132fn mutation_metadata(operation: ToolOperationKind) -> ToolSafetyMetadata {
1133 ToolSafetyMetadata {
1134 read_only: false,
1135 concurrency_safe: false,
1136 operation,
1137 side_effect_level: ToolSideEffectLevel::LocalWrite,
1138 requires_network: false,
1139 destructive: false,
1140 open_world: false,
1141 host_dependent: false,
1142 requires_user_interaction: false,
1143 supports_cancellation: false,
1144 default_requires_approval: true,
1145 should_defer_schema: false,
1146 max_output_chars: Some(DEFAULT_MAX_OUTPUT_CHARS),
1147 max_result_size_chars: Some(DEFAULT_MAX_OUTPUT_CHARS),
1148 }
1149}
1150
1151fn mutation_classification(metadata: &ToolSafetyMetadata, args: &Value) -> ToolCallClassification {
1152 let dry_run = args
1153 .get("dry_run")
1154 .and_then(Value::as_bool)
1155 .unwrap_or(false);
1156 let mut classification = ToolCallClassification::from_metadata(metadata);
1157 classification.safely_retryable = dry_run;
1158 if dry_run {
1159 classification.read_only = true;
1160 classification.concurrency_safe = true;
1161 classification.destructive = false;
1162 classification.side_effect_level = ToolSideEffectLevel::None;
1163 classification.requires_approval = false;
1164 }
1165 classification
1166}
1167
1168fn ensure_write_allowed(
1170 path: &Path,
1171 dry_run: bool,
1172 policy: &MutationPolicySnapshot,
1173) -> Result<(), String> {
1174 let resolver = mutation_path_resolver()?;
1175 resolver
1176 .resolve_path(path)
1177 .map_err(|error| format!("Path policy resolution failed: {}", error))?;
1178 for blocked in &policy.blocked_paths {
1179 if resolver
1180 .matches_restriction(path, Path::new(blocked))
1181 .map_err(|error| format!("Path policy resolution failed: {}", error))?
1182 {
1183 return Err("path is blocked by policy".to_string());
1184 }
1185 }
1186 if !policy.has_write_policy() {
1187 if policy.no_write_policy == "deny" || !dry_run {
1188 return Err("actual mutation requires explicit write_paths policy".to_string());
1189 }
1190 return Ok(());
1191 }
1192 let mut matches_allowed = false;
1193 for allowed in policy.write_paths.iter().chain(policy.allowed_paths.iter()) {
1194 if resolver
1195 .is_allowed(path, Path::new(allowed))
1196 .map_err(|error| format!("Path policy resolution failed: {}", error))?
1197 {
1198 matches_allowed = true;
1199 break;
1200 }
1201 }
1202 if !matches_allowed {
1203 return Err("path is not under an allowed write root".to_string());
1204 }
1205 Ok(())
1206}
1207
1208fn ensure_not_write_root(path: &Path, policy: &MutationPolicySnapshot) -> Result<(), String> {
1209 let resolver = mutation_path_resolver()?;
1210 for root in policy.write_paths.iter().chain(policy.allowed_paths.iter()) {
1211 if resolver
1212 .is_same_location(path, Path::new(root))
1213 .map_err(|error| format!("Path policy resolution failed: {}", error))?
1214 {
1215 return Err("refusing to delete a configured write root".to_string());
1216 }
1217 }
1218 Ok(())
1219}
1220
1221fn enforce_read_before_write(
1222 versions: &FileVersionStore,
1223 path: &Path,
1224 policy: &MutationPolicySnapshot,
1225) -> Result<(), String> {
1226 if !policy.require_read_before_write {
1227 return Ok(());
1228 }
1229 let bytes =
1230 fs::read(path).map_err(|error| format!("Read-before-write check failed: {}", error))?;
1231 let current = file_version_evidence(path, &bytes)
1232 .map_err(|error| format!("Read-before-write version failed: {}", error))?;
1233 if versions.matches(¤t) {
1234 Ok(())
1235 } else {
1236 Err(
1237 "file must be read with file_read before mutation and must not change before write"
1238 .to_string(),
1239 )
1240 }
1241}
1242
1243fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1244 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1245 let temp = parent.join(format!(".{}.tmp", Uuid::new_v4()));
1246 let result = (|| {
1247 let mut file = fs::File::create(&temp)?;
1248 file.write_all(bytes)?;
1249 file.sync_all()?;
1250 fs::rename(&temp, path)
1251 })();
1252 if result.is_err() {
1253 let _ = fs::remove_file(&temp);
1254 }
1255 result
1256}
1257
1258fn validate_safe_target(path: &Path) -> Result<(), String> {
1259 for component in path.components() {
1260 if matches!(component, Component::ParentDir) {
1261 return Err("paths containing parent-directory traversal are not allowed".to_string());
1262 }
1263 }
1264 if path
1265 .components()
1266 .any(|component| component.as_os_str() == ".git")
1267 {
1268 return Err("raw .git paths are not allowed".to_string());
1269 }
1270 Ok(())
1271}
1272
1273fn changed_line_count(before: &str, after: &str) -> usize {
1274 let before_lines: Vec<_> = before.lines().collect();
1275 let after_lines: Vec<_> = after.lines().collect();
1276 before_lines
1277 .iter()
1278 .zip(after_lines.iter())
1279 .filter(|(left, right)| left != right)
1280 .count()
1281 + before_lines.len().abs_diff(after_lines.len())
1282}
1283
1284fn preview_diff(before: &str, after: &str, max_chars: usize) -> String {
1285 let before_lines: Vec<_> = before.lines().collect();
1286 let after_lines: Vec<_> = after.lines().collect();
1287 let mut output = String::new();
1288 for (index, (left, right)) in before_lines.iter().zip(after_lines.iter()).enumerate() {
1289 if left != right {
1290 output.push_str(&format!(
1291 "-{}:{}\n+{}:{}\n",
1292 index + 1,
1293 left,
1294 index + 1,
1295 right
1296 ));
1297 }
1298 if output.chars().count() >= max_chars {
1299 return output.chars().take(max_chars).collect();
1300 }
1301 }
1302 if before_lines.len() != after_lines.len() {
1303 output.push_str(&format!(
1304 "line count changed from {} to {}\n",
1305 before_lines.len(),
1306 after_lines.len()
1307 ));
1308 }
1309 output.chars().take(max_chars).collect()
1310}
1311
1312fn near_matches(content: &str, needle: &str) -> Vec<String> {
1313 let prefix: String = needle.chars().take(16).collect();
1314 if prefix.is_empty() {
1315 return Vec::new();
1316 }
1317 content
1318 .lines()
1319 .filter(|line| line.contains(&prefix))
1320 .take(3)
1321 .map(str::to_string)
1322 .collect()
1323}
1324
1325fn parse_unified_diff(input: &str) -> Result<Vec<PatchFile>, String> {
1326 let mut files = Vec::new();
1327 let mut current: Option<PatchFile> = None;
1328 let mut current_hunk: Option<PatchHunk> = None;
1329 for line in input.lines() {
1330 if let Some(rest) = line.strip_prefix("--- ") {
1331 if let Some(hunk) = current_hunk.take()
1332 && let Some(file) = current.as_mut()
1333 {
1334 file.hunks.push(hunk);
1335 }
1336 if let Some(file) = current.take() {
1337 files.push(file);
1338 }
1339 current = Some(PatchFile {
1340 old_path: clean_patch_path(rest),
1341 new_path: String::new(),
1342 hunks: Vec::new(),
1343 });
1344 } else if let Some(rest) = line.strip_prefix("+++ ") {
1345 let Some(file) = current.as_mut() else {
1346 return Err("patch has +++ before ---".to_string());
1347 };
1348 file.new_path = clean_patch_path(rest);
1349 } else if line.starts_with("@@") {
1350 if let Some(hunk) = current_hunk.take()
1351 && let Some(file) = current.as_mut()
1352 {
1353 file.hunks.push(hunk);
1354 }
1355 current_hunk = Some(PatchHunk {
1356 old_start: parse_hunk_old_start(line)?,
1357 lines: Vec::new(),
1358 });
1359 } else if let Some(hunk) = current_hunk.as_mut() {
1360 if let Some(text) = line.strip_prefix(' ') {
1361 hunk.lines.push(PatchLine {
1362 kind: PatchLineKind::Context,
1363 text: text.to_string(),
1364 });
1365 } else if let Some(text) = line.strip_prefix('+') {
1366 hunk.lines.push(PatchLine {
1367 kind: PatchLineKind::Add,
1368 text: text.to_string(),
1369 });
1370 } else if let Some(text) = line.strip_prefix('-') {
1371 hunk.lines.push(PatchLine {
1372 kind: PatchLineKind::Remove,
1373 text: text.to_string(),
1374 });
1375 }
1376 }
1377 }
1378 if let Some(hunk) = current_hunk
1379 && let Some(file) = current.as_mut()
1380 {
1381 file.hunks.push(hunk);
1382 }
1383 if let Some(file) = current {
1384 files.push(file);
1385 }
1386 for file in &files {
1387 if file.new_path.is_empty() || file.hunks.is_empty() {
1388 return Err("patch file is missing target path or hunks".to_string());
1389 }
1390 }
1391 Ok(files)
1392}
1393
1394fn apply_file_patch(original: &str, patch: &PatchFile) -> Result<String, String> {
1395 let newline = if original.contains("\r\n") {
1396 "\r\n"
1397 } else {
1398 "\n"
1399 };
1400 let original_lines: Vec<String> = if original.is_empty() {
1401 Vec::new()
1402 } else {
1403 original.lines().map(str::to_string).collect()
1404 };
1405 let mut output = Vec::new();
1406 let mut cursor = 0usize;
1407 for hunk in &patch.hunks {
1408 let start = hunk.old_start.saturating_sub(1);
1409 if start < cursor || start > original_lines.len() {
1410 return Err("patch hunk does not align with file contents".to_string());
1411 }
1412 output.extend_from_slice(&original_lines[cursor..start]);
1413 cursor = start;
1414 for line in &hunk.lines {
1415 match line.kind {
1416 PatchLineKind::Context => {
1417 if original_lines.get(cursor).map(String::as_str) != Some(line.text.as_str()) {
1418 return Err("patch context does not match file contents".to_string());
1419 }
1420 output.push(line.text.clone());
1421 cursor += 1;
1422 }
1423 PatchLineKind::Remove => {
1424 if original_lines.get(cursor).map(String::as_str) != Some(line.text.as_str()) {
1425 return Err("patch removal does not match file contents".to_string());
1426 }
1427 cursor += 1;
1428 }
1429 PatchLineKind::Add => output.push(line.text.clone()),
1430 }
1431 }
1432 }
1433 output.extend_from_slice(&original_lines[cursor..]);
1434 let mut joined = output.join(newline);
1435 if original.ends_with('\n') || !joined.is_empty() {
1436 joined.push_str(newline);
1437 }
1438 Ok(joined)
1439}
1440
1441fn summarize_patch(files: &[PatchFile], max_chars: usize) -> String {
1442 let mut output = String::new();
1443 for file in files {
1444 output.push_str(&format!(
1445 "{} -> {} ({} changed lines)\n",
1446 file.old_path,
1447 file.new_path,
1448 file.changed_lines()
1449 ));
1450 }
1451 output.chars().take(max_chars).collect()
1452}
1453
1454fn parse_hunk_old_start(line: &str) -> Result<usize, String> {
1455 let start = line
1456 .split_whitespace()
1457 .find(|part| part.starts_with('-'))
1458 .ok_or_else(|| "invalid hunk header".to_string())?;
1459 let start = start
1460 .trim_start_matches('-')
1461 .split(',')
1462 .next()
1463 .unwrap_or("1");
1464 start
1465 .parse::<usize>()
1466 .map_err(|_| "invalid hunk start".to_string())
1467}
1468
1469fn clean_patch_path(path: &str) -> String {
1470 path.split_whitespace().next().unwrap_or(path).to_string()
1471}
1472
1473fn strip_patch_prefix(path: &str) -> &str {
1474 path.strip_prefix("a/")
1475 .or_else(|| path.strip_prefix("b/"))
1476 .unwrap_or(path)
1477}
1478
1479fn strings_at(value: &Value, field: &str) -> Vec<String> {
1480 value
1481 .get(field)
1482 .and_then(Value::as_array)
1483 .into_iter()
1484 .flatten()
1485 .filter_map(Value::as_str)
1486 .map(str::to_string)
1487 .collect()
1488}
1489
1490fn bool_at(value: &Value, field: &str) -> bool {
1491 value.get(field).and_then(Value::as_bool).unwrap_or(false)
1492}
1493
1494fn normalize_path(path: &Path) -> PathBuf {
1495 let base = if path.is_absolute() {
1496 path.to_path_buf()
1497 } else {
1498 std::env::current_dir()
1499 .unwrap_or_else(|_| PathBuf::from("."))
1500 .join(path)
1501 };
1502 base.components().collect()
1503}
1504
1505fn path_entry_exists(path: &Path) -> bool {
1506 fs::symlink_metadata(path).is_ok()
1507}
1508
1509fn mutation_path_resolver() -> Result<PathPolicyResolver, String> {
1510 PathPolicyResolver::new().map_err(|error| format!("Path policy resolution failed: {}", error))
1511}
1512
1513fn exceeds(limit: Option<usize>, value: usize) -> bool {
1514 limit.is_some_and(|limit| value > limit)
1515}
1516
1517fn json_result<T: Serialize>(output: &T) -> ToolResult {
1518 match serde_json::to_string(output) {
1519 Ok(json) => ToolResult::ok(json),
1520 Err(error) => ToolResult::error(format!("Serialization error: {}", error)),
1521 }
1522}
1523
1524fn json_error_result<T: Serialize>(output: &T) -> ToolResult {
1525 match serde_json::to_string(output) {
1526 Ok(json) => ToolResult {
1527 success: false,
1528 output: json,
1529 metadata: None,
1530 },
1531 Err(error) => ToolResult::error(format!("Serialization error: {}", error)),
1532 }
1533}
1534
1535pub struct CopyPathTool;
1537
1538impl CopyPathTool {
1539 pub fn new() -> Self {
1541 Self
1542 }
1543}
1544
1545impl Default for CopyPathTool {
1546 fn default() -> Self {
1547 Self::new()
1548 }
1549}
1550
1551pub struct MovePathTool;
1553
1554impl MovePathTool {
1555 pub fn new() -> Self {
1557 Self
1558 }
1559}
1560
1561impl Default for MovePathTool {
1562 fn default() -> Self {
1563 Self::new()
1564 }
1565}
1566
1567pub struct DeletePathTool;
1569
1570impl DeletePathTool {
1571 pub fn new() -> Self {
1573 Self
1574 }
1575}
1576
1577impl Default for DeletePathTool {
1578 fn default() -> Self {
1579 Self::new()
1580 }
1581}
1582
1583#[derive(Debug, Deserialize, JsonSchema)]
1584struct CopyPathInput {
1585 source_path: String,
1587 destination_path: String,
1589 #[serde(default)]
1591 overwrite: bool,
1592 #[serde(default)]
1594 create_parent_dirs: bool,
1595 #[serde(default)]
1597 dry_run: bool,
1598}
1599
1600#[derive(Debug, Deserialize, JsonSchema)]
1601struct MovePathInput {
1602 source_path: String,
1604 destination_path: String,
1606 #[serde(default)]
1608 overwrite: bool,
1609 #[serde(default)]
1611 create_parent_dirs: bool,
1612 #[serde(default)]
1614 dry_run: bool,
1615}
1616
1617#[derive(Debug, Deserialize, JsonSchema)]
1618struct DeletePathInput {
1619 path: String,
1621 #[serde(default)]
1623 recursive: bool,
1624 #[serde(default)]
1626 dry_run: bool,
1627}
1628
1629#[derive(Debug, Serialize)]
1630struct PathMutationOutput {
1631 source_path: Option<String>,
1632 destination_path: Option<String>,
1633 path: Option<String>,
1634 dry_run: bool,
1635 mutation_performed: bool,
1636 copied: bool,
1637 moved: bool,
1638 deleted: bool,
1639 recursive: bool,
1640 overwritten: bool,
1641 bytes_affected: usize,
1642 items_affected: usize,
1643 approval_required: bool,
1644 diff_summary: String,
1645 #[serde(skip_serializing_if = "Option::is_none")]
1646 error: Option<String>,
1647 #[serde(skip_serializing_if = "Option::is_none")]
1648 cleanup_warning: Option<String>,
1649 #[serde(skip_serializing_if = "Option::is_none")]
1650 retained_backup_path: Option<String>,
1651}
1652
1653#[async_trait]
1654impl Tool for CopyPathTool {
1655 fn id(&self) -> &str {
1656 "copy_path"
1657 }
1658
1659 fn name(&self) -> &str {
1660 "Copy Path"
1661 }
1662
1663 fn description(&self) -> &str {
1664 "Copy a file or directory tree with source and destination policy checks and dry-run previews."
1665 }
1666
1667 fn input_schema(&self) -> Value {
1668 generate_schema::<CopyPathInput>()
1669 }
1670
1671 fn safety_metadata(&self) -> ToolSafetyMetadata {
1672 path_mutation_metadata(ToolOperationKind::Write)
1673 }
1674
1675 fn classify_call(&self, args: &Value) -> ToolCallClassification {
1676 path_mutation_classification(&self.safety_metadata(), args)
1677 }
1678
1679 fn policy_bindings(&self) -> ToolPolicyBindings {
1680 ToolPolicyBindings {
1681 path_fields: vec![
1682 PathPolicyBinding::read("source_path"),
1683 PathPolicyBinding::write("destination_path"),
1684 ],
1685 ..Default::default()
1686 }
1687 }
1688
1689 async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
1690 let input: CopyPathInput = match serde_json::from_value(args) {
1691 Ok(input) => input,
1692 Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
1693 };
1694 let source = PathBuf::from(&input.source_path);
1695 let destination = PathBuf::from(&input.destination_path);
1696 if let Err(reason) =
1697 validate_safe_target(&source).and_then(|_| validate_safe_target(&destination))
1698 {
1699 return ToolResult::error(reason);
1700 }
1701 let policy = MutationPolicySnapshot::from_context(&ctx.policy_snapshot);
1702 if let Err(reason) = ensure_read_allowed(&source, &policy) {
1703 return ToolResult::error(reason);
1704 }
1705 if let Err(reason) = ensure_write_allowed(&destination, input.dry_run, &policy) {
1706 return ToolResult::error(reason);
1707 }
1708 if !path_entry_exists(&source) {
1709 return ToolResult::error(format!("source path does not exist: {}", input.source_path));
1710 }
1711 if let Err(reason) = validate_copy_destination(&source, &destination) {
1712 return ToolResult::error(reason);
1713 }
1714 let (bytes_affected, items_affected, recursive) = match inspect_copy_source(&source) {
1715 Ok(details) => details,
1716 Err(error) => return ToolResult::error(format!("Copy source error: {}", error)),
1717 };
1718 let destination_exists = path_entry_exists(&destination);
1719 if destination_exists && !input.overwrite {
1720 return ToolResult::error("overwrite must be true to replace an existing destination");
1721 }
1722 if destination_exists && !policy.overwrite_existing && !input.dry_run {
1723 return ToolResult::error("overwrite_existing policy is false for this destination");
1724 }
1725 if let Some(parent) = destination.parent()
1726 && !parent.exists()
1727 {
1728 if !(input.create_parent_dirs && policy.create_parent_dirs) {
1729 return ToolResult::error(
1730 "destination parent directory does not exist or create_parent_dirs is not allowed",
1731 );
1732 }
1733 if !input.dry_run
1734 && let Err(error) = fs::create_dir_all(parent)
1735 {
1736 return ToolResult::error(format!("Create parent directory error: {}", error));
1737 }
1738 }
1739 let mut cleanup_warning = None;
1740 let mut retained_backup_path = None;
1741 if !input.dry_run {
1742 if destination_exists {
1743 match replace_copy_path(&source, &destination) {
1744 ReplacementOutcome::Committed {
1745 cleanup_warning: warning,
1746 retained_backup_path: backup,
1747 } => {
1748 cleanup_warning = warning;
1749 retained_backup_path = backup;
1750 }
1751 ReplacementOutcome::Unchanged(error) => {
1752 return ToolResult::error(format!("Copy error: {}", error));
1753 }
1754 ReplacementOutcome::RecoveryIncomplete {
1755 error,
1756 retained_backup_path,
1757 } => {
1758 return json_error_result(&PathMutationOutput {
1759 source_path: Some(input.source_path.clone()),
1760 destination_path: Some(input.destination_path.clone()),
1761 path: None,
1762 dry_run: false,
1763 mutation_performed: true,
1764 copied: false,
1765 moved: false,
1766 deleted: false,
1767 recursive,
1768 overwritten: false,
1769 bytes_affected,
1770 items_affected,
1771 approval_required: policy.approval_required(),
1772 diff_summary:
1773 "copy failed after moving the previous destination to a backup"
1774 .to_string(),
1775 error: Some(error),
1776 cleanup_warning: None,
1777 retained_backup_path: Some(retained_backup_path),
1778 });
1779 }
1780 }
1781 } else if let Err(error) = copy_path(&source, &destination) {
1782 return ToolResult::error(format!("Copy error: {}", error));
1783 }
1784 }
1785 json_result(&PathMutationOutput {
1786 source_path: Some(input.source_path.clone()),
1787 destination_path: Some(input.destination_path.clone()),
1788 path: None,
1789 dry_run: input.dry_run,
1790 mutation_performed: !input.dry_run,
1791 copied: !input.dry_run,
1792 moved: false,
1793 deleted: false,
1794 recursive,
1795 overwritten: !input.dry_run && destination_exists,
1796 bytes_affected,
1797 items_affected,
1798 approval_required: !input.dry_run && policy.approval_required(),
1799 diff_summary: format!(
1800 "{} {} -> {} ({} bytes)",
1801 if input.dry_run {
1802 "plan to copy"
1803 } else {
1804 "copied"
1805 },
1806 input.source_path,
1807 input.destination_path,
1808 bytes_affected
1809 ),
1810 error: None,
1811 cleanup_warning,
1812 retained_backup_path,
1813 })
1814 }
1815}
1816
1817#[async_trait]
1818impl Tool for MovePathTool {
1819 fn id(&self) -> &str {
1820 "move_path"
1821 }
1822
1823 fn name(&self) -> &str {
1824 "Move Path"
1825 }
1826
1827 fn description(&self) -> &str {
1828 "Move or rename a file or directory with source and destination policy checks and dry-run previews."
1829 }
1830
1831 fn input_schema(&self) -> Value {
1832 generate_schema::<MovePathInput>()
1833 }
1834
1835 fn safety_metadata(&self) -> ToolSafetyMetadata {
1836 path_mutation_metadata(ToolOperationKind::Write)
1837 }
1838
1839 fn classify_call(&self, args: &Value) -> ToolCallClassification {
1840 path_mutation_classification(&self.safety_metadata(), args)
1841 }
1842
1843 fn policy_bindings(&self) -> ToolPolicyBindings {
1844 ToolPolicyBindings {
1845 path_fields: vec![
1846 PathPolicyBinding::read_write("source_path"),
1847 PathPolicyBinding::write("destination_path"),
1848 ],
1849 ..Default::default()
1850 }
1851 }
1852
1853 async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
1854 let input: MovePathInput = match serde_json::from_value(args) {
1855 Ok(input) => input,
1856 Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
1857 };
1858 let source = PathBuf::from(&input.source_path);
1859 let destination = PathBuf::from(&input.destination_path);
1860 if let Err(reason) =
1861 validate_safe_target(&source).and_then(|_| validate_safe_target(&destination))
1862 {
1863 return ToolResult::error(reason);
1864 }
1865 let policy = MutationPolicySnapshot::from_context(&ctx.policy_snapshot);
1866 if let Err(reason) = ensure_write_allowed(&source, input.dry_run, &policy) {
1867 return ToolResult::error(reason);
1868 }
1869 if let Err(reason) = ensure_write_allowed(&destination, input.dry_run, &policy) {
1870 return ToolResult::error(reason);
1871 }
1872 if !path_entry_exists(&source) {
1873 return ToolResult::error(format!("source path does not exist: {}", input.source_path));
1874 }
1875 let resolved_source = match source.canonicalize() {
1876 Ok(path) => path,
1877 Err(error) => return ToolResult::error(format!("Source path error: {}", error)),
1878 };
1879 let resolved_destination = match mutation_path_resolver().and_then(|resolver| {
1880 resolver
1881 .resolve_path(&destination)
1882 .map_err(|error| format!("Path policy resolution failed: {}", error))
1883 }) {
1884 Ok(path) => path,
1885 Err(error) => return ToolResult::error(error),
1886 };
1887 if resolved_source == resolved_destination {
1888 return ToolResult::error("source and destination resolve to the same path");
1889 }
1890 let destination_exists = path_entry_exists(&destination);
1891 if destination_exists && !input.overwrite {
1892 return ToolResult::error("overwrite must be true to replace an existing destination");
1893 }
1894 if destination_exists && !policy.overwrite_existing && !input.dry_run {
1895 return ToolResult::error("overwrite_existing policy is false for this destination");
1896 }
1897 if let Some(parent) = destination.parent()
1898 && !parent.exists()
1899 {
1900 if !(input.create_parent_dirs && policy.create_parent_dirs) {
1901 return ToolResult::error(
1902 "destination parent directory does not exist or create_parent_dirs is not allowed",
1903 );
1904 }
1905 if !input.dry_run
1906 && let Err(error) = fs::create_dir_all(parent)
1907 {
1908 return ToolResult::error(format!("Create parent directory error: {}", error));
1909 }
1910 }
1911 let recursive = source.is_dir();
1912 let bytes_affected = path_size(&source).unwrap_or(0);
1913 let items_affected = path_item_count(&source).unwrap_or(1);
1914 let mut cleanup_warning = None;
1915 let mut retained_backup_path = None;
1916 if !input.dry_run {
1917 if destination_exists {
1918 match replace_moved_path(&source, &destination) {
1919 ReplacementOutcome::Committed {
1920 cleanup_warning: warning,
1921 retained_backup_path: backup,
1922 } => {
1923 cleanup_warning = warning;
1924 retained_backup_path = backup;
1925 }
1926 ReplacementOutcome::Unchanged(error) => {
1927 return ToolResult::error(format!("Move error: {}", error));
1928 }
1929 ReplacementOutcome::RecoveryIncomplete {
1930 error,
1931 retained_backup_path,
1932 } => {
1933 return json_error_result(&PathMutationOutput {
1934 source_path: Some(input.source_path.clone()),
1935 destination_path: Some(input.destination_path.clone()),
1936 path: None,
1937 dry_run: false,
1938 mutation_performed: true,
1939 copied: false,
1940 moved: false,
1941 deleted: false,
1942 recursive,
1943 overwritten: false,
1944 bytes_affected,
1945 items_affected,
1946 approval_required: policy.approval_required(),
1947 diff_summary:
1948 "move failed after moving the previous destination to a backup"
1949 .to_string(),
1950 error: Some(error),
1951 cleanup_warning: None,
1952 retained_backup_path: Some(retained_backup_path),
1953 });
1954 }
1955 }
1956 } else if let Err(error) = fs::rename(&source, &destination) {
1957 return ToolResult::error(format!("Move error: {}", error));
1958 }
1959 }
1960 json_result(&PathMutationOutput {
1961 source_path: Some(input.source_path.clone()),
1962 destination_path: Some(input.destination_path.clone()),
1963 path: None,
1964 dry_run: input.dry_run,
1965 mutation_performed: !input.dry_run,
1966 copied: false,
1967 moved: !input.dry_run,
1968 deleted: false,
1969 recursive,
1970 overwritten: !input.dry_run && destination_exists,
1971 bytes_affected,
1972 items_affected,
1973 approval_required: !input.dry_run && policy.approval_required(),
1974 diff_summary: format!(
1975 "{} {} -> {} ({} bytes)",
1976 if input.dry_run {
1977 "plan to move"
1978 } else {
1979 "moved"
1980 },
1981 input.source_path,
1982 input.destination_path,
1983 bytes_affected
1984 ),
1985 error: None,
1986 cleanup_warning,
1987 retained_backup_path,
1988 })
1989 }
1990}
1991
1992#[async_trait]
1993impl Tool for DeletePathTool {
1994 fn id(&self) -> &str {
1995 "delete_path"
1996 }
1997
1998 fn name(&self) -> &str {
1999 "Delete Path"
2000 }
2001
2002 fn description(&self) -> &str {
2003 "Delete a file or directory with explicit policy checks, recursive-delete gating, and dry-run previews."
2004 }
2005
2006 fn input_schema(&self) -> Value {
2007 generate_schema::<DeletePathInput>()
2008 }
2009
2010 fn safety_metadata(&self) -> ToolSafetyMetadata {
2011 path_mutation_metadata(ToolOperationKind::Delete)
2012 }
2013
2014 fn classify_call(&self, args: &Value) -> ToolCallClassification {
2015 path_mutation_classification(&self.safety_metadata(), args)
2016 }
2017
2018 fn policy_bindings(&self) -> ToolPolicyBindings {
2019 ToolPolicyBindings {
2020 path_fields: vec![PathPolicyBinding::write("path")],
2021 ..Default::default()
2022 }
2023 }
2024
2025 async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
2026 let input: DeletePathInput = match serde_json::from_value(args) {
2027 Ok(input) => input,
2028 Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
2029 };
2030 let path = PathBuf::from(&input.path);
2031 if let Err(reason) = validate_safe_target(&path) {
2032 return ToolResult::error(reason);
2033 }
2034 let policy = MutationPolicySnapshot::from_context(&ctx.policy_snapshot);
2035 if let Err(reason) = ensure_write_allowed(&path, input.dry_run, &policy) {
2036 return ToolResult::error(reason);
2037 }
2038 if let Err(reason) = ensure_not_write_root(&path, &policy) {
2039 return ToolResult::error(reason);
2040 }
2041 if !path_entry_exists(&path) {
2042 return ToolResult::error(format!("path does not exist: {}", input.path));
2043 }
2044 if path.is_dir() && !input.recursive {
2045 return ToolResult::error("recursive must be true to delete a directory");
2046 }
2047 let recursive = path.is_dir();
2048 let bytes_affected = path_size(&path).unwrap_or(0);
2049 let items_affected = path_item_count(&path).unwrap_or(1);
2050 if !input.dry_run {
2051 let result = if path.is_dir() {
2052 fs::remove_dir_all(&path)
2053 } else {
2054 fs::remove_file(&path)
2055 };
2056 if let Err(error) = result {
2057 return ToolResult::error(format!("Delete error: {}", error));
2058 }
2059 }
2060 json_result(&PathMutationOutput {
2061 source_path: None,
2062 destination_path: None,
2063 path: Some(input.path.clone()),
2064 dry_run: input.dry_run,
2065 mutation_performed: !input.dry_run,
2066 copied: false,
2067 moved: false,
2068 deleted: !input.dry_run,
2069 recursive,
2070 overwritten: false,
2071 bytes_affected,
2072 items_affected,
2073 approval_required: !input.dry_run && policy.approval_required(),
2074 diff_summary: format!(
2075 "{} {} ({} bytes, {} items)",
2076 if input.dry_run {
2077 "plan to delete"
2078 } else {
2079 "deleted"
2080 },
2081 input.path,
2082 bytes_affected,
2083 items_affected
2084 ),
2085 error: None,
2086 cleanup_warning: None,
2087 retained_backup_path: None,
2088 })
2089 }
2090}
2091
2092fn path_mutation_metadata(operation: ToolOperationKind) -> ToolSafetyMetadata {
2093 ToolSafetyMetadata {
2094 read_only: false,
2095 concurrency_safe: false,
2096 operation,
2097 side_effect_level: if matches!(operation, ToolOperationKind::Delete) {
2098 ToolSideEffectLevel::Destructive
2099 } else {
2100 ToolSideEffectLevel::LocalWrite
2101 },
2102 requires_network: false,
2103 destructive: matches!(operation, ToolOperationKind::Delete),
2104 open_world: false,
2105 host_dependent: false,
2106 requires_user_interaction: false,
2107 supports_cancellation: false,
2108 default_requires_approval: true,
2109 should_defer_schema: false,
2110 max_output_chars: Some(DEFAULT_MAX_OUTPUT_CHARS),
2111 max_result_size_chars: Some(DEFAULT_MAX_OUTPUT_CHARS),
2112 }
2113}
2114
2115fn path_mutation_classification(
2116 metadata: &ToolSafetyMetadata,
2117 args: &Value,
2118) -> ToolCallClassification {
2119 let dry_run = args
2120 .get("dry_run")
2121 .and_then(Value::as_bool)
2122 .unwrap_or(false);
2123 let mut classification = ToolCallClassification::from_metadata(metadata);
2124 classification.safely_retryable = dry_run;
2125 if dry_run {
2126 classification.read_only = true;
2127 classification.concurrency_safe = true;
2128 classification.destructive = false;
2129 classification.side_effect_level = ToolSideEffectLevel::None;
2130 classification.requires_approval = false;
2131 }
2132 classification
2133}
2134
2135fn ensure_read_allowed(path: &Path, policy: &MutationPolicySnapshot) -> Result<(), String> {
2136 let resolver = mutation_path_resolver()?;
2137 resolver
2138 .resolve_path(path)
2139 .map_err(|error| format!("Path policy resolution failed: {}", error))?;
2140 for blocked in &policy.blocked_paths {
2141 if resolver
2142 .matches_restriction(path, Path::new(blocked))
2143 .map_err(|error| format!("Path policy resolution failed: {}", error))?
2144 {
2145 return Err("source path is blocked by policy".to_string());
2146 }
2147 }
2148 Ok(())
2149}
2150
2151fn validate_copy_destination(source: &Path, destination: &Path) -> Result<(), String> {
2152 let source = source
2153 .canonicalize()
2154 .map_err(|error| format!("Source canonicalization failed: {}", error))?;
2155 let destination = mutation_path_resolver()?
2156 .resolve_path(destination)
2157 .map_err(|error| format!("Path policy resolution failed: {}", error))?;
2158 if destination == source || source.is_dir() && destination.starts_with(&source) {
2159 return Err("destination must not equal or be nested under the source".to_string());
2160 }
2161 Ok(())
2162}
2163
2164fn inspect_copy_source(path: &Path) -> std::io::Result<(usize, usize, bool)> {
2165 let metadata = fs::symlink_metadata(path)?;
2166 if metadata.file_type().is_symlink() {
2167 return Err(std::io::Error::new(
2168 std::io::ErrorKind::InvalidInput,
2169 format!("symbolic links are not supported: {}", path.display()),
2170 ));
2171 }
2172 if !metadata.is_dir() {
2173 return Ok((metadata.len() as usize, 1, false));
2174 }
2175
2176 let mut bytes = 0usize;
2177 let mut items = 1usize;
2178 for entry in fs::read_dir(path)? {
2179 let entry = entry?;
2180 let (entry_bytes, entry_items, _) = inspect_copy_source(&entry.path())?;
2181 bytes = bytes.saturating_add(entry_bytes);
2182 items = items.saturating_add(entry_items);
2183 }
2184 Ok((bytes, items, true))
2185}
2186
2187fn copy_path(source: &Path, destination: &Path) -> std::io::Result<()> {
2188 if source.is_dir() {
2189 copy_directory(source, destination)
2190 } else {
2191 if let Some(parent) = destination.parent()
2192 && !parent.exists()
2193 {
2194 fs::create_dir_all(parent)?;
2195 }
2196 fs::copy(source, destination)?;
2197 Ok(())
2198 }
2199}
2200
2201enum ReplacementOutcome {
2202 Committed {
2203 cleanup_warning: Option<String>,
2204 retained_backup_path: Option<String>,
2205 },
2206 Unchanged(String),
2207 RecoveryIncomplete {
2208 error: String,
2209 retained_backup_path: String,
2210 },
2211}
2212
2213fn replace_copy_path(source: &Path, destination: &Path) -> ReplacementOutcome {
2214 let staged = unique_sibling_path(destination, "copy");
2215 if let Err(error) = copy_path(source, &staged) {
2216 if path_entry_exists(&staged) {
2217 let _ = remove_path(&staged);
2218 }
2219 return ReplacementOutcome::Unchanged(error.to_string());
2220 }
2221 let outcome = replace_existing_path(&staged, destination);
2222 if matches!(
2223 outcome,
2224 ReplacementOutcome::Unchanged(_) | ReplacementOutcome::RecoveryIncomplete { .. }
2225 ) && path_entry_exists(&staged)
2226 {
2227 let _ = remove_path(&staged);
2228 }
2229 outcome
2230}
2231
2232fn replace_moved_path(source: &Path, destination: &Path) -> ReplacementOutcome {
2233 replace_existing_path(source, destination)
2234}
2235
2236fn replace_existing_path(prepared: &Path, destination: &Path) -> ReplacementOutcome {
2237 replace_existing_path_with(prepared, destination, remove_path)
2238}
2239
2240fn replace_existing_path_with<F>(
2241 prepared: &Path,
2242 destination: &Path,
2243 remove_backup: F,
2244) -> ReplacementOutcome
2245where
2246 F: FnOnce(&Path) -> std::io::Result<()>,
2247{
2248 let backup = unique_sibling_path(destination, "backup");
2249 if let Err(error) = fs::rename(destination, &backup) {
2250 return ReplacementOutcome::Unchanged(error.to_string());
2251 }
2252 if let Err(error) = fs::rename(prepared, destination) {
2253 return match fs::rename(&backup, destination) {
2254 Ok(()) => ReplacementOutcome::Unchanged(error.to_string()),
2255 Err(restore_error) => ReplacementOutcome::RecoveryIncomplete {
2256 error: format!(
2257 "replacement failed: {}; destination restore failed: {}",
2258 error, restore_error
2259 ),
2260 retained_backup_path: backup.to_string_lossy().into_owned(),
2261 },
2262 };
2263 }
2264
2265 match remove_backup(&backup) {
2269 Ok(()) => ReplacementOutcome::Committed {
2270 cleanup_warning: None,
2271 retained_backup_path: None,
2272 },
2273 Err(error) => ReplacementOutcome::Committed {
2274 cleanup_warning: Some(format!(
2275 "replacement committed but backup cleanup failed: {}",
2276 error
2277 )),
2278 retained_backup_path: Some(backup.to_string_lossy().into_owned()),
2279 },
2280 }
2281}
2282
2283fn unique_sibling_path(path: &Path, label: &str) -> PathBuf {
2284 let parent = path.parent().unwrap_or_else(|| Path::new("."));
2285 let name = path
2286 .file_name()
2287 .map(|name| name.to_string_lossy())
2288 .unwrap_or_else(|| "path".into());
2289 parent.join(format!(".{}.{}.{}", name, label, Uuid::new_v4()))
2290}
2291
2292fn remove_path(path: &Path) -> std::io::Result<()> {
2293 let metadata = fs::symlink_metadata(path)?;
2294 if metadata.is_dir() {
2295 fs::remove_dir_all(path)
2296 } else {
2297 fs::remove_file(path)
2298 }
2299}
2300
2301fn copy_directory(source: &Path, destination: &Path) -> std::io::Result<()> {
2302 fs::create_dir_all(destination)?;
2303 for entry in fs::read_dir(source)? {
2304 let entry = entry?;
2305 let from = entry.path();
2306 let metadata = fs::symlink_metadata(&from)?;
2307 if metadata.file_type().is_symlink() {
2308 return Err(std::io::Error::new(
2309 std::io::ErrorKind::InvalidInput,
2310 format!("symbolic links are not supported: {}", from.display()),
2311 ));
2312 }
2313 let to = destination.join(entry.file_name());
2314 if metadata.is_dir() {
2315 copy_directory(&from, &to)?;
2316 } else {
2317 fs::copy(&from, &to)?;
2318 }
2319 }
2320 Ok(())
2321}
2322
2323fn path_size(path: &Path) -> std::io::Result<usize> {
2324 if path.is_dir() {
2325 let mut total = 0usize;
2326 for entry in fs::read_dir(path)? {
2327 let entry = entry?;
2328 total += path_size(&entry.path())?;
2329 }
2330 Ok(total)
2331 } else {
2332 Ok(fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0))
2333 }
2334}
2335
2336fn path_item_count(path: &Path) -> std::io::Result<usize> {
2337 if path.is_dir() {
2338 let mut total = 1usize;
2339 for entry in fs::read_dir(path)? {
2340 let entry = entry?;
2341 total += path_item_count(&entry.path())?;
2342 }
2343 Ok(total)
2344 } else {
2345 Ok(1)
2346 }
2347}
2348
2349#[cfg(test)]
2350mod tests {
2351 use super::*;
2352 use tempfile::tempdir;
2353
2354 fn mutation_context(tool_id: &str, root: &Path) -> ToolExecutionContext {
2355 let mut context = ToolExecutionContext::test(tool_id);
2356 context.policy_snapshot = serde_json::json!({
2357 "write_paths": [root.to_string_lossy()],
2358 "overwrite_existing": true,
2359 "create_parent_dirs": true,
2360 "allow_without_confirmation": true
2361 });
2362 context
2363 }
2364
2365 fn result_json(result: &ToolResult) -> Value {
2366 serde_json::from_str(&result.output).unwrap()
2367 }
2368
2369 #[tokio::test]
2370 async fn file_edit_dry_run_requires_unique_match() {
2371 let dir = tempdir().unwrap();
2372 let path = dir.path().join("test.txt");
2373 fs::write(&path, "hello\nhello\n").unwrap();
2374 let tool = FileEditTool::new();
2375 let result = tool
2376 .execute(
2377 serde_json::json!({
2378 "path": path.to_string_lossy(),
2379 "old_text": "hello",
2380 "new_text": "hi",
2381 "dry_run": true
2382 }),
2383 ToolExecutionContext::test("file_edit"),
2384 )
2385 .await;
2386 assert!(!result.success);
2387 }
2388
2389 #[tokio::test]
2390 async fn file_edit_missing_old_text_fails_with_near_matches() {
2391 let dir = tempdir().unwrap();
2392 let path = dir.path().join("test.txt");
2393 fs::write(&path, "Status: draft\n").unwrap();
2394 let tool = FileEditTool::new();
2395 let result = tool
2396 .execute(
2397 serde_json::json!({
2398 "path": path.to_string_lossy(),
2399 "old_text": "Status: ready",
2400 "new_text": "Status: reviewed",
2401 "dry_run": true
2402 }),
2403 ToolExecutionContext::test("file_edit"),
2404 )
2405 .await;
2406 assert!(!result.success);
2407 let output: Value = serde_json::from_str(&result.output).unwrap();
2408 assert_eq!(output["diff_summary"], "old_text was not found");
2409 }
2410
2411 #[tokio::test]
2412 async fn file_write_denies_actual_without_write_policy() {
2413 let dir = tempdir().unwrap();
2414 let path = dir.path().join("test.txt");
2415 let tool = FileWriteTool::new();
2416 let result = tool
2417 .execute(
2418 serde_json::json!({
2419 "path": path.to_string_lossy(),
2420 "content": "hello",
2421 "dry_run": false
2422 }),
2423 ToolExecutionContext::test("file_write"),
2424 )
2425 .await;
2426 assert!(!result.success);
2427 }
2428
2429 #[tokio::test]
2430 async fn patch_delete_requires_allow_delete() {
2431 let dir = tempdir().unwrap();
2432 let path = dir.path().join("delete.txt");
2433 fs::write(&path, "old\n").unwrap();
2434 let tool = PatchTool::new();
2435 let result = tool
2436 .execute(
2437 serde_json::json!({
2438 "base_path": dir.path().to_string_lossy(),
2439 "dry_run": true,
2440 "patch": "--- a/delete.txt\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-old\n"
2441 }),
2442 ToolExecutionContext::test("patch"),
2443 )
2444 .await;
2445 assert!(!result.success);
2446 assert!(result.output.contains("allow_delete is false"));
2447 }
2448
2449 #[tokio::test]
2450 async fn patch_delete_dry_run_reports_planned_delete() {
2451 let dir = tempdir().unwrap();
2452 let path = dir.path().join("delete.txt");
2453 fs::write(&path, "old\n").unwrap();
2454 let result = PatchTool::new()
2455 .execute(
2456 serde_json::json!({
2457 "base_path": dir.path().to_string_lossy(),
2458 "allow_delete": true,
2459 "dry_run": true,
2460 "patch": "--- a/delete.txt\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-old\n"
2461 }),
2462 ToolExecutionContext::test("patch"),
2463 )
2464 .await;
2465 assert!(result.success, "{}", result.output);
2466 assert!(path.exists());
2467 let output = result_json(&result);
2468 assert_eq!(output["mutation_performed"], false);
2469 assert_eq!(output["created"], false);
2470 assert_eq!(output["overwritten"], false);
2471 }
2472
2473 #[tokio::test]
2474 async fn new_file_patch_rejects_existing_target() {
2475 let dir = tempdir().unwrap();
2476 let path = dir.path().join("existing.txt");
2477 fs::write(&path, "original\n").unwrap();
2478 let result = PatchTool::new()
2479 .execute(
2480 serde_json::json!({
2481 "base_path": dir.path().to_string_lossy(),
2482 "allow_new_files": true,
2483 "patch": "--- /dev/null\n+++ b/existing.txt\n@@ -0,0 +1,1 @@\n+created\n"
2484 }),
2485 mutation_context("patch", dir.path()),
2486 )
2487 .await;
2488
2489 assert!(!result.success);
2490 assert!(result.output.contains("already exists"));
2491 assert_eq!(fs::read_to_string(path).unwrap(), "original\n");
2492 }
2493
2494 #[test]
2495 fn parses_simple_patch() {
2496 let patch = "--- a/test.txt\n+++ b/test.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n";
2497 let files = parse_unified_diff(patch).unwrap();
2498 assert_eq!(files.len(), 1);
2499 assert_eq!(files[0].changed_lines(), 2);
2500 }
2501
2502 #[tokio::test]
2503 async fn delete_path_dry_run_does_not_remove_file() {
2504 let dir = tempdir().unwrap();
2505 let path = dir.path().join("gone.txt");
2506 fs::write(&path, "bye\n").unwrap();
2507 let tool = DeletePathTool::new();
2508 let result = tool
2509 .execute(
2510 serde_json::json!({"path": path.to_string_lossy(), "dry_run": true}),
2511 ToolExecutionContext::test("delete_path"),
2512 )
2513 .await;
2514 assert!(result.success);
2515 assert!(path.exists(), "dry-run must not remove the file");
2516 let output: Value = serde_json::from_str(&result.output).unwrap();
2517 assert_eq!(output["mutation_performed"], false);
2518 assert_eq!(output["deleted"], false);
2519 assert_eq!(output["dry_run"], true);
2520 }
2521
2522 #[tokio::test]
2523 async fn delete_path_requires_recursive_for_directory() {
2524 let dir = tempdir().unwrap();
2525 let path = dir.path().join("nested");
2526 fs::create_dir_all(&path).unwrap();
2527 let tool = DeletePathTool::new();
2528 let result = tool
2529 .execute(
2530 serde_json::json!({"path": path.to_string_lossy(), "recursive": false, "dry_run": true}),
2531 ToolExecutionContext::test("delete_path"),
2532 )
2533 .await;
2534 assert!(!result.success);
2535 assert!(result.output.contains("recursive must be true"));
2536 }
2537
2538 #[tokio::test]
2539 async fn copy_path_dry_run_does_not_create_destination() {
2540 let dir = tempdir().unwrap();
2541 let source = dir.path().join("source.txt");
2542 let destination = dir.path().join("destination.txt");
2543 fs::write(&source, "hello").unwrap();
2544 let tool = CopyPathTool::new();
2545 let result = tool
2546 .execute(
2547 serde_json::json!({
2548 "source_path": source.to_string_lossy(),
2549 "destination_path": destination.to_string_lossy(),
2550 "dry_run": true
2551 }),
2552 ToolExecutionContext::test("copy_path"),
2553 )
2554 .await;
2555 assert!(result.success);
2556 assert!(
2557 !destination.exists(),
2558 "dry-run must not create the destination"
2559 );
2560 let output: Value = serde_json::from_str(&result.output).unwrap();
2561 assert_eq!(output["mutation_performed"], false);
2562 assert_eq!(output["copied"], false);
2563 assert_eq!(output["dry_run"], true);
2564 }
2565
2566 #[tokio::test]
2567 async fn copy_path_rejects_destination_nested_under_source() {
2568 let dir = tempdir().unwrap();
2569 let source = dir.path().join("source");
2570 fs::create_dir_all(&source).unwrap();
2571 fs::write(source.join("file.txt"), "hello").unwrap();
2572 let destination = source.join("nested");
2573
2574 let result = CopyPathTool::new()
2575 .execute(
2576 serde_json::json!({
2577 "source_path": source.to_string_lossy(),
2578 "destination_path": destination.to_string_lossy(),
2579 "dry_run": true
2580 }),
2581 mutation_context("copy_path", dir.path()),
2582 )
2583 .await;
2584
2585 assert!(!result.success);
2586 assert!(result.output.contains("nested under the source"));
2587 assert!(!destination.exists());
2588 }
2589
2590 #[cfg(unix)]
2591 #[tokio::test]
2592 async fn copy_path_rejects_symlinks_inside_source_tree() {
2593 use std::os::unix::fs::symlink;
2594
2595 let dir = tempdir().unwrap();
2596 let source = dir.path().join("source");
2597 let outside = dir.path().join("outside");
2598 let destination = dir.path().join("destination");
2599 fs::create_dir_all(&source).unwrap();
2600 fs::create_dir_all(&outside).unwrap();
2601 fs::write(outside.join("secret.txt"), "secret").unwrap();
2602 symlink(&outside, source.join("alias")).unwrap();
2603
2604 let result = CopyPathTool::new()
2605 .execute(
2606 serde_json::json!({
2607 "source_path": source.to_string_lossy(),
2608 "destination_path": destination.to_string_lossy(),
2609 "dry_run": true
2610 }),
2611 mutation_context("copy_path", dir.path()),
2612 )
2613 .await;
2614
2615 assert!(!result.success);
2616 assert!(result.output.contains("symbolic links are not supported"));
2617 assert!(!destination.exists());
2618 }
2619
2620 #[test]
2621 fn mutation_schemas_default_to_execution() {
2622 for schema in [
2623 FileWriteTool::new().input_schema(),
2624 FileEditTool::new().input_schema(),
2625 PatchTool::new().input_schema(),
2626 CopyPathTool::new().input_schema(),
2627 MovePathTool::new().input_schema(),
2628 DeletePathTool::new().input_schema(),
2629 ] {
2630 assert_eq!(schema["properties"]["dry_run"]["default"], false);
2631 }
2632 }
2633
2634 #[test]
2635 fn omitted_dry_run_classifies_as_mutation() {
2636 for classification in [
2637 FileWriteTool::new().classify_call(&serde_json::json!({})),
2638 FileEditTool::new().classify_call(&serde_json::json!({})),
2639 PatchTool::new().classify_call(&serde_json::json!({})),
2640 CopyPathTool::new().classify_call(&serde_json::json!({})),
2641 MovePathTool::new().classify_call(&serde_json::json!({})),
2642 DeletePathTool::new().classify_call(&serde_json::json!({})),
2643 ] {
2644 assert!(!classification.read_only);
2645 assert!(!classification.concurrency_safe);
2646 assert!(!classification.safely_retryable);
2647 assert!(classification.requires_approval);
2648 }
2649 }
2650
2651 #[test]
2652 fn actual_delete_classification_remains_destructive_delete() {
2653 let classification =
2654 DeletePathTool::new().classify_call(&serde_json::json!({"dry_run": false}));
2655 assert!(matches!(
2656 classification.operation,
2657 ToolOperationKind::Delete
2658 ));
2659 assert!(matches!(
2660 classification.side_effect_level,
2661 ToolSideEffectLevel::Destructive
2662 ));
2663 assert!(classification.destructive);
2664 assert!(!classification.read_only);
2665 }
2666
2667 #[tokio::test]
2668 async fn file_write_omitted_dry_run_applies_and_records_version() {
2669 let dir = tempdir().unwrap();
2670 let parent = dir.path().join("missing");
2671 let path = parent.join("new.txt");
2672 let versions = FileVersionStore::default();
2673 let tool = FileWriteTool::with_version_store(versions.clone());
2674 let result = tool
2675 .execute(
2676 serde_json::json!({
2677 "path": path.to_string_lossy(),
2678 "content": "hello",
2679 "create_parent_dirs": true
2680 }),
2681 mutation_context("file_write", dir.path()),
2682 )
2683 .await;
2684 assert!(result.success, "{}", result.output);
2685 assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
2686 assert!(versions.get(&path).is_some());
2687 let output = result_json(&result);
2688 assert_eq!(output["mutation_performed"], true);
2689 assert_eq!(output["created"], true);
2690 assert_eq!(output["overwritten"], false);
2691 assert_eq!(output["bytes_written"], 5);
2692 }
2693
2694 #[tokio::test]
2695 async fn file_edit_omitted_dry_run_applies_and_records_version() {
2696 let dir = tempdir().unwrap();
2697 let path = dir.path().join("edit.txt");
2698 fs::write(&path, "before\n").unwrap();
2699 let versions = FileVersionStore::default();
2700 let tool = FileEditTool::with_version_store(versions.clone());
2701 let result = tool
2702 .execute(
2703 serde_json::json!({
2704 "path": path.to_string_lossy(),
2705 "old_text": "before",
2706 "new_text": "after"
2707 }),
2708 mutation_context("file_edit", dir.path()),
2709 )
2710 .await;
2711 assert!(result.success, "{}", result.output);
2712 assert_eq!(fs::read_to_string(&path).unwrap(), "after\n");
2713 assert!(versions.get(&path).is_some());
2714 let output = result_json(&result);
2715 assert_eq!(output["mutation_performed"], true);
2716 assert_eq!(output["overwritten"], true);
2717 }
2718
2719 #[tokio::test]
2720 async fn omitted_dry_run_copies_moves_and_deletes() {
2721 let dir = tempdir().unwrap();
2722 let source = dir.path().join("source.txt");
2723 let copy_parent = dir.path().join("copy-parent");
2724 let copy_destination = copy_parent.join("copy.txt");
2725 let move_parent = dir.path().join("move-parent");
2726 let move_destination = move_parent.join("move.txt");
2727 fs::write(&source, "content").unwrap();
2728
2729 let copy_result = CopyPathTool::new()
2730 .execute(
2731 serde_json::json!({
2732 "source_path": source.to_string_lossy(),
2733 "destination_path": copy_destination.to_string_lossy(),
2734 "create_parent_dirs": true
2735 }),
2736 mutation_context("copy_path", dir.path()),
2737 )
2738 .await;
2739 assert!(copy_result.success, "{}", copy_result.output);
2740 assert_eq!(fs::read_to_string(©_destination).unwrap(), "content");
2741 let copy_output = result_json(©_result);
2742 assert_eq!(copy_output["mutation_performed"], true);
2743 assert_eq!(copy_output["copied"], true);
2744
2745 let move_result = MovePathTool::new()
2746 .execute(
2747 serde_json::json!({
2748 "source_path": source.to_string_lossy(),
2749 "destination_path": move_destination.to_string_lossy(),
2750 "create_parent_dirs": true
2751 }),
2752 mutation_context("move_path", dir.path()),
2753 )
2754 .await;
2755 assert!(move_result.success, "{}", move_result.output);
2756 assert!(!source.exists());
2757 assert_eq!(fs::read_to_string(&move_destination).unwrap(), "content");
2758 let move_output = result_json(&move_result);
2759 assert_eq!(move_output["mutation_performed"], true);
2760 assert_eq!(move_output["moved"], true);
2761
2762 let delete_result = DeletePathTool::new()
2763 .execute(
2764 serde_json::json!({"path": move_destination.to_string_lossy()}),
2765 mutation_context("delete_path", dir.path()),
2766 )
2767 .await;
2768 assert!(delete_result.success, "{}", delete_result.output);
2769 assert!(!move_destination.exists());
2770 let delete_output = result_json(&delete_result);
2771 assert_eq!(delete_output["mutation_performed"], true);
2772 assert_eq!(delete_output["deleted"], true);
2773 }
2774
2775 #[tokio::test]
2776 async fn move_path_rejects_same_source_and_destination() {
2777 let dir = tempdir().unwrap();
2778 let path = dir.path().join("same.txt");
2779 fs::write(&path, "content").unwrap();
2780 let result = MovePathTool::new()
2781 .execute(
2782 serde_json::json!({
2783 "source_path": path.to_string_lossy(),
2784 "destination_path": path.to_string_lossy(),
2785 "overwrite": true
2786 }),
2787 mutation_context("move_path", dir.path()),
2788 )
2789 .await;
2790
2791 assert!(!result.success);
2792 assert!(result.output.contains("same path"));
2793 assert_eq!(fs::read_to_string(path).unwrap(), "content");
2794 }
2795
2796 #[tokio::test]
2797 async fn delete_path_explicit_dry_run_false_reports_applied_mutation() {
2798 let dir = tempdir().unwrap();
2799 let path = dir.path().join("delete.txt");
2800 fs::write(&path, "content").unwrap();
2801 let result = DeletePathTool::new()
2802 .execute(
2803 serde_json::json!({
2804 "path": path.to_string_lossy(),
2805 "dry_run": false
2806 }),
2807 mutation_context("delete_path", dir.path()),
2808 )
2809 .await;
2810 assert!(result.success, "{}", result.output);
2811 assert!(!path.exists());
2812 let output = result_json(&result);
2813 assert_eq!(output["mutation_performed"], true);
2814 assert_eq!(output["deleted"], true);
2815 }
2816
2817 #[tokio::test]
2818 async fn patch_omitted_dry_run_applies_and_records_version() {
2819 let dir = tempdir().unwrap();
2820 let path = dir.path().join("patch.txt");
2821 fs::write(&path, "old\n").unwrap();
2822 let versions = FileVersionStore::default();
2823 let result = PatchTool::with_version_store(versions.clone())
2824 .execute(
2825 serde_json::json!({
2826 "base_path": dir.path().to_string_lossy(),
2827 "patch": "--- a/patch.txt\n+++ b/patch.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n"
2828 }),
2829 mutation_context("patch", dir.path()),
2830 )
2831 .await;
2832 assert!(result.success, "{}", result.output);
2833 assert_eq!(fs::read_to_string(&path).unwrap(), "new\n");
2834 assert!(versions.get(&path).is_some());
2835 let output = result_json(&result);
2836 assert_eq!(output["mutation_performed"], true);
2837 assert_eq!(output["created"], false);
2838 assert_eq!(output["overwritten"], true);
2839 assert_eq!(output["bytes_written"], 4);
2840 }
2841
2842 #[test]
2843 fn patch_apply_failure_rolls_back_prior_writes() {
2844 let dir = tempdir().unwrap();
2845 let first = dir.path().join("first.txt");
2846 let invalid_parent = dir.path().join("not-a-directory");
2847 fs::write(&first, "old\n").unwrap();
2848 #[cfg(unix)]
2849 {
2850 use std::os::unix::fs::PermissionsExt;
2851 fs::set_permissions(&first, fs::Permissions::from_mode(0o600)).unwrap();
2852 }
2853 fs::write(&invalid_parent, "blocker\n").unwrap();
2854 let outputs = vec![
2855 PatchApply::Write {
2856 path: first.clone(),
2857 content: "new\n".to_string(),
2858 expected: ExpectedPathState::Present(read_file_snapshot(&first).unwrap()),
2859 },
2860 PatchApply::Write {
2861 path: invalid_parent.join("child.txt"),
2862 content: "child\n".to_string(),
2863 expected: ExpectedPathState::Absent,
2864 },
2865 ];
2866 let error = apply_patch_transaction(&outputs).unwrap_err();
2867 assert!(error.contains("no patch changes were retained"));
2868 assert_eq!(fs::read_to_string(&first).unwrap(), "old\n");
2869 #[cfg(unix)]
2870 {
2871 use std::os::unix::fs::PermissionsExt;
2872 assert_eq!(
2873 fs::metadata(&first).unwrap().permissions().mode() & 0o777,
2874 0o600
2875 );
2876 }
2877 assert!(!invalid_parent.join("child.txt").exists());
2878 }
2879
2880 #[test]
2881 fn patch_rejects_pre_apply_content_and_absence_conflicts() {
2882 let dir = tempdir().unwrap();
2883 let existing = dir.path().join("existing.txt");
2884 let created = dir.path().join("created.txt");
2885 fs::write(&existing, "original\n").unwrap();
2886 let existing_output = PatchApply::Write {
2887 path: existing.clone(),
2888 content: "patched\n".to_string(),
2889 expected: ExpectedPathState::Present(read_file_snapshot(&existing).unwrap()),
2890 };
2891 fs::write(&existing, "external\n").unwrap();
2892 let error = apply_patch_transaction(&[existing_output]).unwrap_err();
2893 assert!(error.contains("pre-apply conflict"));
2894 assert_eq!(fs::read_to_string(&existing).unwrap(), "external\n");
2895
2896 let created_output = PatchApply::Write {
2897 path: created.clone(),
2898 content: "patched\n".to_string(),
2899 expected: ExpectedPathState::Absent,
2900 };
2901 fs::write(&created, "external\n").unwrap();
2902 let error = apply_patch_transaction(&[created_output]).unwrap_err();
2903 assert!(error.contains("pre-apply conflict"));
2904 assert_eq!(fs::read_to_string(&created).unwrap(), "external\n");
2905 }
2906
2907 #[test]
2908 fn patch_rollback_conflict_preserves_external_content() {
2909 let dir = tempdir().unwrap();
2910 let path = dir.path().join("target.txt");
2911 fs::write(&path, "original\n").unwrap();
2912 let original = read_file_snapshot(&path).unwrap();
2913 fs::write(&path, "external\n").unwrap();
2914 let rollback = PatchRollback::RestoreWrite {
2915 path: path.clone(),
2916 original: original.clone(),
2917 written: b"transaction\n".to_vec(),
2918 written_permissions: original.permissions,
2919 };
2920 let error = rollback_patch(&[rollback], &[]).unwrap_err();
2921 assert!(error.contains("rollback conflict"));
2922 assert_eq!(fs::read_to_string(&path).unwrap(), "external\n");
2923 }
2924
2925 #[cfg(unix)]
2926 #[test]
2927 fn patch_rollback_conflict_preserves_external_permissions() {
2928 use std::os::unix::fs::PermissionsExt;
2929
2930 let dir = tempdir().unwrap();
2931 let path = dir.path().join("target.txt");
2932 fs::write(&path, "original\n").unwrap();
2933 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
2934 let original = read_file_snapshot(&path).unwrap();
2935 fs::write(&path, "transaction\n").unwrap();
2936 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
2937 let rollback = PatchRollback::RestoreWrite {
2938 path: path.clone(),
2939 original: original.clone(),
2940 written: b"transaction\n".to_vec(),
2941 written_permissions: original.permissions,
2942 };
2943
2944 let error = rollback_patch(&[rollback], &[]).unwrap_err();
2945
2946 assert!(error.contains("permissions changed"));
2947 assert_eq!(fs::read_to_string(&path).unwrap(), "transaction\n");
2948 assert_eq!(
2949 fs::metadata(path).unwrap().permissions().mode() & 0o777,
2950 0o640
2951 );
2952 }
2953
2954 #[tokio::test]
2955 async fn patch_preflights_every_file_before_mutating_any_target() {
2956 let dir = tempdir().unwrap();
2957 let first = dir.path().join("first.txt");
2958 let second = dir.path().join("second.txt");
2959 fs::write(&first, "first old\n").unwrap();
2960 fs::write(&second, "second old\n").unwrap();
2961 let patch = concat!(
2962 "--- a/first.txt\n+++ b/first.txt\n@@ -1,1 +1,1 @@\n-first old\n+first new\n",
2963 "--- a/second.txt\n+++ b/second.txt\n@@ -1,1 +1,1 @@\n-not present\n+second new\n"
2964 );
2965 let result = PatchTool::new()
2966 .execute(
2967 serde_json::json!({
2968 "base_path": dir.path().to_string_lossy(),
2969 "patch": patch,
2970 "dry_run": false
2971 }),
2972 mutation_context("patch", dir.path()),
2973 )
2974 .await;
2975 assert!(!result.success);
2976 assert_eq!(fs::read_to_string(&first).unwrap(), "first old\n");
2977 assert_eq!(fs::read_to_string(&second).unwrap(), "second old\n");
2978 }
2979
2980 #[tokio::test]
2981 async fn copy_path_rejects_missing_source_without_creating_destination() {
2982 let dir = tempdir().unwrap();
2983 let source = dir.path().join("missing.txt");
2984 let destination = dir.path().join("destination.txt");
2985 let result = CopyPathTool::new()
2986 .execute(
2987 serde_json::json!({
2988 "source_path": source.to_string_lossy(),
2989 "destination_path": destination.to_string_lossy(),
2990 "dry_run": false
2991 }),
2992 mutation_context("copy_path", dir.path()),
2993 )
2994 .await;
2995
2996 assert!(!result.success);
2997 assert!(result.output.contains("source path does not exist"));
2998 assert!(!destination.exists());
2999 }
3000
3001 #[tokio::test]
3002 async fn move_path_rejects_missing_source_without_creating_destination() {
3003 let dir = tempdir().unwrap();
3004 let source = dir.path().join("missing.txt");
3005 let destination = dir.path().join("destination.txt");
3006 let result = MovePathTool::new()
3007 .execute(
3008 serde_json::json!({
3009 "source_path": source.to_string_lossy(),
3010 "destination_path": destination.to_string_lossy(),
3011 "dry_run": false
3012 }),
3013 mutation_context("move_path", dir.path()),
3014 )
3015 .await;
3016
3017 assert!(!result.success);
3018 assert!(result.output.contains("source path does not exist"));
3019 assert!(!destination.exists());
3020 }
3021
3022 #[tokio::test]
3023 async fn delete_path_rejects_missing_path() {
3024 let dir = tempdir().unwrap();
3025 let path = dir.path().join("missing.txt");
3026 let result = DeletePathTool::new()
3027 .execute(
3028 serde_json::json!({
3029 "path": path.to_string_lossy(),
3030 "dry_run": false
3031 }),
3032 mutation_context("delete_path", dir.path()),
3033 )
3034 .await;
3035
3036 assert!(!result.success);
3037 assert!(result.output.contains("path does not exist"));
3038 }
3039
3040 #[tokio::test]
3041 async fn copy_path_collision_requires_overwrite() {
3042 let dir = tempdir().unwrap();
3043 let source = dir.path().join("source.txt");
3044 let destination = dir.path().join("destination.txt");
3045 fs::write(&source, "source").unwrap();
3046 fs::write(&destination, "destination").unwrap();
3047 let result = CopyPathTool::new()
3048 .execute(
3049 serde_json::json!({
3050 "source_path": source.to_string_lossy(),
3051 "destination_path": destination.to_string_lossy(),
3052 "dry_run": false
3053 }),
3054 mutation_context("copy_path", dir.path()),
3055 )
3056 .await;
3057
3058 assert!(!result.success);
3059 assert!(result.output.contains("overwrite must be true"));
3060 assert_eq!(fs::read_to_string(&source).unwrap(), "source");
3061 assert_eq!(fs::read_to_string(&destination).unwrap(), "destination");
3062 }
3063
3064 #[tokio::test]
3065 async fn move_path_collision_requires_overwrite() {
3066 let dir = tempdir().unwrap();
3067 let source = dir.path().join("source.txt");
3068 let destination = dir.path().join("destination.txt");
3069 fs::write(&source, "source").unwrap();
3070 fs::write(&destination, "destination").unwrap();
3071 let result = MovePathTool::new()
3072 .execute(
3073 serde_json::json!({
3074 "source_path": source.to_string_lossy(),
3075 "destination_path": destination.to_string_lossy(),
3076 "dry_run": false
3077 }),
3078 mutation_context("move_path", dir.path()),
3079 )
3080 .await;
3081
3082 assert!(!result.success);
3083 assert!(result.output.contains("overwrite must be true"));
3084 assert_eq!(fs::read_to_string(&source).unwrap(), "source");
3085 assert_eq!(fs::read_to_string(&destination).unwrap(), "destination");
3086 }
3087
3088 #[tokio::test]
3089 async fn copy_path_overwrite_replaces_existing_file_and_reports_effect() {
3090 let dir = tempdir().unwrap();
3091 let source = dir.path().join("source.txt");
3092 let destination = dir.path().join("destination.txt");
3093 fs::write(&source, "source").unwrap();
3094 fs::write(&destination, "old").unwrap();
3095 let result = CopyPathTool::new()
3096 .execute(
3097 serde_json::json!({
3098 "source_path": source.to_string_lossy(),
3099 "destination_path": destination.to_string_lossy(),
3100 "overwrite": true,
3101 "dry_run": false
3102 }),
3103 mutation_context("copy_path", dir.path()),
3104 )
3105 .await;
3106
3107 assert!(result.success, "{}", result.output);
3108 assert_eq!(fs::read_to_string(&source).unwrap(), "source");
3109 assert_eq!(fs::read_to_string(&destination).unwrap(), "source");
3110 let output = result_json(&result);
3111 assert_eq!(output["mutation_performed"], true);
3112 assert_eq!(output["copied"], true);
3113 assert_eq!(output["overwritten"], true);
3114 assert_eq!(output["bytes_affected"], 6);
3115 }
3116
3117 #[tokio::test]
3118 async fn copy_path_overwrite_replaces_existing_directory_tree() {
3119 let dir = tempdir().unwrap();
3120 let source = dir.path().join("source");
3121 let destination = dir.path().join("destination");
3122 fs::create_dir_all(source.join("nested")).unwrap();
3123 fs::create_dir_all(&destination).unwrap();
3124 fs::write(source.join("nested/new.txt"), "new").unwrap();
3125 fs::write(destination.join("stale.txt"), "stale").unwrap();
3126 let result = CopyPathTool::new()
3127 .execute(
3128 serde_json::json!({
3129 "source_path": source.to_string_lossy(),
3130 "destination_path": destination.to_string_lossy(),
3131 "overwrite": true,
3132 "dry_run": false
3133 }),
3134 mutation_context("copy_path", dir.path()),
3135 )
3136 .await;
3137
3138 assert!(result.success, "{}", result.output);
3139 assert_eq!(
3140 fs::read_to_string(destination.join("nested/new.txt")).unwrap(),
3141 "new"
3142 );
3143 assert!(!destination.join("stale.txt").exists());
3144 let output = result_json(&result);
3145 assert_eq!(output["mutation_performed"], true);
3146 assert_eq!(output["copied"], true);
3147 assert_eq!(output["overwritten"], true);
3148 assert_eq!(output["recursive"], true);
3149 assert_eq!(output["bytes_affected"], 3);
3150 }
3151
3152 #[tokio::test]
3153 async fn move_path_overwrite_replaces_existing_file_and_reports_effect() {
3154 let dir = tempdir().unwrap();
3155 let source = dir.path().join("source.txt");
3156 let destination = dir.path().join("destination.txt");
3157 fs::write(&source, "source").unwrap();
3158 fs::write(&destination, "old").unwrap();
3159 let result = MovePathTool::new()
3160 .execute(
3161 serde_json::json!({
3162 "source_path": source.to_string_lossy(),
3163 "destination_path": destination.to_string_lossy(),
3164 "overwrite": true,
3165 "dry_run": false
3166 }),
3167 mutation_context("move_path", dir.path()),
3168 )
3169 .await;
3170
3171 assert!(result.success, "{}", result.output);
3172 assert!(!source.exists());
3173 assert_eq!(fs::read_to_string(&destination).unwrap(), "source");
3174 let output = result_json(&result);
3175 assert_eq!(output["mutation_performed"], true);
3176 assert_eq!(output["moved"], true);
3177 assert_eq!(output["overwritten"], true);
3178 assert_eq!(output["bytes_affected"], 6);
3179 }
3180
3181 #[tokio::test]
3182 async fn move_path_overwrite_replaces_existing_directory_tree() {
3183 let dir = tempdir().unwrap();
3184 let source = dir.path().join("source");
3185 let destination = dir.path().join("destination");
3186 fs::create_dir_all(source.join("nested")).unwrap();
3187 fs::create_dir_all(&destination).unwrap();
3188 fs::write(source.join("nested/new.txt"), "new").unwrap();
3189 fs::write(destination.join("stale.txt"), "stale").unwrap();
3190 let result = MovePathTool::new()
3191 .execute(
3192 serde_json::json!({
3193 "source_path": source.to_string_lossy(),
3194 "destination_path": destination.to_string_lossy(),
3195 "overwrite": true,
3196 "dry_run": false
3197 }),
3198 mutation_context("move_path", dir.path()),
3199 )
3200 .await;
3201
3202 assert!(result.success, "{}", result.output);
3203 assert!(!source.exists());
3204 assert_eq!(
3205 fs::read_to_string(destination.join("nested/new.txt")).unwrap(),
3206 "new"
3207 );
3208 assert!(!destination.join("stale.txt").exists());
3209 let output = result_json(&result);
3210 assert_eq!(output["mutation_performed"], true);
3211 assert_eq!(output["moved"], true);
3212 assert_eq!(output["overwritten"], true);
3213 assert_eq!(output["recursive"], true);
3214 assert_eq!(output["bytes_affected"], 3);
3215 }
3216
3217 #[tokio::test]
3218 async fn copy_path_parent_creation_requires_request_opt_in() {
3219 let dir = tempdir().unwrap();
3220 let source = dir.path().join("source.txt");
3221 let destination = dir.path().join("missing/destination.txt");
3222 fs::write(&source, "source").unwrap();
3223 let result = CopyPathTool::new()
3224 .execute(
3225 serde_json::json!({
3226 "source_path": source.to_string_lossy(),
3227 "destination_path": destination.to_string_lossy(),
3228 "create_parent_dirs": false,
3229 "dry_run": false
3230 }),
3231 mutation_context("copy_path", dir.path()),
3232 )
3233 .await;
3234
3235 assert!(!result.success);
3236 assert!(result.output.contains("parent directory does not exist"));
3237 assert!(!destination.exists());
3238 assert!(!dir.path().join("missing").exists());
3239 }
3240
3241 #[tokio::test]
3242 async fn move_path_parent_creation_requires_request_opt_in() {
3243 let dir = tempdir().unwrap();
3244 let source = dir.path().join("source.txt");
3245 let destination = dir.path().join("missing/destination.txt");
3246 fs::write(&source, "source").unwrap();
3247 let result = MovePathTool::new()
3248 .execute(
3249 serde_json::json!({
3250 "source_path": source.to_string_lossy(),
3251 "destination_path": destination.to_string_lossy(),
3252 "create_parent_dirs": false,
3253 "dry_run": false
3254 }),
3255 mutation_context("move_path", dir.path()),
3256 )
3257 .await;
3258
3259 assert!(!result.success);
3260 assert!(result.output.contains("parent directory does not exist"));
3261 assert!(source.exists());
3262 assert!(!destination.exists());
3263 assert!(!dir.path().join("missing").exists());
3264 }
3265
3266 #[tokio::test]
3267 async fn copy_and_move_parent_creation_requires_policy_opt_in() {
3268 let dir = tempdir().unwrap();
3269 let root = dir.path().join("root");
3270 fs::create_dir_all(&root).unwrap();
3271 let copy_source = root.join("copy-source.txt");
3272 let move_source = root.join("move-source.txt");
3273 let copy_destination = root.join("copy-parent/destination.txt");
3274 let move_destination = root.join("move-parent/destination.txt");
3275 fs::write(©_source, "copy").unwrap();
3276 fs::write(&move_source, "move").unwrap();
3277 let mut copy_context = mutation_context("copy_path", &root);
3278 copy_context.policy_snapshot["create_parent_dirs"] = Value::Bool(false);
3279 let mut move_context = mutation_context("move_path", &root);
3280 move_context.policy_snapshot["create_parent_dirs"] = Value::Bool(false);
3281
3282 let copy_result = CopyPathTool::new()
3283 .execute(
3284 serde_json::json!({
3285 "source_path": copy_source.to_string_lossy(),
3286 "destination_path": copy_destination.to_string_lossy(),
3287 "create_parent_dirs": true,
3288 "dry_run": false
3289 }),
3290 copy_context,
3291 )
3292 .await;
3293 assert!(!copy_result.success);
3294 assert!(!copy_destination.exists());
3295 assert!(!root.join("copy-parent").exists());
3296
3297 let move_result = MovePathTool::new()
3298 .execute(
3299 serde_json::json!({
3300 "source_path": move_source.to_string_lossy(),
3301 "destination_path": move_destination.to_string_lossy(),
3302 "create_parent_dirs": true,
3303 "dry_run": false
3304 }),
3305 move_context,
3306 )
3307 .await;
3308 assert!(!move_result.success);
3309 assert!(move_source.exists());
3310 assert!(!move_destination.exists());
3311 assert!(!root.join("move-parent").exists());
3312 }
3313
3314 #[tokio::test]
3315 async fn copy_and_move_overwrite_require_policy_opt_in() {
3316 let dir = tempdir().unwrap();
3317 let copy_source = dir.path().join("copy-source.txt");
3318 let copy_destination = dir.path().join("copy-destination.txt");
3319 let move_source = dir.path().join("move-source.txt");
3320 let move_destination = dir.path().join("move-destination.txt");
3321 fs::write(©_source, "new copy").unwrap();
3322 fs::write(©_destination, "old copy").unwrap();
3323 fs::write(&move_source, "new move").unwrap();
3324 fs::write(&move_destination, "old move").unwrap();
3325 let mut copy_context = mutation_context("copy_path", dir.path());
3326 copy_context.policy_snapshot["overwrite_existing"] = Value::Bool(false);
3327 let mut move_context = mutation_context("move_path", dir.path());
3328 move_context.policy_snapshot["overwrite_existing"] = Value::Bool(false);
3329
3330 let copy_result = CopyPathTool::new()
3331 .execute(
3332 serde_json::json!({
3333 "source_path": copy_source.to_string_lossy(),
3334 "destination_path": copy_destination.to_string_lossy(),
3335 "overwrite": true,
3336 "dry_run": false
3337 }),
3338 copy_context,
3339 )
3340 .await;
3341 assert!(!copy_result.success);
3342 assert!(
3343 copy_result
3344 .output
3345 .contains("overwrite_existing policy is false")
3346 );
3347 assert_eq!(fs::read_to_string(©_destination).unwrap(), "old copy");
3348
3349 let move_result = MovePathTool::new()
3350 .execute(
3351 serde_json::json!({
3352 "source_path": move_source.to_string_lossy(),
3353 "destination_path": move_destination.to_string_lossy(),
3354 "overwrite": true,
3355 "dry_run": false
3356 }),
3357 move_context,
3358 )
3359 .await;
3360 assert!(!move_result.success);
3361 assert!(
3362 move_result
3363 .output
3364 .contains("overwrite_existing policy is false")
3365 );
3366 assert!(move_source.exists());
3367 assert_eq!(fs::read_to_string(&move_destination).unwrap(), "old move");
3368 }
3369
3370 #[tokio::test]
3371 async fn delete_path_recursively_removes_directory_and_reports_effect() {
3372 let dir = tempdir().unwrap();
3373 let path = dir.path().join("tree");
3374 fs::create_dir_all(path.join("nested")).unwrap();
3375 fs::write(path.join("one.txt"), "one").unwrap();
3376 fs::write(path.join("nested/two.txt"), "two").unwrap();
3377 let result = DeletePathTool::new()
3378 .execute(
3379 serde_json::json!({
3380 "path": path.to_string_lossy(),
3381 "recursive": true,
3382 "dry_run": false
3383 }),
3384 mutation_context("delete_path", dir.path()),
3385 )
3386 .await;
3387
3388 assert!(result.success, "{}", result.output);
3389 assert!(!path.exists());
3390 let output = result_json(&result);
3391 assert_eq!(output["dry_run"], false);
3392 assert_eq!(output["mutation_performed"], true);
3393 assert_eq!(output["deleted"], true);
3394 assert_eq!(output["recursive"], true);
3395 assert_eq!(output["bytes_affected"], 6);
3396 assert_eq!(output["approval_required"], false);
3397 }
3398
3399 #[tokio::test]
3400 async fn file_write_allows_creation_beneath_missing_absolute_root() {
3401 let dir = tempdir().unwrap();
3402 let root = dir.path().join("missing-root");
3403 let path = root.join("nested/file.txt");
3404 let result = FileWriteTool::new()
3405 .execute(
3406 serde_json::json!({
3407 "path": path.to_string_lossy(),
3408 "content": "created",
3409 "create_parent_dirs": true,
3410 "dry_run": false
3411 }),
3412 mutation_context("file_write", &root),
3413 )
3414 .await;
3415
3416 assert!(result.success, "{}", result.output);
3417 assert_eq!(fs::read_to_string(path).unwrap(), "created");
3418 }
3419
3420 #[cfg(unix)]
3421 #[tokio::test]
3422 async fn file_write_rejects_dangling_allowed_root() {
3423 use std::os::unix::fs::symlink;
3424
3425 let dir = tempdir().unwrap();
3426 let missing_target = dir.path().join("missing-target");
3427 let root = dir.path().join("dangling-root");
3428 symlink(&missing_target, &root).unwrap();
3429 let path = root.join("file.txt");
3430 let result = FileWriteTool::new()
3431 .execute(
3432 serde_json::json!({
3433 "path": path.to_string_lossy(),
3434 "content": "blocked",
3435 "create_parent_dirs": true,
3436 "dry_run": false
3437 }),
3438 mutation_context("file_write", &root),
3439 )
3440 .await;
3441
3442 assert!(!result.success);
3443 assert!(result.output.contains("Path policy resolution failed"));
3444 assert!(!missing_target.exists());
3445 }
3446
3447 #[cfg(unix)]
3448 #[tokio::test]
3449 async fn copy_path_rejects_blocked_source_through_symlink_alias() {
3450 use std::os::unix::fs::symlink;
3451
3452 let dir = tempdir().unwrap();
3453 let root = dir.path().join("root");
3454 let blocked = dir.path().join("blocked");
3455 fs::create_dir(&root).unwrap();
3456 fs::create_dir(&blocked).unwrap();
3457 fs::write(blocked.join("source.txt"), "source").unwrap();
3458 symlink(&blocked, root.join("alias")).unwrap();
3459 let destination = root.join("destination.txt");
3460 let mut context = mutation_context("copy_path", &root);
3461 context.policy_snapshot["blocked_paths"] = serde_json::json!([blocked.to_string_lossy()]);
3462
3463 let result = CopyPathTool::new()
3464 .execute(
3465 serde_json::json!({
3466 "source_path": root.join("alias/source.txt").to_string_lossy(),
3467 "destination_path": destination.to_string_lossy(),
3468 "dry_run": false
3469 }),
3470 context,
3471 )
3472 .await;
3473
3474 assert!(!result.success);
3475 assert!(result.output.contains("source path is blocked"));
3476 assert!(!destination.exists());
3477 }
3478
3479 #[tokio::test]
3480 async fn mutation_tools_reject_paths_outside_write_root() {
3481 let dir = tempdir().unwrap();
3482 let root = dir.path().join("root");
3483 let outside = dir.path().join("outside");
3484 fs::create_dir_all(&root).unwrap();
3485 fs::create_dir_all(&outside).unwrap();
3486 let copy_source = root.join("copy-source.txt");
3487 let move_source = root.join("move-source.txt");
3488 let delete_target = outside.join("delete-target.txt");
3489 fs::write(©_source, "copy").unwrap();
3490 fs::write(&move_source, "move").unwrap();
3491 fs::write(&delete_target, "delete").unwrap();
3492
3493 let copy_result = CopyPathTool::new()
3494 .execute(
3495 serde_json::json!({
3496 "source_path": copy_source.to_string_lossy(),
3497 "destination_path": outside.join("copied.txt").to_string_lossy(),
3498 "dry_run": false
3499 }),
3500 mutation_context("copy_path", &root),
3501 )
3502 .await;
3503 assert!(!copy_result.success);
3504 assert!(copy_result.output.contains("allowed write root"));
3505 assert!(!outside.join("copied.txt").exists());
3506
3507 let move_result = MovePathTool::new()
3508 .execute(
3509 serde_json::json!({
3510 "source_path": move_source.to_string_lossy(),
3511 "destination_path": outside.join("moved.txt").to_string_lossy(),
3512 "dry_run": false
3513 }),
3514 mutation_context("move_path", &root),
3515 )
3516 .await;
3517 assert!(!move_result.success);
3518 assert!(move_result.output.contains("allowed write root"));
3519 assert!(move_source.exists());
3520 assert!(!outside.join("moved.txt").exists());
3521
3522 let delete_result = DeletePathTool::new()
3523 .execute(
3524 serde_json::json!({
3525 "path": delete_target.to_string_lossy(),
3526 "dry_run": false
3527 }),
3528 mutation_context("delete_path", &root),
3529 )
3530 .await;
3531 assert!(!delete_result.success);
3532 assert!(delete_result.output.contains("allowed write root"));
3533 assert_eq!(fs::read_to_string(delete_target).unwrap(), "delete");
3534 }
3535
3536 #[cfg(unix)]
3537 #[tokio::test]
3538 async fn mutation_tools_reject_symlink_write_escape() {
3539 use std::os::unix::fs::symlink;
3540
3541 let dir = tempdir().unwrap();
3542 let root = dir.path().join("root");
3543 let outside = dir.path().join("outside");
3544 fs::create_dir_all(&root).unwrap();
3545 fs::create_dir_all(&outside).unwrap();
3546 symlink(&outside, root.join("escape")).unwrap();
3547 let copy_source = root.join("copy-source.txt");
3548 let move_source = root.join("move-source.txt");
3549 let delete_target = outside.join("delete-target.txt");
3550 fs::write(©_source, "copy").unwrap();
3551 fs::write(&move_source, "move").unwrap();
3552 fs::write(&delete_target, "delete").unwrap();
3553
3554 let copy_result = CopyPathTool::new()
3555 .execute(
3556 serde_json::json!({
3557 "source_path": copy_source.to_string_lossy(),
3558 "destination_path": root.join("escape/copied.txt").to_string_lossy(),
3559 "dry_run": false
3560 }),
3561 mutation_context("copy_path", &root),
3562 )
3563 .await;
3564 assert!(!copy_result.success);
3565 assert!(!outside.join("copied.txt").exists());
3566
3567 let move_result = MovePathTool::new()
3568 .execute(
3569 serde_json::json!({
3570 "source_path": move_source.to_string_lossy(),
3571 "destination_path": root.join("escape/moved.txt").to_string_lossy(),
3572 "dry_run": false
3573 }),
3574 mutation_context("move_path", &root),
3575 )
3576 .await;
3577 assert!(!move_result.success);
3578 assert!(move_source.exists());
3579 assert!(!outside.join("moved.txt").exists());
3580
3581 let delete_result = DeletePathTool::new()
3582 .execute(
3583 serde_json::json!({
3584 "path": root.join("escape/delete-target.txt").to_string_lossy(),
3585 "dry_run": false
3586 }),
3587 mutation_context("delete_path", &root),
3588 )
3589 .await;
3590 assert!(!delete_result.success);
3591 assert_eq!(fs::read_to_string(delete_target).unwrap(), "delete");
3592 }
3593
3594 #[cfg(unix)]
3595 #[tokio::test]
3596 async fn copy_path_rejects_broken_destination_symlink_escape() {
3597 use std::os::unix::fs::symlink;
3598
3599 let dir = tempdir().unwrap();
3600 let root = dir.path().join("root");
3601 let outside = dir.path().join("outside");
3602 fs::create_dir_all(&root).unwrap();
3603 fs::create_dir_all(&outside).unwrap();
3604 let source = root.join("source.txt");
3605 let escaped = outside.join("escaped.txt");
3606 let destination = root.join("destination.txt");
3607 fs::write(&source, "source").unwrap();
3608 symlink(&escaped, &destination).unwrap();
3609
3610 let result = CopyPathTool::new()
3611 .execute(
3612 serde_json::json!({
3613 "source_path": source.to_string_lossy(),
3614 "destination_path": destination.to_string_lossy(),
3615 "overwrite": true,
3616 "dry_run": false
3617 }),
3618 mutation_context("copy_path", &root),
3619 )
3620 .await;
3621
3622 assert!(!result.success);
3623 assert!(!escaped.exists());
3624 assert!(
3625 fs::symlink_metadata(destination)
3626 .unwrap()
3627 .file_type()
3628 .is_symlink()
3629 );
3630 }
3631
3632 #[tokio::test]
3633 async fn delete_path_rejects_configured_write_root() {
3634 let dir = tempdir().unwrap();
3635 let root = dir.path().join("root");
3636 fs::create_dir_all(&root).unwrap();
3637 fs::write(root.join("keep.txt"), "keep").unwrap();
3638 let result = DeletePathTool::new()
3639 .execute(
3640 serde_json::json!({
3641 "path": root.to_string_lossy(),
3642 "recursive": true,
3643 "dry_run": false
3644 }),
3645 mutation_context("delete_path", &root),
3646 )
3647 .await;
3648
3649 assert!(!result.success);
3650 assert!(result.output.contains("write root"));
3651 assert_eq!(fs::read_to_string(root.join("keep.txt")).unwrap(), "keep");
3652 }
3653
3654 #[test]
3655 fn replacement_leaves_destination_unchanged_when_backup_rename_fails() {
3656 let dir = tempdir().unwrap();
3657 let prepared = dir.path().join("prepared.txt");
3658 let destination = dir.path().join("missing.txt");
3659 fs::write(&prepared, "new").unwrap();
3660
3661 let outcome = replace_existing_path(&prepared, &destination);
3662
3663 assert!(matches!(outcome, ReplacementOutcome::Unchanged(_)));
3664 assert_eq!(fs::read_to_string(prepared).unwrap(), "new");
3665 assert!(!destination.exists());
3666 }
3667
3668 #[test]
3669 fn replacement_restores_destination_when_commit_rename_fails() {
3670 let dir = tempdir().unwrap();
3671 let prepared = dir.path().join("missing-prepared.txt");
3672 let destination = dir.path().join("destination.txt");
3673 fs::write(&destination, "old").unwrap();
3674
3675 let outcome = replace_existing_path(&prepared, &destination);
3676
3677 assert!(matches!(outcome, ReplacementOutcome::Unchanged(_)));
3678 assert_eq!(fs::read_to_string(destination).unwrap(), "old");
3679 }
3680
3681 #[test]
3682 fn replacement_reports_committed_mutation_when_cleanup_fails() {
3683 let dir = tempdir().unwrap();
3684 let prepared = dir.path().join("prepared.txt");
3685 let destination = dir.path().join("destination.txt");
3686 fs::write(&prepared, "new").unwrap();
3687 fs::write(&destination, "old").unwrap();
3688
3689 let outcome = replace_existing_path_with(&prepared, &destination, |_| {
3690 Err(std::io::Error::other("injected cleanup failure"))
3691 });
3692
3693 let ReplacementOutcome::Committed {
3694 cleanup_warning,
3695 retained_backup_path,
3696 } = outcome
3697 else {
3698 panic!("replacement must remain committed");
3699 };
3700 assert_eq!(fs::read_to_string(destination).unwrap(), "new");
3701 assert!(cleanup_warning.unwrap().contains("cleanup failed"));
3702 let backup = PathBuf::from(retained_backup_path.unwrap());
3703 assert_eq!(fs::read_to_string(backup).unwrap(), "old");
3704 }
3705}