Skip to content

Commit 2ade92f

Browse files
committed
feat: add support for multiple file patterns in ResilientFileSystemMonitor
1 parent 13ee188 commit 2ade92f

7 files changed

Lines changed: 389 additions & 26 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.1.0] - 2025-11-13
11+
12+
### Added
13+
- **Multiple File Pattern Support** for `ResilientFileSystemMonitor`
14+
- `.WithFilter(params string[] patterns)` - Monitor multiple file patterns (e.g., `"*.pfx", "*.p12", "*.cer"`)
15+
- Additive behavior - calling `.WithFilter()` multiple times adds patterns
16+
- `.ClearFilters()` - Remove all configured patterns
17+
- Uses `FileSystemName.MatchesSimpleExpression` for consistent pattern matching with `FileSearcher`
18+
- Supports DOS-style wildcards: `*` (any characters) and `?` (single character)
19+
- Filename-only matching (not full path) for performance
20+
1021
## [2.0.0] - 2025-11-12
1122

1223
### Added

‎README.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ var monitor = ResilientFileSystemMonitor
3939
.OnCreated((sender, e) => Console.WriteLine($"Created: {e.Name}"))
4040
.Build();
4141

42+
// Multiple file patterns (certificates example):
43+
var monitor = ResilientFileSystemMonitor
44+
.Watch(@"C:\certs")
45+
.WithFilter("*.pfx", "*.p12", "*.cer") // Monitor multiple extensions
46+
.OnChanged((sender, e) => Console.WriteLine($"Certificate changed: {e.Name}"))
47+
.Build();
48+
4249
// With subdirectory monitoring (depth control):
4350
var monitor = ResilientFileSystemMonitor
4451
.Watch(@"C:\configs", "*.json")
@@ -119,7 +126,7 @@ finally
119126
| Option | Type | Default | Description |
120127
|--------|------|---------|-------------|
121128
| `Path` | `string` | (required) | Directory path to monitor |
122-
| `Filter` | `string` | `"*"` | File filter pattern (e.g., "*.json", "*.txt") |
129+
| `Filter` | `string` | `"*"` | File filter pattern (e.g., "*.json", "*.txt") - supports multiple patterns |
123130
| `IncludeSubdirectories` | `bool` | `false` | Monitor subdirectories recursively |
124131
| `MaxDepth` | `int` | `0` | Maximum subdirectory depth (0=root only, -1=unlimited) |
125132
| `NotifyFilter` | `NotifyFilters` | `LastWrite \| FileName \| Size` | Types of changes to watch for |

‎docs/examples.md‎

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,15 @@ public class CertificateWatcher
7878

7979
public void Watch(string certificatePath)
8080
{
81-
_monitor = new ResilientFileSystemMonitor(new ResilientFileSystemMonitor.Options
82-
{
83-
Path = certificatePath,
84-
Filter = "*.pfx",
85-
EnablePollingFallback = true,
86-
PollingInterval = TimeSpan.FromSeconds(30),
87-
DebounceTime = TimeSpan.FromSeconds(2)
88-
});
89-
90-
_monitor.Changed += OnCertificateChanged;
91-
_monitor.Error += OnError;
92-
_monitor.ModeChanged += OnModeChanged;
81+
// Monitor multiple certificate formats
82+
_monitor = ResilientFileSystemMonitor
83+
.Watch(certificatePath)
84+
.WithFilter("*.pfx", "*.p12", "*.cer") // Multiple formats
85+
.WithDebounce(TimeSpan.FromSeconds(2))
86+
.OnChanged(OnCertificateChanged)
87+
.OnError(OnError)
88+
.OnModeChanged(OnModeChanged)
89+
.Build();
9390
}
9491

9592
private void OnCertificateChanged(object? sender, FileSystemEventArgs e)

