///|
/// Text format parser for change window plans.
///
/// One record per line, fields separated by `;`, each field written as
/// `key=value`:
///
/// ```text
/// id=database;service=orders-db;start=2026-10-01T22:00;end=2026-10-01T23:30;risk=high;depends=
/// id=api;service=orders-api;start=2026-10-01T22:45;end=2026-10-01T23:15;risk=medium;depends=database
/// ```
///
/// Blank lines and lines starting with `#` are ignored. Empty fields such as
/// the trailing `;` above and the empty `depends=` value are ignored as well.
///|
/// Parse a plan from its text form.
///
/// A record that produces an error is dropped from `plan.windows`; a record
/// that only produces warnings is kept. Every problem is reported in
/// `plan.diagnostics` with the 1-based line number it came from.
pub fn parse_plan(source : String) -> Plan {
let windows : Array[Window] = []
let diagnostics : Array[Diagnostic] = []
let normalized = source
.replace_all(old="\r\n", new="\n")
.replace_all(old="\r", new="\n")
let mut line_number = 0
for raw in normalized.split("\n") {
line_number = line_number + 1
let line = raw.to_owned()
let trimmed = line.trim()
if trimmed.length() == 0 {
continue
}
if trimmed[0] == '#' {
continue
}
let mut id = ""
let mut service = ""
let mut start_text = ""
let mut end_text = ""
let mut risk_text = ""
let mut depends_text = ""
let mut kind_text = ""
let mut has_id = false
let mut has_start = false
let mut has_end = false
let seen : Array[String] = []
let mut record_ok = true
for field in line.split(";") {
let piece = field.trim()
if piece.length() == 0 {
continue
}
let parsed = match split_field(piece) {
Some(value) => value
None => {
diagnostics.push(
error_at(line_number, "malformed-field", piece.to_owned()),
)
record_ok = false
continue
}
}
let key = parsed.0
let value = parsed.1
if !is_known_key(key) {
diagnostics.push(warning_at(line_number, "unknown-field", key))
continue
}
if seen.contains(key) {
diagnostics.push(error_at(line_number, "duplicate-field", key))
record_ok = false
continue
}
seen.push(key)
if key == "id" {
id = value
has_id = true
} else if key == "service" {
service = value
} else if key == "start" {
start_text = value
has_start = true
} else if key == "end" {
end_text = value
has_end = true
} else if key == "risk" {
risk_text = value
} else if key == "kind" {
kind_text = value
} else {
depends_text = value
}
}
if !has_id {
diagnostics.push(error_at(line_number, "missing-field", "id"))
record_ok = false
}
if !has_start {
diagnostics.push(error_at(line_number, "missing-field", "start"))
record_ok = false
}
if !has_end {
diagnostics.push(error_at(line_number, "missing-field", "end"))
record_ok = false
}
let mut start_minutes = 0
let mut end_minutes = 0
if has_start {
match parse_instant(start_text) {
Some(value) => start_minutes = value
None => {
diagnostics.push(
error_at(
line_number,
"invalid-instant",
"start=\"" + start_text + "\"",
),
)
record_ok = false
}
}
}
if has_end {
match parse_instant(end_text) {
Some(value) => end_minutes = value
None => {
diagnostics.push(
error_at(line_number, "invalid-instant", "end=\"" + end_text + "\""),
)
record_ok = false
}
}
}
if record_ok && end_minutes <= start_minutes {
diagnostics.push(
error_at(
line_number,
"end-not-after-start",
format_instant(start_minutes) + " -> " + format_instant(end_minutes),
),
)
record_ok = false
}
let mut risk = Risk::Medium
if risk_text != "" {
match Risk::parse(risk_text) {
Some(value) => risk = value
None => {
diagnostics.push(error_at(line_number, "invalid-risk", risk_text))
record_ok = false
}
}
}
let depends : Array[String] = []
if depends_text != "" {
for raw_dependency in depends_text.split(",") {
let dependency = raw_dependency.to_owned().trim().to_owned()
if dependency == "" {
continue
}
if !is_valid_identifier(dependency) {
diagnostics.push(
error_at(line_number, "invalid-dependency", dependency),
)
record_ok = false
continue
}
if depends.contains(dependency) {
diagnostics.push(
warning_at(line_number, "duplicate-dependency", dependency),
)
continue
}
depends.push(dependency)
}
}
let mut kind = WindowKind::Change
if kind_text != "" {
match WindowKind::parse(kind_text) {
Some(value) => kind = value
None => {
diagnostics.push(error_at(line_number, "invalid-kind", kind_text))
record_ok = false
}
}
}
if kind == WindowKind::Freeze {
if risk_text != "" {
diagnostics.push(
warning_at(line_number, "ignored-field-on-freeze", "risk"),
)
}
if depends.length() > 0 {
diagnostics.push(
error_at(line_number, "freeze-with-dependency", join(depends, ",")),
)
record_ok = false
}
}
if !record_ok {
continue
}
if !is_valid_identifier(id) {
diagnostics.push(error_at(line_number, "invalid-id", id))
continue
}
match find_window(windows, id) {
Some(_) => diagnostics.push(error_at(line_number, "duplicate-id", id))
None => ()
}
windows.push({
id,
service,
start: start_minutes,
end: end_minutes,
risk,
depends,
kind,
})
}
{ windows, diagnostics, }
}
///|
/// Field names accepted inside a record.
fn is_known_key(key : String) -> Bool {
key == "id" ||
key == "service" ||
key == "start" ||
key == "end" ||
key == "risk" ||
key == "kind" ||
key == "depends"
}
///|
/// Diagnostic with error severity.
fn error_at(line : Int, code : String, detail : String) -> Diagnostic {
{ severity: Severity::Error, code, line, detail, }
}
///|
/// Diagnostic with warning severity.
fn warning_at(line : Int, code : String, detail : String) -> Diagnostic {
{ severity: Severity::Warning, code, line, detail, }
}