FileComparisonUtils.java

/*
 * *********************************************************************************************************************
 *
 * TheseFoolishThings: Miscellaneous utilities
 * http://tidalwave.it/projects/thesefoolishthings
 *
 * Copyright (C) 2009 - 2024 by Tidalwave s.a.s. (http://tidalwave.it)
 *
 * *********************************************************************************************************************
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
 * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations under the License.
 *
 * *********************************************************************************************************************
 *
 * git clone https://bitbucket.org/tidalwave/thesefoolishthings-src
 * git clone https://github.com/tidalwave-it/thesefoolishthings-src
 *
 * *********************************************************************************************************************
 */
package it.tidalwave.util.test;

import javax.annotation.Nonnegative;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import com.github.difflib.DiffUtils;
import com.github.difflib.patch.AbstractDelta;
import com.github.difflib.text.DiffRowGenerator;
import it.tidalwave.util.Pair;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import static java.util.stream.Collectors.*;
import static java.nio.charset.StandardCharsets.UTF_8;
import static it.tidalwave.util.Pair.indexedPairStream;

/***********************************************************************************************************************
 *
 * A utility class to compare two text files and assert that they have the same contents.
 *
 * @author  Fabrizio Giudici
 *
 **********************************************************************************************************************/
@UtilityClass @Slf4j
public class FileComparisonUtils
  {
    private static final String P_BASE_NAME = FileComparisonUtils.class.getName();

    public static final String P_TABULAR_OUTPUT = P_BASE_NAME + ".tabularOutput";
    public static final String P_TABULAR_LIMIT = P_BASE_NAME + ".tabularLimit";

    private static final boolean TABULAR_OUTPUT = Boolean.getBoolean(P_TABULAR_OUTPUT);
    private static final int TABULAR_LIMIT = Integer.getInteger(P_TABULAR_LIMIT, 500);
    private static final String TF = "TEST FAILED";

    /*******************************************************************************************************************
     *
     * Asserts that two files have the same contents.
     *
     * @param   expectedFile    the file with the expected contents
     * @param   actualFile      the file with the contents to probe
     * @throws  IOException     in case of error
     *
     ******************************************************************************************************************/
    public static void assertSameContents (@Nonnull final File expectedFile, @Nonnull final File actualFile)
      throws IOException
      {
        assertSameContents(expectedFile.toPath(), actualFile.toPath());
      }

    /*******************************************************************************************************************
     *
     * Asserts that two files have the same contents.
     *
     * @param   expectedPath    the file with the expected contents
     * @param   actualPath      the file with the contents to probe
     * @throws  IOException     in case of error
     *
     ******************************************************************************************************************/
    public static void assertSameContents (@Nonnull final Path expectedPath, @Nonnull final Path actualPath)
      throws IOException
      {
        log.info("******** Comparing files:");
        logPaths(expectedPath, actualPath, "");
        assertSameContents(fileToStrings(expectedPath), fileToStrings(actualPath), expectedPath, actualPath);
      }

    /*******************************************************************************************************************
     *
     * Asserts that two collections of strings have the same contents.
     *
     * @param   expected        the expected values
     * @param   actual          the actual values
     *
     ******************************************************************************************************************/
    public static void assertSameContents (@Nonnull final List<String> expected, @Nonnull final List<String> actual)
      {
        assertSameContents(expected, actual, null, null);
      }

    /*******************************************************************************************************************
     *
     * Checks whether two files have the same contents.
     *
     * @param   expectedPath    the file with the expected contents
     * @param   actualPath      the file with the contents to probe
     * @throws  IOException     in case of error
     * @since   1.2-ALPHA-15
     *
     ******************************************************************************************************************/
    public static boolean checkSameContents (@Nonnull final Path expectedPath, @Nonnull final Path actualPath)
            throws IOException
      {
        return checkSameContents(fileToStrings(expectedPath), fileToStrings(actualPath), expectedPath, actualPath)
                .isEmpty();
      }

    /*******************************************************************************************************************
     *
     * Converts a string which contains newlines into a list of strings.
     *
     * @param   string          the source
     * @return                  the strings
     * @throws  IOException     in case of error
     *
     ******************************************************************************************************************/
    @Nonnull
    public static List<String> stringToStrings (@Nonnull final String string)
      throws IOException
      {
        //return List.of(string.split("\n"));
        return resourceToStrings(new ByteArrayInputStream(string.getBytes(UTF_8)));
      }

    /*******************************************************************************************************************
     *
     * Reads a file into a list of strings.
     *
     * @param   file            the file
     * @return                  the strings
     * @throws  IOException     in case of error
     *
     ******************************************************************************************************************/
    @Nonnull
    public static List<String> fileToStrings (@Nonnull final Path file)
      throws IOException
      {
        return Files.readAllLines(file);
      }

    /*******************************************************************************************************************
     *
     * Reads a classpath resource (not a regular file) into a list of strings.
     *
     * @param   path            the path of the classpath resource
     * @return                  the strings
     * @throws  IOException     in case of error
     *
     ******************************************************************************************************************/
    @Nonnull
    public static List<String> resourceToStrings (@Nonnull final String path)
      throws IOException
      {
        final var is = FileComparisonUtils.class.getClassLoader().getResourceAsStream(path);

        if (is == null)
          {
            throw new RuntimeException("Resource not found: " + path);
          }

        return resourceToStrings(is);
      }

    /*******************************************************************************************************************
     *
     * Reads an input stream into a list of strings. The stream is closed at the end.
     *
     * @param   is              the input stream
     * @return                  the strings
     * @throws  IOException     in case of error
     *
     ******************************************************************************************************************/
    @Nonnull
    public static List<String> resourceToStrings (@Nonnull final InputStream is)
      throws IOException
      {
        try (final var br = new BufferedReader(new InputStreamReader(is, UTF_8)))
          {
            final var result = new ArrayList<String>();

            for (;;)
              {
                final var s = br.readLine();

                if (s == null)
                  {
                    break;
                  }

                result.add(s);
              }

            return result;
          }
      }

    /*******************************************************************************************************************
     *
     * Given a string that represents a path whose segments are separated by the standard separator of the platform,
     * returns the common prefix - which means the common directory parents.
     *
     * @param   s1    the former string
     * @param   s2    the latter string
     * @return        the common prefix
     *
     ******************************************************************************************************************/
    @Nonnull
    public static String commonPrefix (@Nonnull final String s1, @Nonnull final String s2)
      {
        final var min = Math.min(s1.length(), s2.length());
        var latestSeenSlash = 0;

        for (var i = 0; i < min; i++)
          {
            if (s1.charAt(i) != s2.charAt(i))
              {
                return (i == 0) ? "" : s1.substring(0, Math.min(latestSeenSlash + 1, min));
              }
            else
              {
                if (s1.charAt(i) == File.separatorChar)
                  {
                    latestSeenSlash = i;
                  }
              }
          }

        return s1.substring(0, min);
      }

    /*******************************************************************************************************************
     *
     * Asserts that two collections of strings have the same contents.
     *
     * @param   expected        the expected values
     * @param   actual          the actual values
     * @param   expectedPath    an optional path for expected values
     * @param   actualPath      an optional path for actual values
     *
     ******************************************************************************************************************/
    private static void assertSameContents (@Nonnull final List<String> expected,
                                            @Nonnull final List<String> actual,
                                            @Nullable final Path expectedPath,
                                            @Nullable final Path actualPath)
      {
        final var diff = checkSameContents(expected, actual, expectedPath, actualPath);

        if (!diff.isEmpty())
          {
            throw new AssertionError(String.join(System.lineSeparator(), diff));
          }
      }

    /*******************************************************************************************************************
     *
     * Checks whether two collections of strings have the same contents.
     *
     * @param   expected        the expected values
     * @param   actual          the actual values
     * @param   expectedPath    an optional path for expected values
     * @param   actualPath      an optional path for actual values
     * @return                  the differences
     *
     ******************************************************************************************************************/
    private static List<String> checkSameContents (@Nonnull final List<String> expected,
                                                   @Nonnull final List<String> actual,
                                                   @Nullable final Path expectedPath,
                                                   @Nullable final Path actualPath)
      {
        final var deltas = DiffUtils.diff(expected, actual).getDeltas();

        if (deltas.isEmpty())
          {
            return List.of();
          }

        if ((expectedPath != null) && (actualPath != null))
          {
            logPaths(expectedPath, actualPath, "TEST FAILED ");
          }

        final var strings = toStrings(deltas);
        strings.forEach(log::error);

        if (!TABULAR_OUTPUT)
          {
            log.error("{} You can set -D{}=true for tabular output; -D{}=<num> to set max table size",
                      TF, P_TABULAR_OUTPUT, P_TABULAR_LIMIT);
          }
        else
          {
            final var generator = DiffRowGenerator.create()
                                                  .showInlineDiffs(false)
                                                  .inlineDiffByWord(true)
                                                  .lineNormalizer(l -> l)
                                                  .build();
            final var pairs = generator.generateDiffRows(expected, actual)
                                       .stream()
                                       .filter(row -> !row.getNewLine().equals(row.getOldLine()))
                                       .map(row -> Pair.of(row.getOldLine().trim(), row.getNewLine().trim()))
                                       .limit(TABULAR_LIMIT)
                                       .collect(toList());

            final var padA = pairs.stream().mapToInt(p -> p.a.length()).max().getAsInt();
            final var padB = pairs.stream().mapToInt(p -> p.b.length()).max().getAsInt();
            log.error("{} Tabular text is trimmed; row limit set to -D{}={}", TF, P_TABULAR_LIMIT, TABULAR_LIMIT);
            log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
            log.error("{} | {} | {} |", TF, pad("expected", padA, ' '), pad("actual  ", padB, ' '));
            log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
            pairs.forEach(p -> log.error("{} | {} | {} |", TF, pad(p.a, padA, ' '), pad(p.b, padB,' ')));
            log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
          }

        strings.add(0, "Unexpected contents: see log above (you can grep '" + TF + "')");
        return strings;
      }

    /*******************************************************************************************************************
     *
     * Converts deltas to output as a list of strings.
     *
     * @param   deltas  the deltas
     * @return          the strings
     *
     ******************************************************************************************************************/
    @Nonnull
    private static List<String> toStrings (@Nonnull final Iterable<? extends AbstractDelta<String>> deltas)
      {
        final List<String> strings = new ArrayList<>();

        deltas.forEach(delta ->
          {
            final var sourceLines = delta.getSource().getLines();
            final var targetLines = delta.getTarget().getLines();
            final var sourcePosition = delta.getSource().getPosition() + 1;
            final var targetPosition = delta.getTarget().getPosition() + 1;

            switch (delta.getType())
              {
                case CHANGE:
                  indexedPairStream(sourceLines).forEach(p -> strings.add(
                          String.format("%s  exp[%d] *%s*", TF, sourcePosition + p.a, p.b)));
                  indexedPairStream(targetLines).forEach(p -> strings.add(
                          String.format("%s  act[%d] *%s*", TF, targetPosition + p.a, p.b)));
                  break;

                case DELETE:
                  indexedPairStream(sourceLines).forEach(p -> strings.add(
                          String.format("%s -act[%d] *%s*", TF, sourcePosition + p.a, p.b)));
                  break;

                case INSERT:
                  indexedPairStream(targetLines).forEach(p -> strings.add(
                          String.format("%s +act[%d] *%s*", TF, targetPosition + p.a, p.b)));
                  break;

                default:
              }
          });

        return strings;
      }

    /*******************************************************************************************************************
     *
     * Logs info about file comparison paths.
     *
     * @param expectedPath      the expected path
     * @param actualPath        the actual path
     * @param prefix            a log prefix
     *
     ******************************************************************************************************************/
    private static void logPaths (@Nonnull final Path expectedPath,
                                  @Nonnull final Path actualPath,
                                  @Nonnull final String prefix)
      {
        final var expectedPathAsString = expectedPath.toAbsolutePath().toString();
        final var actualPathAsString = actualPath.toAbsolutePath().toString();
        final var commonPath = commonPrefix(expectedPathAsString, actualPathAsString);
        log.info("{}>>>> path is: {}", prefix, commonPath);
        log.info("{}>>>> exp is:  {}", prefix, expectedPathAsString.substring(commonPath.length()));
        log.info("{}>>>> act is:  {}", prefix, actualPathAsString.substring(commonPath.length()));
      }

    /*******************************************************************************************************************
     *
     * Pads a string to left to fit the given width.
     *
     * @param   string    the string
     * @param   width     the width
     * @return            the padded string
     *
     ******************************************************************************************************************/
    @Nonnull
    private static String pad (@Nonnull final String string, @Nonnegative final int width, final char padding)
      {
        return String.format("%-" + width + "s", string).replace(' ', padding);
      }
  }