///|
fn json_escape(value : String) -> String {
let builder = StringBuilder::new()
for char in value {
match char {
'"' => builder.write_string("\\\"")
'\\' => builder.write_string("\\\\")
'\n' => builder.write_string("\\n")
'\r' => builder.write_string("\\r")
'\t' => builder.write_string("\\t")
_ => builder.write_char(char)
}
}
builder.to_string()
}
///|
fn json_string(value : String) -> String {
"\"\{json_escape(value)}\""
}
///|
fn json_bool(value : Bool) -> String {
if value {
"true"
} else {
"false"
}
}
///|
fn write_json_string_array(
builder : StringBuilder,
values : Array[String],
) -> Unit {
builder.write_char('[')
for index, value in values {
if index > 0 {
builder.write_char(',')
}
builder.write_string(json_string(value))
}
builder.write_char(']')
}
///|
/// Render one assignment as a stable JSON object.
pub fn assignment_to_json(value : Assignment) -> String {
let builder = StringBuilder::new()
builder.write_string("{\"visit_id\":")
builder.write_string(json_string(value.visit_id))
builder.write_string(",\"worker_id\":")
builder.write_string(json_string(value.worker_id))
builder.write_string(",\"start_minute\":\{value.start_minute}")
builder.write_string(",\"end_minute\":\{value.end_minute}")
builder.write_string(
",\"travel_before_minutes\":\{value.travel_before_minutes}",
)
builder.write_string(",\"score\":\{value.score},\"reasons\":")
write_json_string_array(builder, value.reasons)
builder.write_char('}')
builder.to_string()
}
///|
/// Serialize a schedule for API consumers and reproducibility tests.
pub fn schedule_to_json(value : Schedule) -> String {
let builder = StringBuilder::new()
builder.write_string("{\"schema\":")
builder.write_string(json_string(value.schema))
builder.write_string(",\"feasible\":\{json_bool(value.feasible)}")
builder.write_string(",\"score\":{")
builder.write_string("\"total\":\{value.score.total}")
builder.write_string(
",\"assigned_priority\":\{value.score.assigned_priority}",
)
builder.write_string(",\"travel_penalty\":\{value.score.travel_penalty}")
builder.write_string(",\"fairness_penalty\":\{value.score.fairness_penalty}")
builder.write_string(",\"continuity_bonus\":\{value.score.continuity_bonus}")
builder.write_string(",\"preference_bonus\":\{value.score.preference_bonus}")
builder.write_string(
",\"disruption_penalty\":\{value.score.disruption_penalty}}",
)
builder.write_string(",\"assignments\":[")
for index, assignment in value.assignments {
if index > 0 {
builder.write_char(',')
}
builder.write_string(assignment_to_json(assignment))
}
builder.write_string("],\"unassigned\":[")
for index, missing in value.unassigned {
if index > 0 {
builder.write_char(',')
}
builder.write_string("{\"visit_id\":")
builder.write_string(json_string(missing.visit_id))
builder.write_string(",\"reason_codes\":")
write_json_string_array(builder, missing.reason_codes)
builder.write_string(",\"details\":")
write_json_string_array(builder, missing.details)
builder.write_char('}')
}
builder.write_string("],\"diagnostics\":[")
for index, issue in value.diagnostics {
if index > 0 {
builder.write_char(',')
}
builder.write_string("{\"level\":")
builder.write_string(json_string(diagnostic_level_name(issue.level)))
builder.write_string(",\"code\":")
builder.write_string(json_string(issue.code))
builder.write_string(",\"message\":")
builder.write_string(json_string(issue.message))
builder.write_char('}')
}
builder.write_string("]}")
builder.to_string()
}
///|
fn csv_escape(value : String) -> String {
let needs_quotes = value.contains(",") ||
value.contains("\"") ||
value.contains("\n") ||
value.contains("\r")
if !needs_quotes {
return value
}
let builder = StringBuilder::new()
builder.write_char('"')
for char in value {
if char == '"' {
builder.write_string("\"\"")
} else {
builder.write_char(char)
}
}
builder.write_char('"')
builder.to_string()
}
///|
/// Serialize assignment rows for spreadsheet analysis.
pub fn assignments_to_csv(values : Array[Assignment]) -> String {
let builder = StringBuilder::new()
builder.write_string(
"visit_id,worker_id,start_minute,end_minute,travel_before_minutes,score,reasons\n",
)
for value in sort_schedule_assignments(values) {
builder.write_string(csv_escape(value.visit_id))
builder.write_char(',')
builder.write_string(csv_escape(value.worker_id))
builder.write_string(",\{value.start_minute},\{value.end_minute}")
builder.write_string(",\{value.travel_before_minutes},\{value.score},")
builder.write_string(csv_escape(value.reasons.join("; ")))
builder.write_char('\n')
}
builder.to_string()
}
///|
/// Compact terminal summary.
pub fn schedule_summary(value : Schedule) -> String {
let builder = StringBuilder::new()
builder.write_string("NeighborRota schedule \{value.schema}\n")
builder.write_string(
"feasible=\{value.feasible}, assignments=\{value.assignments.length()}, unassigned=\{value.unassigned.length()}\n",
)
builder.write_string(score_summary(value.score))
builder.write_char('\n')
for assignment in sort_schedule_assignments(value.assignments) {
builder.write_string(
"- \{assignment.start_minute}-\{assignment.end_minute} \{assignment.visit_id} -> \{assignment.worker_id} travel=\{assignment.travel_before_minutes}\n",
)
}
for missing in value.unassigned {
builder.write_string(
"! \{missing.visit_id} unassigned: \{missing.reason_codes.join(", ")}\n",
)
}
builder.to_string()
}
///|
/// Human-oriented Markdown report.
pub fn schedule_to_markdown(value : Schedule) -> String {
let builder = StringBuilder::new()
builder.write_string("# NeighborRota Schedule Report\n\n")
builder.write_string("- Schema: `\{value.schema}`\n")
builder.write_string("- Feasible: **\{value.feasible}**\n")
builder.write_string("- Score: `\{value.score.total}`\n")
builder.write_string("- Assignments: \{value.assignments.length()}\n")
builder.write_string("- Unassigned: \{value.unassigned.length()}\n\n")
builder.write_string("## Assignments\n\n")
builder.write_string("| Visit | Worker | Start | End | Travel |\n")
builder.write_string("|---|---|---:|---:|---:|\n")
for assignment in sort_schedule_assignments(value.assignments) {
builder.write_string(
"| \{assignment.visit_id} | \{assignment.worker_id} | \{assignment.start_minute} | \{assignment.end_minute} | \{assignment.travel_before_minutes} |\n",
)
}
if value.unassigned.length() > 0 {
builder.write_string("\n## Unassigned visits\n\n")
for missing in value.unassigned {
builder.write_string(
"- **\{missing.visit_id}**: \{missing.reason_codes.join(", ")}\n",
)
}
}
builder.write_string("\n## Score breakdown\n\n`")
builder.write_string(score_summary(value.score))
builder.write_string("`\n")
builder.to_string()
}
///|
/// Markdown impact report for an event-driven update.
pub fn reschedule_to_markdown(value : RescheduleReport) -> String {
let builder = StringBuilder::new()
builder.write_string("# NeighborRota Reschedule Report\n\n")
builder.write_string("- ")
builder.write_string(reschedule_summary(value))
builder.write_string("\n\n## Changes\n\n")
for change in value.changes {
builder.write_string("- `\{assignment_change_name(change)}`: ")
builder.write_string(describe_assignment_change(change))
builder.write_char('\n')
}
builder.write_string("\n## Updated schedule\n\n")
builder.write_string(schedule_to_markdown(value.updated))
builder.to_string()
}