< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Utilities.XmlHelper
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Utilities/XmlHelper.cs
Tag: Kestrun/Kestrun@2d87023b37eb91155071c91dd3d6a2eeb3004705
Line coverage
91%
Covered lines: 103
Uncovered lines: 9
Coverable lines: 112
Total lines: 331
Line coverage: 91.9%
Branch coverage
85%
Covered branches: 77
Total branches: 90
Branch coverage: 85.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 09/01/2025 - 04:08:24 Line coverage: 96.4% (54/56) Branch coverage: 95.6% (44/46) Total lines: 189 Tag: Kestrun/Kestrun@d6f26a131219b7a7fcb4e129af3193ec2ec4892910/13/2025 - 16:52:37 Line coverage: 91.9% (103/112) Branch coverage: 85.5% (77/90) Total lines: 331 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e 09/01/2025 - 04:08:24 Line coverage: 96.4% (54/56) Branch coverage: 95.6% (44/46) Total lines: 189 Tag: Kestrun/Kestrun@d6f26a131219b7a7fcb4e129af3193ec2ec4892910/13/2025 - 16:52:37 Line coverage: 91.9% (103/112) Branch coverage: 85.5% (77/90) Total lines: 331 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
get_MaxDepth()100%11100%
ToXml(...)100%11100%
ToXmlInternal(...)100%1010100%
TryHandleTerminal(...)81.25%191677.77%
EnterCycle(...)100%44100%
IsSimple(...)85.71%1414100%
DictionaryToXml(...)66.66%66100%
EnumerableToXml(...)100%22100%
ObjectToXml(...)100%4481.81%
SanitizeName(...)62.5%161690%
.cctor()100%11100%
Equals(...)100%11100%
GetHashCode(...)100%11100%
ToHashtable(...)100%1818100%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Utilities/XmlHelper.cs

