5
\$\begingroup\$

I have a few complex app.configs that I constantly manage to break. This often costs me a lot of time until I find the error/typo. To prevent such situations in future, I wrote a simple utility that should merge parts of other app.configs into the main app.config. This way I can test each config separately and merge into the final config if necessary.

It is called on post-build event, where the first file name is the target app.config in the output-dir and any other file names are sources.


The interface contains only one method:

public interface IXMerge
{
    void Merge(XDocument source, XDocument target);
}

and is implemented by the XMerge class.

public class XMerge
{
    // Specifies which paths require merging by key.
    // <Path, KeyGetter>
    private readonly IReadOnlyDictionary<IEnumerable<string>, Func<XElement, string>> _keyMap;

    public XMerge(IEnumerable<KeyValuePair<string, string>> keyMap)
    {
        _keyMap = keyMap.ToDictionary(
            x => x.Key.Split('/'), 
            x => new Func<XElement, string>(e => e.Attribute(x.Value).Value),
            RelayEqualityComparer<IEnumerable<string>>.Create(
                (left, right) => left.SequenceEqual(right),
                (obj) => obj.CalcHashCode()
            )
        );
    }   

    // Copies elements from source to target one by one. Attributes are copied all at once.
    public void Merge(XDocument sourceDocument, XDocument targetDocument)
    {
        var mode = GetRootMode(sourceDocument);
        var source = sourceDocument.Root;
        var target = targetDocument.Root;

        // Using a stack because not a fan of recursion.
        var stack = new Stack<(XElement Source, XElement Target, IEnumerable<string> Path, Mode Mode)>
        {
            (source, target, Enumerable.Empty<string>(), mode)
        };

        while (stack.Any())
        {
            var current = stack.Pop();
            var directives = new HashSet<Directive>();

            foreach (var node in current.Source.Nodes())
            {
                switch (node)
                {
                    case XComment comment:
                        if (Directive.TryParse(comment, out var directive))
                        {
                            directives.AddOrUpdate(directive);
                        }
                        break;

                    case XElement element:

                        // Update mode if new one is available.
                        mode = directives.ModeOrDefault() ?? current.Mode;

                        // Include mode requires elements to be explicitly marked as included in order to be copied.
                        var canCopyIncluded =
                            mode.Value == MergeMode.Include &&
                            directives.Copy().Mode == MergeMode.Include &&
                            directives.Copy().Scope.In(MergeScope.Single, MergeScope.Begin);

                        // Exclude mode requires elements to be explicitly marked as excluded in order to NOT be copied.
                        var canCopyNotExcluded =
                            mode.Value == MergeMode.Exclude &&
                            directives.Copy().Mode != MergeMode.Exclude &&
                            directives.Copy().Scope.In(MergeScope.Single, MergeScope.Begin);

                        // Indicates stop copying at the end of a scope.
                        var isEnd = directives.Copy().Scope.In(MergeScope.End);

                        if ((canCopyIncluded || canCopyNotExcluded) && !isEnd)
                        {
                            // If element is key-copyable then evaluate its existence.
                            var copyByKey = _keyMap.TryGetValue(Enumerable.Append(current.Path, element.Name.LocalName), out var getKey);

                            // This works only in Visual Studio.
                            //var copyByKey = _keyMap.TryGetValue(current.Path.Append(element.Name.LocalName), out var getKey);

                            var elementExists = copyByKey && current.Target.Elements().Any(x => getKey(x) == getKey(element));
                            if (copyByKey && !elementExists)
                            {
                                ConsumeLabels(current.Target, directives);
                                current.Target.Add(element);
                            }
                            else
                            {
                                target = current.Target.Element(element.Name.LocalName);
                                if (target is null)
                                {
                                    ConsumeLabels(current.Target, directives);
                                    // Without default namespace it'll fail for some xmlns elements.
                                    var defaultNamespace = element.GetDefaultNamespace();

                                    // Child elements should be evaluated separately so copy only the current element.
                                    target = new XElement(defaultNamespace + element.Name.LocalName, element.Attributes());
                                    current.Target.Add(target);
                                }

                                // Due to a bug in LINQPad the Append extension from .net 4.7.1 conflicts with my own with the same signature.
                                stack.Push((element, target, Enumerable.Append(current.Path, element.Name.LocalName), mode));

                                // This works correctly in Visual Studio.
                                //stack.Push((element, target, current.Path.Append(element.Name.LocalName), mode));
                            }
                        }

                        // In order to stop copying after a single-copy this directive needs to be removed.
                        if (directives.Copy().Scope.In(MergeScope.Single))
                        {
                            directives.Remove(directives.Copy());
                        }

                        break;
                }
            }
        }
    }

