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