src/testcontainer_compose.erl

-module(testcontainer_compose).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/testcontainer_compose.gleam").
-export([from_file/1, with_project_name/2, with_env_override/3, parse_services_json/2, formula/1, with_compose/2, services/1, service_by_name/2, service_name/1, service_image/1, service_ports/1]).
-export_type([compose_config/0, compose_services/0, service/0]).

-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.

-opaque compose_config() :: {compose_config,
        binary(),
        binary(),
        list({binary(), cowl:secret(binary())})}.

-opaque compose_services() :: {compose_services,
        binary(),
        binary(),
        list(service())}.

-type service() :: {service, binary(), binary(), list({integer(), integer()})}.

-file("src/testcontainer_compose.gleam", 33).
-spec from_file(binary()) -> compose_config().
from_file(File_path) ->
    {compose_config, File_path, <<"testcontainer"/utf8>>, []}.

-file("src/testcontainer_compose.gleam", 41).
-spec with_project_name(compose_config(), binary()) -> compose_config().
with_project_name(Cfg, Project_name) ->
    {compose_config,
        erlang:element(2, Cfg),
        Project_name,
        erlang:element(4, Cfg)}.

-file("src/testcontainer_compose.gleam", 48).
-spec with_env_override(compose_config(), binary(), binary()) -> compose_config().
with_env_override(Cfg, Key, Value) ->
    Secret = cowl:secret(Value),
    Overrides = lists:append(erlang:element(4, Cfg), [{Key, Secret}]),
    {compose_config, erlang:element(2, Cfg), erlang:element(3, Cfg), Overrides}.

-file("src/testcontainer_compose.gleam", 187).
-spec reveal_env_overrides(list({binary(), cowl:secret(binary())})) -> list({binary(),
    binary()}).
reveal_env_overrides(Overrides) ->
    gleam@list:map(
        Overrides,
        fun(Pair) ->
            {Key, Secret} = Pair,
            {Key, cowl:reveal(Secret)}
        end
    ).

-file("src/testcontainer_compose.gleam", 266).
-spec parse_int_string(binary()) -> gleam@dynamic@decode:decoder(integer()).
parse_int_string(S) ->
    case gleam_stdlib:parse_int(S) of
        {ok, N} ->
            gleam@dynamic@decode:success(N);

        {error, _} ->
            gleam@dynamic@decode:failure(0, <<"Int"/utf8>>)
    end.

-file("src/testcontainer_compose.gleam", 247).
-spec decode_service_ports(gleam@dynamic:dynamic_()) -> list({integer(),
    integer()}).
decode_service_ports(Svc_dyn) ->
    Port_decoder = begin
        gleam@dynamic@decode:field(
            <<"target"/utf8>>,
            {decoder, fun gleam@dynamic@decode:decode_int/1},
            fun(Target) ->
                gleam@dynamic@decode:optional_field(
                    <<"published"/utf8>>,
                    Target,
                    gleam@dynamic@decode:one_of(
                        {decoder, fun gleam@dynamic@decode:decode_int/1},
                        [begin
                                _pipe = {decoder,
                                    fun gleam@dynamic@decode:decode_string/1},
                                gleam@dynamic@decode:then(
                                    _pipe,
                                    fun parse_int_string/1
                                )
                            end]
                    ),
                    fun(Published) ->
                        gleam@dynamic@decode:success({Published, Target})
                    end
                )
            end
        )
    end,
    Decoder = gleam@dynamic@decode:field(
        <<"ports"/utf8>>,
        gleam@dynamic@decode:list(Port_decoder),
        fun gleam@dynamic@decode:success/1
    ),
    case gleam@dynamic@decode:run(Svc_dyn, Decoder) of
        {ok, Ports} ->
            Ports;

        {error, _} ->
            []
    end.

