///|
/// One lexical CSV row before domain conversion.
pub(all) struct CsvRow {
line : Int
fields : Array[String]
} derive(Debug, Eq)
///|
/// Parsed CSV document with non-fatal syntax diagnostics.
pub(all) struct CsvDocument {
rows : Array[CsvRow]
diagnostics : Array[Diagnostic]
} derive(Debug, Eq)
///|
/// Worker import result. Valid rows remain available when other rows fail.
pub(all) struct WorkerBatch {
workers : Array[Worker]
diagnostics : Array[Diagnostic]
} derive(Debug, Eq)
///|
/// Visit import result. Valid rows remain available when other rows fail.
pub(all) struct VisitBatch {
visits : Array[Visit]
diagnostics : Array[Diagnostic]
} derive(Debug, Eq)
///|
fn finish_csv_field(fields : Array[String], builder : StringBuilder) -> Unit {
fields.push(builder.to_string())
builder.reset()
}
///|
fn finish_csv_row(
rows : Array[CsvRow],
fields : Array[String],
builder : StringBuilder,
line : Int,
) -> Unit {
finish_csv_field(fields, builder)
if fields.length() == 1 && fields[0].trim().is_empty() {
fields.clear()
return
}
rows.push({ line, fields: copy_array(fields) })
fields.clear()
}
///|
/// Parse commas, quoted cells, doubled quotes and LF/CRLF line endings.
pub fn parse_csv_document(input : String) -> CsvDocument {
let rows : Array[CsvRow] = []
let diagnostics : Array[Diagnostic] = []
let fields : Array[String] = []
let field = StringBuilder::new()
let characters : Array[Char] = []
for character in input {
characters.push(character)
}
let mut index = 0
let mut line = 1
let mut row_line = 1
let mut quoted = false
let mut after_quote = false
while index < characters.length() {
let character = characters[index]
if quoted {
if character == '"' {
if index + 1 < characters.length() && characters[index + 1] == '"' {
field.write_char('"')
index = index + 2
continue
}
quoted = false
after_quote = true
} else {
field.write_char(character)
if character == '\n' {
line = line + 1
}
}
index = index + 1
continue
}
if after_quote {
if character == ',' {
finish_csv_field(fields, field)
after_quote = false
} else if character == '\n' {
finish_csv_row(rows, fields, field, row_line)
after_quote = false
line = line + 1
row_line = line
} else if character == '\r' || character == ' ' || character == '\t' {
()
} else {
diagnostics.push(
warning_diagnostic(
"csv.content_after_quote", "unexpected content follows closing quote",
).for_entity("line-{line}"),
)
field.write_char(character)
after_quote = false
}
index = index + 1
continue
}
if character == '"' {
if field.is_empty() {
quoted = true
} else {
diagnostics.push(
warning_diagnostic(
"csv.quote_in_plain_field", "quote occurred inside an unquoted field",
).for_entity("line-{line}"),
)
field.write_char(character)
}
} else if character == ',' {
finish_csv_field(fields, field)
} else if character == '\n' {
finish_csv_row(rows, fields, field, row_line)
line = line + 1
row_line = line
} else if character != '\r' {
field.write_char(character)
}
index = index + 1
}
if quoted {
diagnostics.push(
error_diagnostic(
"csv.unclosed_quote", "quoted field was not closed before end of input",
).for_entity("line-{row_line}"),
)
}
if !field.is_empty() || fields.length() > 0 || after_quote {
finish_csv_row(rows, fields, field, row_line)
}
{ rows, diagnostics }
}
///|
fn strip_bom(value : String) -> String {
let characters : Array[Char] = []
for character in value {
characters.push(character)
}
if characters.length() > 0 && characters[0] == '\u{FEFF}' {
let builder = StringBuilder::new()
for index = 1; index < characters.length(); index = index + 1 {
builder.write_char(characters[index])
}
builder.to_string()
} else {
value
}
}
///|
fn normalize_header(value : String) -> String {
strip_bom(value).trim().to_owned().to_lower()
}
///|
fn header_index(headers : Array[String], name : String) -> Int? {
for index, header in headers {
if normalize_header(header) == name {
return Some(index)
}
}
None
}
///|
fn required_header(
headers : Array[String],
name : String,
diagnostics : Array[Diagnostic],
) -> Int? {
match header_index(headers, name) {
Some(index) => Some(index)
None => {
diagnostics.push(
error_diagnostic("csv.missing_header", "required CSV header is missing").at_field(
name,
),
)
None
}
}
}
///|
fn row_value(row : CsvRow, index : Int?) -> String {
match index {
Some(value) =>
if value >= 0 && value < row.fields.length() {
row.fields[value].trim().to_owned()
} else {
""
}
None => ""
}
}
///|
/// Parse a base-10 integer without accepting decimal or exponent notation.
pub fn parse_csv_int(input : String) -> Int? {
let value = input.trim()
if value.is_empty() {
return None
}
let characters : Array[Char] = []
for character in value {
characters.push(character)
}
let mut index = 0
let mut sign = 1
if characters[0] == '-' {
sign = -1
index = 1
} else if characters[0] == '+' {
index = 1
}
if index >= characters.length() {
return None
}
let mut result = 0
while index < characters.length() {
let character = characters[index]
if character < '0' || character > '9' {
return None
}
result = result * 10 + character.to_int() - '0'.to_int()
index = index + 1
}
Some(result * sign)
}
///|
/// Parse common CSV boolean spellings.
pub fn parse_csv_bool(input : String) -> Bool? {
match input.trim().to_lower() {
"true" | "yes" | "y" | "1" => Some(true)
"false" | "no" | "n" | "0" => Some(false)
_ => None
}
}
///|
/// Split a pipe-delimited list, trimming and discarding empty items.
pub fn parse_pipe_list(input : String) -> Array[String] {
let values : Array[String] = []
for part in input.split("|") {
let value = part.trim().to_owned()
if !value.is_empty() {
values.push(value)
}
}
values
}
///|
fn parse_skill_token(
token : String,
entity_id : String,
diagnostics : Array[Diagnostic],
) -> Skill? {
let parts = token.split(":").collect()
if parts.length() != 2 {
diagnostics.push(
error_diagnostic("csv.invalid_skill", "skill must use name:level syntax")
.for_entity(entity_id)
.at_field(token),
)
return None
}
let name = parts[0].trim().to_owned()
match parse_csv_int(parts[1].to_owned()) {
None => {
diagnostics.push(
error_diagnostic(
"csv.invalid_skill_level", "skill level is not an integer",
)
.for_entity(entity_id)
.at_field(token),
)
None
}
Some(level) => {
let value = skill(name, level)
let issues = validate_skill(value, entity_id)
diagnostics.append(issues)
if diagnostics_have_errors(issues) {
None
} else {
Some(value)
}
}
}
}
///|
/// Parse `name:level|name:level` skill declarations.
pub fn parse_skills(
input : String,
entity_id : String,
) -> (Array[Skill], Array[Diagnostic]) {
let skills : Array[Skill] = []
let diagnostics : Array[Diagnostic] = []
for token in parse_pipe_list(input) {
match parse_skill_token(token, entity_id, diagnostics) {
Some(value) => skills.push(value)
None => ()
}
}
(skills, diagnostics)
}
///|
fn parse_window_token(
token : String,
entity_id : String,
diagnostics : Array[Diagnostic],
) -> TimeWindow? {
let parts = token.split("-").collect()
if parts.length() != 2 {
diagnostics.push(
error_diagnostic(
"csv.invalid_window", "availability must use start-end minute syntax",
)
.for_entity(entity_id)
.at_field(token),
)
return None
}
match
(parse_csv_int(parts[0].to_owned()), parse_csv_int(parts[1].to_owned())) {
(Some(start), Some(end)) => {
let value = time_window(start, end)
let issues = validate_time_window(value, entity_id, "availability")
diagnostics.append(issues)
if diagnostics_have_errors(issues) {
None
} else {
Some(value)
}
}
_ => {
diagnostics.push(
error_diagnostic(
"csv.invalid_window_minutes", "availability bounds must be integers",
)
.for_entity(entity_id)
.at_field(token),
)
None
}
}
}
///|
/// Parse `start-end|start-end` availability declarations.
pub fn parse_availability(
input : String,
entity_id : String,
) -> (Array[TimeWindow], Array[Diagnostic]) {
let windows : Array[TimeWindow] = []
let diagnostics : Array[Diagnostic] = []
for token in parse_pipe_list(input) {
match parse_window_token(token, entity_id, diagnostics) {
Some(value) => windows.push(value)
None => ()
}
}
(windows, diagnostics)
}
///|
fn parse_worker_kind_value(input : String) -> WorkerKind? {
match input.trim().to_lower() {
"volunteer" => Some(Volunteer)
"care_worker" | "care-worker" | "careworker" => Some(CareWorker)
"coordinator" => Some(Coordinator)
_ => None
}
}
///|
fn parse_priority_value(input : String) -> Priority? {
match input.trim().to_lower() {
"low" => Some(Low)
"normal" => Some(Normal)
"high" => Some(High)
"critical" => Some(Critical)
_ => None
}
}
///|
fn required_int_field(
row : CsvRow,
index : Int?,
entity_id : String,
field : String,
diagnostics : Array[Diagnostic],
) -> Int? {
match parse_csv_int(row_value(row, index)) {
Some(value) => Some(value)
None => {
diagnostics.push(
error_diagnostic(
"csv.invalid_integer", "required integer field is missing or malformed",
)
.for_entity(entity_id)
.at_field(field),
)
None
}
}
}
///|
fn optional_int_field(row : CsvRow, index : Int?, fallback : Int) -> Int {
match parse_csv_int(row_value(row, index)) {
Some(value) => value
None => fallback
}
}
///|
/// Parse the documented worker CSV format.
pub fn parse_workers_csv(input : String) -> WorkerBatch {
let document = parse_csv_document(input)
let diagnostics = copy_array(document.diagnostics)
let workers : Array[Worker] = []
if document.rows.length() == 0 {
diagnostics.push(error_diagnostic("csv.empty", "worker CSV is empty"))
return { workers, diagnostics }
}
let headers = document.rows[0].fields
let id_col = required_header(headers, "id", diagnostics)
let name_col = required_header(headers, "display_name", diagnostics)
let kind_col = required_header(headers, "kind", diagnostics)
let home_id_col = required_header(headers, "home_id", diagnostics)
let home_x_col = required_header(headers, "home_x", diagnostics)
let home_y_col = required_header(headers, "home_y", diagnostics)
let zone_col = required_header(headers, "zone", diagnostics)
let skills_col = required_header(headers, "skills", diagnostics)
let availability_col = required_header(headers, "availability", diagnostics)
let max_minutes_col = header_index(headers, "max_minutes")
let max_visits_col = header_index(headers, "max_visits")
let min_break_col = header_index(headers, "min_break_minutes")
let preferred_zones_col = header_index(headers, "preferred_zones")
let unavailable_col = header_index(headers, "unavailable")
if diagnostics_have_errors(diagnostics) {
return { workers, diagnostics }
}
for row_index = 1
row_index < document.rows.length()
row_index = row_index + 1 {
let row = document.rows[row_index]
let id = row_value(row, id_col)
let row_issues : Array[Diagnostic] = []
if id.is_empty() {
row_issues.push(
error_diagnostic("csv.worker_empty_id", "worker id is empty").for_entity(
"line-{row.line}",
),
)
}
let kind = parse_worker_kind_value(row_value(row, kind_col))
if kind is None {
row_issues.push(
error_diagnostic("csv.invalid_worker_kind", "worker kind is unknown")
.for_entity(id)
.at_field("kind"),
)
}
let x = required_int_field(row, home_x_col, id, "home_x", row_issues)
let y = required_int_field(row, home_y_col, id, "home_y", row_issues)
let (skills, skill_issues) = parse_skills(row_value(row, skills_col), id)
row_issues.append(skill_issues)
let (availability, availability_issues) = parse_availability(
row_value(row, availability_col),
id,
)
row_issues.append(availability_issues)
let unavailable = match parse_csv_bool(row_value(row, unavailable_col)) {
Some(value) => value
None => false
}
match (kind, x, y) {
(Some(worker_kind), Some(home_x), Some(home_y)) => {
let value : Worker = {
id,
display_name: row_value(row, name_col),
kind: worker_kind,
skills,
availability,
home: location(
row_value(row, home_id_col),
home_x,
home_y,
row_value(row, zone_col),
),
max_minutes: optional_int_field(row, max_minutes_col, 480),
max_visits: optional_int_field(row, max_visits_col, 8),
min_break_minutes: optional_int_field(row, min_break_col, 10),
preferred_zones: parse_pipe_list(row_value(row, preferred_zones_col)),
unavailable,
}
row_issues.append(validate_worker(value))
if !diagnostics_have_errors(row_issues) {
workers.push(value)
}
}
_ => ()
}
diagnostics.append(row_issues)
}
{ workers, diagnostics }
}
///|
/// Parse the documented visit CSV format.
pub fn parse_visits_csv(input : String) -> VisitBatch {
let document = parse_csv_document(input)
let diagnostics = copy_array(document.diagnostics)
let visits : Array[Visit] = []
if document.rows.length() == 0 {
diagnostics.push(error_diagnostic("csv.empty", "visit CSV is empty"))
return { visits, diagnostics }
}
let headers = document.rows[0].fields
let id_col = required_header(headers, "id", diagnostics)
let recipient_col = required_header(headers, "recipient_id", diagnostics)
let title_col = required_header(headers, "title", diagnostics)
let location_col = required_header(headers, "location_id", diagnostics)
let x_col = required_header(headers, "x", diagnostics)
let y_col = required_header(headers, "y", diagnostics)
let zone_col = required_header(headers, "zone", diagnostics)
let start_col = required_header(headers, "window_start", diagnostics)
let end_col = required_header(headers, "window_end", diagnostics)
let duration_col = required_header(headers, "duration_minutes", diagnostics)
let skills_col = required_header(headers, "required_skills", diagnostics)
let priority_col = required_header(headers, "priority", diagnostics)
let preferred_col = header_index(headers, "preferred_workers")
let forbidden_col = header_index(headers, "forbidden_workers")
let continuity_col = header_index(headers, "continuity_group")
let required_col = header_index(headers, "required")
if diagnostics_have_errors(diagnostics) {
return { visits, diagnostics }
}
for row_index = 1
row_index < document.rows.length()
row_index = row_index + 1 {
let row = document.rows[row_index]
let id = row_value(row, id_col)
let row_issues : Array[Diagnostic] = []
let x = required_int_field(row, x_col, id, "x", row_issues)
let y = required_int_field(row, y_col, id, "y", row_issues)
let start = required_int_field(
row, start_col, id, "window_start", row_issues,
)
let end = required_int_field(row, end_col, id, "window_end", row_issues)
let duration = required_int_field(
row, duration_col, id, "duration_minutes", row_issues,
)
let priority = parse_priority_value(row_value(row, priority_col))
if priority is None {
row_issues.push(
error_diagnostic("csv.invalid_priority", "visit priority is unknown")
.for_entity(id)
.at_field("priority"),
)
}
let (skills, skill_issues) = parse_skills(row_value(row, skills_col), id)
row_issues.append(skill_issues)
match (x, y, start, end, duration, priority) {
(
Some(place_x),
Some(place_y),
Some(window_start),
Some(window_end),
Some(service_minutes),
Some(visit_priority),
) => {
let continuity_value = row_value(row, continuity_col)
let required_value = match
parse_csv_bool(row_value(row, required_col)) {
Some(value) => value
None => true
}
let value : Visit = {
id,
recipient_id: row_value(row, recipient_col),
title: row_value(row, title_col),
location: location(
row_value(row, location_col),
place_x,
place_y,
row_value(row, zone_col),
),
window: time_window(window_start, window_end),
duration_minutes: service_minutes,
required_skills: skills,
priority: visit_priority,
preferred_worker_ids: parse_pipe_list(row_value(row, preferred_col)),
forbidden_worker_ids: parse_pipe_list(row_value(row, forbidden_col)),
continuity_group: if continuity_value.is_empty() {
None
} else {
Some(continuity_value)
},
required: required_value,
}
row_issues.append(validate_visit(value))
if !diagnostics_have_errors(row_issues) {
visits.push(value)
}
}
_ => ()
}
diagnostics.append(row_issues)
}
{ visits, diagnostics }
}
///|
/// Parse both input documents and construct a validated request.
pub fn request_from_csv(
workers_csv : String,
visits_csv : String,
) -> (ScheduleRequest, Array[Diagnostic]) {
let worker_batch = parse_workers_csv(workers_csv)
let visit_batch = parse_visits_csv(visits_csv)
let diagnostics = copy_array(worker_batch.diagnostics)
diagnostics.append(visit_batch.diagnostics)
let request = schedule_request(worker_batch.workers, visit_batch.visits)
if !diagnostics_have_errors(diagnostics) {
diagnostics.append(validate_request(request))
}
(request, diagnostics)
}