src/testcontainer_compose_ffi.erl

-module(testcontainer_compose_ffi).
-export([run_compose_up/3, run_compose_down/2, compose_config_json/2]).

%% Timeout safety: kill the port if Docker hangs
-define(PORT_TIMEOUT_MS, 60000).

run_compose_up(FilePath, ProjectName, EnvOverrides) ->
    with_env_overrides(EnvOverrides, fun() ->
        docker_compose([
            "compose", "-f", binary_to_list(FilePath),
            "-p", binary_to_list(ProjectName),
            "up", "-d", "--wait"
        ])
    end).

run_compose_down(FilePath, ProjectName) ->
    docker_compose([
        "compose", "-f", binary_to_list(FilePath),
        "-p", binary_to_list(ProjectName),
        "down", "--volumes", "--remove-orphans"
    ]).

compose_config_json(FilePath, EnvOverrides) ->
    with_env_overrides(EnvOverrides, fun() ->
        docker_compose([
            "compose", "-f", binary_to_list(FilePath),
            "config", "--format", "json"
        ])
    end).

%% Set env overrides, run Fun, then restore old values.
with_env_overrides([], Fun) ->
    Fun();
with_env_overrides(Overrides, Fun) ->
    Saved = lists:map(fun({Key, Val}) ->
        K = binary_to_list(Key),
        Old = os:getenv(K),
        os:putenv(K, binary_to_list(Val)),
        {K, Old}
    end, Overrides),
    Result = Fun(),
    lists:foreach(fun({K, Old}) ->
        case Old of
            false -> os:unsetenv(K);
            _     -> os:putenv(K, Old)
        end
    end, Saved),
    Result.

docker_compose(Args) ->
    case os:find_executable("docker") of
        false ->
            {error, <<"docker executable not found in PATH">>};
        Docker ->
            try
                Port = erlang:open_port(
                    {spawn_executable, Docker},
                    [
                        exit_status, binary, use_stdio,
                        stderr_to_stdout, {args, Args}
                    ]
                ),
                collect_output(Port, <<>>)
            catch
                Class:Reason ->
                    Msg = io_lib:format("port spawn failed: ~p:~p", [Class, Reason]),
                    {error, list_to_binary(Msg)}
            end
    end.

collect_output(Port, Acc) ->
    receive
        {Port, {data, Data}} ->
            collect_output(Port, <<Acc/binary, Data/binary>>);
        {Port, {exit_status, 0}} ->
            {ok, string:trim(Acc)};
        {Port, {exit_status, Code}} ->
            Trimmed = string:trim(Acc),
            Reason = format_exit_reason(Code, Trimmed),
            {error, Reason}
    after ?PORT_TIMEOUT_MS ->
        try erlang:port_close(Port) of
            _ -> ok
        catch
            _:_ -> ok
        end,
        Msg = io_lib:format("docker compose timed out after ~Bms", [?PORT_TIMEOUT_MS]),
        {error, list_to_binary(Msg)}
    end.

format_exit_reason(Code, Output) ->
    Prefix = case Code of
        125 -> <<"docker daemon error: ">>;
        126 -> <<"docker not executable: ">>;
        127 -> <<"docker not found: ">>;
        _   -> Prefix0 = io_lib:format("docker exit ~B: ", [Code]),
               list_to_binary(Prefix0)
    end,
    case Output of
        <<>> -> Prefix;
        _    -> <<Prefix/binary, Output/binary>>
    end.