{ lib, pkgs, depot, ... }:

# Run a nix testsuite.
#
# The tests are simple assertions on the nix level,
# and can use derivation outputs if IfD is enabled.
#
# You build a testsuite by bundling assertions into
# “it”s and then bundling the “it”s into a testsuite.
#
# Running the testsuite will abort evaluation if
# any assertion fails.
#
# Example:
#
#   runTestsuite "myFancyTestsuite" [
#     (it "does an assertion" [
#       (assertEq "42 is equal to 42" "42" "42")
#       (assertEq "also 23" 23 23)
#     ])
#     (it "frmbls the brlbr" [
#       (assertEq true false)
#     ])
#   ]
#
# will fail the second it group because true is not false.

let
  inherit (depot.nix.yants)
    sum
    struct
    string
    any
    defun
    list
    drv
    bool
    ;

  bins = depot.nix.getBins pkgs.coreutils [ "printf" ]
    // depot.nix.getBins pkgs.s6-portable-utils [ "s6-touch" "s6-false" "s6-cat" ];

  # Returns true if the given expression throws when `deepSeq`-ed
  throws = expr:
    !(builtins.tryEval (builtins.deepSeq expr { })).success;

  # rewrite the builtins.partition result
  # to use `ok` and `err` instead of `right` and `wrong`.
  partitionTests = pred: xs:
    let res = builtins.partition pred xs;
    in {
      ok = res.right;
      err = res.wrong;
    };

  AssertErrorContext =
    sum "AssertErrorContext" {
      not-equal = struct "not-equal" {
        left = any;
        right = any;
      };
      should-throw = struct "should-throw" {
        expr = any;
      };
      unexpected-throw = struct "unexpected-throw" { };
    };

  # The result of an assert,
  # either it’s true (yep) or false (nope).
  # If it's nope we return an additional context
  # attribute which gives details on the failure
  # depending on the type of assert performed.
  AssertResult =
    sum "AssertResult" {
      yep = struct "yep" {
        test = string;
      };
      nope = struct "nope" {
        test = string;
        context = AssertErrorContext;
      };
    };

  # Result of an it. An it is a bunch of asserts
  # bundled up with a good description of what is tested.
  ItResult =
    struct "ItResult" {
      it-desc = string;
      asserts = list AssertResult;
    };

  # If the given boolean is true return a positive AssertResult.
  # If the given boolean is false return a negative AssertResult
  # with the provided AssertErrorContext describing the failure.
  #
  # This function is intended as a generic assert to implement
  # more assert types and is not exposed to the user.
  assertBoolContext = defun [ AssertErrorContext string bool AssertResult ]
    (context: desc: res:
      if res
      then { yep = { test = desc; }; }
      else {
        nope = {
          test = desc;
          inherit context;
        };
      });

  # assert that left and right values are equal
  assertEq = defun [ string any any AssertResult ]
    (desc: left: right:
      let
        context = { not-equal = { inherit left right; }; };
      in
      assertBoolContext context desc (left == right));

  # assert that the expression throws when `deepSeq`-ed
  assertThrows = defun [ string any AssertResult ]
    (desc: expr:
      let
        context = { should-throw = { inherit expr; }; };
      in
      assertBoolContext context desc (throws expr));

  # assert that the expression does not throw when `deepSeq`-ed
  assertDoesNotThrow = defun [ string any AssertResult ]
    (desc: expr:
      assertBoolContext { unexpected-throw = { }; } desc (!(throws expr)));

  # Annotate a bunch of asserts with a descriptive name
  it = desc: asserts: {
    it-desc = desc;
    inherit asserts;
  };

  # Run a bunch of its and check whether all asserts are yep.
  # If not, abort evaluation with `throw`
  # and print the result of the test suite.
  #
  # Takes a test suite name as first argument.
  runTestsuite = defun [ string (list ItResult) drv ]
    (name: itResults:
      let
        goodAss = ass: AssertResult.match ass {
          yep = _: true;
          nope = _: false;
        };
        res = partitionTests
          (it:
            (partitionTests goodAss it.asserts).err == [ ]
          )
          itResults;
        prettyRes = lib.generators.toPretty { } res;
      in
      if res.err == [ ]
      then
        depot.nix.runExecline.local "testsuite-${name}-successful" { } [
          "importas"
          "out"
          "out"
          # force derivation to rebuild if test case list changes
          "ifelse"
          [ bins.s6-false ]
          [
            bins.printf
            ""
            (builtins.hashString "sha512" prettyRes)
          ]
          "if"
          [ bins.printf "%s\n" "testsuite ${name} successful!" ]
          bins.s6-touch
          "$out"
        ]
      else
        depot.nix.runExecline.local "testsuite-${name}-failed"
          {
            stdin = prettyRes + "\n";
          } [
          "importas"
          "out"
          "out"
          "if"
          [ bins.printf "%s\n" "testsuite ${name} failed!" ]
          "if"
          [ bins.s6-cat ]
          "exit"
          "1"
        ]);

in
{
  inherit
    assertEq
    assertThrows
    assertDoesNotThrow
    it
    runTestsuite
    ;
}
