| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | |
| 9 | "reasonix/internal/fileops" |
| 10 | fileenc "reasonix/internal/fileutil/encoding" |
| 11 | "reasonix/internal/tool" |
| 12 | ) |
| 13 | |
| 14 | // editSource is the file state a read-modify-write tool works against, plus the |
| 15 | // route its write must take back. Read and write must stay paired: content read |
| 16 | // from the host's unsaved buffer has to return there, and content read from disk |
| 17 | // has to return to disk. Mixing the two silently drops one side's changes. |
| 18 | type editSource struct { |
| 19 | content string |
| 20 | enc fileenc.Kind |
| 21 | overlay bool |
| 22 | id fileIdentity |
| 23 | } |
| 24 | |
| 25 | func overlayObservationTarget(overlay FileOverlay, path string) fileops.Target { |
| 26 | if identified, ok := overlay.(FileOverlayIdentity); ok { |
| 27 | return fileops.OverlayTargetWithIdentity(path, identified.FileOverlayIdentity()) |
| 28 | } |
| 29 | return fileops.OverlayTarget(path) |
| 30 | } |
| 31 | |
| 32 | // readEditSource resolves path the way Execute and Preview must both see it: |
| 33 | // the host's unsaved editor buffer when the overlay can serve it, otherwise the |
| 34 | // decoded disk content. A non-UTF-8 file always stays on the disk route — the |
| 35 | // overlay contract is text-only, so routing GBK or UTF-16 through it would |
| 36 | // rewrite the file as UTF-8. |
| 37 | func readEditSource(ctx context.Context, overlay FileOverlay, path string) (source editSource, readErr error) { |
| 38 | data, id, err := readDiskIdentity(path) |
| 39 | if err != nil { |
| 40 | return editSource{}, err |
| 41 | } |
| 42 | if !id.existed { |
| 43 | if overlay != nil && filepath.IsAbs(path) { |
| 44 | if buffered, ok := overlay.ReadTextFile(ctx, path); ok { |
| 45 | return editSource{content: buffered, enc: fileenc.UTF8, overlay: true, id: overlayIdentity(buffered)}, nil |
| 46 | } |
| 47 | } |
| 48 | return editSource{enc: fileenc.UTF8, id: id}, &os.PathError{Op: "read", Path: path, Err: os.ErrNotExist} |
| 49 | } |
| 50 | enc, _ := fileenc.Detect(data) |
| 51 | content := string(fileenc.Decode(data, enc)) |
| 52 | if overlay != nil && enc == fileenc.UTF8 && filepath.IsAbs(path) { |
| 53 | if buffered, ok := overlay.ReadTextFile(ctx, path); ok { |
| 54 | return editSource{content: buffered, enc: enc, overlay: true, id: overlayIdentity(buffered)}, nil |
| 55 | } |
| 56 | } |
| 57 | return editSource{content: content, enc: enc, id: id}, nil |
| 58 | } |
| 59 | |
| 60 | // lockMutationPath serializes all structured mutations of one real target in |
| 61 | // this process. Existing hard-link aliases share the native file identity. |
| 62 | func lockMutationPath(path string) func() { |
| 63 | info, _ := os.Stat(path) |
| 64 | target := fileops.DiskTarget(path, info) |
| 65 | target.Route = "mutation" |
| 66 | return fileops.Lock(target) |
| 67 | } |
| 68 | |
| 69 | func (s editSource) observation(overlay FileOverlay, path string) (fileops.Target, fileops.Version, error) { |
| 70 | if s.overlay { |
| 71 | return overlayObservationTarget(overlay, path), fileops.OverlayVersion(s.content), nil |
| 72 | } |
| 73 | return s.id.target, s.id.version, nil |
| 74 | } |
| 75 | |
| 76 | func (s editSource) requireObserved(ctx context.Context, overlay FileOverlay, path string) error { |
| 77 | store := fileops.FromContext(ctx) |
| 78 | if store == nil { // Direct package-level tool calls remain usable in tests and embeddings. |
| 79 | return nil |
| 80 | } |
| 81 | target, version, err := s.observation(overlay, path) |
| 82 | if err != nil { |
| 83 | return &tool.OperationError{Diagnostic: tool.OperationDiagnostic{Code: tool.FSNotFound, Path: path, Recovery: "read the file, then retry the edit"}, Cause: err} |
| 84 | } |
| 85 | observed := store.Get(target) |
| 86 | if observed.Kind != fileops.Present { |
| 87 | return &tool.OperationError{Diagnostic: tool.OperationDiagnostic{Code: tool.FSNotObserved, Path: path, Recovery: "read any current window of this file, then retry"}, Cause: fmt.Errorf("file has not been observed in this agent session")} |
| 88 | } |
| 89 | if observed.Version != version { |
| 90 | return &tool.OperationError{Diagnostic: tool.OperationDiagnostic{Code: tool.FSStaleVersion, Path: path, ExpectedSnapshot: string(observed.Version), ActualSnapshot: string(version), Recovery: "the file changed after it was read; read it again, then retry"}, Cause: ErrFileChanged} |
| 91 | } |
| 92 | return nil |
| 93 | } |
| 94 | |
| 95 | func (s editSource) commitObservation(ctx context.Context, overlay FileOverlay, path, content string) { |
| 96 | store := fileops.FromContext(ctx) |
| 97 | if store == nil { |
| 98 | return |
| 99 | } |
| 100 | if s.overlay { |
| 101 | store.ObservePresent(overlayObservationTarget(overlay, path), fileops.OverlayVersion(content)) |
| 102 | return |
| 103 | } |
| 104 | if info, err := os.Stat(path); err == nil { |
| 105 | target, version := fileops.DiskSnapshot(path, info) |
| 106 | store.ObservePresent(target, version) |
| 107 | } else { |
| 108 | store.Forget(fileops.DiskTarget(path, nil)) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | func (s editSource) readSnapshot(path string) string { |
| 113 | kind, prefix := tool.ReadSourceDisk, "raw-sha256:" |
| 114 | if s.overlay { |
| 115 | kind, prefix = tool.ReadSourceOverlay, "overlay:" |
| 116 | } |
| 117 | return tool.SourceSnapshot(kind, path, fmt.Sprintf("%s%x", prefix, s.id.sum)) |
| 118 | } |
| 119 | |
| 120 | // write persists content on the same route the source was read from. An overlay |
| 121 | // that declines a managed write leaves its outcome unknown. It never falls |
| 122 | // back to disk because that would update a different source than the one read. |
| 123 | func (s editSource) write(ctx context.Context, overlay FileOverlay, path, content string) error { |
| 124 | if err := s.assertUnchanged(ctx, overlay, path); err != nil { |
| 125 | return err |
| 126 | } |
| 127 | if s.overlay && overlay != nil { |
| 128 | if err := s.recordWrite(ctx, path, content, "overlay", overlay); err != nil { |
| 129 | return err |
| 130 | } |
| 131 | if err := s.assertUnchanged(ctx, overlay, path); err != nil { |
| 132 | return err |
| 133 | } |
| 134 | if ok, err := overlay.WriteTextFile(ctx, path, content); ok { |
| 135 | if err != nil { |
| 136 | fileops.FromContext(ctx).Forget(overlayObservationTarget(overlay, path)) |
| 137 | return fmt.Errorf("write outcome unknown: %w", err) |
| 138 | } |
| 139 | if err == nil { |
| 140 | s.commitObservation(ctx, overlay, path, content) |
| 141 | } |
| 142 | return err |
| 143 | } |
| 144 | fileops.FromContext(ctx).Forget(overlayObservationTarget(overlay, path)) |
| 145 | return fmt.Errorf("write outcome unknown: original overlay did not confirm the write") |
| 146 | } |
| 147 | if err := s.recordWrite(ctx, path, content, "disk", overlay); err != nil { |
| 148 | return err |
| 149 | } |
| 150 | if err := s.assertUnchanged(ctx, overlay, path); err != nil { |
| 151 | return err |
| 152 | } |
| 153 | if err := writeFileEncoded(path, content, s.enc); err != nil { |
| 154 | return err |
| 155 | } |
| 156 | s.commitObservation(ctx, overlay, path, content) |
| 157 | return nil |
| 158 | } |
| 159 |