#LineLine coverage
 1using System.Collections;
 2using System.Reflection;
 3using System.Xml.Linq;
 4
 5namespace Kestrun.Utilities;
 6
 7/// <summary>
 8/// Helpers for converting arbitrary objects into <see cref="XElement"/> instances.
 9/// </summary>
 10public static class XmlHelper
 11{
 112    private static readonly XNamespace xsi = "http://www.w3.org/2001/XMLSchema-instance";
 13    /// <summary>
 14    /// Default maximum recursion depth for object-to-XML conversion.
 15    /// Chosen to balance performance and stack safety for typical object graphs.
 16    /// Adjust if deeper object graphs need to be serialized.
 17    /// </summary>
 18    public const int DefaultMaxDepth = 32;
 19
 20    /// <summary>
 21    /// Maximum recursion depth for object-to-XML conversion. This value can be adjusted if deeper object graphs need to
 22    /// </summary>
 11323    public static int MaxDepth { get; set; } = DefaultMaxDepth;
 24    // Per-call cycle detection now passed explicitly (was ThreadStatic). Avoids potential thread reuse memory retention
 25    // Rationale:
 26    //   * ThreadStatic HashSet could retain large object graphs across requests in thread pool threads causing memory b
 27    //   * A per-call HashSet has a short lifetime and becomes eligible for GC immediately after serialization completes
 28    //   * Passing the set by reference keeps allocation to a single HashSet per root ToXml call (lazy created on first 
 29    //   * No synchronization needed: the set is confined to the call stack; recursive calls share it by reference.
 30
 31    /// <summary>
 32    /// Converts an object to an <see cref="XElement"/> with the specified name, handling nulls, primitives, dictionarie
 33    /// </summary>
 34    /// <param name="name">The name of the XML element.</param>
 35    /// <param name="value">The object to convert to XML.</param>
 36    /// <returns>An <see cref="XElement"/> representing the object.</returns>
 1337    public static XElement ToXml(string name, object? value) => ToXmlInternal(SanitizeName(name), value, 0, visited: nul
 38
 39    /// <summary>
 40    /// Internal recursive method to convert an object to XML, with depth tracking and cycle detection.
 41    /// </summary>
 42    /// <param name="name">The name of the XML element.</param>
 43    /// <param name="value">The object to convert to XML.</param>
 44    /// <param name="depth">The current recursion depth.</param>
 45    /// <returns>An <see cref="XElement"/> representing the object.</returns>
 46    /// <param name="visited">Per-call set of already visited reference objects for cycle detection.</param>
 47    private static XElement ToXmlInternal(string name, object? value, int depth, HashSet<object>? visited)
 48    {
 49        // Fast path & terminal cases extracted to helpers for reduced branching complexity.
 11250        if (TryHandleTerminal(name, value, depth, ref visited, out var terminal))
 51        {
 6852            return terminal;
 53        }
 54
 55        // At this point value is non-null complex/reference or value-type object requiring reflection.
 4456        var type = value!.GetType();
 4457        var needsCycleTracking = !type.IsValueType;
 4458        if (needsCycleTracking && !EnterCycle(value, ref visited, out var cycleElem))
 59        {
 160            return cycleElem!; // Cycle detected
 61        }
 62
 63        try
 64        {
 4365            return ObjectToXml(name, value, depth, visited);
 66        }
 67        finally
 68        {
 4369            if (needsCycleTracking && visited is not null)
 70            {
 4371                _ = visited.Remove(value);
 72            }
 4373        }
 4374    }
 75
 76    /// <summary>
 77    /// Handles depth guard, null, enums, primitives, simple temporal types, dictionaries &amp; enumerables.
 78    /// </summary>
 79    /// <param name="name">The name of the XML element.</param>
 80    /// <param name="value">The object to convert to XML.</param>
 81    /// <param name="depth">The current recursion depth.</param>
 82    /// <param name="element">The resulting XML element if handled; otherwise, null.</param>
 83    /// <param name="visited">Per-call set used for cycle detection (reference types only).</param>
 84    /// <returns><c>true</c> if the value was handled; otherwise, <c>false</c>.</returns>
 85    private static bool TryHandleTerminal(string name, object? value, int depth, ref HashSet<object>? visited, out XElem
 86    {
 87        // Depth guard handled below.
 11288        if (depth >= MaxDepth)
 89        {
 290            element = new XElement(name, new XAttribute("warning", "MaxDepthExceeded"));
 291            return true;
 92        }
 93
 94        // Null
 11095        if (value is null)
 96        {
 697            element = new XElement(name, new XAttribute(xsi + "nil", true));
 698            return true;
 99        }
 100
 104101        var type = value.GetType();
 102
 103        // Enum
 104104        if (type.IsEnum)
 105        {
 0106            element = new XElement(name, value.ToString());
 0107            return true;
 108        }
 109
 110        // Primitive / simple
 104111        if (IsSimple(value))
 112        {
 58113            element = new XElement(name, value);
 58114            return true;
 115        }
 116
 117        // DateTimeOffset / TimeSpan (already covered by IsSimple for DateTimeOffset/TimeSpan? DateTimeOffset yes, TimeS
 46118        if (value is DateTimeOffset dto)
 119        {
 0120            element = new XElement(name, dto.ToString("O"));
 0121            return true;
 122        }
 46123        if (value is TimeSpan ts)
 124        {
 0125            element = new XElement(name, ts.ToString());
 0126            return true;
 127        }
 128
 129        // IDictionary
 46130        if (value is IDictionary dict)
 131        {
 1132            element = DictionaryToXml(name, dict, depth, visited);
 1133            return true;
 134        }
 135
 136        // IEnumerable
 45137        if (value is IEnumerable enumerable)
 138        {
 1139            element = EnumerableToXml(name, enumerable, depth, visited);
 1140            return true;
 141        }
 142
 44143        element = null!;
 44144        return false;
 145    }
 146
 147    /// <summary>
 148    /// Enters cycle tracking for the specified object. Returns false if a cycle is detected.
 149    /// </summary>
 150    /// <param name="value">The object to track.</param>
 151    /// <param name="cycleElement">The resulting XML element if a cycle is detected; otherwise, null.</param>
 152    /// <returns><c>true</c> if the object is successfully tracked; otherwise, <c>false</c>.</returns>
 153    /// <param name="visited">Per-call set of visited objects (created lazily).</param>
 154    private static bool EnterCycle(object value, ref HashSet<object>? visited, out XElement? cycleElement)
 155    {
 44156        visited ??= new HashSet<object>(ReferenceEqualityComparer.Instance);
 44157        if (!visited.Add(value))
 158        {
 1159            cycleElement = new XElement("Object", new XAttribute("warning", "CycleDetected"));
 1160            return false;
 161        }
 43162        cycleElement = null;
 43163        return true;
 164    }
 165
 166    /// <summary>
 167    /// Determines whether the specified object is a simple type (primitive, string, DateTime, Guid, or decimal).
 168    /// </summary>
 169    /// <param name="value">The object to check.</param>
 170    /// <returns><c>true</c> if the object is a simple type; otherwise, <c>false</c>.</returns>
 171    private static bool IsSimple(object value)
 172    {
 104173        var type = value.GetType();
 104174        return type.IsPrimitive
 104175            || value is string
 104176            || value is DateTime or DateTimeOffset
 104177            || value is Guid
 104178            || value is decimal
 104179            || value is TimeSpan;
 180    }
 181
 182    /// <summary>Converts a dictionary to an XML element (recursive).</summary>
 183    /// <param name="name">Element name for the dictionary.</param>
 184    /// <param name="dict">Dictionary to serialize.</param>
 185    /// <param name="depth">Current recursion depth (guarded).</param>
 186    /// <param name="visited">Per-call set used for cycle detection.</param>
 187    private static XElement DictionaryToXml(string name, IDictionary dict, int depth, HashSet<object>? visited)
 188    {
 1189        var elem = new XElement(name);
 6190        foreach (DictionaryEntry entry in dict)
 191        {
 2192            var key = SanitizeName(entry.Key?.ToString() ?? "Key");
 2193            elem.Add(ToXmlInternal(key, entry.Value, depth + 1, visited));
 194        }
 1195        return elem;
 196    }
 197    /// <summary>Converts an enumerable to an XML element; each item becomes &lt;Item/&gt;.</summary>
 198    /// <param name="name">Element name for the collection.</param>
 199    /// <param name="enumerable">Sequence to serialize.</param>
 200    /// <param name="depth">Current recursion depth (guarded).</param>
 201    /// <param name="visited">Per-call set used for cycle detection.</param>
 202    private static XElement EnumerableToXml(string name, IEnumerable enumerable, int depth, HashSet<object>? visited)
 203    {
 1204        var elem = new XElement(name);
 6205        foreach (var item in enumerable)
 206        {
 2207            elem.Add(ToXmlInternal("Item", item, depth + 1, visited));
 208        }
 1209        return elem;
 210    }
 211
 212    /// <summary>Reflects an object's public instance properties into XML.</summary>
 213    /// <param name="name">Element name for the object.</param>
 214    /// <param name="value">Object instance to serialize.</param>
 215    /// <param name="depth">Current recursion depth (guarded).</param>
 216    /// <param name="visited">Per-call set used for cycle detection.</param>
 217    private static XElement ObjectToXml(string name, object value, int depth, HashSet<object>? visited)
 218    {
 43219        var objElem = new XElement(name);
 43220        var type = value.GetType();
 276221        foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
 222        {
 95223            if (prop.GetIndexParameters().Length > 0)
 224            {
 225                continue; // skip indexers
 226            }
 227            object? propVal;
 228            try
 229            {
 95230                propVal = prop.GetValue(value);
 95231            }
 0232            catch
 233            {
 0234                continue; // skip unreadable props
 235            }
 95236            var childName = SanitizeName(prop.Name);
 95237            objElem.Add(ToXmlInternal(childName, propVal, depth + 1, visited));
 238        }
 43239        return objElem;
 240    }
 241
 242    private static string SanitizeName(string raw)
 243    {
 110244        if (string.IsNullOrWhiteSpace(raw))
 245        {
 0246            return "Element";
 247        }
 248        // XML element names must start with letter or underscore; replace invalid chars with '_'
 110249        var sb = new System.Text.StringBuilder(raw.Length);
 1212250        for (var i = 0; i < raw.Length; i++)
 251        {
 496252            var ch = raw[i];
 496253            var valid = i == 0
 496254                ? (char.IsLetter(ch) || ch == '_')
 496255                : (char.IsLetterOrDigit(ch) || ch == '_' || ch == '-' || ch == '.');
 496256            _ = sb.Append(valid ? ch : '_');
 257        }
 110258        return sb.ToString();
 259    }
 260
 261    private sealed class ReferenceEqualityComparer : IEqualityComparer<object>
 262    {
 1263        public static readonly ReferenceEqualityComparer Instance = new();
 44264        public new bool Equals(object? x, object? y) => ReferenceEquals(x, y);
 87265        public int GetHashCode(object obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj);
 266    }
 267
 268    /// <summary>
 269    /// Converts an <see cref="XElement"/> into a <see cref="Hashtable"/>.
 270    /// Nested elements become nested Hashtables; repeated elements become lists.
 271    /// Attributes are stored as keys prefixed with "@", xsi:nil="true" becomes <c>null</c>.
 272    /// </summary>
 273    /// <param name="element">The XML element to convert.</param>
 274    /// <returns>A Hashtable representation of the XML element.</returns>
 275    public static Hashtable ToHashtable(XElement element)
 276    {
 17277        var table = new Hashtable();
 278
 279        // Handle attributes (as @AttributeName)
 40280        foreach (var attr in element.Attributes())
 281        {
 4282            if (attr.Name.NamespaceName == xsi.NamespaceName && attr.Name.LocalName == "nil" && attr.Value == "true")
 283            {
 2284                return new Hashtable { [element.Name.LocalName] = null! };
 285            }
 2286            table["@" + attr.Name.LocalName] = attr.Value;
 287        }
 288
 289        // If element has no children → treat as value
 15290        if (!element.HasElements)
 291        {
 10292            if (!string.IsNullOrWhiteSpace(element.Value))
 293            {
 9294                table[element.Name.LocalName] = element.Value;
 295            }
 10296            return table;
 297        }
 298
 299        // Otherwise recurse into children
 5300        var childMap = new Hashtable();
 28301        foreach (var child in element.Elements())
 302        {
 9303            var childKey = child.Name.LocalName;
 9304            var childValue = ToHashtable(child);
 305
 9306            if (childMap.ContainsKey(childKey))
 307            {
 308                // Already exists → convert to List (allowing null values)
 3309                if (childMap[childKey] is List<object?> list)
 310                {
 1311                    list.Add(childValue[childKey]);
 312                }
 313                else
 314                {
 2315                    childMap[childKey] = new List<object?>
 2316                    {
 2317                        childMap[childKey],
 2318                        childValue[childKey]
 2319                    };
 320                }
 321            }
 322            else
 323            {
 6324                childMap[childKey] = childValue[childKey];
 325            }
 326        }
 327
 5328        table[element.Name.LocalName] = childMap;
 5329        return table;
 2330    }
 331}