package fileedit import ( "fmt" "strings" ) // Result is what a write or a replace reports — the fields the `edit` CLI // prints and the Pi tool returns: a headline, the readable diff, the unified // patch, the first changed line. Headline + Diff is what goes back to the // model; the caller decides what the screen shows. type Result struct { Path string Created bool Changed bool DryRun bool Edits int Added, Deleted int FirstChangedLine int Headline string Diff string Patch string } // Read returns the file, or a slice of it (1-based, inclusive; 0 = from the // start / to the end). It is the mandatory companion of Replace: an exact // replacement assumes the exact text was READ, not remembered. func Read(path string, start, end int, numbered bool) (string, error) { f, err := load(path) if err != nil { return "", fmt.Errorf("cannot read %s: %v", path, err) } if f.isNew { return "", fmt.Errorf("%s does not exist. Create it with write_file", path) } lines, finalNewline := splitLines(f.body) from, to, err := bounds(start, end, len(lines)) if err != nil { return "", fmt.Errorf("%s: %v", path, err) } slice := lines[from-1 : to] if numbered { var b strings.Builder for i, l := range slice { fmt.Fprintf(&b, "%6d %s\n", from+i, l) } return b.String(), nil } body := strings.Join(slice, "\n") // The final newline is only put back if it existed AND the end of the file // is shown: otherwise a slice would invent a line the file does not have. if finalNewline || to < len(lines) { body += "\n" } return body, nil } // bounds turns start/end into a valid slice, or says why it is not one. func bounds(start, end, total int) (int, int, error) { if total == 0 { return 1, 0, nil // empty file: nothing to show, not an error } if start == 0 { start = 1 } if end == 0 { end = total } if start < 1 || end < 1 { return 0, 0, fmt.Errorf("start and end are 1-based line numbers") } if start > total { return 0, 0, fmt.Errorf("start %d is past the end of the file (%d lines)", start, total) } if end > total { end = total } if start > end { return 0, 0, fmt.Errorf("start %d comes after end %d", start, end) } return start, end, nil } // Write writes the WHOLE file. The tool for a new file or a deliberate // rewrite; to change three lines of an existing file, Replace is safer — it // fails when the targeted text is not what one thought, where Write overwrites // without checking. func Write(path, content string) (Result, error) { f, err := load(path) if err != nil { return Result{}, fmt.Errorf("cannot read %s: %v", path, err) } // A content without a final newline would give a file without one: on a // text file that is almost always an accident, and every diff tool then // flags it. if content != "" && !strings.HasSuffix(content, "\n") { content += "\n" } return finish(Result{Path: path, Created: f.isNew}, f, content) } // Replace applies exact replacements to an existing file — the operation that // matters: it changes a file without rewriting it, and refuses anything // ambiguous. With dryRun the diff is computed and nothing is written. func Replace(path string, edits []Edit, dryRun bool) (Result, error) { f, err := load(path) if err != nil { return Result{}, fmt.Errorf("cannot read %s: %v", path, err) } if f.isNew { return Result{}, fmt.Errorf("%s does not exist — nothing to replace. Create it with write_file", path) } body, err := apply(f.body, edits) if err != nil { // The error carries the file name: it is read out of context, in a // tool result among others. return Result{}, fmt.Errorf("%s: %v", path, err) } return finish(Result{Path: path, Edits: len(edits), DryRun: dryRun}, f, body) } // finish compares, writes if needed, and reports. Write and Replace both end // here: that is what gives them exactly the same output, hence one format for // the model to learn. func finish(r Result, f *file, body string) (Result, error) { c := compare(f.body, body) r.Changed = c.changed() r.Added, r.Deleted = c.stat() r.FirstChangedLine = c.firstChanged() r.Diff = c.render() r.Patch = c.unified(r.Path) // Nothing to write: SAY so rather than touch the file. An identical // rewrite would bump the modification time and wake every watcher, for // nothing. if r.Changed && !r.DryRun { if err := f.write(body); err != nil { return Result{}, fmt.Errorf("cannot write %s: %v", r.Path, err) } } r.Headline = headline(r) return r, nil } // headline sums the operation up in one line — the one the model re-reads in // its tool result, and the one the screen shows under the display line. func headline(r Result) string { switch { case !r.Changed: return fmt.Sprintf("%s: unchanged", r.Path) case r.Created && r.DryRun: return fmt.Sprintf("%s: would be created, %d line(s)", r.Path, r.Added) case r.Created: return fmt.Sprintf("%s: created, %d line(s)", r.Path, r.Added) } what := fmt.Sprintf("+%d -%d, first change at line %d", r.Added, r.Deleted, r.FirstChangedLine) if r.Edits > 0 { what = fmt.Sprintf("%d edit(s) applied, %s", r.Edits, what) } if r.DryRun { return fmt.Sprintf("%s: dry run — nothing written (%s)", r.Path, what) } return fmt.Sprintf("%s: %s", r.Path, what) }