From baf06721a9ee3ee605ff668f96aa226c9fabe5c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:25:41 +0000 Subject: [PATCH 1/2] Exit non-zero when arguments do not parse or a verb fails [patch] Main returned void and every verb reported failures with Console.WriteLine, so a script could not tell that Ollama was down, the import file was missing, a required option was absent, or some images failed to describe. Fixes ktsu-dev/ImageDescriber#167 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015PVg1hwPKWXb1nLDuTQgYR --- ImageDescriber.Test/ProgramTests.cs | 66 +++++++++++++++++++++++++++++ ImageDescriber/Program.cs | 20 ++++++++- ImageDescriber/Verbs/BaseVerb.cs | 5 +++ ImageDescriber/Verbs/Export.cs | 1 + ImageDescriber/Verbs/Import.cs | 2 + ImageDescriber/Verbs/Scan.cs | 3 ++ ImageDescriber/Verbs/Search.cs | 1 + 7 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 ImageDescriber.Test/ProgramTests.cs diff --git a/ImageDescriber.Test/ProgramTests.cs b/ImageDescriber.Test/ProgramTests.cs new file mode 100644 index 0000000..dda3e0c --- /dev/null +++ b/ImageDescriber.Test/ProgramTests.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImageDescriber.Tests; + +[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 RunReturnsZeroWhenTheVerbSucceeds() => + Assert.AreEqual(0, RunQuietly("Stats")); + + [TestMethod] + public void RunReturnsZeroForHelp() => + Assert.AreEqual(0, RunQuietly("--help")); + + private static int RunQuietly(params string[] args) + { + TextWriter originalOut = Console.Out; + TextWriter originalError = Console.Error; + try + { + Console.SetOut(TextWriter.Null); + Console.SetError(TextWriter.Null); + return Program.Run(args); + } + finally + { + 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; } From f9613516e9b7ee10703d33a23e93b2e2876a75ed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:44:14 +0000 Subject: [PATCH 2/2] Cover the remaining failure exits in ProgramTests Adds cases for an unsupported import or export extension, a blank search query, an invalid Ollama endpoint, an empty prompted path and --version, and gives every run an empty standard input so a prompt cannot block. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015PVg1hwPKWXb1nLDuTQgYR --- ImageDescriber.Test/ProgramTests.cs | 62 ++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/ImageDescriber.Test/ProgramTests.cs b/ImageDescriber.Test/ProgramTests.cs index dda3e0c..f165499 100644 --- a/ImageDescriber.Test/ProgramTests.cs +++ b/ImageDescriber.Test/ProgramTests.cs @@ -2,6 +2,8 @@ namespace ktsu.ImageDescriber.Tests; +using ktsu.ImageDescriber.Verbs; + [TestClass] public class ProgramTests { @@ -39,6 +41,50 @@ public void RunReturnsNonZeroWhenOllamaIsUnavailable() 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")); @@ -49,16 +95,30 @@ public void RunReturnsZeroForHelp() => 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); - return Program.Run(args); + action(); } finally { + Console.SetIn(originalIn); Console.SetOut(originalOut); Console.SetError(originalError); }