diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9cf54b420..d900463f8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,7 +7,7 @@ Dev Proxy is a cross-platform .NET API simulator and proxy for testing how appli ## Critical (Non-Obvious) - **Proxy engine:** Built on [ASP.NET Core Kestrel](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel) (a custom forward-proxy + MITM engine; see `DevProxy.Proxy.Kestrel`) -- **Config tokens:** Paths in config files can use `~appFolder` for resolution +- **Config tokens:** Paths in config files can use `~appFolder` and `~dataFolder` tokens, and a leading `~/` (or `~\` on Windows) expands to the home directory - **MCP Server:** Use [@devproxy/mcp](https://www.npmjs.com/package/@devproxy/mcp) to programmatically retrieve up-to-date docs and JSON schemas ## Best Practices diff --git a/DevProxy.Abstractions.Tests/Utils/ProxyUtilsTests.cs b/DevProxy.Abstractions.Tests/Utils/ProxyUtilsTests.cs index 45ff60605..8bba16495 100644 --- a/DevProxy.Abstractions.Tests/Utils/ProxyUtilsTests.cs +++ b/DevProxy.Abstractions.Tests/Utils/ProxyUtilsTests.cs @@ -35,4 +35,45 @@ public void NormalizeWebSocketScheme_DoesNotRewriteSchemeInPath() => [Fact] public void NormalizeWebSocketScheme_NullThrows() => Assert.Throws(() => ProxyUtils.NormalizeWebSocketScheme(null!)); + + [Fact] + public void ReplacePathTokens_ExpandsLeadingTildeSlashToHomeDirectory() + { + var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile); + Assert.Equal(System.IO.Path.Join(home, "mocks/mocks.json"), ProxyUtils.ReplacePathTokens("~/mocks/mocks.json")); + } + + [Fact] + public void ReplacePathTokens_ExpandsTildeSlashOnlyToHomeDirectory() + { + var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile); + Assert.Equal(home, ProxyUtils.ReplacePathTokens("~/")); + } + + [Fact] + public void ReplacePathTokens_ExpandsLeadingTildeBackslashOnWindowsOnly() + { + var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile); + var expected = System.OperatingSystem.IsWindows() + ? System.IO.Path.Join(home, @"mocks\mocks.json") + : @"~\mocks\mocks.json"; + Assert.Equal(expected, ProxyUtils.ReplacePathTokens(@"~\mocks\mocks.json")); + } + + [Theory] + [InlineData("~mocks/mocks.json")] + [InlineData("mocks/~/mocks.json")] + [InlineData("~")] + public void ReplacePathTokens_DoesNotExpandNonLeadingOrBareTilde(string input) => + Assert.Equal(input, ProxyUtils.ReplacePathTokens(input)); + + [Fact] + public void GetFullPath_ResolvesTildeSlashToHomeDirectoryInsteadOfConfigFolder() + { + var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile); + var configFile = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "devproxyrc.json"); + Assert.Equal( + System.IO.Path.GetFullPath(System.IO.Path.Combine(home, "mocks", "mocks.json")), + ProxyUtils.GetFullPath("~/mocks/mocks.json", configFile)); + } } diff --git a/DevProxy.Abstractions/Utils/ProxyUtils.cs b/DevProxy.Abstractions/Utils/ProxyUtils.cs index ccd33c8c4..a4b1ed1bd 100644 --- a/DevProxy.Abstractions/Utils/ProxyUtils.cs +++ b/DevProxy.Abstractions/Utils/ProxyUtils.cs @@ -205,10 +205,28 @@ public static string ReplacePathTokens(string? path) return path ?? string.Empty; } + path = ExpandHomeDirectory(path); path = path.Replace("~dataFolder", DataFolder, StringComparison.OrdinalIgnoreCase); return path.Replace("~appFolder", AppFolder, StringComparison.OrdinalIgnoreCase); } + private static string ExpandHomeDirectory(string path) + { + if (path.Length < 2 || path[0] != '~' || + (path[1] != '/' && !(OperatingSystem.IsWindows() && path[1] == '\\'))) + { + return path; + } + + var homeFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(homeFolder)) + { + return path; + } + + return Path.Join(homeFolder, path[2..]); + } + public static string GetFullPath(string? path, string? basePath) => Path.GetFullPath(ReplacePathTokens(path), Path.GetDirectoryName(basePath) ?? string.Empty); diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index 7d4827486..dfc9b7637 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -36,7 +36,7 @@ sealed class DevProxyCommand : RootCommand internal static readonly Option ConfigFileOption = new(ConfigFileOptionName, "-c") { HelpName = "config-file", - Description = "Path to config file. If not specified, Dev Proxy searches for devproxyrc.jsonc or devproxyrc.json in the current directory, then in a .devproxy/ directory, then under the ~appFolder location. Supports ~appFolder and ~dataFolder tokens." + Description = "Path to config file. If not specified, Dev Proxy searches for devproxyrc.jsonc or devproxyrc.json in the current directory, then in a .devproxy/ directory, then under the ~appFolder location. Supports ~appFolder and ~dataFolder tokens, and a leading ~/ for the home directory." }; internal const string NoFirstRunOptionName = "--no-first-run"; internal const string NoWatchOptionName = "--no-watch"; diff --git a/skills/dev-proxy/references/configuration.md b/skills/dev-proxy/references/configuration.md index 02a2ef2b8..4eea14fd6 100644 --- a/skills/dev-proxy/references/configuration.md +++ b/skills/dev-proxy/references/configuration.md @@ -43,7 +43,7 @@ A configuration file follows a specific property order: `$schema`, then `plugins - Include `$schema` in both the root config and each plugin config section for validation. - The `pluginPath` is always `~appFolder/plugins/DevProxy.Plugins.dll` — `~appFolder` resolves to the Dev Proxy installation directory. - The `configSection` value can be any string. Use different, descriptive names for multiple instances of the same plugin. -- File paths in Dev Proxy configuration files are always relative to the file where they are defined. +- File paths in Dev Proxy configuration files are relative to the file where they are defined, unless they start with `~/` (or `~\` on Windows), which expands to the user's home directory. - Configuration hot-reloads on file save (v2.1.0+) — no restart needed. Works for both the main config and plugin-specific files. ## Plugin Architecture