/*
 * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License").
 * You may not use this file except in compliance with the License.
 * A copy of the License is located at
 *
 *  http://aws.amazon.com/apache2.0
 *
 * or in the "license" file accompanying this file. This file 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.
 */

package software.amazon.smithy.build;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.File;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import software.amazon.smithy.build.model.ProjectionConfig;
import software.amazon.smithy.build.model.SmithyBuildConfig;
import software.amazon.smithy.build.model.TransformConfig;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.node.Node;
import software.amazon.smithy.model.node.ObjectNode;
import software.amazon.smithy.model.shapes.ShapeId;
import software.amazon.smithy.model.traits.DocumentationTrait;
import software.amazon.smithy.model.traits.SensitiveTrait;
import software.amazon.smithy.model.traits.TagsTrait;
import software.amazon.smithy.utils.IoUtils;
import software.amazon.smithy.utils.ListUtils;
import software.amazon.smithy.utils.MapUtils;
import software.amazon.smithy.utils.OptionalUtils;

public class SmithyBuildTest {
    private Path outputDirectory;

    @BeforeEach
    public void before() throws IOException {
        outputDirectory = Files.createTempDirectory(getClass().getName());
    }

    @AfterEach
    public void after() throws IOException {
        Files.walk(outputDirectory).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
    }

    @Test
    public void loadsEmptyObject() throws Exception {
        SmithyBuildConfig.load(Paths.get(getClass().getResource("empty-config.json").toURI()));
    }

    @Test
    public void throwsForUnknownTransform() throws Exception {
        Assertions.assertThrows(UnknownTransformException.class, () -> {
            SmithyBuildConfig config = SmithyBuildConfig
                    .load(Paths.get(getClass().getResource("unknown-transform.json").toURI()));
            new SmithyBuild().config(config).build();
        });
    }

    @Test
    public void appliesAllProjections() throws Exception {
        SmithyBuildConfig config = SmithyBuildConfig.builder()
                .load(Paths.get(getClass().getResource("simple-config.json").toURI()))
                .outputDirectory(outputDirectory.toString())
                .build();
        Model model = Model.assembler()
                .addImport(Paths.get(getClass().getResource("simple-model.json").toURI()))
                .assemble()
                .unwrap();
        SmithyBuild builder = new SmithyBuild().config(config).model(model);
        SmithyBuildResult results = builder.build();
        Model resultA = results.getProjectionResult("a").get().getModel();
        Model resultB = results.getProjectionResult("b").get().getModel();

        assertThat(resultA.getShape(ShapeId.from("ns.foo#String1")), not(Optional.empty()));
        assertThat(resultA.getShape(ShapeId.from("ns.foo#String2")), is(Optional.empty()));
        assertThat(resultA.getShape(ShapeId.from("ns.foo#String3")), not(Optional.empty()));

        assertThat(resultB.getShape(ShapeId.from("ns.foo#String1")), not(Optional.empty()));
        assertThat(resultB.getShape(ShapeId.from("ns.foo#String2")), not(Optional.empty()));
        assertThat(resultB.getShape(ShapeId.from("ns.foo#String3")), not(Optional.empty()));
    }

    @Test
    public void buildsModels() throws Exception {
        SmithyBuildConfig config = SmithyBuildConfig.builder()
                .load(Paths.get(getClass().getResource("simple-config.json").toURI()))
                .outputDirectory(outputDirectory.toString())
                .build();
        Model model = Model.assembler()
                .addImport(Paths.get(getClass().getResource("simple-model.json").toURI()))
                .assemble()
                .unwrap();
        SmithyBuild builder = new SmithyBuild().config(config).model(model);
        builder.build();

        assertThat(Files.isDirectory(outputDirectory.resolve("source")), is(true));
        assertThat(Files.isRegularFile(outputDirectory.resolve("source/model/model.json")), is(true));
        assertThat(Files.isRegularFile(outputDirectory.resolve("source/build-info/smithy-build-info.json")), is(true));

        assertThat(Files.isDirectory(outputDirectory.resolve("a")), is(true));
        assertThat(Files.isRegularFile(outputDirectory.resolve("a/build-info/smithy-build-info.json")), is(true));
        assertThat(Files.isRegularFile(outputDirectory.resolve("a/model/model.json")), is(true));

        assertThat(Files.isDirectory(outputDirectory.resolve("b")), is(true));
        assertThat(Files.isRegularFile(outputDirectory.resolve("b/build-info/smithy-build-info.json")), is(true));
        assertThat(Files.isRegularFile(outputDirectory.resolve("b/model/model.json")), is(true));
    }

