///|
let expected_header = "lap,driver,lap_time_ms,compound,tyre_age_laps,pit,track_status,weather"
///|
priv struct ParsedRecord {
record : LapRecord
line : Int
}
///|
fn csv_error(
code : RaceErrorCode,
line : Int,
field : String,
message : String,
) -> RaceError {
{ code, line, field, message, }
}
///|
fn parse_integer(
text : String,
line : Int,
field : String,
) -> Result[Int, RaceError] {
if text == "" {
return Err(
csv_error(InvalidInteger, line, field, "expected a decimal integer"),
)
}
let mut value = 0
for char in text {
if !char.is_ascii_digit() {
return Err(
csv_error(InvalidInteger, line, field, "expected a decimal integer"),
)
}
let digit = char.to_int() - '0'.to_int()
if value > 214748364 || (value == 214748364 && digit > 7) {
return Err(
csv_error(
InvalidInteger,
line,
field,
"integer is outside the supported range",
),
)
}
value = value * 10 + digit
}
Ok(value)
}
///|
fn parse_compound(text : String, line : Int) -> Result[Compound, RaceError] {
match text {
"SOFT" => Ok(Soft)
"MEDIUM" => Ok(Medium)
"HARD" => Ok(Hard)
"INTERMEDIATE" => Ok(Intermediate)
"WET" => Ok(Wet)
_ =>
Err(
csv_error(
InvalidValue,
line,
"compound",
"compound must be SOFT, MEDIUM, HARD, INTERMEDIATE, or WET",
),
)
}
}
///|
fn parse_pit(text : String, line : Int) -> Result[Bool, RaceError] {
match text {
"true" => Ok(true)
"false" => Ok(false)
_ =>
Err(
csv_error(
InvalidValue,
line,
"pit",
"pit must be lowercase true or false",
),
)
}
}
///|
fn parse_track_status(
text : String,
line : Int,
) -> Result[TrackStatus, RaceError] {
match text {
"GREEN" => Ok(Green)
"SAFETY_CAR" => Ok(SafetyCar)
_ =>
Err(
csv_error(
InvalidValue,
line,
"track_status",
"track_status must be GREEN or SAFETY_CAR",
),
)
}
}
///|
fn parse_weather(text : String, line : Int) -> Result[Weather, RaceError] {
match text {
"DRY" => Ok(Dry)
"DAMP" => Ok(Damp)
"WET" => Ok(Wet)
_ =>
Err(
csv_error(
InvalidValue,
line,
"weather",
"weather must be DRY, DAMP, or WET",
),
)
}
}
///|
fn parse_record(
line_text : String,
line : Int,
) -> Result[ParsedRecord, RaceError] {
if line_text.contains("\"") {
return Err(
csv_error(
UnsupportedCsvSyntax,
line,
"csv",
"quoted CSV fields are not supported",
),
)
}
let fields = line_text
.split(",")
.map(field => field.trim().to_owned())
.to_array()
if fields.length() != 8 {
return Err(
csv_error(FieldCount, line, "row", "expected exactly 8 CSV fields"),
)
}
let lap = match parse_integer(fields[0], line, "lap") {
Ok(value) => value
Err(error) => return Err(error)
}
if lap < 1 {
return Err(csv_error(InvalidValue, line, "lap", "lap must be at least 1"))
}
if fields[1] == "" {
return Err(
csv_error(InvalidValue, line, "driver", "driver must not be empty"),
)
}
let lap_time_ms = match parse_integer(fields[2], line, "lap_time_ms") {
Ok(value) => value
Err(error) => return Err(error)
}
if lap_time_ms < 1 {
return Err(
csv_error(
InvalidValue,
line,
"lap_time_ms",
"lap_time_ms must be greater than zero",
),
)
}
let compound = match parse_compound(fields[3], line) {
Ok(value) => value
Err(error) => return Err(error)
}
let tyre_age_laps = match parse_integer(fields[4], line, "tyre_age_laps") {
Ok(value) => value
Err(error) => return Err(error)
}
if tyre_age_laps < 1 {
return Err(
csv_error(
InvalidValue,
line,
"tyre_age_laps",
"tyre_age_laps must be at least 1",
),
)
}
let pit = match parse_pit(fields[5], line) {
Ok(value) => value
Err(error) => return Err(error)
}
let track_status = match parse_track_status(fields[6], line) {
Ok(value) => value
Err(error) => return Err(error)
}
let weather = match parse_weather(fields[7], line) {
Ok(value) => value
Err(error) => return Err(error)
}
Ok({
record: {
lap,
driver: fields[1],
lap_time_ms,
compound,
tyre_age_laps,
pit,
track_status,
weather,
},
line,
})
}
///|
/// Parse, validate, and canonically sort a CSV v1 race input string.
///
/// Accepts CRLF or LF, ignores blank lines, trims ordinary cell whitespace, and
/// returns records sorted by lap then driver. It rejects unsupported quoted CSV
/// syntax, malformed fields, enum values, and cross-row race constraints with a
/// structured `RaceError` containing a CSV line and field.
pub fn parse_race_csv(input : String) -> Result[RaceData, RaceError] {
let nonblank = []
let lines = input.split("\n").to_array()
for index = 0; index < lines.length(); index = index + 1 {
let raw_line = lines[index].to_owned()
if raw_line.trim() != "" {
// Preserve header whitespace for exact-header validation while removing CRLF's CR.
nonblank.push((index + 1, raw_line.trim_end(chars="\r").to_owned()))
}
}
if nonblank.length() == 0 {
return Err(
csv_error(
EmptyInput,
0,
"csv",
"CSV input contains no header or data rows",
),
)
}
if nonblank[0].1 != expected_header {
return Err(
csv_error(
InvalidHeader,
nonblank[0].0,
"header",
"header must exactly match CSV v1",
),
)
}
let parsed = []
for index = 1; index < nonblank.length(); index = index + 1 {
let (line, text) = nonblank[index]
match parse_record(text, line) {
Ok(record) => parsed.push(record)
Err(error) => return Err(error)
}
}
if parsed.length() == 0 {
return Err(
csv_error(EmptyInput, 0, "csv", "CSV input contains no data rows"),
)
}
parsed.sort_by((left, right) => {
if left.record.lap != right.record.lap {
left.record.lap - right.record.lap
} else {
left.record.driver.compare(right.record.driver)
}
})
match validate_records(parsed) {
Ok(records) => Ok({ records, })
Err(error) => Err(error)
}
}