Use FullPath for Local Path Manipulation
Use this skill when reviewing or writing .NET code that manipulates local file or directory paths.
Why
Raw string paths are a common source of bugs:
- A path can be absolute or relative (
c:\a\bvs.\a\b) - A path that looks absolute can actually be relative (
c:ais relative) - Paths may contain
.or..segments that aren't resolved - Trailing directory separators cause inconsistent comparisons (
c:\avsc:\a\) - Path comparison must be case-insensitive on Windows and case-sensitive on Linux
- Reserved device names on Windows (
CON,PRN,NUL) can cause subtle failures - When a
FileNotFoundExceptionoccurs, relative paths make debugging difficult - A
stringparameter gives no signal whether a full path or a relative path is expected
The FullPath struct from Meziantou.Framework.FullPath solves these problems by guaranteeing the stored value is always a fully-resolved absolute path with normalized separators.
Installation
# .NET 10+
dotnet package add Meziantou.Framework.FullPath
# .NET 9 and earlier
dotnet add package Meziantou.Framework.FullPath
The namespace is Meziantou.Framework.
Core Patterns
Creating a FullPath
// From a string (relative paths are resolved against the current directory)
FullPath path = FullPath.FromPath("demo");
// Well-known locations
FullPath temp = FullPath.GetTempPath();
FullPath cwd = FullPath.CurrentDirectory();
FullPath docs = FullPath.GetFolderPath(Environment.SpecialFolder.MyDocuments);
Combining Paths
Use the / operator or FullPath.Combine — never string.Concat or string interpolation.
FullPath root = FullPath.FromPath("/repo");
// / operator (preferred for readability)
FullPath file = root / "src" / "Program.cs";
// Combine (useful when the number of segments is dynamic)
FullPath file2 = FullPath.Combine(root, "src", "Program.cs");
Comparing Paths
Comparisons are case-insensitive on Windows/macOS and case-sensitive on Linux by default. Navigation segments and trailing separators are already resolved, so equality just works.
// == and != use the OS-default comparison
if (pathA == pathB) { /* same file */ }
// Explicit case-sensitivity control
bool equal = pathA.Equals(pathB, ignoreCase: false);
Navigating the Path Tree
FullPath file = FullPath.FromPath("/repo/src/Program.cs");
FullPath dir = file.Parent; // /repo/src
string name = file.Name; // Program.cs
string nameNoExt = file.NameWithoutExtension; // Program
string ext = file.Extension; // .cs
FullPath renamed = file.ChangeExtension(".vb"); // /repo/src/Program.vb
Checking Hierarchy
FullPath root = FullPath.FromPath("/repo");
FullPath file = root / "src" / "Program.cs";
bool isChild = file.IsChildOf(root); // true
Making a Relative Path
FullPath root = FullPath.FromPath("/repo");
FullPath file = root / "src" / "Program.cs";
string relative = file.MakePathRelativeTo(root); // src/Program.cs (or src\Program.cs on Windows)
Walking Up the Tree
FullPath start = FullPath.FromPath("/repo/src/deep/nested");
// Find the first ancestor (or self) matching a predicate
if (start.TryFindFirstAncestorOrSelf(
p => File.Exists(p / ".editorconfig"), out FullPath match))
{
// match is the closest directory containing .editorconfig
}
// Shortcut: find the Git repository root
if (start.TryFindGitRepositoryRoot(out FullPath gitRoot))
{
// gitRoot is the closest directory containing .git
}
Interop with System.IO
FullPath has an implicit conversion to string, so it works directly with File, Directory, and most APIs that accept a string path.
FullPath config = root / "appsettings.json";
// No .Value or .ToString() needed
string json = File.ReadAllText(config);
File.WriteAllText(config, json);
Directory.CreateDirectory(config.Parent);
When you need the raw string explicitly (e.g., for logging or interpolation):
logger.LogInformation("Loading config from {Path}", config.Value);
Ensuring the Parent Directory Exists
FullPath output = root / "artifacts" / "report.html";
output.CreateParentDirectory(); // creates "artifacts" if it doesn't exist
File.WriteAllText(output, html);
JSON Serialization
FullPath has a built-in System.Text.Json converter. It serializes to a plain JSON string and deserializes back through FullPath.FromPath.
public sealed class AppSettings
{
public FullPath OutputDirectory { get; set; }
}
// { "OutputDirectory": "C:\\repo\\output" }
var settings = JsonSerializer.Deserialize<AppSettings>(json);
Using FullPath in Method Signatures
Prefer FullPath over string in APIs that expect an absolute path. This makes intent explicit at the type level.
// ❌ Ambiguous — is this a full path or a relative fragment?
void Export(string outputPath) { }
// ✅ Clear contract — the caller must provide a resolved path
void Export(FullPath outputPath) { }
Collections and Dictionaries
Use FullPathComparer when storing paths in sets or dictionaries to get correct OS-aware equality.
var seen = new HashSet<FullPath>(); // uses default OS comparison
var map = new Dictionary<FullPath, int>();
// Explicit comparer if needed
var caseSensitiveSet = new HashSet<FullPath>(FullPathComparer.CaseSensitive);
What to Look For in Reviews
| Smell | Refactor to |
|---|---|
string filePath = Path.Combine(root, "sub", "file.txt"); |
FullPath filePath = root / "sub" / "file.txt"; |
string fullPath = Path.GetFullPath(relative); |
FullPath fullPath = FullPath.FromPath(relative); |
if (path1.Equals(path2, StringComparison.OrdinalIgnoreCase)) |
if (path1 == path2) (with FullPath) |
path.StartsWith(root) to check containment |
path.IsChildOf(root) |
Path.GetDirectoryName(path) |
path.Parent |
Path.GetFileName(path) |
path.Name |
Path.GetExtension(path) |
path.Extension |
Path.ChangeExtension(path, ext) |
path.ChangeExtension(ext) |
Directory.CreateDirectory(Path.GetDirectoryName(path)) |
path.CreateParentDirectory() |
void Foo(string path) for an absolute-path parameter |
void Foo(FullPath path) |
Scope
This skill targets local file-system paths only. It does not apply to:
- URLs or URIs
- Cloud/blob storage paths
- Database connection strings
- Paths that must remain relative by design (e.g., entries inside a ZIP archive)