import Foundation
// =============================================================================
// Converts date strings written in several common formats into the canonical
// ISO-8601 form (YYYY-MM-DD).
//
// Architecture notes:
// - Parsing is table-driven: a fixed array of DateFormatter instances
// describes every accepted input shape. To parse a string we simply try
// each formatter until one produces a non-nil Date. Adding a new
// supported format means adding one pattern string to the list, with no
// other code changes.
// - Foundation's DateFormatter (backed by ICU) is used rather than manual
// string splitting/parsing, since calendar arithmetic - leap years,
// variable month lengths, day-of-week resolution - is exactly the kind
// of logic that is easy to get subtly wrong by hand and is already
// implemented correctly, and efficiently, in the platform library.
// =============================================================================
enum FlexibleDateConverter {
/// Error describing why a date string could not be converted.
enum ConversionError: Error, CustomStringConvertible {
case unsupportedFormat
var description: String {
"Invalid or unsupported format"
}
}
/// Builds a DateFormatter configured for strict, locale-independent
/// parsing of one specific pattern.
///
/// Pitfall this avoids: a DateFormatter created with the device's
/// current locale/calendar can parse the very same digits differently
/// - or refuse to parse at all - depending on where the app is running
/// (12-hour vs 24-hour conventions, non-Gregorian calendars, different
/// digit systems, etc.). Apple's own guidance (Technical Q&A QA1480)
/// for any *fixed*, machine-defined format - as opposed to a format
/// meant to be displayed to a person - is to pin both the locale to
/// "en_US_POSIX" and the time zone explicitly, which is done here.
private static func makeFormatter(pattern: String) -> DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = pattern
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "UTC")
// isLenient already defaults to false, but it is set explicitly so
// the intent is visible at the call site: reject calendar-impossible
// dates such as "Feb 30" outright instead of rolling them over into
// a nearby valid date (which is what a *lenient* formatter would do).
formatter.isLenient = false
return formatter
}
/// One formatter per supported input shape, built once and reused.
///
/// Performance note: constructing a DateFormatter is comparatively
/// expensive - it loads locale, calendar and symbol data on first use.
/// Building this table once as a static property, instead of inside the
/// parsing function, means that cost is paid a single time for the
/// life of the program rather than on every call to parse(_:).
///
/// A single-letter pattern 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/yyyy" pattern - no separate fixed-width variant is
/// needed.
private static let supportedFormatters: [DateFormatter] = [
"M/d/yyyy", // 05/01/2024, 7/5/2026
"M-d-yyyy", // 12-31-2023
"yyyy.M.d", // 2024.02.09
"yyyy/M/d", // 2024/09/17
"yyyy-M-d", // 2024-09-17
"MMMM d, yyyy", // January 5, 2024
"MMM d, yyyy", // Jan 7, 2025
"d MMM yyyy", // 25 Dec 2025, 6 Jan 2025
"d MMMM yyyy" // 8 January 2025
].map(makeFormatter)
/// The single output formatter every valid input is normalized to.
private static let isoFormatter: DateFormatter = makeFormatter(pattern: "yyyy-MM-dd")
/// Parses a date string against the supported formatter table, returning
/// the first successful match.
///
/// Algorithm / complexity: this is a linear scan over a small, fixed
/// array of formatters (currently 9), and each parse attempt itself runs
/// in time proportional to the length of the input string. 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 meaningfully
/// faster approach when the accepted shapes are unrelated grammars, and
/// nine linear scans over a short string cost microseconds in practice.
///
/// Memory: `first(where:)` short-circuits at the first match, so no
/// intermediate array of results is ever materialized.
///
/// - Parameter input: the raw date text, in any of the supported formats
/// - Returns: the parsed `Date`, or `nil` if no formatter matched or the
/// text does not represent a real calendar date
static func parse(_ input: String) -> Date? {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return supportedFormatters
.lazy
.compactMap { $0.date(from: trimmed) }
.first
}
/// Public convenience API: converts a date string straight to its
/// "YYYY-MM-DD" representation.
///
/// Error handling: returns `Result` rather than throwing or returning an
/// optional, so a caller gets an explicit, inspectable value and - in
/// the failure case - a specific, named error rather than a bare `nil`
/// that carries no information about what went wrong.
///
/// Security note: parsing uses a fixed, hard-coded set of formatters and
/// a fixed locale/time zone - user input never influences which pattern
/// or code path executes (no dynamic format-string construction from
/// untrusted input), so the input string cannot be crafted to change
/// parsing behavior beyond matching or failing to match a known shape.
static func toISODate(_ input: String) -> Result<String, ConversionError> {
guard let date = parse(input) else {
return .failure(.unsupportedFormat)
}
return .success(isoFormatter.string(from: date))
}
}
/// Entry point: runs the converter against every supported format plus a
/// set of edge cases that must be rejected - unparseable text, and dates
/// that are syntactically well-formed but do not exist on the calendar.
func main() {
let testCases = [
"05/01/2024", // M/d/yyyy
"12-31-2023", // M-d-yyyy
"2024.02.09", // yyyy.M.d
"January 5, 2024", // MMMM d, yyyy
"2024/09/17", // yyyy/M/d
"2024-09-17", // yyyy-M-d (already ISO)
"25 Dec 2025", // d MMM yyyy
"6 Jan 2025", // d MMM yyyy
"Jan 7, 2025", // MMM d, yyyy
"8 January 2025", // d MMMM yyyy
"7/5/2026", // M/d/yyyy, 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)
]
let header = "Input".padding(toLength: 20, withPad: " ", startingAt: 0)
print("\(header) -> Result")
print(String(repeating: "-", count: 45))
for input in testCases {
let result: String
switch FlexibleDateConverter.toISODate(input) {
case .success(let iso):
result = iso
case .failure(let error):
result = error.description
}
let paddedInput = input.padding(toLength: 20, withPad: " ", startingAt: 0)
print("\(paddedInput) -> \(result)")
}
}
main()
/*
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
*/