diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 29d990c..7baa139 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -182,14 +182,33 @@ reviews: - "src/**/SelfTest/Validation.cs" # implementation - "test/**/SelfTest/SelfTestTests.cs" # subsystem tests (no separate unit test file) - - id: VersionMark-SelfTest-PathHelpers - title: Review that VersionMark SelfTest PathHelpers Implementation is Correct - paths: - - "docs/reqstream/version-mark/self-test/path-helpers.yaml" # requirements - - "docs/design/version-mark/self-test/path-helpers.md" # design - - "docs/verification/version-mark/self-test/path-helpers.md" # verification - - "src/**/SelfTest/PathHelpers.cs" # implementation - - "test/**/SelfTest/PathHelpersTests.cs" # unit tests + # VersionMark - Utilities + - id: VersionMark-Utilities + title: Review that VersionMark Utilities Satisfies Subsystem Requirements + paths: + - "docs/reqstream/version-mark/utilities.yaml" + - "docs/design/version-mark/utilities.md" + - "docs/verification/version-mark/utilities.md" + - "test/**/Utilities/GlobMatcherTests.cs" # subsystem tests (no separate subsystem test file needed) + - "test/**/Utilities/PathHelpersTests.cs" # subsystem tests + + - id: VersionMark-Utilities-GlobMatcher + title: Review that VersionMark Utilities GlobMatcher Implementation is Correct + paths: + - "docs/reqstream/version-mark/utilities/glob-matcher.yaml" + - "docs/design/version-mark/utilities/glob-matcher.md" + - "docs/verification/version-mark/utilities/glob-matcher.md" + - "src/**/Utilities/GlobMatcher.cs" + - "test/**/Utilities/GlobMatcherTests.cs" + + - id: VersionMark-Utilities-PathHelpers + title: Review that VersionMark Utilities PathHelpers Implementation is Correct + paths: + - "docs/reqstream/version-mark/utilities/path-helpers.yaml" # requirements + - "docs/design/version-mark/utilities/path-helpers.md" # design + - "docs/verification/version-mark/utilities/path-helpers.md" # verification + - "src/**/Utilities/PathHelpers.cs" # implementation + - "test/**/Utilities/PathHelpersTests.cs" # unit tests # OTS Items - id: OTS-BuildMark diff --git a/docs/design/definition.yaml b/docs/design/definition.yaml index 378d38e..4874a70 100644 --- a/docs/design/definition.yaml +++ b/docs/design/definition.yaml @@ -19,7 +19,9 @@ input-files: - docs/design/version-mark/publishing/markdown-formatter.md - docs/design/version-mark/self-test.md - docs/design/version-mark/self-test/validation.md - - docs/design/version-mark/self-test/path-helpers.md + - docs/design/version-mark/utilities.md + - docs/design/version-mark/utilities/glob-matcher.md + - docs/design/version-mark/utilities/path-helpers.md template: template.html table-of-contents: true number-sections: true diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 9828179..e56fad0 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -15,7 +15,7 @@ The purpose of this document is to: ## Scope -This document covers the design of five subsystems within VersionMark: +This document covers the design of six subsystems within VersionMark: - The **Cli Subsystem**: the `Program` entry point and `Context` class that handle argument parsing, output routing, and program flow control @@ -25,8 +25,10 @@ This document covers the design of five subsystems within VersionMark: captured version data to and from JSON - The **Publishing Subsystem**: the `MarkdownFormatter` class that generates the markdown version report from captured data -- The **SelfTest Subsystem**: the `Validation` class and `PathHelpers` utility that - together provide built-in verification of the tool's core functionality +- The **SelfTest Subsystem**: the `Validation` class that provides built-in verification + of the tool's core functionality +- The **Utilities Subsystem**: the `GlobMatcher` class that provides glob-pattern file matching + and the `PathHelpers` class that provides safe path combination for use by other subsystems This document does not cover installation, end-user usage patterns, or the CI/CD pipeline configuration. Those topics are addressed in the *VersionMark User Guide* and the @@ -50,8 +52,10 @@ VersionMark (System) Version capture/publish tool │ └── VersionInfo (Unit) JSON version data record ├── Publishing (Subsystem) Markdown report publishing │ └── MarkdownFormatter (Unit) Version report formatter -└── SelfTest (Subsystem) Built-in self-validation - ├── Validation (Unit) Self-validation runner +├── SelfTest (Subsystem) Built-in self-validation +│ └── Validation (Unit) Self-validation runner +└── Utilities (Subsystem) General-purpose helper utilities + ├── GlobMatcher (Unit) Glob-pattern file matching └── PathHelpers (Unit) Safe path combination ``` @@ -74,8 +78,10 @@ src/DemaConsulting.VersionMark/ │ └── VersionInfo.cs — captured version data record ├── Publishing/ │ └── MarkdownFormatter.cs — markdown report generation -└── SelfTest/ - ├── Validation.cs — self-validation test runner +├── SelfTest/ +│ └── Validation.cs — self-validation test runner +└── Utilities/ + ├── GlobMatcher.cs — glob-pattern file matching └── PathHelpers.cs — safe path utilities ``` diff --git a/docs/design/version-mark.md b/docs/design/version-mark.md index ee656a9..f7fb723 100644 --- a/docs/design/version-mark.md +++ b/docs/design/version-mark.md @@ -59,15 +59,18 @@ Cli Subsystem → Configuration Subsystem → (shell) ### Publish Mode ```text -Cli Subsystem → Capture Subsystem (VersionInfo.LoadFromFile) → Publishing Subsystem - ↓ - markdown report file +Cli Subsystem → Utilities Subsystem (GlobMatcher.FindMatchingFiles) + ↓ + Capture Subsystem (VersionInfo.LoadFromFile) → Publishing Subsystem + ↓ + markdown report file ``` 1. The Cli Subsystem (Program) parses arguments and calls `RunPublish`. -2. `RunPublish` resolves glob patterns, then uses the Capture Subsystem to load each - JSON file via `VersionInfo.LoadFromFile`. -3. The Publishing Subsystem (`MarkdownFormatter.Format`) converts the loaded records into +2. `RunPublish` uses `GlobMatcher.FindMatchingFiles` (Utilities Subsystem) to resolve glob + patterns into a concrete list of JSON file paths. +3. The Capture Subsystem loads each JSON file via `VersionInfo.LoadFromFile`. +4. The Publishing Subsystem (`MarkdownFormatter.Format`) converts the loaded records into a markdown string, which is written to the report file. ### Lint Mode diff --git a/docs/design/version-mark/self-test.md b/docs/design/version-mark/self-test.md index c11ce1b..47f81c6 100644 --- a/docs/design/version-mark/self-test.md +++ b/docs/design/version-mark/self-test.md @@ -2,10 +2,8 @@ ### Overview -The SelfTest subsystem provides built-in verification of the tool's core functionality -and safe path construction for use within that verification. It consists of two units: -`Validation` (the self-validation test runner) and `PathHelpers` (a safe path combination -utility used internally by `Validation`). +The SelfTest subsystem provides built-in verification of the tool's core functionality. +It consists of one unit: `Validation` (the self-validation test runner). The validation subsystem is invoked when the `--validate` flag is passed and can write results to a TRX or JUnit XML file when `--results` is also provided. This satisfies @@ -22,21 +20,11 @@ writes a structured results file. See *Validation Unit Design* for the full unit design. -#### PathHelpers - -The `PathHelpers` class (`PathHelpers.cs`) provides a single static method, -`SafePathCombine`, used internally by `Validation` when constructing paths inside temporary -directories. It protects against path-traversal attacks by ensuring the resolved combined -path stays within the intended base directory. - -See *PathHelpers Unit Design* for the full unit design. - ### Subsystem Interactions `Validation.Run` creates temporary directories via the private `TemporaryDirectory` helper -class and uses `PathHelpers.SafePathCombine` for all path construction within those -directories. `PathHelpers` has no dependency on `Validation` and may be considered a pure -utility within the subsystem. +class and uses `PathHelpers.SafePathCombine` from the Utilities subsystem for all path +construction within those directories. The subsystem depends on: diff --git a/docs/design/version-mark/utilities.md b/docs/design/version-mark/utilities.md new file mode 100644 index 0000000..d7ba00a --- /dev/null +++ b/docs/design/version-mark/utilities.md @@ -0,0 +1,40 @@ +## Utilities Subsystem + +### Overview + +The Utilities subsystem provides general-purpose helper classes used by other subsystems +within VersionMark. It consists of two units: `GlobMatcher`, which implements glob-pattern +file matching for the Publish mode, and `PathHelpers`, which provides safe path combination +to protect against path-traversal attacks. + +This subsystem satisfies requirements `VersionMark-Utilities-GlobMatch` and +`VersionMark-Utilities-SafePath`. + +### Units + +#### GlobMatcher + +The `GlobMatcher` class (`GlobMatcher.cs`) provides glob-pattern file matching. It exposes +two methods: `FindMatchingFiles`, which accepts an array of glob patterns and returns a +sorted, deduplicated list of matching file paths; and `SplitAbsolutePattern`, which splits +an absolute glob pattern into its root directory and relative pattern components. + +See *GlobMatcher Unit Design* for the full unit design. + +#### PathHelpers + +The `PathHelpers` class (`PathHelpers.cs`) provides a single static method, +`SafePathCombine`, which safely combines a base path and a relative path while +preventing path-traversal attacks. It is used by `SelfTest.Validation` when +constructing paths inside temporary directories. + +See *PathHelpers Unit Design* for the full unit design. + +### Subsystem Interactions + +`GlobMatcher.FindMatchingFiles` is called by the Cli Subsystem (`Program.RunPublish`) to +resolve the glob patterns supplied on the command line into a concrete list of JSON capture +files. `PathHelpers.SafePathCombine` is called by the SelfTest subsystem (`Validation.Run`) +when constructing paths inside temporary directories. The Utilities subsystem has no +dependencies on other VersionMark subsystems; it depends only on +`Microsoft.Extensions.FileSystemGlobbing` for pattern evaluation. diff --git a/docs/design/version-mark/utilities/glob-matcher.md b/docs/design/version-mark/utilities/glob-matcher.md new file mode 100644 index 0000000..00d0302 --- /dev/null +++ b/docs/design/version-mark/utilities/glob-matcher.md @@ -0,0 +1,65 @@ +### GlobMatcher Unit + +#### Overview + +`GlobMatcher` is a static utility class that provides glob-pattern file matching. It +supports both relative patterns (evaluated against the current directory) and absolute +patterns (evaluated from their own root directory), and returns a sorted, deduplicated +list of full file paths. It uses `Microsoft.Extensions.FileSystemGlobbing` for pattern +evaluation. + +#### FindMatchingFiles Method + +```csharp +internal static List FindMatchingFiles(string[] globPatterns) +``` + +Finds all files matching the specified glob patterns and returns them as a sorted list of +full paths. + +**Processing steps:** + +1. Iterate over each pattern in `globPatterns`. +2. If a pattern is rooted (`Path.IsPathRooted`), call `SplitAbsolutePattern` to obtain the + root directory and relative pattern, then use a `Matcher` against that directory. +3. If the pattern is relative, collect it into a separate list. +4. After iterating, if any relative patterns were collected, run a single `Matcher` against + `Directory.GetCurrentDirectory()` covering all relative patterns. +5. Combine all matches into a `HashSet` (case-insensitive) to deduplicate, then + return the sorted result. + +#### SplitAbsolutePattern Helper + +```csharp +internal static (string rootDir, string relativePattern) SplitAbsolutePattern(string absolutePattern) +``` + +Splits an absolute glob pattern into its root directory and the relative pattern to be +passed to the `Matcher`. + +**Algorithm:** + +1. Determine the path root via `Path.GetPathRoot`. +2. Find the index of the first wildcard character (`*`, `?`, or `[`). +3. If no wildcard is found, return `(Path.GetDirectoryName, Path.GetFileName)`. +4. Find the last directory separator before the wildcard using `LastIndexOfAny` searching + backwards from the wildcard position. +5. Split at that separator, handling the drive-root edge case where the separator is the + first character (e.g. `/`) or where the root segment lacks a trailing separator (e.g. + `C:` on Windows). + +#### Design Decisions + +- **Separate absolute and relative handling**: Absolute patterns are rooted at a specific + directory and must be evaluated there, while relative patterns are evaluated relative to + the current directory. Separating the two cases avoids incorrect matches. +- **Single Matcher for relative patterns**: Collecting all relative patterns into one + `Matcher` run reduces directory enumeration overhead compared to one run per pattern. +- **Case-insensitive deduplication**: Using a case-insensitive `HashSet` prevents + duplicates when patterns overlap or when the file system is case-insensitive. +- **Sorted output**: Returning a sorted list makes the output deterministic, simplifying + testing and producing a consistent report order. + +`GlobMatcher` is used by `Program.RunPublish` to resolve command-line glob patterns into +a concrete file list. This satisfies requirements `VersionMark-GlobMatcher-FindFiles` and +`VersionMark-GlobMatcher-AbsolutePaths`. diff --git a/docs/design/version-mark/self-test/path-helpers.md b/docs/design/version-mark/utilities/path-helpers.md similarity index 92% rename from docs/design/version-mark/self-test/path-helpers.md rename to docs/design/version-mark/utilities/path-helpers.md index 7ff8908..4d41df9 100644 --- a/docs/design/version-mark/self-test/path-helpers.md +++ b/docs/design/version-mark/utilities/path-helpers.md @@ -43,5 +43,6 @@ the base directory. - **No logging or error accumulation**: `SafePathCombine` is a pure utility method that throws on invalid input; it does not interact with the `Context` or any output mechanism. -`PathHelpers` is used by `Validation` when constructing paths inside temporary directories -for self-validation tests. This satisfies requirement `VersionMark-PathHelpers-SafeCombine`. +`PathHelpers` is used by `SelfTest.Validation` when constructing paths inside temporary +directories for self-validation tests. This satisfies requirement +`VersionMark-PathHelpers-SafeCombine`. diff --git a/docs/reqstream/version-mark/utilities.yaml b/docs/reqstream/version-mark/utilities.yaml new file mode 100644 index 0000000..0a67b5e --- /dev/null +++ b/docs/reqstream/version-mark/utilities.yaml @@ -0,0 +1,24 @@ +--- +sections: + - title: VersionMark Requirements + sections: + - title: Utilities + requirements: + - id: VersionMark-Utilities-GlobMatch + title: The Utilities subsystem shall provide glob-pattern file matching. + justification: | + Centralizing glob-pattern file matching in a dedicated subsystem separates + the matching concern from the CLI dispatch logic, making both easier to test + and maintain independently. + children: + - VersionMark-GlobMatcher-FindFiles + - VersionMark-GlobMatcher-AbsolutePaths + + - id: VersionMark-Utilities-SafePath + title: The Utilities subsystem shall provide safe path combination. + justification: | + Centralizing safe path combination in a dedicated subsystem makes the + path-traversal protection reusable across all subsystems that construct + file paths from partially-trusted input. + children: + - VersionMark-PathHelpers-SafeCombine diff --git a/docs/reqstream/version-mark/utilities/glob-matcher.yaml b/docs/reqstream/version-mark/utilities/glob-matcher.yaml new file mode 100644 index 0000000..a274fb4 --- /dev/null +++ b/docs/reqstream/version-mark/utilities/glob-matcher.yaml @@ -0,0 +1,31 @@ +--- +sections: + - title: GlobMatcher Unit Requirements + requirements: + - id: VersionMark-GlobMatcher-FindFiles + title: The GlobMatcher class shall find files matching relative glob patterns relative to the current directory. + justification: | + Publish mode accepts glob patterns supplied on the command line, which are + typically relative to the working directory. GlobMatcher must evaluate these + relative patterns against the current directory so callers do not need to + resolve them manually. + tests: + - GlobMatcher_FindMatchingFiles_RelativePattern_ReturnsMatchingFiles + - GlobMatcher_FindMatchingFiles_EmptyPatterns_ReturnsEmptyList + - GlobMatcher_FindMatchingFiles_PatternMatchingNoFiles_ReturnsEmptyList + - GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles + + - id: VersionMark-GlobMatcher-AbsolutePaths + title: >- + The GlobMatcher class shall find files matching absolute glob patterns + regardless of the current working directory. + justification: | + CI/CD pipelines frequently pass fully-qualified artifact paths to VersionMark. + GlobMatcher must evaluate absolute patterns from their own root directory so + that the caller's current working directory does not affect the result. + tests: + - GlobMatcher_FindMatchingFiles_AbsolutePattern_ReturnsMatchingFiles + - GlobMatcher_FindMatchingFiles_SingleFileAbsolutePath_ReturnsSingleFile + - GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles + - GlobMatcher_SplitAbsolutePattern_PatternWithWildcard_SplitsCorrectly + - GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator diff --git a/docs/reqstream/version-mark/self-test/path-helpers.yaml b/docs/reqstream/version-mark/utilities/path-helpers.yaml similarity index 100% rename from docs/reqstream/version-mark/self-test/path-helpers.yaml rename to docs/reqstream/version-mark/utilities/path-helpers.yaml diff --git a/docs/verification/definition.yaml b/docs/verification/definition.yaml index 03019e1..d5795ee 100644 --- a/docs/verification/definition.yaml +++ b/docs/verification/definition.yaml @@ -27,7 +27,9 @@ input-files: - docs/verification/version-mark/publishing/markdown-formatter.md - docs/verification/version-mark/self-test.md - docs/verification/version-mark/self-test/validation.md - - docs/verification/version-mark/self-test/path-helpers.md + - docs/verification/version-mark/utilities.md + - docs/verification/version-mark/utilities/glob-matcher.md + - docs/verification/version-mark/utilities/path-helpers.md - docs/verification/ots.md - docs/verification/ots/buildmark.md - docs/verification/ots/fileassert.md diff --git a/docs/verification/version-mark.md b/docs/verification/version-mark.md index 162699b..9b8e703 100644 --- a/docs/verification/version-mark.md +++ b/docs/verification/version-mark.md @@ -6,13 +6,14 @@ This section documents the verification design for the VersionMark system. Versi a .NET global tool that captures tool version information from CI/CD job environments and publishes consolidated version reports as markdown. -The verification strategy is organized around five subsystems: +The verification strategy is organized around six subsystems: - **Cli** - command-line argument parsing and program dispatch - **Configuration** - YAML configuration loading and validation - **Capture** - tool version capture and JSON serialization - **Publishing** - markdown report generation - **SelfTest** - built-in self-validation +- **Utilities** - glob-pattern file matching and safe path combination ## Verification Approach diff --git a/docs/verification/version-mark/self-test.md b/docs/verification/version-mark/self-test.md index b44effd..8bc1785 100644 --- a/docs/verification/version-mark/self-test.md +++ b/docs/verification/version-mark/self-test.md @@ -3,13 +3,11 @@ ### Overview The SelfTest subsystem provides built-in self-validation for the VersionMark tool. It -consists of two units: `Validation` (the self-validation test runner) and `PathHelpers` -(the safe path combination utility). +consists of one unit: `Validation` (the self-validation test runner). Subsystem-level integration tests are in `SelfTest/SelfTestTests.cs` and cover the full -self-validation workflow including TRX/JUnit results file writing, heading depth handling, -and path safety verification. Unit-level verification for `Validation` and `PathHelpers` -is in the chapters that follow. +self-validation workflow including TRX/JUnit results file writing and heading depth +handling. Unit-level verification for `Validation` is in the chapter that follows. ### Verification Approach @@ -21,9 +19,6 @@ files. No external mocks are required. The following integration test scenarios verify SelfTest subsystem requirements: -- **`SelfTest_PathHelpers_PathTraversal_ThrowsArgumentException`**: Path traversal attempt is rejected with ArgumentException. -- **`SelfTest_PathHelpers_ValidRelativePath_ProducesExpectedPath`**: Valid relative path combines correctly. -- **`SelfTest_PathHelpers_FindsDllInBaseDirectory_FileExists`**: Tool DLL is found in the base directory. - **`SelfTest_Run_WithResultsFlag_WritesResultsFile`**: `--results` flag writes a TRX results file. - **`SelfTest_Run_WithResultsXmlFlag_WritesJUnitResultsFile`**: `--results-xml` flag writes a JUnit results file. - **`SelfTest_Run_WithDepthTwo_WritesHashHashHeader`**: Depth 2 produces a `##` heading in the output. @@ -41,7 +36,4 @@ The following list maps SelfTest subsystem requirements to test scenarios: - **`VersionMark-Validate-Lint`**: `SelfTest_Run_WithResultsFlag_WritesResultsFile` - **`VersionMark-Validate-Results`**: `SelfTest_Run_WithResultsFlag_WritesResultsFile`, `SelfTest_Run_WithResultsXmlFlag_WritesJUnitResultsFile` -- **`VersionMark-PathHelpers-SafeCombine`**: `SelfTest_PathHelpers_PathTraversal_ThrowsArgumentException`, - `SelfTest_PathHelpers_ValidRelativePath_ProducesExpectedPath`, - `SelfTest_PathHelpers_FindsDllInBaseDirectory_FileExists` - **`VersionMark-Validation-HeaderDepth`**: `SelfTest_Run_WithDepthTwo_WritesHashHashHeader` diff --git a/docs/verification/version-mark/utilities.md b/docs/verification/version-mark/utilities.md new file mode 100644 index 0000000..03ad757 --- /dev/null +++ b/docs/verification/version-mark/utilities.md @@ -0,0 +1,84 @@ +## Utilities Subsystem Verification + +### Overview + +The Utilities subsystem provides general-purpose helper classes for use within VersionMark. +It consists of two units: `GlobMatcher` (the glob-pattern file matcher) and `PathHelpers` +(the safe path combination utility). + +Unit-level verification for `GlobMatcher` and `PathHelpers` is in the chapters that follow. + +### Verification Approach + +Unit tests invoke `GlobMatcher` and `PathHelpers` directly with various inputs and assert +on the returned results. Tests use temporary directories for file-system scenarios, +ensuring isolation and repeatability across platforms. No external mocks are required. + +### Test Scenarios + +The following test scenarios verify Utilities subsystem requirements: + +- **`GlobMatcher_FindMatchingFiles_RelativePattern_ReturnsMatchingFiles`**: + Relative glob pattern matches files in the current directory. +- **`GlobMatcher_FindMatchingFiles_AbsolutePattern_ReturnsMatchingFiles`**: + Absolute glob pattern matches files regardless of the working directory. +- **`GlobMatcher_FindMatchingFiles_SingleFileAbsolutePath_ReturnsSingleFile`**: + Absolute path without wildcard returns that single file. +- **`GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles`**: + Mixed absolute and relative patterns are combined correctly. +- **`GlobMatcher_FindMatchingFiles_EmptyPatterns_ReturnsEmptyList`**: + Empty pattern array returns an empty list. +- **`GlobMatcher_FindMatchingFiles_PatternMatchingNoFiles_ReturnsEmptyList`**: + Pattern with no matches returns an empty list. +- **`GlobMatcher_SplitAbsolutePattern_PatternWithWildcard_SplitsCorrectly`**: + Pattern with wildcard is split at the last separator before the wildcard. +- **`GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator`**: + Pattern without wildcard is split at the last separator. +- **`GlobMatcher_SplitAbsolutePattern_ForwardSlashRootPattern_SplitsToRootAndRelative`**: + Root-relative forward-slash pattern (e.g. `/*.json`) splits to the platform path root (`/` on Unix, + `\` on Windows) and relative pattern on all platforms. +- **`GlobMatcher_SplitAbsolutePattern_WindowsDriveRootPattern_SplitsToDriveRootAndRelative`**: + Windows drive-root pattern (e.g. `C:\*.json`) splits to `C:\` root and relative pattern (Windows only). +- **`PathHelpers_SafePathCombine_ValidPaths_CombinesCorrectly`**: A simple relative path is combined with the base path. +- **`PathHelpers_SafePathCombine_PathTraversalWithDoubleDots_ThrowsArgumentException`**: + A path beginning with `../` throws ArgumentException. +- **`PathHelpers_SafePathCombine_DoubleDotsInMiddle_ThrowsArgumentException`**: + A path containing `..` in the middle throws ArgumentException. +- **`PathHelpers_SafePathCombine_AbsolutePath_ThrowsArgumentException`**: + A rooted absolute path throws ArgumentException. +- **`PathHelpers_SafePathCombine_CurrentDirectoryReference_CombinesCorrectly`**: + A path containing `.` (current directory) combines correctly. +- **`PathHelpers_SafePathCombine_NestedPaths_CombinesCorrectly`**: A nested relative path combines correctly. +- **`PathHelpers_SafePathCombine_EmptyRelativePath_ReturnsBasePath`**: + An empty relative path returns the base path unchanged. +- **`PathHelpers_SafePathCombine_DotDotAsNamePrefix_CombinesCorrectly`**: + A filename that starts with `..` but is not a traversal combines correctly. +- **`PathHelpers_SafePathCombine_NullBasePath_ThrowsArgumentNullException`**: + A null base path throws ArgumentNullException. +- **`PathHelpers_SafePathCombine_NullRelativePath_ThrowsArgumentNullException`**: + A null relative path throws ArgumentNullException. + +### Dependencies + +Tests use temporary directories for file-system scenarios. No external mocks are required. + +### Requirements Coverage + +The following list maps Utilities subsystem requirements to test scenarios: + +- **`VersionMark-Utilities-GlobMatch`**: `GlobMatcher_FindMatchingFiles_RelativePattern_ReturnsMatchingFiles`, + `GlobMatcher_FindMatchingFiles_AbsolutePattern_ReturnsMatchingFiles`, + `GlobMatcher_FindMatchingFiles_SingleFileAbsolutePath_ReturnsSingleFile`, + `GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles`, + `GlobMatcher_FindMatchingFiles_EmptyPatterns_ReturnsEmptyList`, + `GlobMatcher_FindMatchingFiles_PatternMatchingNoFiles_ReturnsEmptyList` +- **`VersionMark-Utilities-SafePath`**: `PathHelpers_SafePathCombine_ValidPaths_CombinesCorrectly`, + `PathHelpers_SafePathCombine_PathTraversalWithDoubleDots_ThrowsArgumentException`, + `PathHelpers_SafePathCombine_DoubleDotsInMiddle_ThrowsArgumentException`, + `PathHelpers_SafePathCombine_AbsolutePath_ThrowsArgumentException`, + `PathHelpers_SafePathCombine_CurrentDirectoryReference_CombinesCorrectly`, + `PathHelpers_SafePathCombine_NestedPaths_CombinesCorrectly`, + `PathHelpers_SafePathCombine_EmptyRelativePath_ReturnsBasePath`, + `PathHelpers_SafePathCombine_DotDotAsNamePrefix_CombinesCorrectly`, + `PathHelpers_SafePathCombine_NullBasePath_ThrowsArgumentNullException`, + `PathHelpers_SafePathCombine_NullRelativePath_ThrowsArgumentNullException` diff --git a/docs/verification/version-mark/utilities/glob-matcher.md b/docs/verification/version-mark/utilities/glob-matcher.md new file mode 100644 index 0000000..5d16127 --- /dev/null +++ b/docs/verification/version-mark/utilities/glob-matcher.md @@ -0,0 +1,54 @@ +### GlobMatcher Unit Verification + +#### Overview + +The `GlobMatcher` unit provides `FindMatchingFiles` and `SplitAbsolutePattern` methods for +glob-pattern file matching. It supports relative and absolute patterns and returns a sorted, +deduplicated list of full paths. Tests are in `Utilities/GlobMatcherTests.cs`. + +#### Test Scenarios + +The following test scenarios verify `GlobMatcher`: + +- **`GlobMatcher_FindMatchingFiles_EmptyPatterns_ReturnsEmptyList`**: + An empty pattern array returns an empty list. +- **`GlobMatcher_FindMatchingFiles_PatternMatchingNoFiles_ReturnsEmptyList`**: + A pattern that matches no files returns an empty list. +- **`GlobMatcher_FindMatchingFiles_RelativePattern_ReturnsMatchingFiles`**: + A relative glob pattern is matched against the current directory. +- **`GlobMatcher_FindMatchingFiles_AbsolutePattern_ReturnsMatchingFiles`**: + An absolute glob pattern is matched from its root directory. +- **`GlobMatcher_FindMatchingFiles_SingleFileAbsolutePath_ReturnsSingleFile`**: + An absolute path with no wildcard returns that single file. +- **`GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles`**: + Mixed absolute and relative patterns produce a combined deduplicated result. +- **`GlobMatcher_SplitAbsolutePattern_PatternWithWildcard_SplitsCorrectly`**: + A pattern with a wildcard is split at the last separator before the wildcard. +- **`GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator`**: + A pattern without a wildcard is split at the final separator. +- **`GlobMatcher_SplitAbsolutePattern_ForwardSlashRootPattern_SplitsToRootAndRelative`**: + A root-relative forward-slash pattern (e.g. `/*.json`) splits to the platform path root (`/` on Unix, + `\` on Windows) and relative pattern on all platforms. +- **`GlobMatcher_SplitAbsolutePattern_WindowsDriveRootPattern_SplitsToDriveRootAndRelative`**: + A Windows drive-root pattern (e.g. `C:\*.json`) splits to `C:\` root and relative pattern (Windows only). + +#### Dependencies + +Tests use temporary directories created with `Path.GetTempPath()` for all file-system +scenarios. No external mocks are required. Tests call `GlobMatcher` methods directly. + +#### Requirements Coverage + +The following list maps `GlobMatcher` unit requirements to test scenarios: + +- **`VersionMark-GlobMatcher-FindFiles`**: `GlobMatcher_FindMatchingFiles_RelativePattern_ReturnsMatchingFiles`, + `GlobMatcher_FindMatchingFiles_EmptyPatterns_ReturnsEmptyList`, + `GlobMatcher_FindMatchingFiles_PatternMatchingNoFiles_ReturnsEmptyList`, + `GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles` +- **`VersionMark-GlobMatcher-AbsolutePaths`**: `GlobMatcher_FindMatchingFiles_AbsolutePattern_ReturnsMatchingFiles`, + `GlobMatcher_FindMatchingFiles_SingleFileAbsolutePath_ReturnsSingleFile`, + `GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles`, + `GlobMatcher_SplitAbsolutePattern_PatternWithWildcard_SplitsCorrectly`, + `GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator`, + `GlobMatcher_SplitAbsolutePattern_ForwardSlashRootPattern_SplitsToRootAndRelative`, + `GlobMatcher_SplitAbsolutePattern_WindowsDriveRootPattern_SplitsToDriveRootAndRelative` diff --git a/docs/verification/version-mark/self-test/path-helpers.md b/docs/verification/version-mark/utilities/path-helpers.md similarity index 98% rename from docs/verification/version-mark/self-test/path-helpers.md rename to docs/verification/version-mark/utilities/path-helpers.md index 80b821d..42e9808 100644 --- a/docs/verification/version-mark/self-test/path-helpers.md +++ b/docs/verification/version-mark/utilities/path-helpers.md @@ -5,7 +5,7 @@ The `PathHelpers` unit provides a `SafePathCombine` method that combines a base path and a relative path while preventing path traversal attacks. It rejects relative paths that contain `..` components that would escape the base directory, as well as absolute paths. -Tests are in `SelfTest/PathHelpersTests.cs`. +Tests are in `Utilities/PathHelpersTests.cs`. #### Test Scenarios diff --git a/requirements.yaml b/requirements.yaml index f932540..18feba9 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -20,7 +20,9 @@ includes: - docs/reqstream/version-mark/configuration/load.yaml - docs/reqstream/version-mark/self-test.yaml - docs/reqstream/version-mark/self-test/validation.yaml - - docs/reqstream/version-mark/self-test/path-helpers.yaml + - docs/reqstream/version-mark/utilities.yaml + - docs/reqstream/version-mark/utilities/glob-matcher.yaml + - docs/reqstream/version-mark/utilities/path-helpers.yaml - docs/reqstream/ots/xunit.yaml - docs/reqstream/ots/reqstream.yaml - docs/reqstream/ots/buildmark.yaml diff --git a/src/DemaConsulting.VersionMark/Program.cs b/src/DemaConsulting.VersionMark/Program.cs index 2c47d9d..bdcd7a9 100644 --- a/src/DemaConsulting.VersionMark/Program.cs +++ b/src/DemaConsulting.VersionMark/Program.cs @@ -24,8 +24,7 @@ using DemaConsulting.VersionMark.Configuration; using DemaConsulting.VersionMark.Publishing; using DemaConsulting.VersionMark.SelfTest; -using Microsoft.Extensions.FileSystemGlobbing; -using Microsoft.Extensions.FileSystemGlobbing.Abstractions; +using DemaConsulting.VersionMark.Utilities; namespace DemaConsulting.VersionMark; @@ -299,7 +298,7 @@ private static void RunPublish(Context context) context.WriteLine($"Searching for JSON files with patterns: {string.Join(", ", globPatterns)}"); // Find matching JSON files using glob patterns - var jsonFiles = FindMatchingFiles(globPatterns); + var jsonFiles = GlobMatcher.FindMatchingFiles(globPatterns); // Check if any files were found if (jsonFiles.Count == 0) @@ -330,31 +329,6 @@ private static void RunPublish(Context context) } } - /// - /// Finds files matching the specified glob patterns. - /// - /// Array of glob patterns to match. - /// List of matching file paths. - private static List FindMatchingFiles(string[] globPatterns) - { - var matcher = new Matcher(); - - // Add all glob patterns to the matcher - foreach (var pattern in globPatterns) - { - matcher.AddInclude(pattern); - } - - // Execute the match against the current directory - var result = matcher.Execute(new DirectoryInfoWrapper(new DirectoryInfo(Directory.GetCurrentDirectory()))); - - // Return the full paths of matched files - return result.Files - .Select(f => Path.GetFullPath(f.Path)) - .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) - .ToList(); - } - /// /// Loads VersionInfo instances from the specified JSON files. /// diff --git a/src/DemaConsulting.VersionMark/SelfTest/Validation.cs b/src/DemaConsulting.VersionMark/SelfTest/Validation.cs index 5fd63b4..451a167 100644 --- a/src/DemaConsulting.VersionMark/SelfTest/Validation.cs +++ b/src/DemaConsulting.VersionMark/SelfTest/Validation.cs @@ -22,6 +22,7 @@ using DemaConsulting.TestResults.IO; using DemaConsulting.VersionMark.Capture; using DemaConsulting.VersionMark.Cli; +using DemaConsulting.VersionMark.Utilities; namespace DemaConsulting.VersionMark.SelfTest; diff --git a/src/DemaConsulting.VersionMark/Utilities/GlobMatcher.cs b/src/DemaConsulting.VersionMark/Utilities/GlobMatcher.cs new file mode 100644 index 0000000..42c7502 --- /dev/null +++ b/src/DemaConsulting.VersionMark/Utilities/GlobMatcher.cs @@ -0,0 +1,157 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.FileSystemGlobbing.Abstractions; + +namespace DemaConsulting.VersionMark.Utilities; + +/// +/// Provides glob-pattern file matching utilities. +/// +internal static class GlobMatcher +{ + /// + /// Finds all files matching the specified glob patterns. + /// + /// Array of glob patterns to match. Patterns may be relative + /// (matched against the current directory) or absolute (matched from their root + /// directory). + /// + /// Sorted list of full file paths matching any of the supplied patterns. + /// Deduplication and sort use the file-system-appropriate comparer (ordinal + /// ignore-case on Windows, ordinal on case-sensitive systems). + /// + internal static List FindMatchingFiles(string[] globPatterns) + { + // Use a comparer that matches the underlying file-system's case-sensitivity so that + // deduplication is correct: case-insensitive on Windows, case-sensitive elsewhere. + var fsComparer = OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + var files = new HashSet(fsComparer); + var relativePatterns = new List(); + + foreach (var pattern in globPatterns) + { + if (Path.IsPathRooted(pattern)) + { + // Handle absolute path by extracting the root directory and relative pattern + var (rootDir, relativePattern) = SplitAbsolutePattern(pattern); + if (Directory.Exists(rootDir)) + { + var matcher = new Matcher(); + matcher.AddInclude(relativePattern); + var result = matcher.Execute(new DirectoryInfoWrapper(new DirectoryInfo(rootDir))); + foreach (var file in result.Files) + { + files.Add(Path.GetFullPath(Path.Combine(rootDir, file.Path))); + } + } + } + else + { + relativePatterns.Add(pattern); + } + } + + // Handle all relative patterns together against the current directory + if (relativePatterns.Count > 0) + { + var matcher = new Matcher(); + foreach (var pattern in relativePatterns) + { + matcher.AddInclude(pattern); + } + + var result = matcher.Execute(new DirectoryInfoWrapper(new DirectoryInfo(Directory.GetCurrentDirectory()))); + foreach (var file in result.Files) + { + files.Add(Path.GetFullPath(file.Path)); + } + } + + return files.OrderBy(f => f, fsComparer).ToList(); + } + + /// + /// Splits an absolute glob pattern into a root directory and a relative pattern. + /// + /// The absolute glob pattern to split. + /// + /// A tuple of (rootDir, relativePattern) where rootDir is the + /// deepest directory segment before the first wildcard character and + /// relativePattern is the remainder of the pattern relative to that directory. + /// + /// + /// The method locates the last directory separator that precedes the first wildcard + /// character (*, ?, or [) and uses that position as the + /// split point. If no wildcard is present the pattern is treated as a literal file + /// path and split at the final separator. + /// + internal static (string rootDir, string relativePattern) SplitAbsolutePattern(string absolutePattern) + { + var pathRoot = Path.GetPathRoot(absolutePattern) ?? string.Empty; + + // Find the index of the first wildcard character in the pattern + // Microsoft.Extensions.FileSystemGlobbing supports *, **, ?, and [abc] ranges + var wildcardIndex = absolutePattern.IndexOfAny(['*', '?', '[']); + + if (wildcardIndex < 0) + { + // No wildcard - treat as a specific file path + return ( + Path.GetDirectoryName(absolutePattern) ?? pathRoot, + Path.GetFileName(absolutePattern)); + } + + // Find the last directory separator before the first wildcard. + // LastIndexOfAny with a start index searches backwards from that position toward the start, + // so this finds the rightmost separator that precedes the wildcard character. + var lastSepIndex = absolutePattern.LastIndexOfAny( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + wildcardIndex); + + if (lastSepIndex < 0) + { + // No separator before wildcard - use the path root + return (pathRoot, absolutePattern[pathRoot.Length..]); + } + + var rootDir = absolutePattern[..lastSepIndex]; + var relativePattern = absolutePattern[(lastSepIndex + 1)..]; + + // Handle empty root (Unix paths like /file.json where separator is the very first char) + if (string.IsNullOrEmpty(rootDir)) + { + return (pathRoot, relativePattern); + } + + // Ensure drive/volume root includes trailing separator. + // On Windows, splitting "C:\*.json" at the backslash yields rootDir = "C:" (no trailing + // backslash), but DirectoryInfo requires "C:\" to refer to the drive root. + if (rootDir == pathRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) + { + return (pathRoot, relativePattern); + } + + return (rootDir, relativePattern); + } +} diff --git a/src/DemaConsulting.VersionMark/SelfTest/PathHelpers.cs b/src/DemaConsulting.VersionMark/Utilities/PathHelpers.cs similarity index 96% rename from src/DemaConsulting.VersionMark/SelfTest/PathHelpers.cs rename to src/DemaConsulting.VersionMark/Utilities/PathHelpers.cs index 2cd4380..2014d72 100644 --- a/src/DemaConsulting.VersionMark/SelfTest/PathHelpers.cs +++ b/src/DemaConsulting.VersionMark/Utilities/PathHelpers.cs @@ -18,10 +18,10 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -namespace DemaConsulting.VersionMark.SelfTest; +namespace DemaConsulting.VersionMark.Utilities; /// -/// Helper utilities for safe path operations within the SelfTest subsystem. +/// Helper utilities for safe path operations. /// Protects against path-traversal attacks by ensuring combined paths remain /// within the intended base directory. /// diff --git a/test/DemaConsulting.VersionMark.Tests/Capture/CaptureTests.cs b/test/DemaConsulting.VersionMark.Tests/Capture/CaptureTests.cs index b5164c2..0f36ae7 100644 --- a/test/DemaConsulting.VersionMark.Tests/Capture/CaptureTests.cs +++ b/test/DemaConsulting.VersionMark.Tests/Capture/CaptureTests.cs @@ -21,7 +21,7 @@ using DemaConsulting.VersionMark.Capture; using DemaConsulting.VersionMark.Cli; using DemaConsulting.VersionMark.Configuration; -using DemaConsulting.VersionMark.SelfTest; +using DemaConsulting.VersionMark.Utilities; namespace DemaConsulting.VersionMark.Tests.Capture; diff --git a/test/DemaConsulting.VersionMark.Tests/IntegrationTests.cs b/test/DemaConsulting.VersionMark.Tests/IntegrationTests.cs index 3cdbc2e..e9707e2 100644 --- a/test/DemaConsulting.VersionMark.Tests/IntegrationTests.cs +++ b/test/DemaConsulting.VersionMark.Tests/IntegrationTests.cs @@ -19,7 +19,7 @@ // SOFTWARE. using DemaConsulting.VersionMark.Capture; -using DemaConsulting.VersionMark.SelfTest; +using DemaConsulting.VersionMark.Utilities; namespace DemaConsulting.VersionMark.Tests; diff --git a/test/DemaConsulting.VersionMark.Tests/ProgramTests.cs b/test/DemaConsulting.VersionMark.Tests/ProgramTests.cs index 5fa94a6..f6f2ab3 100644 --- a/test/DemaConsulting.VersionMark.Tests/ProgramTests.cs +++ b/test/DemaConsulting.VersionMark.Tests/ProgramTests.cs @@ -20,7 +20,7 @@ using DemaConsulting.VersionMark.Capture; using DemaConsulting.VersionMark.Cli; -using DemaConsulting.VersionMark.SelfTest; +using DemaConsulting.VersionMark.Utilities; namespace DemaConsulting.VersionMark.Tests; diff --git a/test/DemaConsulting.VersionMark.Tests/Publishing/PublishingTests.cs b/test/DemaConsulting.VersionMark.Tests/Publishing/PublishingTests.cs index 51405d8..458aa79 100644 --- a/test/DemaConsulting.VersionMark.Tests/Publishing/PublishingTests.cs +++ b/test/DemaConsulting.VersionMark.Tests/Publishing/PublishingTests.cs @@ -21,7 +21,7 @@ using DemaConsulting.VersionMark.Capture; using DemaConsulting.VersionMark.Cli; using DemaConsulting.VersionMark.Publishing; -using DemaConsulting.VersionMark.SelfTest; +using DemaConsulting.VersionMark.Utilities; namespace DemaConsulting.VersionMark.Tests.Publishing; @@ -198,6 +198,50 @@ public void Publishing_Run_WithGlobPattern_ReadsMatchingFiles() } } + /// + /// Test that the publishing pipeline accepts absolute glob patterns and reads all matching files. + /// + [Fact] + public void Publishing_Run_WithAbsoluteGlobPattern_ReadsMatchingFiles() + { + // Arrange - Create a temp directory with JSON files and use an absolute glob pattern to match them + var currentDir = Directory.GetCurrentDirectory(); + var tempDir = PathHelpers.SafePathCombine(Path.GetTempPath(), Path.GetRandomFileName()); + var reportFile = PathHelpers.SafePathCombine(tempDir, "report.md"); + try + { + Directory.CreateDirectory(tempDir); + var versionInfo = new VersionInfo("job-abs", new Dictionary { ["dotnet"] = "8.0.100" }); + versionInfo.SaveToFile(PathHelpers.SafePathCombine(tempDir, "versionmark-abs-job.json")); + + // Use a different working directory to confirm the absolute pattern is not relative to cwd + Directory.SetCurrentDirectory(Path.GetTempPath()); + + // Build an absolute glob pattern pointing directly into tempDir + var absolutePattern = PathHelpers.SafePathCombine(tempDir, "versionmark-*.json"); + using var context = Context.Create([ + "--publish", "--report", reportFile, "--silent", "--", absolutePattern + ]); + + // Act - Run the publish pipeline with an absolute glob pattern + Program.Run(context); + + // Assert - The report should have been generated from the matched file + Assert.Equal(0, context.ExitCode); + Assert.True(File.Exists(reportFile), + "Report file should be created when absolute glob pattern matches files"); + Assert.Contains("dotnet", File.ReadAllText(reportFile)); + } + finally + { + Directory.SetCurrentDirectory(currentDir); + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + } + /// /// Test that the publishing pipeline reports an error when a JSON file is malformed. /// diff --git a/test/DemaConsulting.VersionMark.Tests/SelfTest/SelfTestTests.cs b/test/DemaConsulting.VersionMark.Tests/SelfTest/SelfTestTests.cs index 286b548..f05f053 100644 --- a/test/DemaConsulting.VersionMark.Tests/SelfTest/SelfTestTests.cs +++ b/test/DemaConsulting.VersionMark.Tests/SelfTest/SelfTestTests.cs @@ -20,11 +20,12 @@ using DemaConsulting.VersionMark.Cli; using DemaConsulting.VersionMark.SelfTest; +using DemaConsulting.VersionMark.Utilities; namespace DemaConsulting.VersionMark.Tests.SelfTest; /// -/// Subsystem tests for the SelfTest subsystem (Validation and PathHelpers working together). +/// Subsystem tests for the SelfTest subsystem. /// public class SelfTestTests { diff --git a/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs b/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs new file mode 100644 index 0000000..76eccb8 --- /dev/null +++ b/test/DemaConsulting.VersionMark.Tests/Utilities/GlobMatcherTests.cs @@ -0,0 +1,274 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using DemaConsulting.VersionMark.Utilities; + +namespace DemaConsulting.VersionMark.Tests.Utilities; + +/// +/// Tests for the GlobMatcher class. +/// +public class GlobMatcherTests +{ + /// + /// Test that FindMatchingFiles returns an empty list when given an empty pattern array. + /// + [Fact] + public void GlobMatcher_FindMatchingFiles_EmptyPatterns_ReturnsEmptyList() + { + // Arrange + var patterns = Array.Empty(); + + // Act + var result = GlobMatcher.FindMatchingFiles(patterns); + + // Assert + Assert.Empty(result); + } + + /// + /// Test that FindMatchingFiles returns an empty list when no files match the pattern. + /// + [Fact] + public void GlobMatcher_FindMatchingFiles_PatternMatchingNoFiles_ReturnsEmptyList() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + try + { + var pattern = Path.Combine(tempDir, "*.nonexistent"); + + // Act + var result = GlobMatcher.FindMatchingFiles([pattern]); + + // Assert + Assert.Empty(result); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + /// + /// Test that FindMatchingFiles returns matching files when given a relative pattern. + /// + [Fact] + public void GlobMatcher_FindMatchingFiles_RelativePattern_ReturnsMatchingFiles() + { + // Arrange + var originalDir = Environment.CurrentDirectory; + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "test.json"), "{}"); + Environment.CurrentDirectory = tempDir; + + // Act + var result = GlobMatcher.FindMatchingFiles(["*.json"]); + + // Assert + Assert.Single(result); + Assert.Contains(result, f => f.EndsWith("test.json", StringComparison.OrdinalIgnoreCase)); + } + finally + { + Environment.CurrentDirectory = originalDir; + Directory.Delete(tempDir, recursive: true); + } + } + + /// + /// Test that FindMatchingFiles returns matching files when given an absolute pattern. + /// + [Fact] + public void GlobMatcher_FindMatchingFiles_AbsolutePattern_ReturnsMatchingFiles() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "a.json"), "{}"); + File.WriteAllText(Path.Combine(tempDir, "b.json"), "{}"); + var pattern = Path.Combine(tempDir, "*.json"); + + // Act + var result = GlobMatcher.FindMatchingFiles([pattern]); + + // Assert + Assert.Equal(2, result.Count); + Assert.Contains(result, f => f.EndsWith("a.json", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result, f => f.EndsWith("b.json", StringComparison.OrdinalIgnoreCase)); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + /// + /// Test that FindMatchingFiles returns a single file when given an absolute path without a wildcard. + /// + [Fact] + public void GlobMatcher_FindMatchingFiles_SingleFileAbsolutePath_ReturnsSingleFile() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + try + { + var filePath = Path.Combine(tempDir, "single.json"); + File.WriteAllText(filePath, "{}"); + + // Act + var result = GlobMatcher.FindMatchingFiles([filePath]); + + // Assert + Assert.Single(result); + Assert.Equal(Path.GetFullPath(filePath), result[0]); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + /// + /// Test that FindMatchingFiles combines results from both absolute and relative patterns. + /// + [Fact] + public void GlobMatcher_FindMatchingFiles_MixedPatterns_ReturnsCombinedFiles() + { + // Arrange + var originalDir = Environment.CurrentDirectory; + var tempDir1 = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + var tempDir2 = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir1); + Directory.CreateDirectory(tempDir2); + try + { + // Absolute pattern directory + File.WriteAllText(Path.Combine(tempDir1, "abs.json"), "{}"); + + // Relative pattern directory (becomes the working directory) + File.WriteAllText(Path.Combine(tempDir2, "rel.json"), "{}"); + Environment.CurrentDirectory = tempDir2; + + var absolutePattern = Path.Combine(tempDir1, "*.json"); + + // Act + var result = GlobMatcher.FindMatchingFiles([absolutePattern, "*.json"]); + + // Assert + Assert.Equal(2, result.Count); + Assert.Contains(result, f => f.EndsWith("abs.json", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result, f => f.EndsWith("rel.json", StringComparison.OrdinalIgnoreCase)); + } + finally + { + Environment.CurrentDirectory = originalDir; + Directory.Delete(tempDir1, recursive: true); + Directory.Delete(tempDir2, recursive: true); + } + } + + /// + /// Test that SplitAbsolutePattern correctly splits a pattern with a wildcard at the last + /// separator before the wildcard. + /// + [Fact] + public void GlobMatcher_SplitAbsolutePattern_PatternWithWildcard_SplitsCorrectly() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "subdir"); + var pattern = Path.Combine(tempDir, "*.json"); + + // Act + var (rootDir, relativePattern) = GlobMatcher.SplitAbsolutePattern(pattern); + + // Assert + Assert.Equal(tempDir, rootDir); + Assert.Equal("*.json", relativePattern); + } + + /// + /// Test that SplitAbsolutePattern correctly splits a pattern without a wildcard at the + /// final separator. + /// + [Fact] + public void GlobMatcher_SplitAbsolutePattern_PatternWithoutWildcard_SplitsAtLastSeparator() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "subdir"); + var pattern = Path.Combine(tempDir, "file.json"); + + // Act + var (rootDir, relativePattern) = GlobMatcher.SplitAbsolutePattern(pattern); + + // Assert + Assert.Equal(tempDir, rootDir); + Assert.Equal("file.json", relativePattern); + } + + /// + /// Test that SplitAbsolutePattern correctly handles a root-relative pattern using a forward slash + /// (e.g. /*.json), returning the platform path root as the directory and "*.json" as the relative + /// pattern. This covers the empty-rootDir fallback branch and runs on all platforms. + /// + [Fact] + public void GlobMatcher_SplitAbsolutePattern_ForwardSlashRootPattern_SplitsToRootAndRelative() + { + // Arrange + const string pattern = "/*.json"; + + // The path root is platform-dependent: "/" on Unix, "\" on Windows (where a leading forward + // slash is treated as a drive-relative absolute path rooted at the current drive's root). + var expectedRoot = OperatingSystem.IsWindows() ? @"\" : "/"; + + // Act + var (rootDir, relativePattern) = GlobMatcher.SplitAbsolutePattern(pattern); + + // Assert + Assert.Equal(expectedRoot, rootDir); + Assert.Equal("*.json", relativePattern); + } + + /// + /// Test that SplitAbsolutePattern correctly handles a Windows drive-root pattern like C:\*.json, + /// returning "C:\" as the root directory and "*.json" as the relative pattern. + /// + [Fact] + public void GlobMatcher_SplitAbsolutePattern_WindowsDriveRootPattern_SplitsToDriveRootAndRelative() + { + // Arrange + Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows drive-root paths are only applicable on Windows"); + const string pattern = @"C:\*.json"; + + // Act + var (rootDir, relativePattern) = GlobMatcher.SplitAbsolutePattern(pattern); + + // Assert + Assert.Equal(@"C:\", rootDir); + Assert.Equal("*.json", relativePattern); + } +} diff --git a/test/DemaConsulting.VersionMark.Tests/SelfTest/PathHelpersTests.cs b/test/DemaConsulting.VersionMark.Tests/Utilities/PathHelpersTests.cs similarity index 98% rename from test/DemaConsulting.VersionMark.Tests/SelfTest/PathHelpersTests.cs rename to test/DemaConsulting.VersionMark.Tests/Utilities/PathHelpersTests.cs index ff86e1e..5074605 100644 --- a/test/DemaConsulting.VersionMark.Tests/SelfTest/PathHelpersTests.cs +++ b/test/DemaConsulting.VersionMark.Tests/Utilities/PathHelpersTests.cs @@ -18,9 +18,9 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -using DemaConsulting.VersionMark.SelfTest; +using DemaConsulting.VersionMark.Utilities; -namespace DemaConsulting.VersionMark.Tests.SelfTest; +namespace DemaConsulting.VersionMark.Tests.Utilities; /// /// Tests for the PathHelpers class.