- Add java.time DateTimeFormatter service, tool and test
diff --git a/conf/test/CompleteTurbineResources.properties b/conf/test/CompleteTurbineResources.properties
index 624c0fd..50db826 100644
--- a/conf/test/CompleteTurbineResources.properties
+++ b/conf/test/CompleteTurbineResources.properties
@@ -329,6 +329,10 @@
 services.UIService.classname = org.apache.turbine.services.ui.TurbineUIService
 # services.SessionService.classname=org.apache.turbine.services.session.TurbineSessionService
 
+services.DateTimeFormatterService.classname= org.apache.turbine.services.localization.DateTimeFormatterService
+
+services.DateTimeFormatterService.earlyInit=true
+
 # Turn on the appropriate template service.
 services.VelocityService.classname=org.apache.turbine.services.velocity.TurbineVelocityService
 
@@ -472,10 +476,12 @@
 # This pull tool is to allow for easy formatting of Date object into Strings
 tool.request.dateFormatter=org.apache.turbine.services.pull.util.DateFormatter
 
+
 # Use this tool if you need a place to store data that will persist between
 # requests.  Any data stored using this tool will be stored in the session.
 tool.session.sessionData=org.apache.turbine.services.pull.util.SessionData
 
+
 # These are intake tools.
 # tool.request.intake=org.apache.turbine.services.intake.IntakeTool
 
@@ -485,6 +491,9 @@
 # This pull tool can be used to provide skins to an application
 tool.global.ui = org.apache.turbine.services.pull.tools.UITool
 
+# This pull tool is to allow for easy formatting of Datetime / TemporalAccessor object into Strings
+tool.global.dateTimeFormatter=org.apache.turbine.services.pull.util.DateTimeFormatterTool
+
 # # These properties apply to both the old UIManager and the newer UIService
 tool.ui.dir.skin = /turbine-skins/
 tool.ui.dir.image = /turbine-images/
