001/*
002 * Copyright (C) 2022 - 2024, the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *    http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package io.github.ascopes.jct.assertions;
017
018import static io.github.ascopes.jct.utils.IoExceptionUtils.uncheckedIo;
019import static io.github.ascopes.jct.utils.IterableUtils.requireAtLeastOne;
020import static io.github.ascopes.jct.utils.IterableUtils.requireNonNullValues;
021import static io.github.ascopes.jct.utils.StringUtils.quoted;
022import static io.github.ascopes.jct.utils.StringUtils.quotedIterable;
023import static java.util.function.Predicate.not;
024import static org.assertj.core.api.Assertions.assertThat;
025
026import io.github.ascopes.jct.containers.PackageContainerGroup;
027import io.github.ascopes.jct.repr.LocationRepresentation;
028import io.github.ascopes.jct.utils.StringUtils;
029import java.nio.file.Path;
030import java.util.ArrayList;
031import java.util.List;
032import java.util.Objects;
033import java.util.Optional;
034import java.util.Set;
035import java.util.stream.Collectors;
036import java.util.stream.StreamSupport;
037import org.apiguardian.api.API;
038import org.apiguardian.api.API.Status;
039import org.assertj.core.api.AbstractPathAssert;
040import org.assertj.core.description.TextDescription;
041import org.assertj.core.error.MultipleAssertionsError;
042import org.jspecify.annotations.Nullable;
043
044/**
045 * Assertions for package container groups.
046 *
047 * @author Ashley Scopes
048 * @since 0.0.1
049 */
050@API(since = "0.0.1", status = Status.STABLE)
051public final class PackageContainerGroupAssert
052    extends AbstractContainerGroupAssert<PackageContainerGroupAssert, PackageContainerGroup> {
053
054  /**
055   * Initialize the container group assertions.
056   *
057   * @param containerGroup the container group to assert upon.
058   */
059  public PackageContainerGroupAssert(@Nullable PackageContainerGroup containerGroup) {
060    super(containerGroup, PackageContainerGroupAssert.class);
061  }
062
063  /**
064   * Assert that all given files exist.
065   *
066   * @param paths paths to check for.
067   * @return this object for further call chaining.
068   * @throws AssertionError           if the container group is null, or if any of the files do not
069   *                                  exist.
070   * @throws NullPointerException     if any of the paths are null.
071   * @throws IllegalArgumentException if no fragments are provided.
072   */
073  public PackageContainerGroupAssert allFilesExist(String... paths) {
074    requireNonNullValues(paths, "paths");
075    requireAtLeastOne(paths, "paths");
076
077    allFilesExist(List.of(paths));
078    return this;
079  }
080
081  /**
082   * Assert that all given files exist.
083   *
084   * @param paths paths to check for.
085   * @return this object for further call chaining.
086   * @throws AssertionError       if the container group is null, or if any of the files do not
087   *                              exist.
088   * @throws NullPointerException if any of the paths are null.
089   */
090  public PackageContainerGroupAssert allFilesExist(Iterable<String> paths) {
091    requireNonNullValues(paths, "paths");
092
093    isNotNull();
094
095    var errors = new ArrayList<AssertionError>();
096
097    for (var path : paths) {
098      try {
099        fileExists(path);
100      } catch (AssertionError ex) {
101        errors.add(ex);
102      }
103    }
104
105    if (errors.isEmpty()) {
106      return this;
107    }
108
109    throw new MultipleAssertionsError(
110        new TextDescription(
111            "Expected all paths in %s to exist but one or more did not",
112            quotedIterable(paths)
113        ),
114        errors
115    );
116  }
117
118  /**
119   * Get assertions to perform on the class loader associated with this container group.
120   *
121   * @return the assertions to perform.
122   * @throws AssertionError if the container group is null.
123   */
124  public ClassLoaderAssert classLoader() {
125    isNotNull();
126
127    return new ClassLoaderAssert(actual.getClassLoader());
128  }
129
130  /**
131   * Assert that the given file does not exist.
132   *
133   * <pre><code>
134   *   // Using platform-specific separators.
135   *   assertions.fileDoesNotExist("foo/bar/baz.txt")...;
136   *
137   *   // Letting JCT infer the correct path separators to use (recommended).
138   *   assertions.fileDoesNotExist("foo", "bar", "baz.txt");
139   * </code></pre>
140   *
141   * @param fragments any additional parts of the path.
142   * @return this assertion object for further assertions.
143   * @throws AssertionError           if the file exists, or if the container group is null.
144   * @throws NullPointerException     if any of the fragments are null.
145   * @throws IllegalArgumentException if no fragments are provided.
146   */
147  public PackageContainerGroupAssert fileDoesNotExist(String... fragments) {
148    requireNonNullValues(fragments, "fragments");
149    requireAtLeastOne(fragments, "fragments");
150
151    isNotNull();
152
153    var actualFile = actual.getFile(fragments);
154
155    if (actualFile == null) {
156      return this;
157    }
158
159    throw failure(
160        "Expected path %s to not exist in %s but it was found at %s",
161        quotedUserProvidedPath(List.of(fragments)),
162        LocationRepresentation.getInstance().toStringOf(actual.getLocation()),
163        quoted(actualFile)
164    );
165  }
166
167  /**
168   * Assert that the given file exists.
169   *
170   * <pre><code>
171   *   // Letting JCT infer the correct path separators to use (recommended).
172   *   assertions.fileExists("foo", "bar", "baz.txt");
173   *
174   *   // Using platform-specific separators (more likely to produce unexpected results).
175   *   assertions.fileExists("foo/bar/baz.txt")...;
176   * </code></pre>
177   *
178   * <p>If the file does not exist, then this object will attempt to find the
179   * closest matches and list them in an error message along with the assertion error.
180   *
181   * @param fragments parts of the path.
182   * @return assertions to perform on the path of the file that exists.
183   * @throws AssertionError           if the file does not exist, or if the container group is
184   *                                  null.
185   * @throws NullPointerException     if any of the fragments are null.
186   * @throws IllegalArgumentException if no fragments are provided.
187   */
188
189  public AbstractPathAssert<?> fileExists(String... fragments) {
190    requireNonNullValues(fragments, "fragments");
191    requireAtLeastOne(fragments, "fragments");
192
193    isNotNull();
194
195    var actualFile = actual.getFile(fragments);
196
197    if (actualFile != null) {
198      // File exists with this path. Hooray, lets return assertions on it.
199      return assertThat(actualFile);
200    }
201
202    var expected = List.of(fragments);
203    var message = StringUtils.resultNotFoundWithFuzzySuggestions(
204        fuzzySafePath(expected),
205        quotedUserProvidedPath(expected),
206        listAllUniqueFilesForAllContainers(),
207        this::fuzzySafePath,
208        this::quotedUserProvidedPath,
209        "file with relative path"
210    );
211
212    throw failure(message);
213  }
214
215  private Set<Path> listAllUniqueFilesForAllContainers() {
216    return uncheckedIo(actual::listAllFiles)
217        .keySet()
218        .stream()
219        // Make all the files relative to their roots.
220        .flatMap(container -> uncheckedIo(container::listAllFiles)
221            .stream()
222            .map(container.getInnerPathRoot().getPath()::relativize))
223        // Filter out the inner path root itself, preventing few confusing issues with zero-length
224        // file names. In ZIP paths this can also be null, so we have to check for both "" and null
225        // here.
226        .filter(this::fileNameIsPresent)
227        // Remove duplicates (don't think this can ever happen but this is just to be safe).
228        .collect(Collectors.toSet());
229  }
230
231  private <T> String quotedUserProvidedPath(Iterable<T> parts) {
232    return StreamSupport
233        .stream(parts.spliterator(), false)
234        .map(Objects::toString)
235        .collect(Collectors.collectingAndThen(
236            Collectors.joining("/"),
237            StringUtils::quoted
238        ));
239  }
240
241  private <T> String fuzzySafePath(Iterable<T> parts) {
242    // Join on null bytes as we don't ever use those in normal path names. This way, we ignore
243    // file-system and OS-specific path separators creating ambiguities (like how Windows uses
244    // backslashes rather than forward slashes to delimit paths).
245    return StreamSupport
246        .stream(parts.spliterator(), false)
247        .map(Objects::toString)
248        .collect(Collectors.joining("\0"));
249  }
250
251  private boolean fileNameIsPresent(@Nullable Path path) {
252    // Path can be null if no path elements exist in ZipPath implementations.
253    return Optional
254        .ofNullable(path)
255        .map(Path::getFileName)
256        .map(Path::toString)
257        .filter(not(String::isBlank))
258        .isPresent();
259  }
260}