    @Test
    public void doesNotCopyErroneousModelsToBuildOutput() throws Exception {
        SmithyBuildConfig config = SmithyBuildConfig.builder()
                .load(Paths.get(getClass().getResource("resource-model-config.json").toURI()))
                .outputDirectory(outputDirectory.toString())
                .build();
        Model model = Model.assembler()
                .addImport(Paths.get(getClass().getResource("resource-model.json").toURI()))
                .assemble()
                .unwrap();
        SmithyBuild builder = new SmithyBuild(config).model(model);
        builder.build();

        assertThat(Files.isDirectory(outputDirectory.resolve("source")), is(true));
        assertThat(Files.isRegularFile(outputDirectory.resolve("source/build-info/smithy-build-info.json")), is(true));
        assertThat(Files.isRegularFile(outputDirectory.resolve("source/model/model.json")), is(true));

        assertThat(Files.isDirectory(outputDirectory.resolve("valid")), is(true));
        assertThat(Files.isRegularFile(outputDirectory.resolve("valid/build-info/smithy-build-info.json")), is(true));
        assertThat(Files.isRegularFile(outputDirectory.resolve("valid/model/model.json")), is(true));

        assertThat(Files.isDirectory(outputDirectory.resolve("invalid")), is(true));
        assertThat(Files.isDirectory(outputDirectory.resolve("invalid/model")), is(false));
        assertThat(Files.isDirectory(outputDirectory.resolve("invalid/build-info")), is(true));

        String contents = IoUtils.readUtf8File(outputDirectory.resolve("source/build-info/smithy-build-info.json"));
        ObjectNode badBuildInfo = Node.parse(contents).expectObjectNode();
        assertTrue(badBuildInfo.expectMember("version").isStringNode());
        assertThat(badBuildInfo.expectMember("projectionName").expectStringNode().getValue(), equalTo("source"));
        badBuildInfo.expectMember("validationEvents").expectArrayNode();
    }

    @Test
    public void ignoresUnknownPlugins() throws Exception {
        SmithyBuildConfig config = SmithyBuildConfig.builder()
                .load(Paths.get(getClass().getResource("unknown-plugin.json").toURI()))
                .outputDirectory(outputDirectory.toString())
                .build();
        Model model = Model.assembler()
                .addImport(Paths.get(getClass().getResource("resource-model.json").toURI()))
                .assemble()
                .unwrap();
        SmithyBuild builder = new SmithyBuild(config).model(model);
        builder.build();
    }

    @Test
    public void cannotSetFiltersOrMappersOnSourceProjection() {
        Throwable thrown = Assertions.assertThrows(SmithyBuildException.class, () -> {
            SmithyBuildConfig config = SmithyBuildConfig.builder()
                    .version(SmithyBuild.VERSION)
                    .projections(MapUtils.of("source", ProjectionConfig.builder()
                            .transforms(ListUtils.of(TransformConfig.builder().name("foo").build()))
                            .build()))
                    .build();
            new SmithyBuild().config(config).build();
        });

        assertThat(thrown.getMessage(), containsString("The source projection cannot contain any transforms"));
    }