‎docs/resilient-file-system-monitor.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,41 @@ var monitor = ResilientFileSystemMonitor
6767
.Build();
6868
```
6969

70+
### Multiple File Patterns
71+
72+
Monitor multiple file extensions or patterns:
73+
74+
```csharp
75+
// Monitor multiple certificate formats
76+
var monitor = ResilientFileSystemMonitor
77+
.Watch(@"C:\certs")
78+
.WithFilter("*.pfx", "*.p12", "*.cer") // All three formats
79+
.OnChanged((sender, e) => ReloadCertificate(e.FullPath))
80+
.Build();
81+
82+
// Additive pattern building
83+
var monitor = ResilientFileSystemMonitor
84+
.Watch(@"C:\logs")
85+
.WithFilter("*.log") // Add .log files
86+
.WithFilter("*.txt") // Add .txt files
87+
.WithFilter("error-*.json") // Add error JSON files
88+
.Build();
89+
90+
// Clear and reset patterns
91+
var builder = ResilientFileSystemMonitor.Watch(@"C:\data");
92+
builder.WithFilter("*.tmp");
93+
builder.ClearFilters(); // Remove all patterns
94+
builder.WithFilter("*.dat", "*.bin"); // Start fresh
95+
var monitor = builder.Build();
96+
```
97+
98+
**Pattern Matching:**
99+
- Uses DOS-style wildcards: `*` (any characters) and `?` (single character)
100+
- Matches against filename only (not full path) for performance
101+
- Patterns are additive - multiple `WithFilter()` calls add patterns
102+
- Use `ClearFilters()` to reset all patterns
103+
- Examples: `*.pfx`, `backup-*.log`, `config-????.json`
104+
70105
### Subdirectory Depth Control
71106

72107
Control how deeply to monitor subdirectories:
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
using Cocoar.FileSystem.Tests.TestUtilities;
2+
3+
namespace Cocoar.FileSystem.Tests;
4+
5+
public sealed class MultiplePatternTests : IDisposable
6+
{
7+
private readonly string _testRoot;
8+
9+
public MultiplePatternTests()
10+
{
11+
_testRoot = Path.Combine(Path.GetTempPath(), $"CocoarFS_MultiPattern_{Guid.NewGuid():N}");
12+
Directory.CreateDirectory(_testRoot);
13+
}
14+
15+
public void Dispose()
16+
{
17+
try
18+
{
19+
if (Directory.Exists(_testRoot))
20+
Directory.Delete(_testRoot, recursive: true);
21+
}
22+
catch
23+
{
24+
// Best effort cleanup
25+
}
26+
}
27+
28+
[Fact]
29+
public async Task MultiplePatterns_MatchesAllSpecified()
30+
{
31+
// Arrange
32+
var createdFiles = new List<string>();
33+
34+
using var monitor = ResilientFileSystemMonitor.Watch(_testRoot)
35+
.WithFilter("*.pfx", "*.p12", "*.cer")
36+
.Build();
37+
38+
monitor.Created += (_, e) => createdFiles.Add(Path.GetFileName(e.FullPath));
39+
40+
await Task.Delay(200);
41+
42+
// Act
43+
File.WriteAllText(Path.Combine(_testRoot, "cert.pfx"), "pfx");
44+
File.WriteAllText(Path.Combine(_testRoot, "cert.p12"), "p12");
45+
File.WriteAllText(Path.Combine(_testRoot, "cert.cer"), "cer");
46+
File.WriteAllText(Path.Combine(_testRoot, "readme.txt"), "txt");
47+
48+
await ActiveWaitHelpers.WaitUntilAsync(
49+
() => createdFiles.Count >= 3,
50+
timeout: TimeSpan.FromSeconds(3));
51+
52+
// Assert - Only certificate files detected
53+
Assert.Contains("cert.pfx", createdFiles);
54+
Assert.Contains("cert.p12", createdFiles);
55+
Assert.Contains("cert.cer", createdFiles);
56+
Assert.DoesNotContain("readme.txt", createdFiles);
57+
}
58+
59+
[Fact]
60+
public async Task MultiplePatterns_WorksWithWildcards()
61+
{
62+
// Arrange
63+
var createdFiles = new List<string>();
64+
65+
using var monitor = ResilientFileSystemMonitor.Watch(_testRoot)
66+
.WithFilter("test-*.txt", "backup-*.log")
67+
.Build();
68+
69+
monitor.Created += (_, e) => createdFiles.Add(Path.GetFileName(e.FullPath));
70+
71+
await Task.Delay(200);
72+
73+
// Act
74+
File.WriteAllText(Path.Combine(_testRoot, "test-1.txt"), "1");
75+
File.WriteAllText(Path.Combine(_testRoot, "test-2.txt"), "2");
76+
File.WriteAllText(Path.Combine(_testRoot, "backup-old.log"), "old");
77+
File.WriteAllText(Path.Combine(_testRoot, "data.csv"), "csv");
78+
File.WriteAllText(Path.Combine(_testRoot, "other.txt"), "other");
79+
80+
await ActiveWaitHelpers.WaitUntilAsync(
81+
() => createdFiles.Count >= 3,
82+
timeout: TimeSpan.FromSeconds(3));
83+
84+
// Assert
85+
Assert.Contains("test-1.txt", createdFiles);
86+
Assert.Contains("test-2.txt", createdFiles);
87+
Assert.Contains("backup-old.log", createdFiles);
88+
Assert.DoesNotContain("data.csv", createdFiles);
89+
Assert.DoesNotContain("other.txt", createdFiles);
90+
}
91+
92+
[Fact]
93+
public async Task MultiplePatterns_WorksWithSubdirectories()
94+
{
95+
// Arrange
96+
var subDir = Path.Combine(_testRoot, "sub");
97+
Directory.CreateDirectory(subDir);
98+
99+
var createdFiles = new List<string>();
100+
101+
using var monitor = ResilientFileSystemMonitor.Watch(_testRoot)
102+
.WithFilter("*.json", "*.xml")
103+
.IncludeSubdirectories()
104+
.Build();
105+
106+
monitor.Created += (_, e) => createdFiles.Add(Path.GetFileName(e.FullPath));
107+
108+
await Task.Delay(200);
109+
110+
// Act
111+
File.WriteAllText(Path.Combine(_testRoot, "root.json"), "{}");
112+
File.WriteAllText(Path.Combine(subDir, "sub.xml"), "<xml/>");
113+
File.WriteAllText(Path.Combine(subDir, "sub.txt"), "txt");
114+
115+
await ActiveWaitHelpers.WaitUntilAsync(
116+
() => createdFiles.Count >= 2,
117+
timeout: TimeSpan.FromSeconds(3));
118+
119+
// Assert
120+
Assert.Contains("root.json", createdFiles);
121+
Assert.Contains("sub.xml", createdFiles);
122+
Assert.DoesNotContain("sub.txt", createdFiles);
123+
}
124+
125+
[Fact]
126+
public async Task MultiplePatterns_SnapshotFindsExistingFiles()
127+
{
128+
// Arrange - Create files BEFORE starting monitor
129+
File.WriteAllText(Path.Combine(_testRoot, "existing.pfx"), "pfx");
130+
File.WriteAllText(Path.Combine(_testRoot, "existing.p12"), "p12");
131+
File.WriteAllText(Path.Combine(_testRoot, "existing.txt"), "txt");
132+
133+
var changedFiles = new List<string>();
134+
135+
// Act - Start monitor (should snapshot existing files)
136+
using var monitor = ResilientFileSystemMonitor.Watch(_testRoot)
137+
.WithFilter("*.pfx", "*.p12")
138+
.Build();
139+
140+
monitor.Changed += (_, e) => changedFiles.Add(Path.GetFileName(e.FullPath));
141+
142+
await Task.Delay(500); // Give time for initial snapshot
143+
144+
// Modify files to trigger change events
145+
File.WriteAllText(Path.Combine(_testRoot, "existing.pfx"), "modified-pfx");
146+
File.WriteAllText(Path.Combine(_testRoot, "existing.p12"), "modified-p12");
147+
File.WriteAllText(Path.Combine(_testRoot, "existing.txt"), "modified-txt");
148+
149+
await ActiveWaitHelpers.WaitUntilAsync(
150+
() => changedFiles.Count >= 2,
151+
timeout: TimeSpan.FromSeconds(3));
152+
153+
// Assert - Should detect changes to pfx/p12 but not txt
154+
Assert.Contains("existing.pfx", changedFiles);
155+
Assert.Contains("existing.p12", changedFiles);
156+
Assert.DoesNotContain("existing.txt", changedFiles);
157+
}
158+
159+
[Fact]
160+
public void WithFilter_EmptyArray_ThrowsArgumentException()
161+
{
162+
// Act & Assert
163+
Assert.Throws<ArgumentException>(() =>
164+
ResilientFileSystemMonitor.Watch(_testRoot).WithFilter([]));
165+
}
166+
167+
[Fact]
168+
public void WithFilter_NullPattern_ThrowsArgumentNullException()
169+
{
170+
// Act & Assert
171+
Assert.Throws<ArgumentNullException>(() =>
172+
ResilientFileSystemMonitor.Watch(_testRoot).WithFilter((string[])null!));
173+
}
174+
175+
[Fact]
176+
public void WithFilter_ContainsNullOrWhitespace_ThrowsArgumentException()
177+
{
178+
// Act & Assert
179+
Assert.Throws<ArgumentException>(() =>
180+
ResilientFileSystemMonitor.Watch(_testRoot).WithFilter("*.txt", null!, "*.log"));
181+
}
182+
183+
[Fact]
184+
public async Task WithFilter_MultipleCalls_AddsPatterns()
185+
{
186+
// Arrange
187+
var createdFiles = new List<string>();
188+
189+
using var monitor = ResilientFileSystemMonitor.Watch(_testRoot)
190+
.WithFilter("*.txt") // First call
191+
.WithFilter("*.pfx", "*.p12") // Second call - should ADD, not replace
192+
.Build();
193+
194+
monitor.Created += (_, e) => createdFiles.Add(Path.GetFileName(e.FullPath));
195+
196+
await Task.Delay(200);
197+
198+
// Act
199+
File.WriteAllText(Path.Combine(_testRoot, "test.txt"), "txt");
200+
File.WriteAllText(Path.Combine(_testRoot, "test.pfx"), "pfx");
201+
File.WriteAllText(Path.Combine(_testRoot, "test.p12"), "p12");
202+
203+
await ActiveWaitHelpers.WaitUntilAsync(
204+
() => createdFiles.Count >= 3,
205+
timeout: TimeSpan.FromSeconds(3));
206+
207+
// Assert - Should match ALL patterns (additive behavior)
208+
Assert.Contains("test.txt", createdFiles);
209+
Assert.Contains("test.pfx", createdFiles);
210+
Assert.Contains("test.p12", createdFiles);
211+
}
212+
213+
[Fact]
214+
public async Task ClearFilters_RemovesAllPatterns()
215+
{
216+
// Arrange
217+
var createdFiles = new List<string>();
218+
219+
using var monitor = ResilientFileSystemMonitor.Watch(_testRoot)
220+
.WithFilter("*.txt")
221+
.WithFilter("*.pfx")
222+
.ClearFilters() // Should clear all patterns
223+
.Build();
224+
225+
monitor.Created += (_, e) => createdFiles.Add(Path.GetFileName(e.FullPath));
226+
227+
await Task.Delay(200);
228+
229+
// Act
230+
File.WriteAllText(Path.Combine(_testRoot, "test.txt"), "txt");
231+
File.WriteAllText(Path.Combine(_testRoot, "test.pfx"), "pfx");
232+
233+
await ActiveWaitHelpers.WaitUntilAsync(
234+
() => createdFiles.Count >= 2,
235+
timeout: TimeSpan.FromSeconds(3));
236+
237+
// Assert - Should match ALL files (no filters)
238+
Assert.Contains("test.txt", createdFiles);
239+
Assert.Contains("test.pfx", createdFiles);
240+
}
241+
}

0 commit comments

Comments
 (0)