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
*/