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) {