diff --git a/FileDeduplicator.Test/QuotedPathPromptTests.cs b/FileDeduplicator.Test/QuotedPathPromptTests.cs
new file mode 100644
index 0000000..258157a
--- /dev/null
+++ b/FileDeduplicator.Test/QuotedPathPromptTests.cs
@@ -0,0 +1,68 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.FileDeduplicator.Test;
+
+using ktsu.FileDeduplicator.Verbs;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// 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).
+///
+[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);
+ }
+}
diff --git a/FileDeduplicator/Verbs/BaseVerb.cs b/FileDeduplicator/Verbs/BaseVerb.cs
index c154508..c4fc982 100644
--- a/FileDeduplicator/Verbs/BaseVerb.cs
+++ b/FileDeduplicator/Verbs/BaseVerb.cs
@@ -54,6 +54,46 @@ internal abstract class BaseVerb : ICommand
internal virtual bool ValidateArgs() => true;
+ ///
+ /// Asks for the path when none was given with -p.
+ ///
+ ///
+ /// 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 resolve the
+ /// quoted text as a relative name under the current directory, so one matching pair is removed.
+ ///
+ /// What the verb does with the path, as in "Enter the path to scan".
+ /// when the user gave no path and the verb should abort.
+ 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;
+ }
+
+ ///
+ /// Removes one matching pair of " or ' surrounding , and the
+ /// white space just inside them.
+ ///
+ /// The trimmed answer to the path prompt.
+ /// The answer without its quotes, or unchanged when it is not quoted.
+ 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();
}
diff --git a/FileDeduplicator/Verbs/Deduplicate.cs b/FileDeduplicator/Verbs/Deduplicate.cs
index 6cd50ba..21bdc39 100644
--- a/FileDeduplicator/Verbs/Deduplicate.cs
+++ b/FileDeduplicator/Verbs/Deduplicate.cs
@@ -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
{
- 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)
{
diff --git a/FileDeduplicator/Verbs/DryRun.cs b/FileDeduplicator/Verbs/DryRun.cs
index d9f94d1..358ab0d 100644
--- a/FileDeduplicator/Verbs/DryRun.cs
+++ b/FileDeduplicator/Verbs/DryRun.cs
@@ -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
{
- 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)
{
diff --git a/FileDeduplicator/Verbs/Scan.cs b/FileDeduplicator/Verbs/Scan.cs
index 9766cf7..4bfd58e 100644
--- a/FileDeduplicator/Verbs/Scan.cs
+++ b/FileDeduplicator/Verbs/Scan.cs
@@ -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
{
- 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)
{
diff --git a/FileDeduplicator/Verbs/Stats.cs b/FileDeduplicator/Verbs/Stats.cs
index ac11972..8c4db85 100644
--- a/FileDeduplicator/Verbs/Stats.cs
+++ b/FileDeduplicator/Verbs/Stats.cs
@@ -12,23 +12,7 @@ namespace ktsu.FileDeduplicator.Verbs;
[Verb("Stats", HelpText = "Show statistics about files and duplicates in a directory.")]
internal sealed class Stats : BaseVerb
{
- 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)
{