-file("src/testcontainer_compose.gleam", 239).
-spec decode_service_image(gleam@dynamic:dynamic_()) -> binary().
decode_service_image(Svc_dyn) ->
    Decoder = gleam@dynamic@decode:field(
        <<"image"/utf8>>,
        {decoder, fun gleam@dynamic@decode:decode_string/1},
        fun gleam@dynamic@decode:success/1
    ),
    case gleam@dynamic@decode:run(Svc_dyn, Decoder) of
        {ok, Image} ->
            Image;

        {error, _} ->
            <<""/utf8>>
    end.

-file("src/testcontainer_compose.gleam", 226).
-spec services_from_dict(gleam@dict:dict(binary(), gleam@dynamic:dynamic_())) -> list(service()).
services_from_dict(Services_dict) ->
    _pipe = Services_dict,
    _pipe@1 = maps:to_list(_pipe),
    gleam@list:map(
        _pipe@1,
        fun(Entry) ->
            {Name, Svc_dyn} = Entry,
            Image = decode_service_image(Svc_dyn),
            Ports = decode_service_ports(Svc_dyn),
            {service, Name, Image, Ports}
        end
    ).

-file("src/testcontainer_compose.gleam", 201).
?DOC(
    " Parse a `docker compose config --format json` output string into typed\n"
    " `Service` records. Exposed for testability — useful when you have a\n"
    " pre-rendered compose JSON and want to validate parsing without Docker.\n"
    "\n"
    " `path` is only used to enrich error messages.\n"
).
-spec parse_services_json(binary(), binary()) -> {ok, list(service())} |
    {error, testcontainer_compose@error:error()}.
parse_services_json(Path, Json_str) ->
    Decoder = begin
        gleam@dynamic@decode:field(
            <<"services"/utf8>>,
            gleam@dynamic@decode:dict(
                {decoder, fun gleam@dynamic@decode:decode_string/1},
                {decoder, fun gleam@dynamic@decode:decode_dynamic/1}
            ),
            fun(Services_dict) ->
                gleam@dynamic@decode:success(Services_dict)
            end
        )
    end,
    case gleam@json:parse(Json_str, Decoder) of
        {ok, Services_dict@1} ->
            case maps:size(Services_dict@1) of
                0 ->
                    {error,
                        {invalid_yaml, Path, <<"no services defined"/utf8>>}};

                _ ->
                    {ok, services_from_dict(Services_dict@1)}
            end;

        {error, _} ->
            {error,
                {invalid_yaml,
                    Path,
                    <<"compose config json parse failed"/utf8>>}}
    end.

-file("src/testcontainer_compose.gleam", 165).
-spec load_compose_file(compose_config()) -> {ok, list(service())} |
    {error, testcontainer_compose@error:error()}.
load_compose_file(Cfg) ->
    case fio:exists(erlang:element(2, Cfg)) of
        false ->
            {error, {compose_file_not_found, erlang:element(2, Cfg)}};

        true ->
            Env_pairs = reveal_env_overrides(erlang:element(4, Cfg)),
            gleam@result:'try'(
                begin
                    _pipe = testcontainer_compose_ffi:compose_config_json(
                        erlang:element(2, Cfg),
                        Env_pairs
                    ),
                    gleam@result:map_error(
                        _pipe,
                        fun(Reason) ->
                            {invalid_yaml, erlang:element(2, Cfg), Reason}
                        end
                    )
                end,
                fun(Json_str) ->
                    parse_services_json(erlang:element(2, Cfg), Json_str)
                end
            )
    end.

-file("src/testcontainer_compose.gleam", 58).
-spec formula(compose_config()) -> {ok,
        testcontainer@formula:standalone_formula(compose_services(), testcontainer_compose@error:error())} |
    {error, testcontainer_compose@error:error()}.
