///|
/// Version 1 plan input. Times are signed Int32 UTC minutes since 1970-01-01T00:00:00Z.
pub(all) struct PlanDocument {
schema_version : Int32
availability : Array[TimeWindow]
fixed_events : Array[FixedEvent]
tasks : Array[Task]
completed : Array[CompletedTask]
confirmed_blocks : Array[ScheduledBlock]
}
///|
/// JSON syntax, shape, version, and scheduler validation are separate failures.
/// Field paths use JSON Pointer notation; the root is the empty string.
pub(all) suberror PlanJsonError {
JsonSyntax(String)
MissingField(String)
WrongType(String, String)
UnexpectedField(String)
UnsupportedSchemaVersion(Int32)
InvalidPlanInput(String, ValidationError)
}
///|
fn json_object(
value : Json,
path : String,
) -> Map[String, Json] raise PlanJsonError {
match value {
Object(fields) => fields
_ => raise WrongType(path, "object")
}
}
///|
fn json_array(value : Json, path : String) -> Array[Json] raise PlanJsonError {
match value {
Array(items) => items
_ => raise WrongType(path, "array")
}
}
///|
fn json_field(
fields : Map[String, Json],
key : String,
path : String,
) -> Json raise PlanJsonError {
match fields.get(key) {
Some(value) => value
None => raise MissingField("\{path}/\{key}")
}
}
///|
fn reject_unknown_fields(
fields : Map[String, Json],
path : String,
allowed : Array[String],
) -> Unit raise PlanJsonError {
for key, _ in fields {
let mut recognized = false
for name in allowed {
if key == name {
recognized = true
}
}
if !recognized {
raise UnexpectedField("\{path}/\{pointer_token(key)}")
}
}
}
///|
fn pointer_token(key : String) -> String {
let output = StringBuilder()
for character in key.iter() {
match character {
'~' => output.write_string("~0")
'/' => output.write_string("~1")
_ => output.write_char(character)
}
}
output.to_string()
}
///|
fn json_int(value : Json, path : String) -> Int32 raise PlanJsonError {
match value {
Number(number, ..) => {
if number < -2147483648.0 || number > 2147483647.0 {
raise WrongType(path, "signed 32-bit integer")
}
let minute = number.to_int()
if minute.to_double() != number {
raise WrongType(path, "signed 32-bit integer")
}
minute
}
_ => raise WrongType(path, "signed 32-bit integer")
}
}
///|
fn json_string(value : Json, path : String) -> String raise PlanJsonError {
match value {
String(text) => text
_ => raise WrongType(path, "string")
}
}
///|
fn json_optional_int(
fields : Map[String, Json],
key : String,
path : String,
) -> Int32? raise PlanJsonError {
match fields.get(key) {
None | Some(Null) => None
Some(value) => Some(json_int(value, "\{path}/\{key}"))
}
}
///|
fn decode_window(value : Json, path : String) -> TimeWindow raise PlanJsonError {
let fields = json_object(value, path)
reject_unknown_fields(fields, path, ["start", "end"])
{
start: json_int(json_field(fields, "start", path), "\{path}/start"),
end: json_int(json_field(fields, "end", path), "\{path}/end"),
}
}
///|
fn decode_plan(value : Json) -> PlanDocument raise PlanJsonError {
let root = json_object(value, "")
let version = json_int(
json_field(root, "schema_version", ""),
"/schema_version",
)
if version != 1 {
raise UnsupportedSchemaVersion(version)
}
reject_unknown_fields(root, "", [
"schema_version", "availability", "fixed_events", "tasks", "completed", "confirmed_blocks",
])
let availability : Array[TimeWindow] = []
let windows = json_array(
json_field(root, "availability", ""),
"/availability",
)
for index, item in windows {
availability.push(decode_window(item, "/availability/\{index}"))
}
let fixed_events : Array[FixedEvent] = []
let events = json_array(json_field(root, "fixed_events", ""), "/fixed_events")
for index, item in events {
let path = "/fixed_events/\{index}"
let fields = json_object(item, path)
reject_unknown_fields(fields, path, ["id", "start", "end"])
fixed_events.push({
id: json_string(json_field(fields, "id", path), "\{path}/id"),
window: {
start: json_int(json_field(fields, "start", path), "\{path}/start"),
end: json_int(json_field(fields, "end", path), "\{path}/end"),
},
})
}
let tasks : Array[Task] = []
let task_items = json_array(json_field(root, "tasks", ""), "/tasks")
for index, item in task_items {
let path = "/tasks/\{index}"
let fields = json_object(item, path)
reject_unknown_fields(fields, path, [
"id", "duration_minutes", "deadline", "dependencies", "earliest_start", "minimum_block_minutes",
])
let dependencies : Array[String] = []
let dependency_items = json_array(
json_field(fields, "dependencies", path),
"\{path}/dependencies",
)
for dep_index, dependency in dependency_items {
dependencies.push(
json_string(dependency, "\{path}/dependencies/\{dep_index}"),
)
}
tasks.push({
id: json_string(json_field(fields, "id", path), "\{path}/id"),
duration_minutes: json_int(
json_field(fields, "duration_minutes", path),
"\{path}/duration_minutes",
),
deadline: json_int(
json_field(fields, "deadline", path),
"\{path}/deadline",
),
dependencies,
earliest_start: json_optional_int(fields, "earliest_start", path),
minimum_block_minutes: json_optional_int(
fields, "minimum_block_minutes", path,
),
})
}
let completed : Array[CompletedTask] = []
let completions = json_array(json_field(root, "completed", ""), "/completed")
for index, item in completions {
let path = "/completed/\{index}"
let fields = json_object(item, path)
reject_unknown_fields(fields, path, ["id", "completed_at"])
completed.push({
id: json_string(json_field(fields, "id", path), "\{path}/id"),
completed_at: json_int(
json_field(fields, "completed_at", path),
"\{path}/completed_at",
),
})
}
let confirmed_blocks : Array[ScheduledBlock] = []
let blocks = json_array(
json_field(root, "confirmed_blocks", ""),
"/confirmed_blocks",
)
for index, item in blocks {
let path = "/confirmed_blocks/\{index}"
let fields = json_object(item, path)
reject_unknown_fields(fields, path, ["task_id", "start", "end"])
confirmed_blocks.push({
task_id: json_string(
json_field(fields, "task_id", path),
"\{path}/task_id",
),
window: {
start: json_int(json_field(fields, "start", path), "\{path}/start"),
end: json_int(json_field(fields, "end", path), "\{path}/end"),
},
})
}
{
schema_version: version,
availability,
fixed_events,
tasks,
completed,
confirmed_blocks,
}
}
///|
fn task_json_path(document : PlanDocument, id : String) -> String {
for index, item in document.tasks {
if item.id == id {
return "/tasks/\{index}"
}
}
"/tasks"
}
///|
fn duplicate_task_json_path(document : PlanDocument, id : String) -> String {
let mut seen = false
for index, item in document.tasks {
if item.id == id {
if seen {
return "/tasks/\{index}/id"
}
seen = true
}
}
task_json_path(document, id) + "/id"
}
///|
fn completed_json_path(document : PlanDocument, id : String) -> String {
for index, item in document.completed {
if item.id == id {
return "/completed/\{index}"
}
}
"/completed"
}
///|
fn duplicate_completed_json_path(
document : PlanDocument,
id : String,
) -> String {
let mut seen = false
for index, item in document.completed {
if item.id == id {
if seen {
return "/completed/\{index}/id"
}
seen = true
}
}
completed_json_path(document, id) + "/id"
}
///|
fn confirmed_json_path(document : PlanDocument, id : String) -> String {
for index, item in document.confirmed_blocks {
if item.task_id == id {
return "/confirmed_blocks/\{index}"
}
}
"/confirmed_blocks"
}
///|
fn validation_json_path(
document : PlanDocument,
error : ValidationError,
) -> String {
match error {
InvalidWindow("availability", _, start, end) => {
for index, item in document.availability {
if item.start == start && item.end == end {
return "/availability/\{index}"
}
}
"/availability"
}
InvalidWindow(_, id, start, end) => {
for index, item in document.fixed_events {
if item.id == id && item.window.start == start && item.window.end == end {
return "/fixed_events/\{index}"
}
}
"/fixed_events"
}
InvalidTaskDuration(id, _) =>
task_json_path(document, id) + "/duration_minutes"
InvalidEarliestStart(id, _, _) =>
task_json_path(document, id) + "/earliest_start"
InvalidMinimumBlock(id, _, _) =>
task_json_path(document, id) + "/minimum_block_minutes"
DuplicateTaskId(id) => duplicate_task_json_path(document, id)
UnknownDependency(id, dependency) => {
for index, item in document.tasks {
if item.id == id {
for dep_index, dep in item.dependencies {
if dep == dependency {
return "/tasks/\{index}/dependencies/\{dep_index}"
}
}
}
}
task_json_path(document, id) + "/dependencies"
}
DependencyCycle => "/tasks"
UnknownCompletedTaskId(id) => completed_json_path(document, id) + "/id"
DuplicateCompletedTaskId(id) => duplicate_completed_json_path(document, id)
UnknownConfirmedTaskId(id)
| CompletedAndConfirmedTaskId(id)
| InvalidConfirmedWindow(id, _, _)
| ConfirmedOutsideAvailability(id, _, _)
| ConfirmedBeforeEarliestStart(id, _, _)
| ConfirmedAfterDeadline(id, _, _)
| ConfirmedBlockTooShort(id, _, _)
| InvalidConfirmedBlockCount(id, _)
| ConfirmedDurationMismatch(id, _, _)
| ConfirmedOverlapsFixedEvent(id, _)
| ConfirmedBlocksOverlap(id, _) => confirmed_json_path(document, id)
UnresolvedPriorDependency(id, _)
| PriorDependencyFinishesLate(id, _, _, _) =>
task_json_path(document, id) + "/dependencies"
}
}
///|
fn validate_plan_document(document : PlanDocument) -> Unit raise PlanJsonError {
if document.schema_version != 1 {
raise UnsupportedSchemaVersion(document.schema_version)
}
match
schedule_with_confirmed_blocks(
document.availability,
document.fixed_events,
document.tasks,
document.completed,
document.confirmed_blocks,
) {
Ok(_) => ()
Err(error) =>
raise InvalidPlanInput(validation_json_path(document, error), error)
}
}
///|
/// Parse version 1 JSON and validate it with the existing replanning entry.
/// The Result shape is a stable public API; callers inspect parse failures as data.
#warnings("-result_error_return")
pub fn parse_plan_json(source : String) -> Result[PlanDocument, PlanJsonError] {
let value = @json.parse(source) catch {
error => return Err(JsonSyntax(error.to_string()))
}
let document = decode_plan(value) catch { error => return Err(error) }
validate_plan_document(document) catch {
error => return Err(error)
}
Ok(document)
}
///|
/// Emit a canonical version 1 JSON document after scheduler validation.
/// The Result shape is kept for compatibility with existing callers.
#warnings("-result_error_return")
pub fn serialize_plan_json(
document : PlanDocument,
) -> Result[String, PlanJsonError] {
validate_plan_document(document) catch {
error => return Err(error)
}
let availability : Array[Json] = []
for item in document.availability {
availability.push({ "start": item.start, "end": item.end })
}
let fixed_events : Array[Json] = []
for item in document.fixed_events {
fixed_events.push({
"id": item.id,
"start": item.window.start,
"end": item.window.end,
})
}
let tasks : Array[Json] = []
for item in document.tasks {
let dependencies : Array[Json] = []
for dependency in item.dependencies {
dependencies.push(@json.to_json(dependency))
}
let earliest_start : Json = match item.earliest_start {
Some(minute) => @json.to_json(minute)
None => null
}
let minimum_block_minutes : Json = match item.minimum_block_minutes {
Some(minutes) => @json.to_json(minutes)
None => null
}
tasks.push({
"id": item.id,
"duration_minutes": item.duration_minutes,
"deadline": item.deadline,
"dependencies": @json.to_json(dependencies),
"earliest_start": earliest_start,
"minimum_block_minutes": minimum_block_minutes,
})
}
let completed : Array[Json] = []
for item in document.completed {
completed.push({ "id": item.id, "completed_at": item.completed_at })
}
let confirmed_blocks : Array[Json] = []
for item in document.confirmed_blocks {
confirmed_blocks.push({
"task_id": item.task_id,
"start": item.window.start,
"end": item.window.end,
})
}
let json : Json = {
"schema_version": 1,
"availability": @json.to_json(availability),
"fixed_events": @json.to_json(fixed_events),
"tasks": @json.to_json(tasks),
"completed": @json.to_json(completed),
"confirmed_blocks": @json.to_json(confirmed_blocks),
}
Ok(json.stringify())
}