diff --git a/ImageDescriber.Test/ProgramTests.cs b/ImageDescriber.Test/ProgramTests.cs
new file mode 100644
index 0000000..f165499
--- /dev/null
+++ b/ImageDescriber.Test/ProgramTests.cs
@@ -0,0 +1,126 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.ImageDescriber.Tests;
+
+using ktsu.ImageDescriber.Verbs;
+
+[TestClass]
+public class ProgramTests
+{
+ private PersistentState? originalSettings;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ originalSettings = Program.Settings;
+ Program.Settings = new PersistentState();
+ }
+
+ [TestCleanup]
+ public void Cleanup() => Program.Settings = originalSettings!;
+
+ [TestMethod]
+ public void RunReturnsNonZeroWhenARequiredOptionIsMissing() =>
+ Assert.AreEqual(1, RunQuietly("Search"));
+
+ [TestMethod]
+ public void RunReturnsNonZeroWhenOllamaIsUnavailable()
+ {
+ string directory = Directory.CreateTempSubdirectory().FullName;
+ try
+ {
+ Assert.AreEqual(1, RunQuietly("Scan", "-p", directory, "-e", "http://127.0.0.1:1"));
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public void RunReturnsNonZeroWhenTheImportFileIsMissing() =>
+ Assert.AreEqual(1, RunQuietly("Import", "-i", Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".csv")));
+
+ [TestMethod]
+ public void RunReturnsNonZeroWhenTheImportFileHasAnUnsupportedExtension()
+ {
+ string file = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt");
+ File.WriteAllText(file, string.Empty);
+ try
+ {
+ Assert.AreEqual(1, RunQuietly("Import", "-i", file));
+ }
+ finally
+ {
+ File.Delete(file);
+ }
+ }
+
+ [TestMethod]
+ public void RunReturnsNonZeroWhenTheExportFileHasAnUnsupportedExtension()
+ {
+ string hash = new('a', ImageHasher.HashLength);
+ Program.Settings.Descriptions[hash] = new ImageDescription { Hash = hash };
+
+ Assert.AreEqual(1, RunQuietly("Export", "-o", Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt")));
+ }
+
+ [TestMethod]
+ public void SearchReportsFailureForABlankQuery()
+ {
+ Search search = new();
+ RunQuietly(() => search.Run(new Search { Query = " " }));
+ Assert.IsTrue(search.Failed);
+ }
+
+ [TestMethod]
+ public void RunReturnsNonZeroWhenTheOllamaEndpointIsInvalid() =>
+ Assert.AreEqual(1, RunQuietly("Scan", "-p", Path.GetTempPath(), "-e", "ftp://example.com"));
+
+ [TestMethod]
+ public void RunReturnsNonZeroWhenThePromptedPathIsLeftEmpty() =>
+ Assert.AreEqual(1, RunQuietly("Import"));
+
+ [TestMethod]
+ public void RunReturnsZeroForVersion() =>
+ Assert.AreEqual(0, RunQuietly("--version"));
+
+ [TestMethod]
+ public void RunReturnsZeroWhenTheVerbSucceeds() =>
+ Assert.AreEqual(0, RunQuietly("Stats"));
+
+ [TestMethod]
+ public void RunReturnsZeroForHelp() =>
+ Assert.AreEqual(0, RunQuietly("--help"));
+
+ private static int RunQuietly(params string[] args)
+ {
+ int exitCode = 0;
+ RunQuietly(() => exitCode = Program.Run(args));
+ return exitCode;
+ }
+
+ ///
+ /// Runs with output discarded and an empty standard input, so a
+ /// verb that prompts for a missing value reads end-of-input instead of blocking.
+ ///
+ private static void RunQuietly(Action action)
+ {
+ TextReader originalIn = Console.In;
+ TextWriter originalOut = Console.Out;
+ TextWriter originalError = Console.Error;
+ try
+ {
+ Console.SetIn(new StringReader(string.Empty));
+ Console.SetOut(TextWriter.Null);
+ Console.SetError(TextWriter.Null);
+ action();
+ }
+ finally
+ {
+ Console.SetIn(originalIn);
+ Console.SetOut(originalOut);
+ Console.SetError(originalError);
+ }
+ }
+}
diff --git a/ImageDescriber/Program.cs b/ImageDescriber/Program.cs
index 9042219..33cb975 100644
--- a/ImageDescriber/Program.cs
+++ b/ImageDescriber/Program.cs
@@ -18,13 +18,29 @@ internal static class Program
internal static Type[] Verbs { get; } = LoadVerbs();
internal static PersistentState Settings { get; set; } = new();
- private static void Main(string[] args)
+ private static int Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
Settings = PersistentState.LoadOrCreate();
+ return Run(args);
+ }
+
+ ///
+ /// Runs the verb named by and returns the process exit code: 1 when the
+ /// arguments do not parse or the verb reports a failure, so scripts and CI can detect either.
+ ///
+ internal static int Run(string[] args)
+ {
+ int exitCode = 0;
_ = Parser.Default.ParseArguments(args, Verbs)
- .WithParsed(task => task.Run());
+ .WithParsed(task =>
+ {
+ task.Run();
+ exitCode = task.Failed ? 1 : 0;
+ })
+ .WithNotParsed(errors => exitCode = errors.IsHelp() || errors.IsVersion() ? 0 : 1);
+ return exitCode;
}
private static Type[] LoadVerbs() => [.. Assembly.GetExecutingAssembly().GetTypes().Where(t => t.GetCustomAttribute() != null)];
diff --git a/ImageDescriber/Verbs/BaseVerb.cs b/ImageDescriber/Verbs/BaseVerb.cs
index 00c9f44..2332721 100644
--- a/ImageDescriber/Verbs/BaseVerb.cs
+++ b/ImageDescriber/Verbs/BaseVerb.cs
@@ -22,6 +22,9 @@ internal abstract class BaseVerb : ICommand
public abstract bool IsActive { get; }
+ /// Whether the last run failed, which makes the process exit non-zero.
+ internal bool Failed { get; set; }
+
internal AbsoluteDirectoryPath Path => System.IO.Path.GetFullPath(PathString).As();
internal OllamaEndpoint Endpoint
@@ -49,8 +52,10 @@ internal abstract class BaseVerb : BaseVerb where T : BaseVerb
public override void Run()
{
+ Failed = false;
if (!ValidateArgs())
{
+ Failed = true;
return;
}
diff --git a/ImageDescriber/Verbs/Export.cs b/ImageDescriber/Verbs/Export.cs
index d134feb..9bb9959 100644
--- a/ImageDescriber/Verbs/Export.cs
+++ b/ImageDescriber/Verbs/Export.cs
@@ -70,6 +70,7 @@ internal override void Run(Export options)
break;
default:
Console.WriteLine("Error: Output file must have .json or .csv extension.");
+ Failed = true;
return;
}
diff --git a/ImageDescriber/Verbs/Import.cs b/ImageDescriber/Verbs/Import.cs
index 91e3f62..64d1f2e 100644
--- a/ImageDescriber/Verbs/Import.cs
+++ b/ImageDescriber/Verbs/Import.cs
@@ -51,12 +51,14 @@ internal override void Run(Import options)
if (!File.Exists(inputFile.WeakString))
{
Console.WriteLine($"File not found: {inputFile}");
+ Failed = true;
return;
}
List? entries = LoadEntries(inputFile);
if (entries is null)
{
+ Failed = true;
return;
}
diff --git a/ImageDescriber/Verbs/Scan.cs b/ImageDescriber/Verbs/Scan.cs
index 5436b38..085b784 100644
--- a/ImageDescriber/Verbs/Scan.cs
+++ b/ImageDescriber/Verbs/Scan.cs
@@ -46,6 +46,7 @@ internal override void Run(Scan options)
if (OllamaClient.NormalizeEndpoint(options.Endpoint.WeakString) is null)
{
Console.WriteLine($"Error: Invalid Ollama endpoint \"{options.Endpoint}\". Expected an http or https address such as http://localhost:11434.");
+ Failed = true;
return;
}
@@ -56,6 +57,7 @@ internal override void Run(Scan options)
{
Console.WriteLine($"Error: Ollama is not available at {options.Endpoint}");
Console.WriteLine("Make sure Ollama is running and the endpoint is correct.");
+ Failed = true;
return;
}
@@ -150,6 +152,7 @@ internal override void Run(Scan options)
});
PrintFailureSummary(failures);
+ Failed = failures.Count > 0;
Console.WriteLine();
Console.WriteLine("Scan complete.");
diff --git a/ImageDescriber/Verbs/Search.cs b/ImageDescriber/Verbs/Search.cs
index 8071cc2..6da34b5 100644
--- a/ImageDescriber/Verbs/Search.cs
+++ b/ImageDescriber/Verbs/Search.cs
@@ -39,6 +39,7 @@ internal override void Run(Search options)
if (string.IsNullOrWhiteSpace(options.Query))
{
Console.WriteLine("Error: Search query cannot be empty.");
+ Failed = true;
return;
}