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; }