    @Test
    public void loadsImports() throws Exception {
        SmithyBuildConfig config = SmithyBuildConfig.builder()
                .load(Paths.get(getClass().getResource("imports/smithy-build.json").toURI()))
                .load(Paths.get(getClass().getResource("otherimports/smithy-build.json").toURI()))
                .outputDirectory(outputDirectory.toString())
                .build();
        SmithyBuild builder = new SmithyBuild(config);
        SmithyBuildResult results = builder.build();

        Model resultA = results.getProjectionResult("source").get().getModel();
        Model resultB = results.getProjectionResult("b").get().getModel();
        Model resultC = results.getProjectionResult("c").get().getModel();

        assertTrue(resultA.getShape(ShapeId.from("com.foo#String")).get()
                           .getTrait(SensitiveTrait.class).isPresent());
        assertFalse(resultA.getShape(ShapeId.from("com.foo#String")).get()
                           .getTrait(DocumentationTrait.class).isPresent());
        assertTrue(resultA.getShape(ShapeId.from("com.foo#String")).get()
                .getTrait(TagsTrait.class).isPresent());
        assertThat(resultA.getShape(ShapeId.from("com.foo#String")).get()
                .getTrait(TagsTrait.class).get().getValues().get(0), equalTo("multi-import"));

        assertTrue(resultB.getShape(ShapeId.from("com.foo#String")).get()
                           .getTrait(SensitiveTrait.class).isPresent());
        assertTrue(resultB.getShape(ShapeId.from("com.foo#String")).get()
                           .getTrait(DocumentationTrait.class).isPresent());
        assertThat(resultB.getShape(ShapeId.from("com.foo#String")).get()
                           .getTrait(DocumentationTrait.class).get().getValue(), equalTo("b.json"));
        assertTrue(resultB.getShape(ShapeId.from("com.foo#String")).get()
                .getTrait(TagsTrait.class).isPresent());
        assertThat(resultB.getShape(ShapeId.from("com.foo#String")).get()
                .getTrait(TagsTrait.class).get().getValues().get(0), equalTo("multi-import"));

        assertTrue(resultC.getShape(ShapeId.from("com.foo#String")).get()
                           .getTrait(SensitiveTrait.class).isPresent());
        assertTrue(resultC.getShape(ShapeId.from("com.foo#String")).get()
                           .getTrait(DocumentationTrait.class).isPresent());
        assertThat(resultC.getShape(ShapeId.from("com.foo#String")).get()
                           .getTrait(DocumentationTrait.class).get().getValue(), equalTo("c.json"));
        assertTrue(resultC.getShape(ShapeId.from("com.foo#String")).get()
                .getTrait(TagsTrait.class).isPresent());
        assertThat(resultC.getShape(ShapeId.from("com.foo#String")).get()
                .getTrait(TagsTrait.class).get().getValues().get(0), equalTo("multi-import"));
    }

    @Test
    public void appliesPlugins() throws Exception {
        Map<String, SmithyBuildPlugin> plugins = MapUtils.of("test1", new Test1Plugin(), "test2", new Test2Plugin());
        Function<String, Optional<SmithyBuildPlugin>> factory = SmithyBuildPlugin.createServiceFactory();
        Function<String, Optional<SmithyBuildPlugin>> composed = name -> OptionalUtils.or(
                Optional.ofNullable(plugins.get(name)), () -> factory.apply(name));

        SmithyBuild builder = new SmithyBuild().pluginFactory(composed);
        builder.fileManifestFactory(MockManifest::new);
        builder.config(SmithyBuildConfig.builder()
                .load(Paths.get(getClass().getResource("applies-plugins.json").toURI()))
                .outputDirectory("/foo")
                .build());

        SmithyBuildResult results = builder.build();
        ProjectionResult source = results.getProjectionResult("source").get();
        ProjectionResult a = results.getProjectionResult("a").get();
        ProjectionResult b = results.getProjectionResult("b").get();

        assertTrue(source.getPluginManifest("test1").isPresent());
        assertTrue(a.getPluginManifest("test1").isPresent());
        assertTrue(b.getPluginManifest("test1").isPresent());

        assertTrue(source.getPluginManifest("test1").get().hasFile("hello1"));
        assertTrue(a.getPluginManifest("test1").get().hasFile("hello1"));
        assertTrue(b.getPluginManifest("test1").get().hasFile("hello1"));

        assertTrue(source.getPluginManifest("test2").isPresent());
        assertTrue(a.getPluginManifest("test2").isPresent());
        assertTrue(b.getPluginManifest("test2").isPresent());

        assertTrue(source.getPluginManifest("test2").get().hasFile("hello2"));
        assertTrue(a.getPluginManifest("test2").get().hasFile("hello2"));
        assertTrue(b.getPluginManifest("test2").get().hasFile("hello2"));
    }

