< Summary

Information
Class: ArturRios.Configuration.Loaders.ConfigurationLoader
Assembly: ArturRios.Configuration
File(s): /home/ci/agent/workspace/dotnet-configuration-docs_main/app/src/Loaders/ConfigurationLoader.cs
Line coverage
100%
Covered lines: 53
Uncovered lines: 0
Coverable lines: 53
Total lines: 208
Line coverage: 100%
Branch coverage
95%
Covered branches: 23
Total branches: 24
Branch coverage: 95.8%
Method coverage
100%
Covered methods: 6
Fully covered methods: 6
Total methods: 6
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()50%22100%
.ctor(...)100%11100%
.ctor(...)100%44100%
LoadEnvironment()100%44100%
LoadAppSettings()100%66100%
ResolveFile(...)100%88100%

File(s)

/home/ci/agent/workspace/dotnet-configuration-docs_main/app/src/Loaders/ConfigurationLoader.cs

#LineLine coverage
 1using ArturRios.Configuration.Enums;
 2using DotNetEnv;
 3using Microsoft.Extensions.Configuration;
 4using Microsoft.Extensions.Logging;
 5
 6namespace ArturRios.Configuration.Loaders;
 7
 8/// <summary>
 9/// Provides helper methods to load environment variables from <c>.env</c> files and
 10/// application settings from <c>appsettings.&lt;Environment&gt;.json</c> files.
 11/// </summary>
 12/// <remarks>
 13/// <para>
 14/// By default, it looks for files under the <c>Environments</c> and <c>Settings</c> folders within the application base
 15/// If a specific environment file is not found, it falls back to the <see cref="EnvironmentType.Local"/> environment.
 16/// </para>
 17/// <para>
 18/// File names are matched without regard to case, so <c>.env.Development</c> and <c>.env.development</c> —
 19/// or <c>appsettings.Local.json</c> and <c>appsettings.local.json</c> — are equally acceptable on every
 20/// platform. Matching by exact case only would resolve on Windows and silently find nothing on Linux.
 21/// </para>
 22/// </remarks>
 23public class ConfigurationLoader
 24{
 25    /// <summary>
 26    /// Default environment name used when a specific environment file cannot be found.
 27    /// </summary>
 28    private const string DefaultEnvironmentName = nameof(EnvironmentType.Local);
 29    /// <summary>
 30    /// Default folder name that contains <c>.env</c> files.
 31    /// </summary>
 32    private const string DefaultEnvFileFolder = "Environments";
 33    /// <summary>
 34    /// Default folder name that contains <c>appsettings</c> files.
 35    /// </summary>
 36    private const string DefaultAppSettingsFolder = "Settings";
 37
 38    /// <summary>
 39    /// One console logger factory for the whole process. Creating a factory per loader leaked one provider
 40    /// per instance, since nothing ever disposed them.
 41    /// </summary>
 142    private static readonly Lazy<ILoggerFactory> FallbackLoggerFactory =
 343        new(() => LoggerFactory.Create(builder => builder.AddConsole()), isThreadSafe: true);
 44
 45    /// <summary>
 46    /// Loads a <c>.env</c> file without overwriting variables the process already has, so the real environment
 47    /// (what the deployment injected) wins over the file. DotNetEnv's own default overwrites them, which let a
 48    /// shipped <c>.env.local</c> override deployment-injected values even in Production.
 49    /// </summary>
 150    private static readonly LoadOptions NoClobber = new(clobberExistingVars: false);
 51
 52    private readonly string _basePath;
 53    private readonly IConfigurationBuilder? _configurationBuilder;
 54    private readonly string _environmentName;
 55    private readonly ILogger<ConfigurationLoader> _logger;
 56
 57    /// <summary>
 58    /// Initializes a new instance of <see cref="ConfigurationLoader"/> using an existing <see cref="IConfigurationBuild
 59    /// </summary>
 60    /// <param name="configurationBuilder">The configuration builder where JSON files will be added.</param>
 61    /// <param name="environmentName">The environment name (e.g., <c>Local</c>, <c>Development</c>, <c>Staging</c>, <c>P
 62    /// <param name="basePath">Optional base path to search for files. If not provided, the current domain base director
 63    /// <param name="logger">Optional logger instance. If not provided, a shared console logger is used.</param>
 64    /// <exception cref="ArgumentNullException"><paramref name="configurationBuilder"/> is <c>null</c>.</exception>
 65    /// <exception cref="ArgumentException"><paramref name="environmentName"/> is <c>null</c> or whitespace.</exception>
 66    public ConfigurationLoader(IConfigurationBuilder configurationBuilder, string environmentName,
 67        string? basePath = null, ILogger<ConfigurationLoader>? logger = null)
 1568        : this(environmentName, basePath, logger)
 69    {
 1270        ArgumentNullException.ThrowIfNull(configurationBuilder);
 71
 1172        _configurationBuilder = configurationBuilder;
 1173    }
 74
 75    /// <summary>
 76    /// Initializes a new instance of <see cref="ConfigurationLoader"/> without a configuration builder.
 77    /// </summary>
 78    /// <param name="environmentName">The environment name (e.g., <c>Local</c>, <c>Development</c>, <c>Staging</c>, <c>P
 79    /// <param name="basePath">Optional base path to search for files. If not provided, the current domain base director
 80    /// <param name="logger">Optional logger instance. If not provided, a shared console logger is used.</param>
 81    /// <exception cref="ArgumentException"><paramref name="environmentName"/> is <c>null</c> or whitespace.</exception>
 82    /// <remarks>
 83    /// Use <see cref="LoadEnvironment"/> with this constructor. <see cref="LoadAppSettings"/> requires an <see cref="IC
 84    /// </remarks>
 3385    public ConfigurationLoader(string environmentName, string? basePath = null,
 3386        ILogger<ConfigurationLoader>? logger = null)
 87    {
 3388        ArgumentException.ThrowIfNullOrWhiteSpace(environmentName);
 89
 2790        _environmentName = environmentName;
 2791        _basePath = string.IsNullOrEmpty(basePath) ? AppDomain.CurrentDomain.BaseDirectory : basePath;
 2792        _logger = logger ?? FallbackLoggerFactory.Value.CreateLogger<ConfigurationLoader>();
 2793    }
 94
 95    /// <summary>
 96    /// Loads environment variables from a specific <c>.env</c> file based on the configured environment.
 97    /// </summary>
 98    /// <remarks>
 99    /// <para>
 100    /// It looks for <c>Environments/.env.&lt;environment&gt;</c> and falls back to <c>Environments/.env.local</c> if no
 101    /// Only one file is loaded. The file name is matched without regard to case.
 102    /// </para>
 103    /// <para>
 104    /// Variables the process already has win over the file: a key that is already set is left alone, so values
 105    /// injected by the deployment are never overwritten by a <c>.env</c> file shipped with the build. The same rule
 106    /// applies within the file, so a key repeated in it keeps its first value, and a <c>${VAR}</c> reference
 107    /// resolves to the value that is actually in effect.
 108    /// </para>
 109    /// </remarks>
 110    public void LoadEnvironment()
 111    {
 14112        var envFolder = Path.Combine(_basePath, DefaultEnvFileFolder);
 14113        var envFile = ResolveFile(envFolder, $".env.{_environmentName}");
 14114        var defaultEnvFile = ResolveFile(envFolder, $".env.{DefaultEnvironmentName}");
 115
 14116        if (envFile is not null)
 117        {
 7118            _logger.LogInformation("Loading variables for {EnvironmentName} environment...", _environmentName);
 119
 7120            Env.Load(envFile, NoClobber);
 121        }
 7122        else if (defaultEnvFile is not null)
 123        {
 5124            _logger.LogInformation(
 5125                "Could not find variables for {EnvironmentName} environment. Loading default environment {DefaultEnviron
 5126                _environmentName, DefaultEnvironmentName);
 127
 5128            Env.Load(defaultEnvFile, NoClobber);
 129        }
 130        else
 131        {
 2132            _logger.LogInformation("Could not find any environment variables");
 133        }
 2134    }
 135
 136    /// <summary>
 137    /// Loads JSON application settings for the configured environment into the provided <see cref="IConfigurationBuilde
 138    /// </summary>
 139    /// <exception cref="InvalidOperationException">Thrown when no <see cref="IConfigurationBuilder"/> was provided in t
 140    /// <remarks>
 141    /// It looks for <c>Settings/appsettings.&lt;environment&gt;.json</c> and falls back to <c>Settings/appsettings.loca
 142    /// The file name is matched without regard to case.
 143    /// </remarks>
 144    public void LoadAppSettings()
 145    {
 12146        if (_configurationBuilder is null)
 147        {
 1148            throw new InvalidOperationException(
 1149                "Cannot load appsettings.json if configuration builder is not provided on constructor");
 150        }
 151
 11152        var settingsFolder = Path.Combine(_basePath, DefaultAppSettingsFolder);
 11153        var envSettingsFile = ResolveFile(settingsFolder, $"appsettings.{_environmentName}.json");
 11154        var defaultSettingsFile = ResolveFile(settingsFolder, $"appsettings.{DefaultEnvironmentName}.json");
 155
 11156        if (envSettingsFile is not null)
 157        {
 5158            _logger.LogInformation("Loading app settings for {EnvironmentName} environment...", _environmentName);
 159
 5160            _configurationBuilder.AddJsonFile(envSettingsFile, false, true);
 161        }
 6162        else if (defaultSettingsFile is not null)
 163        {
 4164            _logger.LogInformation(
 4165                "Could not find app settings for {EnvironmentName} environment. Loading default environment {DefaultEnvi
 4166                _environmentName, DefaultEnvironmentName);
 167
 4168            _configurationBuilder.AddJsonFile(defaultSettingsFile, false, true);
 169        }
 170        else
 171        {
 2172            _logger.LogInformation("Could not find any app settings");
 173        }
 2174    }
 175
 176    /// <summary>
 177    /// Finds <paramref name="fileName"/> inside <paramref name="folder"/>, ignoring case.
 178    /// </summary>
 179    /// <returns>The full path of the matching file, or <c>null</c> when the folder or the file is absent.</returns>
 180    /// <remarks>
 181    /// The exact-case path is probed first, which is the common case and costs a single stat call. Only when
 182    /// that misses is the directory enumerated, so a case-insensitive file system never pays for the scan.
 183    /// </remarks>
 184    private static string? ResolveFile(string folder, string fileName)
 185    {
 50186        var exact = Path.Combine(folder, fileName);
 187
 50188        if (File.Exists(exact))
 189        {
 8190            return exact;
 191        }
 192
 42193        if (!Directory.Exists(folder))
 194        {
 8195            return null;
 196        }
 197
 121198        foreach (var candidate in Directory.EnumerateFiles(folder))
 199        {
 36200            if (string.Equals(Path.GetFileName(candidate), fileName, StringComparison.OrdinalIgnoreCase))
 201            {
 19202                return candidate;
 203            }
 204        }
 205
 15206        return null;
 19207    }
 208}