use alloc::{
collections::BTreeMap,
format,
string::{String, ToString},
vec::Vec,
};
use super::{
CapacityWave, EvalContract, GateContract, LaneBinding, PlanCapacity, PlanDocument, PlanError,
PlanManifestV2, PlanNode, ReviewContract,
};
const SECTIONS: [&str; 6] = [
"Scope contract",
"Assumptions and decisions",
"Interfaces",
"Phases",
"Capacity",
"Nodes",
];
pub fn parse_plan(markdown: &str) -> Result<PlanDocument, PlanError> {
if markdown.contains('\r') {
return Err(parse_error("CR bytes are not canonical plan input"));
}
let mut lines = markdown.lines();
let title = lines.next().unwrap_or_default();
if !title.starts_with("# Plan: ") || title[8..].trim().is_empty() {
return Err(parse_error("first line must be `# Plan: <run>`"));
}
let title_run = title[8..].trim();
let mut sections: Vec<(String, Vec<&str>)> = Vec::new();
for line in lines {
if let Some(name) = line.strip_prefix("## ") {
sections.push((name.trim().to_string(), Vec::new()));
} else if let Some((_, body)) = sections.last_mut() {
body.push(line);
} else if !line.trim().is_empty() {
return Err(parse_error("content appears before the first section"));
}
}
let actual = sections
.iter()
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>();
if actual != SECTIONS {
return Err(parse_error(format!(
"section order must be exactly `{}`; found `{}`",
SECTIONS.join(" -> "),
actual.join(" -> ")
)));
}
let scope = parse_bullets(§ions[0].1, "scope")?;
expect_keys(
&scope,
&[
"Schema",
"Run",
"Seed",
"Mesh",
"Planning evidence",
"Goal",
"Deliverables",
"Lanes",
"Root roles",
"Child lead roles",
"Planning lead",
"Engineer count",
"Review rejection limit",
"Fourth rejection",
"Root continuation",
"Exclusions",
],
"scope",
)?;
let run = field(&scope, "Run")?;
if run != title_run {
return Err(parse_error(format!(
"title run `{title_run}` does not match scope run `{run}`"
)));
}
require_nonempty_section(§ions[1].1, "Assumptions and decisions")?;
require_nonempty_section(§ions[2].1, "Interfaces")?;
require_nonempty_section(§ions[3].1, "Phases")?;
Ok(PlanDocument {
manifest: PlanManifestV2 {
schema: field(&scope, "Schema")?.to_string(),
run: run.to_string(),
seed: field(&scope, "Seed")?.to_string(),
mesh: field(&scope, "Mesh")?.to_string(),
planning_evidence: field(&scope, "Planning evidence")?.to_string(),
goal: field(&scope, "Goal")?.to_string(),
deliverables: parse_list(field(&scope, "Deliverables")?, "Deliverables")?,
lanes: parse_list(field(&scope, "Lanes")?, "Lanes")?,
root_roles: parse_list(field(&scope, "Root roles")?, "Root roles")?,
child_lead_roles: parse_list(field(&scope, "Child lead roles")?, "Child lead roles")?,
planning_lead: field(&scope, "Planning lead")?.to_string(),
engineer_count: parse_usize(field(&scope, "Engineer count")?, "Engineer count")?,
review_rejection_limit: parse_usize(
field(&scope, "Review rejection limit")?,
"Review rejection limit",
)?,
fourth_rejection: field(&scope, "Fourth rejection")?.to_string(),
root_continuation: field(&scope, "Root continuation")?.to_string(),
exclusions: parse_list(field(&scope, "Exclusions")?, "Exclusions")?,
capacity: parse_capacity(§ions[4].1)?,
nodes: parse_nodes(§ions[5].1)?,
},
})
}
fn require_nonempty_section(lines: &[&str], name: &str) -> Result<(), PlanError> {
if lines.iter().all(|line| line.trim().is_empty()) {
return Err(parse_error(format!("section `{name}` must not be empty")));
}
Ok(())
}
fn parse_bullets<'a>(
lines: &[&'a str],
context: &str,
) -> Result<BTreeMap<String, &'a str>, PlanError> {
let mut fields = BTreeMap::new();
for line in lines
.iter()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
{
let Some(rest) = line.strip_prefix("- ") else {
return Err(parse_error(format!(
"{context} line must be a `- Key: value` bullet: `{line}`"
)));
};
let Some((key, value)) = rest.split_once(':') else {
return Err(parse_error(format!(
"{context} bullet has no value: `{line}`"
)));
};
if value.trim().is_empty() {
return Err(parse_error(format!("{context} field `{key}` is empty")));
}
if fields
.insert(key.trim().to_string(), value.trim())
.is_some()
{
return Err(parse_error(format!("duplicate {context} field `{key}`")));
}
}
Ok(fields)
}
fn parse_capacity(lines: &[&str]) -> Result<PlanCapacity, PlanError> {
let body = fenced_yaml(lines, "Capacity")?;
let fields = parse_yaml_fields(body, "capacity")?;
expect_keys(
&fields,
&[
"logical_lane_limit",
"host_process_ceiling",
"project_spawn_max_parallel",
"plan_process_ceiling",
"parent_role_cap",
"run_budget",
"simultaneous_process_ceiling",
"per_lane_child_wave_ceiling",
"disk_min_mib",
"model_quota",
"backpressure",
"cargo_targets",
"conductors",
"schedule",
"scale_outcome",
],
"capacity",
)?;
let scale = field(&fields, "scale_outcome")?;
Ok(PlanCapacity {
logical_lane_limit: parse_usize(
field(&fields, "logical_lane_limit")?,
"logical_lane_limit",
)?,
host_process_ceiling: parse_usize(
field(&fields, "host_process_ceiling")?,
"host_process_ceiling",
)?,
project_spawn_max_parallel: parse_usize(
field(&fields, "project_spawn_max_parallel")?,
"project_spawn_max_parallel",
)?,
plan_process_ceiling: parse_usize(
field(&fields, "plan_process_ceiling")?,
"plan_process_ceiling",
)?,
parent_role_cap: parse_usize(field(&fields, "parent_role_cap")?, "parent_role_cap")?,
run_budget: parse_usize(field(&fields, "run_budget")?, "run_budget")?,
simultaneous_process_ceiling: parse_usize(
field(&fields, "simultaneous_process_ceiling")?,
"simultaneous_process_ceiling",
)?,
per_lane_child_wave_ceiling: parse_usize(
field(&fields, "per_lane_child_wave_ceiling")?,
"per_lane_child_wave_ceiling",
)?,
disk_min_mib: field(&fields, "disk_min_mib")?
.parse()
.map_err(|_| parse_error("capacity `disk_min_mib` must be an integer"))?,
model_quota: parse_usize(field(&fields, "model_quota")?, "model_quota")?,
backpressure: field(&fields, "backpressure")?.to_string(),
cargo_targets: parse_bindings(field(&fields, "cargo_targets")?, "cargo target")?,
conductors: parse_bindings(field(&fields, "conductors")?, "conductor")?,
schedule: parse_schedule(field(&fields, "schedule")?)?,
scale_outcome: (scale != "none").then(|| scale.to_string()),
})
}
fn parse_nodes(lines: &[&str]) -> Result<Vec<PlanNode>, PlanError> {
let mut nodes = Vec::new();
let mut index = 0;
while index < lines.len() {
let line = lines[index].trim();
if line.is_empty() {
index += 1;
continue;
}
let Some(heading) = line.strip_prefix("### ") else {
return Err(parse_error(format!(
"Nodes section requires `### <node-id>` headings, found `{line}`"
)));
};
index += 1;
if lines.get(index).map(|line| line.trim()) != Some("```yaml") {
return Err(parse_error(format!(
"node `{heading}` must contain one fenced yaml record"
)));
}
index += 1;
let start = index;
while index < lines.len() && lines[index].trim() != "```" {
index += 1;
}
if index == lines.len() {
return Err(parse_error(format!(
"node `{heading}` yaml fence is unclosed"
)));
}
let fields = parse_yaml_fields(&lines[start..index], "node")?;
index += 1;
nodes.push(parse_node(heading.trim(), &fields)?);
}
if nodes.is_empty() {
return Err(parse_error("Nodes section has zero nodes"));
}
Ok(nodes)
}
fn parse_node(heading: &str, fields: &BTreeMap<String, &str>) -> Result<PlanNode, PlanError> {
expect_keys(
fields,
&[
"id",
"seed_deliverables",
"lane",
"role",
"work_kind",
"outcome",
"owns",
"forbidden",
"consumes",
"produces",
"depends_on",
"red",
"green",
"eval",
"evidence",
"review",
"failure_route",
"rollback",
],
"node",
)?;
let id = field(fields, "id")?;
if id != heading {
return Err(parse_error(format!(
"node heading `{heading}` does not match id `{id}`"
)));
}
Ok(PlanNode {
id: id.to_string(),
seed_deliverables: parse_list(field(fields, "seed_deliverables")?, "seed_deliverables")?,
lane: field(fields, "lane")?.to_string(),
role: field(fields, "role")?.to_string(),
work_kind: field(fields, "work_kind")?.to_string(),
outcome: field(fields, "outcome")?.to_string(),
owns: parse_list(field(fields, "owns")?, "owns")?,
forbidden: parse_list(field(fields, "forbidden")?, "forbidden")?,
consumes: parse_list(field(fields, "consumes")?, "consumes")?,
produces: parse_list(field(fields, "produces")?, "produces")?,
depends_on: parse_list(field(fields, "depends_on")?, "depends_on")?,
red: parse_gate(field(fields, "red")?, "red")?,
green: parse_gate(field(fields, "green")?, "green")?,
eval: parse_eval(field(fields, "eval")?)?,
evidence: field(fields, "evidence")?.to_string(),
review: parse_review(field(fields, "review")?)?,
failure_route: field(fields, "failure_route")?.to_string(),
rollback: field(fields, "rollback")?.to_string(),
})
}
fn parse_gate(value: &str, name: &str) -> Result<GateContract, PlanError> {
let fields = parse_inline_map(value, name)?;
expect_keys(&fields, &["command", "expects", "reason"], name)?;
Ok(GateContract {
command: parse_list(field(&fields, "command")?, &format!("{name}.command"))?,
expects: field(&fields, "expects")?.to_string(),
reason: field(&fields, "reason")?.to_string(),
})
}
fn parse_eval(value: &str) -> Result<EvalContract, PlanError> {
let fields = parse_inline_map(value, "eval")?;
expect_keys(&fields, &["command", "threshold"], "eval")?;
let command = field(&fields, "command")?;
let threshold = field(&fields, "threshold")?;
Ok(EvalContract {
command: if command == "none" {
Vec::new()
} else {
parse_list(command, "eval.command")?
},
threshold: if threshold == "none" {
None
} else {
Some(
threshold
.parse()
.map_err(|_| parse_error("eval threshold must be an integer or `none`"))?,
)
},
})
}
fn parse_review(value: &str) -> Result<ReviewContract, PlanError> {
let Some((role, predicate)) = value.split_once(':') else {
return Err(parse_error("review must be `<role>: <predicate>`"));
};
if role.trim().is_empty() || predicate.trim().is_empty() {
return Err(parse_error("review role and predicate must be nonempty"));
}
Ok(ReviewContract {
role: role.trim().to_string(),
predicate: predicate.trim().to_string(),
})
}
fn parse_inline_map<'a>(
value: &'a str,
context: &str,
) -> Result<BTreeMap<String, &'a str>, PlanError> {
let inner = value
.strip_prefix('{')
.and_then(|value| value.strip_suffix('}'))
.ok_or_else(|| parse_error(format!("{context} must be an inline map")))?;
let mut fields = BTreeMap::new();
for entry in split_top_level(inner, ',')? {
let Some((key, value)) = entry.split_once(':') else {
return Err(parse_error(format!(
"{context} entry `{entry}` has no value"
)));
};
let key = key.trim();
let value = value.trim();
if key.is_empty() || value.is_empty() {
return Err(parse_error(format!("{context} has an empty key or value")));
}
if fields.insert(key.to_string(), value).is_some() {
return Err(parse_error(format!("duplicate {context} field `{key}`")));
}
}
Ok(fields)
}
fn fenced_yaml<'a>(lines: &'a [&'a str], context: &str) -> Result<&'a [&'a str], PlanError> {
let start = lines.iter().position(|line| line.trim() == "```yaml");
let end = lines.iter().rposition(|line| line.trim() == "```");
match (start, end) {
(Some(start), Some(end)) if start < end => {
if lines[..start].iter().any(|line| !line.trim().is_empty())
|| lines[end + 1..].iter().any(|line| !line.trim().is_empty())
{
return Err(parse_error(format!(
"{context} may contain only one fenced yaml record"
)));
}
Ok(&lines[start + 1..end])
}
_ => Err(parse_error(format!(
"{context} requires one fenced yaml record"
))),
}
}
fn parse_yaml_fields<'a>(
lines: &[&'a str],
context: &str,
) -> Result<BTreeMap<String, &'a str>, PlanError> {
let mut fields = BTreeMap::new();
for line in lines
.iter()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
{
let Some((key, value)) = line.split_once(':') else {
return Err(parse_error(format!(
"{context} line has no value: `{line}`"
)));
};
let key = key.trim();
let value = value.trim();
if key.is_empty() || value.is_empty() {
return Err(parse_error(format!("{context} has an empty key or value")));
}
if fields.insert(key.to_string(), value).is_some() {
return Err(parse_error(format!("duplicate {context} field `{key}`")));
}
}
Ok(fields)
}
fn parse_bindings(value: &str, context: &str) -> Result<Vec<LaneBinding>, PlanError> {
parse_list(value, context)?
.into_iter()
.map(|entry| {
let Some((lane, value)) = entry.split_once('=') else {
return Err(parse_error(format!(
"{context} binding `{entry}` must be `<lane>=<value>`"
)));
};
Ok(LaneBinding {
lane: lane.trim().to_string(),
value: value.trim().to_string(),
})
})
.collect()
}
fn parse_schedule(value: &str) -> Result<Vec<CapacityWave>, PlanError> {
parse_list(value, "schedule")?
.into_iter()
.map(|entry| {
let Some((lanes, slots)) = entry.rsplit_once('@') else {
return Err(parse_error(format!(
"schedule entry `{entry}` must be `<lane>[+<lane>]@<process-slots>`"
)));
};
let lanes = lanes
.split('+')
.map(str::trim)
.map(str::to_string)
.collect::<Vec<_>>();
if lanes.iter().any(String::is_empty) {
return Err(parse_error(format!(
"schedule entry `{entry}` has an empty lane"
)));
}
Ok(CapacityWave {
lanes,
process_slots: parse_usize(slots.trim(), "schedule process slots")?,
})
})
.collect()
}
fn parse_list(value: &str, context: &str) -> Result<Vec<String>, PlanError> {
let inner = value
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.ok_or_else(|| parse_error(format!("{context} must be an inline list")))?;
if inner.trim().is_empty() {
return Ok(Vec::new());
}
split_top_level(inner, ',')?
.into_iter()
.map(|value| {
let value = unquote(value.trim());
if value.is_empty() {
Err(parse_error(format!("{context} contains an empty value")))
} else {
Ok(value.to_string())
}
})
.collect()
}
fn split_top_level(value: &str, delimiter: char) -> Result<Vec<&str>, PlanError> {
let mut parts = Vec::new();
let mut start = 0;
let mut square = 0usize;
let mut curly = 0usize;
let mut quote = None;
for (index, character) in value.char_indices() {
if let Some(active) = quote {
if character == active {
quote = None;
}
continue;
}
match character {
'\'' | '"' => quote = Some(character),
'[' => square += 1,
']' => {
square = square
.checked_sub(1)
.ok_or_else(|| parse_error("unbalanced inline list"))?;
}
'{' => curly += 1,
'}' => {
curly = curly
.checked_sub(1)
.ok_or_else(|| parse_error("unbalanced inline map"))?;
}
_ if character == delimiter && square == 0 && curly == 0 => {
parts.push(&value[start..index]);
start = index + character.len_utf8();
}
_ => {}
}
}
if square != 0 || curly != 0 || quote.is_some() {
return Err(parse_error("unbalanced inline value"));
}
parts.push(&value[start..]);
Ok(parts)
}
fn unquote(value: &str) -> &str {
if value.len() >= 2
&& ((value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\'')))
{
&value[1..value.len() - 1]
} else {
value
}
}
fn expect_keys<T>(
fields: &BTreeMap<String, T>,
expected: &[&str],
context: &str,
) -> Result<(), PlanError> {
for key in expected {
if !fields.contains_key(*key) {
return Err(parse_error(format!("{context} is missing `{key}`")));
}
}
for key in fields.keys() {
if !expected.contains(&key.as_str()) {
return Err(parse_error(format!("unknown {context} field `{key}`")));
}
}
Ok(())
}
fn field<'a, T: AsRef<str>>(
fields: &'a BTreeMap<String, T>,
name: &str,
) -> Result<&'a str, PlanError> {
fields
.get(name)
.map(AsRef::as_ref)
.ok_or_else(|| parse_error(format!("missing field `{name}`")))
}
fn parse_usize(value: &str, context: &str) -> Result<usize, PlanError> {
value
.parse()
.map_err(|_| parse_error(format!("{context} must be a nonnegative integer")))
}
fn parse_error(message: impl Into<String>) -> PlanError {
PlanError::Parse(message.into())
}