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
68 changes: 68 additions & 0 deletions FileDeduplicator.Test/QuotedPathPromptTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.FileDeduplicator.Test;

using ktsu.FileDeduplicator.Verbs;

using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Tests that a path pasted at the prompt in quotes is scanned rather than resolved, quotes and all,
/// under the current directory (ktsu-dev/FileDeduplicator#152).
/// </summary>
[TestClass]
[DoNotParallelize]
public sealed class QuotedPathPromptTests
{
[TestMethod]
[DataRow("\"{0}\"")]
[DataRow("'{0}'")]
[DataRow(" \"{0}\" ")]
public void ScanStripsOnePairOfQuotesFromAPastedPath(string format)
{
using TempTree tree = new();
_ = tree.Write("q dir/a.txt", "alpha");
_ = tree.Write("q dir/aa.txt", "alpha");
string directory = Path.Combine(tree.Root.WeakString, "q dir");

string output = ConsoleCapture.Run(new Scan(), string.Format(System.Globalization.CultureInfo.InvariantCulture, format, directory) + "\n", out int exitCode);

Assert.Contains($"Scanning: {directory}", output);
Assert.DoesNotContain("Directory not found", output);
Assert.Contains("Found 1 group(s) of duplicate files", output);
Assert.AreEqual(0, exitCode);
}

[TestMethod]
public void StatsDryRunAndDeduplicateAcceptAQuotedPathToo()
{
using TempTree tree = new();
_ = tree.Write("q dir/a.txt", "alpha");
string directory = Path.Combine(tree.Root.WeakString, "q dir");
string answer = $"\"{directory}\"\n";

Assert.Contains($"Analyzing: {directory}", ConsoleCapture.Run(new Stats(), answer));
Assert.Contains($"Dry run for: {directory}", ConsoleCapture.Run(new DryRun(), answer));
Assert.Contains($"Deduplicating: {directory}", ConsoleCapture.Run(new Deduplicate(), answer));
}

[TestMethod]
[DataRow("\"/some dir\"", "/some dir")]
[DataRow("'/some dir'", "/some dir")]
[DataRow("\"C:\\some dir\"", "C:\\some dir")]
[DataRow("\"/some dir'", "\"/some dir'")]
[DataRow("\"", "\"")]
[DataRow("/plain", "/plain")]
[DataRow("\"\"a\"\"", "\"a\"")]
public void UnquoteRemovesOnlyOneMatchingPair(string input, string expected) =>
Assert.AreEqual(expected, BaseVerb.Unquote(input));

[TestMethod]
public void AQuotedEmptyAnswerAbortsLikeAnEmptyOne()
{
string output = ConsoleCapture.Run(new Scan(), "\"\"\n", out int exitCode);

Assert.Contains("No path provided. Aborting.", output);
Assert.AreEqual(1, exitCode);
}
}
40 changes: 40 additions & 0 deletions FileDeduplicator/Verbs/BaseVerb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,46 @@ internal abstract class BaseVerb : ICommand

internal virtual bool ValidateArgs() => true;

/// <summary>
/// Asks for the path when none was given with <c>-p</c>.
/// </summary>
/// <remarks>
/// A pasted path usually arrives in quotes: Explorer's Copy as path always adds them, and dragging a
/// folder with a space into a terminal does too. Left in, they make <see cref="Path"/> resolve the
/// quoted text as a relative name under the current directory, so one matching pair is removed.
/// </remarks>
/// <param name="action">What the verb does with the path, as in "Enter the path to scan".</param>
/// <returns><see langword="false"/> when the user gave no path and the verb should abort.</returns>
internal bool PromptForPathIfMissing(string action)
{
if (!string.IsNullOrWhiteSpace(PathString))
{
return true;
}

Console.Write($"Enter the path to {action}: ");
string input = Unquote(Console.ReadLine()?.Trim() ?? string.Empty);
if (string.IsNullOrWhiteSpace(input))
{
Console.WriteLine("No path provided. Aborting.");
return false;
}

PathString = input;
return true;
}

/// <summary>
/// Removes one matching pair of <c>"</c> or <c>'</c> surrounding <paramref name="input"/>, and the
/// white space just inside them.
/// </summary>
/// <param name="input">The trimmed answer to the path prompt.</param>
/// <returns>The answer without its quotes, or unchanged when it is not quoted.</returns>
internal static string Unquote(string input) =>
input.Length >= 2 && (input[0] == '"' || input[0] == '\'') && input[^1] == input[0]
? input[1..^1].Trim()
: input;