    @Test
    public void appliesSerialPlugins() throws Exception {
        Map<String, SmithyBuildPlugin> plugins = MapUtils.of(
                "test1Serial", new Test1SerialPlugin(),
                "test2Serial", new Test2SerialPlugin(),
                "test1Parallel", new Test1ParallelPlugin(),
                "test2Parallel", new Test2ParallelPlugin()
        );
        Function<String, Optional<SmithyBuildPlugin>> factory = SmithyBuildPlugin.createServiceFactory();
        Function<String, Optional<SmithyBuildPlugin>> composed = name -> OptionalUtils.or(
                Optional.ofNullable(plugins.get(name)), () -> factory.apply(name));

        SmithyBuild builder = new SmithyBuild().pluginFactory(composed);
        builder.fileManifestFactory(MockManifest::new);
        builder.config(SmithyBuildConfig.builder()
                .load(Paths.get(getClass().getResource("applies-serial-plugins.json").toURI()))
                .outputDirectory("/foo")
                .build());

        SmithyBuildResult results = builder.build();
        ProjectionResult source = results.getProjectionResult("source").get();
        ProjectionResult a = results.getProjectionResult("a").get();
        ProjectionResult b = results.getProjectionResult("b").get();

        assertPluginPresent("test1Serial", "hello1Serial", source, a);
        assertPluginPresent("test2Serial", "hello2Serial", a);
        assertPluginPresent("test1Parallel", "hello1Parallel", source, b);
        assertPluginPresent("test2Parallel", "hello2Parallel", source);

        // Both the "a" and "source" projections have serial plugins, so they are run in serial, in alphabetical order.
        assertTrue(getPluginFileContents(a, "test1Serial") < getPluginFileContents(source, "test1Serial"));
        // The "b" projection has only parallel plugins, so it's a parallel projection. Parallel projections are run
        // after all the serial projections.
        assertTrue(getPluginFileContents(source, "test1Serial") < getPluginFileContents(b, "test1Parallel"));
    }

    @Test
    public void appliesGlobalSerialPlugins() throws Exception {
        Map<String, SmithyBuildPlugin> plugins = MapUtils.of(
                "test1Serial", new Test1SerialPlugin(),
                "test1Parallel", new Test1ParallelPlugin(),
                "test2Parallel", new Test2ParallelPlugin()
        );
        Function<String, Optional<SmithyBuildPlugin>> factory = SmithyBuildPlugin.createServiceFactory();
        Function<String, Optional<SmithyBuildPlugin>> composed = name -> OptionalUtils.or(
                Optional.ofNullable(plugins.get(name)), () -> factory.apply(name));

        SmithyBuild builder = new SmithyBuild().pluginFactory(composed);
        builder.fileManifestFactory(MockManifest::new);
        builder.config(SmithyBuildConfig.builder()
                .load(Paths.get(getClass().getResource("applies-global-serial-plugins.json").toURI()))
                .outputDirectory("/foo")
                .build());

        SmithyBuildResult results = builder.build();
        ProjectionResult a = results.getProjectionResult("a").get();
        ProjectionResult b = results.getProjectionResult("b").get();

        assertPluginPresent("test1Serial", "hello1Serial", a, b);
        assertPluginPresent("test1Parallel", "hello1Parallel", a);
        assertPluginPresent("test2Parallel", "hello2Parallel", b);

        // The order of execution should be: test1Parallel (a), test1Serial (a), test1Serial (b), test2Parallel (b)
        assertTrue(getPluginFileContents(a, "test1Parallel") < getPluginFileContents(a, "test1Serial"));
        assertTrue(getPluginFileContents(a, "test1Serial") < getPluginFileContents(b, "test1Serial"));
        assertTrue(getPluginFileContents(b, "test1Serial") < getPluginFileContents(b, "test2Parallel"));
    }

    private long getPluginFileContents(ProjectionResult projection, String pluginName) {
        MockManifest manifest = (MockManifest) projection.getPluginManifest(pluginName).get();
        return Long.parseLong(manifest.getFileString(manifest.getFiles().iterator().next()).get());
    }

