Skip to content

Commit 4d22767

Browse files
Implement --required-files CLI argument with glob matching, tests, and README docs
Co-authored-by: 304NotModified <5808377+304NotModified@users.noreply.github.com> Agent-Logs-Url: https://github.com/304NotModified/SLNX-validator/sessions/a91fd106-0ab8-4a61-966c-d18d183152f2
1 parent 1564a23 commit 4d22767

6 files changed

Lines changed: 330 additions & 1 deletion

File tree

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,54 @@ slnx-validator MySolution.slnx --sonarqube-report-file sonar-issues.json --conti
6565

6666
Always exits with code `0`, even when validation errors are found. Useful in CI pipelines where SonarQube handles the failure decision. Default: `false`.
6767

68+
### `--required-files`
69+
70+
Verify that a set of files or directories matching glob patterns exist before the tool runs. If any pattern produces no match, or if a matched path does not exist on disk, the tool exits with code `2`.
71+
72+
**Syntax**
73+
74+
```
75+
--required-files "<pattern1>;<pattern2>;..."
76+
```
77+
78+
Patterns are separated by `;`. Patterns starting with `!` are exclusions. Pattern order matters: a later pattern can override an earlier one.
79+
80+
**Supported glob syntax**
81+
82+
| Pattern | Meaning | Example |
83+
|---|---|---|
84+
| `*` | Any file in the current directory (no path separator) | `doc/*.md` |
85+
| `**` | Any depth of subdirectories | `src/**/*.cs` |
86+
| `!pattern` | Exclude matching paths | `!**/bin/**` |
87+
| `dir/` | Match a directory and its contents | `docs/` |
88+
89+
> **Note:** `{a,b}` alternation and `[abc]` character classes are not supported by this library. Use multiple patterns separated by `;` instead.
90+
> For example, instead of `*.{cs,fs}`, use `**/*.cs;**/*.fs`.
91+
92+
**Examples**
93+
94+
Require all `.md` files under `doc/`:
95+
```
96+
slnx-validator MySolution.slnx --required-files "doc/*.md"
97+
```
98+
99+
Require all `.cs` files under `src/`, excluding the `bin` and `obj` folders:
100+
```
101+
slnx-validator MySolution.slnx --required-files "src/**/*.cs;!**/bin/**;!**/obj/**"
102+
```
103+
104+
Require a specific config file and the entire `docs/` directory:
105+
```
106+
slnx-validator MySolution.slnx --required-files "appsettings.json;docs/"
107+
```
108+
109+
**Exit codes**
110+
111+
| Code | Description |
112+
|------|-------------|
113+
| `0` | All patterns matched and all matched paths exist on disk. |
114+
| `2` | One or more patterns produced no matches, or a matched path does not exist on disk. |
115+
68116
## SonarQube integration example
69117

