rok-cli 0.3.6

Developer CLI for rok-based Axum applications
//! `rok plan:*` — roadmap navigation from router.md.

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();
        // Detect table rows: | N | name | ...
        if line.starts_with('|') {
            let cols: Vec<&str> = line.split('|').map(|c| c.trim()).collect();
            if cols.len() >= 5 {
                // Try to parse first col as number
                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
}

// ── Public command entry points ───────────────────────────────────────────────

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(())
}