///|
/// The "can I add this change, and when" layer.
///
/// Everything here answers one of three questions about a *candidate* interval
/// that is not part of the plan yet:
///
/// 1. `can_schedule` explains why the candidate is refused or accepted,
/// 2. `earliest_start` and `slack_minutes` look for room around it,
/// 3. `earliest_schedule` compresses the whole plan to the earliest dependency
/// aware schedule without ever moving a change earlier.
///
/// Every decision is reproducible: conflicts are collected in plan order and no
/// result depends on a hash or on a library sort.
///|
/// One planned change the candidate runs into, with the shared minutes.
pub(all) struct SlotConflict {
window_id : String
minutes : Int
}
///|
/// Render as `api: 45m`.
pub fn SlotConflict::to_line(self : SlotConflict) -> String {
self.window_id + ": " + format_duration(self.minutes)
}
///|
/// True when `candidate` shares at least one minute with a change or a freeze
/// of the plan.
///
/// Used by `earliest_start` to decide whether a position has to be skipped; it
/// is the union of the two blocking rules of `can_schedule`.
fn schedule_is_blocked(plan : Plan, candidate : Slot) -> Bool {
for window in plan.windows {
if overlap_minutes(window.start, window.end, candidate.start, candidate.end) >
0 {
return true
}
}
false
}
///|
/// The verdict for one candidate interval.
///
/// `reasons` lists the rule codes that fired, in the fixed order
/// `no-duration`, `too-short`, `blocked-by-change`, `inside-freeze`,
/// `unknown-dependency`, `dependency-order`, each at most once. `allowed` is
/// true exactly when `reasons` is empty.
///
/// Freezes and changes both block. A freeze never appears in
/// `change_conflicts`: freezes are reported through `freeze_ids`.
pub(all) struct ScheduleCheck {
candidate : Slot
allowed : Bool
reasons : Array[String]
change_conflicts : Array[SlotConflict]
freeze_ids : Array[String]
}
///|
/// Render as ` -> ()`, followed by
/// ` ()` when at least one rule fired.
///
/// For example
/// `REJECTED 2026-10-01T22:30 -> 2026-10-01T23:30 (1h) (blocked-by-change,inside-freeze)`.
pub fn ScheduleCheck::to_line(self : ScheduleCheck) -> String {
let status = if self.allowed { "ALLOWED" } else { "REJECTED" }
let head = status +
" " +
format_instant(self.candidate.start) +
" -> " +
format_instant(self.candidate.end) +
" (" +
format_duration(self.candidate.duration()) +
")"
if self.reasons.length() == 0 {
head
} else {
head + " (" + join(self.reasons, ",") + ")"
}
}
///|
/// Decide whether a candidate interval may be added to the plan.
///
/// The rules are applied in a fixed order and each reason is reported at most
/// once:
///
/// - `no-duration`: the candidate is empty or reversed,
/// - `too-short`: shorter than `min_minutes`,
/// - `blocked-by-change`: it overlaps a planned change; a change that only
/// touches the candidate is not a conflict,
/// - `inside-freeze`: it overlaps a change freeze,
/// - `unknown-dependency`: one of `depends` is not a record of the plan,
/// - `dependency-order`: a dependency that exists ends after the candidate
/// starts. Ending exactly at the start is accepted, so a change may run back
/// to back with what it waits for.
///
/// Dependencies are read from the `depends` argument rather than from a record,
/// because the candidate is not in the plan yet.
pub fn can_schedule(
plan : Plan,
candidate : Slot,
depends : Array[String],
min_minutes : Int,
) -> ScheduleCheck {
let reasons : Array[String] = []
let duration = candidate.duration()
if duration <= 0 {
reasons.push("no-duration")
} else if duration < min_minutes {
reasons.push("too-short")
}
let conflicts : Array[SlotConflict] = []
for change in plan.changes() {
let minutes = overlap_minutes(
change.start,
change.end,
candidate.start,
candidate.end,
)
if minutes > 0 {
conflicts.push({ window_id: change.id, minutes, })
}
}
if conflicts.length() > 0 {
reasons.push("blocked-by-change")
}
let freeze_ids : Array[String] = []
for freeze in freezes_hit_by(plan, candidate) {
freeze_ids.push(freeze.id)
}
if freeze_ids.length() > 0 {
reasons.push("inside-freeze")
}
for dependency in depends {
match plan.find(dependency) {
None => push_unique_reason(reasons, "unknown-dependency")
Some(window) =>
if window.end > candidate.start {
push_unique_reason(reasons, "dependency-order")
}
}
}
{
candidate,
allowed: reasons.length() == 0,
reasons,
change_conflicts: conflicts,
freeze_ids,
}
}
///|
/// Append a rule code unless it is already reported, which keeps each reason in
/// its first position and the list free of duplicates.
fn push_unique_reason(reasons : Array[String], reason : String) -> Unit {
if !reasons.contains(reason) {
reasons.push(reason)
}
}
///|
/// Free function alias of `ScheduleCheck::to_line`.
pub fn schedule_check_line(check : ScheduleCheck) -> String {
check.to_line()
}
///|
/// Build a `[start, end)` interval, so that callers do not have to name the
/// struct literal of `Slot`.
pub fn candidate_slot(start : Int, end : Int) -> Slot {
{ start, end, }
}
///|
/// Smallest slack, in minutes, that `id` has against its dependencies:
/// `change.start - dependency.end`.
///
/// A negative value means the dependency outlives the start of the dependent,
/// `0` means back to back. `None` when the record is not in the plan or when
/// none of its dependencies exists, because there is then nothing to measure.
pub fn slack_minutes(plan : Plan, id : String) -> Int? {
let record = match plan.find(id) {
Some(window) => window
None => return None
}
let mut slack : Int? = None
for dependency in record.depends {
match plan.find(dependency) {
None => ()
Some(target) => {
let value = record.start - target.end
match slack {
None => slack = Some(value)
Some(current) => if value < current { slack = Some(value) }
}
}
}
}
slack
}
///|
/// Earliest minute at or after `candidate.start` where an interval of the same
/// duration fits without overlapping a change or a freeze.
///
/// The walk never steps minute by minute across a blocked stretch: when the
/// current position is blocked it jumps to the end of the blocking interval, so
/// a long freeze is skipped in one step. The last position tried is
/// `candidate.start + search_minutes`.
///
/// `None` when the duration is not positive, when `min_minutes` is greater than
/// the duration, when `search_minutes` is negative, or when nothing fits inside
/// the search range. A zero `search_minutes` therefore asks the single question
/// "does the candidate fit where it is?".
pub fn earliest_start(
plan : Plan,
candidate : Slot,
min_minutes : Int,
search_minutes : Int,
) -> Int? {
let duration = candidate.duration()
if duration <= 0 || min_minutes > duration || search_minutes < 0 {
return None
}
let limit = candidate.start + search_minutes
let mut cursor = candidate.start
while cursor <= limit {
let probe = { start: cursor, end: cursor + duration, }
if !schedule_is_blocked(plan, probe) {
return Some(cursor)
}
let mut moved = cursor
for window in plan.windows {
if overlap_minutes(window.start, window.end, probe.start, probe.end) > 0 {
if window.end > moved {
moved = window.end
}
}
}
if moved <= cursor {
cursor = cursor + 1
} else {
cursor = moved
}
}
None
}
///|
/// One change of the compressed schedule.
///
/// `start`/`end` are the planned minutes, `original_start` the minute the plan
/// asked for and `shift_minutes` the delay that was added. The shift is never
/// negative: a change is never moved earlier than its original start.
pub(all) struct ScheduledChange {
id : String
start : Int
end : Int
original_start : Int
shift_minutes : Int
}
///|
/// Render as ` -> (+)`, for example
/// `db 2026-10-01T19:00 -> 2026-10-01T20:00 (+0m)`.
pub fn ScheduledChange::to_line(self : ScheduledChange) -> String {
self.id +
" " +
format_instant(self.start) +
" -> " +
format_instant(self.end) +
" (+" +
format_duration(self.shift_minutes) +
")"
}
///|
/// Compress the whole plan into the earliest dependency aware schedule.
///
/// Changes are processed in `execution_order`, and a change starts at the later
/// of its original start and the scheduled end of every dependency that exists
/// in the plan and was already scheduled. Duration and order are preserved, and
/// nothing is moved earlier, so the result is a valid schedule whenever the
/// original plan is acyclic.
///
/// `None` when the plan cannot be ordered, that is when it contains a cycle.
pub fn earliest_schedule(plan : Plan) -> Array[ScheduledChange]? {
let order = match execution_order(plan) {
Some(ids) => ids
None => return None
}
let scheduled : Array[ScheduledChange] = []
for id in order {
match plan.find(id) {
None => ()
Some(window) => {
let mut start = window.start
// Dependencies already scheduled decide how far the start is pushed
// forward. A dependency that is missing from the plan is simply not in
// `scheduled`, and the audit reports it separately.
for dependency in window.depends {
for already in scheduled {
if already.id == dependency && already.end > start {
start = already.end
}
}
}
scheduled.push({
id,
start,
end: start + window.duration(),
original_start: window.start,
shift_minutes: start - window.start,
})
}
}
}
Some(scheduled)
}
///|
/// Render a schedule that is already known to be acyclic, one two space
/// indented line per change.
///
/// An empty schedule renders as `Earliest dependency aware schedule (0 changes)\n (no changes)\n`.
pub fn render_schedule(schedule : Array[ScheduledChange]) -> String {
let builder = StringBuilder()
builder.write_string(
"Earliest dependency aware schedule (" +
count_text(schedule.length(), "change", "changes") +
")\n",
)
if schedule.length() == 0 {
builder.write_string(" (no changes)\n")
} else {
for change in schedule {
builder.write_string(" ")
builder.write_string(change.to_line())
builder.write_char('\n')
}
}
builder.to_string()
}
///|
/// Render the result of `earliest_schedule`, including the cyclic case that the
/// schedule array itself cannot express.
///
/// - a schedule: `Earliest dependency aware schedule ( changes)` and one
/// indented line per change,
/// - `None`: `Earliest dependency aware schedule` and ` (cyclic)`,
/// - an empty schedule: `Earliest dependency aware schedule (0 changes)` and
/// ` (no changes)`.
///
/// The plan is not needed: `earliest_schedule` already turned it into the
/// schedule, and the cyclic case carries no per change detail.
/// The text always ends with exactly one newline.
pub fn render_schedule_result(schedule : Array[ScheduledChange]?) -> String {
match schedule {
None => "Earliest dependency aware schedule\n" + " (cyclic)\n"
Some(changes) => render_schedule(changes)
}
}
///|
/// The first `limit` free slots of `safe_slots(plan, from, to, min_minutes)`, in
/// order.
///
/// The horizon is not extended to fill the limit: fewer slots are returned when
/// the plan leaves less room. `limit <= 0` yields an empty array.
pub fn next_slots(
plan : Plan,
from : Int,
to : Int,
min_minutes : Int,
limit : Int,
) -> Array[Slot] {
let found : Array[Slot] = []
if limit <= 0 {
return found
}
for slot in safe_slots(plan, from, to, min_minutes) {
if found.length() >= limit {
break
}
found.push(slot)
}
found
}