Skip to content

Commit ff6a409

Browse files
authored
feat: add folder rename detection and harmonize FileSearcher API (#3)
* feat: add folder rename detection and harmonize FileSearcher API - Folder rename detection now always enabled for ResilientFileSystemMonitor - Emits Renamed events when folders containing matching files are renamed - Uses high-performance FileSearcher for directory enumeration - Critical for correctness - prevents blind spots in file monitoring - Essential for certificate rotation scenarios (atomic folder swaps) - FileSearcher API harmonization with ResilientFileSystemMonitor - Added .WithFilter(params string[]) for multiple patterns - Added .ClearFilters() to remove all patterns - Added .IncludeSubdirectories(int) for depth control - Added .IncludeSubdirectories(bool) for simple on/off - Fully backward compatible - all existing methods retained - 139 tests passing, comprehensive coverage for both features * ci: disable fail-fast in PR validation to see all platform test results * fix: use HashSet in SubdirectoryDepthTests to handle macOS duplicate events macOS FileSystemWatcher can fire duplicate events for the same file operation. Using HashSet instead of List automatically deduplicates by filename, making tests robust across all platforms while still verifying correct behavior.
1 parent 5c95536 commit ff6a409

9 files changed

Lines changed: 1158 additions & 15 deletions

File tree

‎.github/workflows/01-pr-validation.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ jobs:
99
name: Test on ${{ matrix.os }}
1010
runs-on: ${{ matrix.os }}
1111
strategy:
12+
fail-fast: false # Continue testing on other platforms even if one fails
1213
matrix:
1314
os: [ubuntu-latest, windows-latest, macos-latest]
1415
permissions:

‎CHANGELOG.md‎

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

88
## [Unreleased]
99

10+
## [2.2.0] - 2025-11-15
11+
12+
### Added
13+
- **Folder Rename Detection** for `ResilientFileSystemMonitor`
14+
- Automatically emits `Renamed` events when directories containing matching files are renamed
15+
- No configuration needed - works out of the box for correctness
16+
- Uses high-performance `FileSearcher` for efficient directory enumeration
17+
- Respects `MaxDepth` and filter patterns when checking directory contents
18+
- Useful for certificate rotation scenarios where entire folders are atomically renamed (e.g., `kid2-staging` → `kid2`)
19+
- When a folder containing matching files is renamed, a single `Renamed` event is emitted with the folder path
20+
- **API Harmonization** for `FileSearcher` (matches `ResilientFileSystemMonitor` API)
21+
- `.WithFilter(params string[])` - Search for multiple file patterns (e.g., `"*.cs", "*.csproj"`)
22+
- `.ClearFilters()` - Remove all configured patterns
23+
- `.IncludeSubdirectories(int maxDepth)` - Control recursion depth (0 = root only, -1 = unlimited)
24+
- `.IncludeSubdirectories(bool)` - Enable/disable recursion (true = unlimited, false = root only)
25+
- Additive behavior - calling `.WithFilter()` multiple times adds patterns
26+
- All existing methods remain unchanged - fully backward compatible
27+
1028
## [2.1.0] - 2025-11-13
1129

1230
### Added

‎README.md‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ var monitor = ResilientFileSystemMonitor
5252
.IncludeSubdirectories(2) // Monitor up to 2 levels deep
5353
.OnChanged((sender, e) => Console.WriteLine($"Changed: {e.FullPath}"))
5454
.Build();
55+
56+
// Detect folder renames (certificate rotation scenario):
57+
var monitor = ResilientFileSystemMonitor
58+
.Watch(@"C:\certs")
59+
.WithFilter("*.pfx")
60+
.IncludeSubdirectories()
61+
.OnRenamed((sender, e) =>
62+
{
63+
// Fires when folders containing .pfx files are renamed
64+
// OR when individual .pfx files are renamed
65+
Console.WriteLine($"Renamed: {e.OldFullPath} -> {e.FullPath}");
66+
})
67+
.Build();
5568
```
5669

5770
**Key Features:** Auto-recovery, debouncing, depth control, reactive streams, health checks
@@ -61,19 +74,41 @@ var monitor = ResilientFileSystemMonitor
6174

6275
### High-Performance File Search
6376

64-
Fast, lazy-evaluated directory traversal:
77+
Fast, lazy-evaluated directory traversal with fluent API:
6578

6679
```csharp
6780
using Cocoar.FileSystem;
6881

82+
// Find all C# files in a project (excluding build folders)
6983
var codeFiles = FileSearcher
7084
.Search(@"C:\repos\myproject", "*.cs")
7185
.Excluding("bin", "obj", "node_modules")
7286
.WithMaxDepth(5)
7387
.ToList();
88+
89+
// Multiple file patterns (harmonized with ResilientFileSystemMonitor API)
90+
var projectFiles = FileSearcher
91+
.InDirectory(@"C:\repos\myapp")
92+
.WithFilter("*.cs", "*.csproj", "*.json")
93+
.IncludeSubdirectories(2) // Search 2 levels deep
94+
.ToList();
95+
96+
// Lazy evaluation with LINQ
97+
var largeFiles = FileSearcher
98+
.InDirectory(@"C:\data")
99+
.WithPattern("*.log")
100+
.Recursively()
101+
.Where(file => new FileInfo(file).Length > 1_000_000)
102+
.Take(10);
103+
104+
// Search only current directory (no recursion)
105+
var configs = FileSearcher
106+
.Search(@"C:\app", "*.json")
107+
.WithMaxDepth(0) // Current directory only
108+
.ToArray();
74109
```
75110

76-
**Key Features:** Lazy evaluation, depth limits, exclusion patterns, LINQ support
111+
**Key Features:** Lazy evaluation, depth limits, exclusion patterns, LINQ support, efficient for large directories
77112
📖 **[Examples](docs/examples.md#high-performance-file-search)**
78113

79114
---

‎docs/examples.md‎

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,3 +470,195 @@ public class CredentialReader
470470
}
471471
}
472472
```
473+
474+
## High-Performance File Search
475+
476+
### Finding Code Files with Exclusions
477+
478+
Search for source files while excluding build and dependency folders:
479+
480+
```csharp
481+
using Cocoar.FileSystem;
482+
483+
public class CodeAnalyzer
484+
{
485+
public List<string> FindSourceFiles(string projectRoot)
486+
{
487+
// Find all C# files, excluding common build/dependency folders
488+
var sourceFiles = FileSearcher
489+
.Search(projectRoot, "*.cs")
490+
.Excluding("bin", "obj", "packages", "node_modules", ".git")
491+
.Recursively()
492+
.ToList();
493+
494+
Console.WriteLine($"Found {sourceFiles.Count} C# files");
495+
return sourceFiles;
496+
}
497+
}
498+
```
499+
500+
### Lazy Evaluation with LINQ
501+
502+
Use LINQ to process files as they're discovered without loading all results into memory:
503+
504+
```csharp
505+
using Cocoar.FileSystem;
506+
507+
public class LargeFileFinder
508+
{
509+
public void FindAndProcessLargeFiles(string searchPath)
510+
{
511+
// Lazy evaluation - files are found and processed one at a time
512+
var largeLogFiles = FileSearcher
513+
.InDirectory(searchPath)
514+
.WithPattern("*.log")
515+
.WithMaxDepth(3)
516+
.Where(file => new FileInfo(file).Length > 10_000_000) // > 10 MB
517+
.OrderByDescending(file => new FileInfo(file).Length)
518+
.Take(10); // Only get top 10
519+
520+
foreach (var file in largeLogFiles)
521+
{
522+
Console.WriteLine($"Large file: {file} ({new FileInfo(file).Length:N0} bytes)");
523+
ArchiveFile(file);
524+
}
525+
}
526+
527+
private void ArchiveFile(string filePath) { /* ... */ }
528+
}
529+
```
530+
531+
### Depth-Limited Search
532+
533+
Control how deep the search recurses into subdirectories:
534+
535+
```csharp
536+
using Cocoar.FileSystem;
537+
538+
public class ConfigurationScanner
539+
{
540+
public void ScanConfigs(string appRoot)
541+
{
542+
// Only search current directory (no subdirectories)
543+
var rootConfigs = FileSearcher
544+
.Search(appRoot, "*.json")
545+
.WithMaxDepth(0) // 0 = current directory only
546+
.ToList();
547+
548+
// Search up to 2 levels deep
549+
var nestedConfigs = FileSearcher
550+
.Search(appRoot, "*.json")
551+
.WithMaxDepth(2) // appRoot + 2 levels of subdirectories
552+
.Excluding("node_modules")
553+
.ToList();
554+
555+
// Unlimited depth (all subdirectories)
556+
var allConfigs = FileSearcher
557+
.Search(appRoot, "*.json")
558+
.Recursively() // Same as .WithMaxDepth(null)
559+
.ToList();
560+
561+
Console.WriteLine($"Root: {rootConfigs.Count}, Nested: {nestedConfigs.Count}, All: {allConfigs.Count}");
562+
}
563+
}
564+
```
565+
566+
### Multiple Search Operations
567+
568+
Combine multiple searches efficiently:
569+
570+
```csharp
571+
using Cocoar.FileSystem;
572+
573+
public class AssetScanner
574+
{
575+
public Dictionary<string, List<string>> CategorizeAssets(string projectPath)
576+
{
577+
var result = new Dictionary<string, List<string>>();
578+
579+
// Images
580+
result["images"] = FileSearcher
581+
.InDirectory(Path.Combine(projectPath, "assets"))
582+
.WithPattern("*.png")
583+
.Recursively()
584+
.ToList();
585+
586+
// Stylesheets
587+
result["styles"] = FileSearcher
588+
.InDirectory(Path.Combine(projectPath, "styles"))
589+
.WithPattern("*.css")
590+
.Excluding("dist", "build")
591+
.ToList();
592+
593+
// Scripts
594+
result["scripts"] = FileSearcher
595+
.InDirectory(Path.Combine(projectPath, "src"))
596+
.WithPattern("*.js")
597+
.WithMaxDepth(5)
598+
.ToList();
599+
600+
return result;
601+
}
602+
}
603+
```
604+
605+
### Finding Recent Files
606+
607+
Combine FileSearcher with LINQ and FileInfo for advanced filtering:
608+
609+
```csharp
610+
using Cocoar.FileSystem;
611+
612+
public class RecentFilesFinder
613+
{
614+
public List<string> FindRecentlyModifiedFiles(string searchPath, TimeSpan maxAge)
615+
{
616+
var cutoffTime = DateTime.Now - maxAge;
617+
618+
// Lazy evaluation - only checks files that match the pattern
619+
var recentFiles = FileSearcher
620+
.InDirectory(searchPath)
621+
.WithPattern("*.*")
622+
.Recursively()
623+
.Where(file => File.GetLastWriteTime(file) > cutoffTime)
624+
.OrderByDescending(file => File.GetLastWriteTime(file))
625+
.ToList();
626+
627+
Console.WriteLine($"Found {recentFiles.Count} files modified in the last {maxAge.TotalHours} hours");
628+
return recentFiles;
629+
}
630+
}
631+
```
632+
633+
### Performance: Lazy vs Eager Evaluation
634+
635+
```csharp
636+
using Cocoar.FileSystem;
637+
638+
public class PerformanceExample
639+
{
640+
public void LazyEvaluation()
641+
{
642+
// Lazy - no I/O happens until you iterate
643+
var query = FileSearcher
644+
.Search(@"C:\Windows", "*.dll")
645+
.Recursively();
646+
647+
// I/O happens here, but stops after finding 5 files
648+
var firstFive = query.Take(5).ToList();
649+
650+
Console.WriteLine("Only enumerated files until we found 5 matches");
651+
}
652+
653+
public void EagerEvaluation()
654+
{
655+
// Eager - ToList() forces full enumeration immediately
656+
var allFiles = FileSearcher
657+
.Search(@"C:\Program Files", "*.exe")
658+
.Recursively()
659+
.ToList(); // All files loaded into memory at once
660+
661+
Console.WriteLine($"Loaded all {allFiles.Count} files into memory");
662+
}
663+
}
664+
```

0 commit comments

Comments
 (0)