    private static Mode GetRootMode(XDocument xDoc)
    {
        foreach (var node in xDoc.Nodes())
        {
            // It's not possible to use a helper variable here 
            // because it won't compile due to an unitialized 'directive' error.
            if (node is XComment comment && Directive.TryParse(comment, out var directive))
            {
                // Mode must be the first directive.
                return
                    directive is Mode mode
                        ? mode
                        : throw (
                            $"InvalidDirective{nameof(Exception)}",
                            $"The first directive must be {nameof(Mode)} ({Directive.Prefix}mode: <incl|excl>)."
                        ).ToDynamicException();
            }
        }

        // Mode directive must be specified explicitly. There is no default value.
        throw (
            $"ModeDirectiveNotFound{nameof(Exception)}",
            $"You must specify the {nameof(Mode)} ({Directive.Prefix}mode: <incl|excl>)."
        ).ToDynamicException();
    }

    // Adds labesl to the element and removes them from directives.
    private static void ConsumeLabels(XElement target, HashSet<Directive> directives)
    {
        var labels = directives.OfType<Label>();
        foreach (var label in labels)
        {
            target.Add(new XComment(label.Text.EncloseWith("  ")));
        }
        directives.RemoveWhere(x => labels.Contains(x));
    }
}

This class runs over the xml-tree and compares the source with the target element by element and copies them if required. Attributes are copied all at once and are not compared.

It supports two modes:

  • xm-mode: incl - elements must be marked with xm-incl in order to be copied
  • xm-mode: excl - elements must be marked with xm-excl in order to NOT be copied

Modes are set via directives which are xml-comments. Other directives are:

  • xm-incl[: begin|end] - begins/ends a copy scope where multiple elements can be copied
  • xm-excl[: begin|end] - begins/end a copy scope where multiple elements should not be copied
  • xm-label: <text> - allow to add comments to copied elements

A source file may look like this:

<?xml version="1.0" encoding="utf-8"?>
<!-- xm-mode: incl -->
<configuration>    
  <!-- xm-incl -->
  <configSections>    
    <!-- xm-incl -->
    <section name="bar" />
  </configSections>
  <startup>
    <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7"/>
  </startup>
  <!-- xm-incl -->
  <appSettings>

    <add key="not" value="copied" />

    <!-- xm-incl -->
    <add key="bar" value="qux" />
    <!-- xm-label: Credentials -->
    <!-- xm-incl: begin -->
    <add key="UserName" value="abc" />
    <add key="Password" value="xyz" />
    <!-- xm-incl: end -->

   <add key="not2" value="copied2" />

    <!-- xm-incl -->
    <add key="qux" value="quux"/>

    <add key="not3" value="copied3" />
  </appSettings>
<configuration> 

Because almost all elements require different merge rules (some of them use name while others use key or id attributes to disambiguate their children). I use a dictionary with their paths to map them to the attribute that is used as a key:

var keyMap = new Dictionary<string, string>
{
    ["configSections/section"] = "name",
    ["appSettings/add"] = "key",
    ["connectionStrings/add"] = "name",
    ["system.runtime.remoting/channelSinkProviders/clientProviders/provider"] = "id",
    ["system.runtime.remoting/application/channels/channel"] = "ref",
    ["system.runtime.remoting/application/channels/channel/clientProviders/provider"] = "ref",
};

This is passed to the constructor.


Directives are implemented as classes derived from the Directive base type. Each of them has a static factory method TryParse that I use to parse the XML comments.

public abstract class Directive : IEquatable<Directive>
{
    public const string Prefix = "xm-";

    private delegate bool TryParseFunc(XComment comment, out Directive directive);

    private static readonly IEnumerable<TryParseFunc> _factories = new TryParseFunc[]
    {
            Mode.TryParse,
            Label.TryParse,
            Copy.TryParse
    };

    // By default there can be only one directive of each type name.
    [AutoEqualityProperty]
    public virtual string Name => GetType().Name;

    public static bool TryParse(XComment comment, out Directive directive)
    {
        foreach (var factory in _factories)
        {
            if (factory(comment, out directive))
            {
                return true;
            }
        }
        directive = default;
        return false;
    }

    public bool Equals(Directive other) => AutoEquality<Directive>.Comparer.Equals(this, other);

    public override bool Equals(object obj) => obj is Directive d && Equals(d);

    public override int GetHashCode() => AutoEquality<Directive>.Comparer.GetHashCode(this);
}

// <!-- xm-mode: <incl|excl> -->
public class Mode : Directive
{
    public static readonly Mode None = new Mode { Value = MergeMode.None };

    public MergeMode Value { get; private set; }

