devrig 0.30.2

Local development orchestrator
Documentation
use crate::orchestrator::registry::InstanceRegistry;
use crate::orchestrator::state::ProjectState;
use anyhow::Result;
use std::path::Path;

pub fn run(config_path: Option<&Path>, all: bool) -> Result<()> {
    if all {
        run_all()
    } else {
        run_local(config_path)
    }
}

fn run_local(config_path: Option<&Path>) -> Result<()> {
    // Resolve config path to find state dir
    let config_path = match config_path {
        Some(p) => p.to_path_buf(),
        None => crate::config::resolve::resolve_config(None)?,
    };
    let project_dir = config_path.parent().unwrap_or(Path::new("."));
    let state_dir = ProjectState::state_dir_for(project_dir);

    let state = match ProjectState::load(&state_dir) {
        Some(s) => s,
        None => {
            println!("No running services found.");
            println!("Run `devrig start` to start services.");
            return Ok(());
        }
    };

    println!(
        "  Project: {} (started {})",
        state.slug,
        state.started_at.format("%Y-%m-%d %H:%M:%S")
    );
    println!();

    // Docker containers
    if !state.docker.is_empty() {
        println!("  {:<20} {:<14} {:<24} STATUS", "INFRA", "CONTAINER", "URL");
        println!("  {}", "-".repeat(68));
        for (name, docker_svc) in &state.docker {
            let url = docker_svc
                .port
                .map(|p| format!("localhost:{}", p))
                .unwrap_or_else(|| "-".to_string());
            let auto_tag = if docker_svc.port_auto { " (auto)" } else { "" };
            let short_id = if docker_svc.container_id.len() > 12 {
                &docker_svc.container_id[..12]
            } else {
                &docker_svc.container_id
            };
            let init_tag = if docker_svc.init_completed { " [init]" } else { "" };
            println!(
                "  {:<20} {:<14} {:<24} running{}",
                name,
                short_id,
                format!("{}{}", url, auto_tag),
                init_tag,
            );
        }
        println!();
    }

    // Compose services
    if !state.compose_services.is_empty() {
        println!(
            "  {:<20} {:<14} {:<24} STATUS",
            "COMPOSE", "CONTAINER", "URL"
        );
        println!("  {}", "-".repeat(68));
        for (name, cs) in &state.compose_services {
            let url = cs
                .port
                .map(|p| format!("localhost:{}", p))
                .unwrap_or_else(|| "-".to_string());
            let short_id = if cs.container_id.len() > 12 {
                &cs.container_id[..12]
            } else {
                &cs.container_id
            };
            println!("  {:<20} {:<14} {:<24} running", name, short_id, url,);
        }
        println!();
    }

    // Dashboard
    if let Some(ref dash) = state.dashboard {
        println!("  {:<20} {:<24}", "DASHBOARD", "URL");
        println!("  {}", "-".repeat(48));
        println!(
            "  {:<20} http://localhost:{}",
            "dashboard", dash.dashboard_port
        );
        println!(
            "  {:<20} localhost:{}",
            "otel-grpc", dash.grpc_port
        );
        println!(
            "  {:<20} localhost:{}",
            "otel-http", dash.http_port
        );
        println!();
    }

    // Services
    if !state.services.is_empty() {
        println!("  {:<20} {:<8} {:<24} STATUS", "SERVICE", "PID", "URL");
        println!("  {}", "-".repeat(62));
        for (name, svc) in &state.services {
            let url = svc
                .port
                .map(|p| format!("http://localhost:{}", p))
                .unwrap_or_else(|| "-".to_string());
            let auto_tag = if svc.port_auto { " (auto)" } else { "" };
            let alive = is_process_alive(svc.pid);
            let phase = svc.phase.as_deref().unwrap_or("");
            let status = if alive {
                if phase.is_empty() { "running".to_string() } else { phase.to_string() }
            } else if phase == "failed" {
                match svc.exit_code {
                    Some(code) => format!("failed (exit {})", code),
                    None => "failed".to_string(),
                }
            } else if phase == "running" || phase == "starting" {
                "stopped (stale)".to_string()
            } else {
                "stopped".to_string()
            };
            let pid_display = if svc.pid == 0 {
                "-".to_string()
            } else {
                svc.pid.to_string()
            };
            println!(
                "  {:<20} {:<8} {:<24} {}",
                name,
                pid_display,
                format!("{}{}", url, auto_tag),
                status
            );
        }
        println!();
    }

    Ok(())
}

fn run_all() -> Result<()> {
    let mut registry = InstanceRegistry::load();
    registry.cleanup();
    let _ = registry.save();

    let instances = registry.list();
    if instances.is_empty() {
        println!("No running devrig instances found.");
        return Ok(());
    }

    println!("  {:<24} {:<40} STATUS", "PROJECT", "CONFIG");
    println!("  {}", "-".repeat(70));

    for entry in instances {
        let state = ProjectState::load(&std::path::PathBuf::from(&entry.state_dir));
        let parts: Vec<String> = if let Some(ref s) = state {
            build_status_parts(s)
        } else {
            vec![]
        };
        let status = if parts.is_empty() {
            "unknown".to_string()
        } else {
            parts.join(", ")
        };
        println!("  {:<24} {:<40} {}", entry.slug, entry.config_path, status);
    }
    println!();
    Ok(())
}

fn is_process_alive(pid: u32) -> bool {
    crate::platform::is_process_alive(pid)
}

/// Build the status summary parts for `ps --all` display.
pub fn build_status_parts(state: &ProjectState) -> Vec<String> {
    let mut p = Vec::new();
    if !state.services.is_empty() {
        p.push(format!("{} svc", state.services.len()));
    }
    if !state.docker.is_empty() {
        p.push(format!("{} docker", state.docker.len()));
    }
    if !state.compose_services.is_empty() {
        p.push(format!("{} compose", state.compose_services.len()));
    }
    if state.dashboard.is_some() {
        p.push("dashboard".to_string());
    }
    p
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::orchestrator::state::{DashboardState, ServiceState};
    use chrono::Utc;
    use std::collections::BTreeMap;

    fn empty_state() -> ProjectState {
        ProjectState {
            slug: "test".to_string(),
            config_path: "devrig.toml".to_string(),
            services: BTreeMap::new(),
            started_at: Utc::now(),
            docker: BTreeMap::new(),
            compose_services: BTreeMap::new(),
            network_name: None,
            cluster: None,
            dashboard: None,
        }
    }

    #[test]
    fn dashboard_only_shows_dashboard() {
        let mut state = empty_state();
        state.dashboard = Some(DashboardState {
            dashboard_port: 4000,
            grpc_port: 4317,
            http_port: 4318,
        });
        assert_eq!(build_status_parts(&state), vec!["dashboard"]);
    }

    #[test]
    fn services_and_dashboard() {
        let mut state = empty_state();
        state.services.insert(
            "api".to_string(),
            ServiceState {
                pid: 0,
                port: Some(3000),
                port_auto: false,
                protocol: None,
                phase: None,
                exit_code: None,
            },
        );
        state.dashboard = Some(DashboardState {
            dashboard_port: 4000,
            grpc_port: 4317,
            http_port: 4318,
        });
        assert_eq!(build_status_parts(&state), vec!["1 svc", "dashboard"]);
    }

    #[test]
    fn no_dashboard_no_services_is_empty() {
        let state = empty_state();
        assert!(build_status_parts(&state).is_empty());
    }
}