Class: Rubino::Tools::EditTool

Inherits:
Base
  • Object
show all
Defined in:
lib/rubino/tools/edit_tool.rb

Overview

Tool for performing exact string replacements in files. Replaces a specific old string with a new string - more precise than full file writes.

Instance Attribute Summary

Attributes inherited from Base

#cancel_token, #read_tracker, #stream_chunk, #stream_kind

Instance Method Summary collapse

Methods inherited from Base

#cancellation_requested?, #config_key, #emit_chunk, #risky?, #to_tool_definition, workspace_root, workspace_roots

Instance Method Details

#call(arguments) ⇒ Object



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/rubino/tools/edit_tool.rb', line 48

def call(arguments)
  file_path, old_string, new_string, replace_all = parse_args(arguments)

  # Input guards (#329a/b): reject an empty needle (a literal sub/gsub on
  # "" matches at every char boundary and would corrupt the file under
  # replace_all) and a no-op old==new (reporting "1 replacement" misleads
  # the model — multi_edit already rejects it, so match that).
  if (guard = guard_args(old_string, new_string))
    return guard
  end

  expanded = expand_workspace_path(file_path)
  # SECRET/credential edits (#446) are no longer HARD-refused here — they
  # are gated UPSTREAM by Security::ApprovalPolicy#decide (→ :ask): an
  # APPROVED edit of your .env actually applies, a denied/headless one
  # never reaches #call. The workspace sandbox below is unchanged.
  return workspace_violation_message(file_path) unless within_workspace?(expanded)

  return "Error: File not found: #{file_path}" unless File.exist?(expanded)

  if (gate = read_gate_error(expanded, file_path, verb: "edit"))
    return gate
  end

  # Read the RAW bytes (binary) for the read-modify-write so non-UTF-8
  # bytes on untouched lines are preserved verbatim on write (#326); the
  # model-supplied needle/replacement are matched/spliced as bytes too.
  content    = read_for_edit(expanded)
  old_bytes  = to_match_bytes(old_string)
  new_bytes  = to_match_bytes(new_string)

  unless content.include?(old_bytes)
    # The model's mental model of the file was wrong (hallucinated text).
    # Flag a recovery so its next read of this path bypasses dedup and
    # returns FRESH bytes instead of a stale "[DUPLICATE READ]" nudge
    # (r5 B3).
    @read_tracker&.note_edit_failure(expanded)
    return "Error: old_string not found in file content. " \
           "Make sure the text matches exactly including whitespace."
  end

  # Count occurrences
  count = content.scan(old_bytes).size
  if count > 1 && !replace_all
    return "Error: Found #{count} matches for old_string. " \
           "Provide more surrounding context to make it unique, " \
           "or set replace_all: true to replace all occurrences."
  end

  new_content = replace_literal(content, old_bytes, new_bytes, replace_all)
  # Crash-safe write: temp-in-same-dir + fsync + atomic rename, so a
  # SIGINT/crash mid-flush can't destroy the user's existing file content
  # (this is a read-modify-write of an existing file — HIGH-1).
  Util::AtomicFile.write_atomic(expanded, new_content)
  # Refresh-on-own-write: the bytes we just wrote are now authoritative,
  # so the very next edit to this file passes the read-gate instead of
  # "changed on disk since last read" (r5 B2).
  @read_tracker&.note_write(expanded, new_content)

  replaced_count = replace_all ? count : 1
  added   = new_string.to_s.lines.size
  removed = old_string.to_s.lines.size
  { output: "Edit applied: #{replaced_count} replacement(s) in #{file_path}",
    metrics: "#{replaced_count} replacement#{"s" if replaced_count != 1} · " \
             "+#{added * replaced_count}#{removed * replaced_count}",
    body: build_diff_preview(old_string, new_string, replaced_count),
    body_kind: :diff }
rescue StandardError => e
  # Mirror WriteTool: a read-only/permission-denied target (Errno::EACCES)
  # or any other filesystem error returns a clean, uniform message rather
  # than leaking a raw exception/backtrace to the model.
  "Error editing #{file_path}: #{e.message}"
end

#descriptionObject



12
13
14
15
16
17
# File 'lib/rubino/tools/edit_tool.rb', line 12

def description
  "Perform exact string replacement in a file. " \
    "Specify the old text to find and the new text to replace it with. " \
    "The old text must match exactly (including whitespace/indentation). " \
    "Use replace_all to replace all occurrences."
end

#input_schemaObject



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/rubino/tools/edit_tool.rb', line 19

def input_schema
  {
    type: "object",
    properties: {
      file_path: {
        type: "string",
        description: "The path to the file to edit"
      },
      old_string: {
        type: "string",
        description: "The exact text to find and replace"
      },
      new_string: {
        type: "string",
        description: "The text to replace it with"
      },
      replace_all: {
        type: "boolean",
        description: "Replace all occurrences (default: false, replaces first only)"
      }
    },
    required: %w[file_path old_string new_string]
  }
end

#nameObject



8
9
10
# File 'lib/rubino/tools/edit_tool.rb', line 8

def name
  "edit"
end

#risk_levelObject



44
45
46
# File 'lib/rubino/tools/edit_tool.rb', line 44

def risk_level
  :medium
end