    public static new bool TryParse(XComment comment, out Directive mode)
    {
        var match = Regex.Match(comment.Value, $"{Prefix}mode:(?<args>.+)");
        if (match.Success)
        {
            Enum.TryParse<MergeMode>(match.Groups["args"].Value.Trim(), true, out var value);
            mode = new Mode
            {
                Value = value
            };
            return true;
        }
        else
        {
            mode = default;
            return false;
        }
    }
}

// <!-- xm-label: <"text"> -->
public class Label : Directive
{
    public string Text { get; private set; }

    // Labels are allowed to be defined multiple times.
    public override string Name => Text;

    public static new bool TryParse(XComment comment, out Directive label)
    {
        var match = Regex.Match(comment.Value, $"{Prefix}label:(?<args>.+)");
        if (match.Success)
        {
            label = new Label
            {
                Text = match.Groups["args"].Value.Trim()
            };
            return true;
        }
        else
        {
            label = default;
            return false;
        }
    }
}

// <!-- xm-<include|exclude>[: begin|end] -->
public class Copy : Directive
{
    public static readonly Copy None = new Copy { Mode = MergeMode.None, Scope = MergeScope.None };

    public MergeMode Mode { get; internal set; }

    public MergeScope Scope { get; internal set; }

    public static new bool TryParse(XComment comment, out Directive copy)
    {
        var directiveMatch = Regex.Match(comment.Value, $"{Prefix}(?<mode>incl|excl(ude)?)(:(?<args>.+))?");
        if (directiveMatch.Success)
        {
            var mode = directiveMatch.Groups["mode"].Value;
            var args = directiveMatch.Groups["args"].Value.Split(',').Select(x => x.Trim());

            var ignoreCase = true;

            copy = new Copy
            {
                Mode = (MergeMode)Enum.Parse(typeof(MergeMode), mode, ignoreCase),
                Scope = Enum.TryParse<MergeScope>(args.FirstOrDefault(), ignoreCase, out var scope) ? scope : MergeScope.Single
            };
            return true;
        }
        else
        {
            copy = default;
            return false;
        }
    }
}

They are supported by two enumes and a couple of extensions for common operations:

public enum MergeMode
{
    None,
    Include,
    Incl = Include,
    Exclude,
    Excl = Exclude
}

public enum MergeScope
{
    None,
    Single,
    Begin,
    End,
}

public static class HelperExtensions
{
    public static HashSet<T> AddOrUpdate<T>(this HashSet<T> set, T item)
    {
        set.Remove(item);
        set.Add(item);
        return set;
    }

    public static Mode ModeOrDefault(this HashSet<Directive> directives)
    {
        return directives.OfType<Mode>().SingleOrDefault();
    }

    public static Copy Copy(this HashSet<Directive> directives)
    {
        return directives.OfType<Copy>().SingleOrDefault() ?? new Copy { Mode = MergeMode.None, Scope = MergeScope.Single }; ;
    }

    public static bool In<T>(this T value, params T[] others)
    {
        return others.Contains(value);
    }
}

This works and does its job but do you see anything that could be done better?

I'm not going to create any super-sophisticated merge-utility with overwrites and hierarchy or specificity, just merge unique parts from various app.configs to avoid copy/paste/edit mistakes.

\$\endgroup\$
  • 1
    \$\begingroup\$ Sounds like you've made your own version of Slow Cheetah \$\endgroup\$ – RobH Dec 4 '17 at 11:17
  • \$\begingroup\$ @RobH I was just going to write a comment that I didn't want to use XLST because I wasn't happy with the available tooling and the lack of good XLST processors. I've heard of SC but it seems to blindly merge app.configs. I'm going to keep each source app.config in a separate unit-test project so that I can verify their settings and merge only those parts that are about the specific technology. There will be other settings that I don't want to merge into the final result (e.g. because they are test specific settings). But I guess I should take a look at how they solved the project settings. \$\endgroup\$ – t3chb0t Dec 4 '17 at 11:29
  • \$\begingroup\$ Is there a reason why you haven't used external file links, thus precluding the need to manually merge the config files? \$\endgroup\$ – Flater Dec 4 '17 at 16:54
  • \$\begingroup\$ @Flater the reason is... embarrassing/awkward? I've never heard of it before :-o do you have any experience with such external files? It looks like I can only reference a single external file per section. This is a big limitation. Merging appSettings from three or more files seems to be impossible. \$\endgroup\$ – t3chb0t Dec 4 '17 at 17:02
  • \$\begingroup\$ @t3chb0t: Something like this? Note that I'm just Google-Fu-ing here; I assume the separate features I linked stack nicely :) It's been a few years since I used external links (and I only maintained the existing project), so I can't help you off the top of my head, sorry. \$\endgroup\$ – Flater Dec 4 '17 at 17:06

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.