70118
```powershell
@@ -185,6 +233,7 @@ The following are **intentionally out of scope** because the toolchain already h
185233
| `SLNX011` | `ReferencedFileNotFound` | A file referenced in `<File Path="...">` does not exist on disk. |
186234
| `SLNX012` | `InvalidWildcardUsage` | A `<File Path="...">` contains a wildcard pattern (see [`examples/invalid-wildcard.slnx`](examples/invalid-wildcard.slnx)). |
187235
| `SLNX013` | `XsdViolation` | The XML structure violates the schema, e.g. `<Folder>` inside `<Folder>` (see [`examples/invalid-xsd.slnx`](examples/invalid-xsd.slnx)). |
236+
| `SLNX020` | `RequiredFilesNotFound` | A `--required-files` pattern produced no matches, or a matched path does not exist on disk (exits with code `2`). |
188237

189238
## XSD Schema
190239

src/SLNX-validator/Program.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,17 @@ public static async Task<int> Main(string[] args)
2323
Description = "Continue and exit with code 0 even when validation errors are found."
2424
};
2525

26+
var requiredFilesOption = new Option<string?>("--required-files")
27+
{
28+
Description = "Semicolon-separated glob patterns for required files and directories. The tool exits with code 2 if any pattern produces no match or a matched path does not exist."
29+
};
30+
2631
var rootCommand = new RootCommand("Validates .slnx solution files.")
2732
{
2833
inputArgument,
2934
sonarqubeReportOption,
30-
continueOnErrorOption
35+
continueOnErrorOption,
36+
requiredFilesOption
3137
};
3238

3339
var services = new ServiceCollection()
@@ -38,6 +44,14 @@ public static async Task<int> Main(string[] args)
3844

3945
rootCommand.SetAction(async (parseResult, cancellationToken) =>
4046
{
47+
var requiredFiles = parseResult.GetValue(requiredFilesOption);
48+
if (requiredFiles is not null)
49+
{
50+
var checkResult = await RequiredFilesChecker.CheckAsync(requiredFiles, Environment.CurrentDirectory);
51+
if (checkResult != 0)
52+
return checkResult;
53+
}
54+
4155
var input = parseResult.GetValue(inputArgument);
4256
var sonarqubeReport = parseResult.GetValue(sonarqubeReportOption);
4357
var continueOnError = parseResult.GetValue(continueOnErrorOption);
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using Microsoft.Extensions.FileSystemGlobbing;
2+
using Microsoft.Extensions.FileSystemGlobbing.Abstractions;
3+
4+
namespace JulianVerdurmen.SlnxValidator;
5+
6+
internal static class RequiredFilesChecker
7+
{
8+
public static async Task<int> CheckAsync(string patternsRaw, string rootDirectory)
9+
{
10+
var patterns = patternsRaw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
11+
12+
var matcher = new Matcher(StringComparison.OrdinalIgnoreCase, preserveFilterOrder: true);
13+
14+
foreach (var pattern in patterns)
15+
{
16+
if (pattern.StartsWith('!'))
17+
matcher.AddExclude(pattern[1..]);
18+
else
19+
matcher.AddInclude(pattern);
20+
}
21+
22+
var directoryInfo = new DirectoryInfoWrapper(new DirectoryInfo(rootDirectory));
23+
var result = matcher.Execute(directoryInfo);
24+
25+
if (!result.HasMatches)
26+
{
27+
await Console.Error.WriteLineAsync($"[SLNX020] Required files check failed: no files matched the patterns: {patternsRaw}");
28+
return 2;
29+
}
30+
31+
return 0;
32+
}
33+
}

src/SLNX-validator/SLNX-validator.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
<ItemGroup>
2929
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.*" />
30+
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="*" />
3031
<PackageReference Include="System.CommandLine" Version="2.*" />
3132
</ItemGroup>
3233

tests/SLNX-validator.Tests/ProgramIntegrationTests.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,81 @@ public async Task Invoke_WithBinaryFile_ReturnsNonZeroExitCode()
139139
File.Delete(path);
140140
}
141141
}
142+
143+
[Test]
144+
[NotInParallel("CurrentDirectory")]
145+
public async Task Invoke_WithRequiredFiles_AllMatch_ReturnsZeroExitCode()
146+
{
147+
var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
148+
Directory.CreateDirectory(tempDir);
149+
150+
var csprojPath = Path.Combine(tempDir, "App.csproj");
151+
var slnxPath = Path.Combine(tempDir, "test.slnx");
152+
var docDir = Path.Combine(tempDir, "doc");
153+
Directory.CreateDirectory(docDir);
154+
155+
await File.WriteAllTextAsync(csprojPath, "<Project />");
156+
await File.WriteAllTextAsync(slnxPath, """
157+
<Solution>
158+
<Project Path="App.csproj" />
159+
</Solution>
160+
""");
161+
await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme");
162+
163+
try
164+
{
165+
var previousDir = Environment.CurrentDirectory;
166+
Environment.CurrentDirectory = tempDir;
167+
try
168+
{
169+
var exitCode = await Program.Main([slnxPath, "--required-files", "doc/*.md"]);
170+
exitCode.Should().Be(0);
171+
}
172+
finally
173+
{
174+
Environment.CurrentDirectory = previousDir;
175+
}
176+
}
177+
finally
178+
{
179+
Directory.Delete(tempDir, recursive: true);
180+
}
181+
}
182+
183+
[Test]
184+
[NotInParallel("CurrentDirectory")]
185+
public async Task Invoke_WithRequiredFiles_NoMatch_ReturnsTwoExitCode()
186+
{
187+
var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
188+
Directory.CreateDirectory(tempDir);
189+
190+
var csprojPath = Path.Combine(tempDir, "App.csproj");
191+
var slnxPath = Path.Combine(tempDir, "test.slnx");
192+
193+
await File.WriteAllTextAsync(csprojPath, "<Project />");
194+
await File.WriteAllTextAsync(slnxPath, """
195+
<Solution>
196+
<Project Path="App.csproj" />
197+
</Solution>
198+
""");
199+
200+
try
201+
{
202+
var previousDir = Environment.CurrentDirectory;
203+
Environment.CurrentDirectory = tempDir;
204+
try
205+
{
206+
var exitCode = await Program.Main([slnxPath, "--required-files", "nonexistent/**/*.md"]);
207+
exitCode.Should().Be(2);
208+
}
209+
finally
210+
{
211+
Environment.CurrentDirectory = previousDir;
212+
}
213+
}
214+
finally
215+
{
216+
Directory.Delete(tempDir, recursive: true);
217+
}
218+
}
142219
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
using AwesomeAssertions;
2+
3+
namespace JulianVerdurmen.SlnxValidator.Tests;
4+
5+
public class RequiredFilesCheckerTests
6+
{
7+
private static string CreateTempDir()
8+
{
9+
var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
10+
Directory.CreateDirectory(tempDir);
11+
return tempDir;
12+
}
13+
14+
[Test]
15+
public async Task CheckAsync_SingleIncludePattern_MatchesFiles_ReturnsZero()
16+
{
17+
var tempDir = CreateTempDir();
18+
try
19+
{
20+
var docDir = Path.Combine(tempDir, "doc");
21+
Directory.CreateDirectory(docDir);
22+
await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme");
23+
await File.WriteAllTextAsync(Path.Combine(docDir, "contributing.md"), "# Contributing");
24+
25+
var exitCode = await RequiredFilesChecker.CheckAsync("doc/*.md", tempDir);
26+
27+
exitCode.Should().Be(0);
28+
}
29+
finally
30+
{
31+
Directory.Delete(tempDir, recursive: true);
32+
}
33+
}
34+
35+
[Test]
36+
public async Task CheckAsync_IncludeFollowedByExclude_ExcludesFiles_ReturnsZero()
37+
{
38+
var tempDir = CreateTempDir();
39+
try
40+
{
41+
var docDir = Path.Combine(tempDir, "doc");
42+
Directory.CreateDirectory(docDir);
43+
await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme");
44+
await File.WriteAllTextAsync(Path.Combine(docDir, "contributing.md"), "# Contributing");
45+
46+
// Include all .md in doc/, then exclude contributing.md — should still match (readme.md)
47+
var exitCode = await RequiredFilesChecker.CheckAsync("doc/*.md;!doc/contributing.md", tempDir);
48+
49+
exitCode.Should().Be(0);
50+
}
51+
finally
52+
{
53+
Directory.Delete(tempDir, recursive: true);
54+
}
55+
}
56+
57+
[Test]
58+
public async Task CheckAsync_IncludeFollowedByExcludeAll_NoMatches_ReturnsNonZero()
59+
{
60+
var tempDir = CreateTempDir();
61+
try
62+
{
63+
var docDir = Path.Combine(tempDir, "doc");
64+
Directory.CreateDirectory(docDir);
65+
await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme");
66+
67+
// Include then exclude everything → no matches
68+
var exitCode = await RequiredFilesChecker.CheckAsync("doc/*.md;!doc/*.md", tempDir);
69+
70+
exitCode.Should().NotBe(0);
71+
}
72+
finally
73+
{
74+
Directory.Delete(tempDir, recursive: true);
75+
}
76+
}
77+
78+
[Test]
79+
public async Task CheckAsync_ExcludeFollowedByReInclude_RestoresFile_ReturnsZero()
80+
{
81+
var tempDir = CreateTempDir();
82+
try
83+
{
84+
var docDir = Path.Combine(tempDir, "doc");
85+
Directory.CreateDirectory(docDir);
86+
await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme");
87+
await File.WriteAllTextAsync(Path.Combine(docDir, "contributing.md"), "# Contributing");
88+
89+
// Exclude all md, then re-include readme.md → readme.md should match
90+
var exitCode = await RequiredFilesChecker.CheckAsync("doc/*.md;!doc/*.md;doc/readme.md", tempDir);
91+
92+
exitCode.Should().Be(0);
93+
}
94+
finally
95+
{
96+
Directory.Delete(tempDir, recursive: true);
97+
}
98+
}
99+
100+
[Test]
101+
public async Task CheckAsync_PatternWithNoMatches_ReturnsNonZero()
102+
{
103+
var tempDir = CreateTempDir();
104+
try
105+
{
106+
var exitCode = await RequiredFilesChecker.CheckAsync("nonexistent/**/*.cs", tempDir);
107+
108+
exitCode.Should().NotBe(0);
109+
}
110+
finally
111+
{
112+
Directory.Delete(tempDir, recursive: true);
113+
}
114+
}
115+
116+
[Test]
117+
public async Task CheckAsync_WhitespaceAroundPatterns_IsTrimmed_ReturnsZero()
118+
{
119+
var tempDir = CreateTempDir();
120+
try
121+
{
122+
var docDir = Path.Combine(tempDir, "doc");
123+
Directory.CreateDirectory(docDir);
124+
await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme");
125+
126+
var exitCode = await RequiredFilesChecker.CheckAsync(" doc/*.md ", tempDir);
127+
128+
exitCode.Should().Be(0);
129+
}
130+
finally
131+
{
132+
Directory.Delete(tempDir, recursive: true);
133+
}
134+
}
135+
136+
[Test]
137+
public async Task CheckAsync_EmptyPatternEntries_AreDiscarded_ReturnsZero()
138+
{
139+
var tempDir = CreateTempDir();
140+
try
141+
{
142+
var docDir = Path.Combine(tempDir, "doc");
143+
Directory.CreateDirectory(docDir);
144+
await File.WriteAllTextAsync(Path.Combine(docDir, "readme.md"), "# Readme");
145+
146+
var exitCode = await RequiredFilesChecker.CheckAsync(";;doc/*.md;;", tempDir);
147+
148+
exitCode.Should().Be(0);
149+
}
150+
finally
151+
{
152+
Directory.Delete(tempDir, recursive: true);
153+
}
154+
}
155+
}

0 commit comments

Comments
 (0)