rft-cli 0.4.1

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

use crate::error::Result;
use crate::executor::Executor;
use crate::ports::PortAllocation;

const BLOCK_START: &str = "# --- rft port overrides ---";
const BLOCK_END: &str = "# --- end rft ---";

pub async fn copy_base_env(
    repo_root: &Path,
    worktree_path: &Path,
    executor: &Executor,
) -> Result<PathBuf> {
    let target = worktree_path.join(".env");

    let env_source = repo_root.join(".env");
    if env_source.is_file() {
        executor.copy_file(&env_source, &target).await?;
        return Ok(target);
    }

    let example_source = repo_root.join(".env.example");
    if example_source.is_file() {
        executor.copy_file(&example_source, &target).await?;
        return Ok(target);
    }

    executor.write_file(&target, "").await?;
    Ok(target)
}

pub async fn inject_port_overrides(
    env_path: &Path,
    allocations: &[PortAllocation],
    env_overrides: &HashMap<String, String>,
    executor: &Executor,
) -> Result<()> {
    let block = build_override_block(allocations, env_overrides);

    if executor.is_dry_run() {
        executor.write_file(env_path, &block).await?;
        return Ok(());
    }

    let content = tokio::fs::read_to_string(env_path).await?;
    let clean = strip_override_block(&content);

    let mut final_content = clean.trim_end().to_string();
    if !final_content.is_empty() {
        final_content.push('\n');
    }
    final_content.push_str(&block);
    final_content.push('\n');

    tokio::fs::write(env_path, final_content).await?;
    Ok(())
}

pub fn strip_override_block(content: &str) -> String {
    let mut result = String::with_capacity(content.len());
    let mut inside_block = false;

    for line in content.lines() {
        if line.trim() == BLOCK_START {
            inside_block = true;
            continue;
        }
        if line.trim() == BLOCK_END {
            inside_block = false;
            continue;
        }
        if !inside_block {
            result.push_str(line);
            result.push('\n');
        }
    }

    result
}

pub fn build_override_block(
    allocations: &[PortAllocation],
    env_overrides: &HashMap<String, String>,
) -> String {
    let mut lines = Vec::new();
    lines.push(BLOCK_START.to_string());

    for allocation in allocations {
        lines.push(format!("{}={}", allocation.env_var, allocation.port));
    }

    let resolved_overrides = resolve_template_vars(allocations, env_overrides);
    let mut override_keys: Vec<&String> = resolved_overrides.keys().collect();
    override_keys.sort();
    for key in override_keys {
        lines.push(format!("{}={}", key, resolved_overrides[key]));
    }

    lines.push(BLOCK_END.to_string());
    lines.join("\n")
}

fn resolve_template_vars(
    allocations: &[PortAllocation],
    env_overrides: &HashMap<String, String>,
) -> HashMap<String, String> {
    let port_lookup: HashMap<&str, u16> = allocations
        .iter()
        .map(|allocation| (allocation.env_var.as_str(), allocation.port))
        .collect();

    env_overrides
        .iter()
        .map(|(key, template)| {
            let resolved = resolve_single_template(template, &port_lookup);
            (key.clone(), resolved)
        })
        .collect()
}

