Skip to content

Commit 7d559f7

Browse files
committed
Add FindParentWithFileMatching to find the closest parent directory containing a file matching wildcards
1 parent 591e917 commit 7d559f7

File tree

7 files changed

+229
-20
lines changed

7 files changed

+229
-20
lines changed

Pathy.ApiVerificationTests/ApprovedApi/pathy.net47.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ namespace Pathy
1919
public static Pathy.ChainablePath Empty { get; }
2020
public static Pathy.ChainablePath New { get; }
2121
public static Pathy.ChainablePath Temp { get; }
22+
public Pathy.ChainablePath FindParentWithFileMatching(params string[] wildcards) { }
2223
public bool HasExtension(string extension) { }
2324
public Pathy.ChainablePath ToAbsolute() { }
2425
public object ToAbsolute(Pathy.ChainablePath parentPath) { }

Pathy.ApiVerificationTests/ApprovedApi/pathy.net8.0.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ namespace Pathy
2020
public static Pathy.ChainablePath New { get; }
2121
public static Pathy.ChainablePath Temp { get; }
2222
public Pathy.ChainablePath AsRelativeTo(Pathy.ChainablePath basePath) { }
23+
public Pathy.ChainablePath FindParentWithFileMatching(params string[] wildcards) { }
2324
public bool HasExtension(string extension) { }
2425
public Pathy.ChainablePath ToAbsolute() { }
2526
public object ToAbsolute(Pathy.ChainablePath parentPath) { }

Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.0.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ namespace Pathy
1919
public static Pathy.ChainablePath Empty { get; }
2020
public static Pathy.ChainablePath New { get; }
2121
public static Pathy.ChainablePath Temp { get; }
22+
public Pathy.ChainablePath FindParentWithFileMatching(params string[] wildcards) { }
2223
public bool HasExtension(string extension) { }
2324
public Pathy.ChainablePath ToAbsolute() { }
2425
public object ToAbsolute(Pathy.ChainablePath parentPath) { }

Pathy.ApiVerificationTests/ApprovedApi/pathy.netstandard2.1.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ namespace Pathy
2020
public static Pathy.ChainablePath New { get; }
2121
public static Pathy.ChainablePath Temp { get; }
2222
public Pathy.ChainablePath AsRelativeTo(Pathy.ChainablePath basePath) { }
23+
public Pathy.ChainablePath FindParentWithFileMatching(params string[] wildcards) { }
2324
public bool HasExtension(string extension) { }
2425
public Pathy.ChainablePath ToAbsolute() { }
2526
public object ToAbsolute(Pathy.ChainablePath parentPath) { }

Pathy.Specs/ChainablePathSpecs.cs

Lines changed: 177 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,183 @@ public void Can_determine_if_a_path_refers_to_a_directory()
513513
path.IsDirectory.Should().BeTrue();
514514
}
515515

