rft-cli 0.5.3

Zero-config Docker Compose isolation for git worktrees
use miette::Result;
use owo_colors::OwoColorize;

use crate::context::{build_context, filter_worktrees};
use crate::ports::{BASE_OFFSET, PortAllocation, PortMapping, allocate_worktree_ports};
use crate::sanitize::compose_project_name;

const PADDING: usize = 2;
const MIN_WIDTH: usize = 50;

pub async fn run() -> Result<()> {
    run_inner().await.map_err(miette::Report::new)
}

pub async fn run_inner() -> crate::error::Result<()> {
    let context = build_context().await?;
    let non_main = filter_worktrees(&context.worktrees, &[]);

    if non_main.is_empty() {
        println!("{}", "No worktrees found (only main branch).".dimmed());
        return Ok(());
    }

    let base_offset = context.config.port_offset.unwrap_or(BASE_OFFSET);
    let mut rows = Vec::new();

    for worktree in &non_main {
        let project_name =
            compose_project_name(&context.repo_name, worktree.index, &worktree.branch);
        let ports =
            match allocate_worktree_ports(&context.port_mappings, worktree.index, base_offset) {
                Ok(ports) => ports,
                Err(error) => {
                    eprintln!(
                        "{}",
                        format!(
                            "warning: port allocation failed for worktree {}: {error}",
                            worktree.branch
                        )
                        .yellow()
                    );
                    Vec::new()
                }
            };

        let status = get_container_status(&project_name).await;

        rows.push(WorktreeRow {
            index: worktree.index,
            branch: worktree.branch.clone(),
            status,
            project_name,
            ports,
        });
    }

    render_bordered(&rows, &context.port_mappings, &context.config.host);
    Ok(())
}

#[derive(Debug)]
pub struct WorktreeRow {
    pub index: usize,
    pub branch: String,
    pub status: ContainerStatus,
    pub project_name: String,
    pub ports: Vec<PortAllocation>,
}

#[derive(Debug, PartialEq)]
pub enum ContainerStatus {
    Up,
    Down,
    Partial,
    Unknown,
}

impl std::fmt::Display for ContainerStatus {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ContainerStatus::Up => write!(f, "up"),
            ContainerStatus::Down => write!(f, "down"),
            ContainerStatus::Partial => write!(f, "partial"),
            ContainerStatus::Unknown => write!(f, "unknown"),
        }
    }
}

pub async fn list_as_text() -> crate::error::Result<String> {
    let context = build_context().await?;
    let non_main = filter_worktrees(&context.worktrees, &[]);

    if non_main.is_empty() {
        return Ok("No worktrees found (only main branch).".to_string());
    }

    let base_offset = context.config.port_offset.unwrap_or(BASE_OFFSET);
    let mut rows = Vec::new();

    for worktree in &non_main {
        let project_name =
            compose_project_name(&context.repo_name, worktree.index, &worktree.branch);
        let ports = allocate_worktree_ports(&context.port_mappings, worktree.index, base_offset)
            .unwrap_or_default();

        let status = get_container_status(&project_name).await;

        rows.push(WorktreeRow {
            index: worktree.index,
            branch: worktree.branch.clone(),
            status,
            project_name,
            ports,
        });
    }

    Ok(render_table_as_text(&rows))
}

fn render_table_as_text(rows: &[WorktreeRow]) -> String {
    let mut lines = Vec::new();
    lines.push(format!(
        "{:<4} {:<30} {:<10} {}",
        "#", "Branch", "Status", "Ports"
    ));
    lines.push("-".repeat(80));

    for row in rows {
        let ports_display = if row.ports.is_empty() {
            "-".to_string()
        } else {
            row.ports
                .iter()
                .map(|allocation| format!("{}={}", allocation.env_var, allocation.port))
                .collect::<Vec<_>>()
                .join(", ")
        };

        lines.push(format!(
            "{:<4} {:<30} {:<10} {}",
            row.index, row.branch, row.status, ports_display
        ));
    }

    lines.join("\n")
}

fn terminal_width() -> usize {
    terminal_size::terminal_size()
        .map(|(w, _)| w.0 as usize)
        .unwrap_or(80)
        .max(MIN_WIDTH)
}

fn port_hyperlink(port: u16, host: &str) -> String {
    format!("\x1b]8;;http://{host}:{port}\x1b\\{port}\x1b]8;;\x1b\\")
}

fn format_port(allocation: &PortAllocation, host: &str) -> String {
    format!(
        "{}={}",
        allocation.env_var,
        port_hyperlink(allocation.port, host)
    )
}

fn visible_port_width(allocation: &PortAllocation) -> usize {
    // ENV_VAR=12345
    allocation.env_var.len() + 1 + allocation.port.to_string().len()
}

fn status_display(status: &ContainerStatus) -> (String, usize) {
    match status {
        ContainerStatus::Up => (format!("{}", "● up".green()), 4),
        ContainerStatus::Down => (format!("{}", "○ down".red()), 6),
        ContainerStatus::Partial => (format!("{}", "◐ partial".yellow()), 9),
        ContainerStatus::Unknown => (format!("{}", "○ unknown".dimmed()), 9),
    }
}

