diff --git a/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs b/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs
index 251469eb..e5cb256e 100644
--- a/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs
+++ b/DevProxy.Abstractions/Proxy/IProxyConfiguration.cs
@@ -45,6 +45,11 @@ public interface IProxyConfiguration
bool NoWatch { get; set; }
int Port { get; set; }
bool Record { get; set; }
+ ///
+ /// Absolute path of the folder where reporters write report files.
+ /// When null or empty, reports are written to the current working directory.
+ ///
+ string? ReportsFolder { get; set; }
bool ShowTimestamps { get; }
long? TimeoutSeconds { get; set; }
bool ValidateSchemas { get; }
diff --git a/DevProxy.Integration.Tests/JsonReporterTests.cs b/DevProxy.Integration.Tests/JsonReporterTests.cs
index 0fd6cb47..b3314b5d 100644
--- a/DevProxy.Integration.Tests/JsonReporterTests.cs
+++ b/DevProxy.Integration.Tests/JsonReporterTests.cs
@@ -12,7 +12,7 @@ namespace DevProxy.Integration.Tests;
public sealed class JsonReporterTests
{
- private sealed class TestJsonReporter() : JsonReporter(NullLogger.Instance, new HashSet())
+ private sealed class TestJsonReporter() : JsonReporter(NullLogger.Instance, new HashSet(), new TestProxyConfiguration())
{
public string? Report(string key, object value) => GetReport(new(key, value));
}
diff --git a/DevProxy.Integration.Tests/ReporterReportsFolderTests.cs b/DevProxy.Integration.Tests/ReporterReportsFolderTests.cs
new file mode 100644
index 00000000..d16194fd
--- /dev/null
+++ b/DevProxy.Integration.Tests/ReporterReportsFolderTests.cs
@@ -0,0 +1,100 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using DevProxy.Abstractions.Proxy;
+using DevProxy.Abstractions.Utils;
+using DevProxy.Plugins.Reporters;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Verifies that reporters honour
+/// and fall back to the current working directory when it isn't set.
+///
+public sealed class ReporterReportsFolderTests
+{
+ private static RecordingArgs Recording() =>
+ new([])
+ {
+ GlobalData = new()
+ {
+ [ProxyUtils.ReportsKey] = new Dictionary
+ {
+ ["TestPlugin"] = new { Value = 1 }
+ }
+ },
+ };
+
+ private static JsonReporter CreateReporter(string? reportsFolder) =>
+ new(NullLogger.Instance, new HashSet(), new TestProxyConfiguration { ReportsFolder = reportsFolder });
+
+ [Fact]
+ public async Task WritesReportToReportsFolder_CreatingItWhenMissing()
+ {
+ var root = Directory.CreateTempSubdirectory("devproxy-reports-");
+ var cwd = Directory.CreateTempSubdirectory("devproxy-cwd-");
+ var originalCwd = Directory.GetCurrentDirectory();
+ Directory.SetCurrentDirectory(cwd.FullName);
+ try
+ {
+ var reportsFolder = Path.Combine(root.FullName, "nested", "reports");
+ var reporter = CreateReporter(reportsFolder);
+
+ await reporter.AfterRecordingStopAsync(Recording(), CancellationToken.None);
+
+ Assert.True(File.Exists(Path.Combine(reportsFolder, "TestPlugin_JsonReporter.json")));
+ Assert.Empty(Directory.GetFiles(cwd.FullName));
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalCwd);
+ root.Delete(true);
+ cwd.Delete(true);
+ }
+ }
+
+ [Fact]
+ public async Task AppendsTimestamp_WhenReportExistsInReportsFolder()
+ {
+ var root = Directory.CreateTempSubdirectory("devproxy-reports-");
+ try
+ {
+ await File.WriteAllTextAsync(Path.Combine(root.FullName, "TestPlugin_JsonReporter.json"), "{}");
+ var reporter = CreateReporter(root.FullName);
+
+ await reporter.AfterRecordingStopAsync(Recording(), CancellationToken.None);
+
+ Assert.Equal(2, Directory.GetFiles(root.FullName, "TestPlugin_JsonReporter*.json").Length);
+ }
+ finally
+ {
+ root.Delete(true);
+ }
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public async Task WritesReportToWorkingDirectory_WhenReportsFolderNotSet(string? reportsFolder)
+ {
+ var cwd = Directory.CreateTempSubdirectory("devproxy-cwd-");
+ var originalCwd = Directory.GetCurrentDirectory();
+ Directory.SetCurrentDirectory(cwd.FullName);
+ try
+ {
+ var reporter = CreateReporter(reportsFolder);
+
+ await reporter.AfterRecordingStopAsync(Recording(), CancellationToken.None);
+
+ Assert.True(File.Exists(Path.Combine(cwd.FullName, "TestPlugin_JsonReporter.json")));
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalCwd);
+ cwd.Delete(true);
+ }
+ }
+}
diff --git a/DevProxy.Integration.Tests/TestProxyConfiguration.cs b/DevProxy.Integration.Tests/TestProxyConfiguration.cs
index 4297df6e..2de0ea3c 100644
--- a/DevProxy.Integration.Tests/TestProxyConfiguration.cs
+++ b/DevProxy.Integration.Tests/TestProxyConfiguration.cs
@@ -30,6 +30,7 @@ internal sealed class TestProxyConfiguration : IProxyConfiguration, IProcessTree
public bool NoWatch { get; set; }
public int Port { get; set; }
public bool Record { get; set; }
+ public string? ReportsFolder { get; set; }
public bool ShowTimestamps => false;
public long? TimeoutSeconds { get; set; }
public bool ValidateSchemas => false;
diff --git a/DevProxy.Plugins/Reporters/BaseReporter.cs b/DevProxy.Plugins/Reporters/BaseReporter.cs
index 84fc5866..d5138a58 100644
--- a/DevProxy.Plugins/Reporters/BaseReporter.cs
+++ b/DevProxy.Plugins/Reporters/BaseReporter.cs
@@ -9,10 +9,22 @@
namespace DevProxy.Plugins.Reporters;
-public abstract class BaseReporter(
- ILogger logger,
- ISet urlsToWatch) : BasePlugin(logger, urlsToWatch)
+public abstract class BaseReporter : BasePlugin
{
+ private readonly IProxyConfiguration? _proxyConfiguration;
+
+ protected BaseReporter(ILogger logger, ISet urlsToWatch) : base(logger, urlsToWatch)
+ {
+ }
+
+ protected BaseReporter(
+ ILogger logger,
+ ISet urlsToWatch,
+ IProxyConfiguration proxyConfiguration) : base(logger, urlsToWatch)
+ {
+ _proxyConfiguration = proxyConfiguration;
+ }
+
public abstract string FileExtension { get; }
public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken)
@@ -45,16 +57,20 @@ value is not Dictionary reports ||
continue;
}
- var fileName = $"{report.Key}_{Name}{FileExtension}";
+ var fileName = GetReportFilePath($"{report.Key}_{Name}{FileExtension}");
Logger.LogDebug("File name for report {Report}: {FileName}", report.Key, fileName);
if (File.Exists(fileName))
{
Logger.LogDebug("File {FileName} already exists, appending timestamp", fileName);
- fileName = $"{report.Key}_{Name}_{DateTime.Now:yyyyMMddHHmmss}{FileExtension}";
+ fileName = GetReportFilePath($"{report.Key}_{Name}_{DateTime.Now:yyyyMMddHHmmss}{FileExtension}");
}
Logger.LogInformation("Writing report {ReportKey} to {FileName}...", report.Key, fileName);
+ if (!string.IsNullOrEmpty(ReportsFolder))
+ {
+ _ = Directory.CreateDirectory(ReportsFolder);
+ }
await File.WriteAllTextAsync(fileName, reportContents, cancellationToken);
}
@@ -62,4 +78,13 @@ value is not Dictionary reports ||
}
protected abstract string? GetReport(KeyValuePair report);
+
+ ///
+ /// Folder where reports are written. When null or empty, reports are written
+ /// to the current working directory.
+ ///
+ protected string? ReportsFolder => _proxyConfiguration?.ReportsFolder;
+
+ protected string GetReportFilePath(string fileName) =>
+ string.IsNullOrEmpty(ReportsFolder) ? fileName : Path.Combine(ReportsFolder, fileName);
}
\ No newline at end of file
diff --git a/DevProxy.Plugins/Reporters/JsonReporter.cs b/DevProxy.Plugins/Reporters/JsonReporter.cs
index 05fc0d80..396610db 100644
--- a/DevProxy.Plugins/Reporters/JsonReporter.cs
+++ b/DevProxy.Plugins/Reporters/JsonReporter.cs
@@ -12,7 +12,8 @@ namespace DevProxy.Plugins.Reporters;
public class JsonReporter(
ILogger logger,
- ISet urlsToWatch) : BaseReporter(logger, urlsToWatch)
+ ISet urlsToWatch,
+ IProxyConfiguration proxyConfiguration) : BaseReporter(logger, urlsToWatch, proxyConfiguration)
{
private const string DefaultFileExtension = ".json";
private string _fileExtension = DefaultFileExtension;
diff --git a/DevProxy.Plugins/Reporters/MarkdownReporter.cs b/DevProxy.Plugins/Reporters/MarkdownReporter.cs
index 5e8f130e..7d25b92e 100644
--- a/DevProxy.Plugins/Reporters/MarkdownReporter.cs
+++ b/DevProxy.Plugins/Reporters/MarkdownReporter.cs
@@ -10,7 +10,8 @@ namespace DevProxy.Plugins.Reporters;
public class MarkdownReporter(
ILogger logger,
- ISet urlsToWatch) : BaseReporter(logger, urlsToWatch)
+ ISet urlsToWatch,
+ IProxyConfiguration proxyConfiguration) : BaseReporter(logger, urlsToWatch, proxyConfiguration)
{
public override string Name => nameof(MarkdownReporter);
public override string FileExtension => ".md";
diff --git a/DevProxy.Plugins/Reporters/PlainTextReporter.cs b/DevProxy.Plugins/Reporters/PlainTextReporter.cs
index 0ec0490f..893c85ef 100644
--- a/DevProxy.Plugins/Reporters/PlainTextReporter.cs
+++ b/DevProxy.Plugins/Reporters/PlainTextReporter.cs
@@ -10,7 +10,8 @@ namespace DevProxy.Plugins.Reporters;
public class PlainTextReporter(
ILogger logger,
- ISet urlsToWatch) : BaseReporter(logger, urlsToWatch)
+ ISet urlsToWatch,
+ IProxyConfiguration proxyConfiguration) : BaseReporter(logger, urlsToWatch, proxyConfiguration)
{
public override string Name => nameof(PlainTextReporter);
public override string FileExtension => ".txt";
diff --git a/DevProxy.Tests/Fakes.cs b/DevProxy.Tests/Fakes.cs
index 39447f29..1f1f36ae 100644
--- a/DevProxy.Tests/Fakes.cs
+++ b/DevProxy.Tests/Fakes.cs
@@ -85,6 +85,7 @@ internal sealed class FakeProxyConfiguration : IProxyConfiguration, IProcessTree
public bool NoWatch { get; set; }
public int Port { get; set; } = 8000;
public bool Record { get; set; }
+ public string? ReportsFolder { get; set; }
public bool ShowTimestamps => false;
public long? TimeoutSeconds { get; set; }
public bool ValidateSchemas => false;
diff --git a/DevProxy.Tests/ReportsFolderTests.cs b/DevProxy.Tests/ReportsFolderTests.cs
new file mode 100644
index 00000000..bf9225af
--- /dev/null
+++ b/DevProxy.Tests/ReportsFolderTests.cs
@@ -0,0 +1,93 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using DevProxy.Commands;
+using DevProxy.Proxy;
+using Microsoft.Extensions.Configuration;
+
+using Xunit;
+
+namespace DevProxy.Tests;
+
+public class ReportsFolderTests
+{
+ [Fact]
+ public void ReportsFolder_FromConfigFile_ResolvesRelativeToConfigFile()
+ {
+ var configDir = Directory.CreateTempSubdirectory("devproxy-config-");
+ try
+ {
+ var configPath = Path.Combine(configDir.FullName, "devproxyrc.json");
+ File.WriteAllText(configPath, """{ "reportsFolder": "out/reports" }""");
+ var configuration = new ConfigurationBuilder().AddJsonFile(configPath).Build();
+
+ var proxyConfiguration = new ProxyConfiguration(configuration);
+
+ Assert.Equal(
+ Path.Combine(Path.GetDirectoryName(proxyConfiguration.ConfigFile)!, "out", "reports"),
+ proxyConfiguration.ReportsFolder);
+ }
+ finally
+ {
+ configDir.Delete(true);
+ }
+ }
+
+ [Fact]
+ public void ReportsFolder_FromConfigFile_KeepsAbsolutePath()
+ {
+ var configDir = Directory.CreateTempSubdirectory("devproxy-config-");
+ try
+ {
+ var reportsFolder = Path.Combine(Path.GetTempPath(), "devproxy-absolute-reports");
+ var configPath = Path.Combine(configDir.FullName, "devproxyrc.json");
+ File.WriteAllText(configPath, $$"""{ "reportsFolder": {{System.Text.Json.JsonSerializer.Serialize(reportsFolder)}} }""");
+ var configuration = new ConfigurationBuilder().AddJsonFile(configPath).Build();
+
+ var proxyConfiguration = new ProxyConfiguration(configuration);
+
+ Assert.Equal(Path.GetFullPath(reportsFolder), proxyConfiguration.ReportsFolder);
+ }
+ finally
+ {
+ configDir.Delete(true);
+ }
+ }
+
+ [Fact]
+ public void ReportsFolder_NotConfigured_IsNull()
+ {
+ var configDir = Directory.CreateTempSubdirectory("devproxy-config-");
+ try
+ {
+ var configPath = Path.Combine(configDir.FullName, "devproxyrc.json");
+ File.WriteAllText(configPath, "{}");
+ var configuration = new ConfigurationBuilder().AddJsonFile(configPath).Build();
+
+ var proxyConfiguration = new ProxyConfiguration(configuration);
+
+ Assert.Null(proxyConfiguration.ReportsFolder);
+ }
+ finally
+ {
+ configDir.Delete(true);
+ }
+ }
+
+ [Fact]
+ public void ReportDirOption_ResolvesRelativeToWorkingDirectory()
+ {
+ var expected = Path.Combine(Directory.GetCurrentDirectory(), "reports");
+
+ Assert.Equal(expected, DevProxyCommand.ResolveReportDirOption("reports"));
+ }
+
+ [Fact]
+ public void ReportDirOption_ExpandsHomeDirectory()
+ {
+ var expected = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "reports");
+
+ Assert.Equal(expected, DevProxyCommand.ResolveReportDirOption($"~{Path.DirectorySeparatorChar}reports"));
+ }
+}
diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs
index 6577c0bc..f4094b09 100644
--- a/DevProxy/Commands/DevProxyCommand.cs
+++ b/DevProxy/Commands/DevProxyCommand.cs
@@ -29,6 +29,7 @@ sealed class DevProxyCommand : RootCommand
internal const string IpAddressOptionName = "--ip-address";
internal const string LogLevelOptionName = "--log-level";
internal const string RecordOptionName = "--record";
+ internal const string ReportDirOptionName = "--report-dir";
internal const string WatchPidsOptionName = "--watch-pids";
internal const string WatchProcessNamesOptionName = "--watch-process-names";
internal const string WatchProcessTreeOptionName = "--watch-process-tree";
@@ -227,6 +228,9 @@ public static string DetachedLogFilePath
Environment.GetEnvironmentVariable("TERM"));
// Per https://no-color.org, NO_COLOR disables color only when present and not empty.
+ internal static string ResolveReportDirOption(string value) =>
+ Path.GetFullPath(ProxyUtils.ReplacePathTokens(value));
+
internal static bool ShouldDisableColor(string[] args, string? noColorEnv, string? termEnv) =>
args.Contains(NoColorOptionName) ||
!string.IsNullOrEmpty(noColorEnv) ||
@@ -443,6 +447,31 @@ private void ConfigureCommand(DevProxyConfigOptions configOptions)
Description = "Use this option to record all request logs"
};
+ var reportDirOption = new Option(ReportDirOptionName)
+ {
+ Description = "Folder where reporters write report files. Overrides reportsFolder from the config file. Relative paths resolve against the current working directory. Default: the current working directory",
+ HelpName = "path"
+ };
+ reportDirOption.Validators.Add(input =>
+ {
+ if (input.Tokens.Count == 0)
+ {
+ return;
+ }
+
+ var value = input.Tokens[0].Value;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ input.AddError("The report directory path can't be empty.");
+ return;
+ }
+
+ if (File.Exists(ResolveReportDirOption(value)))
+ {
+ input.AddError($"The report directory path '{value}' points to a file. Specify a folder.");
+ }
+ });
+
var watchPidsOption = new Option>(WatchPidsOptionName)
{
AllowMultipleArgumentsPerToken = true,
@@ -612,6 +641,7 @@ private void ConfigureCommand(DevProxyConfigOptions configOptions)
outputOption,
portOption,
recordOption,
+ reportDirOption,
timeoutOption,
urlsToWatchOption,
watchPidsOption,
@@ -710,6 +740,11 @@ private void ConfigureFromOptions(ParseResult parseResult)
{
_proxyConfiguration.Record = record.Value;
}
+ var reportDir = parseResult.GetValueOrDefault(ReportDirOptionName);
+ if (!string.IsNullOrWhiteSpace(reportDir))
+ {
+ _proxyConfiguration.ReportsFolder = ResolveReportDirOption(reportDir);
+ }
var watchPids = parseResult.GetValueOrDefault>(WatchPidsOptionName);
if (watchPids is not null && watchPids.Any())
{
diff --git a/DevProxy/Proxy/ProxyConfiguration.cs b/DevProxy/Proxy/ProxyConfiguration.cs
index 27b65c73..fbeab55f 100755
--- a/DevProxy/Proxy/ProxyConfiguration.cs
+++ b/DevProxy/Proxy/ProxyConfiguration.cs
@@ -4,6 +4,7 @@
using DevProxy.Abstractions.Models;
using DevProxy.Abstractions.Proxy;
+using DevProxy.Abstractions.Utils;
using System.Text.Json.Serialization;
namespace DevProxy.Proxy;
@@ -44,6 +45,7 @@ public string ConfigFile
public ReleaseType NewVersionNotification { get; set; } = ReleaseType.Stable;
public int Port { get; set; } = 8000;
public bool Record { get; set; }
+ public string? ReportsFolder { get; set; }
public bool ShowSkipMessages { get; set; } = true;
public bool ShowTimestamps { get; set; } = true;
public long? TimeoutSeconds { get; set; }
@@ -57,6 +59,29 @@ public ProxyConfiguration(IConfigurationRoot configurationRoot)
{
_configurationRoot = configurationRoot;
_configurationRoot.Bind(this);
+ ReportsFolder = ResolveReportsFolder(ReportsFolder, _configurationRoot);
+ }
+
+ // reportsFolder from the config file is relative to the config file
+ internal static string? ResolveReportsFolder(string? reportsFolder, IConfigurationRoot configurationRoot)
+ {
+ if (string.IsNullOrWhiteSpace(reportsFolder))
+ {
+ return null;
+ }
+
+ string? configFilePath = null;
+ try
+ {
+ configFilePath = GetConfigFilePath(configurationRoot);
+ }
+ catch (InvalidOperationException)
+ {
+ }
+
+ return configFilePath is null
+ ? Path.GetFullPath(ProxyUtils.ReplacePathTokens(reportsFolder))
+ : ProxyUtils.GetFullPath(reportsFolder, configFilePath);
}
internal static string GetConfigFilePath(IConfigurationRoot configurationRoot)
diff --git a/schemas/v4.0.0/rc.schema.json b/schemas/v4.0.0/rc.schema.json
index 4db88db8..b7f256fb 100644
--- a/schemas/v4.0.0/rc.schema.json
+++ b/schemas/v4.0.0/rc.schema.json
@@ -183,6 +183,10 @@
"type": "boolean",
"description": "Whether to record requests and responses."
},
+ "reportsFolder": {
+ "type": "string",
+ "description": "Folder where reporters write report files. Relative paths resolve against the folder of this configuration file. Default: the current working directory."
+ },
"showSkipMessages": {
"type": "boolean",
"description": "Show messages for skipped requests."