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..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) @@ -137,9 +153,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..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) { @@ -107,3 +123,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) +}