public void Execute() => _ = Run();
}

Expand Down
18 changes: 1 addition & 17 deletions FileDeduplicator/Verbs/Deduplicate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,7 @@ namespace ktsu.FileDeduplicator.Verbs;
[Verb("Deduplicate", HelpText = "Scan a directory and remove duplicate files, keeping the copy with the shortest name.")]
internal sealed class Deduplicate : BaseVerb<Deduplicate>
{
internal override bool ValidateArgs()
{
if (string.IsNullOrWhiteSpace(PathString))
{
Console.Write("Enter the path to deduplicate: ");
string? input = Console.ReadLine()?.Trim();
if (string.IsNullOrEmpty(input))
{
Console.WriteLine("No path provided. Aborting.");
return false;
}

PathString = input;
}

return base.ValidateArgs();
}
internal override bool ValidateArgs() => PromptForPathIfMissing("deduplicate") && base.ValidateArgs();

internal override void Run(Deduplicate options)
{
Expand Down
18 changes: 1 addition & 17 deletions FileDeduplicator/Verbs/DryRun.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,7 @@ namespace ktsu.FileDeduplicator.Verbs;
[Verb("DryRun", HelpText = "Scan for duplicates and show what would be deleted without actually deleting.")]
internal sealed class DryRun : BaseVerb<DryRun>
{
internal override bool ValidateArgs()
{
if (string.IsNullOrWhiteSpace(PathString))
{
Console.Write("Enter the path to scan: ");
string? input = Console.ReadLine()?.Trim();
if (string.IsNullOrEmpty(input))
{
Console.WriteLine("No path provided. Aborting.");
return false;
}

PathString = input;
}

return base.ValidateArgs();
}
internal override bool ValidateArgs() => PromptForPathIfMissing("scan") && base.ValidateArgs();

internal override void Run(DryRun options)
{
Expand Down
18 changes: 1 addition & 17 deletions FileDeduplicator/Verbs/Scan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,7 @@ namespace ktsu.FileDeduplicator.Verbs;
[Verb("Scan", HelpText = "Scan a directory for duplicate files and display results.")]
internal sealed class Scan : BaseVerb<Scan>
{
internal override bool ValidateArgs()
{
if (string.IsNullOrWhiteSpace(PathString))
{
Console.Write("Enter the path to scan: ");
string? input = Console.ReadLine()?.Trim();
if (string.IsNullOrEmpty(input))
{
Console.WriteLine("No path provided. Aborting.");
return false;
}

PathString = input;
}

return base.ValidateArgs();
}
internal override bool ValidateArgs() => PromptForPathIfMissing("scan") && base.ValidateArgs();

internal override void Run(Scan options)
{
Expand Down
18 changes: 1 addition & 17 deletions FileDeduplicator/Verbs/Stats.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,7 @@
[Verb("Stats", HelpText = "Show statistics about files and duplicates in a directory.")]
internal sealed class Stats : BaseVerb<Stats>
{
internal override bool ValidateArgs()
{
if (string.IsNullOrWhiteSpace(PathString))
{
Console.Write("Enter the path to analyze: ");
string? input = Console.ReadLine()?.Trim();
if (string.IsNullOrEmpty(input))
{
Console.WriteLine("No path provided. Aborting.");
return false;
}

PathString = input;
}

return base.ValidateArgs();
}
internal override bool ValidateArgs() => PromptForPathIfMissing("analyze") && base.ValidateArgs();

internal override void Run(Stats options)
{
Expand Down Expand Up @@ -128,7 +112,7 @@
private static Dictionary<string, int> CountRedundantCopiesByExtension(IReadOnlyList<DuplicateGroup> duplicates)
{
Dictionary<string, int> extensionCounts = [];
foreach (DuplicateGroup group in duplicates)

Check warning on line 115 in FileDeduplicator/Verbs/Stats.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loop should be simplified by calling Select(group => group.Files)

Check warning on line 115 in FileDeduplicator/Verbs/Stats.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loop should be simplified by calling Select(group => group.Files)
{
AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(group.Files);
IEnumerable<string> extensions = group.Files
Expand Down
Loading