rft-cli 0.4.1

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

use crate::compose::ComposeFile;
use crate::error::Result;
use crate::executor::Executor;

pub fn extract_dockerfile_paths(compose_file: &ComposeFile) -> Vec<PathBuf> {
    let compose_dir = compose_file.compose_path.parent().unwrap_or(Path::new("."));

    compose_file
        .services
        .iter()
        .filter_map(|service| {
            let build = service.build.as_ref()?;
            let context = build.context.as_deref().unwrap_or(".");
            let dockerfile = build.dockerfile.as_deref().unwrap_or("Dockerfile");
            Some(compose_dir.join(context).join(dockerfile))
        })
        .collect()
}

pub async fn sync_worktree_files(
    repo_root: &Path,
    worktree_path: &Path,
    compose_file: &ComposeFile,
    extra_sync: &[String],
    executor: &Executor,
) -> Result<()> {
    let relative_compose = compose_file
        .compose_path
        .strip_prefix(repo_root)
        .unwrap_or(&compose_file.compose_path);
    let target_compose = worktree_path.join(relative_compose);
    executor
        .copy_file(&compose_file.compose_path, &target_compose)
        .await?;

    let dockerfile_paths = extract_dockerfile_paths(compose_file);
    for absolute_dockerfile in &dockerfile_paths {
        if !absolute_dockerfile.exists() {
            continue;
        }
        let relative = absolute_dockerfile
            .strip_prefix(repo_root)
            .unwrap_or(absolute_dockerfile);
        let target = worktree_path.join(relative);
        executor.copy_file(absolute_dockerfile, &target).await?;
    }

    for extra in extra_sync {
        crate::error::validate_path_within(repo_root, extra)?;
        let source = repo_root.join(extra);
        if !source.exists() {
            continue;
        }
        let target = worktree_path.join(extra);
        if source.is_dir() {
            copy_directory_recursive(&source, &target, executor).await?;
        } else {
            executor.copy_file(&source, &target).await?;
        }
    }

    Ok(())
}

async fn copy_directory_recursive(source: &Path, target: &Path, executor: &Executor) -> Result<()> {
    executor.create_dir_all(target).await?;

    let mut entries = tokio::fs::read_dir(source).await?;
    while let Some(entry) = entries.next_entry().await? {
        let entry_path = entry.path();
        let target_path = target.join(entry.file_name());

        if entry_path.is_dir() {
            Box::pin(copy_directory_recursive(
                &entry_path,
                &target_path,
                executor,
            ))
            .await?;
        } else {
            executor.copy_file(&entry_path, &target_path).await?;
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::compose::{BuildConfig, ComposeService};

    fn make_compose_file(compose_path: PathBuf, services: Vec<ComposeService>) -> ComposeFile {
        ComposeFile {
            services,
            compose_path,
        }
    }

    #[test]
    fn extract_dockerfiles_from_build_context() {
        let compose = make_compose_file(
            PathBuf::from("/repo/compose.yaml"),
            vec![
                ComposeService {
                    name: "web".to_string(),
                    ports: vec![],
                    build: Some(BuildConfig {
                        context: Some("./web".to_string()),
                        dockerfile: Some("Dockerfile.prod".to_string()),
                    }),
                    env_file: vec![],
                },
                ComposeService {
                    name: "api".to_string(),
                    ports: vec![],
                    build: Some(BuildConfig {
                        context: Some("./api".to_string()),
                        dockerfile: None,
                    }),
                    env_file: vec![],
                },
            ],
        );

        let paths = extract_dockerfile_paths(&compose);
        assert_eq!(paths.len(), 2);
        assert_eq!(paths[0], PathBuf::from("/repo/./web/Dockerfile.prod"));
        assert_eq!(paths[1], PathBuf::from("/repo/./api/Dockerfile"));
    }

    #[test]
    fn extract_dockerfiles_skips_services_without_build() {
        let compose = make_compose_file(
            PathBuf::from("/repo/compose.yaml"),
            vec![ComposeService {
                name: "redis".to_string(),
                ports: vec![],
                build: None,
                env_file: vec![],
            }],
        );

        let paths = extract_dockerfile_paths(&compose);
        assert!(paths.is_empty());
    }

    #[test]
    fn extract_dockerfiles_defaults_context_to_dot() {
        let compose = make_compose_file(
            PathBuf::from("/repo/compose.yaml"),
            vec![ComposeService {
                name: "app".to_string(),
                ports: vec![],
                build: Some(BuildConfig {
                    context: None,
                    dockerfile: None,
                }),
                env_file: vec![],
            }],
        );

        let paths = extract_dockerfile_paths(&compose);
        assert_eq!(paths.len(), 1);
        assert_eq!(paths[0], PathBuf::from("/repo/./Dockerfile"));
    }

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

        let compose_path = repo.path().join("compose.yaml");
        tokio::fs::write(&compose_path, "services: {}")
            .await
            .unwrap();

        let compose_file = make_compose_file(compose_path, vec![]);

        sync_worktree_files(
            repo.path(),
            worktree.path(),
            &compose_file,
            &[],
            &Executor::Real,
        )
        .await
        .unwrap();

        let target = worktree.path().join("compose.yaml");
        assert!(target.exists());
        assert_eq!(
            tokio::fs::read_to_string(&target).await.unwrap(),
            "services: {}"
        );
    }

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

        let compose_path = repo.path().join("compose.yaml");
        tokio::fs::write(&compose_path, "services: {}")
            .await
            .unwrap();

        tokio::fs::write(repo.path().join("Makefile"), "all: build")
            .await
            .unwrap();

        let subdir = repo.path().join("config");
        tokio::fs::create_dir_all(&subdir).await.unwrap();
        tokio::fs::write(subdir.join("settings.toml"), "[app]")
            .await
            .unwrap();

        let compose_file = make_compose_file(compose_path, vec![]);

        let extra = vec!["Makefile".to_string(), "config".to_string()];
        sync_worktree_files(
            repo.path(),
            worktree.path(),
            &compose_file,
            &extra,
            &Executor::Real,
        )
        .await
        .unwrap();

        assert!(worktree.path().join("Makefile").exists());
        assert!(worktree.path().join("config/settings.toml").exists());
    }

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

        let compose_path = repo.path().join("compose.yaml");
        tokio::fs::write(&compose_path, "services: {}")
            .await
            .unwrap();

        let compose_file = make_compose_file(compose_path, vec![]);

        let extra = vec!["nonexistent.txt".to_string()];
        let result = sync_worktree_files(
            repo.path(),
            worktree.path(),
            &compose_file,
            &extra,
            &Executor::Real,
        )
        .await;
        assert!(result.is_ok());
    }
}