    private void assertPluginPresent(String pluginName, String outputFileName, ProjectionResult...results) {
        for (ProjectionResult result : results) {
            assertTrue(result.getPluginManifest(pluginName).isPresent());
            assertTrue(result.getPluginManifest(pluginName).get().hasFile(outputFileName));
        }
    }

    @Test
    public void buildCanOverrideConfigOutputDirectory() throws Exception {
        Path outputDirectory = Paths.get("/custom/foo");
        SmithyBuildConfig config = SmithyBuildConfig.builder()
                .load(Paths.get(getClass().getResource("simple-config.json").toURI()))
                // Note: this is not the same as the setting on SmithyBuild.
                .outputDirectory("/foo/baz/bar")
                .build();
        Model model = Model.assembler()
                .addImport(Paths.get(getClass().getResource("simple-model.json").toURI()))
                .assemble()
                .unwrap();
        SmithyBuild builder = new SmithyBuild()
                .config(config)
                .model(model)
                .outputDirectory(outputDirectory)
                .fileManifestFactory(MockManifest::new);
        SmithyBuildResult results = builder.build();
        List<Path> files = results.allArtifacts().collect(Collectors.toList());

        assertThat(files, containsInAnyOrder(
                outputDirectory.resolve("source/model/model.json"),
                outputDirectory.resolve("source/build-info/smithy-build-info.json"),
                outputDirectory.resolve("a/sources/manifest"),
                outputDirectory.resolve("a/sources/model.json"),
                outputDirectory.resolve("a/model/model.json"),
                outputDirectory.resolve("a/build-info/smithy-build-info.json"),
                outputDirectory.resolve("b/sources/manifest"),
                outputDirectory.resolve("b/sources/model.json"),
                outputDirectory.resolve("b/model/model.json"),
                outputDirectory.resolve("b/build-info/smithy-build-info.json")));
    }

    @Test
    public void detectsCyclesInApplyProjection() throws Exception {
        Throwable thrown = Assertions.assertThrows(SmithyBuildException.class, () -> {
            SmithyBuildConfig config = SmithyBuildConfig.builder()
                    .load(Paths.get(getClass().getResource("apply-cycle.json").toURI()))
                    .build();
            new SmithyBuild().config(config).build();
        });

        assertThat(thrown.getMessage(), containsString("Cycle found in apply transforms:"));
    }

    @Test
    public void detectsMissingApplyProjection() throws Exception {
        Throwable thrown = Assertions.assertThrows(SmithyBuildException.class, () -> {
            SmithyBuildConfig config = SmithyBuildConfig.builder()
                    .load(Paths.get(getClass().getResource("apply-invalid-projection.json").toURI()))
                    .build();
            new SmithyBuild().config(config).build();
        });

        assertThat(thrown.getMessage(), containsString("Unable to find projection named `bar` referenced by `foo`"));
    }

    @Test
    public void detectsDirectlyRecursiveApply() throws Exception {
        Throwable thrown = Assertions.assertThrows(SmithyBuildException.class, () -> {
            SmithyBuildConfig config = SmithyBuildConfig.builder()
                    .load(Paths.get(getClass().getResource("apply-direct-recursion.json").toURI()))
                    .build();
            new SmithyBuild().config(config).build();
        });

        assertThat(thrown.getMessage(), containsString("Cannot recursively apply the same projection:"));
    }

    @Test
    public void appliesProjections() throws Exception {
        Model model = Model.assembler()
                .addImport(Paths.get(getClass().getResource("simple-model.json").toURI()))
                .assemble()
                .unwrap();

        SmithyBuild builder = new SmithyBuild()
                .model(model)
                .fileManifestFactory(MockManifest::new);
        builder.config(SmithyBuildConfig.builder()
                .load(Paths.get(getClass().getResource("apply-multiple-projections.json").toURI()))
                .outputDirectory("/foo")
                .build());

        SmithyBuildResult results = builder.build();
        assertTrue(results.getProjectionResult("source").isPresent());
        assertTrue(results.getProjectionResult("a").isPresent());
        ProjectionResult a = results.getProjectionResult("a").get();
        assertTrue(a.getPluginManifest("model").isPresent());
        MockManifest manifest = (MockManifest) a.getPluginManifest("model").get();
        String modelText = manifest.getFileString("model.json").get();

        assertThat(modelText, not(containsString("length\"")));
        assertThat(modelText, not(containsString("tags\"")));
    }

