rash_core 2.20.0

Declarative shell scripting using Rust native bindings
Documentation
use std::fs;
use std::process::Command;

use crate::cli::modules::{docker_test_lock, run_test};

fn docker_available() -> bool {
    Command::new("docker")
        .args(["info"])
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

macro_rules! skip_without_docker {
    () => {
        if !docker_available() {
            eprintln!("Skipping test: Docker not available");
            return;
        }
        let _lock = docker_test_lock();
    };
}

fn cleanup_project(name: &str) {
    let _ = Command::new("docker")
        .args(["compose", "-p", name, "down", "--volumes", "--rmi", "all"])
        .output();
}

fn create_compose_file(path: &str) {
    fs::write(
        path,
        r#"
version: '3'
services:
  web:
    image: alpine:latest
    command: tail -f /dev/null
  db:
    image: alpine:latest
    command: tail -f /dev/null
"#,
    )
    .expect("Failed to create compose file");
}

#[test]
fn test_docker_compose_check_mode() {
    skip_without_docker!();

    let project_name = "rash-test-compose-check";
    cleanup_project(project_name);

    let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let compose_file = tmp_dir.path().join("docker-compose.yml");
    create_compose_file(compose_file.to_str().unwrap());

    let script_text = format!(
        r#"
#!/usr/bin/env rash
- name: Check mode - should not start project
  docker_compose:
    project_src: {}
    project_name: {}
    state: started
"#,
        tmp_dir.path().to_str().unwrap(),
        project_name
    );

    let args = ["--check", "--diff"];
    let (stdout, stderr) = run_test(&script_text, &args);

    assert!(stderr.is_empty(), "stderr should be empty: {}", stderr);
    assert!(
        stdout.contains("changed"),
        "stdout should contain 'changed' in check mode: {}",
        stdout
    );

    let output = Command::new("docker")
        .args(["compose", "-p", project_name, "ps", "-q"])
        .output()
        .expect("Failed to check project status");
    assert!(
        output.stdout.is_empty(),
        "Project should NOT be started in check mode"
    );

    cleanup_project(project_name);
}

#[test]
fn test_docker_compose_start() {
    skip_without_docker!();

    let project_name = "rash-test-compose-start";
    cleanup_project(project_name);

    let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let compose_file = tmp_dir.path().join("docker-compose.yml");
    create_compose_file(compose_file.to_str().unwrap());

    let script_text = format!(
        r#"
#!/usr/bin/env rash
- name: Start project
  docker_compose:
    project_src: {}
    project_name: {}
    state: started
"#,
        tmp_dir.path().to_str().unwrap(),
        project_name
    );

    let args = ["--diff"];
    let (stdout, stderr) = run_test(&script_text, &args);

    assert!(stderr.is_empty(), "stderr should be empty: {}", stderr);
    assert!(
        stdout.contains("changed"),
        "stdout should contain 'changed': {}",
        stdout
    );

    let output = Command::new("docker")
        .args(["compose", "-p", project_name, "ps", "-q"])
        .output()
        .expect("Failed to check project status");
    assert!(!output.stdout.is_empty(), "Project should be started");

    cleanup_project(project_name);
}

#[test]
fn test_docker_compose_stop() {
    skip_without_docker!();

    let project_name = "rash-test-compose-stop";
    cleanup_project(project_name);

    let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let compose_file = tmp_dir.path().join("docker-compose.yml");
    create_compose_file(compose_file.to_str().unwrap());

    let start_script = format!(
        r#"
#!/usr/bin/env rash
- name: Start project first
  docker_compose:
    project_src: {}
    project_name: {}
    state: started
"#,
        tmp_dir.path().to_str().unwrap(),
        project_name
    );

    let args = ["--diff"];
    let (_stdout, stderr) = run_test(&start_script, &args);
    assert!(
        stderr.is_empty(),
        "stderr should be empty after start: {}",
        stderr
    );

    let stop_script = format!(
        r#"
#!/usr/bin/env rash
- name: Stop project
  docker_compose:
    project_src: {}
    project_name: {}
    state: stopped
"#,
        tmp_dir.path().to_str().unwrap(),
        project_name
    );

    let (stdout, stderr) = run_test(&stop_script, &args);

    assert!(stderr.is_empty(), "stderr should be empty: {}", stderr);
    assert!(
        stdout.contains("changed"),
        "stdout should contain 'changed': {}",
        stdout
    );

    let output = Command::new("docker")
        .args(["compose", "-p", project_name, "ps", "--format", "json"])
        .output()
        .expect("Failed to check project status");
    let stdout_check = String::from_utf8_lossy(&output.stdout);
    let running = stdout_check.lines().any(|line| {
        if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
            json.get("State").and_then(|s| s.as_str()) == Some("running")
        } else {
            false
        }
    });
    assert!(!running, "Project should be stopped");

    cleanup_project(project_name);
}

