Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions FileDeduplicator.Test/ConsoleCapture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace ktsu.FileDeduplicator.Test;
/// Runs a verb with the console redirected, and hands back everything it wrote.
/// </summary>
/// <remarks>
/// The verbs have no return value and no output abstraction -- what they do is what they print --
/// The verbs return only an exit code and have no output abstraction -- what they do is what they print --
/// so the console is the only surface a test can assert against. Redirecting it is global state,
/// which is why every class using this is marked <c>[DoNotParallelize]</c>.
/// </remarks>
Expand All @@ -20,7 +20,26 @@ internal static class ConsoleCapture
/// <param name="verb">The verb to run.</param>
/// <param name="stdin">Lines the verb's prompts will read, or nothing.</param>
/// <returns>Everything the verb wrote to the console.</returns>
internal static string Run(BaseVerb verb, string stdin = "")
internal static string Run(BaseVerb verb, string stdin = "") => Run(verb, stdin, out _);

/// <summary>
/// Runs a verb, feeding it the given answers on stdin, and hands back its exit code too.
/// </summary>
/// <param name="verb">The verb to run.</param>
/// <param name="stdin">Lines the verb's prompts will read, or nothing.</param>
/// <param name="exitCode">The exit code the verb returned.</param>
/// <returns>Everything the verb wrote to the console.</returns>
internal static string Run(BaseVerb verb, string stdin, out int exitCode) =>
Run(_ => verb.Run(), stdin, out exitCode);

/// <summary>
/// Runs an action that returns an exit code, feeding it the given answers on stdin.
/// </summary>
/// <param name="action">The action to run. It is handed the writer standing in for the console.</param>
/// <param name="stdin">Lines the action's prompts will read, or nothing.</param>
/// <param name="exitCode">The exit code the action returned.</param>
/// <returns>Everything the action wrote to the console.</returns>
internal static string Run(Func<TextWriter, int> action, string stdin, out int exitCode)
{
TextWriter originalOut = Console.Out;
TextReader originalIn = Console.In;
Expand All @@ -32,7 +51,7 @@ internal static string Run(BaseVerb verb, string stdin = "")
Console.SetOut(captured);
Console.SetIn(answers);

verb.Run();
exitCode = action(captured);

return captured.ToString();
}
Expand Down
138 changes: 138 additions & 0 deletions FileDeduplicator.Test/ExitCodeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.FileDeduplicator.Test;

using CommandLine;

using ktsu.FileDeduplicator.Verbs;
using ktsu.Semantics.Paths;

