001/*
002 * Copyright (C) 2022 - 2023, 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.Objects;
032import java.util.Optional;
033import java.util.Set;
034import java.util.stream.Collectors;
035import java.util.stream.StreamSupport;
036import org.apiguardian.api.API;
037import org.apiguardian.api.API.Status;
038import org.assertj.core.api.AbstractPathAssert;
039import org.jspecify.annotations.Nullable;
040
041/**
042 * Assertions for package container groups.
043 *
044 * @author Ashley Scopes
045 * @since 0.0.1
046 */
047@API(since = "0.0.1", status = Status.STABLE)
048public final class PackageContainerGroupAssert
049    extends AbstractContainerGroupAssert<PackageContainerGroupAssert, PackageContainerGroup> {
050
051  /**
052   * Initialize the container group assertions.
053   *
054   * @param containerGroup the container group to assert upon.
055   */
056  public PackageContainerGroupAssert(@Nullable PackageContainerGroup containerGroup) {
057    super(containerGroup, PackageContainerGroupAssert.class);
058  }
059
060  /**
061   * Assert that all given files exist.
062   *
063   * @param path  the first path to check for
064   * @param paths additional paths to check for.
065   * @return this object for further call chaining.
066   * @throws AssertionError       if the container group is null, or if any of the files do not
067   *                              exist.
068   * @throws NullPointerException if any of the paths are null.
069   */
070  public PackageContainerGroupAssert allFilesExist(String path, String... paths) {
071    requireNonNull(path, "path");
072    requireNonNullValues(paths, "paths");
073
074    allFilesExist(combineOneOrMore(path, paths));
075    return this;
076  }
077
078  /**
079   * Assert that all given files exist.
080   *
081   * @param paths paths to check for.
082   * @return this object for further call chaining.
083   * @throws AssertionError       if the container group is null, or if any of the files do not
084   *                              exist.
085   * @throws NullPointerException if any of the paths are null.
086   */
087  public PackageContainerGroupAssert allFilesExist(Iterable<String> paths) {
088    requireNonNullValues(paths, "paths");
089
090    isNotNull();
091
092    assertThat(paths)
093        .withFailMessage(
094            "Expected all paths in %s to exist but one or more did not",
095            quotedIterable(paths)
096        )
097        .allSatisfy(this::fileExists);
098    return this;
099  }
100
101  /**
102   * Get assertions to perform on the class loader associated with this container group.
103   *
104   * @return the assertions to perform.
105   * @throws AssertionError if the container group is null.
106   */
107  public ClassLoaderAssert classLoader() {
108    isNotNull();
109
110    return new ClassLoaderAssert(actual.getClassLoader());
111  }
112
113  /**
114   * Assert that the given file does not exist.
115   *
116   * <pre><code>
117   *   // Using platform-specific separators.
118   *   assertions.fileDoesNotExist("foo/bar/baz.txt")...;
119   *
120   *   // Letting JCT infer the correct path separators to use (recommended).
121   *   assertions.fileDoesNotExist("foo", "bar", "baz.txt");
122   * </code></pre>
123   *
124   * @param fragment  the first part of the path.
125   * @param fragments any additional parts of the path.
126   * @return this assertion object for further assertions.
127   * @throws AssertionError       if the file exists, or if the container group is null.
128   * @throws NullPointerException if any of the fragments are null.
129   */
130  public PackageContainerGroupAssert fileDoesNotExist(String fragment, String... fragments) {
131    requireNonNull(fragment, "fragment");
132    requireNonNullValues(fragments, "fragments");
133
134    isNotNull();
135
136    var actualFile = actual.getFile(fragment, fragments);
137
138    if (actualFile == null) {
139      return this;
140    }
141
142    throw failure(
143        "Expected path %s to not exist in %s but it was found at %s",
144        quotedUserProvidedPath(combineOneOrMore(fragment, fragments)),
145        LocationRepresentation.getInstance().toStringOf(actual.getLocation()),
146        quoted(actualFile)
147    );
148  }
149
150  /**
151   * Assert that the given file exists.
152   *
153   * <pre><code>
154   *   // Letting JCT infer the correct path separators to use (recommended).
155   *   assertions.fileExists("foo", "bar", "baz.txt");
156   *
157   *   // Using platform-specific separators (more likely to produce unexpected results).
158   *   assertions.fileExists("foo/bar/baz.txt")...;
159   * </code></pre>
160   *
161   * <p>If the file does not exist, then this object will attempt to find the
162   * closest matches and list them in an error message along with the assertion error.
163   *
164   * @param fragment  the first part of the path.
165   * @param fragments any additional parts of the path.
166   * @return assertions to perform on the path of the file that exists.
167   * @throws AssertionError       if the file does not exist, or if the container group is null.
168   * @throws NullPointerException if any of the fragments are null.
169   */
170
171  public AbstractPathAssert<?> fileExists(String fragment, String... fragments) {
172    requireNonNull(fragment, "fragment");
173    requireNonNullValues(fragments, "fragments");
174
175    isNotNull();
176
177    var actualFile = actual.getFile(fragment, fragments);
178
179    if (actualFile != null) {
180      // File exists with this path. Hooray, lets return assertions on it.
181      return assertThat(actualFile);
182    }
183
184    var expected = combineOneOrMore(fragment, fragments);
185    var message = StringUtils.resultNotFoundWithFuzzySuggestions(
186        fuzzySafePath(expected),
187        quotedUserProvidedPath(expected),
188        listAllUniqueFilesForAllContainers(),
189        this::fuzzySafePath,
190        this::quotedUserProvidedPath,
191        "file with relative path"
192    );
193    throw failure(message);
194  }
195
196  private Set<Path> listAllUniqueFilesForAllContainers() {
197    return uncheckedIo(actual::listAllFiles)
198        .keySet()
199        .stream()
200        // Make all the files relative to their roots.
201        .flatMap(container -> uncheckedIo(container::listAllFiles)
202            .stream()
203            .map(container.getInnerPathRoot().getPath()::relativize))
204        // Filter out the inner path root itself, preventing few confusing issues with zero-length
205        // file names. In ZIP paths this can also be null, so we have to check for both "" and null
206        // here.
207        .filter(this::fileNameIsPresent)
208        // Remove duplicates (don't think this can ever happen but this is just to be safe).
209        .collect(Collectors.toSet());
210  }
211
212  private <T> String quotedUserProvidedPath(Iterable<T> parts) {
213    return StreamSupport
214        .stream(parts.spliterator(), false)
215        .map(Objects::toString)
216        .collect(Collectors.collectingAndThen(
217            Collectors.joining("/"),
218            StringUtils::quoted
219        ));
220  }
221
222  private <T> String fuzzySafePath(Iterable<T> parts) {
223    // Join on null bytes as we don't ever use those in normal path names. This way, we ignore
224    // file-system and OS-specific path separators creating ambiguities (like how Windows uses
225    // backslashes rather than forward slashes to delimit paths).
226    return StreamSupport
227        .stream(parts.spliterator(), false)
228        .map(Objects::toString)
229        .collect(Collectors.joining("\0"));
230  }
231
232  private boolean fileNameIsPresent(@Nullable Path path) {
233    // Path can be null if no path elements exist in ZipPath impls.
234    return Optional
235        .ofNullable(path)
236        .map(Path::getFileName)
237        .map(Path::toString)
238        .filter(not(String::isBlank))
239        .isPresent();
240  }
241}