返回 DeepSeek-Reasonix
movefile.go
根目录 / internal / tool / builtin / movefile.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "strings"
12
13 "reasonix/internal/tool"
14 )
15
16 func init() { tool.RegisterBuiltin(moveFile{}) }
17
18 var renameFile = os.Rename
19
20 // moveFile moves or renames one file. roots, when non-empty, confine both the
21 // source and destination to the workspace; guard rejects Reasonix session-data
22 // endpoints on either side (a move out of the store mutates it too); workDir
23 // resolves relative paths.
24 type moveFile struct {
25 roots []string
26 guard SessionDataGuard
27 managed ManagedConfigPaths
28 workDir string
29 }
30
31 func (moveFile) Name() string { return "move_file" }
32
33 func (moveFile) Description() string {
34 return "Move or rename a file from source_path to destination_path. Creates the destination parent directory as needed. Use instead of shell mv, Move-Item, or ren for file moves so workspace confinement and file-edit permissions apply."
35 }
36
37 func (moveFile) Schema() json.RawMessage {
38 return json.RawMessage(`{"type":"object","properties":{"source_path":{"type":"string","description":"Existing file path to move"},"destination_path":{"type":"string","description":"Destination file path; must not already exist"}},"required":["source_path","destination_path"]}`)
39 }
40
41 func (moveFile) ReadOnly() bool { return false }
42
43 func (m moveFile) Execute(ctx context.Context, args json.RawMessage) (string, error) {
44 var p struct {
45 SourcePath string `json:"source_path"`
46 DestinationPath string `json:"destination_path"`
47 }
48 if err := json.Unmarshal(args, &p); err != nil {
49 return "", fmt.Errorf("invalid args: %w", err)
50 }
51 if p.SourcePath == "" {
52 return "", fmt.Errorf("source_path is required")
53 }
54 if p.DestinationPath == "" {
55 return "", fmt.Errorf("destination_path is required")
56 }
57 src := resolveIn(m.workDir, p.SourcePath)
58 dst := resolveIn(m.workDir, p.DestinationPath)
59 if err := confineWrite(ctx, m.roots, m.guard, m.managed, src); err != nil {
60 return "", err
61 }
62 if err := confineWrite(ctx, m.roots, m.guard, m.managed, dst); err != nil {
63 return "", err
64 }
65 info, err := os.Stat(src)
66 if err != nil {
67 return "", fmt.Errorf("stat %s: %w", src, err)
68 }
69 if info.IsDir() {
70 return "", fmt.Errorf("%s is a directory; move_file only moves files", src)
71 }
72 if filepath.Clean(src) == filepath.Clean(dst) {
73 return fmt.Sprintf("%s is already at %s; no changes made", src, dst), nil
74 }
75 sameFileDestination := false
76 if dstInfo, err := os.Stat(dst); err == nil {
77 if !os.SameFile(info, dstInfo) {
78 return "", fmt.Errorf("destination %s already exists", dst)
79 }
80 sameFileDestination = true
81 } else if !os.IsNotExist(err) {
82 return "", fmt.Errorf("stat %s: %w", dst, err)
83 }
84 if dir := filepath.Dir(dst); dir != "" && dir != "." {
85 if err := os.MkdirAll(dir, 0o755); err != nil {
86 return "", fmt.Errorf("mkdir %s: %w", dir, err)
87 }
88 }
89 if err := renameFile(src, dst); err != nil {
90 if sameFileDestination {
91 if rerr := renameSameFileDestination(src, dst); rerr != nil {
92 return "", fmt.Errorf("move %s to %s: %w", src, dst, rerr)
93 }
94 return fmt.Sprintf("moved %s to %s", src, dst), nil
95 }
96 if isCrossDeviceMove(err) {
97 if cerr := copyRegularFileAndRemoveSource(src, dst, info); cerr != nil {
98 return "", fmt.Errorf("move %s to %s: %w", src, dst, cerr)
99 }
100 return fmt.Sprintf("moved %s to %s", src, dst), nil
101 }
102 return "", fmt.Errorf("move %s to %s: %w", src, dst, err)
103 }
104 return fmt.Sprintf("moved %s to %s", src, dst), nil
105 }
106
107 func renameSameFileDestination(src, dst string) error {
108 tmp, err := os.CreateTemp(filepath.Dir(src), ".reasonix-move-*")
109 if err != nil {
110 return err
111 }
112 tmpName := tmp.Name()
113 if err := tmp.Close(); err != nil {
114 _ = os.Remove(tmpName)
115 return err
116 }
117 if err := os.Remove(tmpName); err != nil {
118 return err
119 }
120
121 if err := renameFile(src, tmpName); err != nil {
122 return err
123 }
124 if err := os.Remove(dst); err != nil && !os.IsNotExist(err) {
125 if restoreErr := renameFile(tmpName, src); restoreErr != nil {
126 return fmt.Errorf("%w; restore %s: %v", err, src, restoreErr)
127 }
128 return err
129 }
130 if err := renameFile(tmpName, dst); err != nil {
131 if restoreErr := renameFile(tmpName, src); restoreErr != nil {
132 return fmt.Errorf("%w; restore %s: %v", err, src, restoreErr)
133 }
134 return err
135 }
136 return nil
137 }
138
139 func isCrossDeviceMove(err error) bool {
140 var linkErr *os.LinkError
141 if !errors.As(err, &linkErr) {
142 return false
143 }
144 msg := strings.ToLower(linkErr.Err.Error())
145 return strings.Contains(msg, "cross-device") ||
146 strings.Contains(msg, "different device") ||
147 strings.Contains(msg, "different disk") ||
148 strings.Contains(msg, "not same device")
149 }
150
151 func copyRegularFileAndRemoveSource(src, dst string, info os.FileInfo) error {
152 if !info.Mode().IsRegular() {
153 return fmt.Errorf("cross-filesystem fallback only supports regular files")
154 }
155 in, err := os.Open(src)
156 if err != nil {
157 return err
158 }
159 defer in.Close()
160
161 out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm())
162 if err != nil {
163 return err
164 }
165 removeDst := true
166 defer func() {
167 if removeDst {
168 _ = os.Remove(dst)
169 }
170 }()
171 if _, err := io.Copy(out, in); err != nil {
172 _ = out.Close()
173 return err
174 }
175 if err := out.Close(); err != nil {
176 return err
177 }
178 if err := in.Close(); err != nil {
179 return err
180 }
181 if err := os.Remove(src); err != nil {
182 return err
183 }
184 removeDst = false
185 return nil
186 }
187
187 lines GO