using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Tests that a failed or aborted run exits non-zero, so a script can tell it from a successful one.
/// </summary>
/// <remarks>
/// <c>Main</c> and every verb returned <see langword="void"/>, so the process exited 0 for a missing
/// directory, an abort, failed deletions and unparseable arguments alike, and
/// <c>Deduplicate -p /data &amp;&amp; echo done</c> reported success when nothing was deleted
/// (ktsu-dev/FileDeduplicator#144).
/// </remarks>
[TestClass]
[DoNotParallelize]
public sealed class ExitCodeTests
{
private static int ExitCodeOf(BaseVerb verb, string stdin = "")
{
_ = ConsoleCapture.Run(verb, stdin, out int exitCode);
return exitCode;
}

private static int ExitCodeOfCommandLine(params string[] args)
{
_ = ConsoleCapture.Run(
captured =>
{
using Parser parser = new(settings => settings.HelpWriter = captured);
return Program.Run(parser, args);
},
string.Empty,
out int exitCode);
return exitCode;
}

private static void AssertEveryVerbExitsOne(string path)
{
Assert.AreEqual(1, ExitCodeOf(new Scan { PathString = path }), "Scan");
Assert.AreEqual(1, ExitCodeOf(new DryRun { PathString = path }), "DryRun");
Assert.AreEqual(1, ExitCodeOf(new Stats { PathString = path }), "Stats");
Assert.AreEqual(1, ExitCodeOf(new Deduplicate { PathString = path }, "y"), "Deduplicate");
}

[TestMethod]
public void EveryVerbExitsOneForAMissingDirectory()
{
using TempTree tree = new();
AssertEveryVerbExitsOne(Path.Combine(tree.Root.WeakString, "not-there"));
}

[TestMethod]
public void EveryVerbExitsOneForAFileGivenAsTheRoot()
{
using TempTree tree = new();
AssertEveryVerbExitsOne(tree.Write("f.txt", "content").WeakString);
}

[TestMethod]
public void EveryVerbExitsZeroForADirectoryItCouldScan()
{
using TempTree empty = new();
using TempTree duplicates = new();
_ = duplicates.Write("a.txt", "alpha");
_ = duplicates.Write("aa.txt", "alpha");

Assert.AreEqual(0, ExitCodeOf(new Scan { PathString = empty.Root.WeakString }), "Scan of an empty directory");
Assert.AreEqual(0, ExitCodeOf(new Scan { PathString = duplicates.Root.WeakString }), "Scan");
Assert.AreEqual(0, ExitCodeOf(new DryRun { PathString = duplicates.Root.WeakString }), "DryRun");
Assert.AreEqual(0, ExitCodeOf(new Stats { PathString = duplicates.Root.WeakString }), "Stats");
}

[TestMethod]
public void AbortingAtThePathPromptExitsOne() =>
Assert.AreEqual(1, ExitCodeOf(new Scan(), "\n"));

[TestMethod]
public void DecliningTheDeletionExitsOne()
{
using TempTree tree = new();
_ = tree.Write("a.txt", "alpha");
_ = tree.Write("aa.txt", "alpha");

Assert.AreEqual(1, ExitCodeOf(new Deduplicate { PathString = tree.Root.WeakString }, "n"));
}

[TestMethod]
public void DeduplicateExitsZeroWhenEveryDeletionSucceeds()
{
using TempTree tree = new();
_ = tree.Write("a.txt", "alpha");
AbsoluteFilePath copy = tree.Write("aa.txt", "alpha");

Assert.AreEqual(0, ExitCodeOf(new Deduplicate { PathString = tree.Root.WeakString }, "y"));
Assert.IsFalse(TempTree.Exists(copy));
}

[TestMethod]
public void DeduplicateExitsOneWhenADeletionFails()
{
using TempTree tree = new();
_ = tree.Write("a.txt", "alpha");
AbsoluteFilePath undeletable = tree.Write("locked/aa.txt", "alpha");

using DeletionBlock block = new(undeletable);
if (!block.IsEnforced)
{
Assert.Inconclusive("This process deletes through a write-protected directory, so the refusal under test cannot be staged. Run the tests as an unprivileged user.");
}

Assert.AreEqual(1, ExitCodeOf(new Deduplicate { PathString = tree.Root.WeakString }, "y"));
}

[TestMethod]
public void AnUnknownOptionExitsTwo() =>
Assert.AreEqual(2, ExitCodeOfCommandLine("Scan", "--nope"));

[TestMethod]
public void AskingForHelpExitsZero() =>
Assert.AreEqual(0, ExitCodeOfCommandLine("--help"));

[TestMethod]
public void AParsedVerbReturnsItsOwnExitCode()
{
using TempTree tree = new();
string missing = Path.Combine(tree.Root.WeakString, "not-there");

Assert.AreEqual(1, ExitCodeOfCommandLine("Scan", "-p", missing));
Assert.AreEqual(0, ExitCodeOfCommandLine("Scan", "-p", tree.Root.WeakString));
}
}
20 changes: 18 additions & 2 deletions FileDeduplicator/FileScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,36 @@ internal static class FileScanner
AttributesToSkip = FileAttributes.ReparsePoint,
};

internal static IReadOnlyList<AbsoluteFilePath> ScanForFiles(AbsoluteDirectoryPath path)
/// <summary>
/// Checks that <paramref name="path"/> is a directory that can be scanned, and says why in one
/// line when it is not.
/// </summary>
/// <param name="path">The root to check.</param>
/// <returns><see langword="false"/> when the root is missing or is not a directory.</returns>
internal static bool IsScannableDirectory(AbsoluteDirectoryPath path)
{
// path.Exists is also true for a file, which Directory.EnumerateFiles then rejects with an
// unhandled DirectoryNotFoundException. A path tab-completed one level too far is an easy
// mistake, so it gets the same one-line error as a path that is not there at all.
if (File.Exists(path.WeakString))
{
Console.WriteLine($"Not a directory: {path}");
return [];
return false;
}

if (!Directory.Exists(path.WeakString))
{
Console.WriteLine($"Directory not found: {path}");
return false;
}

return true;
}