#[test]
fn test_docker_compose_down() {
    skip_without_docker!();

    let project_name = "rash-test-compose-down";
    cleanup_project(project_name);

    let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let compose_file = tmp_dir.path().join("docker-compose.yml");
    create_compose_file(compose_file.to_str().unwrap());

    let start_script = format!(
        r#"
#!/usr/bin/env rash
- name: Start project first
  docker_compose:
    project_src: {}
    project_name: {}
    state: started
"#,
        tmp_dir.path().to_str().unwrap(),
        project_name
    );

    let args = ["--diff"];
    let (_stdout, stderr) = run_test(&start_script, &args);
    assert!(
        stderr.is_empty(),
        "stderr should be empty after start: {}",
        stderr
    );

    let down_script = format!(
        r#"
#!/usr/bin/env rash
- name: Remove project
  docker_compose:
    project_src: {}
    project_name: {}
    state: absent
"#,
        tmp_dir.path().to_str().unwrap(),
        project_name
    );

    let (stdout, stderr) = run_test(&down_script, &args);

    assert!(stderr.is_empty(), "stderr should be empty: {}", stderr);
    assert!(
        stdout.contains("changed"),
        "stdout should contain 'changed': {}",
        stdout
    );

    let output = Command::new("docker")
        .args(["compose", "-p", project_name, "ps", "-q"])
        .output()
        .expect("Failed to check project status");
    assert!(output.stdout.is_empty(), "Project should be removed");

    cleanup_project(project_name);
}

#[test]
fn test_docker_compose_restart() {
    skip_without_docker!();

    let project_name = "rash-test-compose-restart";
    cleanup_project(project_name);

    let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let compose_file = tmp_dir.path().join("docker-compose.yml");
    create_compose_file(compose_file.to_str().unwrap());

    let start_script = format!(
        r#"
#!/usr/bin/env rash
- name: Start project first
  docker_compose:
    project_src: {}
    project_name: {}
    state: started
"#,
        tmp_dir.path().to_str().unwrap(),
        project_name
    );

    let args = ["--diff"];
    let (_stdout, stderr) = run_test(&start_script, &args);
    assert!(
        stderr.is_empty(),
        "stderr should be empty after start: {}",
        stderr
    );

    let restart_script = format!(
        r#"
#!/usr/bin/env rash
- name: Restart project
  docker_compose:
    project_src: {}
    project_name: {}
    state: restarted
"#,
        tmp_dir.path().to_str().unwrap(),
        project_name
    );

    let (stdout, stderr) = run_test(&restart_script, &args);

    assert!(stderr.is_empty(), "stderr should be empty: {}", stderr);
    assert!(
        stdout.contains("changed"),
        "stdout should contain 'changed': {}",
        stdout
    );

    cleanup_project(project_name);
}

#[test]
fn test_docker_compose_specific_services() {
    skip_without_docker!();

    let project_name = "rash-test-compose-services";
    cleanup_project(project_name);

    let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let compose_file = tmp_dir.path().join("docker-compose.yml");
    create_compose_file(compose_file.to_str().unwrap());

    let script_text = format!(
        r#"
#!/usr/bin/env rash
- name: Start only web service
  docker_compose:
    project_src: {}
    project_name: {}
    state: started
    services:
      - web
"#,
        tmp_dir.path().to_str().unwrap(),
        project_name
    );

    let args = ["--diff"];
    let (stdout, stderr) = run_test(&script_text, &args);

    assert!(stderr.is_empty(), "stderr should be empty: {}", stderr);
    assert!(
        stdout.contains("changed"),
        "stdout should contain 'changed': {}",
        stdout
    );

    let output = Command::new("docker")
        .args(["compose", "-p", project_name, "ps", "--format", "json"])
        .output()
        .expect("Failed to check project status");
    let stdout_check = String::from_utf8_lossy(&output.stdout);

    let services: Vec<String> = stdout_check
        .lines()
        .filter_map(|line| {
            if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
                json.get("Service")
                    .and_then(|s| s.as_str())
                    .map(|s| s.to_string())
            } else {
                None
            }
        })
        .collect();

    assert!(
        services.contains(&"web".to_string()),
        "web service should be started"
    );
    assert!(
        !services.contains(&"db".to_string()),
        "db service should not be started"
    );

    cleanup_project(project_name);
}