From cc1c1d9404dd392d9b0dc4f797775073a42d4fbb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 00:24:06 +0000 Subject: [PATCH] Strip the quotes from a path pasted at the prompt [patch] Explorer's Copy as path, and dragging a folder with a space into a terminal, wrap the path in quotes. The prompt only trimmed its input, so the quotes stayed in and Path.GetFullPath resolved the quoted text under the current directory, reporting "Directory not found". The four copies of the prompt in Scan, DryRun, Deduplicate and Stats move into BaseVerb.PromptForPathIfMissing, which removes one matching pair of surrounding " or ' before using the answer. Fixes ktsu-dev/FileDeduplicator#152 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW2CxWzSsPmDZkw2R6J --- .../QuotedPathPromptTests.cs | 68 +++++++++++++++++++ FileDeduplicator/Verbs/BaseVerb.cs | 40 +++++++++++ FileDeduplicator/Verbs/Deduplicate.cs | 18 +---- FileDeduplicator/Verbs/DryRun.cs | 18 +---- FileDeduplicator/Verbs/Scan.cs | 18 +---- FileDeduplicator/Verbs/Stats.cs | 18 +---- 6 files changed, 112 insertions(+), 68 deletions(-) create mode 100644 FileDeduplicator.Test/QuotedPathPromptTests.cs 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) {