diff --git a/ImageDescriber.Test/MenuTests.cs b/ImageDescriber.Test/MenuTests.cs
index 4d488d9..1242090 100644
--- a/ImageDescriber.Test/MenuTests.cs
+++ b/ImageDescriber.Test/MenuTests.cs
@@ -3,6 +3,8 @@
namespace ktsu.ImageDescriber.Tests;
using ktsu.ImageDescriber.Verbs;
+using ktsu.Semantics.Paths;
+using ktsu.Semantics.Strings;
[TestClass]
public class MenuTests
@@ -87,4 +89,95 @@ public void PromptMenuStopsAtEndOfInput()
Assert.AreEqual("Only", string.Join(",", ran));
}
+
+ [TestMethod]
+ public void ExportRunWithoutAnOutputPathAsksForOne()
+ {
+ string outputFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.json");
+ try
+ {
+ string text = RunWithConsole(new Export(), $"{outputFile}\n");
+
+ Assert.Contains("Enter the output file path", text);
+ Assert.IsTrue(File.Exists(outputFile));
+ Assert.Contains("A dog on a beach.", File.ReadAllText(outputFile));
+ }
+ finally
+ {
+ File.Delete(outputFile);
+ }
+ }
+
+ [TestMethod]
+ public void ExportRunWithAnEmptyAnswerAbortsCleanly()
+ {
+ string text = RunWithConsole(new Export(), "\n");
+
+ Assert.Contains("No path provided. Aborting.", text);
+ }
+
+ [TestMethod]
+ public void SearchRunWithoutAQueryAsksForOne()
+ {
+ string text = RunWithConsole(new Search(), "dog\n");
+
+ Assert.Contains("Enter the search query", text);
+ Assert.Contains("Search results for \"dog\": 1 match(es)", text);
+ }
+
+ [TestMethod]
+ public void MenuItemsAskAgainOnEveryRun()
+ {
+ Action search = Menu.CreateVerbAction(typeof(Search));
+
+ string text = RunWithConsole(search, "dog\ncat\n");
+
+ Assert.Contains("Search results for \"dog\": 1 match(es)", text);
+ Assert.Contains("Search results for \"cat\": 0 match(es)", text);
+ }
+
+ private static string RunWithConsole(BaseVerb verb, string input) => RunWithConsole(verb.Run, input);
+
+ ///
+ /// Runs as many times as has lines, against a
+ /// database holding one description, with the console redirected.
+ ///
+ private static string RunWithConsole(Action run, string input)
+ {
+ PersistentState originalSettings = Program.Settings;
+ TextReader originalIn = Console.In;
+ TextWriter originalOut = Console.Out;
+ using StringReader reader = new(input);
+ using StringWriter output = new();
+ try
+ {
+ Program.Settings = new PersistentState();
+ Program.Settings.Descriptions[new string('a', 64)] = new ImageDescription
+ {
+ Hash = new string('a', 64),
+ SuggestedFileName = "dog-on-beach.jpg".As(),
+ KnownPaths = [Path.Combine(Path.GetTempPath(), "a.jpg").As()],
+ Model = "llava".As(),
+ DescribedAt = new DateTime(2026, 9, 27, 1, 7, 46, DateTimeKind.Utc),
+ FileSizeBytes = 1,
+ Description = "A dog on a beach.",
+ };
+ Console.SetIn(reader);
+ Console.SetOut(output);
+
+ int runs = input.Count(c => c == '\n');
+ for (int i = 0; i < runs; i++)
+ {
+ run();
+ }
+ }
+ finally
+ {
+ Console.SetIn(originalIn);
+ Console.SetOut(originalOut);
+ Program.Settings = originalSettings;
+ }
+
+ return output.ToString();
+ }
}
diff --git a/ImageDescriber/Verbs/Export.cs b/ImageDescriber/Verbs/Export.cs
index f20a7e2..2d80f8d 100644
--- a/ImageDescriber/Verbs/Export.cs
+++ b/ImageDescriber/Verbs/Export.cs
@@ -29,6 +29,25 @@ internal sealed class Export : BaseVerb
[Option('o', "output", Required = true, HelpText = "Output file path (.json or .csv).")]
public string OutputPath { get; set; } = string.Empty;
+ internal override bool ValidateArgs()
+ {
+ // The menu runs verbs without CommandLineParser, which is what enforces Required = true
+ if (string.IsNullOrWhiteSpace(OutputPath))
+ {
+ Console.Write("Enter the output file path (.json or .csv): ");
+ string? input = Console.ReadLine()?.Trim();
+ if (string.IsNullOrEmpty(input))
+ {
+ Console.WriteLine("No path provided. Aborting.");
+ return false;
+ }
+
+ OutputPath = input;
+ }
+
+ return base.ValidateArgs();
+ }
+
internal override void Run(Export options)
{
Dictionary descriptions = Program.Settings.Descriptions;
diff --git a/ImageDescriber/Verbs/Menu.cs b/ImageDescriber/Verbs/Menu.cs
index 6330705..f9673d9 100644
--- a/ImageDescriber/Verbs/Menu.cs
+++ b/ImageDescriber/Verbs/Menu.cs
@@ -29,7 +29,7 @@ internal override void Run(Menu options)
}
else
{
- RunPromptMenu(Console.In, Console.Out, [.. verbs.Select(verb => (GetMenuText(verb), (Action)(() => CreateVerb(verb).Execute())))]);
+ RunPromptMenu(Console.In, Console.Out, [.. verbs.Select(verb => (GetMenuText(verb), CreateVerbAction(verb)))]);
}
}
@@ -109,6 +109,12 @@ private sealed class ActionCommand(Action action) : ICommand
public void Execute() => action();
}
+ ///
+ /// Returns an action that runs a fresh instance of the verb each time, so a path or query
+ /// entered for one run is asked for again on the next rather than silently reused.
+ ///
+ internal static Action CreateVerbAction(Type verbType) => () => CreateVerb(verbType).Execute();
+
private static BaseVerb CreateVerb(Type verbType)
{
BaseVerb? verb = Activator.CreateInstance(verbType) as BaseVerb;
@@ -126,7 +132,7 @@ private static string GetMenuText(Type verbType)
private static LabelMenuItem CreateMenuItem(Type verbType) => new()
{
Text = GetMenuText(verbType),
- Command = CreateVerb(verbType),
+ Command = new ActionCommand(CreateVerbAction(verbType)),
IsEnabled = true,
};
}
diff --git a/ImageDescriber/Verbs/Search.cs b/ImageDescriber/Verbs/Search.cs
index 3a6c66c..8071cc2 100644
--- a/ImageDescriber/Verbs/Search.cs
+++ b/ImageDescriber/Verbs/Search.cs
@@ -15,6 +15,25 @@ internal sealed class Search : BaseVerb
[Option('q', "query", Required = true, HelpText = "The search query to find in descriptions.")]
public string Query { get; set; } = string.Empty;
+ internal override bool ValidateArgs()
+ {
+ // The menu runs verbs without CommandLineParser, which is what enforces Required = true
+ if (string.IsNullOrWhiteSpace(Query))
+ {
+ Console.Write("Enter the search query: ");
+ string? input = Console.ReadLine()?.Trim();
+ if (string.IsNullOrEmpty(input))
+ {
+ Console.WriteLine("No query provided. Aborting.");
+ return false;
+ }
+
+ Query = input;
+ }
+
+ return base.ValidateArgs();
+ }
+
internal override void Run(Search options)
{
if (string.IsNullOrWhiteSpace(options.Query))