devforge 0.3.0

Dev environment orchestrator — docker, health checks, mprocs, custom commands via TOML config
Documentation
use devforge::Config;

#[test]
fn parse_minimal_config() {
    let toml = r#"
env_files = [".env"]
required_tools = ["docker", "cargo"]

[docker]

[dev]
"#;
    let config: Config = toml::from_str(toml).unwrap();
    assert_eq!(config.env_files.len(), 1);
    assert_eq!(config.env_files[0].path, ".env");
    assert!(config.env_files[0].template.is_none());
    assert_eq!(config.required_tools, vec!["docker", "cargo"]);
    assert!(config.docker.health_checks.is_empty());
    assert!(config.dev.hooks.is_empty());
    assert!(config.commands.is_empty());
}

#[test]
fn parse_full_config() {
    let toml = r#"
env_files = [".env", "web/.env.local"]
required_tools = ["docker", "cargo", "node", "npm", "mprocs"]

[docker]
compose_file = "docker-compose.yml"

[[docker.health_checks]]
name = "postgres"
cmd = ["docker", "compose", "exec", "-T", "postgres", "pg_isready", "-U", "myuser"]
timeout = 30

[[docker.health_checks]]
name = "minio"
url = "http://localhost:9000/minio/health/live"
timeout = 15

[dev]
mprocs_config = "mprocs.yaml"

[[dev.hooks]]
cmd = "npm install"
cwd = "web"

[dev.hooks.condition]
missing = "web/node_modules"

[[commands]]
name = "migrate"
cmd = ["cargo", "run", "--release", "--package", "my-api", "--", "migrate"]
description = "Run database migrations"
docker = true

[[commands]]
name = "lint"
cmd = ["cargo", "clippy", "--workspace"]
description = "Run lints"
docker = false
"#;
    let config: Config = toml::from_str(toml).unwrap();
    assert_eq!(config.env_files.len(), 2);
    assert_eq!(config.docker.compose_file, "docker-compose.yml");
    assert_eq!(config.docker.health_checks.len(), 2);

    let pg = &config.docker.health_checks[0];
    assert_eq!(pg.name, "postgres");
    assert!(pg.cmd.is_some());
    assert_eq!(pg.timeout, 30);

    let minio = &config.docker.health_checks[1];
    assert_eq!(minio.name, "minio");
    assert!(minio.url.is_some());
    assert_eq!(minio.timeout, 15);

    assert_eq!(config.dev.mprocs_config, "mprocs.yaml");
    assert_eq!(config.dev.hooks.len(), 1);
    assert_eq!(config.dev.hooks[0].cwd.as_deref(), Some("web"));
    assert_eq!(
        config.dev.hooks[0]
            .condition
            .as_ref()
            .unwrap()
            .missing
            .as_deref(),
        Some("web/node_modules")
    );

    assert_eq!(config.commands.len(), 2);
    assert_eq!(config.commands[0].name, "migrate");
    assert!(config.commands[0].docker);
    assert!(!config.commands[1].docker);
}

#[test]
fn parse_empty_config() {
    let config = Config::load("").unwrap();
    assert!(config.env_files.is_empty());
    assert!(config.required_tools.is_empty());
    assert_eq!(config.docker.compose_file, "docker-compose.yml");
    assert_eq!(config.dev.mprocs_config, "mprocs.yaml");
    assert!(config.commands.is_empty());
}

// --- TCP health check ---

#[test]
fn parse_tcp_health_check() {
    let toml = r#"
[[docker.health_checks]]
name = "postgres"
tcp = "localhost:5432"
timeout = 10
"#;
    let config = Config::load(toml).unwrap();
    let check = &config.docker.health_checks[0];
    assert_eq!(check.name, "postgres");
    assert_eq!(check.tcp.as_deref(), Some("localhost:5432"));
    assert!(check.cmd.is_none());
    assert!(check.url.is_none());
    assert_eq!(check.timeout, 10);
}

#[test]
fn tcp_health_check_default_timeout() {
    let toml = r#"
[[docker.health_checks]]
name = "redis"
tcp = "localhost:6379"
"#;
    let config = Config::load(toml).unwrap();
    assert_eq!(config.docker.health_checks[0].timeout, 30);
}

// --- URL protocol validation ---

#[test]
fn reject_non_http_url_scheme() {
    let toml = r#"
[[docker.health_checks]]
name = "bad"
url = "ftp://localhost/file"
"#;
    let err = Config::load(toml).unwrap_err().to_string();
    assert!(err.contains("http:// or https://"), "got: {err}");
}

#[test]
fn reject_tcp_url_scheme() {
    let toml = r#"
[[docker.health_checks]]
name = "bad"
url = "tcp://localhost:5432"
"#;
    let err = Config::load(toml).unwrap_err().to_string();
    assert!(err.contains("http:// or https://"), "got: {err}");
}

#[test]
fn accept_http_url() {
    let toml = r#"
[[docker.health_checks]]
name = "api"
url = "http://localhost:3000/health"
"#;
    let config = Config::load(toml).unwrap();
    assert_eq!(
        config.docker.health_checks[0].url.as_deref(),
        Some("http://localhost:3000/health")
    );
}

#[test]
fn accept_https_url() {
    let toml = r#"
[[docker.health_checks]]
name = "api"
url = "https://localhost:3000/health"
"#;
    let config = Config::load(toml).unwrap();
    assert!(config.docker.health_checks[0].url.is_some());
}

// --- Mutual exclusivity ---

