/// <summary>
        /// Reads a config file. Supports both "dynamic" and "static" config files.
        /// Additionally dumps config files into the log as JSON on first read for experimental provenance.
        /// Additionally caches the contents of config file on read based on the fully-qualified path.
        /// All configs returned are clones of the cached copy (even the first config).
        /// </summary>
        /// <remarks>
        /// Support exists for processing recursive config file (where a <see cref="Config"/> objected is nested in another
        /// <see cref="Config"/>.
        /// </remarks>
        /// <typeparam name="T">The type to deserialize.</typeparam>
        /// <param name="path">
        /// The path to the config file to read (will be expanded with <see cref="Path.GetFullPath"/>.
        /// </param>
        /// <param name="factory">
        /// A factory used to create a new config if <typeparamref name="T"/> is exactly the type <see cref="Config"/>.
        /// </param>
        /// <returns>The config object, or a cached copy after the first call.</returns>
        private static T LoadAndCache <T>(string path, Func <T> factory)
            where T : IConfig
        {
            Contract.RequiresNotNull(path, nameof(path));
            path = Path.GetFullPath(path);

            lock (CachedProperties)
            {
                // "cache" path skips this
                if (!CachedProperties.TryGetValue(path, out var cachedConfig))
                {
                    // not cached, load, log, and cache
                    T      loadedConfig;
                    object generic;

                    // if is exactly the Config type (no sub types)
                    if (typeof(T) == typeof(Config))
                    {
                        // "untyped" config
                        Log.Trace($"Reading untyped config file `{path}`");
                        using (var file = File.OpenText(path))
                        {
                            generic = Yaml.Deserialize <object>(file);
                        }

                        loadedConfig = factory();
                    }
                    else
                    {
                        // deserialize typed config
                        Log.Trace($"Reading typed config file `{path}`");
                        (generic, loadedConfig) = Yaml.LoadAndDeserialize <T>(path);
                    }

                    // if implements Config in any subtype (more specific than IConfig)
                    if (loadedConfig is Config config)
                    {
                        config.GenericConfig = generic;
                        Contract.EnsuresNotNull(config.GenericConfig);
                    }

                    loadedConfig.ConfigPath = path;
                    Contract.EnsuresNotNull(loadedConfig.ConfigPath);

                    // dump the config in the log
                    configJsonSerializerSettings = new JsonSerializerSettings();
                    var configDump = Json.SerializeToString(loadedConfig, false, configJsonSerializerSettings);
                    NoConsole.Log.Info($"Config file `{path}` loaded:{Environment.NewLine}{configDump}");

                    // this has the potential to be recursive here if a config file loads another config file.
                    ((IConfig)loadedConfig).InvokeLoaded();

                    // cache the config (with possible nested configs)
                    CachedProperties.AddOrUpdate(path, loadedConfig, (key, existing) => loadedConfig);

                    cachedConfig = loadedConfig;
                }

                // always need to clone a copy to protect from cross-thread mutability
                return(((T)cachedConfig).DeepClone());
            }
        }