diff --git a/SabreTools.IO.Extensions.Test/IOExtensionsTests.cs b/SabreTools.IO.Extensions.Test/IOExtensionsTests.cs
index 44197d5..d2c4314 100644
--- a/SabreTools.IO.Extensions.Test/IOExtensionsTests.cs
+++ b/SabreTools.IO.Extensions.Test/IOExtensionsTests.cs
@@ -355,6 +355,106 @@ namespace SabreTools.IO.Extensions.Test
#endregion
+ #region ResolvePath
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void ResolvePath_NullOrEmpty_ReturnsNull(string? path)
+ {
+ string? actual = path.ResolvePath();
+ Assert.Null(actual);
+ }
+
+ // TODO: Add tests for home path without modifying the runner's home path
+
+ [Fact]
+ public void ResolvePath_AbsolutePath_Exists_ReturnsAsIs()
+ {
+ string path = typeof(IOExtensionsTests).Assembly.Location;
+
+ string? actual = path.ResolvePath();
+ Assert.Equal(path, actual);
+ }
+
+ [Fact]
+ public void ResolvePath_AbsolutePath_Missing_ReturnsNull()
+ {
+ string path = Path.Combine(Path.GetTempPath(), "fake-path.bin");
+
+ string? actual = path.ResolvePath();
+ Assert.Null(actual);
+ }
+
+ // TODO: Add ResolvePath_RelativePath_Exists_ReturnsAsIs
+
+ [Fact]
+ public void ResolvePath_RelativePath_Missing_ReturnsNull()
+ {
+ string path = Path.Combine("INVALID", "fake-path.bin");
+
+ string? actual = path.ResolvePath();
+ Assert.Null(actual);
+ }
+
+ [Fact]
+ public void ResolvePath_BareName_RuntimeDirectory_ReturnsFullPath()
+ {
+ // Create a file in the test assembly's runtime directory
+ string filename = "fake-path.bin";
+ string path = Path.Combine(AppContext.BaseDirectory, filename);
+
+ File.WriteAllBytes(path, []);
+ try
+ {
+ string? actual = filename.ResolvePath();
+ Assert.Equal(path, actual);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void ResolvePath_BareName_Path_ReturnsFullPath()
+ {
+ // Create a temporary directory for testing
+ string tempDir = Path.Combine(Path.GetTempPath(), "fake-path");
+ Directory.CreateDirectory(tempDir);
+
+ // Create a file in the temporary directory
+ string filename = "fake-path.bin";
+ string path = Path.Combine(tempDir, filename);
+ File.WriteAllBytes(path, []);
+
+ // Prepend the temp directory to PATH and check it exists
+ // TODO: See if there's a way around changing the actual environment variables
+ string? originalPath = Environment.GetEnvironmentVariable("PATH");
+ try
+ {
+ Environment.SetEnvironmentVariable("PATH", tempDir + Path.PathSeparator + (originalPath ?? string.Empty));
+ string? actual = filename.ResolvePath();
+ Assert.Equal(path, actual);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("PATH", originalPath);
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void ResolvePath_BareName_Missing_ReturnsNull()
+ {
+ string filename = "fake-path.bin";
+
+ string? actual = filename.ResolvePath();
+ Assert.Null(actual);
+ }
+
+ #endregion
+
#region SafeGetDirectories
[Fact]
diff --git a/SabreTools.IO.Extensions/IOExtensions.cs b/SabreTools.IO.Extensions/IOExtensions.cs
index 5a0944c..f918346 100644
--- a/SabreTools.IO.Extensions/IOExtensions.cs
+++ b/SabreTools.IO.Extensions/IOExtensions.cs
@@ -1,4 +1,5 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
@@ -417,6 +418,67 @@ namespace SabreTools.IO.Extensions
return path ?? string.Empty;
}
+ ///
+ /// Resolve a file path that may be relative or contained within a PATH directory.
+ ///
+ /// Raw value from the user's options
+ /// The absolute path of the located files, or null on failure
+ ///
+ /// A value containing a path separator is treated as an explicit location and
+ /// returned as-is when it exists. A bare name (no separator) is searched in the
+ /// runtime directory first, then in each PATH entry.
+ ///
+ public static string? ResolvePath(this string? path)
+ {
+ // Invalid paths always return null
+ if (string.IsNullOrEmpty(path))
+ return null;
+
+ // If the configured path starts with a home character
+ if (path!.StartsWith("~/") || path.StartsWith("~\\"))
+ {
+ string homeDirectory = PathTool.GetHomeDirectory();
+
+ path = path.Substring(2);
+ path = Path.Combine(homeDirectory, path);
+
+ return File.Exists(path) ? path : null;
+ }
+
+ // Explicit location (absolute or relative path)
+ if (path.Contains("/") || path.Contains("\\"))
+ return File.Exists(path) ? path : null;
+
+ // Check the runtime directory if no directory path is provided
+ string runtimeDir = PathTool.GetRuntimeDirectory();
+ if (!string.IsNullOrEmpty(runtimeDir))
+ {
+ string candidate = Path.Combine(runtimeDir, path);
+ if (File.Exists(candidate))
+ return candidate;
+ }
+
+ // Attempt to get the PATH variable for searching
+ string? pathEnv = Environment.GetEnvironmentVariable("PATH");
+ if (string.IsNullOrEmpty(pathEnv))
+ return null;
+
+ // Loop through all entries in PATH
+ var pathParts = pathEnv!.Split(Path.PathSeparator);
+ foreach (string dir in pathParts)
+ {
+ if (string.IsNullOrEmpty(dir))
+ continue;
+
+ string candidate = Path.Combine(dir, path);
+ if (File.Exists(candidate))
+ return candidate;
+ }
+
+ // All options failed
+ return null;
+ }
+
#endregion
#region Safe Directory Enumeration