-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).