formula(Cfg) ->
    gleam@result:'try'(
        load_compose_file(Cfg),
        fun(Services) ->
            Stack = {compose_services,
                erlang:element(2, Cfg),
                erlang:element(3, Cfg),
                Services},
            Env_pairs = reveal_env_overrides(erlang:element(4, Cfg)),
            {ok,
                testcontainer@formula:new_standalone(
                    fun() ->
                        _pipe = testcontainer_compose_ffi:run_compose_up(
                            erlang:element(2, Cfg),
                            erlang:element(3, Cfg),
                            Env_pairs
                        ),
                        _pipe@1 = gleam@result:map(_pipe, fun(_) -> Stack end),
                        gleam@result:map_error(
                            _pipe@1,
                            fun(Reason) ->
                                {compose_failed, erlang:element(2, Cfg), Reason}
                            end
                        )
                    end,
                    fun() ->
                        _pipe@2 = testcontainer_compose_ffi:run_compose_down(
                            erlang:element(2, Cfg),
                            erlang:element(3, Cfg)
                        ),
                        _pipe@3 = gleam@result:map(_pipe@2, fun(_) -> nil end),
                        gleam@result:map_error(
                            _pipe@3,
                            fun(Reason@1) ->
                                {compose_failed,
                                    erlang:element(2, Cfg),
                                    Reason@1}
                            end
                        )
                    end
                )}
        end
    ).

-file("src/testcontainer_compose.gleam", 93).
?DOC(" Spin up a compose stack, run the body, and always tear it down.\n").
-spec with_compose(
    compose_config(),
    fun((compose_services()) -> {ok, IZA} |
        {error, testcontainer_compose@error:error()})
) -> {ok, IZA} | {error, testcontainer_compose@error:error()}.
with_compose(Cfg, Body) ->
    gleam@result:'try'(
        load_compose_file(Cfg),
        fun(Services) ->
            Env_pairs = reveal_env_overrides(erlang:element(4, Cfg)),
            gleam@result:'try'(
                begin
                    _pipe = testcontainer_compose_ffi:run_compose_up(
                        erlang:element(2, Cfg),
                        erlang:element(3, Cfg),
                        Env_pairs
                    ),
                    gleam@result:map_error(
                        _pipe,
                        fun(Reason) ->
                            {compose_failed, erlang:element(2, Cfg), Reason}
                        end
                    )
                end,
                fun(_) ->
                    Stack = {compose_services,
                        erlang:element(2, Cfg),
                        erlang:element(3, Cfg),
                        Services},
                    Body_result = Body(Stack),
                    Down_result = begin
                        _pipe@1 = testcontainer_compose_ffi:run_compose_down(
                            erlang:element(2, Cfg),
                            erlang:element(3, Cfg)
                        ),
                        _pipe@2 = gleam@result:map(_pipe@1, fun(_) -> nil end),
                        gleam@result:map_error(
                            _pipe@2,
                            fun(Reason@1) ->
                                {compose_failed,
                                    erlang:element(2, Cfg),
                                    Reason@1}
                            end
                        )
                    end,
                    case {Body_result, Down_result} of
                        {{ok, _}, {error, E}} ->
                            {error, E};

                        {_, _} ->
                            Body_result
                    end
                end
            )
        end
    ).

-file("src/testcontainer_compose.gleam", 139).
-spec services(compose_services()) -> list(service()).
services(Stack) ->
    erlang:element(4, Stack).

-file("src/testcontainer_compose.gleam", 143).
-spec service_by_name(compose_services(), binary()) -> gleam@option:option(service()).
service_by_name(Stack, Name) ->
    case gleam@list:find(
        erlang:element(4, Stack),
        fun(Svc) -> erlang:element(2, Svc) =:= Name end
    ) of
        {ok, Svc@1} ->
            {some, Svc@1};

        {error, _} ->
            none
    end.

-file("src/testcontainer_compose.gleam", 153).
-spec service_name(service()) -> binary().
service_name(Svc) ->
    erlang:element(2, Svc).

-file("src/testcontainer_compose.gleam", 157).
-spec service_image(service()) -> binary().
service_image(Svc) ->
    erlang:element(3, Svc).

-file("src/testcontainer_compose.gleam", 161).
-spec service_ports(service()) -> list({integer(), integer()}).
service_ports(Svc) ->
    erlang:element(4, Svc).