| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "strings" |
| 9 | |
| 10 | udiff "github.com/aymanbagabas/go-udiff" |
| 11 | "reasonix/internal/diff" |
| 12 | "reasonix/internal/readcoord" |
| 13 | "reasonix/internal/tool" |
| 14 | ) |
| 15 | |
| 16 | // Evidence is resolved by the same preview implementation that validates the |
| 17 | // writer's matches and builds its final edit. Multiple edits are compared to |
| 18 | // the original source, so text created by an earlier step needs no prior read. |
| 19 | func previewEvidence(change diff.Change, err error) (tool.EvidenceTargetInfo, error) { |
| 20 | if err != nil { |
| 21 | return tool.EvidenceTargetInfo{}, err |
| 22 | } |
| 23 | info := tool.EvidenceTargetInfo{Path: change.Path, SourceTextDigest: digestText(change.OldText)} |
| 24 | if change.Kind == diff.Create || change.OldText == change.NewText { |
| 25 | return info, nil |
| 26 | } |
| 27 | if change.Binary { |
| 28 | info.WholeFile = true |
| 29 | return info, nil |
| 30 | } |
| 31 | lines := strings.Split(strings.TrimSuffix(strings.ReplaceAll(change.OldText, "\r\n", "\n"), "\n"), "\n") |
| 32 | if change.OldText == "" { |
| 33 | return info, nil |
| 34 | } |
| 35 | var ranges []tool.ReadRange |
| 36 | for _, edit := range udiff.Lines(change.OldText, change.NewText) { |
| 37 | start := strings.Count(change.OldText[:edit.Start], "\n") |
| 38 | end := strings.Count(change.OldText[:edit.End], "\n") |
| 39 | if edit.End > edit.Start && change.OldText[edit.End-1] != '\n' { |
| 40 | end++ |
| 41 | } |
| 42 | if start == end { |
| 43 | start = max(0, start-1) |
| 44 | end++ |
| 45 | } |
| 46 | ranges = append(ranges, tool.ReadRange{Start: min(start, len(lines)-1), End: min(end, len(lines))}) |
| 47 | } |
| 48 | info.Ranges = readcoord.Normalize(ranges) |
| 49 | for _, r := range info.Ranges { |
| 50 | for _, line := range lines[r.Start:r.End] { |
| 51 | info.Hashes = append(info.Hashes, digestText(line)) |
| 52 | } |
| 53 | } |
| 54 | return info, nil |
| 55 | } |
| 56 | |
| 57 | func (e editFile) DeclareEvidenceTarget(ctx context.Context, args json.RawMessage) (tool.EvidenceTargetInfo, error) { |
| 58 | change, err := e.Preview(ctx, args) |
| 59 | return versionedPreviewEvidence(ctx, e.overlay, change, err) |
| 60 | } |
| 61 | func (m multiEdit) DeclareEvidenceTarget(ctx context.Context, args json.RawMessage) (tool.EvidenceTargetInfo, error) { |
| 62 | change, err := m.Preview(ctx, args) |
| 63 | return versionedPreviewEvidence(ctx, m.overlay, change, err) |
| 64 | } |
| 65 | func (d deleteSymbol) DeclareEvidenceTarget(ctx context.Context, args json.RawMessage) (tool.EvidenceTargetInfo, error) { |
| 66 | change, err := d.Preview(ctx, args) |
| 67 | return versionedPreviewEvidence(ctx, d.overlay, change, err) |
| 68 | } |
| 69 | func (n notebookEdit) DeclareEvidenceTarget(ctx context.Context, args json.RawMessage) (tool.EvidenceTargetInfo, error) { |
| 70 | change, err := n.Preview(ctx, args) |
| 71 | return versionedPreviewEvidence(ctx, n.overlay, change, err) |
| 72 | } |
| 73 | |
| 74 | // Bind the preview to the same current source route as Execute. A change |
| 75 | // between preview and capture fails closed instead of attaching a new version |
| 76 | // to old hashes; Execute rechecks this identity once more before writing. |
| 77 | func versionedPreviewEvidence(ctx context.Context, overlay FileOverlay, change diff.Change, previewErr error) (tool.EvidenceTargetInfo, error) { |
| 78 | info, err := previewEvidence(change, previewErr) |
| 79 | if err != nil || change.Kind == diff.Create { |
| 80 | return info, err |
| 81 | } |
| 82 | src, err := readEditSource(ctx, overlay, change.Path) |
| 83 | if err != nil { |
| 84 | return tool.EvidenceTargetInfo{}, err |
| 85 | } |
| 86 | if src.content != change.OldText { |
| 87 | return tool.EvidenceTargetInfo{}, &tool.OperationError{Diagnostic: tool.OperationDiagnostic{Code: tool.WriteEvidenceStale, Path: change.Path, Recovery: "source changed during evidence preview; re-read then retry"}, Cause: ErrFileChanged} |
| 88 | } |
| 89 | if err := src.assertUnchanged(ctx, overlay, change.Path); err != nil { |
| 90 | return tool.EvidenceTargetInfo{}, err |
| 91 | } |
| 92 | info.Snapshot = src.readSnapshot(change.Path) |
| 93 | return info, nil |
| 94 | } |
| 95 | func (m moveFile) DeclareEvidenceTarget(ctx context.Context, args json.RawMessage) (tool.EvidenceTargetInfo, error) { |
| 96 | var p struct { |
| 97 | Source string `json:"source_path"` |
| 98 | Destination string `json:"destination_path"` |
| 99 | } |
| 100 | if err := json.Unmarshal(args, &p); err != nil { |
| 101 | return tool.EvidenceTargetInfo{}, err |
| 102 | } |
| 103 | if p.Source == "" || p.Destination == "" { |
| 104 | return tool.EvidenceTargetInfo{}, fmt.Errorf("source_path and destination_path are required") |
| 105 | } |
| 106 | source, dest := resolveIn(m.workDir, p.Source), resolveIn(m.workDir, p.Destination) |
| 107 | if source == dest { |
| 108 | return tool.EvidenceTargetInfo{}, nil |
| 109 | } |
| 110 | for _, path := range []string{source, dest} { |
| 111 | if err := confinePreview(effectiveWriteRoots(ctx, m.rootSet, m.roots), m.guard, m.managed, path); err != nil { |
| 112 | return tool.EvidenceTargetInfo{}, err |
| 113 | } |
| 114 | } |
| 115 | id, err := diskIdentity(source) |
| 116 | if err != nil { |
| 117 | return tool.EvidenceTargetInfo{}, err |
| 118 | } |
| 119 | if !id.existed { |
| 120 | return tool.EvidenceTargetInfo{}, &tool.OperationError{Diagnostic: tool.OperationDiagnostic{Code: tool.WriteTargetAbsent, Path: source, Recovery: "read the source at its current path"}, Cause: os.ErrNotExist} |
| 121 | } |
| 122 | return tool.EvidenceTargetInfo{Path: source, Snapshot: tool.SourceSnapshot(tool.ReadSourceDisk, source, fmt.Sprintf("raw-sha256:%x", id.sum)), PreservesContent: true}, nil |
| 123 | } |
| 124 |