diff --git a/src/test/org/apache/turbine/services/localization/DateTimeFormatterServiceTest.java b/src/test/org/apache/turbine/services/localization/DateTimeFormatterServiceTest.java
new file mode 100644
index 0000000..87970c4
--- /dev/null
+++ b/src/test/org/apache/turbine/services/localization/DateTimeFormatterServiceTest.java
@@ -0,0 +1,259 @@
+package org.apache.turbine.services.localization;
+
+
+import org.apache.turbine.annotation.AnnotationProcessor;
+import org.apache.turbine.annotation.TurbineService;
+import org.apache.turbine.services.pull.PullService;
+import org.apache.turbine.services.pull.util.DateTimeFormatterTool;
+import org.apache.turbine.util.TurbineConfig;
+import org.apache.turbine.util.TurbineException;
+import org.apache.velocity.context.Context;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DynamicNode;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestFactory;
+import org.junit.jupiter.api.TestInstance;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoField;
+import java.time.temporal.ChronoUnit;
+import java.time.temporal.TemporalAccessor;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.DynamicContainer.dynamicContainer;
+import static org.junit.jupiter.api.DynamicTest.dynamicTest;
+
+/**
+ * Test class for DateTimeFormatter.
+ *
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public class DateTimeFormatterServiceTest  {
+
+    @TurbineService
+    private DateTimeFormatterService df;
+
+    private TurbineConfig tc = null;
+
+    @TurbineService
+    private PullService pullService;
+
+    DateTimeFormatterTool dateTimeFormatterTool;
+    
+    @BeforeAll
+    public void setup() throws TurbineException
+    {
+        // required to initialize defaults
+        tc = new TurbineConfig(
+                        ".",
+                        "/conf/test/CompleteTurbineResources.properties");
+        tc.initialize();
+
+        AnnotationProcessor.process(this);
+
+        assertNotNull(pullService);
+        Context globalContext = pullService.getGlobalContext();
+        assertNotNull(globalContext);
+
+        dateTimeFormatterTool = (DateTimeFormatterTool) globalContext.get("dateTimeFormatter");
+        assertNotNull(dateTimeFormatterTool);
+    }
+
+    @AfterAll
+    public void tearDown()
+    {
+        tc.dispose();
+    }
+
+    /*
+     * Class under test for String format(Date, String)
+     */
+    @Test
+    void testTool() throws TurbineException
+    {
+        assertNotNull(pullService);
+        Context globalContext = pullService.getGlobalContext();
+        assertNotNull(globalContext);
+
+        DateTimeFormatterTool dateTimeFormatterTool = (DateTimeFormatterTool) globalContext.get("dateTimeFormatter");
+        assertNotNull(dateTimeFormatterTool);
+    }
+
+    @TestFactory
+    Stream<DynamicNode> testDateTimeFormatterInstances() {
+        // Stream of DateTimeFormatterInterface to check
+        Stream<DateTimeFormatterInterface> inputStream = Stream.of(df,dateTimeFormatterTool);
+        // Executes tests based on the current input value.
+        return inputStream.map(dtf -> dynamicContainer(
+                "Test " + dtf + " in factory container:",
+                Stream.of(
+                        dynamicTest("test formatDateString",() -> formatDateString(dtf) ),
+                        dynamicTest("test formatZonedDateString",() -> formatZonedDateString(dtf) ),
+                        dynamicTest("test defaultMapFromInstant",() -> defaultMapFromInstant(dtf) ),
+                        dynamicTest("test defaultMapInstant",() -> defaultMapInstant(dtf) ),
+                        dynamicTest("test mapDateStringNullString", () -> mapDateStringNullString(dtf)),
+                        dynamicTest("test mapDateStringEmptyString",() -> mapDateStringEmptyString(dtf)),
+                        dynamicTest("test formatDateStringNullFormat",() -> formatDateStringNullFormat(dtf)),
+                        dynamicTest("test formatDateStringNullString",() -> formatDateStringNullString(dtf)),
+                        dynamicTest("test formatDateStringEmptyString",() -> formatDateStringEmptyString(dtf))
+                ))
+        );
+        // Or returns a stream of dynamic tests instead of Dynamic nodes,
+        // but requires Function<DateTimeFormatterInterface, String> displayNameGenerator and
+        // e.g. ThrowingConsumer<DateTimeFormatterInterface> testExecutor = dtf
+        //return DynamicTest.stream(inputStream, displayNameGenerator, testExecutor);
+    }
+
+    void formatDateString(DateTimeFormatterInterface dateTime)
+    {
+        LocalDateTime ldt = LocalDateTime.now();
+        int day = ldt.get(ChronoField.DAY_OF_MONTH);
+        int month = ldt.get(ChronoField.MONTH_OF_YEAR); // one based
+        int year = ldt.get(ChronoField.YEAR);
+
+        String dayString = (day < 10 ? "0" : "") + day;
+        String monthString = (month < 10 ? "0" : "") + month;
+        String ddmmyyyy = dayString + "/" + monthString + "/" + year;
+
+        String mmddyyyy = "" + monthString + "/" + dayString + "/" + year;
+
+        assertEquals(ddmmyyyy,  dateTime.format(ldt, "dd/MM/yyyy"));
+        assertEquals(mmddyyyy, dateTime.format(ldt, "MM/dd/yyyy"));
+    }
+
+    void formatZonedDateString(DateTimeFormatterInterface dateTime)
+    {
+        ZonedDateTime zdt = ZonedDateTime.now();
+        int day = zdt.get(ChronoField.DAY_OF_MONTH);
+        int month = zdt.get(ChronoField.MONTH_OF_YEAR); // one based
+        int year = zdt.get(ChronoField.YEAR);
+        zdt = zdt.truncatedTo(ChronoUnit.MINUTES);
+
+        String dayString = (day < 10 ? "0" : "") + day;
+        String monthString = (month < 10 ? "0" : "") + month;
+        String ddmmyyyy = dayString + "/" + monthString + "/" + year;
+        Assertions.assertEquals(ddmmyyyy, df.format(zdt, "dd/MM/yyyy"));
+
+        int hours = zdt.get(ChronoField.HOUR_OF_DAY);
+        int mins = zdt.get(ChronoField.MINUTE_OF_HOUR);
+        int secs = zdt.get(ChronoField.SECOND_OF_MINUTE);
+        String hourString = (hours < 10 ? "0" : "") + hours;
+        String minsString = (mins < 10 ? "0" : "") + mins;
+        String secsString = (secs < 10 ? "0" : "") + secs;
+
+        String zone = zdt.getZone().getId();
+        String offset = zdt.getOffset().getId();
+        // offset formatting not easy matchable, removed
+        String mmddyyyy =
+                "" + monthString + "/" + dayString + "/" + year + " " + hourString + ":" + minsString + ":" + secsString + " " + zone;
+        // zone + offset format, removed offset ZZZ
+        assertEquals(mmddyyyy, dateTime.format(zdt, "MM/dd/yyyy HH:mm:ss VV"));
+    }
+
+    void defaultMapFromInstant(DateTimeFormatterInterface dateTime)
+    {
+        DateTimeFormatter incomingFormat = DateTimeFormatter.ISO_DATE_TIME.withZone(ZoneId.systemDefault());
+        // may throws an DateTimeParseException
+        Instant now = Instant.now().truncatedTo(ChronoUnit.MINUTES);
+        String source = incomingFormat.format(now);
+
+        TemporalAccessor dateTimeFromInstant = incomingFormat.parse(source);
+        int day = dateTimeFromInstant.get(ChronoField.DAY_OF_MONTH);
+        int month = dateTimeFromInstant.get(ChronoField.MONTH_OF_YEAR); // one based
+        int year = dateTimeFromInstant.get(ChronoField.YEAR);
+
+        String dayString = (day < 10 ? "0" : "") + day;
+        String monthString = (month < 10 ? "0" : "") + month;
+        String mmddyyyy = "" + monthString + "/" + dayString + "/" + year;
+        assertEquals(mmddyyyy,  dateTime.mapFrom(source, incomingFormat));
+    }
+
+    void defaultMapInstant(DateTimeFormatterInterface dateTime)
+    {
+        String source = dateTime.format(Instant.now());
+
+        TemporalAccessor dateTimeFromInstant = dateTime.getDefaultFormat().parse(source);
+
+        int day = dateTimeFromInstant.get(ChronoField.DAY_OF_MONTH);
+        int month = dateTimeFromInstant.get(ChronoField.MONTH_OF_YEAR); // one based
+        int year = dateTimeFromInstant.get(ChronoField.YEAR);
+
+        String dayString = (day < 10 ? "0" : "") + day;
+        String monthString = (month < 10 ? "0" : "") + month;
+        String yyyymmdd = year + "-" + monthString + "-" + dayString;
+
+        // caution we are mapping from the DateTimeFormatterTool defaultFormat-pattern without time!
+        // ISO_DATE_TIME will throw an error:
+        // java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: HourOfDay
+        DateTimeFormatter outgoingFormat = DateTimeFormatter.ISO_DATE.withZone(ZoneId.systemDefault());
+        Assertions.assertEquals(yyyymmdd, dateTime.mapTo(source, outgoingFormat));
+
+        outgoingFormat = DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneId.systemDefault());
+        Assertions.assertEquals(yyyymmdd, dateTime.mapTo(source, outgoingFormat));
+
+        // ISO_OFFSET_DATE :  Unsupported field: OffsetSeconds
+        // ISO_INSTANT; Unsupported field: InstantSeconds
+        yyyymmdd = year + monthString + dayString;
+        outgoingFormat = DateTimeFormatter.BASIC_ISO_DATE.withZone(ZoneId.systemDefault());
+        assertEquals(yyyymmdd, dateTime.mapTo(source, outgoingFormat));
+    }
+    /*
+     * Class under test for String format(null, String)
+     */
+    void mapDateStringNullString(DateTimeFormatterInterface dateTime)
+    {
+        DateTimeFormatter outgoingFormat = DateTimeFormatter.ISO_INSTANT;
+        Assertions.assertEquals("",
+               dateTime.mapFrom(null, outgoingFormat), "null argument should produce an empty String");
+    }
+
+    /*
+     * Class under test for String format(Date, "")
+     */
+    void mapDateStringEmptyString(DateTimeFormatterInterface dateTime )
+    {
+        Instant today = Instant.now();
+        String todayFormatted = df.format(today);
+        Assertions.assertEquals("",
+                dateTime.mapFrom(todayFormatted, null), "Empty pattern should map to empty String");
+    }
+
+    /*
+     * Class under test for String format(null, String)
+     */
+    void formatDateStringNullString(DateTimeFormatterInterface dateTime )
+    {
+        Assertions.assertEquals("",
+               dateTime.format(null, "MM/dd/xyyyy"), "null argument should produce an empty String");
+    }
+
+    /*
+     * Class under test for String format(Date, "")
+     */
+    void formatDateStringEmptyString(DateTimeFormatterInterface dateTime)
+    {
+        Instant today = Instant.now();
+        Assertions.assertEquals("",
+               dateTime.format(today, ""), "Empty pattern should produce empty String");
+    }
+
+    /*
+     * Class under test for String format(Date, "")
+     */
+
+    void formatDateStringNullFormat(DateTimeFormatterInterface dateTime)
+    {
+        Instant today = Instant.now();
+        Assertions.assertEquals("",
+               dateTime.format(today, null), "null pattern should produce empty String");
+    }
+
+}