From 490fa687e754714f9335b2c0921251d25037f898 Mon Sep 17 00:00:00 2001
From: queil <4584075+queil@users.noreply.github.com>
Date: Fri, 22 May 2026 19:30:09 +0000
Subject: [PATCH] add: yzl patch
---
Yzl.All.slnx | 1 +
src/Yzl.Patch/Patch.fs | 472 +++++++++++++++++++++++++++
src/Yzl.Patch/Yzl.Patch.fsproj | 25 ++
tests/PatchTests.fs | 576 +++++++++++++++++++++++++++++++++
tests/Yzl.Tests.Unit.fsproj | 2 +
5 files changed, 1076 insertions(+)
create mode 100644 src/Yzl.Patch/Patch.fs
create mode 100644 src/Yzl.Patch/Yzl.Patch.fsproj
create mode 100644 tests/PatchTests.fs
diff --git a/Yzl.All.slnx b/Yzl.All.slnx
index 329515a..11ff738 100644
--- a/Yzl.All.slnx
+++ b/Yzl.All.slnx
@@ -4,6 +4,7 @@
+
diff --git a/src/Yzl.Patch/Patch.fs b/src/Yzl.Patch/Patch.fs
new file mode 100644
index 0000000..e61bbc1
--- /dev/null
+++ b/src/Yzl.Patch/Patch.fs
@@ -0,0 +1,472 @@
+namespace Yzl.Patch
+
+open System.IO
+open YamlDotNet.RepresentationModel
+open YamlDotNet.Serialization
+open YamlDotNet.Core
+open Yzl
+open System.Collections.Generic
+
+[]
+module Patch =
+ open System.Text.RegularExpressions
+ open YamlDotNet.Core.Events
+
+ let private withStyle (style: ScalarStyle) (node: YamlScalarNode) =
+ node.Style <- style
+ node
+
+ let private parseSegment (seg: string) =
+ let predicateMatch =
+ Regex.Match(seg, @"^\[([^=]+)=(?:""([^""]*)""|'([^']*)'|([^\]]+))\]$")
+
+ let scalarMatch = Regex.Match(seg, @"^\[(?:""([^""]*)""|'([^']*)'|([^\]]+))\]$")
+
+ if predicateMatch.Success then
+ let key = predicateMatch.Groups.[1].Value
+
+ let value =
+ if predicateMatch.Groups.[2].Success then
+ predicateMatch.Groups.[2].Value
+ elif predicateMatch.Groups.[3].Success then
+ predicateMatch.Groups.[3].Value
+ else
+ predicateMatch.Groups.[4].Value
+
+ None, Some(key, value)
+ elif scalarMatch.Success then
+
+ let value =
+ if scalarMatch.Groups.[1].Success then
+ scalarMatch.Groups.[1].Value
+ elif scalarMatch.Groups.[2].Success then
+ scalarMatch.Groups.[2].Value
+ else
+ scalarMatch.Groups.[3].Value
+
+ None, Some(null, value)
+ else
+ Some seg, None
+
+ let private getSegments jsonPath =
+ Regex.Split(jsonPath, @"\.(?![^\[]*\])")
+ |> Array.filter (fun s -> s <> "")
+ |> Array.map parseSegment
+ |> Array.toList
+
+ let private resolveNestedKey (keyPath: string) (node: YamlNode) =
+ match node with
+ | :? YamlMappingNode as m ->
+ let literalKey = YamlScalarNode keyPath
+
+ if m.Children.ContainsKey literalKey then
+ Some m.Children.[literalKey]
+ else
+ let rec go (parts: string list) (n: YamlNode) =
+ match parts with
+ | [] -> Some n
+ | p :: rest ->
+ match n with
+ | :? YamlMappingNode as m ->
+ let k = YamlScalarNode p
+
+ if m.Children.ContainsKey k then
+ go rest m.Children.[k]
+ else
+ None
+ | _ -> None
+
+ go (keyPath.Split('.') |> Array.toList) node
+ | _ -> None
+
+ /// Converts Yzl tree into YamlDotNet
+ let fromYzl (sourceStart: Mark) (node: Node) =
+ let rec traverse =
+ function
+ | MapNode xs ->
+ let map =
+ YamlMappingNode(
+ seq {
+ for Named(Name name, x) in xs do
+ yield (YamlScalarNode name, traverse x) |> KeyValuePair
+ }
+ )
+
+ map.Style <- MappingStyle.Block
+ map :> YamlNode
+ | SeqNode xs ->
+ let seq =
+ YamlSequenceNode(
+ seq {
+ for x in xs do
+ yield traverse x
+ }
+ )
+
+ seq.Style <- SequenceStyle.Block
+ seq :> YamlNode
+ | Scalar s ->
+ let scalar =
+ match s with
+ | Bool x -> x |> string |> _.ToLowerInvariant() |> YamlScalarNode
+ | Int x -> x |> string |> YamlScalarNode
+ | Float x -> x |> string |> YamlScalarNode
+ | Str s ->
+ let format (p: string) =
+ let lines = p.Split([| '\n' |])
+ let nonEmpty = lines |> Array.filter (fun x -> x.Trim() <> "")
+ let baseIndent =
+ if nonEmpty.Length = 0 then 0
+ else nonEmpty |> Array.map (fun x -> x.Length - x.TrimStart().Length) |> Array.min
+ lines
+ |> Array.map (fun x -> if x.Trim() = "" then "" else x.[baseIndent..])
+ |> Array.skipWhile (fun x -> x = "")
+ |> fun arr -> arr |> Array.rev |> Array.skipWhile (fun x -> x = "") |> Array.rev
+ |> String.concat "\n"
+
+ match s with
+ | Plain p ->
+ let formatted = p |> format
+ let node = formatted |> YamlScalarNode
+ if formatted.Contains "\n" then node |> withStyle ScalarStyle.Literal else node
+ | FoldedStrip p -> p |> format |> YamlScalarNode |> withStyle ScalarStyle.Folded
+ | FoldedKeep p -> p |> format |> YamlScalarNode |> withStyle ScalarStyle.Folded
+ | Folded p -> p |> format |> YamlScalarNode |> withStyle ScalarStyle.Folded
+ | Literal p -> p |> format |> YamlScalarNode |> withStyle ScalarStyle.Literal
+ | LiteralKeep p -> p |> format |> YamlScalarNode |> withStyle ScalarStyle.Literal
+ | LiteralStrip p -> p |> format |> YamlScalarNode |> withStyle ScalarStyle.Literal
+ | DoubleQuoted p -> p |> format |> YamlScalarNode |> withStyle ScalarStyle.DoubleQuoted
+ | SingleQuoted p -> p |> format |> YamlScalarNode |> withStyle ScalarStyle.SingleQuoted
+
+ scalar :> YamlNode
+ | _ -> failwithf "Do not know"
+
+ traverse node
+
+
+ let (|MapNode|SeqNode|ScalarNode|) (node: YamlNode) =
+ match node with
+ | :? YamlMappingNode as x -> MapNode x
+ | :? YamlSequenceNode as x -> SeqNode x
+ | :? YamlScalarNode as x -> ScalarNode x
+ | _ -> failwithf "Node %s is not supported" (node.NodeType |> string)
+
+ let mergeFromYzl (source: Node) (target: YamlNode) =
+
+ let kv (node: NamedNode) =
+ let (Named(Name key, value)) = node
+ YamlScalarNode key, value
+
+ let map =
+ function
+ | Yzl.Core.MapNode _ ->
+ let map = YamlMappingNode()
+ map.Style <- MappingStyle.Block
+ map :> YamlNode
+ | Yzl.Core.SeqNode _ ->
+ let seq = YamlSequenceNode()
+ seq.Style <- SequenceStyle.Block
+ seq :> YamlNode
+ | Yzl.Core.Scalar _ -> YamlScalarNode() :> YamlNode
+ | NoNode as z -> failwithf "Not supported %A" z
+
+ let rec traverse (source: Node) (target: YamlNode) (parentMapWithKey: (YamlScalarNode * YamlMappingNode) option) =
+ match source, target with
+ | Yzl.Core.MapNode xs, MapNode m ->
+ xs
+ |> Seq.map kv
+ |> Seq.iter (fun (k, v) ->
+ let newOrExisting =
+ if not <| m.Children.ContainsKey k then
+ v |> map
+ else
+ m.Children.[k]
+
+ m.Children.[k] <- traverse v newOrExisting (Some(k, m)))
+
+ m :> YamlNode
+ | Yzl.Core.MapNode xs, _ ->
+ let m = YamlMappingNode()
+
+ xs
+ |> Seq.map kv
+ |> Seq.iter (fun (k, v) ->
+ let newOrExisting =
+ if not <| m.Children.ContainsKey k then
+ v |> map
+ else
+ m.Children.[k]
+
+ m.Children.[k] <- traverse v newOrExisting (Some(k, m)))
+
+ m :> YamlNode
+ | Yzl.Core.SeqNode xs, SeqNode s ->
+ xs
+ |> Seq.iter (fun n ->
+ let xn = n |> fromYzl target.Start
+
+ if not <| s.Children.Contains xn then
+ s.Children.Add xn
+ else
+ parentMapWithKey |> Option.iter (fun (k, m) -> m.Children.[k] <- xn))
+
+ s.Style <- SequenceStyle.Block
+ s :> YamlNode
+ | Yzl.Core.Scalar _ as z, ScalarNode _ -> z |> fromYzl (Mark())
+ | Yzl.Core.Scalar _ as z, null -> z |> fromYzl (Mark())
+ | a, b -> failwithf "Merge failed. Node type mismatch: %A vs %A" a b
+
+ traverse source target None
+
+ let mergeFromYzlAtPath (source: Node) (target: YamlNode) (path: string) =
+ let segments =
+ if System.String.IsNullOrEmpty path then
+ []
+ else
+ getSegments path
+
+ let rec navigateTo (node: YamlNode) (segs: (string option * (string * string) option) list) =
+ match segs with
+ | [] -> mergeFromYzl source node |> ignore
+ | seg :: rest ->
+ match node, seg with
+
+ | MapNode m, (Some propName, None) ->
+ let key = YamlScalarNode propName
+
+ if m.Children.ContainsKey key then
+ if List.isEmpty rest then
+ m.Children.[key] <- mergeFromYzl source m.Children.[key]
+ else
+ navigateTo m.Children.[key] rest
+ else
+ failwithf "Path not found: property '%s' does not exist" propName
+
+ | SeqNode s, (Some idx, None) ->
+ match System.Int32.TryParse idx with
+ | true, index when index >= 0 && index < s.Children.Count ->
+ if List.isEmpty rest then
+ s.Children.[index] <- mergeFromYzl source s.Children.[index]
+ else
+ navigateTo s.Children.[index] rest
+ | _ -> failwithf "Invalid array index: '%s' (length %d)" idx s.Children.Count
+
+ | SeqNode s, (None, Some(key, value)) when key <> null ->
+ let matchingIdx =
+ s.Children
+ |> Seq.tryFindIndex (fun child ->
+ resolveNestedKey key child
+ |> Option.map (fun n ->
+ match n with
+ | ScalarNode scalar -> scalar.Value = value
+ | _ -> false)
+ |> Option.defaultValue false)
+
+ match matchingIdx with
+ | Some idx ->
+ if List.isEmpty rest then
+ s.Children.[idx] <- mergeFromYzl source s.Children.[idx]
+ else
+ navigateTo s.Children.[idx] rest
+ | None -> failwithf "Sequence item with '%s=%s' not found" key value
+
+ | SeqNode s, (None, Some(nullKey, value)) when nullKey = null ->
+ let matchingIdx =
+ s.Children
+ |> Seq.tryFindIndex (fun child ->
+ match child with
+ | ScalarNode scalar when scalar.Value = value -> true
+ | _ -> false)
+
+ match matchingIdx with
+ | Some idx ->
+ if List.isEmpty rest then
+ s.Children.[idx] <- mergeFromYzl source s.Children.[idx]
+ else
+ navigateTo s.Children.[idx] rest
+ | None -> failwithf "Sequence item matching '%s' not found" value
+
+ | _ -> failwithf "Path navigation failed: type mismatch at segment %A on node type %A" seg (node.GetType().Name)
+
+ navigateTo target segments
+
+ let loadFile (path: string) =
+ use file = File.OpenRead path
+ use reader = new StreamReader(file)
+ let yaml = YamlStream()
+ yaml.Load reader
+ yaml
+
+ let private serializer = SerializerBuilder().Build()
+
+ let saveFile (path: string) (yaml: YamlStream) =
+ use sw = new System.IO.StringWriter()
+ serializer.Serialize(sw, yaml.Documents.[0].RootNode)
+
+ let bytes =
+ sw.ToString().Replace("\r\n", "\n") |> System.Text.Encoding.UTF8.GetBytes
+
+ File.WriteAllBytes(path, bytes)
+
+ let editInPlace (nodes: Node list) (filePath: string) =
+ let stream = loadFile filePath
+
+ for n in nodes do
+ stream.Documents.[0].RootNode |> mergeFromYzl n |> ignore
+
+ stream |> saveFile filePath
+
+
+ let editInPlaceAtPath (node: Node) (jsonPath: string) (filePath: string) =
+ let stream = loadFile filePath
+ mergeFromYzlAtPath node stream.Documents.[0].RootNode jsonPath
+ stream |> saveFile filePath
+
+ let private removeNodeCore (doc: YamlDocument) (jsonPath: string) : bool =
+
+ let rec navigate (node: YamlNode) segments : bool =
+ match segments with
+ | [] -> failwith "Empty path"
+ | [ None, Some(predKey, predVal) ] ->
+ match node with
+ | :? YamlSequenceNode as sequence ->
+ let idx =
+ sequence.Children
+ |> Seq.tryFindIndex (fun child ->
+ match child with
+ | :? YamlMappingNode as m when predKey <> null ->
+ resolveNestedKey predKey (m :> YamlNode)
+ |> Option.map (fun n ->
+ match n with
+ | ScalarNode scalar -> scalar.Value = predVal
+ | _ -> false)
+ |> Option.defaultValue false
+ | :? YamlScalarNode as s when predKey = null -> s.Value = predVal
+ | _ -> false)
+
+ match idx with
+ | Some idx ->
+ sequence.Children.RemoveAt idx
+ true
+ | None -> false
+ | _ -> failwith "Predicate requires sequence"
+ | (Some key, None) :: rest ->
+ if List.isEmpty rest then
+ match node with
+ | :? YamlMappingNode as mapping -> mapping.Children.Remove(YamlScalarNode key)
+ | :? YamlSequenceNode as sequence ->
+ sequence.Children.RemoveAt(int key)
+ true
+ | _ -> failwithf "Cannot remove from %s" (node.GetType().Name)
+ else
+ let next =
+ match node with
+ | :? YamlMappingNode as mapping -> mapping.Children.[YamlScalarNode key]
+ | :? YamlSequenceNode as sequence -> sequence.Children.[int key]
+ | _ -> failwith "Invalid path"
+
+ navigate next rest
+ | (None, Some(predKey, predVal)) :: rest when predKey = null ->
+ if List.isEmpty rest then
+ match node with
+ | :? YamlSequenceNode as sequence ->
+ sequence.Children
+ |> Seq.iter (fun c ->
+ printfn
+ "Child type: %s, value: %A"
+ (c.GetType().Name)
+ (match c with
+ | :? YamlScalarNode as s -> s.Value
+ | _ -> "not scalar"))
+
+ let idx =
+ sequence.Children
+ |> Seq.tryFindIndex (fun child ->
+ match child with
+ | :? YamlScalarNode as s when s.Value = predVal -> true
+ | _ -> false)
+
+ match idx with
+ | Some idx ->
+ sequence.Children.RemoveAt idx
+ true
+ | None -> false
+ | _ -> failwith "Scalar predicate requires sequence"
+ else
+ let next =
+ match node with
+ | :? YamlSequenceNode as sequence ->
+ sequence.Children
+ |> Seq.tryFind (fun child ->
+ match child with
+ | :? YamlScalarNode as s when s.Value = predVal -> true
+ | _ -> false)
+ | _ -> failwith "Scalar predicate requires sequence"
+
+ match next with
+ | Some next -> navigate next rest
+ | None -> false
+
+ | (None, Some(predKey, predVal)) :: rest ->
+ let next =
+ match node with
+ | :? YamlSequenceNode as sequence ->
+ sequence.Children
+ |> Seq.tryFind (fun child ->
+ match child with
+ | :? YamlMappingNode as m ->
+ resolveNestedKey predKey (m :> YamlNode)
+ |> Option.map (fun n ->
+ match n with
+ | ScalarNode scalar -> scalar.Value = predVal
+ | _ -> false)
+ |> Option.defaultValue false
+ | _ -> false)
+ | _ -> failwith "Predicate requires sequence"
+
+ match next with
+ | Some next -> navigate next rest
+ | None -> false
+ | (Some key, Some(predKey, predVal)) :: rest ->
+ let sequence =
+ match node with
+ | :? YamlMappingNode as mapping -> mapping.Children.[YamlScalarNode key] :?> YamlSequenceNode
+ | _ -> failwith "Expected mapping"
+
+ let next =
+ sequence.Children
+ |> Seq.tryFind (fun child ->
+ match child with
+ | :? YamlMappingNode as m ->
+ resolveNestedKey predKey (m :> YamlNode)
+ |> Option.map (fun n ->
+ match n with
+ | ScalarNode scalar -> scalar.Value = predVal
+ | _ -> false)
+ |> Option.defaultValue false
+ | _ -> false)
+
+ match next with
+ | Some next -> navigate next rest
+ | None -> false
+ | (None, None) :: _ -> failwithf "Unreachable"
+
+ let segments = getSegments jsonPath
+ let isRemoved = navigate doc.RootNode segments
+ isRemoved
+
+ let removeNodes (filePath: string) (jsonPaths: string list) : unit =
+ let stream = loadFile filePath
+ let doc = stream.Documents.[0]
+
+ for jp in jsonPaths do
+ removeNodeCore doc jp |> ignore
+
+ saveFile filePath stream
+
+ let removeNode (filePath: string) (jsonPath: string) : bool =
+ let stream = loadFile filePath
+ let doc = stream.Documents.[0]
+ let isRemoved = removeNodeCore doc jsonPath
+ saveFile filePath stream
+ isRemoved
diff --git a/src/Yzl.Patch/Yzl.Patch.fsproj b/src/Yzl.Patch/Yzl.Patch.fsproj
new file mode 100644
index 0000000..e091acc
--- /dev/null
+++ b/src/Yzl.Patch/Yzl.Patch.fsproj
@@ -0,0 +1,25 @@
+
+
+
+ netstandard2.0
+ true
+ snupkg
+ true
+ Apache-2.0
+ yzl.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/PatchTests.fs b/tests/PatchTests.fs
new file mode 100644
index 0000000..bb7881d
--- /dev/null
+++ b/tests/PatchTests.fs
@@ -0,0 +1,576 @@
+namespace Yzl.Tests.Unit
+
+module PatchTests =
+
+ open Expecto
+ open Yzl
+ open Yzl.Patch
+ open System.IO
+
+ let items = Yzl.seq
+ let item = Yzl.str
+
+ let private prepareFile (fileName: string) (content: string) =
+ let dir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName())
+ Directory.CreateDirectory dir |> ignore
+ let path = Path.Combine(dir, fileName)
+ File.WriteAllText(path, content)
+ path
+
+ []
+ let mergeTests =
+ testList "patch merge" [
+
+ test "Merge simple scalar into map" {
+ let path =
+ [ "name" .= "original"; "value" .= 123 ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.editInPlace [ ![ "value" .= 456; "extra" .= "new" ] ] path
+
+ let expected =
+ "name: original
+value: 456
+extra: new
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should merge scalars into existing map"
+ }
+
+ test "Merge nested map" {
+ let path =
+ [ "config" .= [ "timeout" .= 30; "retries" .= 3 ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.editInPlace [ ![ "config" .= [ "retries" .= 5; "maxConnections" .= 100 ] ] ] path
+
+ let expected =
+ "config:
+ timeout: 30
+ retries: 5
+ maxConnections: 100
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should merge nested maps preserving existing keys"
+ }
+
+ test "Merge into sequence" {
+ let path =
+ items [ [ "name" .= "first" ]; [ "name" .= "second" ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.editInPlace [ !(items [ [ "name" .= "third" ] ]) ] path
+
+ let expected =
+ "items:
+- name: first
+- name: second
+- name: third
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should merge sequence items"
+ }
+
+ test "MergeYzl should use Yzl magic indentation" {
+ let path =
+ items
+ [ [ item
+ !|-"
+ - one: A
+ other: []
+ " ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.editInPlace
+ [ ![ items
+ [ [ item
+ !|-"
+ - test: value
+ some: []
+ " ] ] ] ]
+ path
+
+ let expected =
+ """items:
+- item: |-
+ - one: A
+ other: []
+- item: |-
+ - test: value
+ some: []
+"""
+
+ Expect.equal (File.ReadAllText path) expected "Should have correct content"
+ }
+
+ test "MergeYzlAt - simple property path" {
+ let path =
+ [ "database" .= [ "host" .= "localhost"; "port" .= 5432 ]
+ "cache" .= [ "ttl" .= 3600 ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.editInPlaceAtPath ![ "host" .= "prod.db.com"; "maxConnections" .= 50 ] "database" path
+
+ let expected =
+ "database:
+ host: prod.db.com
+ port: 5432
+ maxConnections: 50
+cache:
+ ttl: 3600
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should merge at nested path preserving siblings"
+ }
+
+ test "MergeYzlAt - predicate-based path" {
+ let path =
+ items [ [ "name" .= "prod"; "port" .= 8080 ]; [ "name" .= "dev"; "port" .= 3000 ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.editInPlaceAtPath ![ "port" .= 8443; "ssl" .= true ] "items.[name=prod]" path
+
+ let expected =
+ "items:
+- name: prod
+ port: 8443
+ ssl: true
+- name: dev
+ port: 3000
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should merge into predicate-matched item"
+ }
+
+ test "MergeYzlAt - simple nested property chain" {
+ let path =
+ [ "servers"
+ .= [ [ "name" .= "primary"; "config" .= [ "timeout" .= 30; "retries" .= 3 ] ] ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.editInPlaceAtPath ![ "timeout" .= 60; "maxQueue" .= 1000 ] "servers.0.config" path
+
+ let expected =
+ "servers:
+- name: primary
+ config:
+ timeout: 60
+ retries: 3
+ maxQueue: 1000
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should merge at deeply nested path with index"
+ }
+
+ test "MergeYzlAt - array index path" {
+ let path =
+ items [ [ "id" .= 1; "status" .= "active" ]; [ "id" .= 2; "status" .= "inactive" ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.editInPlaceAtPath ![ "status" .= "archived"; "archived_at" .= "2024-01-01" ] "items.1" path
+
+ let expected =
+ "items:
+- id: 1
+ status: active
+- id: 2
+ status: archived
+ archived_at: 2024-01-01
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should merge at array index path"
+ }
+
+ test "MergeYzlAt with empty path targets root" {
+ let path = [ "a" .= 1 ] |> Yzl.render |> prepareFile "test.yaml"
+
+ Patch.editInPlaceAtPath ![ "b" .= 2 ] "" path
+
+ let expected =
+ "a: 1
+b: 2
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should merge at root when path is empty"
+ }
+
+ test "MergeYzlAt - map in sequence by predicate" {
+ let path =
+ [ "servers"
+ .= [ [ "hostname" .= "prod-1"; "port" .= 8080; "status" .= "active" ]
+ [ "hostname" .= "prod-2"; "port" .= 8081; "status" .= "inactive" ] ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.editInPlaceAtPath ![ "status" .= "archived"; "lastSeen" .= "2024-01-01" ] "servers.[hostname=prod-2]" path
+
+ let expected =
+ "servers:
+- hostname: prod-1
+ port: 8080
+ status: active
+- hostname: prod-2
+ port: 8081
+ status: archived
+ lastSeen: 2024-01-01
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should merge into sequence item by predicate"
+ }
+
+ test "MergeYzlAt - nested predicate key path" {
+ let path =
+ [ "patches"
+ .= [ [ "patch" .= "original-patch"
+ "target" .= [ "kind" .= "ServiceAccount"; "name" .= "sa" ] ]
+ [ "patch" .= "other-patch"
+ "target" .= [ "kind" .= "ServiceAccount"; "name" .= "other" ] ] ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.editInPlaceAtPath ![ "patch" .= "updated-patch" ] "patches.[target.name=sa]" path
+
+ let expected =
+ "patches:
+- patch: updated-patch
+ target:
+ kind: ServiceAccount
+ name: sa
+- patch: other-patch
+ target:
+ kind: ServiceAccount
+ name: other
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should merge into patches item matched by nested predicate target.name"
+ }
+
+ test "MergeYzlAt - replace scalar at nested predicate path" {
+ let path =
+ [ "patches"
+ .= [ [ "patch"
+ .= !|-"""
+ - op: add
+ path: /zzz/yy
+ value: x
+ """
+ "target" .= [ "kind" .= "ServiceAccount"; "name" .= "sa" ] ] ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.editInPlaceAtPath
+ (! """
+ - op: add
+ path: /zzz/yy
+ value: lobsters
+ """)
+ "patches.[target.name=sa].patch"
+ path
+
+ let expected =
+ "patches:
+- patch: |-
+ - op: add
+ path: /zzz/yy
+ value: lobsters
+ target:
+ kind: ServiceAccount
+ name: sa
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should replace scalar at patches.[target.name=sa].patch"
+ }
+
+ test "Merge into empty sequence uses block style" {
+ let path =
+ items []
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.editInPlace [ !(items [ [ "name" .= "first" ] ]) ] path
+
+ let expected =
+ "items:
+- name: first
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should merge into empty sequence with block style not flow style"
+ }
+
+ ]
+
+ []
+ let removeTests =
+ testList "patch remove" [
+
+ test "Remove node - simple key" {
+ let path =
+ items [ [ "name" .= "test"; "value" .= "123" ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.removeNodes path [ "items.0.value" ]
+
+ let expected =
+ "items:
+- name: test
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should remove value field"
+ }
+
+ test "Remove node - by index" {
+ let path =
+ items [ [ "name" .= "first" ]; [ "name" .= "second" ]; [ "name" .= "third" ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.removeNodes path [ "items.1" ]
+
+ let expected =
+ "items:
+- name: first
+- name: third
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should remove second item"
+ }
+
+ test "Remove node - predicate with multiple matches" {
+ let path =
+ items [ [ "name" .= "prod"; "port" .= 8080 ]; [ "name" .= "dev"; "port" .= 3000 ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.removeNodes path [ "items.[name=dev]" ]
+
+ let expected =
+ "items:
+- name: prod
+ port: 8080
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should remove dev item"
+ }
+
+ test "Remove node - nested path" {
+ let path =
+ [ "database" .= [ "host" .= "localhost"; "port" .= 5432; "password" .= "secret" ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.removeNodes path [ "database.password" ]
+
+ let expected =
+ "database:
+ host: localhost
+ port: 5432
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should remove password"
+ }
+
+ test "Remove node - predicate mid-path" {
+ let path =
+ [ "servers"
+ .= [ [ "name" .= "prod"; "config" .= [ "timeout" .= 30; "retries" .= 3 ] ]
+ [ "name" .= "dev"; "config" .= [ "timeout" .= 10; "retries" .= 1 ] ] ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.removeNodes path [ "servers.[name=prod].config.retries" ]
+
+ let expected =
+ "servers:
+- name: prod
+ config:
+ timeout: 30
+- name: dev
+ config:
+ timeout: 10
+ retries: 1
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should remove retries from prod only"
+ }
+
+ test "Remove node - top level key" {
+ let path =
+ [ "version" .= "1.0"; "name" .= "myapp"; "debug" .= true ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.removeNodes path [ ".debug" ]
+
+ let expected =
+ "version: '1.0'
+name: myapp
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should remove debug key"
+ }
+
+ test "Remove node - entire array" {
+ let path =
+ [ "items" .= [ [ "id" .= 1 ]; [ "id" .= 2 ] ]; "other" .= "data" ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.removeNodes path [ "items" ]
+
+ let expected =
+ "other: data
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should remove entire items array"
+ }
+
+ test "Remove node - scalar sequence by value" {
+ let path =
+ [ "tags" .= [ "production"; "debug"; "experimental" ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.removeNodes path [ "tags.[debug]" ]
+
+ let expected =
+ "tags:
+- production
+- experimental
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should remove debug tag"
+ }
+
+ test "Remove node - predicate with quoted value" {
+ let path =
+ items
+ [ [ "url" .= "https://api.example.com"; "name" .= "api" ]
+ [ "url" .= "https://web.example.com"; "name" .= "web" ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.removeNodes path [ "items.[url=\"https://api.example.com\"]" ]
+
+ let expected =
+ "items:
+- url: https://web.example.com
+ name: web
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should remove api item"
+ }
+
+ test "Remove node - predicate with special chars in key" {
+ let path =
+ items
+ [ [ "app.kubernetes.io/name" .= "myapp"; "version" .= "1.0" ]
+ [ "app.kubernetes.io/name" .= "other"; "version" .= "2.0" ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.removeNodes path [ "items.[app.kubernetes.io/name=myapp]" ]
+
+ let expected =
+ "items:
+- app.kubernetes.io/name: other
+ version: '2.0'
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should remove myapp item"
+ }
+
+ test "Remove node - deep nested with multiple predicates" {
+ let path =
+ [ "environments"
+ .= [ [ "name" .= "prod"
+ "servers"
+ .= [ [ "hostname" .= "prod-1"; "port" .= 8080 ]
+ [ "hostname" .= "prod-2"; "port" .= 8081 ] ] ]
+ [ "name" .= "dev"; "servers" .= [ [ "hostname" .= "dev-1"; "port" .= 3000 ] ] ] ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.removeNodes path [ "environments.[name=prod].servers.[hostname=prod-2]" ]
+
+ let expected =
+ "environments:
+- name: prod
+ servers:
+ - hostname: prod-1
+ port: 8080
+- name: dev
+ servers:
+ - hostname: dev-1
+ port: 3000
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should remove prod-2 server only"
+ }
+
+ test "Remove node - scalar with spaces" {
+ let path =
+ [ "commands" .= [ "npm start"; "npm test"; "npm run build" ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.removeNodes path [ "commands.[\"npm test\"]" ]
+
+ let expected =
+ "commands:
+- npm start
+- npm run build
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should remove npm test command"
+ }
+
+ test "Remove node - array index at root level" {
+ let path =
+ [ [ "name" .= "first" ]; [ "name" .= "second" ]; [ "name" .= "third" ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.removeNodes path [ "0" ]
+
+ let expected =
+ "- name: second
+- name: third
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should remove first item from root array"
+ }
+
+ test "Remove node - nested predicate then key" {
+ let path =
+ [ "clusters"
+ .= [ [ "name" .= "us-east"; "config" .= [ "replicas" .= 3; "autoscale" .= true ] ]
+ [ "name" .= "eu-west"; "config" .= [ "replicas" .= 5; "autoscale" .= false ] ] ] ]
+ |> Yzl.render
+ |> prepareFile "test.yaml"
+
+ Patch.removeNodes path [ "clusters.[name=eu-west].config.autoscale" ]
+
+ let expected =
+ "clusters:
+- name: us-east
+ config:
+ replicas: 3
+ autoscale: true
+- name: eu-west
+ config:
+ replicas: 5
+"
+
+ Expect.equal (File.ReadAllText path) expected "Should remove autoscale from eu-west only"
+ }
+
+ ]
diff --git a/tests/Yzl.Tests.Unit.fsproj b/tests/Yzl.Tests.Unit.fsproj
index 5a3fbdd..dbb32e7 100644
--- a/tests/Yzl.Tests.Unit.fsproj
+++ b/tests/Yzl.Tests.Unit.fsproj
@@ -10,6 +10,7 @@
+
@@ -21,6 +22,7 @@
+