///|
/// Data model of a change window plan and of the audit result.
///|
/// Severity of a diagnostic or of a dependency issue.
pub(all) enum Severity {
Error
Warning
} derive(Eq)
///|
pub extend Severity with Eq::{equal}
///|
pub extend Severity with Eq::{not_equal}
///|
/// Lower case label: `error` or `warning`.
pub fn Severity::label(self : Severity) -> String {
match self {
Severity::Error => "error"
Severity::Warning => "warning"
}
}
///|
/// One problem found while parsing or auditing.
pub(all) struct Diagnostic {
severity : Severity
code : String
line : Int
detail : String
}
///|
/// Render as `error line 3: invalid-instant (start="2026-13-01T22:00")`.
///
/// The `line` part is omitted when the diagnostic is not tied to an input line.
pub fn Diagnostic::to_line(self : Diagnostic) -> String {
let position = if self.line > 0 {
" line " + self.line.to_string()
} else {
""
}
let head = self.severity.label() + position + ": " + self.code
if self.detail == "" {
head
} else {
head + " (" + self.detail + ")"
}
}
///|
/// Risk level attached to a change window.
pub(all) enum Risk {
Low
Medium
High
} derive(Eq)
///|
pub extend Risk with Eq::{equal}
///|
pub extend Risk with Eq::{not_equal}
///|
/// Parse `low`, `medium` or `high`. Matching is exact and lower case.
pub fn Risk::parse(text : String) -> Risk? {
match text {
"low" => Some(Risk::Low)
"medium" => Some(Risk::Medium)
"high" => Some(Risk::High)
_ => None
}
}
///|
/// Lower case label of a risk level.
pub fn Risk::label(self : Risk) -> String {
match self {
Risk::Low => "low"
Risk::Medium => "medium"
Risk::High => "high"
}
}
///|
/// Kind of record: a planned change, or a period during which no change may run
/// at all.
pub(all) enum WindowKind {
Change
Freeze
} derive(Eq)
///|
pub extend WindowKind with Eq::{equal}
///|
pub extend WindowKind with Eq::{not_equal}
///|
/// Parse `change` or `freeze`. `window` is accepted as a synonym of `change`.
pub fn WindowKind::parse(text : String) -> WindowKind? {
match text {
"change" => Some(WindowKind::Change)
"window" => Some(WindowKind::Change)
"freeze" => Some(WindowKind::Freeze)
_ => None
}
}
///|
/// Lower case label: `change` or `freeze`.
pub fn WindowKind::label(self : WindowKind) -> String {
match self {
WindowKind::Change => "change"
WindowKind::Freeze => "freeze"
}
}
///|
/// One maintenance or change window: a half open `[start, end)` interval with
/// the identifiers of the windows it depends on.
///
/// A freeze uses the same shape but forbids changes instead of describing one,
/// so it carries no risk level and no dependencies.
pub(all) struct Window {
id : String
service : String
start : Int
end : Int
risk : Risk
depends : Array[String]
kind : WindowKind
}
///|
/// True when the record is a change freeze.
pub fn Window::is_freeze(self : Window) -> Bool {
self.kind == WindowKind::Freeze
}
///|
/// True when the record is a planned change.
pub fn Window::is_change(self : Window) -> Bool {
self.kind == WindowKind::Change
}
///|
/// Length of the window in minutes. Always positive for a parsed window.
pub fn Window::duration(self : Window) -> Int {
self.end - self.start
}
///|
/// Render as
/// `database | orders-db | 2026-10-01T22:00 -> 2026-10-01T23:30 | 1h30m | high | -`.
///
/// An empty service or an empty dependency list renders as `-`.
pub fn Window::to_line(self : Window) -> String {
let service = if self.service == "" { "-" } else { self.service }
let dependencies = if self.depends.length() == 0 {
"-"
} else {
join(self.depends, ", ")
}
let builder = StringBuilder()
builder.write_string(self.id)
builder.write_string(" | ")
builder.write_string(service)
builder.write_string(" | ")
builder.write_string(format_instant(self.start))
builder.write_string(" -> ")
builder.write_string(format_instant(self.end))
builder.write_string(" | ")
builder.write_string(format_duration(self.duration()))
builder.write_string(" | ")
builder.write_string(self.risk.label())
builder.write_string(" | ")
builder.write_string(dependencies)
builder.to_string()
}
///|
/// Render a freeze as `freeze | company | 2026-12-24T00:00 -> 2026-12-27T00:00 | 3d`.
pub fn Window::to_freeze_line(self : Window) -> String {
let service = if self.service == "" { "-" } else { self.service }
self.id +
" | " +
service +
" | " +
format_instant(self.start) +
" -> " +
format_instant(self.end) +
" | " +
format_duration(self.duration())
}
///|
/// A parsed set of change windows together with everything reported while
/// parsing.
pub(all) struct Plan {
windows : Array[Window]
diagnostics : Array[Diagnostic]
}
///|
/// Records of one kind, in input order.
pub fn Plan::windows_of_kind(self : Plan, kind : WindowKind) -> Array[Window] {
let selected : Array[Window] = []
for window in self.windows {
if window.kind == kind {
selected.push(window)
}
}
selected
}
///|
/// Planned changes, in input order.
pub fn Plan::changes(self : Plan) -> Array[Window] {
self.windows_of_kind(WindowKind::Change)
}
///|
/// Change freezes, in input order.
pub fn Plan::freezes(self : Plan) -> Array[Window] {
self.windows_of_kind(WindowKind::Freeze)
}
///|
/// Number of windows that were accepted.
pub fn Plan::window_count(self : Plan) -> Int {
self.windows.length()
}
///|
/// First window with the given id in input order, or `None`.
///
/// Duplicate ids are reported as errors while parsing; this lookup keeps using
/// the first record so that a plan with duplicates still resolves every
/// dependency to exactly one window.
pub fn Plan::find(self : Plan, id : String) -> Window? {
find_window(self.windows, id)
}
///|
/// True when parsing produced at least one error. Records with an error are
/// dropped from `windows`, so the plan itself is still safe to inspect.
pub fn Plan::has_errors(self : Plan) -> Bool {
has_error_diagnostic(self.diagnostics)
}
///|
/// First window with the given id, or `None`.
fn find_window(windows : Array[Window], id : String) -> Window? {
for window in windows {
if window.id == id {
return Some(window)
}
}
None
}
///|
/// True when any diagnostic has error severity.
fn has_error_diagnostic(diagnostics : Array[Diagnostic]) -> Bool {
for diagnostic in diagnostics {
if diagnostic.severity == Severity::Error {
return true
}
}
false
}
///|
/// A half open `[start, end)` interval on the minute axis.
pub(all) struct Slot {
start : Int
end : Int
}
///|
/// Length of the interval in minutes.
pub fn Slot::duration(self : Slot) -> Int {
self.end - self.start
}
///|
/// Render as `2026-10-01T20:00 -> 2026-10-01T22:00 (2h)`.
pub fn Slot::to_line(self : Slot) -> String {
format_instant(self.start) +
" -> " +
format_instant(self.end) +
" (" +
format_duration(self.duration()) +
")"
}
///|
/// Operational impact of an overlap, derived from the risk of both windows.
pub(all) enum Impact {
Critical
Major
Minor
} derive(Eq)
///|
pub extend Impact with Eq::{equal}
///|
pub extend Impact with Eq::{not_equal}
///|
/// Lower case label: `critical`, `major` or `minor`.
pub fn Impact::label(self : Impact) -> String {
match self {
Impact::Critical => "critical"
Impact::Major => "major"
Impact::Minor => "minor"
}
}
///|
/// Two windows that share at least one minute.
pub(all) struct Overlap {
first : String
second : String
minutes : Int
impact : Impact
}
///|
/// Render as `api vs database: 45m (major)`.
pub fn Overlap::to_line(self : Overlap) -> String {
self.first +
" vs " +
self.second +
": " +
format_duration(self.minutes) +
" (" +
self.impact.label() +
")"
}
///|
/// A planned change that runs inside a change freeze.
pub(all) struct FreezeConflict {
window_id : String
freeze_id : String
minutes : Int
}
///|
/// Render as `api runs inside freeze blackout: 45m`.
pub fn FreezeConflict::to_line(self : FreezeConflict) -> String {
self.window_id +
" runs inside " +
self.freeze_id +
": " +
format_duration(self.minutes)
}
///|
/// Kind of dependency problem reported by `audit`.
pub(all) enum DependencyProblem {
MissingDependency
SelfDependency
OutOfOrder
BackToBack
} derive(Eq)
///|
pub extend DependencyProblem with Eq::{equal}
///|
pub extend DependencyProblem with Eq::{not_equal}
///|
/// Kebab case label: `missing-dependency`, `self-dependency`, `out-of-order` or
/// `back-to-back`.
pub fn DependencyProblem::label(self : DependencyProblem) -> String {
match self {
MissingDependency => "missing-dependency"
SelfDependency => "self-dependency"
OutOfOrder => "out-of-order"
BackToBack => "back-to-back"
}
}
///|
/// A problem between one window and one of the windows it depends on.
pub(all) struct DependencyIssue {
window_id : String
dependency : String
problem : DependencyProblem
severity : Severity
detail : String
}
///|
/// Render as
/// `error api -> database: out-of-order (the dependency ends 45m after the dependent starts)`.
pub fn DependencyIssue::to_line(self : DependencyIssue) -> String {
self.severity.label() +
" " +
self.window_id +
" -> " +
self.dependency +
": " +
self.problem.label() +
" (" +
self.detail +
")"
}
///|
/// Result of auditing a plan.
pub(all) struct AuditReport {
plan : Plan
overlaps : Array[Overlap]
dependency_issues : Array[DependencyIssue]
freeze_conflicts : Array[FreezeConflict]
blocked_minutes : Int
}
///|
/// Number of records that were audited.
pub fn AuditReport::window_count(self : AuditReport) -> Int {
self.plan.window_count()
}
///|
/// Number of audited records that are planned changes.
pub fn AuditReport::change_count(self : AuditReport) -> Int {
self.plan.changes().length()
}
///|
/// Number of audited records that are change freezes.
pub fn AuditReport::freeze_count(self : AuditReport) -> Int {
self.plan.freezes().length()
}
///|
/// Number of errors: parse errors, overlaps, freeze conflicts and error level
/// dependency issues.
pub fn AuditReport::error_count(self : AuditReport) -> Int {
let mut total = 0
for diagnostic in self.plan.diagnostics {
if diagnostic.severity == Severity::Error {
total = total + 1
}
}
for issue in self.dependency_issues {
if issue.severity == Severity::Error {
total = total + 1
}
}
total + self.overlaps.length() + self.freeze_conflicts.length()
}
///|
/// Number of warnings: parse warnings and warning level dependency issues.
pub fn AuditReport::warning_count(self : AuditReport) -> Int {
let mut total = 0
for diagnostic in self.plan.diagnostics {
if diagnostic.severity == Severity::Warning {
total = total + 1
}
}
for issue in self.dependency_issues {
if issue.severity == Severity::Warning {
total = total + 1
}
}
total
}
///|
/// True when the audit found at least one error.
pub fn AuditReport::has_errors(self : AuditReport) -> Bool {
self.error_count() > 0
}
///|
/// `PASS` when there is no error and `FAIL` otherwise. Warnings do not fail an
/// audit.
pub fn AuditReport::status(self : AuditReport) -> String {
if self.has_errors() {
"FAIL"
} else {
"PASS"
}
}