Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions src/ModularPipelines/Context/Downloader.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using ModularPipelines.Helpers;
using ModularPipelines.Http;
using ModularPipelines.Logging;
using ModularPipelines.Options;
Expand Down Expand Up @@ -66,13 +67,44 @@ public async Task<HttpResponseMessage> DownloadResponseAsync(DownloadOptions opt
return response.EnsureSuccessStatusCode();
}

/// <summary>
/// Determines the file path where the downloaded content should be saved.
/// </summary>
/// <remarks>
/// <para>
/// The save location is determined using the following logic:
/// </para>
/// <list type="number">
/// <item><description>If SavePath is null/empty, generates a temp file path with a GUID name and extension from the download URI.</description></item>
/// <item><description>If SavePath ends with a directory separator (e.g., "/" or "\"), treats it as a directory and generates a filename.</description></item>
/// <item><description>If SavePath has a file extension, treats it as a complete file path.</description></item>
/// <item><description>Otherwise, treats SavePath as a directory and generates a filename within it.</description></item>
/// </list>
/// <para>
/// <strong>Note:</strong> 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.
/// </para>
/// </remarks>
/// <param name="options">The download options containing the save path.</param>
/// <returns>The determined file path for saving the download.</returns>
private string GetSaveLocation(DownloadFileOptions options)
{
if (string.IsNullOrWhiteSpace(options.SavePath))
{
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);
Expand All @@ -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))
{
Expand All @@ -92,4 +124,4 @@ private string GetExtension(string downloadUri)

return string.Empty;
}
}
}
55 changes: 54 additions & 1 deletion src/ModularPipelines/Helpers/PathHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@ namespace ModularPipelines.Helpers;

internal static class PathHelpers
{
/// <summary>
/// Determines whether a path represents a file or directory.
/// </summary>
/// <remarks>
/// <para>
/// The detection logic follows this order of precedence:
/// </para>
/// <list type="number">
/// <item><description>If the path exists as a file on disk, returns <see cref="PathType.File"/>.</description></item>
/// <item><description>If the path exists as a directory on disk, returns <see cref="PathType.Directory"/>.</description></item>
/// <item><description>If the path ends with a directory separator character (e.g., "/" or "\"), returns <see cref="PathType.Directory"/>.</description></item>
/// <item><description>If the path has a file extension (e.g., ".txt", ".cs"), returns <see cref="PathType.File"/>.</description></item>
/// <item><description>Otherwise, defaults to <see cref="PathType.Directory"/>.</description></item>
/// </list>
/// <para>
/// <strong>Limitations:</strong> 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.
/// </para>
/// </remarks>
/// <param name="path">The path to analyze.</param>
/// <returns>The determined <see cref="PathType"/>.</returns>
public static PathType GetPathType(this string path)
{
if (File.Exists(path))
Expand All @@ -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;
Expand All @@ -22,8 +54,29 @@ public static PathType GetPathType(this string path)
return PathType.Directory;
}

/// <summary>
/// Gets the directory portion of a path.
/// </summary>
/// <param name="path">The path to extract the directory from.</param>
/// <returns>The directory path, or <c>null</c> if it cannot be determined.</returns>
public static string? GetDirectory(this string path)
{
return Path.GetDirectoryName(path) ?? new FileInfo(path).Directory?.FullName;
}
}

/// <summary>
/// Determines whether the path ends with a directory separator character.
/// </summary>
/// <param name="path">The path to check.</param>
/// <returns><c>true</c> if the path ends with a directory separator; otherwise, <c>false</c>.</returns>
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;
}
}
48 changes: 48 additions & 0 deletions test/ModularPipelines.UnitTests/PathHelpersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Loading