fp-dotfiles-manager 0.2.5

Minimal, zero-dependency Chezmoi-based dotfiles manager
use std::process::{Command, Output};
use std::sync::OnceLock;
use testcontainers::{ContainerAsync, GenericImage, ImageExt, runners::AsyncRunner};

const TEST_IMAGE_TAG: &str = "fp-dotfiles-manager-test-fedora:latest";
static BUILD_TEST_IMAGE_RESULT: OnceLock<Result<(), String>> = OnceLock::new();
static DOCKER_START_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();

/// Ensure the Fedora testing image with chezmoi + age is built and cached
fn ensure_test_image() -> Result<(), String> {
    BUILD_TEST_IMAGE_RESULT
        .get_or_init(|| {
            let dockerfile = format!("{}/tests/common/fedora.Dockerfile", env!("CARGO_MANIFEST_DIR"));
            let context_dir = format!("{}/tests/common", env!("CARGO_MANIFEST_DIR"));
            let status = Command::new("docker")
                .arg("build")
                .arg("-t")
                .arg(TEST_IMAGE_TAG)
                .arg("-f")
                .arg(&dockerfile)
                .arg(&context_dir)
                .status()
                .map_err(|e| format!("Failed to build cached Fedora test image: {}", e))?;
            if status.success() {
                Ok(())
            } else {
                Err("Failed to build cached Fedora test image".to_string())
            }
        })
        .clone()
}

async fn acquire_docker_start_lock() -> tokio::sync::MutexGuard<'static, ()> {
    DOCKER_START_LOCK
        .get_or_init(|| tokio::sync::Mutex::new(()))
        .lock()
        .await
}

/// Spins up the Fedora container, copies the compiled binary, and returns the running container instance
pub async fn setup_fedora_container() -> ContainerAsync<GenericImage> {
    ensure_test_image().expect("Failed to build cached Fedora test image");
    let _guard = acquire_docker_start_lock().await;

    let image = GenericImage::new("fp-dotfiles-manager-test-fedora", "latest")
        .with_cmd(["sleep", "infinity"])
        .with_network("host");

    let container = image
        .start()
        .await
        .expect("Failed to start Fedora container");

    let container_id = container.id();

    // Copy the compiled release binary from the target folder
    // Note: We use target/release-static/fp-dotfiles-manager or target/release/fp-dotfiles-manager
    let mut binary_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("target/release-static/fp-dotfiles-manager");

    if !binary_path.exists() {
        binary_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("target/release/fp-dotfiles-manager");
    }

    if !binary_path.exists() {
        // Fall back to building it if not pre-built
        println!("Binary not pre-built. Compiling release binary...");
        let status = Command::new("cargo")
            .arg("build")
            .arg("--release")
            .status()
            .expect("Failed to compile release binary");
        assert!(status.success(), "Failed to compile release binary");
        binary_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("target/release/fp-dotfiles-manager");
    }

    let status = Command::new("docker")
        .arg("cp")
        .arg(&binary_path)
        .arg(format!("{}:/usr/local/bin/fp-dotfiles-manager", container_id))
        .status()
        .expect("Failed to copy binary into container");
    assert!(status.success(), "Failed to copy binary into container");

    let status = Command::new("docker")
        .arg("exec")
        .arg(container_id)
        .arg("chmod")
        .arg("+x")
        .arg("/usr/local/bin/fp-dotfiles-manager")
        .status()
        .expect("Failed to chmod binary");
    assert!(status.success(), "Failed to chmod binary in container");

    container
}

/// Helper to run commands inside the container
#[allow(dead_code)]
pub async fn run_manager_cmd(container: &ContainerAsync<GenericImage>, args: &[&str]) -> Output {
    Command::new("docker")
        .arg("exec")
        .arg("-e")
        .arg("EDITOR=true")
        .arg(container.id())
        .arg("fp-dotfiles-manager")
        .args(args)
        .output()
        .expect("Failed to execute fp-dotfiles-manager inside container")
}

#[allow(dead_code)]
pub async fn run_manager_with_stdin(
    container: &ContainerAsync<GenericImage>,
    args: &[&str],
    stdin_input: &str,
) -> Output {
    use std::io::Write;
    use std::process::Stdio;
    let mut child = Command::new("docker")
        .arg("exec")
        .arg("-i")
        .arg("-e")
        .arg("EDITOR=true")
        .arg(container.id())
        .arg("fp-dotfiles-manager")
        .args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("Failed to spawn fp-dotfiles-manager inside container");

    {
        let mut stdin = child.stdin.take().expect("Failed to open stdin");
        stdin.write_all(stdin_input.as_bytes()).expect("Failed to write to stdin");
    }

    child.wait_with_output().expect("Failed to read output")
}


#[allow(dead_code)]
pub async fn run_manager_success(container: &ContainerAsync<GenericImage>, args: &[&str]) -> String {
    let output = run_manager_cmd(container, args).await;
    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).to_string();

    if !output.status.success() {
        panic!("Command {:?} failed.\nSTDOUT:\n{}\nSTDERR:\n{}", args, stdout, stderr);
    }
    stdout
}

/// Helper to write files directly into the container (simulating mock dotfiles)
#[allow(dead_code)]
pub fn write_file_in_container(container: &ContainerAsync<GenericImage>, path: &str, content: &str) {
    let command = format!("mkdir -p \"$(dirname '{path}')\" && cat > '{path}' <<'EOF'\n{content}\nEOF");
    let status = Command::new("docker")
        .arg("exec")
        .arg(container.id())
        .arg("sh")
        .arg("-lc")
        .arg(command)
        .status()
        .expect("Failed to write file in container");
    assert!(status.success(), "Failed to write file in container");
}

/// Helper to read files from the container for test assertions
#[allow(dead_code)]
pub fn read_file_in_container(container: &ContainerAsync<GenericImage>, path: &str) -> String {
    let output = Command::new("docker")
        .arg("exec")
        .arg(container.id())
        .arg("cat")
        .arg(path)
        .output()
        .expect("Failed to read file from container");
    assert!(output.status.success(), "Failed to read file: {path}");
    String::from_utf8_lossy(&output.stdout).into_owned()
}