fn resolve_single_template(template: &str, port_lookup: &HashMap<&str, u16>) -> String {
    let mut result = template.to_string();
    for (var_name, port_value) in port_lookup {
        let placeholder = format!("${{{var_name}}}");
        result = result.replace(&placeholder, &port_value.to_string());
    }
    result
}

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

    fn make_allocation(env_var: &str, port: u16) -> PortAllocation {
        PortAllocation {
            service_name: "svc".to_string(),
            env_var: env_var.to_string(),
            port,
            container_port: 80,
        }
    }

    #[test]
    fn strip_removes_override_block() {
        let content = "\
DB_HOST=localhost
# --- rft port overrides ---
WEB_PORT=23001
# --- end rft ---
EXTRA=value
";
        let stripped = strip_override_block(content);
        assert!(stripped.contains("DB_HOST=localhost"));
        assert!(stripped.contains("EXTRA=value"));
        assert!(!stripped.contains("WEB_PORT=23001"));
        assert!(!stripped.contains("rft port overrides"));
    }

    #[test]
    fn strip_is_idempotent() {
        let content = "DB_HOST=localhost\n";
        let stripped_once = strip_override_block(content);
        let stripped_twice = strip_override_block(&stripped_once);
        assert_eq!(stripped_once, stripped_twice);
    }

    #[test]
    fn strip_handles_empty_content() {
        let stripped = strip_override_block("");
        assert_eq!(stripped, "");
    }

    #[test]
    fn build_block_with_allocations() {
        let allocations = vec![
            make_allocation("WEB_PORT", 23001),
            make_allocation("API_PORT", 28081),
        ];
        let block = build_override_block(&allocations, &HashMap::new());

        assert!(block.starts_with(BLOCK_START));
        assert!(block.ends_with(BLOCK_END));
        assert!(block.contains("WEB_PORT=23001"));
        assert!(block.contains("API_PORT=28081"));
    }

    #[test]
    fn build_block_with_env_overrides() {
        let allocations = vec![make_allocation("WEB_PORT", 23001)];
        let mut overrides = HashMap::new();
        overrides.insert(
            "APP_URL".to_string(),
            "http://localhost:${WEB_PORT}".to_string(),
        );

        let block = build_override_block(&allocations, &overrides);

        assert!(block.contains("WEB_PORT=23001"));
        assert!(block.contains("APP_URL=http://localhost:23001"));
    }

    #[test]
    fn template_substitution_replaces_multiple_vars() {
        let allocations = vec![
            make_allocation("WEB_PORT", 23001),
            make_allocation("API_PORT", 28081),
        ];
        let mut overrides = HashMap::new();
        overrides.insert(
            "PROXY".to_string(),
            "web=${WEB_PORT},api=${API_PORT}".to_string(),
        );

        let block = build_override_block(&allocations, &overrides);
        assert!(block.contains("PROXY=web=23001,api=28081"));
    }

    #[test]
    fn template_without_matching_var_stays_as_is() {
        let allocations = vec![make_allocation("WEB_PORT", 23001)];
        let mut overrides = HashMap::new();
        overrides.insert(
            "URL".to_string(),
            "http://localhost:${UNKNOWN_PORT}".to_string(),
        );

        let block = build_override_block(&allocations, &overrides);
        assert!(block.contains("URL=http://localhost:${UNKNOWN_PORT}"));
    }

    #[tokio::test]
    async fn copy_base_env_uses_dotenv() {
        let repo = tempfile::tempdir().unwrap();
        let worktree = tempfile::tempdir().unwrap();

        tokio::fs::write(repo.path().join(".env"), "DB=postgres")
            .await
            .unwrap();

        let target = copy_base_env(repo.path(), worktree.path(), &Executor::Real)
            .await
            .unwrap();
        assert_eq!(
            tokio::fs::read_to_string(&target).await.unwrap(),
            "DB=postgres"
        );
    }

    #[tokio::test]
    async fn copy_base_env_falls_back_to_example() {
        let repo = tempfile::tempdir().unwrap();
        let worktree = tempfile::tempdir().unwrap();

        tokio::fs::write(repo.path().join(".env.example"), "DB=example")
            .await
            .unwrap();

        let target = copy_base_env(repo.path(), worktree.path(), &Executor::Real)
            .await
            .unwrap();
        assert_eq!(
            tokio::fs::read_to_string(&target).await.unwrap(),
            "DB=example"
        );
    }

    #[tokio::test]
    async fn copy_base_env_creates_empty_when_no_source() {
        let repo = tempfile::tempdir().unwrap();
        let worktree = tempfile::tempdir().unwrap();

        let target = copy_base_env(repo.path(), worktree.path(), &Executor::Real)
            .await
            .unwrap();
        assert_eq!(tokio::fs::read_to_string(&target).await.unwrap(), "");
    }

    #[tokio::test]
    async fn inject_is_idempotent() {
        let dir = tempfile::tempdir().unwrap();
        let env_path = dir.path().join(".env");
        tokio::fs::write(&env_path, "EXISTING=value").await.unwrap();

        let allocations = vec![make_allocation("WEB_PORT", 23001)];

        inject_port_overrides(&env_path, &allocations, &HashMap::new(), &Executor::Real)
            .await
            .unwrap();
        let first_write = tokio::fs::read_to_string(&env_path).await.unwrap();

        inject_port_overrides(&env_path, &allocations, &HashMap::new(), &Executor::Real)
            .await
            .unwrap();
        let second_write = tokio::fs::read_to_string(&env_path).await.unwrap();

        assert_eq!(first_write, second_write);
    }
}