diff --git a/src/dotenv.net.Tests/DotEnvOptionsTests.cs b/src/dotenv.net.Tests/DotEnvOptionsTests.cs index 5dc29e9..e9472a7 100644 --- a/src/dotenv.net.Tests/DotEnvOptionsTests.cs +++ b/src/dotenv.net.Tests/DotEnvOptionsTests.cs @@ -31,19 +31,36 @@ public void Constructor_WithNullEncoding_ShouldUseUtf8() } [Fact] - public void WithEncoding_WithNullEncoding_ShouldUseUtf8() + public void Constructor_WithProbeForEnvAndNoLevels_ShouldSetDefaults() + { + var options = new DotEnvOptions(probeForEnv: true, envFilePaths: null); + options.ProbeForEnv.ShouldBeTrue(); + options.ProbeLevelsToSearch.ShouldBe(DotEnvOptions.DefaultProbeAscendLimit); + } + + [Fact] + public void Constructor_WithProbeForEnvAndExplicitLevels_ShouldRespectProvidedLevel() + { + var options = new DotEnvOptions(probeForEnv: true, probeLevelsToSearch: 2, envFilePaths: null); + options.ProbeLevelsToSearch.ShouldBe(2); + } + + [Fact] + public void WithEncoding_WithNullEncoding_ShouldThrowException() { var options = new DotEnvOptions(); - options.WithEncoding(null!); - options.Encoding.ShouldBe(Encoding.UTF8); + Action action = () => options.WithEncoding(null!); + action.ShouldThrow() + .Message.ShouldBe("Encoding cannot be null (Parameter 'encoding')"); } [Fact] - public void WithEnvFiles_WithNullParams_ShouldUseDefaultPath() + public void WithEnvFiles_WithNullParams_ShouldThrowException() { var options = new DotEnvOptions(); - options.WithEnvFiles(null!); - options.EnvFilePaths.ShouldBe([DotEnvOptions.DefaultEnvFileName]); + Action action = () => options.WithEnvFiles(null!); + action.ShouldThrow() + .Message.ShouldBe("EnvFilePaths cannot be null (Parameter 'envFilePaths')"); } [Fact] @@ -54,6 +71,22 @@ public void WithEnvFiles_WithEmptyParams_ShouldUseDefaultPath() options.EnvFilePaths.ShouldBe([DotEnvOptions.DefaultEnvFileName]); } + [Fact] + public void WithEnvFiles_WhenProbeForEnvIsTrue_ShouldThrow() + { + var options = new DotEnvOptions(probeForEnv: true); + var ex = Should.Throw(() => options.WithEnvFiles("custom.env")); + ex.Message.ShouldBe("EnvFiles paths cannot be set when ProbeForEnv is true"); + } + + [Fact] + public void WithEnvFiles_WithNonEmptyList_ShouldSetPaths() + { + var options = new DotEnvOptions(); + options.WithEnvFiles("test.env"); + options.EnvFilePaths.ShouldBe(["test.env"]); + } + [Fact] public void WithProbeForEnv_WithNegativeProbeLevels_ShouldUseDefaultProbeDepth() { @@ -62,6 +95,14 @@ public void WithProbeForEnv_WithNegativeProbeLevels_ShouldUseDefaultProbeDepth() options.ProbeLevelsToSearch.ShouldBe(DotEnvOptions.DefaultProbeAscendLimit); } + [Fact] + public void WithProbeForEnv_WhenCustomEnvFilePathSet_ShouldThrow() + { + var options = new DotEnvOptions(envFilePaths: new[] { "custom.env" }); + var ex = Should.Throw(() => options.WithProbeForEnv()); + ex.Message.ShouldBe("Cannot use ProbeForEnv when EnvFiles is set."); + } + [Fact] public void WithoutProbeForEnv_ShouldResetProbeLevelsToDefault() { @@ -71,14 +112,6 @@ public void WithoutProbeForEnv_ShouldResetProbeLevelsToDefault() options.ProbeLevelsToSearch.ShouldBe(DotEnvOptions.DefaultProbeAscendLimit); } - [Fact] - public void WithDefaultEncoding_ShouldResetToUtf8() - { - var options = new DotEnvOptions().WithEncoding(Encoding.ASCII); - options.WithDefaultEncoding(); - options.Encoding.ShouldBe(Encoding.UTF8); - } - [Theory] [InlineData(true)] [InlineData(false)] diff --git a/src/dotenv.net.Tests/ReaderTests.cs b/src/dotenv.net.Tests/ReaderTests.cs index b0cbe5a..fe76aef 100644 --- a/src/dotenv.net.Tests/ReaderTests.cs +++ b/src/dotenv.net.Tests/ReaderTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using Shouldly; using Xunit; @@ -10,39 +11,48 @@ namespace dotenv.net.Tests; public class ReaderTests : IDisposable { private readonly string _tempFilePath; - private readonly string _tempDirPath; + + private readonly string _testRootPath; + private readonly string _startPath; public ReaderTests() { _tempFilePath = Path.GetTempFileName(); - _tempDirPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - Directory.CreateDirectory(_tempDirPath); + + // Create a unique root directory for this test run in the system's temp folder. + _testRootPath = Path.Combine(Path.GetTempPath(), "DotEnvTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_testRootPath); + + _startPath = AppContext.BaseDirectory; } public void Dispose() { if (File.Exists(_tempFilePath)) File.Delete(_tempFilePath); - - if (Directory.Exists(_tempDirPath)) - Directory.Delete(_tempDirPath, true); + + if (Directory.Exists(_testRootPath)) + Directory.Delete(_testRootPath, true); } [Theory] [InlineData(null, false)] [InlineData("", false)] [InlineData(" ", false)] - public void ReadFileLines_InvalidPathAndIgnoreExceptionsFalse_ShouldThrowArgumentException(string path, bool ignoreExceptions) + public void ReadFileLines_InvalidPathAndIgnoreExceptionsFalse_ShouldThrowArgumentException(string path, + bool ignoreExceptions) { Action act = () => Reader.ReadFileLines(path, ignoreExceptions, null); - act.ShouldThrow().Message.ShouldContain("The file path cannot be null, empty or whitespace."); + act.ShouldThrow().Message + .ShouldContain("The file path cannot be null, empty or whitespace."); } [Theory] [InlineData(null, true)] [InlineData("", true)] [InlineData(" ", true)] - public void ReadFileLines_InvalidPathAndIgnoreExceptionsTrue_ShouldReturnEmptySpan(string path, bool ignoreExceptions) + public void ReadFileLines_InvalidPathAndIgnoreExceptionsTrue_ShouldReturnEmptySpan(string path, + bool ignoreExceptions) { var result = Reader.ReadFileLines(path, ignoreExceptions, null).ToArray(); result.ShouldBeEmpty(); @@ -51,9 +61,9 @@ public void ReadFileLines_InvalidPathAndIgnoreExceptionsTrue_ShouldReturnEmptySp [Fact] public void ReadFileLines_NonExistentFileAndIgnoreExceptionsFalse_ShouldThrowFileNotFoundException() { - var path = "nonexistent.env"; + const string path = "nonexistent.env"; Action act = () => Reader.ReadFileLines(path, false, null); - act.ShouldThrow().Message.ShouldContain (path); + act.ShouldThrow().Message.ShouldContain(path); } [Fact] @@ -76,7 +86,7 @@ public void ReadFileLines_ValidFile_ShouldReturnLines() [Fact] public void ReadFileLines_WithCustomEncoding_ShouldReturnCorrectContent() { - var content = "KEY=üñîçø∂é"; + const string content = "KEY=üñîçø∂é"; File.WriteAllText(_tempFilePath, content, Encoding.UTF32); var result = Reader.ReadFileLines(_tempFilePath, false, Encoding.UTF32); result[0].ShouldBe(content); @@ -109,8 +119,13 @@ public void MergeEnvKeyValues_NoArrays_ShouldReturnEmptyDictionary() [Fact] public void MergeEnvKeyValues_SingleArray_ShouldReturnAllItems() { - var input = new[] { - new[] { new KeyValuePair("KEY1", "value1"), new KeyValuePair("KEY2", "value2") } + var input = new[] + { + new[] + { + new KeyValuePair("KEY1", "value1"), + new KeyValuePair("KEY2", "value2") + } }; var result = Reader.MergeEnvKeyValues(input, false); result.ShouldBe(new Dictionary { { "KEY1", "value1" }, { "KEY2", "value2" } }); @@ -119,7 +134,8 @@ public void MergeEnvKeyValues_SingleArray_ShouldReturnAllItems() [Fact] public void MergeEnvKeyValues_MultipleArraysWithoutOverwrite_ShouldKeepFirstValue() { - var input = new[] { + var input = new[] + { new[] { new KeyValuePair("KEY", "first") }, new[] { new KeyValuePair("KEY", "second") } }; @@ -131,7 +147,8 @@ public void MergeEnvKeyValues_MultipleArraysWithoutOverwrite_ShouldKeepFirstValu [Fact] public void MergeEnvKeyValues_MultipleArraysWithOverwrite_ShouldKeepLastValue() { - var input = new[] { + var input = new[] + { new[] { new KeyValuePair("KEY", "first") }, new[] { new KeyValuePair("KEY", "second") } }; @@ -143,60 +160,78 @@ public void MergeEnvKeyValues_MultipleArraysWithOverwrite_ShouldKeepLastValue() [Fact] public void MergeEnvKeyValues_ComplexMerge_ShouldHandleAllCases() { - var input = new[] { - new[] { + var input = new[] + { + new[] + { new KeyValuePair("KEY1", "value1"), new KeyValuePair("KEY2", "value2") }, - new[] { + new[] + { new KeyValuePair("KEY2", "updated"), new KeyValuePair("KEY3", "value3") } }; var result = Reader.MergeEnvKeyValues(input, true); - result.ShouldBe(new Dictionary { { "KEY1", "value1" }, { "KEY2", "updated" }, { "KEY3", "value3" } }); + result.ShouldBe(new Dictionary + { { "KEY1", "value1" }, { "KEY2", "updated" }, { "KEY3", "value3" } }); } [Fact] public void GetProbedEnvPath_FileNotFoundAndIgnoreExceptionsFalse_ShouldThrow() { - using var dir = new TempWorkingDirectory(_tempDirPath); - Action act = () => Reader.GetProbedEnvPath(levelsToSearch: 2, ignoreExceptions: false); - act.ShouldThrow() - .Message.ShouldContain(DotEnvOptions.DefaultEnvFileName); + var levelsToSearch = 2; + var exception = Should.Throw(() => + { + Reader.GetProbedEnvPath(levelsToSearch, ignoreExceptions: false); + }); + + exception.Message.ShouldContain($"Could not find '{DotEnvOptions.DefaultEnvFileName}'"); + exception.Message.ShouldContain($"after searching {levelsToSearch} directory level(s) upwards."); + exception.Message.ShouldContain("Searched paths:"); + exception.Message.ShouldContain(_startPath); + exception.Message.ShouldContain("net9.0"); + exception.Message.ShouldContain("Debug"); } [Fact] - public void GetProbedEnvPath_FileNotFoundAndIgnoreExceptionsTrue_ShouldReturnNull() + public void GetProbedEnvPath_FileNotFoundAndIgnoreExceptionsTrue_ShouldReturnEmpty() { - using var dir = new TempWorkingDirectory(_tempDirPath); var result = Reader.GetProbedEnvPath(levelsToSearch: 2, ignoreExceptions: true); - result.ShouldBeNull(); + result.ShouldBeEmpty(); } [Fact] - public void GetProbedEnvPath_LevelsTooLow_ShouldNotFindFile() + public void GetProbedEnvPath_ShouldFindFile_ThreeLevelsUp() { - var envPath = Path.Combine(_tempDirPath, ".env"); - File.WriteAllText(envPath, "TEST=value"); - var startDir = Path.Combine(_tempDirPath, "subdir1", "subdir2", "subdir3"); - Directory.CreateDirectory(startDir); + var grandParentDirectory = Directory.GetParent(_startPath)!.Parent!.Parent!.Parent!.FullName; + var expectedPath = Path.Combine(grandParentDirectory, DotEnvOptions.DefaultEnvFileName); - using var dir = new TempWorkingDirectory(startDir); - var result = Reader.GetProbedEnvPath(levelsToSearch: 2, ignoreExceptions: true); - result.ShouldBeNull(); + var result = Reader.GetProbedEnvPath(levelsToSearch: 3, ignoreExceptions: true).ToList(); + + result.ShouldHaveSingleItem(); + result.First().ShouldBe(expectedPath); } - private class TempWorkingDirectory : IDisposable + [Fact] + public void GetProbedEnvPath_ShouldReturnEmpty_WhenFileExistsButIsOutOfSearchRange() { - private readonly string _originalDirectory; + // File is at level 3, but we only search up to level 1. + var result = Reader.GetProbedEnvPath(levelsToSearch: 1, ignoreExceptions: true); - public TempWorkingDirectory(string path) + result.ShouldBeEmpty(); + } + + [Fact] + public void GetProbedEnvPath_ShouldThrow_WhenFileExistsButIsOutOfSearchRange() + { + // File is at level 3, but we only search up to level 1. + var exception = Should.Throw(() => { - _originalDirectory = Directory.GetCurrentDirectory(); - Directory.SetCurrentDirectory(path); - } + Reader.GetProbedEnvPath(levelsToSearch: 1, ignoreExceptions: false); + }); - public void Dispose() => Directory.SetCurrentDirectory(_originalDirectory); + exception.Message.ShouldContain($"Could not find '{DotEnvOptions.DefaultEnvFileName}'"); } -} \ No newline at end of file +} diff --git a/src/dotenv.net/DotEnv.cs b/src/dotenv.net/DotEnv.cs index d9431c6..d6d3164 100644 --- a/src/dotenv.net/DotEnv.cs +++ b/src/dotenv.net/DotEnv.cs @@ -19,7 +19,7 @@ public static IDictionary Read(DotEnvOptions? options = null) { options ??= new DotEnvOptions(); var envFilePaths = options.ProbeForEnv - ? [Reader.GetProbedEnvPath(options.ProbeLevelsToSearch, options.IgnoreExceptions)] + ? Reader.GetProbedEnvPath(options.ProbeLevelsToSearch!.Value, options.IgnoreExceptions) : options.EnvFilePaths; var envFileKeyValues = envFilePaths .Select(envFilePath => diff --git a/src/dotenv.net/DotEnvOptions.cs b/src/dotenv.net/DotEnvOptions.cs index e987791..641473d 100644 --- a/src/dotenv.net/DotEnvOptions.cs +++ b/src/dotenv.net/DotEnvOptions.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Text; @@ -44,7 +45,7 @@ public class DotEnvOptions /// /// A value to state how far up the directory structure we should search for env files. /// - public int ProbeLevelsToSearch { get; private set; } + public int? ProbeLevelsToSearch { get; private set; } /// /// Default constructor for the dot env options @@ -58,15 +59,30 @@ public class DotEnvOptions /// The env file paths to load public DotEnvOptions(bool ignoreExceptions = true, IEnumerable? envFilePaths = null, Encoding? encoding = null, bool trimValues = false, bool overwriteExistingVars = true, - bool probeForEnv = false, int probeLevelsToSearch = DefaultProbeAscendLimit) + bool probeForEnv = false, int? probeLevelsToSearch = null) { - IgnoreExceptions = ignoreExceptions; - EnvFilePaths = envFilePaths?.Any() != true ? DefaultEnvPath : envFilePaths; - Encoding = encoding ?? Encoding.UTF8; - TrimValues = trimValues; - OverwriteExistingVars = overwriteExistingVars; - ProbeForEnv = probeForEnv; - ProbeLevelsToSearch = probeLevelsToSearch < 0 ? DefaultProbeAscendLimit : probeLevelsToSearch; + if (ignoreExceptions) + WithoutExceptions(); + else + WithoutOverwriteExistingVars(); + + WithEnvFiles((envFilePaths ?? []).ToArray()); + WithEncoding(encoding ?? Encoding.UTF8); + + if (trimValues) + WithTrimValues(); + else + WithoutTrimValues(); + + if (overwriteExistingVars) + WithOverwriteExistingVars(); + else + WithoutOverwriteExistingVars(); + + if (probeForEnv) + WithProbeForEnv(probeLevelsToSearch ?? DefaultProbeAscendLimit); + else + WithoutProbeForEnv(); } /// @@ -95,8 +111,11 @@ public DotEnvOptions WithoutExceptions() /// configured dot env options public DotEnvOptions WithProbeForEnv(int probeLevelsToSearch = DefaultProbeAscendLimit) { + if (EnvFilePaths?.FirstOrDefault() != DefaultEnvFileName) + throw new InvalidOperationException("Cannot use ProbeForEnv when EnvFiles is set."); + ProbeForEnv = true; - ProbeLevelsToSearch = probeLevelsToSearch < 0 ? DefaultProbeAscendLimit : probeLevelsToSearch; + ProbeLevelsToSearch = probeLevelsToSearch < 0 ? DefaultProbeAscendLimit : probeLevelsToSearch; return this; } @@ -157,17 +176,10 @@ public DotEnvOptions WithoutTrimValues() /// configured dot env options public DotEnvOptions WithEncoding(Encoding encoding) { - Encoding = encoding ?? Encoding.UTF8; - return this; - } + if (encoding == null ) + throw new ArgumentNullException(nameof(encoding), "Encoding cannot be null"); - /// - /// Revert to the default encoding for reading the env files. The default encoding is UTF-8 - /// - /// configured dot env options - public DotEnvOptions WithDefaultEncoding() - { - Encoding = Encoding.UTF8; + Encoding = encoding; return this; } @@ -177,7 +189,13 @@ public DotEnvOptions WithDefaultEncoding() /// configured dot env options public DotEnvOptions WithEnvFiles(params string[] envFilePaths) { - EnvFilePaths = envFilePaths?.Any() != true ? DefaultEnvPath : envFilePaths; + if (ProbeForEnv) + throw new InvalidOperationException("EnvFiles paths cannot be set when ProbeForEnv is true"); + + if (envFilePaths == null ) + throw new ArgumentNullException(nameof(envFilePaths), "EnvFilePaths cannot be null"); + + EnvFilePaths = envFilePaths.Any() != true ? DefaultEnvPath : envFilePaths; return this; } diff --git a/src/dotenv.net/Reader.cs b/src/dotenv.net/Reader.cs index a5e4ec0..eb0e356 100644 --- a/src/dotenv.net/Reader.cs +++ b/src/dotenv.net/Reader.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; namespace dotenv.net; @@ -56,31 +57,35 @@ internal static Dictionary MergeEnvKeyValues( return response; } - internal static string GetProbedEnvPath(int levelsToSearch, bool ignoreExceptions) + internal static IEnumerable GetProbedEnvPath(int levelsToSearch, bool ignoreExceptions) { - var currentDirectory = new DirectoryInfo(AppContext.BaseDirectory); + var pathsSearched = new List(); var count = levelsToSearch; var foundEnvPath = SearchPaths(); if (string.IsNullOrEmpty(foundEnvPath) && !ignoreExceptions) throw new FileNotFoundException( - $"Failed to find a file matching the '{DotEnvOptions.DefaultEnvFileName}' search pattern." + - $"{Environment.NewLine}Current Directory: {currentDirectory}" + - $"{Environment.NewLine}Levels Searched: {levelsToSearch}"); - - return foundEnvPath; + $"Could not find '{DotEnvOptions.DefaultEnvFileName}' after searching {levelsToSearch} directory level(s) upwards.{Environment.NewLine}Searched paths:{Environment.NewLine}{string.Join(Environment.NewLine, pathsSearched)}"); + return foundEnvPath == null ? [] : [foundEnvPath]; string? SearchPaths() { - for (; - currentDirectory != null && count > 0; - count--, currentDirectory = currentDirectory.Parent - ) - foreach (var file in currentDirectory.GetFiles( - DotEnvOptions.DefaultEnvFileName, SearchOption.TopDirectoryOnly) - ) - return file.FullName; + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + for (var i = 0; i <= count; i++) + { + if (directory == null) + break; + + pathsSearched.Add(directory.FullName); + + foreach (var fileInfo in directory.EnumerateFiles(DotEnvOptions.DefaultEnvFileName, + SearchOption.TopDirectoryOnly)) + return fileInfo.FullName; + + directory = directory.Parent; + } return null; }