Files
pad/internal/cli/builtin_format.go
T
xarmian 99aac1687b feat(cli): pad library diff and pad library update for built-in conventions and playbooks (TASK-3462 U3c) (#1875)
* test: pad library diff/update and the builtin_update capability (TASK-3462 U3c, red)

* feat(cli): pad library diff and pad library update for built-in conventions and playbooks (TASK-3462 U3c)

- diff shows what Pad's library changed since the item was made, what the
  user changed (or a plain comparison when that is unknown), and the
  settings an update would replace.
- update takes the library's text, guarded by the seq read just before. It
  refuses content_pending_flush unless --overwrite-pending-edits, explains
  update_conflict, and reports a current item without sending.
- The server advertises builtin_update; both commands refuse without it,
  since an older server's bare 404 reads like not_builtin.
Not on MCP, per the design.

* test: pad library diff/update codex r1 findings (TASK-3462 U3c, red)

* fix(cli): pad library diff/update codex r1 findings (TASK-3462 U3c)

- The JSON diff carries the current text from a server that does not send it.
- A no-op under --format json is a JSON result.
- A failed capabilities probe says so; it is no longer reported as an older
  server.
- An update prints the write's warnings: applied to an open editor, and
  pending edits pruned.
- content_pending_flush is rendered by the shared helper, which names
  set-aside rows apart from an open editor's; the flag's help covers both.
- The diff names an end-of-file newline change.

* test(cli): built-in diff keeps large numbers and refuses a mixed-version fallback (TASK-3462 U3c, codex r2, red)

* fix(cli): built-in diff keeps numbers as written and refuses a fallback from another version (TASK-3462 U3c, codex r2)

BuiltinText decodes its fields keeping numbers, and the fallback read of the
item does the same, with status and nulls dropped to match the server's
current. A fallback whose seq differs from the state's is refused instead of
mixed in.

* test(cli): built-in update reports undeclared fields (TASK-3462 U3c, codex r5, red)

* fix(cli): built-in update reports undeclared fields like any item update (TASK-3462 U3c, codex r5)
2026-10-08 05:05:04 -04:00

148 lines
3.6 KiB
Go

package cli
import (
"encoding/json"
"sort"
"strings"
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/PerpetualSoftware/pad/internal/models"
)
// FormatLineDiff renders old -> new as lines prefixed "-", "+" or " ",
// keeping at most context unchanged lines around each change and eliding the
// rest with "…" (TASK-3462 U3c). Empty when the two are equal.
func FormatLineDiff(oldText, newText string, context int) string {
if oldText == newText {
return ""
}
// A change to the final newline alone renders as two identical-looking
// lines, so it is named (codex r1), as diff does.
oldNL, newNL := strings.HasSuffix(oldText, "\n"), strings.HasSuffix(newText, "\n")
dmp := diffmatchpatch.New()
a, b, lines := dmp.DiffLinesToChars(oldText, newText)
diffs := dmp.DiffCharsToLines(dmp.DiffMain(a, b, false), lines)
type line struct {
op diffmatchpatch.Operation
text string
}
var all []line
for _, d := range diffs {
text := strings.TrimSuffix(d.Text, "\n")
for _, l := range strings.Split(text, "\n") {
all = append(all, line{d.Type, l})
}
}
keep := make([]bool, len(all))
for i, l := range all {
if l.op == diffmatchpatch.DiffEqual {
continue
}
for j := i - context; j <= i+context; j++ {
if j >= 0 && j < len(all) {
keep[j] = true
}
}
}
var out strings.Builder
elided := false
for i, l := range all {
if !keep[i] {
if !elided {
out.WriteString(" …\n")
elided = true
}
continue
}
elided = false
switch l.op {
case diffmatchpatch.DiffInsert:
out.WriteString("+")
case diffmatchpatch.DiffDelete:
out.WriteString("-")
default:
out.WriteString(" ")
}
out.WriteString(l.text)
out.WriteString("\n")
}
if oldNL != newNL {
side := "new"
if !oldNL {
side = "old"
}
out.WriteString("\\ No newline at end of file (" + side + ")\n")
}
return out.String()
}
// BuiltinFieldChange is one setting an accepted update would replace.
type BuiltinFieldChange struct {
Key string
Current any
Library any
HasCur bool
HasLib bool
}
// BuiltinFieldChanges lists the settings taking the library's text would
// replace: every key the library writes whose value differs from the item's,
// and every key the seed had that the library dropped (the update removes
// those). status is never touched by an update, so it is never listed. Values
// compare as canonical JSON, so number spelling and key order do not count.
func BuiltinFieldChanges(current, library, seed map[string]any) []BuiltinFieldChange {
keys := map[string]bool{}
for k := range library {
keys[k] = true
}
for k := range seed {
if _, kept := library[k]; !kept {
keys[k] = true
}
}
delete(keys, "status")
sorted := make([]string, 0, len(keys))
for k := range keys {
sorted = append(sorted, k)
}
sort.Strings(sorted)
canon := func(v any) string {
b, err := json.Marshal(models.CanonicalJSONNumbers(v))
if err != nil {
return ""
}
return string(b)
}
var out []BuiltinFieldChange
for _, k := range sorted {
cur, hasCur := current[k]
lib, hasLib := library[k]
if !hasCur && !hasLib {
continue
}
if hasCur && hasLib && canon(cur) == canon(lib) {
continue
}
out = append(out, BuiltinFieldChange{Key: k, Current: cur, Library: lib, HasCur: hasCur, HasLib: hasLib})
}
return out
}
// ShowFieldValue renders a setting's value for a terminal: a string as
// itself, anything else as JSON.
func ShowFieldValue(v any, present bool) string {
if !present {
return "(none)"
}
if s, ok := v.(string); ok {
return s
}
b, err := json.Marshal(v)
if err != nil {
return "?"
}
return string(b)
}