Skip to content

Commit bbfb68e

Browse files
authored
Merge pull request #6 from AxeForging/feature/preserve-formatting-edits
Add --preserve for surgical, formatting-preserving json/yaml edits
2 parents 427c5a1 + b557b4f commit bbfb68e

6 files changed

Lines changed: 1535 additions & 1 deletion

File tree

actions/json.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"io"
77
"os"
8+
"path/filepath"
89

910
"github.com/AxeForging/pipekit/services"
1011

@@ -82,6 +83,7 @@ func dataSetCmd(def services.DataFormat) cli.Command {
8283
cli.StringFlag{Name: "json-value, j", Usage: "JSON-encoded value (object/array/number/bool)"},
8384
cli.BoolFlag{Name: "in-place, i", Usage: "write back to the file (default: stdout)"},
8485
cli.BoolFlag{Name: "pretty", Usage: "pretty-print output"},
86+
cli.BoolFlag{Name: "preserve, P", Usage: "surgical edit: keep comments/formatting, change only the target (yaml, json)"},
8587
},
8688
Action: func(c *cli.Context) error {
8789
file, err := firstArgOrErr(c, "FILE")
@@ -101,6 +103,12 @@ func dataSetCmd(def services.DataFormat) cli.Command {
101103
newVal = c.String("value")
102104
}
103105

106+
if c.Bool("preserve") {
107+
return writePreserved(c, file, def, c.Bool("in-place"), func(data []byte, format services.DataFormat) ([]byte, error) {
108+
return services.SetPreserving(data, format, path, newVal)
109+
})
110+
}
111+
104112
doc, _, err := loadFileWithDefault(file, def)
105113
if err != nil {
106114
return err
@@ -123,6 +131,7 @@ func dataDelCmd(def services.DataFormat) cli.Command {
123131
cli.StringFlag{Name: "path, p", Usage: "path to delete"},
124132
cli.BoolFlag{Name: "in-place, i"},
125133
cli.BoolFlag{Name: "pretty"},
134+
cli.BoolFlag{Name: "preserve, P", Usage: "surgical edit: keep comments/formatting, remove only the target (yaml, json)"},
126135
},
127136
Action: func(c *cli.Context) error {
128137
file, err := firstArgOrErr(c, "FILE")
@@ -133,6 +142,13 @@ func dataDelCmd(def services.DataFormat) cli.Command {
133142
if path == "" {
134143
return cli.NewExitError("--path required", 1)
135144
}
145+
146+
if c.Bool("preserve") {
147+
return writePreserved(c, file, def, c.Bool("in-place"), func(data []byte, format services.DataFormat) ([]byte, error) {
148+
return services.DelPreserving(data, format, path)
149+
})
150+
}
151+
136152
doc, _, err := loadFileWithDefault(file, def)
137153
if err != nil {
138154
return err
@@ -357,6 +373,72 @@ func writeResult(c *cli.Context, srcPath string, doc interface{}, def services.D
357373
return nil
358374
}
359375

376+
// writePreserved reads the source file's raw bytes, applies a formatting-
377+
// preserving edit, and either writes back in place or prints to stdout. Unlike
378+
// writeResult it never round-trips through Decode/Encode, so the file is changed
379+
// only where the edit lands.
380+
func writePreserved(c *cli.Context, srcPath string, def services.DataFormat, inPlace bool, edit func([]byte, services.DataFormat) ([]byte, error)) error {
381+
data, err := os.ReadFile(srcPath)
382+
if err != nil {
383+
return err
384+
}
385+
format := services.DetectFormat(srcPath)
386+
if format == "" {
387+
format = def
388+
}
389+
out, err := edit(data, format)
390+
if err != nil {
391+
return err
392+
}
393+
if inPlace {
394+
return atomicWriteFile(srcPath, out)
395+
}
396+
fmt.Print(string(out))
397+
return nil
398+
}
399+
400+
// atomicWriteFile writes data to a temp file in the same directory, fsyncs it,
401+
// then renames it over the target. The rename is atomic on POSIX, so a crash or
402+
// kill mid-write leaves the original file fully intact rather than truncated.
403+
// The original file's permission bits are preserved.
404+
func atomicWriteFile(path string, data []byte) error {
405+
mode := os.FileMode(0644)
406+
if info, err := os.Stat(path); err == nil {
407+
mode = info.Mode().Perm()
408+
}
409+
dir := filepath.Dir(path)
410+
tmp, err := os.CreateTemp(dir, ".pipekit-*.tmp")
411+
if err != nil {
412+
return err
413+
}
414+
tmpName := tmp.Name()
415+
cleanup := func() { _ = os.Remove(tmpName) }
416+
417+
if _, err := tmp.Write(data); err != nil {
418+
tmp.Close()
419+
cleanup()
420+
return err
421+
}
422+
if err := tmp.Sync(); err != nil {
423+
tmp.Close()
424+
cleanup()
425+
return err
426+
}
427+
if err := tmp.Close(); err != nil {
428+
cleanup()
429+
return err
430+
}
431+
if err := os.Chmod(tmpName, mode); err != nil {
432+
cleanup()
433+
return err
434+
}
435+
if err := os.Rename(tmpName, path); err != nil {
436+
cleanup()
437+
return err
438+
}
439+
return nil
440+
}
441+
360442
func pickFormat(flag, outputPath string, def services.DataFormat) services.DataFormat {
361443
if flag != "" {
362444
return services.FormatString(flag)

docs/COMMANDS.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,8 +978,30 @@ pipekit json get values.yaml --path '.image.tag' --raw --to-github-output IMAGE_
978978
pipekit json set values.yaml --path '.image.tag' --value 'v2.0.0' --in-place
979979
pipekit json set config.json --path '.flags' --json-value '["a","b"]' --pretty
980980
pipekit json del values.yaml --path '.legacy' --in-place
981+
982+
# Surgical edit — change ONLY the target, keep comments/key-order/quoting/spacing
983+
pipekit yaml set values.yaml --path '.image.tag' --value 'v2.0.0' --in-place --preserve
984+
pipekit json set config.json --path '.newKey' --json-value '{"on":true}' --in-place --preserve # insert
985+
pipekit json del config.json --path '.legacy' --in-place --preserve
981986
```
982987
988+
By default `set`/`del` parse the document and re-serialize it, which normalizes
989+
formatting (comments dropped, keys reordered, re-indented). Add `--preserve`
990+
(`-P`) for a surgical, byte-level edit that touches **only** the targeted node
991+
and leaves every other byte identical — comments (including their column
992+
alignment), key order, quoting style, indentation, and blank lines are all kept.
993+
Ideal for hand-maintained files like Helm `values.yaml`.
994+
995+
- Supported with `--preserve`: **yaml**, **json** (toml/csv return a clear error).
996+
- Editing an existing value, deleting a key, and **inserting a new key into an
997+
existing object** are all supported and formatting-matched to siblings.
998+
- In-place writes are **atomic** (temp file + fsync + rename) and keep the
999+
original file's permission bits, so a crash mid-write can't truncate the file.
1000+
- Safety: every YAML splice is re-parsed and validated; if the result wouldn't
1001+
hold the intended value (e.g. a type-ambiguous edit like setting a plain
1002+
numeric field to a numeric string) it automatically falls back to the safe
1003+
re-encode path rather than risk a wrong edit.
1004+
9831005
</details>
9841006
9851007
<details>

docs/EXAMPLES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,8 @@ kubectl apply -f /tmp/deployment.yaml
426426

427427
```sh
428428
# Bump only the .image.tag in values.yaml without touching anything else
429-
pipekit yaml set chart/values.yaml --path '.image.tag' --value 'v1.2.3' --in-place
429+
# (--preserve keeps comments, key order, and quoting exactly as-is)
430+
pipekit yaml set chart/values.yaml --path '.image.tag' --value 'v1.2.3' --in-place --preserve
430431
431432
# Or: deep-merge a per-env overlay
432433
pipekit yaml merge chart/values.yaml chart/values.prod.yaml --output /tmp/merged.yaml

integration/integration_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,116 @@ func TestE2E_JSONGetSetMerge(t *testing.T) {
135135
}
136136
}
137137

138+
// TestE2E_YAMLSetPreserve verifies that `--preserve` performs a surgical in-place
139+
// edit: only the targeted value changes, while comments, key order, and quoting
140+
// of every other line are left byte-for-byte intact.
141+
func TestE2E_YAMLSetPreserve(t *testing.T) {
142+
dir := t.TempDir()
143+
values := filepath.Join(dir, "values.yaml")
144+
original := `# Helm values
145+
image:
146+
repository: myapp # do not touch
147+
tag: "v1.0.0"
148+
replicas: 3
149+
`
150+
if err := os.WriteFile(values, []byte(original), 0644); err != nil {
151+
t.Fatal(err)
152+
}
153+
154+
_, stderr, code := runPipekit(t,
155+
[]string{"yaml", "set", values, "--path", ".image.tag", "--value", "v2.0.0", "--in-place", "--preserve"}, "")
156+
if code != 0 {
157+
t.Fatalf("preserve set exit %d, stderr: %s", code, stderr)
158+
}
159+
got, _ := os.ReadFile(values)
160+
gotStr := string(got)
161+
162+
for _, want := range []string{"# Helm values", "repository: myapp # do not touch", `tag: "v2.0.0"`, "replicas: 3"} {
163+
if !strings.Contains(gotStr, want) {
164+
t.Errorf("preserve lost %q:\n%s", want, gotStr)
165+
}
166+
}
167+
if strings.Contains(gotStr, "v1.0.0") {
168+
t.Errorf("old value should be gone:\n%s", gotStr)
169+
}
170+
171+
// Backward-compat sanity: the same edit WITHOUT --preserve still works,
172+
// just normalizing formatting (comments dropped).
173+
if err := os.WriteFile(values, []byte(original), 0644); err != nil {
174+
t.Fatal(err)
175+
}
176+
_, _, code = runPipekit(t,
177+
[]string{"yaml", "set", values, "--path", ".image.tag", "--value", "v2.0.0", "--in-place"}, "")
178+
if code != 0 {
179+
t.Fatalf("legacy set exit %d", code)
180+
}
181+
legacy, _ := os.ReadFile(values)
182+
if !strings.Contains(string(legacy), "v2.0.0") {
183+
t.Errorf("legacy set failed:\n%s", legacy)
184+
}
185+
}
186+
187+
// TestE2E_JSONDelPreserve verifies surgical key removal keeps surrounding JSON
188+
// formatting intact.
189+
func TestE2E_JSONDelPreserve(t *testing.T) {
190+
dir := t.TempDir()
191+
cfg := filepath.Join(dir, "config.json")
192+
original := "{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3\n}\n"
193+
if err := os.WriteFile(cfg, []byte(original), 0644); err != nil {
194+
t.Fatal(err)
195+
}
196+
_, stderr, code := runPipekit(t,
197+
[]string{"json", "del", cfg, "--path", ".b", "--in-place", "--preserve"}, "")
198+
if code != 0 {
199+
t.Fatalf("preserve del exit %d, stderr: %s", code, stderr)
200+
}
201+
want := "{\n \"a\": 1,\n \"c\": 3\n}\n"
202+
if got, _ := os.ReadFile(cfg); string(got) != want {
203+
t.Errorf("got:\n%s\nwant:\n%s", got, want)
204+
}
205+
}
206+
207+
// TestE2E_JSONSetPreserveInsert verifies `set --preserve` can add a new key to
208+
// an existing object, formatting-matched to its siblings.
209+
func TestE2E_JSONSetPreserveInsert(t *testing.T) {
210+
dir := t.TempDir()
211+
cfg := filepath.Join(dir, "config.json")
212+
if err := os.WriteFile(cfg, []byte("{\n \"a\": 1\n}\n"), 0644); err != nil {
213+
t.Fatal(err)
214+
}
215+
_, stderr, code := runPipekit(t,
216+
[]string{"json", "set", cfg, "--path", ".b", "--json-value", "2", "--in-place", "--preserve"}, "")
217+
if code != 0 {
218+
t.Fatalf("insert exit %d, stderr: %s", code, stderr)
219+
}
220+
want := "{\n \"a\": 1,\n \"b\": 2\n}\n"
221+
if got, _ := os.ReadFile(cfg); string(got) != want {
222+
t.Errorf("got:\n%s\nwant:\n%s", got, want)
223+
}
224+
}
225+
226+
// TestE2E_PreservePreservesFileMode verifies the atomic in-place write keeps the
227+
// original file's permission bits.
228+
func TestE2E_PreservePreservesFileMode(t *testing.T) {
229+
dir := t.TempDir()
230+
f := filepath.Join(dir, "values.yaml")
231+
if err := os.WriteFile(f, []byte("tag: v1\n"), 0640); err != nil {
232+
t.Fatal(err)
233+
}
234+
_, _, code := runPipekit(t,
235+
[]string{"yaml", "set", f, "--path", ".tag", "--value", "v2", "--in-place", "--preserve"}, "")
236+
if code != 0 {
237+
t.Fatalf("exit %d", code)
238+
}
239+
info, err := os.Stat(f)
240+
if err != nil {
241+
t.Fatal(err)
242+
}
243+
if info.Mode().Perm() != 0640 {
244+
t.Errorf("mode changed: got %o want 640", info.Mode().Perm())
245+
}
246+
}
247+
138248
func TestE2E_RenderFile(t *testing.T) {
139249
dir := t.TempDir()
140250
tmpl := filepath.Join(dir, "v.tpl")

0 commit comments

Comments
 (0)