#[test]
fn reject_cmd_and_url_together() {
    let toml = r#"
[[docker.health_checks]]
name = "conflict"
cmd = ["echo", "hi"]
url = "http://localhost:3000"
"#;
    let err = Config::load(toml).unwrap_err().to_string();
    assert!(
        err.contains("only one of cmd, url, or tcp"),
        "got: {err}"
    );
}

#[test]
fn reject_cmd_and_tcp_together() {
    let toml = r#"
[[docker.health_checks]]
name = "conflict"
cmd = ["echo", "hi"]
tcp = "localhost:5432"
"#;
    let err = Config::load(toml).unwrap_err().to_string();
    assert!(
        err.contains("only one of cmd, url, or tcp"),
        "got: {err}"
    );
}

#[test]
fn reject_url_and_tcp_together() {
    let toml = r#"
[[docker.health_checks]]
name = "conflict"
url = "http://localhost:3000"
tcp = "localhost:5432"
"#;
    let err = Config::load(toml).unwrap_err().to_string();
    assert!(
        err.contains("only one of cmd, url, or tcp"),
        "got: {err}"
    );
}

#[test]
fn reject_all_three_together() {
    let toml = r#"
[[docker.health_checks]]
name = "conflict"
cmd = ["echo", "hi"]
url = "http://localhost:3000"
tcp = "localhost:5432"
"#;
    let err = Config::load(toml).unwrap_err().to_string();
    assert!(
        err.contains("only one of cmd, url, or tcp"),
        "got: {err}"
    );
}

#[test]
fn reject_health_check_with_no_check_type() {
    let toml = r#"
[[docker.health_checks]]
name = "empty"
"#;
    let err = Config::load(toml).unwrap_err().to_string();
    assert!(
        err.contains("must specify one of cmd, url, or tcp"),
        "got: {err}"
    );
}

// --- Runner config ---

#[test]
fn runner_mprocs_explicit() {
    let toml = r#"
[dev.runner]
type = "mprocs"
"#;
    let config = Config::load(toml).unwrap();
    assert!(matches!(config.dev.runner, Some(_)));
}

#[test]
fn runner_shell() {
    let toml = r#"
[dev.runner]
type = "shell"
cmd = "npm run dev"
"#;
    let config = Config::load(toml).unwrap();
    assert!(config.dev.runner.is_some());
    let debug = format!("{:?}", config.dev.runner);
    assert!(debug.contains("Shell"), "got: {debug}");
    assert!(debug.contains("npm run dev"), "got: {debug}");
}

#[test]
fn runner_none() {
    let toml = r#"
[dev.runner]
type = "none"
"#;
    let config = Config::load(toml).unwrap();
    let debug = format!("{:?}", config.dev.runner);
    assert!(debug.contains("None"), "got: {debug}");
}

#[test]
fn runner_shell_requires_cmd() {
    let toml = r#"
[dev.runner]
type = "shell"
"#;
    assert!(Config::load(toml).is_err());
}

#[test]
fn no_runner_block_infers_mprocs() {
    let toml = r#"
[dev]
mprocs_config = "mprocs.yaml"
"#;
    let config = Config::load(toml).unwrap();
    assert!(config.dev.runner.is_none());
}

#[test]
fn runner_with_mprocs_config_backward_compat() {
    let toml = r#"
[dev]
mprocs_config = "custom-mprocs.yaml"
"#;
    let config = Config::load(toml).unwrap();
    assert_eq!(config.dev.mprocs_config, "custom-mprocs.yaml");
    assert!(config.dev.runner.is_none());
}

#[test]
fn runner_invalid_type() {
    let toml = r#"
[dev.runner]
type = "docker"
"#;
    assert!(Config::load(toml).is_err());
}

// --- env_files ---

#[test]
fn env_files_string_shorthand() {
    let toml = r#"env_files = [".env", "web/.env.local"]"#;
    let config = Config::load(toml).unwrap();
    assert_eq!(config.env_files.len(), 2);
    assert_eq!(config.env_files[0].path, ".env");
    assert!(config.env_files[0].template.is_none());
    assert_eq!(config.env_files[1].path, "web/.env.local");
    assert!(config.env_files[1].template.is_none());
}

#[test]
fn env_files_object_with_template() {
    let toml = r#"
[[env_files]]
path = "safe-route/.env"
template = "safe-route/.env.example"

[[env_files]]
path = "safe-route/dashboard/.env.local"
template = "safe-route/dashboard/.env.example"
"#;
    let config = Config::load(toml).unwrap();
    assert_eq!(config.env_files.len(), 2);
    assert_eq!(config.env_files[0].path, "safe-route/.env");
    assert_eq!(
        config.env_files[0].template.as_deref(),
        Some("safe-route/.env.example")
    );
    assert_eq!(config.env_files[1].path, "safe-route/dashboard/.env.local");
    assert_eq!(
        config.env_files[1].template.as_deref(),
        Some("safe-route/dashboard/.env.example")
    );
}

#[test]
fn env_files_object_without_template() {
    let toml = r#"
[[env_files]]
path = ".env"
"#;
    let config = Config::load(toml).unwrap();
    assert_eq!(config.env_files.len(), 1);
    assert_eq!(config.env_files[0].path, ".env");
    assert!(config.env_files[0].template.is_none());
}

#[test]
fn env_files_mixed_not_supported() {
    // TOML doesn't allow mixing inline string arrays with [[table_array]] syntax.
    // Users must pick one form per config. This test documents that constraint.
    let string_form = r#"env_files = [".env"]"#;
    let object_form = r#"
[[env_files]]
path = ".env"
template = ".env.example"
"#;
    assert!(Config::load(string_form).is_ok());
    assert!(Config::load(object_form).is_ok());
}