516+
[Fact]
517+
public void Parent_directory_with_matching_file_is_found()
518+
{
519+
// Arrange
520+
var testRoot = testFolder / "FindParentTest";
521+
var grandparentDir = testRoot / "Grandparent";
522+
var parentDir = grandparentDir / "Parent";
523+
var childDir = parentDir / "Child";
524+
525+
childDir.CreateDirectoryRecursively();
526+
File.WriteAllText(parentDir / "project.sln", "solution file");
527+
File.WriteAllText(childDir / "program.cs", "some code");
528+
529+
// Act
530+
var result = childDir.FindParentWithFileMatching("*.sln");
531+
532+
// Assert
533+
result.Should().Be(parentDir);
534+
}
535+
536+
[Fact]
537+
public void Parent_directory_with_multiple_matching_wildcards_is_found()
538+
{
539+
// Arrange
540+
var testRoot = testFolder / "FindParentMultipleTest";
541+
var grandparentDir = testRoot / "Grandparent";
542+
var parentDir = grandparentDir / "Parent";
543+
var childDir = parentDir / "Child";
544+
545+
childDir.CreateDirectoryRecursively();
546+
File.WriteAllText(parentDir / "project.slnx", "solution file");
547+
File.WriteAllText(childDir / "program.cs", "some code");
548+
549+
// Act
550+
var result = childDir.FindParentWithFileMatching("*.sln", "*.slnx");
551+
552+
// Assert
553+
result.Should().Be(parentDir);
554+
}
555+
556+
[Fact]
557+
public void Empty_path_returned_when_no_match_found()
558+
{
559+
// Arrange
560+
var testRoot = testFolder / "FindParentNoMatchTest";
561+
var parentDir = testRoot / "Parent";
562+
var childDir = parentDir / "Child";
563+
564+
childDir.CreateDirectoryRecursively();
565+
File.WriteAllText(childDir / "program.cs", "some code");
566+
567+
// Act
568+
var result = childDir.FindParentWithFileMatching("*.sln");
569+
570+
// Assert
571+
result.Should().Be(ChainablePath.Empty);
572+
}
573+
574+
[Fact]
575+
public void Parent_search_works_from_file_path()
576+
{
577+
// Arrange
578+
var testRoot = testFolder / "FindParentFromFileTest";
579+
var parentDir = testRoot / "Parent";
580+
var childDir = parentDir / "Child";
581+
582+
childDir.CreateDirectoryRecursively();
583+
File.WriteAllText(parentDir / "project.sln", "solution file");
584+
File.WriteAllText(childDir / "program.cs", "some code");
585+
586+
var filePath = childDir / "program.cs";
587+
588+
// Act
589+
var result = filePath.FindParentWithFileMatching("*.sln");
590+
591+
// Assert
592+
result.Should().Be(parentDir);
593+
}
594+
595+
[Fact]
596+
public void Closest_parent_directory_is_found()
597+
{
598+
// Arrange
599+
var testRoot = testFolder / "FindParentClosestTest";
600+
var grandparentDir = testRoot / "Grandparent";
601+
var parentDir = grandparentDir / "Parent";
602+
var childDir = parentDir / "Child";
603+
604+
childDir.CreateDirectoryRecursively();
605+
File.WriteAllText(grandparentDir / "outer.sln", "outer solution file");
606+
File.WriteAllText(parentDir / "inner.sln", "inner solution file");
607+
File.WriteAllText(childDir / "program.cs", "some code");
608+
609+
// Act
610+
var result = childDir.FindParentWithFileMatching("*.sln");
611+
612+
// Assert
613+
result.Should().Be(parentDir); // Should find the closest parent, not the grandparent
614+
}
615+
616+
[Fact]
617+
public void Case_insensitive_matching_works()
618+
{
619+
// Arrange
620+
var testRoot = testFolder / "FindParentCaseTest";
621+
var parentDir = testRoot / "Parent";
622+
var childDir = parentDir / "Child";
623+
624+
childDir.CreateDirectoryRecursively();
625+
File.WriteAllText(parentDir / "PROJECT.SLN", "solution file");
626+
File.WriteAllText(childDir / "program.cs", "some code");
627+
628+
// Act
629+
var result = childDir.FindParentWithFileMatching("*.sln");
630+
631+
// Assert
632+
result.Should().Be(parentDir);
633+
}
634+
635+
[Fact]
636+
public void Null_wildcards_throws_exception()
637+
{
638+
// Arrange
639+
var path = testFolder / "SomeDir";
640+
641+
// Act
642+
Action act = () => path.FindParentWithFileMatching(null);
643+
644+
// Assert
645+
act.Should().Throw<ArgumentException>().WithMessage("*wildcard*provided*");
646+
}
647+
648+
[Fact]
649+
public void Empty_wildcards_throws_exception()
650+
{
651+
// Arrange
652+
var path = testFolder / "SomeDir";
653+
654+
// Act
655+
Action act = () => path.FindParentWithFileMatching();
656+
657+
// Assert
658+
act.Should().Throw<ArgumentException>().WithMessage("*wildcard*provided*");
659+
}
660+
661+
[Fact]
662+
public void Null_or_empty_wildcard_throws_exception()
663+
{
664+
// Arrange
665+
var path = testFolder / "SomeDir";
666+
667+
// Act
668+
Action act = () => path.FindParentWithFileMatching("*.sln", "", "*.slnx");
669+
670+
// Assert
671+
act.Should().Throw<ArgumentException>().WithMessage("*cannot be null or empty*");
672+
}
673+
674+
[Fact]
675+
public void Question_mark_wildcard_matching_works()
676+
{
677+
// Arrange
678+
var testRoot = testFolder / "FindParentQuestionTest";
679+
var parentDir = testRoot / "Parent";
680+
var childDir = parentDir / "Child";
681+
682+
childDir.CreateDirectoryRecursively();
683+
File.WriteAllText(parentDir / "test1.txt", "test file");
684+
File.WriteAllText(childDir / "program.cs", "some code");
685+
686+
// Act
687+
var result = childDir.FindParentWithFileMatching("test?.txt");
688+
689+
// Assert
690+
result.Should().Be(parentDir);
691+
}
692+
516693
[Fact]
517694
public void Can_find_the_first_existing_file_using_a_string_path()
518695
{
@@ -649,22 +826,4 @@ public void Cannot_find_the_first_existing_path_from_an_empty_array()
649826
.WithMessage("*At least one path must be provided*")
650827
.WithParameterName("paths");
651828
}
652-
653-
[Theory]
654-
[InlineData("config.json", ".subdirectory/config.json")]
655-
[InlineData("app.config", "config/app.config", ".config/app.config")]
656-
public void FromFirstExisting_usage_examples_work_as_expected(params string[] paths)
657-
{
658-
// Arrange - create the second path in each case
659-
var testPath = testFolder / paths[1];
660-
testPath.Directory.CreateDirectoryRecursively();
661-
File.WriteAllText(testPath, "configuration content");
662-
663-
// Act
664-
var result = ChainablePath.FindFirst(paths.Select(p => testFolder / p).Select(p => p.ToString()).ToArray());
665-
666-
// Assert
667-
result.ToString().Should().Be(testPath.ToString());
668-
result.FileExists.Should().BeTrue();
669-
}
670829
}

