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