pub fn render_bordered(rows: &[WorktreeRow], port_mappings: &[PortMapping], host: &str) {
    let width = terminal_width();
    let inner = width - 2; // left and right border

    println!("{}", "".repeat(inner));

    for (i, row) in rows.iter().enumerate() {
        let (status_styled, status_len) = status_display(&row.status);
        let index_prefix = format!("[{}] ", row.index);
        let min_gap = 2;
        let max_branch =
            inner.saturating_sub(PADDING * 2 + status_len + index_prefix.len() + min_gap);
        let branch_display = if row.branch.len() > max_branch {
            format!("{}", &row.branch[..max_branch.saturating_sub(1)])
        } else {
            row.branch.clone()
        };
        let header = format!("{}{}", index_prefix, branch_display);
        let gap = inner
            .saturating_sub(PADDING)
            .saturating_sub(header.len())
            .saturating_sub(PADDING)
            .saturating_sub(status_len);

        println!(
            "{}{}{}{}{}",
            " ".repeat(PADDING),
            header.bold(),
            " ".repeat(gap),
            status_styled,
            " ".repeat(PADDING),
        );

        if !row.ports.is_empty() {
            let port_area = inner - PADDING * 2;
            let mut line_parts: Vec<String> = Vec::new();
            let mut line_visible_len: usize = 0;

            for allocation in &row.ports {
                let visible_w = visible_port_width(allocation);
                let needed = if line_parts.is_empty() {
                    visible_w
                } else {
                    visible_w + 2 // "  " separator
                };

                if !line_parts.is_empty() && line_visible_len + needed > port_area {
                    let pad = inner - PADDING - line_visible_len - PADDING;
                    println!(
                        "{}{}{}",
                        " ".repeat(PADDING),
                        line_parts.join("  ").dimmed(),
                        " ".repeat(pad),
                    );
                    line_parts.clear();
                    line_visible_len = 0;
                }

                if !line_parts.is_empty() {
                    line_visible_len += 2;
                }
                line_parts.push(format_port(allocation, host));
                line_visible_len += visible_w;
            }

            if !line_parts.is_empty() {
                let pad = inner - PADDING - line_visible_len - PADDING;
                println!(
                    "{}{}{}",
                    " ".repeat(PADDING),
                    line_parts.join("  ").dimmed(),
                    " ".repeat(pad),
                );
            }
        }

        if i < rows.len() - 1 {
            println!("{}", "".repeat(inner));
        }
    }

    println!("{}", "".repeat(inner));

    if has_raw_port_warnings(port_mappings) {
        println!(
            "\n{}",
            "Warning: some ports use raw values without env vars. Use ${VAR:-default}:container format for port isolation."
                .yellow()
        );
    }
}

pub fn has_raw_port_warnings(port_mappings: &[PortMapping]) -> bool {
    port_mappings
        .iter()
        .any(|mapping| mapping.env_var.is_none())
}

pub async fn get_container_status(project_name: &str) -> ContainerStatus {
    let output = tokio::process::Command::new("docker")
        .args(["compose", "-p", project_name, "ps", "--format", "json"])
        .output()
        .await;

    let output = match output {
        Ok(output) => output,
        Err(_) => return ContainerStatus::Unknown,
    };

    if !output.status.success() {
        return ContainerStatus::Down;
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stdout = stdout.trim();

    if stdout.is_empty() {
        return ContainerStatus::Down;
    }

    let mut total = 0usize;
    let mut running = 0usize;

    for line in stdout.lines() {
        let parsed: std::result::Result<serde_json::Value, _> = serde_json::from_str(line);
        if let Ok(value) = parsed {
            total += 1;
            if value.get("State").and_then(|s| s.as_str()) == Some("running") {
                running += 1;
            }
        }
    }

    match (total, running) {
        (0, _) => ContainerStatus::Down,
        (t, r) if t == r => ContainerStatus::Up,
        (_, 0) => ContainerStatus::Down,
        _ => ContainerStatus::Partial,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ports::PortMapping;

    #[test]
    fn detects_raw_port_warnings() {
        let mappings = vec![PortMapping {
            service_name: "web".to_string(),
            env_var: None,
            default_port: 3000,
            container_port: 3000,
            raw: "3000:3000".to_string(),
        }];
        assert!(has_raw_port_warnings(&mappings));
    }

    #[test]
    fn no_warnings_when_all_ports_have_env_vars() {
        let mappings = vec![PortMapping {
            service_name: "web".to_string(),
            env_var: Some("WEB_PORT".to_string()),
            default_port: 3000,
            container_port: 3000,
            raw: "${WEB_PORT:-3000}:3000".to_string(),
        }];
        assert!(!has_raw_port_warnings(&mappings));
    }

    #[test]
    fn container_status_display() {
        assert_eq!(ContainerStatus::Up.to_string(), "up");
        assert_eq!(ContainerStatus::Down.to_string(), "down");
        assert_eq!(ContainerStatus::Partial.to_string(), "partial");
        assert_eq!(ContainerStatus::Unknown.to_string(), "unknown");
    }
}