use std::fmt::Write as _;
use crate::commands::issue::config::StateModel;
use crate::commands::issue::state::StateMachine;
use super::backend::ChildIssue;
use super::plan::{EpicPlan, PhasePlan};
pub(crate) const PHASES_START: &str = "<!-- phases:start -->";
pub(crate) const PHASES_END: &str = "<!-- phases:end -->";
pub(crate) const DEFERRED_START: &str = "<!-- deferred:start -->";
pub(crate) const DEFERRED_END: &str = "<!-- deferred:end -->";
pub(crate) const FOLLOWUPS_START: &str = "<!-- followups:start -->";
pub(crate) const FOLLOWUPS_END: &str = "<!-- followups:end -->";
const PHASES_HEADER: &str =
"| # | Phase | Issue | State | Gate |\n|---|-------|-------|-------|------|";
pub(crate) const DEFERRED_HEADER: &str =
"| Item | Why deferred | Where it went |\n|------|--------------|---------------|";
#[derive(Debug, thiserror::Error)]
pub(crate) enum BlockError {
#[error(
"the body carries no `{0}` line — refusing to append a block to a tracker that declares \
none, since a hand-authored body is not a tracker"
)]
Missing(&'static str),
#[error(
"the body carries {count} `{marker}` lines — exactly one is required; refusing to guess \
which block to replace"
)]
Duplicate {
marker: &'static str,
count: usize,
},
#[error("the body's `{end}` line precedes its `{start}` line — refusing to rewrite it")]
Inverted {
start: &'static str,
end: &'static str,
},
}
pub(crate) fn replace_block(
body: &str,
start: &'static str,
end: &'static str,
content: &str,
) -> Result<String, BlockError> {
let segments: Vec<&str> = body.split_inclusive('\n').collect();
let start_idx = sole_index(&segments, start)?;
let end_idx = sole_index(&segments, end)?;
if end_idx < start_idx {
return Err(BlockError::Inverted { start, end });
}
let mut out = String::with_capacity(body.len() + content.len());
for segment in &segments[..=start_idx] {
out.push_str(segment);
}
if !out.ends_with('\n') {
out.push('\n');
}
let trimmed = content.trim_end_matches('\n');
if !trimmed.is_empty() {
out.push_str(trimmed);
out.push('\n');
}
for segment in &segments[end_idx..] {
out.push_str(segment);
}
debug_assert!(
!out.trim().is_empty() && out.contains(start) && out.contains(end),
"replace_block must preserve both markers and never empty the body"
);
Ok(out)
}
pub(crate) fn block_content(
body: &str,
start: &'static str,
end: &'static str,
) -> Result<String, BlockError> {
let segments: Vec<&str> = body.split_inclusive('\n').collect();
let start_idx = sole_index(&segments, start)?;
let end_idx = sole_index(&segments, end)?;
if end_idx < start_idx {
return Err(BlockError::Inverted { start, end });
}
Ok(segments[start_idx + 1..end_idx]
.concat()
.trim_end_matches('\n')
.to_string())
}
fn sole_index(segments: &[&str], marker: &'static str) -> Result<usize, BlockError> {
let hits: Vec<usize> = segments
.iter()
.enumerate()
.filter(|(_, s)| s.trim() == marker)
.map(|(i, _)| i)
.collect();
match hits.as_slice() {
[] => Err(BlockError::Missing(marker)),
[one] => Ok(*one),
many => Err(BlockError::Duplicate {
marker,
count: many.len(),
}),
}
}
pub(crate) fn tracker_title(epic: u64, outcome: &str) -> String {
format!("[EPIC {epic}] {outcome}")
}
pub(crate) fn placeholder_tracker_title(outcome: &str) -> String {
format!("[EPIC] {outcome}")
}
pub(crate) fn phase_title(epic: u64, phase: u64, what: &str) -> String {
format!("[EPIC_{epic} PHASE_{phase}] {what}")
}
pub(crate) fn is_tracker_title(title: &str, number: u64, outcome: &str) -> bool {
title.trim() == tracker_title(number, outcome)
}
pub(crate) fn phase_number_of(title: &str) -> Option<u64> {
let rest = title.trim().strip_prefix("[EPIC_")?;
let (_, rest) = rest.split_once(" PHASE_")?;
let (digits, _) = rest.split_once(']')?;
digits.trim().parse().ok()
}
pub(crate) fn epic_number_of(title: &str) -> Option<u64> {
let rest = title.trim().strip_prefix("[EPIC_")?;
let (digits, _) = rest.split_once(" PHASE_")?;
digits.trim().parse().ok()
}
pub(crate) fn phase_what(title: &str) -> String {
title
.trim()
.split_once("] ")
.map_or_else(|| title.trim().to_string(), |(_, what)| what.to_string())
}
pub(crate) fn next_phase_number(children: &[ChildIssue]) -> u64 {
children
.iter()
.filter_map(|c| phase_number_of(&c.title))
.max()
.map_or(1, |m| m + 1)
}
pub(crate) fn plan_permalink(repo: &str, sha: &str, path: &str) -> String {
format!("https://github.com/{repo}/blob/{sha}/{path}")
}
pub(crate) fn phases_table(children: &[ChildIssue], model: &StateModel) -> String {
let mut rows: Vec<(u64, &ChildIssue)> = children
.iter()
.filter_map(|c| phase_number_of(&c.title).map(|n| (n, c)))
.collect();
rows.sort_by_key(|(n, _)| *n);
let mut out = String::from(PHASES_HEADER);
for (number, child) in rows {
let _ = write!(
out,
"\n| {number} | {} | #{} | {} | {} |",
cell(&phase_what(&child.title)),
child.number,
cell(&state_cell(child, model)),
cell(&gate_of(&child.body))
);
}
out
}
pub(crate) fn state_cell(child: &ChildIssue, model: &StateModel) -> String {
if child.state.eq_ignore_ascii_case("CLOSED") {
return "closed".to_string();
}
let prefix = model.label_config.status_prefix.as_str();
let mut states: Vec<&str> = StateMachine::new(model)
.labelled_states(&child.labels)
.into_iter()
.map(|name| name.strip_prefix(prefix).unwrap_or(name))
.collect();
states.sort_unstable();
states.dedup();
if states.is_empty() {
"open".to_string()
} else {
states.join("/")
}
}
pub(crate) fn cell(text: &str) -> String {
text.replace('|', "\\|").replace('\n', " ")
}
pub(crate) fn outcomes_of(body: &str) -> Vec<(String, String)> {
let mut inside = false;
let mut out: Vec<(String, String)> = Vec::new();
let mut folding = false;
for line in body.lines() {
if line.trim_end() == "## Outcomes" {
inside = true;
continue;
}
if !inside {
continue;
}
if line.starts_with("## ") {
break;
}
let trimmed = line.trim();
if trimmed.is_empty() {
folding = false;
continue;
}
if folding && !trimmed.starts_with("- ") && line.starts_with(char::is_whitespace) {
if let Some((_, text)) = out.last_mut() {
text.push(' ');
text.push_str(trimmed);
}
continue;
}
folding = false;
let Some(rest) = trimmed.strip_prefix("- **O") else {
continue;
};
let Some((digits, text)) = rest.split_once("**") else {
continue;
};
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
continue;
}
out.push((format!("O{digits}"), text.trim().to_string()));
folding = true;
}
out
}
fn gate_of(body: &str) -> String {
let mut inside = false;
let mut collected: Vec<&str> = Vec::new();
for line in body.lines() {
if line.trim_end() == "## Gate" {
inside = true;
continue;
}
if !inside {
continue;
}
if line.starts_with("## ") {
break;
}
if line.trim().is_empty() {
if collected.is_empty() {
continue;
}
break;
}
collected.push(line.trim());
}
if collected.is_empty() {
"(no gate declared)".to_string()
} else {
collected.join(" ")
}
}
pub(crate) fn tracker_body(plan: &EpicPlan, plan_url: &str) -> String {
let mut out = String::new();
let _ = writeln!(out, "Plan: {plan_url}\n");
push_block(&mut out, &plan.summary);
out.push_str("\n## Outcomes\n\n");
push_block(&mut out, &plan.outcomes);
out.push_str("\n## Ratified decisions\n\n");
push_block(&mut out, &plan.decisions);
out.push_str("\n## Ordering\n\n");
push_block(&mut out, &plan.ordering);
let _ = writeln!(
out,
"\n## Phases\n\n{PHASES_START}\n{PHASES_HEADER}\n{PHASES_END}"
);
let _ = writeln!(out, "\n## Deferred\n\n{DEFERRED_START}");
if plan.deferred.is_empty() {
let _ = writeln!(out, "{DEFERRED_HEADER}");
} else {
push_block(&mut out, &plan.deferred);
}
let _ = writeln!(out, "{DEFERRED_END}");
let _ = writeln!(out, "\n## Follow-ups\n\n{FOLLOWUPS_START}");
out.push_str(
"| Finding | Severity | Where it went |\n|---------|----------|---------------|\n",
);
let _ = writeln!(out, "{FOLLOWUPS_END}");
out
}
pub(crate) fn phase_body(phase: &PhasePlan, epic: u64, index: usize, total: usize) -> String {
let mut out = String::new();
let _ = writeln!(out, "Part of #{epic}, phase {index} of {total}.\n");
let _ = writeln!(out, "## Gate\n\n{}\n", phase.gate);
push_block(&mut out, &phase.body);
out
}
fn push_block(out: &mut String, lines: &[String]) {
for line in lines {
out.push_str(line);
out.push('\n');
}
}