///|
pub struct CsvIssue {
source : String
line : Int
column : Int
code : String
message : String
} derive(Eq, Show, ToJson)
///|
pub fn CsvIssue::new(
source : String,
line : Int,
column : Int,
code : String,
message : String,
) -> CsvIssue {
{ source, line, column, code, message }
}
///|
pub(all) enum ProblemParseResult {
Parsed(PlanningProblem)
ParseFailed(Array[CsvIssue])
} derive(Eq, Show, ToJson)
///|
fn split_owned(text : String, separator : String) -> Array[String] {
[
for part in text.split(separator) => part.to_string()
]
}
///|
fn nonempty_lines(text : String) -> Array[String] {
let lines : Array[String] = []
for raw in text.split("\n") {
let line = raw.to_string().trim().to_string()
if line.length() > 0 && !line.starts_with("#") {
lines.push(line)
}
}
lines
}
///|
fn parse_unsigned(text : String) -> Int? {
let value = text.trim()
if value.length() == 0 {
return None
}
let mut result = 0
for ch in value {
if ch < '0' || ch > '9' {
return None
}
result = result * 10 + ch.to_int() - '0'.to_int()
}
Some(result)
}
///|
fn parse_boolean(text : String) -> Bool? {
match text.trim().to_lower() {
"true" | "yes" | "1" => Some(true)
"false" | "no" | "0" => Some(false)
_ => None
}
}
///|
fn parse_skill_level(text : String) -> SkillLevel? {
match text.trim().to_lower() {
"1" | "beginner" => Some(Beginner)
"2" | "qualified" => Some(Qualified)
"3" | "advanced" => Some(Advanced)
"4" | "expert" => Some(Expert)
_ => None
}
}
///|
fn parse_shift_kind(text : String) -> ShiftKind {
match text.trim().to_lower() {
"morning" => Morning
"day" => Day
"evening" => Evening
"night" => Night
"oncall" | "on-call" => OnCall
other => Custom(other.to_string())
}
}
///|
fn expected_columns(
source : String,
line : Int,
actual : Int,
expected : Int,
) -> CsvIssue {
CsvIssue::new(
source,
line,
0,
"column_count",
"expected " +
expected.to_string() +
" columns but found " +
actual.to_string(),
)
}
///|
fn parse_number_field(
source : String,
line : Int,
column : Int,
name : String,
text : String,
issues : Array[CsvIssue],
) -> Int {
match parse_unsigned(text) {
Some(value) => value
None => {
issues.push(
CsvIssue::new(
source,
line,
column,
"invalid_number",
name + " must be a non-negative integer",
),
)
0
}
}
}
///|
fn parse_worker_skills(
text : String,
source : String,
line : Int,
issues : Array[CsvIssue],
) -> Array[WorkerSkill] {
let result : Array[WorkerSkill] = []
if text.trim().length() == 0 {
return result
}
for token in text.split("|") {
let parts = split_owned(token.to_string(), ":")
if parts.length() != 2 {
issues.push(
CsvIssue::new(
source, line, 7, "invalid_worker_skill", "skill must use skill-id:level syntax",
),
)
} else {
match parse_skill_level(parts[1]) {
Some(level) =>
result.push(WorkerSkill::new(parts[0].trim().to_string(), level))
None =>
issues.push(
CsvIssue::new(
source, line, 7, "invalid_skill_level", "skill level must be beginner, qualified, advanced, expert, or 1-4",
),
)
}
}
}
result
}
///|
fn parse_requirements(
text : String,
source : String,
line : Int,
issues : Array[CsvIssue],
) -> Array[SkillRequirement] {
let result : Array[SkillRequirement] = []
if text.trim().length() == 0 {
return result
}
for token in text.split("|") {
let parts = split_owned(token.to_string(), ":")
if parts.length() != 3 {
issues.push(
CsvIssue::new(
source, line, 8, "invalid_requirement", "requirement must use skill-id:level:count syntax",
),
)
} else {
let level = parse_skill_level(parts[1])
let count = parse_unsigned(parts[2])
match (level, count) {
(Some(level), Some(count)) if count > 0 =>
result.push(
SkillRequirement::new(parts[0].trim().to_string(), level, count),
)
_ =>
issues.push(
CsvIssue::new(
source, line, 8, "invalid_requirement_value", "requirement level or count is invalid",
),
)
}
}
}
result
}
///|
fn parse_workers_csv(text : String, issues : Array[CsvIssue]) -> Array[Worker] {
let workers : Array[Worker] = []
let lines = nonempty_lines(text)
if lines.length() == 0 {
issues.push(
CsvIssue::new("workers", 0, 0, "empty_file", "workers CSV is empty"),
)
return workers
}
for index = 1; index < lines.length(); index = index + 1 {
let line_number = index + 1
let fields = split_owned(lines[index], ",")
if fields.length() != 8 {
issues.push(expected_columns("workers", line_number, fields.length(), 8))
continue
}
let min_minutes = parse_number_field(
"workers",
line_number,
2,
"min_minutes",
fields[2],
issues,
)
let target_minutes = parse_number_field(
"workers",
line_number,
3,
"target_minutes",
fields[3],
issues,
)
let max_minutes = parse_number_field(
"workers",
line_number,
4,
"max_minutes",
fields[4],
issues,
)
let consecutive = parse_number_field(
"workers",
line_number,
5,
"max_consecutive",
fields[5],
issues,
)
let rest = parse_number_field(
"workers",
line_number,
6,
"min_rest",
fields[6],
issues,
)
let mut worker = Worker::new(
fields[0].trim().to_string(),
fields[1].trim().to_string(),
)
.with_limits(min_minutes, target_minutes, max_minutes)
.with_rest_rules(consecutive, rest)
for skill in parse_worker_skills(fields[7], "workers", line_number, issues) {
worker = worker.add_skill(skill)
}
workers.push(worker)
}
workers
}
///|
fn parse_shifts_csv(text : String, issues : Array[CsvIssue]) -> Array[Shift] {
let shifts : Array[Shift] = []
let lines = nonempty_lines(text)
if lines.length() == 0 {
issues.push(
CsvIssue::new("shifts", 0, 0, "empty_file", "shifts CSV is empty"),
)
return shifts
}
for index = 1; index < lines.length(); index = index + 1 {
let line_number = index + 1
let fields = split_owned(lines[index], ",")
if fields.length() != 9 {
issues.push(expected_columns("shifts", line_number, fields.length(), 9))
continue
}
let start = parse_number_field(
"shifts",
line_number,
2,
"start",
fields[2],
issues,
)
let end = parse_number_field(
"shifts",
line_number,
3,
"end",
fields[3],
issues,
)
let required = parse_number_field(
"shifts",
line_number,
4,
"required",
fields[4],
issues,
)
let preferred = parse_number_field(
"shifts",
line_number,
5,
"preferred",
fields[5],
issues,
)
let weekend = match parse_boolean(fields[7]) {
Some(value) => value
None => {
issues.push(
CsvIssue::new(
"shifts", line_number, 7, "invalid_boolean", "weekend must be true or false",
),
)
false
}
}
let mut shift = Shift::new(
fields[0].trim().to_string(),
fields[1].trim().to_string(),
start,
end,
required,
)
.with_preferred_workers(preferred)
.with_kind(parse_shift_kind(fields[6]))
.with_weekend(weekend)
for
requirement in parse_requirements(
fields[8],
"shifts",
line_number,
issues,
) {
shift = shift.add_requirement(requirement)
}
shifts.push(shift)
}
shifts
}
///|
fn discover_skills(
workers : Array[Worker],
shifts : Array[Shift],
) -> Array[Skill] {
let ids : Array[String] = []
for worker in workers {
for skill in worker.skills {
if !ids.contains(skill.skill_id) {
ids.push(skill.skill_id)
}
}
}
for shift in shifts {
for requirement in shift.requirements {
if !ids.contains(requirement.skill_id) {
ids.push(requirement.skill_id)
}
}
}
let skills : Array[Skill] = []
for id in ids {
skills.push(Skill::new(id, id))
}
skills
}
///|
pub fn parse_problem_csv(
name : String,
workers_csv : String,
shifts_csv : String,
) -> ProblemParseResult {
let issues : Array[CsvIssue] = []
let workers = parse_workers_csv(workers_csv, issues)
let shifts = parse_shifts_csv(shifts_csv, issues)
if issues.length() > 0 {
return ParseFailed(issues)
}
let mut problem = PlanningProblem::new(name)
for skill in discover_skills(workers, shifts) {
problem = problem.add_skill(skill)
}
for worker in workers {
problem = problem.add_worker(worker)
}
for shift in shifts {
problem = problem.add_shift(shift)
}
let validation = problem.validate()
if diagnostics_have_errors(validation) {
for diagnostic in validation {
issues.push(
CsvIssue::new("problem", 0, 0, diagnostic.code, diagnostic.message),
)
}
ParseFailed(issues)
} else {
Parsed(problem)
}
}
///|
pub fn workers_csv_template() -> String {
"id,name,min_minutes,target_minutes,max_minutes,max_consecutive,min_rest,skills\n" +
"ada,Ada,0,960,1440,5,600,first-aid:4|lead:3\n" +
"grace,Grace,0,960,1440,5,600,first-aid:2\n"
}
///|
pub fn shifts_csv_template() -> String {
"id,label,start,end,required,preferred,kind,weekend,requirements\n" +
"day-1,Day 1,480,960,1,1,day,false,first-aid:2:1\n" +
"day-2,Day 2,1920,2400,1,1,day,false,\n"
}