From 7211a5a9389a29fd69d66a439927d75aaa567a34 Mon Sep 17 00:00:00 2001 From: firepinn Date: Wed, 15 Jul 2026 11:47:19 +0200 Subject: [PATCH 1/2] yaml: export ErrPathNotExist; treat deleting a missing path as a no-op addToEnd returned a fresh unnamed error value when asked to delete a path whose parent does not exist, so callers could not distinguish it from a real failure. Export it as the sentinel ErrPathNotExist and have PatchConfig treat a delete of a non-existent path (value == nil) as a no-op via errors.Is, instead of surfacing an error. Add a yaml test asserting that deleting a path with an absent parent returns ErrPathNotExist. --- internal/app/config.go | 4 ++++ pkg/yaml/yaml.go | 4 +++- pkg/yaml/yaml_test.go | 8 ++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/internal/app/config.go b/internal/app/config.go index 0f95894a6..beffafc31 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -34,6 +34,10 @@ func PatchConfig(path []string, value any) error { b, err := yaml.Patch(b, path, value) if err != nil { + // deleting a path that doesn't exist is a no-op, not an error + if value == nil && errors.Is(err, yaml.ErrPathNotExist) { + return nil + } return err } diff --git a/pkg/yaml/yaml.go b/pkg/yaml/yaml.go index 4672cb4cf..98befc6f3 100644 --- a/pkg/yaml/yaml.go +++ b/pkg/yaml/yaml.go @@ -137,9 +137,11 @@ func nodeBounds(in []byte, node *yaml.Node) (offset0, offset1 int) { return } +var ErrPathNotExist = errors.New("yaml: path not exist") + func addToEnd(in []byte, path []string, value any) ([]byte, error) { if len(path) != 2 || value == nil { - return nil, errors.New("yaml: path not exist") + return nil, ErrPathNotExist } v := map[string]map[string]any{ diff --git a/pkg/yaml/yaml_test.go b/pkg/yaml/yaml_test.go index 264546af4..5a302086a 100644 --- a/pkg/yaml/yaml_test.go +++ b/pkg/yaml/yaml_test.go @@ -107,3 +107,11 @@ func TestPatch(t *testing.T) { }) } } + +func TestPatchDeleteMissing(t *testing.T) { + // Deleting a path whose parent does not exist routes through addToEnd and + // must return the ErrPathNotExist sentinel so callers can treat a missing + // delete target as a no-op via errors.Is. + _, err := Patch([]byte("streams:\n camera1: url1\n"), []string{"homekit", "camera1"}, nil) + require.ErrorIs(t, err, ErrPathNotExist) +} From 57833561db9d4aa149bf551a73554c7c306522f8 Mon Sep 17 00:00:00 2001 From: firepinn Date: Wed, 15 Jul 2026 11:47:59 +0200 Subject: [PATCH 2/2] yaml: allow adding a child under an explicit null scalar key yaml.Marshal of an empty map emits the explicit token "key: null". The surgical Patch() then cannot add the first child back: it pastes the child mapping after the null scalar, yielding "key: null\n child: ...", which fails to parse ("mapping values are not allowed in this context"). Strip the explicit null token so the key becomes a bare mapping parent before appending the child. A bare/implicit-null value ("key:") carries no token and keeps the previous behaviour. Add tests covering an explicit-null parent both alongside a sibling key and on its own. --- pkg/yaml/yaml.go | 16 ++++++++++++++++ pkg/yaml/yaml_test.go | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/pkg/yaml/yaml.go b/pkg/yaml/yaml.go index 98befc6f3..20953cda4 100644 --- a/pkg/yaml/yaml.go +++ b/pkg/yaml/yaml.go @@ -92,6 +92,22 @@ func patch(in []byte, path []string, value any) ([]byte, error) { } else { // parent value is nil (use parent indent + 2) paste = addIndent(paste, pKey.Column+1) + + // An explicit null scalar on the key's line (e.g. "streams: null", as emitted by + // yaml.Marshal of an empty map) can't hold a child: pasting one after it yields + // "streams: null\n x: y", which fails to parse ("mapping values are not allowed + // in this context"). Strip the scalar so the key becomes a bare mapping parent, + // then append the child. A bare/implicit-null value ("streams:") carries no token + // (pVal.Value == "") and keeps the original behaviour below. + if value != nil && pVal.Value != "" && pVal.Line == pKey.Line { + vOff := lineOffset(in, pVal.Line) + pVal.Column - 1 + colon := bytes.LastIndexByte(in[:vOff], ':') + eol := lineOffset(in, pKey.Line+1) + if eol < 0 { + eol = len(in) + } + return join(in[:colon+1], paste, in[eol:]), nil + } } _, i1 := nodeBounds(in, pKey) diff --git a/pkg/yaml/yaml_test.go b/pkg/yaml/yaml_test.go index 5a302086a..74de4e8f2 100644 --- a/pkg/yaml/yaml_test.go +++ b/pkg/yaml/yaml_test.go @@ -98,6 +98,22 @@ func TestPatch(t *testing.T) { value: []string{"val1"}, expect: "streams:\n camera1: url1\nhomekit:\n camera1:\n name: dummy\n pairings:\n - val1\n", }, + { + // yaml.Marshal of an empty streams map emits the explicit token "streams: null"; + // adding the first stream back must not produce "streams: null\n camera1: ...". + name: "explicit null scalar parent", + src: "streams: null\nwebrtc:\n listen: \":8555\"\n", + path: []string{"streams", "camera1"}, + value: "val1", + expect: "streams:\n camera1: val1\nwebrtc:\n listen: \":8555\"\n", + }, + { + name: "explicit null scalar only", + src: "streams: null\n", + path: []string{"streams", "camera1"}, + value: "val1", + expect: "streams:\n camera1: val1\n", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {