rft-cli 0.1.0

Zero-config Docker Compose isolation for git worktrees
use std::path::{Path, PathBuf};

use tokio::process::Command;

use crate::error::{Result, RftError};

#[derive(Debug, Clone)]
pub struct WorktreeInfo {
    pub path: PathBuf,
    pub branch: String,
    pub is_main: bool,
    pub index: usize,
}

pub async fn get_repo_root(cwd: &Path) -> Result<PathBuf> {
    let output = Command::new("git")
        .args(["rev-parse", "--show-toplevel"])
        .current_dir(cwd)
        .output()
        .await?;

    if !output.status.success() {
        return Err(RftError::NotAGitRepo);
    }

    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
    Ok(PathBuf::from(root))
}

pub fn get_repo_name(repo_root: &Path) -> String {
    repo_root
        .file_name()
        .map(|name| name.to_string_lossy().into_owned())
        .unwrap_or_else(|| "unknown".to_string())
}

pub async fn get_worktrees(repo_root: &Path) -> Result<Vec<WorktreeInfo>> {
    let output = Command::new("git")
        .args(["worktree", "list", "--porcelain"])
        .current_dir(repo_root)
        .output()
        .await?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
        return Err(RftError::CommandFailed {
            cmd: "git worktree list --porcelain".to_string(),
            stderr,
        });
    }

    let raw = String::from_utf8_lossy(&output.stdout);
    parse_porcelain_output(&raw)
}

pub async fn get_non_main_worktrees(repo_root: &Path) -> Result<Vec<WorktreeInfo>> {
    let worktrees = get_worktrees(repo_root).await?;
    Ok(worktrees.into_iter().filter(|wt| !wt.is_main).collect())
}

pub async fn get_worktree_by_index(repo_root: &Path, index: usize) -> Result<WorktreeInfo> {
    let worktrees = get_worktrees(repo_root).await?;
    worktrees
        .into_iter()
        .find(|wt| !wt.is_main && wt.index == index)
        .ok_or(RftError::WorktreeNotFound { index })
}

fn parse_porcelain_output(raw: &str) -> Result<Vec<WorktreeInfo>> {
    let mut worktrees = Vec::new();
    let blocks = raw.split("\n\n").filter(|block| !block.trim().is_empty());
    let mut non_main_counter = 0usize;

    for (position, block) in blocks.enumerate() {
        let mut path: Option<PathBuf> = None;
        let mut branch = String::from("detached");

        for line in block.lines() {
            if let Some(worktree_path) = line.strip_prefix("worktree ") {
                path = Some(PathBuf::from(worktree_path));
            } else if let Some(branch_ref) = line.strip_prefix("branch ") {
                branch = branch_ref
                    .strip_prefix("refs/heads/")
                    .unwrap_or(branch_ref)
                    .to_string();
            }
        }

        let is_main = position == 0;

        if let Some(path) = path {
            let index = if is_main {
                0
            } else {
                non_main_counter += 1;
                non_main_counter
            };

            worktrees.push(WorktreeInfo {
                path,
                branch,
                is_main,
                index,
            });
        }
    }

    Ok(worktrees)
}

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

    const PORCELAIN_TWO_WORKTREES: &str = "\
worktree /home/user/project
HEAD abc123def456
branch refs/heads/main

worktree /home/user/project-feature
HEAD 789def012abc
branch refs/heads/feature/login
";

    const PORCELAIN_WITH_DETACHED: &str = "\
worktree /home/user/project
HEAD abc123def456
branch refs/heads/main

worktree /home/user/project-detached
HEAD 789def012abc
detached
";

    const PORCELAIN_SINGLE: &str = "\
worktree /home/user/project
HEAD abc123def456
branch refs/heads/main
";

    #[test]
    fn parse_two_worktrees() {
        let worktrees = parse_porcelain_output(PORCELAIN_TWO_WORKTREES).unwrap();

        assert_eq!(worktrees.len(), 2);

        assert_eq!(worktrees[0].path, PathBuf::from("/home/user/project"));
        assert_eq!(worktrees[0].branch, "main");
        assert!(worktrees[0].is_main);
        assert_eq!(worktrees[0].index, 0);

        assert_eq!(
            worktrees[1].path,
            PathBuf::from("/home/user/project-feature")
        );
        assert_eq!(worktrees[1].branch, "feature/login");
        assert!(!worktrees[1].is_main);
        assert_eq!(
            worktrees[1].index, 1,
            "first non-main worktree should be 1-indexed"
        );
    }

    #[test]
    fn parse_detached_head() {
        let worktrees = parse_porcelain_output(PORCELAIN_WITH_DETACHED).unwrap();

        assert_eq!(worktrees.len(), 2);
        assert_eq!(worktrees[1].branch, "detached");
        assert!(!worktrees[1].is_main);
        assert_eq!(
            worktrees[1].index, 1,
            "detached non-main worktree is 1-indexed"
        );
    }

    #[test]
    fn parse_single_worktree() {
        let worktrees = parse_porcelain_output(PORCELAIN_SINGLE).unwrap();

        assert_eq!(worktrees.len(), 1);
        assert!(worktrees[0].is_main);
        assert_eq!(worktrees[0].branch, "main");
    }

    #[test]
    fn parse_empty_output() {
        let worktrees = parse_porcelain_output("").unwrap();
        assert!(worktrees.is_empty());
    }

    #[test]
    fn repo_name_from_path() {
        assert_eq!(
            get_repo_name(Path::new("/home/user/my-project")),
            "my-project"
        );
        assert_eq!(get_repo_name(Path::new("/")), "unknown");
    }
}