internal static IReadOnlyList<AbsoluteFilePath> ScanForFiles(AbsoluteDirectoryPath path)
{
if (!IsScannableDirectory(path))
{
return [];
}

Expand Down
25 changes: 21 additions & 4 deletions FileDeduplicator/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,30 @@ internal static class Program
{
internal static Type[] Verbs { get; } = LoadVerbs();

private static void Main(string[] args)
/// <summary>
/// Runs the verb named on the command line.
/// </summary>
/// <param name="args">The command-line arguments.</param>
/// <returns>
/// 0 on success, 1 when the verb failed or the user aborted, and 2 when the arguments could not be
/// parsed. Asking for help or the version is a success.
/// </returns>
private static int Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;

_ = Parser.Default.ParseArguments(args, Verbs)
.WithParsed<BaseVerb>(task => task.Run());
return Run(Parser.Default, args);
}

/// <summary>
/// Parses <paramref name="args"/> with <paramref name="parser"/> and runs the verb they name.
/// </summary>
/// <param name="parser">The parser to use.</param>
/// <param name="args">The command-line arguments.</param>
/// <returns>The process exit code, as <see cref="Main"/> describes it.</returns>
internal static int Run(Parser parser, string[] args) =>
parser.ParseArguments(args, Verbs).MapResult(
(BaseVerb verb) => verb.Run(),
errors => errors.IsHelp() || errors.IsVersion() ? 0 : 2);

private static Type[] LoadVerbs() => [.. Assembly.GetExecutingAssembly().GetTypes().Where(t => t.GetCustomAttribute<VerbAttribute>() != null)];
}
28 changes: 23 additions & 5 deletions FileDeduplicator/Verbs/BaseVerb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,32 +23,50 @@ internal abstract class BaseVerb : ICommand

internal AbsoluteDirectoryPath Path => System.IO.Path.GetFullPath(PathString ?? ".").As<AbsoluteDirectoryPath>();

public abstract void Run();
/// <summary>
/// Runs the verb.
/// </summary>
/// <returns>The process exit code: 0 on success, 1 when the run failed or the user aborted.</returns>
public abstract int Run();

/// <summary>
/// Gets or sets the exit code of the current run. It starts at 0, and a verb sets it to 1 when the
/// run fails or the user aborts.
/// </summary>
internal int ExitCode { get; set; }

/// <summary>
/// Gets whether the verb works on the directory named by <see cref="Path"/>, which then has to
/// exist before the verb runs.
/// </summary>
internal virtual bool ScansADirectory => true;

internal virtual bool ValidateArgs() => true;

public void Execute() => Run();
public void Execute() => _ = Run();
}

internal abstract class BaseVerb<T> : BaseVerb where T : BaseVerb<T>
{
private bool isActive = true;
public override bool IsActive => isActive;

public override void Run()
public override int Run()
{
// 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
{
if (!ValidateArgs())
ExitCode = 0;
if (!ValidateArgs() || (ScansADirectory && !FileScanner.IsScannableDirectory(Path)))
{
return;
return 1;
}

isActive = false;
Run((T)this);
return ExitCode;
}
finally
{
Expand Down
2 changes: 2 additions & 0 deletions FileDeduplicator/Verbs/Deduplicate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ internal override void Run(Deduplicate options)
if (!string.Equals(confirmation, "y", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine("Aborted.");
ExitCode = 1;
return;
}

Expand All @@ -108,6 +109,7 @@ internal override void Run(Deduplicate options)
if (result.Errors.Count > 0)
{
Console.WriteLine($"Encountered {result.Errors.Count} error(s) during deletion.");
ExitCode = 1;
}
}
}
2 changes: 2 additions & 0 deletions FileDeduplicator/Verbs/Menu.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
[Verb("Menu", isDefault: true)]
internal sealed class Menu : BaseVerb<Menu>
{
internal override bool ScansADirectory => false;

internal override void Run(Menu options)

Check warning on line 20 in FileDeduplicator/Verbs/Menu.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Add a way to break out of this method's recursion.

Check warning on line 20 in FileDeduplicator/Verbs/Menu.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Add a way to break out of this method's recursion.

Check warning on line 20 in FileDeduplicator/Verbs/Menu.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Add a way to break out of this method's recursion.

Check warning on line 20 in FileDeduplicator/Verbs/Menu.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Add a way to break out of this method's recursion.
{
ScrollMenu scrollMenu = new()
{
Expand Down
Loading