Merge commit 'refs/pull/2/head' of https://github.com/apache/freemarker-generator
diff --git a/README.md b/README.md
index 1ab5a81..26b8923 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,153 @@
 will be callable on other ways (say, from Gradle, or as a standalone command

 line tool).

 

+freemarker-generator-maven-plugin

+---------------------------------

+## Table of contents

+

+- [Background](#background)

+- [Install](#install)

+- [Usage](#usage)

+  - [FreeMarker Template Files](#freemarker-template-files)

+  - [JSON Generator Files](#json-generator-files)

+  - [Using POM Properties During Generation](#using-pom-properties-during-generation)

+  - [FreeMarker Configuration](#freemarker-configuration)

+  - [Incremental Builds](#incremental-builds)

+- [Code Coverage](#code-coverage)

+- [Contributing](#contributing)

+- [License](#license)

+

+## Background

+This plugin generates source files from FreeMarker templates with a flexible process that includes the ability to:

+

+- Generate multiple source files from a single template,

+- Generate source files during multiple steps in the build process such as testing, and

+- Specify distinct locations for the templates and data models for different stages of the build. 

+

+## Install

+### pom.xml

+

+Add the following snippet within the `<plugins>` tag of your pom.xml:

+

+```xml

+      <plugin>

+        <groupId>com.oath</groupId>

+        <artifactId>freemarker-maven-plugin</artifactId>

+        <version>${freemarker-maven-plugin.version}</version>

+        <configuration>

+          <!-- Required. Specifies the compatibility version for template processing -->

+          <freeMarkerVersion>2.3.23</freeMarkerVersion>

+        </configuration>

+        <executions>

+          <!-- If you want to generate files during other phases, just add more execution

+               tags and specify appropriate phase, sourceDirectory and outputDirectory values.

+          -->

+          <execution>

+            <id>freemarker</id>

+            <!-- Optional, defaults to generate-sources -->

+            <phase>generate-sources</phase>

+            <goals>

+              <!-- Required, must be generate -->

+              <goal>generate</goal>

+            </goals>

+            <configuration>

+              <!-- Optional, defaults to src/main/freemarker/generator -->

+              <sourceDirectory>src/main/freemarker</templateDirectory>

+              <!-- Optional, defaults to src/main/freemarker/generator/template -->

+              <templateDirectory>src/main/freemarker/template</templateDirectory>

+              <!-- Optional, defaults to src/main/freemarker/generator -->

+              <generatorDirectory>src/main/freemarker/generator/generator</generatorDirectory>

+              <!-- Optional, defaults to target/generated-sources/freemarker -->

+              <outputDirectory>target/generated-sources/freemarker/generator</outputDirectory>

+            </configuration>

+          </execution>

+        </executions>

+      </plugin>

+```

+

+## Usage

+

+### FreeMarker Template Files

+FreeMarker template files must reside in the `templateDirectory`. For the default configuration,

+this is: `src/main/freemarker/generator/template`.

+

+By convention, file names for FreeMarker template files use the .ftl extension. For details on the FreeMarker

+template syntax, see: [Getting Started](https://freemarker.apache.org/docs/dgui_quickstart.html) and

+[Template Language Reference](https://freemarker.apache.org/docs/ref.html).

+

+### JSON Generator Files

+The JSON generator files must reside in the `generatorDirectory`. For the default

+configuration, this is: `src/main/freemarker/generator/generator`.

+

+For each JSON generator file, freemarker-maven-plugin will generate a file under the outputDirectory.

+The name of the generated file will be based on the name of the JSON data file. For example,

+the following JSON file: 

+```

+    <sourceDirectory>/data/my/package/MyClass.java.json

+```

+will result in the following file being generated:

+```

+    <outputDirectory>/my/package/MyClass.java

+```

+

+This plugin parses the JSON generator file's `dataModel` field into a `Map<String, Object>` instance (hereafter, referred

+to as the data model). If the dataModel field is empty, an empty map will be created.

+

+Here are some additional details you need to know.

+

+  - This plugin *requires* one top-level field in the JSON data file: `templateName`. This field is used to locate the template file under `<sourceDirectory>/template` that is used to generate the file. This plugin provides the data model to FreeMarker as the data model to process the template identified by `templateName`.

+  - The parser allows for comments.

+  - This plugin currently assumes that the JSON data file encoded using UTF-8.

+

+Here is an example JSON data file:

+```json

+{

+  // An end-of-line comment.

+  # Another end-of-line comment

+  "templateName": "my-template.ftl", #Required

+  "dataModel": { #Optional

+      /* A multi-line

+         comment */

+      "myString": "a string",

+      "myNumber": 1,

+      "myListOfStrings": ["s1", "s2"],

+      "myListOfNumbers": [1, 2],

+      "myMap": {

+        "key1": "value1",

+        "key2": 2

+      }

+  }

+}

+```

+

+### Using POM Properties During Generation

+After parsing the JSON file, the plugin will add

+a `pomProperties` entry into the data model, which is a map itself, that contains the properties defined in the pom. Thus, your template can reference the pom property `my_property` using `${pomProperties.my_property}`. If you have a period or dash in the property name, use `${pomProperties["my.property"]}`.

+

+

+

+### FreeMarker Configuration

+

+Typically, users of this plugin do not need to mess with the FreeMarker configuration. This plugin explicitly sets two FreeMarker configurations:

+

+ 1. the default encoding is set to UTF-8

+ 2. the template loader is set to be a FileTemplateLoader that reads from `templateDirectory`.

+ 

+If you need to override these configs or set your own, you can put them in a 

+`<sourceDirectory>/freemarker.properties` file. If that file exists, this plugin will read it into a java Properties instance and pass it to freemarker.core.Configurable.setSettings() to establish the FreeMarker configuration. See this [javadoc](https://freemarker.apache.org/docs/api/freemarker/template/Configuration.html#setSetting-java.lang.String-java.lang.String-) for configuration details.

+

+

+### Incremental Builds

+This plugin supports incremental builds; it only generates sources if the generator file, template file, or pom file have timestamps newer than any existing output file.  To force a rebuild if these conditions are not met (for example, if you pass in a model parameter on the command line), first run `mvn clean`.

+

+## Code Coverage

+

+By default, the code coverage report is not generated. It is generated by screwdriver jobs. You can generate code coverage on your dev machine with the following maven command:

+```bash

+mvn clean initialize -Dclover-phase=initialize 

+``` 

+Bring up the coverage report by pointing your browser to target/site/clover/dashboard.html under the root directory of the local repository.

+

 

 Licensing

 ---------

diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..4edc9e9
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,172 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <groupId>org.apache.freemarker</groupId>
+  <artifactId>freemarker-generator-maven-plugin</artifactId>
+  <version>1.0.${build}</version>
+  <packaging>maven-plugin</packaging>
+
+  <name>Freemarker Generator Maven Plugin</name>
+  <url>http://freemarker.apache.org/</url>
+
+  <properties>
+    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    <maven.compiler.source>1.8</maven.compiler.source>
+    <maven.compiler.target>1.8</maven.compiler.target>
+    <maven-core.version>3.5.2</maven-core.version>
+    <maven-plugin-api.version>3.5.2</maven-plugin-api.version>
+    <maven-plugin-annotations.version>3.5</maven-plugin-annotations.version>
+    <fastutil.version>8.1.0</fastutil.version>
+    <freemarker.version>2.3.23</freemarker.version>
+    <gson.version>2.8.2</gson.version>
+    <jmockit.version>1.32</jmockit.version>
+    <org.testng.version>6.8</org.testng.version>
+    <assertj-core.version>3.8.0</assertj-core.version>
+    <clover-target-percentage>100</clover-target-percentage>
+    <clover-phase>pre-site</clover-phase>
+    <target-jdk-version>1.8</target-jdk-version>
+  </properties>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.apache.maven</groupId>
+      <artifactId>maven-core</artifactId>
+      <version>${maven-core.version}</version>
+      <scope>provided</scope>
+      <exclusions>
+        <exclusion>
+          <groupId>com.google.guava</groupId>
+          <artifactId>guava</artifactId>
+        </exclusion>
+        <exclusion>
+          <groupId>org.codehaus.plexus</groupId>
+          <artifactId>plexus-utils</artifactId>
+        </exclusion>
+      </exclusions>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.maven</groupId>
+      <artifactId>maven-plugin-api</artifactId>
+      <version>${maven-plugin-api.version}</version>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.maven.plugin-tools</groupId>
+      <artifactId>maven-plugin-annotations</artifactId>
+      <version>${maven-plugin-annotations.version}</version>
+      <scope>provided</scope>
+      <exclusions>
+        <exclusion>
+          <groupId>org.apache.maven</groupId>
+          <artifactId>maven-artifact</artifactId>
+        </exclusion>
+      </exclusions>
+    </dependency>
+    <dependency>
+      <groupId>org.eclipse.sisu</groupId>
+      <artifactId>org.eclipse.sisu.plexus</artifactId>
+      <version>0.3.3</version>
+      <exclusions>
+        <exclusion>
+          <groupId>org.codehaus.plexus</groupId>
+          <artifactId>plexus-utils</artifactId>
+        </exclusion>
+      </exclusions>
+    </dependency>
+    <dependency>
+      <groupId>org.jmockit</groupId>
+      <artifactId>jmockit</artifactId>
+      <version>${jmockit.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.google.code.gson</groupId>
+      <artifactId>gson</artifactId>
+      <version>${gson.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>org.freemarker</groupId>
+      <artifactId>freemarker</artifactId>
+      <version>${freemarker.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>org.testng</groupId>
+      <artifactId>testng</artifactId>
+      <version>${org.testng.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.assertj</groupId>
+      <artifactId>assertj-core</artifactId>
+      <version>${assertj-core.version}</version>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <version>3.7.0</version>
+        <configuration>
+          <source>1.8</source>
+          <target>1.8</target>
+        </configuration>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-source-plugin</artifactId>
+        <version>3.0.1</version>
+        <executions>
+          <execution>
+            <id>attach-sources</id>
+            <goals>
+              <goal>jar</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-plugin-plugin</artifactId>
+        <version>3.5</version>
+        <configuration>
+          <goalPrefix>freemarker</goalPrefix>
+        </configuration>
+        <executions>
+          <execution>
+            <id>default-descriptor</id>
+            <goals>
+              <goal>descriptor</goal>
+            </goals>
+            <phase>process-classes</phase>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>org.openclover</groupId>
+        <artifactId>clover-maven-plugin</artifactId>
+        <version>4.2.0</version>
+        <executions>
+          <execution>
+            <id>clover</id>
+            <phase>${clover-phase}</phase>
+            <goals>
+              <goal>instrument-test</goal>
+              <goal>clover</goal>
+              <goal>check</goal>
+            </goals>
+            <configuration>
+              <targetPercentage>${clover-target-percentage}</targetPercentage>
+              <generateHtml>true</generateHtml>
+              <generateXml>true</generateXml>
+              <jdk>${target-jdk-version}</jdk>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+    </plugins>
+  </build>
+</project>
diff --git a/src/main/java/org/apache/freemarker/generator/FactoryUtil.java b/src/main/java/org/apache/freemarker/generator/FactoryUtil.java
new file mode 100644
index 0000000..359dd3f
--- /dev/null
+++ b/src/main/java/org/apache/freemarker/generator/FactoryUtil.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License 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 org.apache.freemarker.generator;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+
+import freemarker.template.Configuration;
+import freemarker.template.Version;
+
+/**
+ * Simple utility class to call various constructors.
+ * Needed because some jmockit features don't work well with constructors.
+ */
+public class FactoryUtil {
+
+  public static Configuration createConfiguration(String freeMarkerVersion) {
+    return new Configuration(new Version(freeMarkerVersion));
+  }
+
+  public static File createFile(File parent, String child) {
+    return new File(parent, child);
+  }
+
+  public static FileInputStream createFileInputStream(File file) throws FileNotFoundException {
+    return new FileInputStream(file);
+  }
+  
+  public static File createFile(String name) {
+    return new File(name);
+  }
+}
diff --git a/src/main/java/org/apache/freemarker/generator/FreeMarkerMojo.java b/src/main/java/org/apache/freemarker/generator/FreeMarkerMojo.java
new file mode 100644
index 0000000..5b0454a
--- /dev/null
+++ b/src/main/java/org/apache/freemarker/generator/FreeMarkerMojo.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License 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 org.apache.freemarker.generator;
+
+import java.io.File;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecution;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+
+import freemarker.cache.FileTemplateLoader;
+import freemarker.template.Configuration;
+
+@Mojo(name = "generate", defaultPhase = LifecyclePhase.GENERATE_SOURCES)
+public class FreeMarkerMojo extends AbstractMojo {
+
+  /** FreeMarker version string used to build FreeMarker Configuration instance. */
+  @Parameter
+  private String freeMarkerVersion;
+
+  @Parameter(defaultValue = "src/main/freemarker/generator")
+  private File sourceDirectory;
+
+  @Parameter(defaultValue = "src/main/freemarker/generator/template")
+  private File templateDirectory;
+
+  @Parameter(defaultValue = "src/main/freemarker/generator/generator")
+  private File generatorDirectory;
+
+  @Parameter(defaultValue = "target/generated-sources/freemarker/generator")
+  private File outputDirectory;
+
+  @Parameter(defaultValue = "${session}", readonly = true)
+  private MavenSession session;
+
+  @Parameter(defaultValue = "${mojoExecution}", readonly = true)
+  private MojoExecution mojo;
+
+  @Override
+  public void execute() throws MojoExecutionException, MojoFailureException {
+
+    if (freeMarkerVersion == null || freeMarkerVersion.length() == 0) {
+      throw new MojoExecutionException("freeMarkerVersion is required");
+    }
+
+    if (!generatorDirectory.isDirectory()) {
+      throw new MojoExecutionException("Required directory does not exist: " + generatorDirectory);
+    }
+
+    Configuration config = FactoryUtil.createConfiguration(freeMarkerVersion);
+
+    config.setDefaultEncoding("UTF-8");
+
+    if (!templateDirectory.isDirectory()) {
+      throw new MojoExecutionException("Required directory does not exist: " + templateDirectory);
+    }
+    try {
+      config.setTemplateLoader(new FileTemplateLoader(templateDirectory));
+    } catch (Throwable t) {
+      getLog().error("Could not establish file template loader for directory: " + templateDirectory, t);
+      throw new MojoExecutionException("Could not establish file template loader for directory: " + templateDirectory);
+    }
+
+    File freeMarkerProps = FactoryUtil.createFile(sourceDirectory, "freemarker.properties");
+    if (freeMarkerProps.isFile()) {
+      Properties configProperties = new Properties();
+      try (InputStream is = FactoryUtil.createFileInputStream(freeMarkerProps)) {
+        configProperties.load(is);
+      } catch (Throwable t) {
+        getLog().error("Failed to load " + freeMarkerProps, t);
+        throw new MojoExecutionException("Failed to load " + freeMarkerProps);
+      }
+      try {
+        config.setSettings(configProperties);
+      } catch (Throwable t) {
+        getLog().error("Invalid setting(s) in " + freeMarkerProps, t);
+        throw new MojoExecutionException("Invalid setting(s) in " + freeMarkerProps);
+      }
+    }
+    
+    if ("generate-sources".equals(mojo.getLifecyclePhase())) {
+      session.getCurrentProject().addCompileSourceRoot(outputDirectory.toString());
+    } else if ("generate-test-sources".equals(mojo.getLifecyclePhase())) {
+      session.getCurrentProject().addTestCompileSourceRoot(outputDirectory.toString());
+    }
+
+    Map<String, OutputGeneratorPropertiesProvider> extensionToBuilders = new HashMap<>(1);
+    extensionToBuilders.put(".json", JsonPropertiesProvider.create(generatorDirectory,templateDirectory,outputDirectory));
+
+    GeneratingFileVisitor fileVisitor = GeneratingFileVisitor.create(config, session, extensionToBuilders);
+    try {
+      Files.walkFileTree(generatorDirectory.toPath(), fileVisitor);
+    } catch (Throwable t) {
+      getLog().error("Failed to process files in generator dir: " + generatorDirectory, t);
+      throw new MojoExecutionException("Failed to process files in generator dir: " + generatorDirectory);
+    }
+  }
+}
diff --git a/src/main/java/org/apache/freemarker/generator/GeneratingFileVisitor.java b/src/main/java/org/apache/freemarker/generator/GeneratingFileVisitor.java
new file mode 100644
index 0000000..a130257
--- /dev/null
+++ b/src/main/java/org/apache/freemarker/generator/GeneratingFileVisitor.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License 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 org.apache.freemarker.generator;
+
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Map;
+
+import org.apache.maven.execution.MavenSession;
+
+import freemarker.template.Configuration;
+
+/**
+ * FileVisitor designed to process json data files. The json file parsed into
+ * a map and given to FreeMarker to 
+ */
+public class GeneratingFileVisitor extends SimpleFileVisitor<Path> {
+
+  private final Configuration config;
+  private final MavenSession session;
+  private final long pomLastModifiedTimestamp;
+  private final Map<String, OutputGeneratorPropertiesProvider > extensionToBuilder;
+
+  private GeneratingFileVisitor(Configuration config, MavenSession session, Map<String, OutputGeneratorPropertiesProvider> extensionToBuilder) {
+    this.config = config;
+    this.session = session;
+    this.extensionToBuilder = extensionToBuilder;
+    this.pomLastModifiedTimestamp = session.getAllProjects().stream()
+        .map(project->project.getFile().lastModified())
+        .reduce(Long::max)
+        .orElse(0L);
+  }
+
+  /**
+   * Factory method that calls constructor, added to facilitate testing with jmockit.
+   */
+  public static GeneratingFileVisitor create(Configuration config, MavenSession session, Map<String, OutputGeneratorPropertiesProvider> extensionToBuilder) {
+    return new GeneratingFileVisitor(config, session, extensionToBuilder);
+  }
+
+  @Override
+  public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) {
+    if (attrs.isRegularFile()) {
+      OutputGenerator.OutputGeneratorBuilder builder = OutputGenerator.builder()
+          .addGeneratorLocation(path)
+          .addPomLastModifiedTimestamp(pomLastModifiedTimestamp);
+      String fileName = path.getFileName().toString();
+      String extenstion = fileName.substring(fileName.lastIndexOf('.'));
+      OutputGeneratorPropertiesProvider pathProcessor = extensionToBuilder.get(extenstion);
+      if (pathProcessor == null) {
+        throw new RuntimeException("Unknown file extension: " + path);
+      }
+      pathProcessor.providePropertiesFromFile(path, builder);
+      builder.addToDataModel("pomProperties", session.getCurrentProject().getProperties());
+      builder.create().generate(config);
+    }
+    return FileVisitResult.CONTINUE;
+  }
+}
diff --git a/src/main/java/org/apache/freemarker/generator/JsonPropertiesProvider.java b/src/main/java/org/apache/freemarker/generator/JsonPropertiesProvider.java
new file mode 100644
index 0000000..ea9e678
--- /dev/null
+++ b/src/main/java/org/apache/freemarker/generator/JsonPropertiesProvider.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License 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 org.apache.freemarker.generator;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStreamReader;
+import java.lang.reflect.Type;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+
+public class JsonPropertiesProvider implements OutputGeneratorPropertiesProvider {
+	private final Gson gson;
+	private final Type stringObjectMap;
+	private final File dataDir;
+	private final File templateDir;
+	private final File outputDir;
+
+	private JsonPropertiesProvider(File dataDir, File templateDir, File outputDir) {
+		this.dataDir = dataDir;
+		this.templateDir = templateDir;
+		this.outputDir = outputDir;
+		gson = new GsonBuilder().setLenient().create();
+		stringObjectMap = new TypeToken<Map<String, Object>>() { } .getType();
+	}
+
+	public static JsonPropertiesProvider create(File dataDir, File templateDir, File outputDir) {
+		return new JsonPropertiesProvider(dataDir, templateDir, outputDir);
+	}
+
+	@Override
+	public void providePropertiesFromFile(Path path, OutputGenerator.OutputGeneratorBuilder builder) {
+		File jsonDataFile = path.toFile();
+		Map<String,Object> data = parseJson(jsonDataFile);
+
+		Object obj = data.get("dataModel");
+		if (obj != null) {
+			builder.addDataModel((Map<String, Object>) obj);
+		} else {
+			builder.addDataModel(new HashMap<String,Object>());
+		}
+
+		obj = data.get("templateName");
+		if (obj == null) {
+			throw new RuntimeException("Require json data property not found: templateName");
+		}
+		builder.addTemplateLocation(templateDir.toPath().resolve(obj.toString()));
+
+		String dataDirName = dataDir.getAbsolutePath();
+		String jsonFileName = jsonDataFile.getAbsolutePath();
+		if (!jsonFileName.startsWith(dataDirName)) {
+			throw new IllegalStateException("visitFile() given file not in sourceDirectory: " + jsonDataFile);
+		}
+
+		String outputFileName = jsonFileName.substring(dataDirName.length()+1);
+		outputFileName = outputFileName.substring(0, outputFileName.length() - 5);
+		Path outputPath = outputDir.toPath();
+		Path resolved = outputPath.resolve(outputFileName);
+		builder.addOutputLocation(resolved);
+	}
+
+	private Map<String, Object> parseJson(File jsonDataFile) {
+		try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(jsonDataFile), "UTF-8"))) {
+			return gson.fromJson(reader, stringObjectMap);
+		} catch (Throwable t) {
+			throw new RuntimeException("Could not parse json data file: " + jsonDataFile, t);
+		}
+	}
+}
diff --git a/src/main/java/org/apache/freemarker/generator/OutputGenerator.java b/src/main/java/org/apache/freemarker/generator/OutputGenerator.java
new file mode 100644
index 0000000..e639392
--- /dev/null
+++ b/src/main/java/org/apache/freemarker/generator/OutputGenerator.java
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License 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 org.apache.freemarker.generator;
+
+import freemarker.template.Configuration;
+import freemarker.template.Template;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Knows how to generate an output file given five things:
+ * <ul>
+ * <li>The latest update time of the <b>pom file(s)</b> for the project</li>
+ * <li>The location of the <b>generator file</b></li>
+ * <li>The location of the <b>template file</b></li>
+ * <li>The location of the <b>output file</b></li>
+ * <li>A <b>data model<b> used to fill out the template.</li>
+ * </ul>
+ *<p>Given these five pieces of information, the generator will generate a new output file, but only if any existing
+ * generated file is not newer than the inputs (pom, generator, and template).</p>
+ */
+class OutputGenerator {
+	public final long pomModifiedTimestamp;
+	public final Path generatorLocation;
+	public final Path templateLocation;
+	public final Path outputLocation;
+	public final Map<String,Object> dataModel;
+	private OutputGenerator(
+		 long pomModifiedTimestamp,
+		 Path generatorLocation,
+		 Path templateLocation,
+		 Path outputLocation,
+		 Map<String, Object> dataModel) {
+		this.pomModifiedTimestamp = pomModifiedTimestamp;
+		this.generatorLocation = generatorLocation;
+		this.templateLocation = templateLocation;
+		this.outputLocation = outputLocation;
+		this.dataModel = dataModel;
+	}
+
+	/**
+	 * Uses a fluent builder to make the code more legible in place.
+	 * Also allows the output generator to be built from multiple locations in source by passing the builder.
+	 * @return A new fluent builder for the OutputGenerator class.
+	 */
+	public static OutputGeneratorBuilder builder() {
+		return new OutputGeneratorBuilder();
+	}
+
+	public static class OutputGeneratorBuilder {
+		private long pomModifiedTimestamp = Long.MAX_VALUE;
+		private Path generatorLocation = null;
+		private Path templateLocation = null;
+		private Path outputLocation = null;
+		private Map<String,Object> dataModel = null;
+
+		public OutputGeneratorBuilder addPomLastModifiedTimestamp(long pomModifiedTimestamp) {
+			this.pomModifiedTimestamp = pomModifiedTimestamp;
+			return this;
+		}
+
+		public OutputGeneratorBuilder addGeneratorLocation(Path generatorLocation) {
+			this.generatorLocation = generatorLocation;
+			return this;
+		}
+
+		public OutputGeneratorBuilder addTemplateLocation(Path templateLocation) {
+			this.templateLocation = templateLocation;
+			return this;
+		}
+
+		public OutputGeneratorBuilder addOutputLocation(Path outputLocation) {
+			this.outputLocation = outputLocation;
+			return this;
+		}
+
+		public OutputGeneratorBuilder addDataModel(Map<String,Object> dataModel) {
+			this.dataModel = dataModel;
+			return this;
+		}
+
+		public OutputGeneratorBuilder addToDataModel(String key, Object val) {
+			if (this.dataModel == null) {
+				this.dataModel = new HashMap<>(4);
+			}
+			this.dataModel.put(key,val);
+			return this;
+		}
+
+		/**
+		 * @throws IllegalStateException if any of the parts of the OutputGenerator were not set.
+		 * @return A new output generator (which is immutable).
+		 */
+		public OutputGenerator create() {
+			if (pomModifiedTimestamp == Long.MAX_VALUE) throw new IllegalStateException("Must set the pomModifiedTimestamp");
+			if (generatorLocation == null) throw new IllegalStateException("Must set a non-null generatorLocation");
+			if (templateLocation == null) throw new IllegalStateException("Must set a non-null templateLocation");
+			if (outputLocation == null) throw new IllegalStateException("Must set a non-null outputLocation");
+			if (dataModel == null) throw new IllegalStateException("Must set a non-null dataModel");
+			return new OutputGenerator(pomModifiedTimestamp, generatorLocation, templateLocation, outputLocation, dataModel);
+		}
+	}
+
+	/**
+	 * <p>Generates an output by applying the model to the template.</p>
+	 * <p>Checks the ages of the inputs against an existing output file to early exit if there is no update.</p>
+	 * @param config Used to load the template from the template name.
+	 */
+	public void generate(Configuration config) {
+		//Use "createFile" for testing purposes only
+		File outputFile = FactoryUtil.createFile(outputLocation.toFile().toString());
+		File templateFile = templateLocation.toFile();
+		File generatorFile = generatorLocation.toFile();
+		if (outputFile.exists()) {
+			//early exit only if the output file is newer than all files that contribute to its generation
+			if (outputFile.lastModified() > generatorFile.lastModified()
+				 && outputFile.lastModified() > templateFile.lastModified()
+				 && outputFile.lastModified() > pomModifiedTimestamp) {
+				return;
+			}
+		} else {
+			File parentDir = outputFile.getParentFile();
+			if (parentDir.isFile()) {
+				throw new RuntimeException("Parent directory of output file is a file: " + parentDir.getAbsoluteFile());
+			}
+			parentDir.mkdirs();
+			if (!parentDir.isDirectory()) {
+				throw new RuntimeException("Could not create directory: " + parentDir.getAbsoluteFile());
+			}
+		}
+
+		Template template;
+		try {
+			template = config.getTemplate(templateFile.getName());
+		} catch (Throwable t) {
+			throw new RuntimeException("Could not read template: " + templateFile.getName(), t);
+		}
+
+		try (FileWriter writer = new FileWriter(outputFile)) {
+			template.process(dataModel, writer);
+		} catch (Throwable t) {
+			throw new RuntimeException("Could not process template associated with data file: " + generatorLocation, t);
+		}
+	}
+}
diff --git a/src/main/java/org/apache/freemarker/generator/OutputGeneratorPropertiesProvider.java b/src/main/java/org/apache/freemarker/generator/OutputGeneratorPropertiesProvider.java
new file mode 100644
index 0000000..019373b
--- /dev/null
+++ b/src/main/java/org/apache/freemarker/generator/OutputGeneratorPropertiesProvider.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License 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 org.apache.freemarker.generator;
+
+import java.nio.file.Path;
+
+public interface OutputGeneratorPropertiesProvider {
+	/**
+	 * Must add three properties to the builder: the <b>templateLocation</b>, <b>outputLocation</b>, and <b>dataModel</b>
+	 * The <b>pom updated timestamp</b> and <b>generatorLocation</b> are added elsewhere.
+	 * @param path The path to the generator file, to be used to decide on the three properties above.
+	 * @param builder The builder to which to add the properties.
+	 */
+	public void providePropertiesFromFile(Path path, OutputGenerator.OutputGeneratorBuilder builder);
+}
diff --git a/src/test/data/freemarker-mojo/data/test.txt.json b/src/test/data/freemarker-mojo/data/test.txt.json
new file mode 100644
index 0000000..0702dca
--- /dev/null
+++ b/src/test/data/freemarker-mojo/data/test.txt.json
@@ -0,0 +1,3 @@
+{
+  "templateName": "test.ftl"
+}
\ No newline at end of file
diff --git a/src/test/data/freemarker-mojo/freemarker.properties b/src/test/data/freemarker-mojo/freemarker.properties
new file mode 100644
index 0000000..17cefa1
--- /dev/null
+++ b/src/test/data/freemarker-mojo/freemarker.properties
@@ -0,0 +1,3 @@
+# Test properties file used by FreeMarkerMojoTest to verify FreeMarkerMojo
+# passes these settings to the FreeMarker configuration.
+boolean_format: T,F
\ No newline at end of file
diff --git a/src/test/data/freemarker-mojo/template/test.ftl b/src/test/data/freemarker-mojo/template/test.ftl
new file mode 100644
index 0000000..6986c80
--- /dev/null
+++ b/src/test/data/freemarker-mojo/template/test.ftl
@@ -0,0 +1 @@
+This is a dummy test file. It is only here to make sure the directory exists.
\ No newline at end of file
diff --git a/src/test/data/generating-file-visitor/badPath/success-test.txt.json b/src/test/data/generating-file-visitor/badPath/success-test.txt.json
new file mode 100644
index 0000000..89b6d13
--- /dev/null
+++ b/src/test/data/generating-file-visitor/badPath/success-test.txt.json
@@ -0,0 +1,4 @@
+{
+  "templateName": "test.ftl",
+  "testVar": "test value"
+}
\ No newline at end of file
diff --git a/src/test/data/generating-file-visitor/data/badParent/bad-parent-test.txt.json b/src/test/data/generating-file-visitor/data/badParent/bad-parent-test.txt.json
new file mode 100644
index 0000000..ad68303
--- /dev/null
+++ b/src/test/data/generating-file-visitor/data/badParent/bad-parent-test.txt.json
@@ -0,0 +1,6 @@
+{
+  "templateName": "test.ftl",
+  "dataModel": {
+    "testVar": "test value"
+  }
+}
\ No newline at end of file
diff --git a/src/test/data/generating-file-visitor/data/mydir/bad-extension-test.txt b/src/test/data/generating-file-visitor/data/mydir/bad-extension-test.txt
new file mode 100644
index 0000000..e4747b5
--- /dev/null
+++ b/src/test/data/generating-file-visitor/data/mydir/bad-extension-test.txt
@@ -0,0 +1,6 @@
+{
+  "templateName": "test.ftl",
+  "dataModel": {
+    "testVar": "test 2 value"
+  }
+}
\ No newline at end of file
diff --git a/src/test/data/generating-file-visitor/data/mydir/bad-template-name.txt.json b/src/test/data/generating-file-visitor/data/mydir/bad-template-name.txt.json
new file mode 100644
index 0000000..5f01033
--- /dev/null
+++ b/src/test/data/generating-file-visitor/data/mydir/bad-template-name.txt.json
@@ -0,0 +1,6 @@
+{
+  "templateName": "missing.ftl",
+  "dataModel": {
+    "testVar": "test value"
+  }
+}
\ No newline at end of file
diff --git a/src/test/data/generating-file-visitor/data/mydir/missing-template-name.txt.json b/src/test/data/generating-file-visitor/data/mydir/missing-template-name.txt.json
new file mode 100644
index 0000000..f80ce71
--- /dev/null
+++ b/src/test/data/generating-file-visitor/data/mydir/missing-template-name.txt.json
@@ -0,0 +1,6 @@
+{
+  //not ok to be missing the templateName
+  "dataModel": {
+    "testVar": "test value"
+  }
+}
\ No newline at end of file
diff --git a/src/test/data/generating-file-visitor/data/mydir/missing-var-test.txt.json b/src/test/data/generating-file-visitor/data/mydir/missing-var-test.txt.json
new file mode 100644
index 0000000..b97ea16
--- /dev/null
+++ b/src/test/data/generating-file-visitor/data/mydir/missing-var-test.txt.json
@@ -0,0 +1,4 @@
+{
+  "templateName": "test.ftl"
+  //missing dataModel is not ok, since we are missing a variable needed to fill out the template
+}
\ No newline at end of file
diff --git a/src/test/data/generating-file-visitor/data/mydir/success-test-2.txt.json b/src/test/data/generating-file-visitor/data/mydir/success-test-2.txt.json
new file mode 100644
index 0000000..5dc549c
--- /dev/null
+++ b/src/test/data/generating-file-visitor/data/mydir/success-test-2.txt.json
@@ -0,0 +1,4 @@
+{
+  "templateName": "test-pom-only.ftl"
+  //missing dataModel, is OK, assuming all required properies are found in pom
+}
diff --git a/src/test/data/generating-file-visitor/data/mydir/success-test.txt.json b/src/test/data/generating-file-visitor/data/mydir/success-test.txt.json
new file mode 100644
index 0000000..ad68303
--- /dev/null
+++ b/src/test/data/generating-file-visitor/data/mydir/success-test.txt.json
@@ -0,0 +1,6 @@
+{
+  "templateName": "test.ftl",
+  "dataModel": {
+    "testVar": "test value"
+  }
+}
\ No newline at end of file
diff --git a/src/test/data/generating-file-visitor/template/test-pom-only.ftl b/src/test/data/generating-file-visitor/template/test-pom-only.ftl
new file mode 100644
index 0000000..7168154
--- /dev/null
+++ b/src/test/data/generating-file-visitor/template/test-pom-only.ftl
@@ -0,0 +1 @@
+This is a test freemarker template. Test pom data: '${pomProperties.pomVar}'.
diff --git a/src/test/data/generating-file-visitor/template/test.ftl b/src/test/data/generating-file-visitor/template/test.ftl
new file mode 100644
index 0000000..1edee95
--- /dev/null
+++ b/src/test/data/generating-file-visitor/template/test.ftl
@@ -0,0 +1 @@
+This is a test freemarker template. Test json data: '${testVar}'. Test pom data: '${pomProperties.pomVar}'.
\ No newline at end of file
diff --git a/src/test/java/org/apache/freemarker/generator/FreeMarkerMojoTest.java b/src/test/java/org/apache/freemarker/generator/FreeMarkerMojoTest.java
new file mode 100644
index 0000000..f5b846b
--- /dev/null
+++ b/src/test/java/org/apache/freemarker/generator/FreeMarkerMojoTest.java
@@ -0,0 +1,340 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License 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 org.apache.freemarker.generator;
+
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.FileVisitor;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.Properties;
+
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.plugin.MojoExecution;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.project.MavenProject;
+import org.junit.Assert;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import freemarker.cache.FileTemplateLoader;
+import freemarker.cache.TemplateLoader;
+import freemarker.template.Configuration;
+import mockit.Deencapsulation;
+import mockit.Expectations;
+import mockit.Mocked;
+import mockit.Verifications;
+
+public class FreeMarkerMojoTest extends Assert {
+
+  public static final File testOutputDir = new File("target/test-output/freemarker-mojo");
+  
+  @BeforeClass
+  public static void beforeClass() throws IOException {
+    // Clean output dir before each run.
+    if (testOutputDir.exists()) {
+      // Recursively delete output from previous run.
+      Files.walk(testOutputDir.toPath())
+       .sorted(Comparator.reverseOrder())
+       .map(Path::toFile)
+       .forEach(File::delete);
+    }
+  }
+
+  @Test
+  public void executeTest(
+      @Mocked MavenSession session,
+      @Mocked MavenProject project,
+      @Mocked MojoExecution mojoExecution,
+      @Mocked GeneratingFileVisitor generatingFileVisitor,
+      @Mocked Files files
+      ) throws MojoExecutionException, MojoFailureException, IOException {
+
+    new Expectations(mojoExecution, generatingFileVisitor) {{
+      mojoExecution.getLifecyclePhase(); result = "generate-sources";
+      session.getCurrentProject(); result = project;
+    }};
+
+    FreeMarkerMojo mojo = new FreeMarkerMojo();
+    
+    // Validate freeMarkerVersion is required.
+    assertThatExceptionOfType(MojoExecutionException.class).isThrownBy(() -> {
+      mojo.execute();
+    }).withMessage("freeMarkerVersion is required");
+    
+    Deencapsulation.setField(mojo, "freeMarkerVersion", "");
+    assertThatExceptionOfType(MojoExecutionException.class).isThrownBy(() -> {
+      mojo.execute();
+    }).withMessage("freeMarkerVersion is required");
+
+    File testCaseOutputDir = new File(testOutputDir, "executeTest");
+    Deencapsulation.setField(mojo, "freeMarkerVersion", "2.3.23");
+    Deencapsulation.setField(mojo, "sourceDirectory", testCaseOutputDir);
+    Deencapsulation.setField(mojo, "templateDirectory", new File(testCaseOutputDir, "template"));
+    Deencapsulation.setField(mojo, "generatorDirectory", new File(testCaseOutputDir, "data"));
+    Deencapsulation.setField(mojo, "outputDirectory", new File(testCaseOutputDir, "generated-files"));
+    Deencapsulation.setField(mojo, "mojo", mojoExecution);
+    Deencapsulation.setField(mojo, "session", session);
+
+    // Validate source directory.
+    assertThatExceptionOfType(MojoExecutionException.class).isThrownBy(() -> {
+      mojo.execute();
+    }).withMessage("Required directory does not exist: target/test-output/freemarker-mojo/executeTest/data");
+    
+    new File(testCaseOutputDir, "data").mkdirs();
+    assertThatExceptionOfType(MojoExecutionException.class).isThrownBy(() -> {
+      mojo.execute();
+    }).withMessage("Required directory does not exist: target/test-output/freemarker-mojo/executeTest/template");
+    new File(testCaseOutputDir, "template").mkdirs();
+    
+    // Validate minimum configuration.
+    mojo.execute();
+    
+    new Verifications() {{
+      project.addCompileSourceRoot("target/test-output/freemarker-mojo/executeTest/generated-files"); times = 1;
+
+      Configuration config;
+      MavenSession capturedSession;
+      Map<String, OutputGeneratorPropertiesProvider> builders;
+
+      GeneratingFileVisitor.create(
+          config = withCapture(), 
+          capturedSession = withCapture(), 
+          builders = withCapture()); times = 1;
+
+      assertEquals("UTF-8", config.getDefaultEncoding());
+      assertEquals(session, capturedSession);
+      TemplateLoader loader = config.getTemplateLoader();
+      assertTrue(loader instanceof FileTemplateLoader);
+
+      Path path;
+      FileVisitor<Path> fileVisitor;
+      
+      Files.walkFileTree(path = withCapture(), fileVisitor = withCapture()); times = 1;
+      
+      assertEquals(new File(testCaseOutputDir, "data").toPath(), path);
+      assertTrue(fileVisitor instanceof GeneratingFileVisitor);
+    }};
+  }
+  
+  @Test
+  public void execute_generateTestSourceTest(
+      @Mocked MavenSession session,
+      @Mocked MavenProject project,
+      @Mocked MojoExecution mojoExecution,
+      @Mocked GeneratingFileVisitor generatingFileVisitor,
+      @Mocked Files files
+      ) throws MojoExecutionException, MojoFailureException, IOException {
+
+    new Expectations(mojoExecution, generatingFileVisitor) {{
+      mojoExecution.getLifecyclePhase(); result = "generate-test-sources";
+      session.getCurrentProject(); result = project;
+    }};
+
+    FreeMarkerMojo mojo = new FreeMarkerMojo();
+    
+    File testCaseOutputDir = new File(testOutputDir, "generateTestSourceTest");
+    Deencapsulation.setField(mojo, "freeMarkerVersion", "2.3.23");
+    Deencapsulation.setField(mojo, "sourceDirectory", testCaseOutputDir);
+    Deencapsulation.setField(mojo, "templateDirectory", new File(testCaseOutputDir, "template"));
+    Deencapsulation.setField(mojo, "generatorDirectory", new File(testCaseOutputDir, "data"));
+    Deencapsulation.setField(mojo, "outputDirectory", new File(testCaseOutputDir, "generated-files"));
+    Deencapsulation.setField(mojo, "mojo", mojoExecution);
+    Deencapsulation.setField(mojo, "session", session);
+
+    new File(testCaseOutputDir, "data").mkdirs();
+    new File(testCaseOutputDir, "template").mkdirs();
+    
+    mojo.execute();
+    
+    new Verifications() {{
+      project.addTestCompileSourceRoot("target/test-output/freemarker-mojo/generateTestSourceTest/generated-files"); times = 1;
+    }};
+  }
+
+  @SuppressWarnings({"rawtypes", "unchecked"})
+  @Test
+  public void execute_walkFileTreeExceptionTest(
+      @Mocked MavenSession session,
+      @Mocked MavenProject project,
+      @Mocked MojoExecution mojoExecution,
+      @Mocked GeneratingFileVisitor generatingFileVisitor,
+      @Mocked Files files
+      ) throws MojoExecutionException, MojoFailureException, IOException {
+
+    new Expectations(mojoExecution, generatingFileVisitor) {{
+      mojoExecution.getLifecyclePhase(); result = "generate-test-sources";
+      session.getCurrentProject(); result = project;
+      Files.walkFileTree((Path) any,(FileVisitor) any); result = new RuntimeException("test exception");
+    }};
+
+    FreeMarkerMojo mojo = new FreeMarkerMojo();
+    
+    File testCaseOutputDir = new File(testOutputDir, "generateTestSourceTest");
+    Deencapsulation.setField(mojo, "freeMarkerVersion", "2.3.23");
+    Deencapsulation.setField(mojo, "sourceDirectory", testCaseOutputDir);
+    Deencapsulation.setField(mojo, "templateDirectory", new File(testCaseOutputDir, "template"));
+    Deencapsulation.setField(mojo, "generatorDirectory", new File(testCaseOutputDir, "data"));
+    Deencapsulation.setField(mojo, "outputDirectory", new File(testCaseOutputDir, "generated-files"));
+    Deencapsulation.setField(mojo, "mojo", mojoExecution);
+    Deencapsulation.setField(mojo, "session", session);
+
+    new File(testCaseOutputDir, "data").mkdirs();
+    new File(testCaseOutputDir, "template").mkdirs();
+    
+    assertThatExceptionOfType(MojoExecutionException.class).isThrownBy(() -> {
+      mojo.execute();
+    }).withMessage("Failed to process files in generator dir: target/test-output/freemarker-mojo/generateTestSourceTest/data");
+  }
+  
+  @Test
+  public void execute_setTemplateLoaderExceptionTest(
+      @Mocked MavenSession session,
+      @Mocked MavenProject project,
+      @Mocked MojoExecution mojoExecution,
+      @Mocked FactoryUtil factoryUtil,
+      @Mocked Configuration config) {
+    
+    new Expectations(config, FactoryUtil.class) {{
+      FactoryUtil.createConfiguration("2.3.23"); result = config;
+      config.setTemplateLoader((TemplateLoader) any); result = new RuntimeException("test exception");
+    }};
+
+    FreeMarkerMojo mojo = new FreeMarkerMojo();
+
+    File testCaseOutputDir = new File(testOutputDir, "setTemplateLoaderException");
+
+    Deencapsulation.setField(mojo, "freeMarkerVersion", "2.3.23");
+    Deencapsulation.setField(mojo, "sourceDirectory", testCaseOutputDir);
+    Deencapsulation.setField(mojo, "templateDirectory", new File(testCaseOutputDir, "template"));
+    Deencapsulation.setField(mojo, "generatorDirectory", new File(testCaseOutputDir, "data"));
+    Deencapsulation.setField(mojo, "outputDirectory", new File(testCaseOutputDir, "generated-files"));
+    Deencapsulation.setField(mojo, "mojo", mojoExecution);
+    Deencapsulation.setField(mojo, "session", session);
+
+    new File(testCaseOutputDir, "data").mkdirs();
+    new File(testCaseOutputDir, "template").mkdirs();
+
+    assertThatExceptionOfType(MojoExecutionException.class).isThrownBy(() -> {
+      mojo.execute();
+    }).withMessage("Could not establish file template loader for directory: target/test-output/freemarker-mojo/setTemplateLoaderException/template");
+  }
+  
+  @Test
+  public void execute_loadFreemarkerPropertiesTest(
+      @Mocked MavenSession session,
+      @Mocked MavenProject project,
+      @Mocked MojoExecution mojoExecution,
+      @Mocked Configuration config) throws Exception {
+    
+    FreeMarkerMojo mojo = new FreeMarkerMojo();
+
+    File sourceDirectory = new File("src/test/data/freemarker-mojo");
+    File testCaseOutputDir = new File(testOutputDir, "loadFreemarkerProperties");
+
+    Deencapsulation.setField(mojo, "freeMarkerVersion", "2.3.23");
+    Deencapsulation.setField(mojo, "sourceDirectory", sourceDirectory);
+    Deencapsulation.setField(mojo, "templateDirectory", new File( sourceDirectory, "template"));
+    Deencapsulation.setField(mojo, "generatorDirectory", new File( sourceDirectory, "data"));
+    Deencapsulation.setField(mojo, "outputDirectory", new File(testCaseOutputDir, "generated-files"));
+    Deencapsulation.setField(mojo, "mojo", mojoExecution);
+    Deencapsulation.setField(mojo, "session", session);
+
+    mojo.execute();
+    
+    new Verifications() {{
+      Properties properties;
+      
+      config.setSettings(properties = withCapture()); times = 1;
+      
+      assertEquals("T,F", properties.getProperty("boolean_format"));
+    }};
+  }
+  
+  @Test
+  public void execute_loadFreemarkerPropertiesExceptionTest(
+      @Mocked MavenSession session,
+      @Mocked MavenProject project,
+      @Mocked MojoExecution mojoExecution,
+      @Mocked FactoryUtil factoryUtil,
+      @Mocked Configuration config) throws Exception {
+    
+    new Expectations(FactoryUtil.class) {{
+      FactoryUtil.createFileInputStream((File) any); result = new RuntimeException("test exception");
+    }};
+    
+    FreeMarkerMojo mojo = new FreeMarkerMojo();
+
+    File sourceDirectory = new File("src/test/data/freemarker-mojo");
+    File testCaseOutputDir = new File(testOutputDir, "loadFreemarkerPropertiesExceptionTest");
+
+    Deencapsulation.setField(mojo, "freeMarkerVersion", "2.3.23");
+    Deencapsulation.setField(mojo, "sourceDirectory", sourceDirectory);
+    Deencapsulation.setField(mojo, "templateDirectory", new File( sourceDirectory, "template"));
+    Deencapsulation.setField(mojo, "generatorDirectory", new File( sourceDirectory, "data"));
+    Deencapsulation.setField(mojo, "outputDirectory", new File(testCaseOutputDir, "generated-files"));
+    Deencapsulation.setField(mojo, "mojo", mojoExecution);
+    Deencapsulation.setField(mojo, "session", session);
+
+    System.out.println("==== before mojo execute");
+    try {
+    assertThatExceptionOfType(MojoExecutionException.class).isThrownBy(() -> {
+      mojo.execute();
+    }).withMessage("Failed to load src/test/data/freemarker-mojo/freemarker.properties");
+    } catch ( Throwable t) {
+      t.printStackTrace();
+    }
+  }
+
+  @Test
+  public void execute_setSettingsExceptionTest(
+      @Mocked MavenSession session,
+      @Mocked MavenProject project,
+      @Mocked MojoExecution mojoExecution,
+      @Mocked Configuration config) throws Exception {
+    
+    new Expectations() {{
+      config.setSettings((Properties) any); result = new RuntimeException("test exception");
+    }};
+    
+    FreeMarkerMojo mojo = new FreeMarkerMojo();
+
+    File sourceDirectory = new File("src/test/data/freemarker-mojo");
+    File testCaseOutputDir = new File(testOutputDir, "loadFreemarkerProperties");
+
+    Deencapsulation.setField(mojo, "freeMarkerVersion", "2.3.23");
+    Deencapsulation.setField(mojo, "sourceDirectory", sourceDirectory);
+    Deencapsulation.setField(mojo, "templateDirectory", new File( sourceDirectory, "template"));
+    Deencapsulation.setField(mojo, "generatorDirectory", new File( sourceDirectory, "data"));
+    Deencapsulation.setField(mojo, "outputDirectory", new File(testCaseOutputDir, "generated-files"));
+    Deencapsulation.setField(mojo, "mojo", mojoExecution);
+    Deencapsulation.setField(mojo, "session", session);
+
+    assertThatExceptionOfType(MojoExecutionException.class).isThrownBy(() -> {
+      mojo.execute();
+    }).withMessage("Invalid setting(s) in src/test/data/freemarker-mojo/freemarker.properties");
+  }
+  
+}
diff --git a/src/test/java/org/apache/freemarker/generator/GeneratingFileVisitorTest.java b/src/test/java/org/apache/freemarker/generator/GeneratingFileVisitorTest.java
new file mode 100644
index 0000000..8770a27
--- /dev/null
+++ b/src/test/java/org/apache/freemarker/generator/GeneratingFileVisitorTest.java
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License 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 org.apache.freemarker.generator;
+
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.*;
+
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.project.MavenProject;
+import org.junit.Assert;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import freemarker.cache.FileTemplateLoader;
+import freemarker.template.Configuration;
+import mockit.Expectations;
+import mockit.Mocked;
+
+public class GeneratingFileVisitorTest extends Assert {
+
+  private static File testDir = new File("src/test/data/generating-file-visitor");
+  private static File dataDir = new File(testDir, "data");
+  private static File templateDir = new File(testDir, "template");
+  private static File outputDir = new File("target/test-output/generating-file-visitor");
+  private static Map<String, OutputGeneratorPropertiesProvider> builders = new HashMap<>();
+  private Configuration config;
+  private Properties pomProperties = new Properties();
+  
+  @BeforeClass
+  public static void beforeClass() throws IOException {
+  	 builders.put(".json", JsonPropertiesProvider.create(dataDir,templateDir,outputDir));
+    // Clean output dir before each run.
+    File outputDir = new File("target/test-output/generating-file-visitor");
+    if (outputDir.exists()) {
+      // Recursively delete output from previous run.
+      Files.walk(outputDir.toPath())
+       .sorted(Comparator.reverseOrder())
+       .map(Path::toFile)
+       .forEach(File::delete);
+    }
+  }
+
+  @BeforeMethod
+  public void before() throws IOException {
+    if (!testDir.isDirectory()) {
+      throw new RuntimeException("Can't find required test data directory. "
+          + "If running test outside of maven, make sure working directory is the project directory. "
+          + "Looking for: " + testDir);
+    }
+
+    config = new Configuration(Configuration.VERSION_2_3_23);
+    config.setDefaultEncoding("UTF-8");
+    config.setTemplateLoader(new FileTemplateLoader(templateDir));
+    pomProperties.put("pomVar", "pom value");
+  }
+  
+  @Test
+  public void functionalHappyPathTestNoDataModel(
+      @Mocked MavenSession session, 
+      @Mocked MavenProject project,
+      @Mocked File mockFile,
+      @Mocked BasicFileAttributes attrs) throws IOException {
+    List<MavenProject> projects = new ArrayList<>();
+    projects.add(project);
+    new Expectations(session, project, mockFile) {{
+      session.getCurrentProject(); result = project;
+      session.getAllProjects(); result = projects;
+      project.getProperties(); result = pomProperties;
+      attrs.isRegularFile(); result = true;
+      project.getFile(); result = mockFile;
+      mockFile.lastModified(); result = 10;
+    }};
+    
+    File file = new File(dataDir, "mydir/success-test-2.txt.json");
+    GeneratingFileVisitor gfv = GeneratingFileVisitor.create(config, session, builders);
+    assertEquals(FileVisitResult.CONTINUE, gfv.visitFile(file.toPath(), attrs));
+    
+    File outputFile = new File(outputDir, "mydir/success-test-2.txt");
+    assertTrue(outputFile.isFile());
+    List<String> lines = Files.readAllLines(outputFile.toPath(), StandardCharsets.UTF_8);
+    assertEquals(1, lines.size());
+    assertEquals("This is a test freemarker template. Test pom data: 'pom value'.", lines.get(0));
+  }
+
+  @Test
+  public void functionalHappyPathTest(
+      @Mocked MavenSession session,
+      @Mocked MavenProject project,
+      @Mocked File mockFile,
+      @Mocked BasicFileAttributes attrs) throws IOException {
+    List<MavenProject> projects = new ArrayList<>();
+    projects.add(project);
+    new Expectations(session, project, mockFile) {{
+      session.getCurrentProject(); result = project;
+      session.getAllProjects(); result = projects;
+      project.getProperties(); result = pomProperties;
+      attrs.isRegularFile(); result = true;
+      project.getFile(); result = mockFile;
+      mockFile.lastModified(); result = 10;
+    }};
+
+    File file = new File(dataDir, "mydir/success-test.txt.json");
+    GeneratingFileVisitor gfv = GeneratingFileVisitor.create(config, session, builders);
+    assertEquals(FileVisitResult.CONTINUE, gfv.visitFile(file.toPath(), attrs));
+
+    File outputFile = new File(outputDir, "mydir/success-test.txt");
+    assertTrue(outputFile.isFile());
+    List<String> lines = Files.readAllLines(outputFile.toPath(), StandardCharsets.UTF_8);
+    assertEquals(1, lines.size());
+    assertEquals("This is a test freemarker template. Test json data: 'test value'. Test pom data: 'pom value'.", lines.get(0));
+  }
+
+  @Test
+  public void visitFile_badExtensionTest(
+      @Mocked MavenSession session,
+      @Mocked MavenProject project,
+      @Mocked File mockFile,
+      @Mocked BasicFileAttributes attrs) throws IOException {
+    List<MavenProject> projects = new ArrayList<>();
+    projects.add(project);
+    new Expectations(session, project, mockFile) {{
+      attrs.isRegularFile(); result = true;
+      session.getAllProjects(); result = projects;
+      project.getFile(); result = mockFile;
+      mockFile.lastModified(); result = 10;
+    }};
+    // Test file without .json suffix.
+    File file = new File(dataDir, "mydir/bad-extension-test.txt");
+    GeneratingFileVisitor gfv = GeneratingFileVisitor.create(config, session, builders);
+    assertThatExceptionOfType(RuntimeException.class).isThrownBy(() -> {
+      gfv.visitFile(file.toPath(), attrs);
+    }).withMessage("Unknown file extension: " + file.toPath());
+  }
+
+  @Test 
+  public void visitFile_notRegularFileTest(@Mocked MavenSession session,
+                                           @Mocked MavenProject project,
+                                           @Mocked BasicFileAttributes attrs,
+                                           @Mocked File mockFile
+                                           ) {
+    List<MavenProject> projects = new ArrayList<>();
+    projects.add(project);
+    new Expectations(session, project, mockFile) {{
+      attrs.isRegularFile(); result = false;
+      session.getAllProjects(); result = projects;
+      project.getFile(); result = mockFile;
+      mockFile.lastModified(); result = 10;
+    }};
+    // FYI: if you change above result to true, test will fail trying to read the 'mydir' directory
+    // as a json file.
+    File dir = new File(dataDir, "mydir");
+    GeneratingFileVisitor gfv = GeneratingFileVisitor.create(config, session, builders);
+    assertEquals(FileVisitResult.CONTINUE, gfv.visitFile(dir.toPath(), attrs));
+  }
+}
diff --git a/src/test/java/org/apache/freemarker/generator/JsonPropertiesProviderTest.java b/src/test/java/org/apache/freemarker/generator/JsonPropertiesProviderTest.java
new file mode 100644
index 0000000..3f5b2ff
--- /dev/null
+++ b/src/test/java/org/apache/freemarker/generator/JsonPropertiesProviderTest.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License 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 org.apache.freemarker.generator;
+
+import com.google.gson.Gson;
+import com.google.gson.stream.JsonReader;
+import mockit.Expectations;
+import mockit.Mocked;
+import mockit.Verifications;
+import org.testng.annotations.Test;
+
+import java.io.File;
+import java.lang.reflect.Type;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType;
+import static org.testng.AssertJUnit.assertEquals;
+import static org.testng.internal.junit.ArrayAsserts.assertArrayEquals;
+
+public class JsonPropertiesProviderTest {
+	private File testDir = new File("src/test/data/generating-file-visitor");
+	private File dataDir = new File(testDir, "data");
+	private File templateDir = new File(testDir, "template");
+	private File outputDir = new File("target/test-output/generating-file-visitor");
+
+	@Test
+	public void testSuccess(@Mocked OutputGenerator.OutputGeneratorBuilder builder) {
+		Path path = dataDir.toPath().resolve("mydir/success-test.txt.json");
+		Path expectedTemplateLocation = templateDir.toPath().resolve("test.ftl");
+		Path expectedOutputLocation = outputDir.toPath().resolve("mydir/success-test.txt");
+		Map<String,Object> expectedMap = new HashMap<String,Object>(4);
+		expectedMap.put("testVar", "test value");
+		JsonPropertiesProvider toTest = JsonPropertiesProvider.create(dataDir, templateDir, outputDir);
+		toTest.providePropertiesFromFile(path, builder);
+		new Verifications(){{
+			Path templateLocation;
+			builder.addTemplateLocation(templateLocation = withCapture());
+			Path outputLocation;
+			builder.addOutputLocation(outputLocation = withCapture());
+			Map<String,Object> actualMap;
+			builder.addDataModel(actualMap = withCapture());
+
+			assertEquals(expectedTemplateLocation, templateLocation);
+			assertEquals(expectedOutputLocation, outputLocation);
+			assertArrayEquals(expectedMap.entrySet().toArray(), actualMap.entrySet().toArray());
+		}};
+	}
+
+	@Test
+	public void testSuccessNoDataModel(@Mocked OutputGenerator.OutputGeneratorBuilder builder) {
+		Path path = dataDir.toPath().resolve("mydir/success-test-2.txt.json");
+		Path expectedTemplateLocation = templateDir.toPath().resolve("test-pom-only.ftl");
+		Path expectedOutputLocation = outputDir.toPath().resolve("mydir/success-test-2.txt");
+		Map<String,Object> expectedMap = new HashMap<String,Object>(4);
+		JsonPropertiesProvider toTest = JsonPropertiesProvider.create(dataDir, templateDir, outputDir);
+		toTest.providePropertiesFromFile(path, builder);
+		new Verifications(){{
+			Path templateLocation;
+			builder.addTemplateLocation(templateLocation = withCapture());
+			Path outputLocation;
+			builder.addOutputLocation(outputLocation = withCapture());
+			Map<String,Object> actualMap;
+			builder.addDataModel(actualMap = withCapture());
+
+			assertEquals(expectedTemplateLocation, templateLocation);
+			assertEquals(expectedOutputLocation, outputLocation);
+			assertArrayEquals(expectedMap.entrySet().toArray(), actualMap.entrySet().toArray());
+		}};
+	}
+
+	@Test
+	public void testParsingException(@Mocked OutputGenerator.OutputGeneratorBuilder builder, @Mocked Gson gson) {
+		Path path = dataDir.toPath().resolve("mydir/success-test.txt.json");
+		new Expectations() {{
+			gson.fromJson((JsonReader) any, (Type) any); result = new RuntimeException("test exception");
+		}};
+		JsonPropertiesProvider toTest = JsonPropertiesProvider.create(dataDir, templateDir, outputDir);
+
+		assertThatExceptionOfType(RuntimeException.class).isThrownBy(() -> {
+			toTest.providePropertiesFromFile(path, builder);
+		}).withMessage("Could not parse json data file: src/test/data/generating-file-visitor/data/mydir/success-test.txt.json");
+	}
+
+	@Test
+	public void testMissingTemplateName(@Mocked OutputGenerator.OutputGeneratorBuilder builder) {
+		Path path = dataDir.toPath().resolve("mydir/missing-template-name.txt.json");
+		JsonPropertiesProvider toTest = JsonPropertiesProvider.create(dataDir, templateDir, outputDir);
+
+		assertThatExceptionOfType(RuntimeException.class).isThrownBy(() -> {
+			toTest.providePropertiesFromFile(path, builder);
+		}).withMessage("Require json data property not found: templateName");
+	}
+
+	@Test
+	public void testBadPath(@Mocked OutputGenerator.OutputGeneratorBuilder builder) {
+		Path path = testDir.toPath().resolve("badPath/success-test.txt.json");
+		JsonPropertiesProvider toTest = JsonPropertiesProvider.create(dataDir, templateDir, outputDir);
+		assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> {
+			toTest.providePropertiesFromFile(path, builder);
+		}).withMessage("visitFile() given file not in sourceDirectory: src/test/data/generating-file-visitor/badPath/success-test.txt.json");
+	}
+}
diff --git a/src/test/java/org/apache/freemarker/generator/OutputGeneratorTest.java b/src/test/java/org/apache/freemarker/generator/OutputGeneratorTest.java
new file mode 100644
index 0000000..63e5eeb
--- /dev/null
+++ b/src/test/java/org/apache/freemarker/generator/OutputGeneratorTest.java
@@ -0,0 +1,295 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License 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 org.apache.freemarker.generator;
+
+import freemarker.cache.FileTemplateLoader;
+import freemarker.template.Configuration;
+import mockit.Expectations;
+import mockit.Mocked;
+import org.assertj.core.api.Assertions;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+import org.testng.annotations.BeforeMethod;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+
+import static junit.framework.Assert.assertEquals;
+import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertTrue;
+
+public class OutputGeneratorTest {
+
+	private File testDir = new File("src/test/data/generating-file-visitor");
+	private File dataDir = new File(testDir, "data");
+	private File templateDir = new File(testDir, "template");
+	private File outputDir = new File("target/test-output/generating-file-visitor");
+	private Configuration config;
+	private Map<String, Object> dataModel = new HashMap<String,Object>();
+
+	@BeforeMethod
+	public void setupDataModel() {
+		dataModel.clear();
+		dataModel.put("testVar", "test value");
+		dataModel.put("pomProperties", new HashMap<String,String>());
+		((Map<String,String>)dataModel.get("pomProperties")).put("pomVar", "pom value");
+	}
+
+	@BeforeClass
+	public static void cleanFields() throws IOException {
+		// Clean output dir before each run.
+		File outputDir = new File("target/test-output/generating-file-visitor");
+		if (outputDir.exists()) {
+			// Recursively delete output from previous run.
+			Files.walk(outputDir.toPath())
+				 .sorted(Comparator.reverseOrder())
+				 .map(Path::toFile)
+				 .forEach(File::delete);
+		}
+	}
+
+	@BeforeMethod
+	public void before() throws IOException {
+		if (!testDir.isDirectory()) {
+			throw new RuntimeException("Can't find required test data directory. "
+				 + "If running test outside of maven, make sure working directory is the project directory. "
+				 + "Looking for: " + testDir);
+		}
+
+		config = new Configuration(Configuration.VERSION_2_3_23);
+		config.setDefaultEncoding("UTF-8");
+		config.setTemplateLoader(new FileTemplateLoader(templateDir));
+	}
+
+	@Test
+	public void createTest() {
+		OutputGenerator.OutputGeneratorBuilder builder = OutputGenerator.builder();
+		assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> {
+			builder.create();
+		}).withMessage("Must set the pomModifiedTimestamp");
+
+		builder.addPomLastModifiedTimestamp(0);
+		assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> {
+			builder.create();
+		}).withMessage("Must set a non-null generatorLocation");
+
+		File file = new File(dataDir, "mydir/success-test.txt.json");
+		builder.addGeneratorLocation(file.toPath());
+		assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> {
+			builder.create();
+		}).withMessage("Must set a non-null templateLocation");
+
+		File templateFile = new File(templateDir, "test.ftl");
+		builder.addTemplateLocation(templateFile.toPath());
+		assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> {
+			builder.create();
+		}).withMessage("Must set a non-null outputLocation");
+
+		File outputFile = new File(outputDir, "mydir/success-test.txt");
+		builder.addOutputLocation(outputFile.toPath());
+
+		assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> {
+			builder.create();
+		}).withMessage("Must set a non-null dataModel");
+
+		builder.addDataModel(dataModel);
+		OutputGenerator generator = builder.create();
+
+		assertEquals(0, generator.pomModifiedTimestamp);
+		assertEquals(file.toPath(), generator.generatorLocation);
+		assertEquals(templateFile.toPath(), generator.templateLocation);
+		assertEquals(outputFile.toPath(), generator.outputLocation);
+		assertEquals(dataModel.size(), generator.dataModel.size());
+		assertArrayEquals(dataModel.entrySet().toArray(), generator.dataModel.entrySet().toArray());
+	}
+
+	@Test
+	public void addToDataModelTest() {
+		OutputGenerator.OutputGeneratorBuilder builder = OutputGenerator.builder();
+		assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> {
+			builder.create();
+		}).withMessage("Must set the pomModifiedTimestamp");
+
+		builder.addPomLastModifiedTimestamp(0);
+		assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> {
+			builder.create();
+		}).withMessage("Must set a non-null generatorLocation");
+
+		File file = new File(dataDir, "mydir/success-test.txt.json");
+		builder.addGeneratorLocation(file.toPath());
+		assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> {
+			builder.create();
+		}).withMessage("Must set a non-null templateLocation");
+
+		File templateFile = new File(templateDir, "test.ftl");
+		builder.addTemplateLocation(templateFile.toPath());
+		assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> {
+			builder.create();
+		}).withMessage("Must set a non-null outputLocation");
+
+		File outputFile = new File(outputDir, "mydir/success-test.txt");
+		builder.addOutputLocation(outputFile.toPath());
+
+		assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> {
+			builder.create();
+		}).withMessage("Must set a non-null dataModel");
+
+		builder.addToDataModel("testVar", "testVal");
+		OutputGenerator generator = builder.create();
+
+		assertEquals(1, generator.dataModel.size());
+		assertEquals( "testVal", generator.dataModel.get("testVar"));
+
+		builder.addDataModel(dataModel);
+		builder.addToDataModel("testVar2", "testVal2");
+
+		generator = builder.create();
+
+		assertEquals(3, generator.dataModel.size());
+		assertEquals( "test value", generator.dataModel.get("testVar"));
+		assertEquals( "testVal2", generator.dataModel.get("testVar2"));
+	}
+
+	@Test
+	public void generate_SuccessTest()
+		  throws IOException {
+		OutputGenerator.OutputGeneratorBuilder builder = OutputGenerator.builder();
+		builder.addPomLastModifiedTimestamp(0);
+		File file = new File(dataDir, "mydir/success-test.txt.json");
+		builder.addGeneratorLocation(file.toPath());
+		File outputFile = new File(outputDir, "mydir/success-test.txt");
+		builder.addOutputLocation(outputFile.toPath());
+		File templateFile = new File(templateDir, "test.ftl");
+		builder.addTemplateLocation(templateFile.toPath());
+		builder.addDataModel(dataModel);
+		OutputGenerator generator = builder.create();
+		generator.generate(config);
+
+		assertTrue(outputFile.isFile());
+		List<String> lines = Files.readAllLines(outputFile.toPath(), StandardCharsets.UTF_8);
+		assertEquals(1, lines.size());
+		assertEquals("This is a test freemarker template. Test json data: 'test value'. Test pom data: 'pom value'.", lines.get(0));
+
+		// Process same file again, should not regenerate file.
+		long lastMod = outputFile.lastModified();
+		generator.generate(config);
+		assertEquals(lastMod, outputFile.lastModified());
+
+		// Set mod time to before json file.
+		lastMod = file.lastModified() - 1000; // File system may only keep 1 second precision.
+		outputFile.setLastModified(lastMod);
+		generator.generate(config);
+		assertTrue(lastMod < outputFile.lastModified());
+
+		// Set mod time to before template file.
+		lastMod = templateFile.lastModified() - 1000; // File system may only keep 1 second precision.
+		outputFile.setLastModified(lastMod);
+		generator.generate(config);
+		assertTrue(lastMod < outputFile.lastModified());
+	}
+
+	@Test
+	public void generate_badTemplateNameTest(){
+		OutputGenerator.OutputGeneratorBuilder builder = OutputGenerator.builder();
+		builder.addPomLastModifiedTimestamp(0);
+		File file = new File(dataDir, "mydir/bad-template-name.txt.json");
+		builder.addGeneratorLocation(file.toPath());
+		File outputFile = new File(outputDir, "mydir/bad-template-name.txt");
+		builder.addOutputLocation(outputFile.toPath());
+		File templateFile = new File(templateDir, "missing.ftl"); //this doesn't exist
+		builder.addTemplateLocation(templateFile.toPath());
+		builder.addDataModel(dataModel);
+		OutputGenerator generator = builder.create();
+		Assertions.assertThatExceptionOfType(RuntimeException.class).isThrownBy(() -> {
+			generator.generate(config);
+		}).withMessage("Could not read template: missing.ftl");
+	}
+
+	@Test
+	public void generate_missingVarTest() {
+		OutputGenerator.OutputGeneratorBuilder builder = OutputGenerator.builder();
+		builder.addPomLastModifiedTimestamp(0);
+		File file = new File(dataDir, "mydir/missing-var-test.txt.json");
+		builder.addGeneratorLocation(file.toPath());
+		File outputFile = new File(outputDir, "mydir/missing-var-test.txt");
+		builder.addOutputLocation(outputFile.toPath());
+		File templateFile = new File(templateDir, "test.ftl"); //this is missing a
+		builder.addTemplateLocation(templateFile.toPath());
+		dataModel.remove("testVar");
+		builder.addDataModel(dataModel);
+		OutputGenerator generator = builder.create();
+		Assertions.assertThatExceptionOfType(RuntimeException.class).isThrownBy(() -> {
+			generator.generate(config);
+		}).withMessage("Could not process template associated with data file: src/test/data/generating-file-visitor/data/mydir/missing-var-test.txt.json");
+	}
+
+	@Test
+	public void generate_badParentTest() throws IOException {
+		OutputGenerator.OutputGeneratorBuilder builder = OutputGenerator.builder();
+		builder.addPomLastModifiedTimestamp(0);
+		File file = new File(dataDir, "badParent/bad-parent-test.txt.json");
+		builder.addGeneratorLocation(file.toPath());
+		File outputFile = new File(outputDir, "badParent/bad-parent-test.txt");
+		builder.addOutputLocation(outputFile.toPath());
+		File templateFile = new File(templateDir, "test.ftl"); //this is missing a
+		builder.addTemplateLocation(templateFile.toPath());
+		builder.addDataModel(dataModel);
+		OutputGenerator generator = builder.create();
+		outputDir.mkdirs();
+		outputFile.getParentFile().createNewFile();
+
+		Assertions.assertThatExceptionOfType(RuntimeException.class).isThrownBy(() -> {
+			generator.generate(config);
+		}).withMessage("Parent directory of output file is a file: " + outputFile.getParentFile().getAbsolutePath());
+	}
+
+	@Test
+	public void generate_cantCreateOutputFileParentDirTest(
+		 @Mocked FactoryUtil factoryUtil,
+		 @Mocked File mockOutputFile) throws IOException {
+
+		File parentDir = new File("target/test-output/generating-file-visitor/mydir");
+		new Expectations(mockOutputFile, parentDir) {{
+			FactoryUtil.createFile(anyString); result = mockOutputFile;
+			mockOutputFile.exists(); result = false;
+			mockOutputFile.getParentFile(); result = parentDir;
+			parentDir.isDirectory(); result = false;
+		}};
+
+		OutputGenerator.OutputGeneratorBuilder builder = OutputGenerator.builder();
+		builder.addPomLastModifiedTimestamp(0);
+		File file = new File(dataDir, "mydir/missing-var-test.txt.json");
+		builder.addGeneratorLocation(file.toPath());
+		File outputFile = new File(outputDir, "mydir/missing-var-test.txt");
+		builder.addOutputLocation(outputFile.toPath());
+		File templateFile = new File(templateDir, "test.ftl"); //this is missing a
+		builder.addTemplateLocation(templateFile.toPath());
+		builder.addDataModel(dataModel);
+		OutputGenerator generator = builder.create();
+		Assertions.assertThatExceptionOfType(RuntimeException.class).isThrownBy(() -> {
+			generator.generate(config);
+		}).withMessage("Could not create directory: " + parentDir.getAbsoluteFile().toString());
+	}
+}