use anyhow::Result;
use serde_json::{json, Value};
const ROUTER_MD: &str = "router.md";
#[derive(Debug, Clone)]
pub struct PhaseInfo {
pub number: u32,
pub name: String,
pub status: String,
pub priority: String,
pub dependencies: String,
pub notes: String,
}
impl PhaseInfo {
pub fn is_complete(&self) -> bool {
self.status.contains('✅') || self.status.contains("100%")
}
pub fn to_json(&self) -> Value {
json!({
"phase": self.number,
"name": self.name,
"status": self.status,
"priority": self.priority,
"dependencies": self.dependencies,
"notes": self.notes,
"complete": self.is_complete(),
})
}
}
pub struct PlanEngine {
pub phases: Vec<PhaseInfo>,
}
impl PlanEngine {
pub fn load() -> Result<Self> {
let content = std::fs::read_to_string(ROUTER_MD)
.map_err(|_| anyhow::anyhow!("router.md not found — run from project root"))?;
let phases = parse_phase_table(&content);
Ok(Self { phases })
}
pub fn next(&self) -> Option<&PhaseInfo> {
self.phases.iter().find(|p| !p.is_complete())
}
pub fn list(&self) -> &[PhaseInfo] {
&self.phases
}
pub fn get(&self, number: u32) -> Option<&PhaseInfo> {
self.phases.iter().find(|p| p.number == number)
}
#[allow(dead_code)]
pub fn status_of(&self, number: u32) -> Option<Value> {
self.get(number).map(|p| p.to_json())
}
pub fn dot_graph(&self) -> String {
let mut out = String::from("digraph rok_phases {\n rankdir=LR;\n");
for p in &self.phases {
let color = if p.is_complete() { "green" } else { "gray" };
out.push_str(&format!(
" p{} [label=\"Phase {}\\n{}\", color={}];\n",
p.number, p.number, p.name, color
));
if !p.dependencies.is_empty() && p.dependencies != "—" && p.dependencies != "-" {
for dep in p.dependencies.split(',') {
let dep = dep.trim().trim_start_matches("Phase ");
if let Ok(d) = dep.split_whitespace().next().unwrap_or("").parse::<u32>() {
out.push_str(&format!(" p{d} -> p{};\n", p.number));
}
}
}
}
out.push('}');
out
}
}
fn parse_phase_table(content: &str) -> Vec<PhaseInfo> {
let mut phases = Vec::new();
let mut in_table = false;
for line in content.lines() {
let line = line.trim();
if line.starts_with('|') {
let cols: Vec<&str> = line.split('|').map(|c| c.trim()).collect();
if cols.len() >= 5 {
if let Ok(n) = cols[1].parse::<u32>() {
in_table = true;
phases.push(PhaseInfo {
number: n,
name: cols.get(2).unwrap_or(&"").to_string(),
status: cols.get(3).unwrap_or(&"").to_string(),
priority: cols.get(4).unwrap_or(&"").to_string(),
dependencies: cols.get(5).unwrap_or(&"").to_string(),
notes: cols.get(6).unwrap_or(&"").to_string(),
});
}
}
} else if in_table && !line.is_empty() {
in_table = false;
}
}
phases
}
pub fn next(json: bool) -> Result<()> {
let engine = PlanEngine::load()?;
match engine.next() {
Some(p) => {
if json {
println!("{}", serde_json::to_string_pretty(&p.to_json())?);
} else {
println!("Next: Phase {} — {}", p.number, p.name);
println!("Status: {}", p.status);
println!(
"Prompt: feat/phase-{}-{}/prompt.md",
p.number,
p.name.to_lowercase().replace(' ', "-")
);
}
}
None => {
if json {
println!(
"{}",
json!({"status":"done","message":"All phases complete"})
);
} else {
println!("All phases complete!");
}
}
}
Ok(())
}
pub fn list(json: bool) -> Result<()> {
let engine = PlanEngine::load()?;
if json {
let arr: Vec<Value> = engine.list().iter().map(|p| p.to_json()).collect();
println!("{}", serde_json::to_string_pretty(&arr)?);
} else {
for p in engine.list() {
let icon = if p.is_complete() { "✅" } else { "🔴" };
println!("{icon} Phase {:>3} — {}", p.number, p.name);
}
}
Ok(())
}
pub fn graph() -> Result<()> {
let engine = PlanEngine::load()?;
println!("{}", engine.dot_graph());
Ok(())
}
pub fn status(phase: Option<u32>, json: bool) -> Result<()> {
let engine = PlanEngine::load()?;
match phase {
Some(n) => match engine.get(n) {
Some(p) => {
if json {
println!("{}", serde_json::to_string_pretty(&p.to_json())?);
} else {
println!("Phase {} — {}: {}", p.number, p.name, p.status);
}
}
None => anyhow::bail!("Phase {n} not found in router.md"),
},
None => return list(json),
}
Ok(())
}