Pathy/ChainablePath.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,51 @@ public object ToAbsolute(ChainablePath parentPath)
429429

430430
return From(Path.Combine(parentPath.ToString(), this.ToString()));
431431
}
432+
433+
/// <summary>
434+
/// Finds the first parent directory in the hierarchy that contains a file matching any of the provided wildcard patterns.
435+
/// </summary>
436+
/// <param name="wildcards">One or more wildcard patterns to match against files in parent directories (e.g., "*.sln", "*.slnx").</param>
437+
/// <returns>
438+
/// A <see cref="ChainablePath"/> representing the first parent directory that contains a matching file,
439+
/// or <see cref="Empty"/> if no parent directory contains a matching file.
440+
/// </returns>
441+
/// <exception cref="ArgumentException">Thrown if no wildcards are provided or if any wildcard is null or empty.</exception>
442+
public ChainablePath FindParentWithFileMatching(params string[] wildcards)
443+
{
444+
if (wildcards == null || wildcards.Length == 0)
445+
{
446+
throw new ArgumentException("At least one wildcard pattern must be provided", nameof(wildcards));
447+
}
448+
449+
foreach (string wildcard in wildcards)
450+
{
451+
if (string.IsNullOrWhiteSpace(wildcard))
452+
{
453+
throw new ArgumentException("Wildcard patterns cannot be null or empty", nameof(wildcards));
454+
}
455+
}
456+
457+
// Start from the directory containing this path, or the path itself if it's already a directory
458+
ChainablePath currentDirectory = IsFile ? Directory : this;
459+
460+
while (currentDirectory != Root && currentDirectory != Empty && currentDirectory.DirectoryExists)
461+
{
462+
foreach (string wildcard in wildcards)
463+
{
464+
// Check if any files in the current directory match the wildcards
465+
if (System.IO.Directory.GetFiles(currentDirectory, wildcard).Any())
466+
{
467+
return currentDirectory;
468+
}
469+
}
470+
471+
// Move to the parent directory
472+
currentDirectory = currentDirectory.Parent;
473+
}
474+
475+
return Empty;
476+
}
432477
}
433478

434479
#if PATHY_PUBLIC

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,11 @@ Given an instance of `ChainablePath`, you can get a lot of useful information:
119119
* Want to know the delta between two paths? Use `AsRelativeTo`.
120120
* To determine if a file has a case-insensitive extension, use `HasExtension(".txt")` or `HasExtension("txt")`.
121121

122+
And if the built-in functionality really isn't enough, you can always call `ToDirectoryInfo` or `ToFileInfo` to continue with an instance of `DirectoryInfo` and `FileInfo`.
123+
122124
Other features
123125
* Build an absolute path from a relative path using `ToAbsolute` to use the current directory as the base or `ToAbsolute(parentPath)` to use something else as the base.
124-
125-
And if the built-in functionality really isn't enough, you can always call `ToDirectoryInfo` or `ToFileInfo` to continue with an instance of `DirectoryInfo` and `FileInfo`.
126+
* Finding the closest parent directory containing a file matching one or more wildcards. For example, given you have a `ChainablePath` pointing to a `.csproj` file, you can then use `FindParentWithFileMatching("*.sln", "*.slnx")` to find the directory containing the `.sln` or `.slnx` file.
126127

127128
### Globbing
128129

0 commit comments

Comments
 (0)