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 @@ +