How to convert multiple date formats to YYYY-MM-DD in Kotlin

1 Answer

0 votes
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
import java.time.format.DateTimeParseException
import java.time.format.ResolverStyle
import java.util.Locale

/**
 * Converts date strings written in several common human/system formats into
 * the canonical ISO-8601 form (YYYY-MM-DD).
 *
 * Architecture notes:
 *   - Parsing is table-driven: a fixed list of [DateTimeFormatter]s describes
 *     every accepted input shape. To parse a string we simply try each
 *     formatter until one accepts it. Adding a new supported format means
 *     adding one pattern string to the list — no other code changes.
 *   - `java.time` (JSR-310) is used exclusively rather than the legacy
 *     `java.util.Date` / `SimpleDateFormat` API, which was mutable and not
 *     thread-safe. `LocalDate` and `DateTimeFormatter` are immutable and
 *     thread-safe, so the formatter table below is built once at object
 *     initialization and can be shared freely across threads or calls.
 */

/**
 * Builds a formatter for a given pattern using STRICT resolution.
 *
 * Pitfall #1 this avoids: [DateTimeFormatter.ofPattern] alone defaults to
 * `ResolverStyle.SMART`. SMART does not merely reject nonsense — for the
 * day-of-month field it actively *clamps* an out-of-range day down to the
 * last valid day of that month instead of failing (e.g. it silently turns
 * "Feb 30, 2024" into 2024-02-29 and "Apr 31, 2024" into 2024-04-30). That
 * is the opposite of what a validating converter should do. STRICT
 * resolution removes this clamping and validates every field (month
 * length, leap years, ranges) exactly, so calendar-impossible dates are
 * always rejected as parse errors rather than quietly "corrected".
 *
 * Pitfall #2 this avoids: the pattern letter 'y' means *year-of-era*, and
 * under STRICT resolution `java.time` refuses to resolve a year-of-era
 * into a date unless an explicit era ('G') is also present in the input —
 * parsing fails even for a perfectly ordinary year like 2024. The pattern
 * letter 'u' (era-neutral proleptic year) has no such requirement, so
 * every pattern below uses 'u' rather than 'y' specifically so it can be
 * paired safely with STRICT.
 *
 * [Locale.ENGLISH] is fixed explicitly rather than relying on the JVM
 * default locale, since month names ("January", "Dec") must be parsed
 * consistently regardless of where the program runs.
 */
private fun strict(pattern: String): DateTimeFormatter =
    DateTimeFormatterBuilder()
        .appendPattern(pattern)
        .toFormatter(Locale.ENGLISH)
        .withResolverStyle(ResolverStyle.STRICT)

/**
 * One formatter per supported input shape. A single-letter field symbol
 * (M, d) parses a variable-width 1-or-2-digit number, so "7/5/2026" and
 * "05/01/2024" are both handled by the same "M/d/u" pattern — no need for
 * separate fixed-width variants.
 */
private val supportedFormatters: List<DateTimeFormatter> = listOf(
    strict("M/d/u"),      // 05/01/2024, 7/5/2026
    strict("M-d-u"),      // 12-31-2023
    strict("u.M.d"),      // 2024.02.09
    strict("u/M/d"),      // 2024/09/17
    strict("u-M-d"),      // 2024-09-17
    strict("MMMM d, u"),  // January 5, 2024
    strict("MMM d, u"),   // Jan 7, 2025
    strict("d MMM u"),    // 25 Dec 2025, 6 Jan 2025
    strict("d MMMM u"),   // 8 January 2025
)

/** The single output format every valid input is normalized to. */
private val isoFormat: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE

