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.combineOneOrMore;
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.Objects.requireNonNull;
024import static java.util.function.Predicate.not;
025import static org.assertj.core.api.Assertions.assertThat;
026
027import io.github.ascopes.jct.containers.PackageContainerGroup;
028import io.github.ascopes.jct.repr.LocationRepresentation;
029import io.github.ascopes.jct.utils.StringUtils;
030import java.nio.file.Path;
031import java.util.ArrayList;
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 path  the first path to check for
067   * @param paths additional paths to check for.
068   * @return this object for further call chaining.
069   * @throws AssertionError       if the container group is null, or if any of the files do not
070   *                              exist.
071   * @throws NullPointerException if any of the paths are null.
072   */
073  public PackageContainerGroupAssert allFilesExist(String path, String... paths) {
074    requireNonNull(path, "path");
075    requireNonNullValues(paths, "paths");
076
077    allFilesExist(combineOneOrMore(path, 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 fragment  the first part of the path.
142   * @param fragments any additional parts of the path.
143   * @return this assertion object for further assertions.
144   * @throws AssertionError       if the file exists, or if the container group is null.
145   * @throws NullPointerException if any of the fragments are null.
146   */
147  public PackageContainerGroupAssert fileDoesNotExist(String fragment, String... fragments) {
148    requireNonNull(fragment, "fragment");
149    requireNonNullValues(fragments, "fragments");
150
151    isNotNull();
152
153    var actualFile = actual.getFile(fragment, 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(combineOneOrMore(fragment, 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 fragment  the first part of the path.
182   * @param fragments any additional parts of the path.
183   * @return assertions to perform on the path of the file that exists.
184   * @throws AssertionError       if the file does not exist, or if the container group is null.
185   * @throws NullPointerException if any of the fragments are null.
186   */
187
188  public AbstractPathAssert<?> fileExists(String fragment, String... fragments) {
189    requireNonNull(fragment, "fragment");
190    requireNonNullValues(fragments, "fragments");
191
192    isNotNull();
193
194    var actualFile = actual.getFile(fragment, fragments);
195
196    if (actualFile != null) {
197      // File exists with this path. Hooray, lets return assertions on it.
198      return assertThat(actualFile);
199    }
200
201    var expected = combineOneOrMore(fragment, fragments);
202    var message = StringUtils.resultNotFoundWithFuzzySuggestions(
203        fuzzySafePath(expected),
204        quotedUserProvidedPath(expected),
205        listAllUniqueFilesForAllContainers(),
206        this::fuzzySafePath,
207        this::quotedUserProvidedPath,
208        "file with relative path"
209    );
210
211    throw failure(message);
212  }
213
214  private Set<Path> listAllUniqueFilesForAllContainers() {
215    return uncheckedIo(actual::listAllFiles)
216        .keySet()
217        .stream()
218        // Make all the files relative to their roots.
219        .flatMap(container -> uncheckedIo(container::listAllFiles)
220            .stream()
221            .map(container.getInnerPathRoot().getPath()::relativize))
222        // Filter out the inner path root itself, preventing few confusing issues with zero-length
223        // file names. In ZIP paths this can also be null, so we have to check for both "" and null
224        // here.
225        .filter(this::fileNameIsPresent)
226        // Remove duplicates (don't think this can ever happen but this is just to be safe).
227        .collect(Collectors.toSet());
228  }
229
230  private <T> String quotedUserProvidedPath(Iterable<T> parts) {
231    return StreamSupport
232        .stream(parts.spliterator(), false)
233        .map(Objects::toString)
234        .collect(Collectors.collectingAndThen(
235            Collectors.joining("/"),
236            StringUtils::quoted
237        ));
238  }
239
240  private <T> String fuzzySafePath(Iterable<T> parts) {
241    // Join on null bytes as we don't ever use those in normal path names. This way, we ignore
242    // file-system and OS-specific path separators creating ambiguities (like how Windows uses
243    // backslashes rather than forward slashes to delimit paths).
244    return StreamSupport
245        .stream(parts.spliterator(), false)
246        .map(Objects::toString)
247        .collect(Collectors.joining("\0"));
248  }
249
250  private boolean fileNameIsPresent(@Nullable Path path) {
251    // Path can be null if no path elements exist in ZipPath implementations.
252    return Optional
253        .ofNullable(path)
254        .map(Path::getFileName)
255        .map(Path::toString)
256        .filter(not(String::isBlank))
257        .isPresent();
258  }
259}