diff --git a/docs/CHANGELOG-v3.md b/docs/CHANGELOG-v3.md index ba030b55c7..9c11d37b06 100644 --- a/docs/CHANGELOG-v3.md +++ b/docs/CHANGELOG-v3.md @@ -38,6 +38,8 @@ What's changed since pre-release v3.0.0-B0351: - Bug fixes: - Fixed string formatting of semantic version and constraints by @BernieWhite. [#1828](https://github.com/microsoft/PSRule/issues/1828) + - Fixed directory handling of input paths without trailing slash by @BernieWhite. + [#1842](https://github.com/microsoft/PSRule/issues/1842) ## v3.0.0-B0351 (pre-release) diff --git a/src/PSRule/Pipeline/InputPathBuilder.cs b/src/PSRule/Pipeline/InputPathBuilder.cs index cace5fb033..257867bc37 100644 --- a/src/PSRule/Pipeline/InputPathBuilder.cs +++ b/src/PSRule/Pipeline/InputPathBuilder.cs @@ -3,8 +3,10 @@ namespace PSRule.Pipeline; -internal sealed class InputPathBuilder : PathBuilder +/// +/// A builder for input paths. +/// +internal sealed class InputPathBuilder(IPipelineWriter logger, string basePath, string searchPattern, PathFilter filter, PathFilter required) + : PathBuilder(logger, basePath, searchPattern, filter, required) { - public InputPathBuilder(IPipelineWriter logger, string basePath, string searchPattern, PathFilter filter, PathFilter required) - : base(logger, basePath, searchPattern, filter, required) { } } diff --git a/src/PSRule/Pipeline/PathBuilder.cs b/src/PSRule/Pipeline/PathBuilder.cs index c9ea0c730e..67309aa990 100644 --- a/src/PSRule/Pipeline/PathBuilder.cs +++ b/src/PSRule/Pipeline/PathBuilder.cs @@ -7,18 +7,7 @@ namespace PSRule.Pipeline; -//public interface IPathBuilder -//{ -// void Add(string path); - -// void Add(FileInfo[] fileInfo); - -// void Add(PathInfo[] pathInfo); - -// InputFileInfo[] Build(); -//} - -internal abstract class PathBuilder +internal abstract class PathBuilder(IPipelineWriter logger, string basePath, string searchPattern, PathFilter filter, PathFilter required) { // Path separators private const char Slash = '/'; @@ -28,27 +17,16 @@ internal abstract class PathBuilder private const string CurrentPath = "."; private const string RecursiveSearchOperator = "**"; - private static readonly char[] PathLiteralStopCharacters = new char[] { '*', '[', '?' }; - private static readonly char[] PathSeparatorCharacters = new char[] { '\\', '/' }; + private static readonly char[] PathLiteralStopCharacters = ['*', '[', '?']; + private static readonly char[] PathSeparatorCharacters = ['\\', '/']; - private readonly IPipelineWriter _Logger; - private readonly List _Files; - private readonly HashSet _Paths; - private readonly string _BasePath; - private readonly string _DefaultSearchPattern; - private readonly PathFilter _GlobalFilter; - private readonly PathFilter _Required; - - protected PathBuilder(IPipelineWriter logger, string basePath, string searchPattern, PathFilter filter, PathFilter required) - { - _Logger = logger; - _Files = []; - _Paths = []; - _BasePath = NormalizePath(Environment.GetRootedBasePath(basePath)); - _DefaultSearchPattern = searchPattern; - _GlobalFilter = filter; - _Required = required; - } + private readonly IPipelineWriter _Logger = logger; + private readonly List _Files = []; + private readonly HashSet _Paths = []; + private readonly string _BasePath = NormalizePath(Environment.GetRootedBasePath(basePath)); + private readonly string _DefaultSearchPattern = searchPattern; + private readonly PathFilter _GlobalFilter = filter; + private readonly PathFilter _Required = required; /// /// The number of files found. @@ -89,7 +67,7 @@ public InputFileInfo[] Build() { try { - return _Files.ToArray(); + return [.. _Files]; } finally { @@ -105,9 +83,14 @@ private void FindFiles(string path) var pathLiteral = GetSearchParameters(path, out var searchPattern, out var searchOption, out var filter); var files = Directory.EnumerateFiles(pathLiteral, searchPattern, searchOption); + foreach (var file in files) + { if (ShouldInclude(file, filter)) + { AddFile(file); + } + } } private bool TryUrl(string path) @@ -128,9 +111,7 @@ private bool TryPath(string path, out string normalPath) var rootedPath = GetRootedPath(path); if (Directory.Exists(rootedPath) || path == CurrentPath) { - if (IsBasePath(rootedPath)) - normalPath = CurrentPath; - + normalPath = IsBasePath(rootedPath) ? CurrentPath : NormalizeDirectoryPath(path); return false; } if (!File.Exists(rootedPath)) @@ -144,8 +125,7 @@ private bool TryPath(string path, out string normalPath) private bool IsBasePath(string path) { - path = IsSeparator(path[path.Length - 1]) ? path : string.Concat(path, Path.DirectorySeparatorChar); - return NormalizePath(path) == _BasePath; + return NormalizeDirectoryPath(path) == _BasePath; } private void ErrorNotFound(string path) @@ -251,7 +231,7 @@ private static bool IsSeparator(char c) [DebuggerStepThrough] private static bool UseSimpleSearch(string s) { - return s.IndexOf(RecursiveSearchOperator, System.StringComparison.OrdinalIgnoreCase) == -1; + return s.IndexOf(RecursiveSearchOperator, StringComparison.OrdinalIgnoreCase) == -1; } [DebuggerStepThrough] @@ -259,4 +239,12 @@ private static string NormalizePath(string path) { return path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); } + + [DebuggerStepThrough] + private static string NormalizeDirectoryPath(string path) + { + return NormalizePath( + IsSeparator(path[path.Length - 1]) ? path : string.Concat(path, Path.DirectorySeparatorChar) + ); + } } diff --git a/tests/PSRule.Tests/InputPathBuilderTests.cs b/tests/PSRule.Tests/InputPathBuilderTests.cs deleted file mode 100644 index 732cbf64aa..0000000000 --- a/tests/PSRule.Tests/InputPathBuilderTests.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using System.IO; -using System.Linq; -using PSRule.Pipeline; - -namespace PSRule; - -public sealed class InputPathBuilderTests -{ - [Fact] - public void GetPath() - { - var builder = new InputPathBuilder(null, GetWorkingPath(), "*", null, null); - builder.Add("."); - var actual = builder.Build(); - Assert.True(actual.Length > 100); - - builder.Add(GetWorkingPath()); - actual = builder.Build(); - Assert.True(actual.Length > 100); - - builder.Add("./src"); - actual = builder.Build(); - Assert.True(actual.Length == 0); - - builder.Add("./src/"); - actual = builder.Build(); - Assert.True(actual.Length > 100); - - builder.Add("./"); - actual = builder.Build(); - Assert.True(actual.Length > 100); - - builder.Add("./.github/*.yml"); - actual = builder.Build(); - Assert.Single(actual); - - builder.Add("./.github/**/*.yaml"); - actual = builder.Build(); - Assert.Equal(9, actual.Length); - - builder.Add("./.github/"); - actual = builder.Build(); - Assert.Equal(12, actual.Length); - - builder.Add(".github/"); - actual = builder.Build(); - Assert.Equal(12, actual.Length); - - builder.Add("./*.json"); - actual = builder.Build(); - Assert.Equal(8, actual.Length); - - builder.Add("src/"); - actual = builder.Build(); - Assert.True(actual.Length > 100); - - // Check error handling - var writer = new TestWriter(new Configuration.PSRuleOption()); - builder = new InputPathBuilder(writer, GetWorkingPath(), "*", null, null); - builder.Add("ZZ://not/path"); - actual = builder.Build(); - Assert.Empty(actual); - Assert.True(writer.Errors.Count(r => r.FullyQualifiedErrorId == "PSRule.ReadInputFailed") == 1); - } - - [Fact] - public void GetPathRequired() - { - var required = PathFilter.Create(GetWorkingPath(), new string[] { "README.md" }); - var builder = new InputPathBuilder(null, GetWorkingPath(), "*", null, required); - builder.Add("."); - var actual = builder.Build(); - Assert.True(actual.Length == 1); - - builder.Add(GetWorkingPath()); - actual = builder.Build(); - Assert.True(actual.Length == 1); - - required = PathFilter.Create(GetWorkingPath(), new string[] { "**" }); - builder = new InputPathBuilder(null, GetWorkingPath(), "*", null, required); - builder.Add("."); - actual = builder.Build(); - Assert.True(actual.Length > 100); - } - - #region Helper methods - - private static string GetWorkingPath() - { - return Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "../../../../..")); - } - - #endregion Helper methods -} diff --git a/tests/PSRule.Tests/Pipeline/InputPathBuilderTests.cs b/tests/PSRule.Tests/Pipeline/InputPathBuilderTests.cs new file mode 100644 index 0000000000..02828a6fc6 --- /dev/null +++ b/tests/PSRule.Tests/Pipeline/InputPathBuilderTests.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; + +namespace PSRule.Pipeline; + +/// +/// Tests for . +/// +public sealed class InputPathBuilderTests +{ + [Theory] + [InlineData("./.github/*.yml", 1)] + [InlineData("./.github/**/*.yaml", 9)] + [InlineData("./.github/", 12)] + [InlineData(".github/", 12)] + [InlineData(".github", 12)] + [InlineData("./*.json", 8)] + public void Build_WithValidPathAdded_ShouldReturnFiles(string path, int expected) + { + var builder = new InputPathBuilder(null, GetWorkingPath(), "*", null, null); + builder.Add(path); + var actual = builder.Build(); + + Assert.Equal(expected, actual.Length); + } + + [Theory] + [InlineData(".")] + [InlineData("./")] + [InlineData("./src")] + [InlineData("./src/")] + [InlineData("src/")] + public void Build_WithValidPathAdded_ShouldReturnManyFiles(string path) + { + var builder = new InputPathBuilder(null, GetWorkingPath(), "*", null, null); + builder.Add(path); + var actual = builder.Build(); + + Assert.True(actual.Length > 100); + } + + [Fact] + public void Build_WithWorkingPathAdded_ShouldReturnFiles() + { + var builder = new InputPathBuilder(null, GetWorkingPath(), "*", null, null); + builder.Add(GetWorkingPath()); + var actual = builder.Build(); + + Assert.True(actual.Length > 100); + } + + /// + /// Test that an invalid path is handled correctly. + /// Should not return any files, and should log an error. + /// + [Fact] + public void Build_WithInvalidPathAdded_ShouldReturnEmpty() + { + var writer = new TestWriter(new Configuration.PSRuleOption()); + var builder = new InputPathBuilder(writer, GetWorkingPath(), "*", null, null); + builder.Add("ZZ://not/path"); + var actual = builder.Build(); + + Assert.Empty(actual); + Assert.True(writer.Errors.Count(r => r.FullyQualifiedErrorId == "PSRule.ReadInputFailed") == 1); + } + + [Fact] + public void GetPathRequired() + { + var required = PathFilter.Create(GetWorkingPath(), ["README.md"]); + var builder = new InputPathBuilder(null, GetWorkingPath(), "*", null, required); + builder.Add("."); + var actual = builder.Build(); + Assert.True(actual.Length == 1); + + builder.Add(GetWorkingPath()); + actual = builder.Build(); + Assert.True(actual.Length == 1); + + required = PathFilter.Create(GetWorkingPath(), ["**"]); + builder = new InputPathBuilder(null, GetWorkingPath(), "*", null, required); + builder.Add("."); + actual = builder.Build(); + Assert.True(actual.Length > 100); + } + + #region Helper methods + + private static string GetWorkingPath() + { + return Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "../../../../..")); + } + + #endregion Helper methods +}