use crate::commands::issue::epic::render::{
DEFERRED_END, DEFERRED_START, FOLLOWUPS_END, FOLLOWUPS_START, PHASES_END, PHASES_START,
};
pub(crate) const EPIC_PLAN_HEADING: &str = "## Epic plan";
const OUTCOMES_HEADING: &str = "### Outcomes";
const DECISIONS_HEADING: &str = "### Ratified decisions";
const ORDERING_HEADING: &str = "### Ordering";
const PHASE_PREFIX: &str = "### Phase:";
const DEFERRED_HEADING: &str = "### Deferred";
const ACCEPTANCE_HEADING: &str = "#### Acceptance criteria";
const GATE_PREFIX: &str = "Gate:";
#[derive(Debug, thiserror::Error)]
pub(crate) enum PlanError {
#[error("{path} declares no `{heading}` heading — the plan document's schema requires it")]
MissingHeading {
path: String,
heading: &'static str,
},
#[error(
"{path}'s `### Ordering` section is empty — an epic needs a reason its phases run in \
order. Work with nothing to say here does not need an epic; file one issue with a task \
list instead"
)]
EmptyOrdering {
path: String,
},
#[error(
"{path} declares no `# <outcome>` heading — the tracker's title is taken from it, so an \
absent or blank one would file an epic with no outcome in its title"
)]
MissingTitle {
path: String,
},
#[error("{path} declares no `### Phase: <title>` section — an epic needs at least one phase")]
NoPhases {
path: String,
},
#[error(
"{path} declares `### Phase: {phase}` twice — phases are matched by title, so a duplicate \
would file one issue and silently skip the other. Give them distinct titles"
)]
DuplicatePhase {
path: String,
phase: String,
},
#[error(
"{path}'s `### Phase: {phase}` has an empty `Gate:` line — a blank gate renders a tracker \
that looks complete with nothing justifying the pattern. State the gate, or drop the \
phase"
)]
EmptyGate {
path: String,
phase: String,
},
#[error(
"{path}'s `### Phase: {phase}` has no `Gate:` line — the gate is what justifies using \
the tracker pattern at all"
)]
MissingGate {
path: String,
phase: String,
},
#[error("{path}'s `### Phase: {phase}` has no `{ACCEPTANCE_HEADING}` list")]
MissingAcceptance {
path: String,
phase: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PhasePlan {
pub(crate) title: String,
pub(crate) gate: String,
pub(crate) body: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct EpicPlan {
pub(crate) outcome: String,
pub(crate) summary: Vec<String>,
pub(crate) outcomes: Vec<String>,
pub(crate) decisions: Vec<String>,
pub(crate) ordering: Vec<String>,
pub(crate) phases: Vec<PhasePlan>,
pub(crate) deferred: Vec<String>,
}
pub(crate) fn parse(path: &str, text: &str) -> Result<EpicPlan, PlanError> {
let lines: Vec<&str> = text.lines().collect();
let outcome = lines
.iter()
.find(|l| l.starts_with("# ") && !l.starts_with("##"))
.map(|l| l[2..].trim().to_string())
.filter(|t| !t.is_empty())
.ok_or_else(|| PlanError::MissingTitle {
path: path.to_string(),
})?;
let start = lines
.iter()
.position(|l| l.trim_end() == EPIC_PLAN_HEADING)
.ok_or_else(|| PlanError::MissingHeading {
path: path.to_string(),
heading: EPIC_PLAN_HEADING,
})?;
let end = lines[start + 1..]
.iter()
.position(|l| l.starts_with("## ") && !l.starts_with("###"))
.map_or(lines.len(), |i| start + 1 + i);
let region = &lines[start + 1..end];
let sections = split_sections(region);
let summary = trim_block(sections.leading);
let mut outcomes = Vec::new();
let mut decisions = Vec::new();
let mut ordering: Option<Vec<String>> = None;
let mut deferred = Vec::new();
let mut phases = Vec::new();
for (heading, body) in sections.subsections {
match heading {
h if h == OUTCOMES_HEADING => outcomes = trim_block(body),
h if h == DECISIONS_HEADING => decisions = trim_block(body),
h if h == ORDERING_HEADING => ordering = Some(trim_block(strip_phases_block(body))),
h if h == DEFERRED_HEADING => deferred = trim_block(drop_marker_lines(body)),
h if h.starts_with(PHASE_PREFIX) => {
let title = h[PHASE_PREFIX.len()..].trim();
if phases.iter().any(|p: &PhasePlan| p.title == title) {
return Err(PlanError::DuplicatePhase {
path: path.to_string(),
phase: title.to_string(),
});
}
phases.push(parse_phase(path, title, body)?);
}
_ => {}
}
}
if outcomes.is_empty() {
return Err(PlanError::MissingHeading {
path: path.to_string(),
heading: OUTCOMES_HEADING,
});
}
if decisions.is_empty() {
return Err(PlanError::MissingHeading {
path: path.to_string(),
heading: DECISIONS_HEADING,
});
}
let ordering = ordering.ok_or_else(|| PlanError::MissingHeading {
path: path.to_string(),
heading: ORDERING_HEADING,
})?;
if ordering.is_empty() {
return Err(PlanError::EmptyOrdering {
path: path.to_string(),
});
}
if phases.is_empty() {
return Err(PlanError::NoPhases {
path: path.to_string(),
});
}
Ok(EpicPlan {
outcome,
summary,
outcomes,
decisions,
ordering,
phases,
deferred,
})
}
struct Sections<'a> {
leading: Vec<&'a str>,
subsections: Vec<(&'a str, Vec<&'a str>)>,
}
fn is_fence(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("```") || trimmed.starts_with("~~~")
}
fn unfenced<'a>(body: &[&'a str]) -> Vec<&'a str> {
let mut out = Vec::new();
let mut in_fence = false;
for line in body.iter().copied() {
if is_fence(line) {
in_fence = !in_fence;
continue;
}
if !in_fence {
out.push(line);
}
}
out
}
fn split_sections<'a>(region: &[&'a str]) -> Sections<'a> {
let mut leading = Vec::new();
let mut subsections: Vec<(&'a str, Vec<&'a str>)> = Vec::new();
let mut in_fence = false;
for line in region.iter().copied() {
if is_fence(line) {
in_fence = !in_fence;
}
if !in_fence && !is_fence(line) && line.starts_with("### ") {
subsections.push((line.trim_end(), Vec::new()));
} else if let Some(last) = subsections.last_mut() {
last.1.push(line);
} else {
leading.push(line);
}
}
Sections {
leading,
subsections,
}
}
fn parse_phase(path: &str, title: &str, body: Vec<&str>) -> Result<PhasePlan, PlanError> {
let scan = unfenced(&body);
let gate = scan
.iter()
.find(|l| l.trim_start().starts_with(GATE_PREFIX))
.map(|l| l.trim().trim_start_matches(GATE_PREFIX).trim().to_string())
.ok_or_else(|| PlanError::MissingGate {
path: path.to_string(),
phase: title.to_string(),
})?;
if gate.is_empty() {
return Err(PlanError::EmptyGate {
path: path.to_string(),
phase: title.to_string(),
});
}
if !scan.iter().any(|l| l.trim_end() == ACCEPTANCE_HEADING) {
return Err(PlanError::MissingAcceptance {
path: path.to_string(),
phase: title.to_string(),
});
}
let mut gate_seen = false;
let mut in_fence = false;
let rest: Vec<&str> = body
.into_iter()
.filter(|l| {
if is_fence(l) {
in_fence = !in_fence;
return true;
}
if !in_fence && !gate_seen && l.trim_start().starts_with(GATE_PREFIX) {
gate_seen = true;
return false;
}
true
})
.collect();
let promoted = trim_block(rest)
.into_iter()
.map(|l| {
l.strip_prefix("#### ")
.map_or(l.clone(), |rest| format!("## {rest}"))
})
.collect();
Ok(PhasePlan {
title: title.to_string(),
gate,
body: promoted,
})
}
fn strip_phases_block(body: Vec<&str>) -> Vec<&str> {
let mut out = Vec::new();
let mut inside = false;
for line in body {
let trimmed = line.trim();
if trimmed == PHASES_START {
inside = true;
continue;
}
if trimmed == PHASES_END {
inside = false;
continue;
}
if !inside {
out.push(line);
}
}
out
}
fn drop_marker_lines(body: Vec<&str>) -> Vec<&str> {
const MARKERS: [&str; 6] = [
PHASES_START,
PHASES_END,
DEFERRED_START,
DEFERRED_END,
FOLLOWUPS_START,
FOLLOWUPS_END,
];
body.into_iter()
.filter(|l| !MARKERS.contains(&l.trim()))
.collect()
}
fn trim_block(lines: Vec<&str>) -> Vec<String> {
let first = lines.iter().position(|l| !l.trim().is_empty());
let last = lines.iter().rposition(|l| !l.trim().is_empty());
match (first, last) {
(Some(a), Some(b)) => lines[a..=b].iter().map(|l| (*l).to_string()).collect(),
_ => Vec::new(),
}
}