返回 DeepSeek-Reasonix
preview.go
根目录 / internal / tool / builtin / preview.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8
9 "reasonix/internal/diff"
10 )
11
12 // preview.go gives the file-writing built-ins the optional tool.Previewer
13 // capability: compute the change a call would make, reading the current file
14 // but never writing. A front-end (e.g. a desktop approval card) calls Preview
15 // before the permission gate runs Execute, so every Preview confines its path
16 // with confinePreview first: the read must be as bounded as the write.
17 //
18 // Each Preview mirrors its Execute's transformation exactly — same arg parsing,
19 // same uniqueness / not-found rules — so the previewed NewText equals what
20 // Execute would persist (asserted by TestPreviewMatchesExecute in
21 // preview_test.go; drift fails the test rather than the preview lying).
22
23 // Preview computes the change write_file would make. A path that does not yet
24 // exist is a Create; an existing one is a Modify.
25 func (w writeFile) Preview(ctx context.Context, args json.RawMessage) (diff.Change, error) {
26 var p struct {
27 Path string `json:"path"`
28 Content string `json:"content"`
29 }
30 if err := json.Unmarshal(args, &p); err != nil {
31 return diff.Change{}, fmt.Errorf("invalid args: %w", err)
32 }
33 if p.Path == "" {
34 return diff.Change{}, fmt.Errorf("path is required")
35 }
36 p.Path = resolveIn(w.workDir, p.Path)
37 if err := confinePreview(effectiveWriteRoots(ctx, w.rootSet, w.roots), w.guard, w.managed, p.Path); err != nil {
38 return diff.Change{}, err
39 }
40
41 old, kind := "", diff.Create
42 if src, err := readEditSource(ctx, w.overlay, p.Path); err == nil {
43 old, kind = src.content, diff.Modify
44 } else if !os.IsNotExist(err) {
45 return diff.Change{}, fmt.Errorf("read %s: %w", p.Path, err)
46 }
47 return diff.Build(p.Path, old, p.Content, kind), nil
48 }
49
50 // Preview computes the change edit_file would make. It enforces the same
51 // "old_string must occur exactly once" rule as Execute, returning that error
52 // when it doesn't — so a preview never shows a change the call couldn't make.
53 func (e editFile) Preview(ctx context.Context, args json.RawMessage) (diff.Change, error) {
54 var p struct {
55 Path string `json:"path"`
56 OldString string `json:"old_string"`
57 NewString string `json:"new_string"`
58 }
59 if err := json.Unmarshal(args, &p); err != nil {
60 return diff.Change{}, fmt.Errorf("invalid args: %w", err)
61 }
62 if p.Path == "" {
63 return diff.Change{}, fmt.Errorf("path is required")
64 }
65 if p.OldString == "" {
66 return diff.Change{}, fmt.Errorf("old_string is required")
67 }
68 p.Path = resolveIn(e.workDir, p.Path)
69 if err := confinePreview(effectiveWriteRoots(ctx, e.rootSet, e.roots), e.guard, e.managed, p.Path); err != nil {
70 return diff.Change{}, err
71 }
72
73 src, err := readEditSource(ctx, e.overlay, p.Path)
74 if err != nil {
75 return diff.Change{}, fmt.Errorf("read %s: %w", p.Path, err)
76 }
77 content := src.content
78
79 applied := applyOldStringEdit(content, p.OldString, p.NewString, false)
80 switch {
81 case applied.applied == 1:
82 // ok
83 case applied.matches == 0:
84 return diff.Change{}, oldStringNotFoundError(p.Path, p.OldString, content)
85 default:
86 return diff.Change{}, oldStringNotUniqueError(p.Path, p.OldString, content, applied.matches, false)
87 }
88
89 return diff.Build(p.Path, content, applied.updated, diff.Modify), nil
90 }
91
92 // Preview computes the change multi_edit would make by replaying every edit
93 // against an in-memory buffer — exactly as Execute does — and diffing the
94 // result against the original. Any edit error surfaces here too, so a preview
95 // of an invalid batch fails the same way the call would.
96 func (m multiEdit) Preview(ctx context.Context, args json.RawMessage) (diff.Change, error) {
97 var p struct {
98 Path string `json:"path"`
99 Edits []editStep `json:"edits"`
100 }
101 if err := json.Unmarshal(args, &p); err != nil {
102 return diff.Change{}, fmt.Errorf("invalid args: %w", err)
103 }
104 if p.Path == "" {
105 return diff.Change{}, fmt.Errorf("path is required")
106 }
107 if len(p.Edits) == 0 {
108 return diff.Change{}, fmt.Errorf("edits must not be empty")
109 }
110 p.Path = resolveIn(m.workDir, p.Path)
111 if err := confinePreview(effectiveWriteRoots(ctx, m.rootSet, m.roots), m.guard, m.managed, p.Path); err != nil {
112 return diff.Change{}, err
113 }
114
115 src, err := readEditSource(ctx, m.overlay, p.Path)
116 if err != nil {
117 return diff.Change{}, fmt.Errorf("read %s: %w", p.Path, err)
118 }
119 content := src.content
120 original := content
121
122 for i, step := range p.Edits {
123 if step.OldString == "" {
124 return diff.Change{}, fmt.Errorf("edit %d: old_string is required", i+1)
125 }
126 result := applyOldStringEdit(content, step.OldString, step.NewString, step.ReplaceAll)
127 switch {
128 case result.applied > 0:
129 content = result.updated
130 case result.matches == 0:
131 return diff.Change{}, fmt.Errorf("edit %d: %w", i+1, oldStringNotFoundError(p.Path, step.OldString, content))
132 default:
133 return diff.Change{}, fmt.Errorf("edit %d: %w", i+1, oldStringNotUniqueError(p.Path, step.OldString, content, result.matches, true))
134 }
135 }
136 return diff.Build(p.Path, original, content, diff.Modify), nil
137 }
138
138 lines GO