/**
 * Parses a date string against the supported formatter table, returning
 * the first successful match.
 *
 * Algorithm / complexity: for an input string this is a linear scan over
 * a small, fixed list of formatters (currently 9), each parse attempt
 * itself running in O(length of string) time. Since the formatter count
 * is a constant, the whole lookup is O(1) in the number of formatters and
 * O(n) in string length — there is no meaningful way to do better than
 * "try each known shape" when the shapes are unrelated grammars, and 9
 * linear scans over a short string is negligible in practice.
 *
 * Memory: [asSequence] makes the traversal lazy, so evaluation stops at
 * the first successful parse instead of eagerly trying every formatter
 * and building a full list of results.
 *
 * @param input the raw date text, in any of the supported formats
 * @return the parsed [LocalDate], or `null` if no formatter matched or the
 *         text does not represent a real calendar date
 */
fun parseFlexibleDate(input: String): LocalDate? {
    val trimmed = input.trim()
    if (trimmed.isEmpty()) return null

    return supportedFormatters.asSequence()
        .mapNotNull { formatter ->
            try {
                LocalDate.parse(trimmed, formatter)
            } catch (_: DateTimeParseException) {
                // Expected for every formatter that does not match this
                // particular input shape; move on to the next candidate.
                null
            }
        }
        .firstOrNull()
}

/**
 * Public convenience API: converts a date string straight to its
 * "YYYY-MM-DD" representation.
 *
 * Error handling: returns [Result] rather than throwing, so callers get a
 * normal, inspectable value instead of having to wrap every call in
 * try/catch. This mirrors how a small validation function is typically
 * exposed in a larger codebase (e.g. as part of an input-validation layer
 * before persisting or transmitting data).
 *
 * Security note: dates are parsed with a fixed, hard-coded set of
 * formatters and a fixed locale — user input never influences which
 * pattern or code path is executed (no reflection, no dynamic pattern
 * construction from untrusted input), which avoids the class of bugs
 * where format strings themselves become an injection vector.
 */
fun toIsoDate(input: String): Result<String> {
    val date = parseFlexibleDate(input)
    return if (date != null) {
        Result.success(date.format(isoFormat))
    } else {
        Result.failure(IllegalArgumentException("Invalid or unsupported format"))
    }
}

fun main() {

    // Test cases: each supported format at least once, plus edge cases
    // that must be rejected (unparseable text, and dates that are
    // syntactically well-formed but do not exist on the calendar).
    val testCases = listOf(
        "05/01/2024",       // M/d/u
        "12-31-2023",       // M-d-u
        "2024.02.09",       // u.M.d
        "January 5, 2024",  // MMMM d, u
        "2024/09/17",       // u/M/d
        "2024-09-17",       // u-M-d (already ISO)
        "25 Dec 2025",      // d MMM u
        "6 Jan 2025",       // d MMM u
        "Jan 7, 2025",      // MMM d, u
        "8 January 2025",   // d MMMM u
        "7/5/2026",         // M/d/u, single-digit month and day
        "BadFormat",        // edge case: not a date at all
        "Feb 30, 2024",     // edge case: no such day in February
        "April 31, 2024",   // edge case: April only has 30 days
        "",                 // edge case: empty input
        "2024-13-01",       // edge case: month out of range (13)
    )

    println("%-20s -> Result".format("Input"))
    println("-".repeat(45))

    testCases.forEach { input ->
        val result = toIsoDate(input).getOrElse { it.message ?: "Invalid or unsupported format" }
        println("%-20s -> %s".format(input, result))
    }
}


/*
run:

Input                -> Result
---------------------------------------------
05/01/2024           -> 2024-05-01
12-31-2023           -> 2023-12-31
2024.02.09           -> 2024-02-09
January 5, 2024      -> 2024-01-05
2024/09/17           -> 2024-09-17
2024-09-17           -> 2024-09-17
25 Dec 2025          -> 2025-12-25
6 Jan 2025           -> 2025-01-06
Jan 7, 2025          -> 2025-01-07
8 January 2025       -> 2025-01-08
7/5/2026             -> 2026-07-05
BadFormat            -> Invalid or unsupported format
Feb 30, 2024         -> Invalid or unsupported format
April 31, 2024       -> Invalid or unsupported format
                     -> Invalid or unsupported format
2024-13-01           -> Invalid or unsupported format

*/

 



answered Sep 25 by avibootz
...