diff --git a/src/ModularPipelines/Context/Downloader.cs b/src/ModularPipelines/Context/Downloader.cs index b6f1f70905..12d799dd4d 100644 --- a/src/ModularPipelines/Context/Downloader.cs +++ b/src/ModularPipelines/Context/Downloader.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using ModularPipelines.Helpers; using ModularPipelines.Http; using ModularPipelines.Logging; using ModularPipelines.Options; @@ -66,6 +67,27 @@ public async Task DownloadResponseAsync(DownloadOptions opt return response.EnsureSuccessStatusCode(); } + /// + /// Determines the file path where the downloaded content should be saved. + /// + /// + /// + /// The save location is determined using the following logic: + /// + /// + /// If SavePath is null/empty, generates a temp file path with a GUID name and extension from the download URI. + /// If SavePath ends with a directory separator (e.g., "/" or "\"), treats it as a directory and generates a filename. + /// If SavePath has a file extension, treats it as a complete file path. + /// Otherwise, treats SavePath as a directory and generates a filename within it. + /// + /// + /// Note: The extension heuristic (step 3) may not be reliable for directory names + /// containing dots (e.g., "my.folder"). To ensure a path is treated as a directory, append a + /// directory separator character to the path. + /// + /// + /// The download options containing the save path. + /// The determined file path for saving the download. private string GetSaveLocation(DownloadFileOptions options) { if (string.IsNullOrWhiteSpace(options.SavePath)) @@ -73,6 +95,16 @@ private string GetSaveLocation(DownloadFileOptions options) return Path.Combine(Path.GetTempPath(), Guid.NewGuid() + GetExtension(options.DownloadUri.AbsoluteUri)); } + // Check if the path explicitly ends with a directory separator + // This is a reliable indicator that the user intends this to be a directory + if (PathHelpers.EndsWithDirectorySeparator(options.SavePath)) + { + Directory.CreateDirectory(options.SavePath); + return Path.Combine(options.SavePath, Guid.NewGuid() + GetExtension(options.DownloadUri.AbsoluteUri)); + } + + // Use extension heuristic as a fallback + // Note: This can be unreliable for directory names containing dots (e.g., "my.folder") if (Path.HasExtension(options.SavePath)) { Directory.CreateDirectory(new FileInfo(options.SavePath).Directory!.FullName); @@ -83,7 +115,7 @@ private string GetSaveLocation(DownloadFileOptions options) return Path.Combine(options.SavePath, Guid.NewGuid() + GetExtension(options.DownloadUri.AbsoluteUri)); } - private string GetExtension(string downloadUri) + private static string GetExtension(string downloadUri) { if (Path.HasExtension(downloadUri)) { @@ -92,4 +124,4 @@ private string GetExtension(string downloadUri) return string.Empty; } -} \ No newline at end of file +} diff --git a/src/ModularPipelines/Helpers/PathHelpers.cs b/src/ModularPipelines/Helpers/PathHelpers.cs index c87c2b7b4b..e96a520814 100644 --- a/src/ModularPipelines/Helpers/PathHelpers.cs +++ b/src/ModularPipelines/Helpers/PathHelpers.cs @@ -2,6 +2,29 @@ namespace ModularPipelines.Helpers; internal static class PathHelpers { + /// + /// Determines whether a path represents a file or directory. + /// + /// + /// + /// The detection logic follows this order of precedence: + /// + /// + /// If the path exists as a file on disk, returns . + /// If the path exists as a directory on disk, returns . + /// If the path ends with a directory separator character (e.g., "/" or "\"), returns . + /// If the path has a file extension (e.g., ".txt", ".cs"), returns . + /// Otherwise, defaults to . + /// + /// + /// Limitations: When the path does not exist on disk, this method uses heuristics + /// that may not always be accurate. For example, a directory named "my.folder" would be incorrectly + /// identified as a file due to the extension heuristic. Use explicit directory separators when + /// working with non-existent directory paths that contain dots. + /// + /// + /// The path to analyze. + /// The determined . public static PathType GetPathType(this string path) { if (File.Exists(path)) @@ -14,6 +37,15 @@ public static PathType GetPathType(this string path) return PathType.Directory; } + // Check if the path explicitly ends with a directory separator + // This is a reliable indicator that the user intends this to be a directory + if (EndsWithDirectorySeparator(path)) + { + return PathType.Directory; + } + + // Use extension heuristic as a fallback + // Note: This can be unreliable for directory names containing dots (e.g., "my.folder") if (Path.HasExtension(path)) { return PathType.File; @@ -22,8 +54,29 @@ public static PathType GetPathType(this string path) return PathType.Directory; } + /// + /// Gets the directory portion of a path. + /// + /// The path to extract the directory from. + /// The directory path, or null if it cannot be determined. public static string? GetDirectory(this string path) { return Path.GetDirectoryName(path) ?? new FileInfo(path).Directory?.FullName; } -} \ No newline at end of file + + /// + /// Determines whether the path ends with a directory separator character. + /// + /// The path to check. + /// true if the path ends with a directory separator; otherwise, false. + internal static bool EndsWithDirectorySeparator(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + var lastChar = path[path.Length - 1]; + return lastChar == Path.DirectorySeparatorChar || lastChar == Path.AltDirectorySeparatorChar; + } +} diff --git a/test/ModularPipelines.UnitTests/PathHelpersTests.cs b/test/ModularPipelines.UnitTests/PathHelpersTests.cs index de47cb31a4..ba36709764 100644 --- a/test/ModularPipelines.UnitTests/PathHelpersTests.cs +++ b/test/ModularPipelines.UnitTests/PathHelpersTests.cs @@ -44,4 +44,52 @@ public async Task Directory_Path_Type2() var path = Path.Combine(TestContext.WorkingDirectory, "Blah", "Foo", "Bar", "Foo"); await Assert.That(path.GetPathType()).IsEqualTo(PathType.Directory); } + + [Test] + public async Task Directory_Path_Type_With_Trailing_Separator() + { + var path = Path.Combine(TestContext.WorkingDirectory, "Blah", "Foo") + Path.DirectorySeparatorChar; + await Assert.That(path.GetPathType()).IsEqualTo(PathType.Directory); + } + + [Test] + public async Task Directory_Path_Type_With_Dots_And_Trailing_Separator() + { + // A directory with dots in the name should be detected as directory when it has a trailing separator + var path = Path.Combine(TestContext.WorkingDirectory, "my.folder") + Path.DirectorySeparatorChar; + await Assert.That(path.GetPathType()).IsEqualTo(PathType.Directory); + } + + [Test] + public async Task EndsWithDirectorySeparator_Returns_True_For_Trailing_Separator() + { + var path = Path.Combine(TestContext.WorkingDirectory, "foo") + Path.DirectorySeparatorChar; + await Assert.That(PathHelpers.EndsWithDirectorySeparator(path)).IsTrue(); + } + + [Test] + public async Task EndsWithDirectorySeparator_Returns_True_For_Alt_Separator() + { + var path = Path.Combine(TestContext.WorkingDirectory, "foo") + Path.AltDirectorySeparatorChar; + await Assert.That(PathHelpers.EndsWithDirectorySeparator(path)).IsTrue(); + } + + [Test] + public async Task EndsWithDirectorySeparator_Returns_False_For_No_Separator() + { + var path = Path.Combine(TestContext.WorkingDirectory, "foo"); + await Assert.That(PathHelpers.EndsWithDirectorySeparator(path)).IsFalse(); + } + + [Test] + public async Task EndsWithDirectorySeparator_Returns_False_For_Empty_String() + { + await Assert.That(PathHelpers.EndsWithDirectorySeparator(string.Empty)).IsFalse(); + } + + [Test] + public async Task EndsWithDirectorySeparator_Returns_False_For_Null() + { + await Assert.That(PathHelpers.EndsWithDirectorySeparator(null!)).IsFalse(); + } } \ No newline at end of file