    @Test
    public void pluginsMustHaveValidNames() {
        Throwable thrown = Assertions.assertThrows(SmithyBuildException.class, () -> {
            SmithyBuildConfig config = SmithyBuildConfig.builder()
                    .version(SmithyBuild.VERSION)
                    .plugins(MapUtils.of("!invalid", Node.objectNode()))
                    .build();
            new SmithyBuild().config(config).build();
        });

        assertThat(thrown.getMessage(), containsString(
                "Invalid plugin name `!invalid` found in the `[top-level]` projection"));
    }

    @Test
    public void canFilterProjections() throws URISyntaxException {
        Model model = Model.assembler()
                .addImport(Paths.get(getClass().getResource("simple-model.json").toURI()))
                .assemble()
                .unwrap();

        SmithyBuild builder = new SmithyBuild()
                .model(model)
                .fileManifestFactory(MockManifest::new)
                .projectionFilter(name -> name.equals("a"));
        builder.config(SmithyBuildConfig.builder()
                .load(Paths.get(getClass().getResource("apply-multiple-projections.json").toURI()))
                .outputDirectory("/foo")
                .build());

        SmithyBuildResult results = builder.build();
        assertFalse(results.getProjectionResult("source").isPresent());
        assertTrue(results.getProjectionResult("a").isPresent());
    }

    @Test
    public void canFilterPlugins() throws URISyntaxException {
        Model model = Model.assembler()
                .addImport(Paths.get(getClass().getResource("simple-model.json").toURI()))
                .assemble()
                .unwrap();

        SmithyBuild builder = new SmithyBuild()
                .model(model)
                .fileManifestFactory(MockManifest::new)
                .pluginFilter(name -> name.equals("build-info"));
        builder.config(SmithyBuildConfig.builder()
                .load(Paths.get(getClass().getResource("apply-multiple-projections.json").toURI()))
                .outputDirectory("/foo")
                .build());

        SmithyBuildResult results = builder.build();
        assertTrue(results.getProjectionResult("source").isPresent());
        assertTrue(results.getProjectionResult("a").isPresent());
        ProjectionResult a = results.getProjectionResult("a").get();
        assertFalse(a.getPluginManifest("model").isPresent());
        assertTrue(a.getPluginManifest("build-info").isPresent());
    }

    @Test
    public void throwsWhenErrorsOccur() throws Exception {
        Path badConfig = Paths.get(getClass().getResource("trigger-plugin-error.json").toURI());
        Model model = Model.assembler()
                .addImport(getClass().getResource("simple-model.json"))
                .assemble()
                .unwrap();

        RuntimeException canned = new RuntimeException("Hi");
        Map<String, SmithyBuildPlugin> plugins = new HashMap<>();
        plugins.put("foo", new SmithyBuildPlugin() {
            @Override
            public String getName() {
                return "foo";
            }

            @Override
            public void execute(PluginContext context) {
                throw canned;
            }
        });

        Function<String, Optional<SmithyBuildPlugin>> factory = SmithyBuildPlugin.createServiceFactory();
        Function<String, Optional<SmithyBuildPlugin>> composed = name -> OptionalUtils.or(
                Optional.ofNullable(plugins.get(name)), () -> factory.apply(name));

        SmithyBuild builder = new SmithyBuild()
                .model(model)
                .fileManifestFactory(MockManifest::new)
                .pluginFactory(composed)
                .config(SmithyBuildConfig.load(badConfig));

        SmithyBuildException e = Assertions.assertThrows(SmithyBuildException.class, builder::build);
        assertThat(e.getMessage(), containsString("1 Smithy build projections failed"));
        assertThat(e.getMessage(), containsString("(exampleProjection): java.lang.RuntimeException: Hi"));
        assertThat(e.getSuppressed(), equalTo(new Throwable[]{canned}));
    }
}