rft-cli 0.5.1

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

use serde_yml::Value;

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

#[derive(Debug, Clone)]
pub struct ComposeService {
    pub name: String,
    pub ports: Vec<String>,
    pub build: Option<BuildConfig>,
    pub env_file: Vec<String>,
}

#[derive(Debug, Clone)]
pub struct BuildConfig {
    pub context: Option<String>,
    pub dockerfile: Option<String>,
}

#[derive(Debug, Clone)]
pub struct ComposeFile {
    pub services: Vec<ComposeService>,
    pub compose_path: PathBuf,
}

pub fn parse_compose_file(path: &Path) -> Result<ComposeFile> {
    let content = std::fs::read_to_string(path)?;
    let root: Value = serde_yml::from_str(&content)?;

    let services_map = root
        .get("services")
        .and_then(Value::as_mapping)
        .ok_or_else(|| RftError::Config("missing or invalid 'services' key".into()))?;

    let mut services = Vec::with_capacity(services_map.len());

    for (key, value) in services_map {
        let name = key
            .as_str()
            .ok_or_else(|| RftError::Config("service name must be a string".into()))?
            .to_owned();

        let ports = extract_ports(value);
        let build = extract_build(value);
        let env_file = extract_env_file(value);

        services.push(ComposeService {
            name,
            ports,
            build,
            env_file,
        });
    }

    Ok(ComposeFile {
        services,
        compose_path: path.to_path_buf(),
    })
}

fn extract_ports(service: &Value) -> Vec<String> {
    let Some(ports_value) = service.get("ports") else {
        return Vec::new();
    };

    let Some(ports_seq) = ports_value.as_sequence() else {
        return Vec::new();
    };

    ports_seq.iter().filter_map(port_entry_to_string).collect()
}

fn port_entry_to_string(entry: &Value) -> Option<String> {
    match entry {
        Value::String(s) => Some(s.clone()),
        Value::Number(n) => Some(n.to_string()),
        Value::Mapping(map) => {
            let target = map.get(Value::String("target".into()))?.as_u64()?;
            let published = map
                .get(Value::String("published".into()))
                .and_then(|v| v.as_u64().or_else(|| v.as_str().map(|s| s.parse().ok())?));

            match published {
                Some(host) => Some(format!("{host}:{target}")),
                None => Some(target.to_string()),
            }
        }
        _ => None,
    }
}

fn extract_build(service: &Value) -> Option<BuildConfig> {
    let build_value = service.get("build")?;

    match build_value {
        Value::String(context) => Some(BuildConfig {
            context: Some(context.clone()),
            dockerfile: None,
        }),
        Value::Mapping(map) => {
            let context = map
                .get(Value::String("context".into()))
                .and_then(Value::as_str)
                .map(String::from);
            let dockerfile = map
                .get(Value::String("dockerfile".into()))
                .and_then(Value::as_str)
                .map(String::from);
            Some(BuildConfig {
                context,
                dockerfile,
            })
        }
        _ => None,
    }
}

fn extract_env_file(service: &Value) -> Vec<String> {
    let Some(env_value) = service.get("env_file") else {
        return Vec::new();
    };

    match env_value {
        Value::String(s) => vec![s.clone()],
        Value::Sequence(seq) => seq
            .iter()
            .filter_map(|v| v.as_str().map(String::from))
            .collect(),
        _ => Vec::new(),
    }
}

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

    fn write_compose(dir: &TempDir, filename: &str, content: &str) -> PathBuf {
        let path = dir.path().join(filename);
        std::fs::write(&path, content).unwrap();
        path
    }

    #[test]
    fn parse_env_var_ports() {
        let dir = TempDir::new().unwrap();
        let path = write_compose(
            &dir,
            "compose.yaml",
            r#"
services:
  frontend:
    image: node:20
    ports:
      - "${FRONTEND_PORT:-3000}:3000"
"#,
        );

        let compose = parse_compose_file(&path).unwrap();
        assert_eq!(compose.services.len(), 1);
        assert_eq!(compose.services[0].name, "frontend");
        assert_eq!(
            compose.services[0].ports,
            vec!["${FRONTEND_PORT:-3000}:3000"]
        );
    }

    #[test]
    fn parse_numeric_ports() {
        let dir = TempDir::new().unwrap();
        let path = write_compose(
            &dir,
            "compose.yaml",
            r#"
services:
  api:
    image: python:3.12
    ports:
      - 8080
      - "9090:9090"
"#,
        );

        let compose = parse_compose_file(&path).unwrap();
        assert_eq!(compose.services[0].ports, vec!["8080", "9090:9090"]);
    }

    #[test]
    fn parse_long_form_ports() {
        let dir = TempDir::new().unwrap();
        let path = write_compose(
            &dir,
            "compose.yaml",
            r#"
services:
  web:
    image: nginx
    ports:
      - target: 80
        published: 8080
"#,
        );

        let compose = parse_compose_file(&path).unwrap();
        assert_eq!(compose.services[0].ports, vec!["8080:80"]);
    }

    #[test]
    fn parse_build_as_string() {
        let dir = TempDir::new().unwrap();
        let path = write_compose(
            &dir,
            "compose.yaml",
            r#"
services:
  app:
    build: ./app
"#,
        );

        let compose = parse_compose_file(&path).unwrap();
        let build = compose.services[0].build.as_ref().unwrap();
        assert_eq!(build.context.as_deref(), Some("./app"));
        assert!(build.dockerfile.is_none());
    }

    #[test]
    fn parse_build_as_object() {
        let dir = TempDir::new().unwrap();
        let path = write_compose(
            &dir,
            "compose.yaml",
            r#"
services:
  app:
    build:
      context: ./app
      dockerfile: Dockerfile.prod
"#,
        );

        let compose = parse_compose_file(&path).unwrap();
        let build = compose.services[0].build.as_ref().unwrap();
        assert_eq!(build.context.as_deref(), Some("./app"));
        assert_eq!(build.dockerfile.as_deref(), Some("Dockerfile.prod"));
    }

    #[test]
    fn parse_env_file_as_string() {
        let dir = TempDir::new().unwrap();
        let path = write_compose(
            &dir,
            "compose.yaml",
            r#"
services:
  app:
    image: node:20
    env_file: .env
"#,
        );

        let compose = parse_compose_file(&path).unwrap();
        assert_eq!(compose.services[0].env_file, vec![".env"]);
    }

    #[test]
    fn parse_env_file_as_array() {
        let dir = TempDir::new().unwrap();
        let path = write_compose(
            &dir,
            "compose.yaml",
            r#"
services:
  app:
    image: node:20
    env_file:
      - .env
      - .env.local
"#,
        );

        let compose = parse_compose_file(&path).unwrap();
        assert_eq!(compose.services[0].env_file, vec![".env", ".env.local"]);
    }

    #[test]
    fn detect_compose_file_priority() {
        use crate::compose::detect::detect_compose_file;

        let dir = TempDir::new().unwrap();

        assert!(detect_compose_file(dir.path()).is_none());

        std::fs::File::create(dir.path().join("docker-compose.yml")).unwrap();
        assert_eq!(
            detect_compose_file(dir.path()).unwrap(),
            dir.path().join("docker-compose.yml")
        );

        std::fs::File::create(dir.path().join("compose.yaml")).unwrap();
        assert_eq!(
            detect_compose_file(dir.path()).unwrap(),
            dir.path().join("compose.yaml")
        );
    }
}