1package tools
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"log/slog"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11	"time"
 12
 13	"github.com/charmbracelet/crush/internal/diff"
 14	"github.com/charmbracelet/crush/internal/history"
 15
 16	"github.com/charmbracelet/crush/internal/lsp"
 17	"github.com/charmbracelet/crush/internal/permission"
 18)
 19
 20type WriteParams struct {
 21	FilePath string `json:"file_path"`
 22	Content  string `json:"content"`
 23}
 24
 25type WritePermissionsParams struct {
 26	FilePath   string `json:"file_path"`
 27	OldContent string `json:"old_content,omitempty"`
 28	NewContent string `json:"new_content,omitempty"`
 29}
 30
 31type writeTool struct {
 32	lspClients  map[string]*lsp.Client
 33	permissions permission.Service
 34	files       history.Service
 35	workingDir  string
 36}
 37
 38type WriteResponseMetadata struct {
 39	Diff      string `json:"diff"`
 40	Additions int    `json:"additions"`
 41	Removals  int    `json:"removals"`
 42}
 43
 44const (
 45	WriteToolName    = "write"
 46	writeDescription = `File writing tool that creates or updates files in the filesystem, allowing you to save or modify text content.
 47
 48WHEN TO USE THIS TOOL:
 49- Use when you need to create a new file
 50- Helpful for updating existing files with modified content
 51- Perfect for saving generated code, configurations, or text data
 52
 53HOW TO USE:
 54- Provide the path to the file you want to write
 55- Include the content to be written to the file
 56- The tool will create any necessary parent directories
 57
 58FEATURES:
 59- Can create new files or overwrite existing ones
 60- Creates parent directories automatically if they don't exist
 61- Checks if the file has been modified since last read for safety
 62- Avoids unnecessary writes when content hasn't changed
 63
 64LIMITATIONS:
 65- You should read a file before writing to it to avoid conflicts
 66- Cannot append to files (rewrites the entire file)
 67
 68WINDOWS NOTES:
 69- File permissions (0o755, 0o644) are Unix-style but work on Windows with appropriate translations
 70- Use forward slashes (/) in paths for cross-platform compatibility
 71- Windows file attributes and permissions are handled automatically by the Go runtime
 72
 73TIPS:
 74- Use the View tool first to examine existing files before modifying them
 75- Use the LS tool to verify the correct location when creating new files
 76- Combine with Glob and Grep tools to find and modify multiple files
 77- Always include descriptive comments when making changes to existing code`
 78)
 79
 80func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service, workingDir string) BaseTool {
 81	return &writeTool{
 82		lspClients:  lspClients,
 83		permissions: permissions,
 84		files:       files,
 85		workingDir:  workingDir,
 86	}
 87}
 88
 89func (w *writeTool) Name() string {
 90	return WriteToolName
 91}
 92
 93func (w *writeTool) Info() ToolInfo {
 94	return ToolInfo{
 95		Name:        WriteToolName,
 96		Description: writeDescription,
 97		Parameters: map[string]any{
 98			"file_path": map[string]any{
 99				"type":        "string",
100				"description": "The path to the file to write",
101			},
102			"content": map[string]any{
103				"type":        "string",
104				"description": "The content to write to the file",
105			},
106		},
107		Required: []string{"file_path", "content"},
108	}
109}
110
111func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
112	var params WriteParams
113	if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
114		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
115	}
116
117	if params.FilePath == "" {
118		return NewTextErrorResponse("file_path is required"), nil
119	}
120
121	if params.Content == "" {
122		return NewTextErrorResponse("content is required"), nil
123	}
124
125	filePath := params.FilePath
126	if !filepath.IsAbs(filePath) {
127		filePath = filepath.Join(w.workingDir, filePath)
128	}
129
130	fileInfo, err := os.Stat(filePath)
131	if err == nil {
132		if fileInfo.IsDir() {
133			return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
134		}
135
136		modTime := fileInfo.ModTime()
137		lastRead := getLastReadTime(filePath)
138		if modTime.After(lastRead) {
139			return NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
140				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
141		}
142
143		oldContent, readErr := os.ReadFile(filePath)
144		if readErr == nil && string(oldContent) == params.Content {
145			return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
146		}
147	} else if !os.IsNotExist(err) {
148		return ToolResponse{}, fmt.Errorf("error checking file: %w", err)
149	}
150
151	dir := filepath.Dir(filePath)
152	if err = os.MkdirAll(dir, 0o755); err != nil {
153		return ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
154	}
155
156	oldContent := ""
157	if fileInfo != nil && !fileInfo.IsDir() {
158		oldBytes, readErr := os.ReadFile(filePath)
159		if readErr == nil {
160			oldContent = string(oldBytes)
161		}
162	}
163
164	sessionID, messageID := GetContextValues(ctx)
165	if sessionID == "" || messageID == "" {
166		return ToolResponse{}, fmt.Errorf("session_id and message_id are required")
167	}
168
169	diff, additions, removals := diff.GenerateDiff(
170		oldContent,
171		params.Content,
172		strings.TrimPrefix(filePath, w.workingDir),
173	)
174
175	rootDir := w.workingDir
176	permissionPath := filepath.Dir(filePath)
177	if strings.HasPrefix(filePath, rootDir) {
178		permissionPath = rootDir
179	}
180	p := w.permissions.Request(
181		permission.CreatePermissionRequest{
182			SessionID:   sessionID,
183			Path:        permissionPath,
184			ToolCallID:  call.ID,
185			ToolName:    WriteToolName,
186			Action:      "write",
187			Description: fmt.Sprintf("Create file %s", filePath),
188			Params: WritePermissionsParams{
189				FilePath:   filePath,
190				OldContent: oldContent,
191				NewContent: params.Content,
192			},
193		},
194	)
195	if !p {
196		return ToolResponse{}, permission.ErrorPermissionDenied
197	}
198
199	err = os.WriteFile(filePath, []byte(params.Content), 0o644)
200	if err != nil {
201		return ToolResponse{}, fmt.Errorf("error writing file: %w", err)
202	}
203
204	// Check if file exists in history
205	file, err := w.files.GetByPathAndSession(ctx, filePath, sessionID)
206	if err != nil {
207		_, err = w.files.Create(ctx, sessionID, filePath, oldContent)
208		if err != nil {
209			// Log error but don't fail the operation
210			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
211		}
212	}
213	if file.Content != oldContent {
214		// User Manually changed the content store an intermediate version
215		_, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent)
216		if err != nil {
217			slog.Debug("Error creating file history version", "error", err)
218		}
219	}
220	// Store the new version
221	_, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content)
222	if err != nil {
223		slog.Debug("Error creating file history version", "error", err)
224	}
225
226	recordFileWrite(filePath)
227	recordFileRead(filePath)
228	waitForLspDiagnostics(ctx, filePath, w.lspClients)
229
230	result := fmt.Sprintf("File successfully written: %s", filePath)
231	result = fmt.Sprintf("<result>\n%s\n</result>", result)
232	result += getDiagnostics(filePath, w.lspClients)
233	return WithResponseMetadata(NewTextResponse(result),
234		WriteResponseMetadata{
235			Diff:      diff,
236			Additions: additions,
237			Removals:  removals,
238		},
239	), nil
240}