diff --git a/FileDeduplicator.Test/VerbReuseTests.cs b/FileDeduplicator.Test/VerbReuseTests.cs
new file mode 100644
index 0000000..8177573
--- /dev/null
+++ b/FileDeduplicator.Test/VerbReuseTests.cs
@@ -0,0 +1,93 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.FileDeduplicator.Test;
+
+using ktsu.FileDeduplicator.Verbs;
+using ktsu.Semantics.Paths;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Tests that a verb instance asks for a new path each time it runs, however its last run ended.
+///
+///
+/// The interactive menu creates one instance of each verb and runs it every time its item is chosen.
+/// Each verb reset its path only on the last line of its run, so an early return (nothing found, no
+/// duplicates, a declined confirmation) kept the old path. The next run then skipped the prompt,
+/// and the path the user typed was read as the answer to the next question instead
+/// (ktsu-dev/FileDeduplicator#138).
+///
+[TestClass]
+[DoNotParallelize]
+public sealed class VerbReuseTests
+{
+ ///
+ /// Runs a verb against a tree with no duplicates, which returns early, then runs it again.
+ ///
+ /// The verb instance, reused as the menu reuses it.
+ /// The prompt the verb shows when it has no path.
+ private static void AssertPromptsAgainAfterAnEarlyReturn(BaseVerb verb, string prompt)
+ {
+ using TempTree first = new();
+ _ = first.Write("a.txt", "alpha");
+ _ = first.Write("b.txt", "beta");
+
+ using TempTree second = new();
+ _ = second.Write("c.txt", "gamma");
+
+ string firstOutput = ConsoleCapture.Run(verb, $"{first.Root.WeakString}\n");
+ string secondOutput = ConsoleCapture.Run(verb, $"{second.Root.WeakString}\n");
+
+ Assert.Contains(prompt, firstOutput);
+ Assert.Contains(prompt, secondOutput, $"{verb.GetType().Name} reused the previous path without asking.");
+ Assert.DoesNotContain(first.Root.WeakString, secondOutput, $"{verb.GetType().Name} ran against the previous path.");
+ }
+
+ [TestMethod]
+ public void DeduplicatePromptsAgainAfterFindingNoDuplicates() =>
+ AssertPromptsAgainAfterAnEarlyReturn(new Deduplicate(), "Enter the path to deduplicate:");
+
+ [TestMethod]
+ public void DryRunPromptsAgainAfterFindingNoDuplicates() =>
+ AssertPromptsAgainAfterAnEarlyReturn(new DryRun(), "Enter the path to scan:");
+
+ [TestMethod]
+ public void ScanPromptsAgainAfterFindingNoDuplicates() =>
+ AssertPromptsAgainAfterAnEarlyReturn(new Scan(), "Enter the path to scan:");
+
+ [TestMethod]
+ public void StatsPromptsAgainAfterFindingNoFiles()
+ {
+ Stats stats = new();
+ using TempTree empty = new();
+ using TempTree second = new();
+ _ = second.Write("c.txt", "gamma");
+
+ _ = ConsoleCapture.Run(stats, $"{empty.Root.WeakString}\n");
+ string secondOutput = ConsoleCapture.Run(stats, $"{second.Root.WeakString}\n");
+
+ Assert.Contains("Enter the path to analyze:", secondOutput, "Stats reused the previous path without asking.");
+ }
+
+ ///
+ /// Declining the deletion is the case the issue describes: the next path typed must not be read
+ /// as a confirmation against the previous directory.
+ ///
+ [TestMethod]
+ public void DeduplicatePromptsAgainAfterTheUserDeclines()
+ {
+ Deduplicate deduplicate = new();
+ using TempTree first = new();
+ AbsoluteFilePath keeper = first.Write("a.txt", "alpha");
+ AbsoluteFilePath copy = first.Write("aa.txt", "alpha");
+
+ using TempTree second = new();
+ _ = second.Write("c.txt", "gamma");
+
+ _ = ConsoleCapture.Run(deduplicate, $"{first.Root.WeakString}\nn\n");
+ string secondOutput = ConsoleCapture.Run(deduplicate, $"{second.Root.WeakString}\n");
+
+ Assert.Contains("Enter the path to deduplicate:", secondOutput, "Deduplicate reused the previous path without asking.");
+ Assert.IsTrue(TempTree.Exists(keeper) && TempTree.Exists(copy), "The path typed second was read as a confirmation against the first directory.");
+ }
+}
diff --git a/FileDeduplicator/Verbs/BaseVerb.cs b/FileDeduplicator/Verbs/BaseVerb.cs
index e59aac8..34c0bcf 100644
--- a/FileDeduplicator/Verbs/BaseVerb.cs
+++ b/FileDeduplicator/Verbs/BaseVerb.cs
@@ -32,14 +32,24 @@ internal abstract class BaseVerb : BaseVerb where T : BaseVerb
public override void Run()
{
- if (!ValidateArgs())
+ // The interactive menu runs the same instance every time its item is chosen, so the path is
+ // put back on every exit, early returns and exceptions included. Otherwise the next run skips
+ // the prompt and works on the previous directory.
+ try
{
- return;
- }
+ if (!ValidateArgs())
+ {
+ return;
+ }
- isActive = false;
- Run((T)this);
- isActive = true;
+ isActive = false;
+ Run((T)this);
+ }
+ finally
+ {
+ isActive = true;
+ PathString = ".";
+ }
}
internal abstract void Run(T options);
diff --git a/FileDeduplicator/Verbs/Deduplicate.cs b/FileDeduplicator/Verbs/Deduplicate.cs
index 6341f03..e53eac2 100644
--- a/FileDeduplicator/Verbs/Deduplicate.cs
+++ b/FileDeduplicator/Verbs/Deduplicate.cs
@@ -109,7 +109,5 @@ internal override void Run(Deduplicate options)
{
Console.WriteLine($"Encountered {result.Errors.Count} error(s) during deletion.");
}
-
- PathString = ".";
}
}
diff --git a/FileDeduplicator/Verbs/DryRun.cs b/FileDeduplicator/Verbs/DryRun.cs
index f241d47..3b5d70f 100644
--- a/FileDeduplicator/Verbs/DryRun.cs
+++ b/FileDeduplicator/Verbs/DryRun.cs
@@ -71,7 +71,5 @@ internal override void Run(DryRun options)
Console.WriteLine($"Duplicate groups: {duplicates.Count}");
Console.WriteLine($"Files to delete: {plan.FileCount}");
Console.WriteLine($"Space to reclaim: {DuplicateReport.FormatBytes(plan.BytesReclaimable)}");
-
- PathString = ".";
}
}
diff --git a/FileDeduplicator/Verbs/Scan.cs b/FileDeduplicator/Verbs/Scan.cs
index 62bedc6..952ea1e 100644
--- a/FileDeduplicator/Verbs/Scan.cs
+++ b/FileDeduplicator/Verbs/Scan.cs
@@ -86,7 +86,5 @@ internal override void Run(Scan options)
Console.WriteLine($"Total wasted space: {DuplicateReport.FormatBytes(totalWastedBytes)}");
Console.WriteLine();
Console.WriteLine("Run the 'Deduplicate' command to remove duplicates.");
-
- PathString = ".";
}
}
diff --git a/FileDeduplicator/Verbs/Stats.cs b/FileDeduplicator/Verbs/Stats.cs
index 2ad684f..87446bd 100644
--- a/FileDeduplicator/Verbs/Stats.cs
+++ b/FileDeduplicator/Verbs/Stats.cs
@@ -104,7 +104,5 @@ internal override void Run(Stats options)
Console.WriteLine($" {group.Hash[..12]}... - {group.Files.Count} copies, {DuplicateReport.FormatBytes(group.FileSize)} each, {DuplicateReport.FormatBytes(wasted)} wasted");
}
}
-
- PathString = ".";
}
}